Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
e462871
Add tests for carrying Left and Right Half across displays
mortenbrudvik Sep 25, 2026
68a816d
Carry Left and Right Half on to the display beside the window
mortenbrudvik Sep 25, 2026
d96a52e
Document carrying a half across displays
mortenbrudvik Sep 25, 2026
fbf6309
Test the placement record against refused, late, and outdated frames
mortenbrudvik Sep 25, 2026
3d34e06
Record a placement by the frame asked for, once the window gets there
mortenbrudvik Sep 25, 2026
da9c203
Nest Side inside Display
mortenbrudvik Sep 25, 2026
d451705
Pin continuations as inverse pairs and correct their comments
mortenbrudvik Sep 25, 2026
3131030
Drop the placement when another command moves the window
mortenbrudvik Sep 25, 2026
8fcb3af
Pin the refused-write and top-left checks on a placement
mortenbrudvik Sep 25, 2026
a96e2c3
Take a window's display from where Loadstone put it
mortenbrudvik Sep 25, 2026
daa470f
Read a window's identity and a tile's frame once per command
mortenbrudvik Sep 25, 2026
b5b6752
Log why a half is or is not carried on to the next display
mortenbrudvik Sep 25, 2026
03f2896
Correct the director's comments on placements and near-misses
mortenbrudvik Sep 25, 2026
c4fa745
Bring the docs and comments in line with the placement fixes
mortenbrudvik Sep 25, 2026
e80bdb6
Pin the odd-width near-miss of a half carried by Next Display
mortenbrudvik Sep 25, 2026
e019e6a
Pin the display lookup and when a placement survives or goes
mortenbrudvik Sep 25, 2026
a0b9e25
Drop a window's placement in apply, and look it up in one place
mortenbrudvik Sep 25, 2026
a4cf307
Drop the placement when a refused write still moved the window
mortenbrudvik Sep 25, 2026
af12bc8
Record under a title-based identity as it reads after the write
mortenbrudvik Sep 25, 2026
df4d30c
Move a window to another display from the one under its centre
mortenbrudvik Sep 25, 2026
b0cd686
Log why a half was not carried on only where the press does not say
mortenbrudvik Sep 25, 2026
414cab2
Tighten the comments on display moves and refused writes
mortenbrudvik Sep 25, 2026
1a82050
Bring the docs and comments in line with the director's final shape
mortenbrudvik Sep 25, 2026
935e42d
Pin what a standing placement has to match to count as the half
mortenbrudvik Sep 25, 2026
4022527
Pin that an unreadable frame after a refusal drops the placement
mortenbrudvik Sep 25, 2026
553949a
Carry a window's Restore frame across when a write renames it
mortenbrudvik Sep 25, 2026
179238a
Log a half press that leaves the window where it was
mortenbrudvik Sep 25, 2026
6ab76c0
Correct the director's and WindowCommand's docs on their edge cases
mortenbrudvik Sep 25, 2026
e7fa8ab
Send the director's info lines through an injectable diagnose closure
mortenbrudvik Sep 25, 2026
cfe5856
Pin which half presses explain themselves in the log
mortenbrudvik Sep 25, 2026
842423a
Pin that a refusal is judged against where the window was
mortenbrudvik Sep 25, 2026
5acc5e7
Pin that Restore stays one-shot and survives a refusal
mortenbrudvik Sep 25, 2026
b964b7e
Document the stale title a late-applying app records a placement under
mortenbrudvik Sep 25, 2026
4a133d3
Correct the docs on what drops a placement and what needs a refit
mortenbrudvik Sep 25, 2026
b0f97c2
Keep the late-apply test comment over only the tests it describes
mortenbrudvik Sep 25, 2026
b329622
Say that CI runs AccessibilityAuthTests
mortenbrudvik Sep 25, 2026
c9aaafd
Say exactly which refits the log explains
mortenbrudvik Sep 25, 2026
7075d8e
Correct the docs on the placed display and a renamed Restore frame
mortenbrudvik Sep 25, 2026
e13183f
Record a half that macOS pulled below a menu bar as placed
mortenbrudvik Sep 25, 2026
8b15965
Describe the recording rule for a window pulled below a menu bar
mortenbrudvik Sep 25, 2026
9d80d1f
Say that the fake window's constrain need not act like macOS
mortenbrudvik Sep 25, 2026
1f949ef
Pin that a resize alone counts as the window having moved
mortenbrudvik Sep 25, 2026
dfeb421
Keep a pulled-down half placed when pressed at the edge of the desk
mortenbrudvik Sep 25, 2026
d1ad265
Document wide and minimum-width windows in half continuation
mortenbrudvik Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Bump `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `project.yml`, then `x

After the GitHub release is published, update the tap so `brew upgrade` sees it: in a clone of the tap (`brew --repo mortenbrudvik/tap` once tapped) run `./bump.sh <version>`, which downloads the asset, records its sha256, and commits, then push.

Reading the app's logs: `log` is a zsh builtin, so use the full path, and info-level lines (startup summary, hotkey bindings) need `--info`:
Reading the app's logs: `log` is a zsh builtin, so use the full path, and info-level lines (startup summary, hotkey bindings, why a half did or did not carry on) need `--info`:

```bash
/usr/bin/log show --info --predicate 'subsystem == "com.brudvik.loadstone"' --last 10m
Expand All @@ -58,15 +58,24 @@ Tests run inside the real app as host (`TEST_HOST`), so `AppDelegate.application
Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) → screen geometry.

- **Input.** `HotkeyCenter` registers one global hotkey per `WindowCommand` through the KeyboardShortcuts package (Carbon hotkeys; fire on key-up). `EdgeDragMonitor` owns the `SnapPreview` overlay and the mouse monitors (behind `MouseEventMonitoring`, so the setting-driven install/uninstall lifecycle is testable without Accessibility trust) but makes no decisions: it feeds events to `DragSnapTracker`, a pure state machine that resolves the window under the pointer only after the drag threshold, arms only once that window's origin has moved (so text-selection and slider drags never snap), and reads the origin only while the pointer is inside a snap zone — and then at most once per `originPollInterval` (50ms), because each read is a synchronous AX round trip on the main thread and polling at event rate stutters the drag.
- **Director.** `WindowDirector` runs a `WindowCommand` on a `MovableWindow` and returns a `CommandOutcome`; the no-argument `perform(_:)` overload resolves the focused window, beeps, logs, and triggers the Accessibility prompt. It keeps the restore ledger (first frame per `WindowIdentity`, one-shot restore, pruned when the owning process quits because macOS reuses window ids). The ledger is written *after* the window accepts a frame, never before, so a refused write or a command that never ran leaves nothing for a later Restore to act on; Restore likewise drops its entry only once the old frame has been accepted. Drag snaps pass the display the gesture ended on explicitly; the hotkey path uses the display under the window's centre.
- **AX layer.** `AXWindow` is the only file that handles Accessibility-space (top-left origin) coordinates; everything it exposes is Cocoa space. It validates the window role in its initializer, returns `AXError` from writes instead of swallowing them, wraps every frame write in a save-disable-restore of `AXEnhancedUserInterface`, applies size → position → size (apps clamp a position-first write to the old display), and resolves the private `_AXUIElementGetWindow` SPI with `dlsym` so a missing symbol degrades to a title-based identity. A 0.25s AX messaging timeout is installed at launch. `AXWindowTests` drives this file through real AX calls against windows the test host owns: a process may read and write its own Accessibility hierarchy untrusted, so those tests need no TCC grant and run in CI. What they cannot reach is another app: a refused frame, a hang past the messaging timeout, or the `AXEnhancedUserInterface` dance.
- **Geometry.** `Tile.frame(in:)` floors the left/bottom pieces and gives the remainder to the right/top ones so complementary tiles meet exactly on odd widths; `leftTwoThirds` is `2 * thirdW` wide for the same reason. `SnapZones` maps a pointer against the *full* screen frame, tiles are laid into the *visible* frame. `ScreenGeometry` flips about the primary display's top edge (`NSScreen.screens[0]`, never `NSScreen.main`). `Display` is a value snapshot of an `NSScreen` so multi-display logic is testable. `ScreenGeometry.neighbor(of:delta:in:)` walks displays in reading order (x ascending, then top before bottom), not `NSScreen.screens` order, so Next/Previous Display follow the desk layout.
- **Director.** `WindowDirector` runs a `WindowCommand` on a `MovableWindow` and returns a `CommandOutcome`; the no-argument `perform(_:)` overload resolves the focused window, beeps, logs, and triggers the Accessibility prompt. It keeps the restore ledger (first frame per `WindowIdentity`, one-shot restore, pruned when the owning process quits because macOS reuses window ids). The ledger is written *after* the window accepts a frame, never before, so a refused write or a command that never ran leaves nothing for a later Restore to act on; Restore likewise drops its entry only once the old frame has been accepted. Drag snaps pass the display the gesture ended on explicitly. From hotkeys and the menu, a tile or Center uses the display holding the window's placement while the window is still where it landed (below); otherwise, and always for Next/Previous Display, the display under the window's centre, or the primary when it is on none. The identity is read once per command, since on `AXWindow` it costs AX calls. A title-based one is read again after an accepted write, and the ledger and the placement are recorded under the new one, a ledger entry under the old one moving across, because a title can follow the window's size (Terminal's does).
- **Continuation.** Left/Right Half on a window already in that half carry it into the opposite half of the display beside it (`Tile.continuation`, `ScreenGeometry.adjacent`); at the edge of the desk they apply the half again, with no beep. Rules to keep (the detail is in `WindowDirector`):
- "Already in" means within a point of the tile, or of where the window landed when Loadstone last sent it to that same frame (`placements`): grid-rounding and minimum-width windows never fill a tile.
- Only a tile records a placement, drag snaps included, and only if the read-back has the target's left edge and either its top-left or, once the window has moved or if it was already in that tile, a top no higher than the target's: macOS pulls a window down below the menu bar of the display holding most of it, so a wide window carried onto a display that reaches above that menu bar lands lower than it was sent; a late-applying app reads back its old frame, which has not moved; and a pulled-down window given the same half again at the edge of the desk reads back where it was, which that half has already recorded.
- Any write the window accepts drops its placement (a tile then records a fresh one), as do a refused write that moved the window anyway (`AXWindow` sets the size before the position) or left its frame unreadable, and the process quitting.
- A new resolution or a moved Dock alters the tile's frame, so the first press refits the window and the next carries it on.
- While the window is where it landed, tiles and Center use the display holding the target: a window wider than its tile can have its centre on the next display, which would send the next half from the wrong one, and one wider than the display it was placed on would bounce between the two. Next/Previous Display go by the centre, since mapped from a display narrower than the window it comes out wider than the display it goes to. Going by the centre avoids that only for a window narrower than the display under its centre: `Layout.mapped` is proportional and unclamped, so a wider one (one wider than every display, say) still comes out wider than the display it goes to and can hang far off the edge of the desk (known, unhandled).
- Info-level `Log.ax` lines say when a half press carries a window on or stops at the edge of the desk, and why it refits the window instead when Loadstone last put it in that half, the press leaves it where it was, or it had the half's top-left with no placement standing. Otherwise the fit is left unlogged, even one from a few points off the half that is too small to see. A placement left unrecorded logs what the read-back missed. They go through the director's `diagnose` closure, which is how the tests see them.
- Residuals, from late-applying apps, which only an `AXObserver` would close: an old frame sharing the target's top-left is recorded as where the window landed, so a window put back there by hand is carried on (one known by a title that follows its size even when Loadstone put it back, since the title reads back stale too and the entry stays under it); and a carry-over goes unrecorded, so a window carried left onto a display narrower than itself bounces back.
- Reading a hand test: with "Displays have separate Spaces" on (the macOS default), a window spanning the seam between two displays is drawn only on the one holding most of it, so a wide window carried onto the next display can look as if it stayed put or vanished; and a window whose minimum width exceeds the half stays wider than it, which can pass for maximized. Go by the frame (CGWindowList), not by what the screens show.
- **AX layer.** `AXWindow` is the only file that handles Accessibility-space (top-left origin) coordinates; everything it exposes is Cocoa space. It validates the window role in its initializer, returns `AXError` from writes instead of swallowing them, wraps every frame write in a save-disable-restore of `AXEnhancedUserInterface`, applies size → position → size (apps clamp a position-first write to the old display), and resolves the private `_AXUIElementGetWindow` SPI with `dlsym` so a missing symbol degrades to a title-based identity. A 0.25s AX messaging timeout is installed at launch. `AXWindowTests` drives this file through real AX calls against windows the test host owns: a process may read and write its own Accessibility hierarchy untrusted, so those tests need no TCC grant. CI skips them (`-skip-testing`) because its runner has no window server that answers AX, so they run only on a developer machine, and a change to `AXWindow` has to be tested there. What they cannot reach is another app: a refused frame, a hang past the messaging timeout, or the `AXEnhancedUserInterface` dance.
- **Geometry.** `Tile.frame(in:)` floors the left/bottom pieces and gives the remainder to the right/top ones so complementary tiles meet exactly on odd widths; `leftTwoThirds` is `2 * thirdW` wide for the same reason. `SnapZones` maps a pointer against the *full* screen frame, tiles are laid into the *visible* frame. `ScreenGeometry` flips about the primary display's top edge (`NSScreen.screens[0]`, never `NSScreen.main`). `Display` is a value snapshot of an `NSScreen` so multi-display logic is testable. `ScreenGeometry.neighbor(of:delta:in:)` walks displays in reading order (x ascending, then top before bottom), not `NSScreen.screens` order, so Next/Previous Display follow the desk layout. `ScreenGeometry.adjacent(to:toward:in:)` is the directional one behind the Left/Right Half continuation: it takes a `Display.Side` and never wraps (nil at the edge of the desk), and only a display wholly to that side and sharing some height counts, so a monitor above or below never counts as beside it, nor does one touching only at a corner. Of several, it picks the nearest, then the one sharing more height, then the higher, whatever order AppKit lists them in.

### Invariants worth knowing before editing

- `WindowCommand` is the single source of truth for hotkeys, the status-bar menu, and the Shortcuts pane. Adding a `Tile` case fails to compile until `title`, `defaultShortcut`, and `section` cover it, then it appears everywhere. `WindowCommand.id` strings are the UserDefaults keys where users' custom shortcuts live (`KeyboardShortcuts_<id>`); never rename one. `WindowCommandTests` pins the list.
- `WindowCommand` is the single source of truth for hotkeys, the status-bar menu, and the Shortcuts pane. Adding a `Tile` case fails to compile until `title`, `defaultShortcut`, `section`, `frame(in:)`, and `continuation` cover it, then it appears everywhere. `WindowCommand.id` strings are the UserDefaults keys where users' custom shortcuts live (`KeyboardShortcuts_<id>`); never rename one. `WindowCommandTests` pins the list.
- Swift 6 with `SWIFT_STRICT_CONCURRENCY: complete`. Everything with state is `@MainActor`; the one deliberate `nonisolated` hop is `EdgeDragMonitor.enqueue`. Some Carbon/AX globals (e.g. `kAXTrustedCheckOptionPrompt`) are imported as mutable globals and rejected by the compiler; use their string values instead.
- Testability seams, all with production defaults: `WindowDirector(displays:)`, the `MovableWindow` protocol, `AppSettings(defaults:loginItems:)` via `LoginItemService`, `NativeTiling(defaults:marker:)`, `EdgeDragMonitor(monitors:)` via `MouseEventMonitoring`, and `DragSnapTracker<Window>` with closures plus an injected event time. Extend these rather than reaching for singletons in new code.
- Testability seams, all with production defaults: `WindowDirector(displays:diagnose:)`, the `MovableWindow` protocol, `AppSettings(defaults:loginItems:)` via `LoginItemService`, `NativeTiling(defaults:marker:)`, `EdgeDragMonitor(monitors:)` via `MouseEventMonitoring`, and `DragSnapTracker<Window>` with closures plus an injected event time. Extend these rather than reaching for singletons in new code.
- Test fixtures use dimensions that do not divide by 2 or 3 (1201×901, negative-origin displays) on purpose; even fixtures hid a real rounding bug.
- `AppSettings.launchAtLogin` reassigns itself inside a method called from its own `didSet`; the `isApplyingLoginItem` guard is what stops infinite recursion. `SMAppService` can succeed and still report `.requiresApproval`, which the UI surfaces.

Expand Down
5 changes: 3 additions & 2 deletions Loadstone/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
dragMonitor = EdgeDragMonitor()
dragMonitor?.start()

// Restore memory is keyed by window id, which macOS reuses; drop a process's entries
// when it quits so a new window cannot inherit a stale frame.
// The director's memory of each window (Restore's frame, where a tile put it) is keyed by
// window id, which macOS reuses; drop a process's entries when it quits so a new window
// cannot inherit them.
terminationObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main
) { notification in
Expand Down
2 changes: 1 addition & 1 deletion Loadstone/Core/AXWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ struct AXWindow: MovableWindow {

private static let getWindow: GetWindowFunction? = {
guard let symbol = dlsym(dlopen(nil, RTLD_NOW), "_AXUIElementGetWindow") else {
Log.ax.error("_AXUIElementGetWindow is unavailable; Restore will identify windows by title")
Log.ax.error("_AXUIElementGetWindow is unavailable; Restore and repeated halves will identify windows by title")
return nil
}
return unsafeBitCast(symbol, to: GetWindowFunction.self)
Expand Down
5 changes: 5 additions & 0 deletions Loadstone/Core/Display.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,9 @@ struct Display: Equatable, Sendable {
static var all: [Display] {
NSScreen.screens.map(Display.init)
}

/// A side of a display, and so a direction across the desk.
enum Side: Sendable {
case left, right
}
}
7 changes: 5 additions & 2 deletions Loadstone/Core/MovableWindow.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import ApplicationServices

/// How `WindowDirector` recognises a window across commands, so Restore can find its memory.
/// How `WindowDirector` recognises a window across commands, so Restore and a repeated half can
/// find what the director remembers about the window.
enum WindowIdentity: Hashable, Sendable {
/// Stable for the window's lifetime. macOS reuses ids after a window closes, which is why
/// the director drops entries when their process quits.
case cgWindow(CGWindowID, pid: pid_t)
/// Weaker fallback when the window id is unavailable: titles change (browser tabs, "edited"
/// markers), which orphans the memory.
/// markers), which orphans the memory. A title that follows the window's size, as
/// Terminal's does, changes with every resize, so the director reads the identity again
/// after each write the window accepts and records under the title it reads then.
case fallback(pid: pid_t, title: String)

var pid: pid_t {
Expand Down
30 changes: 30 additions & 0 deletions Loadstone/Core/ScreenGeometry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,36 @@ enum ScreenGeometry {
return ordered[(index + delta).modulo(ordered.count)]
}

/// The display beside `display` toward `side`, for a move that heads one way rather than
/// round a cycle: the nearest display lying wholly to that side and sharing some of its
/// height, and of a stacked pair equally near, the one sharing more. Unlike `neighbor`, a
/// display only above or below never counts; reading order can put a monitor just before
/// the laptop under it, which would send a leftward move up. Nil when nothing is beside it,
/// so a move toward the edge of the desk stops there instead of wrapping.
static func adjacent(to display: Display, toward side: Display.Side, in displays: [Display] = Display.all) -> Display? {
let here = display.frame
// How far out from this display's edge on `side` the other one starts. Negative when
// their x ranges overlap (one above or below the other), and for any display on the
// other side.
func gap(_ other: CGRect) -> CGFloat {
switch side {
case .left: return here.minX - other.maxX
case .right: return other.minX - here.maxX
}
}
func sharedHeight(_ other: CGRect) -> CGFloat {
min(here.maxY, other.maxY) - max(here.minY, other.minY)
}
// Nearest, then most shared height, then the higher one, so that the answer never
// depends on the order AppKit lists displays in.
func rank(_ other: Display) -> (CGFloat, CGFloat, CGFloat) {
(gap(other.frame), -sharedHeight(other.frame), -other.frame.maxY)
}
return displays
.filter { gap($0.frame) >= 0 && sharedHeight($0.frame) > 0 }
.min { rank($0) < rank($1) }
}

private static func readingOrder(_ a: Display, _ b: Display) -> Bool {
if a.frame.minX != b.frame.minX { return a.frame.minX < b.frame.minX }
return a.frame.maxY > b.frame.maxY
Expand Down
Loading