From e46287192bf79933ce145f0f6b436eb07ed4f026 Mon Sep 17 00:00:00 2001 From: Morten Brudvik Date: Fri, 25 Sep 2026 16:50:32 +0200 Subject: [PATCH 01/44] Add tests for carrying Left and Right Half across displays Pressing Left Half on a window already in the left half re-applies the same frame today, so the second press does nothing. The next commit makes it carry the window on to the right half of the display beside it, one half at a time, stopping at the edge of the desk; Right Half mirrors it. The tests come first. Those describing the new behaviour carry a strict XCTExpectFailure, so this commit passes CI only if every one of them fails against the current code; the next commit drops the marks along with the implementation. ScreenGeometry.adjacent is a stub returning nil so they compile, and Side moves out of SnapZones so both can share it. The unmarked tests pass today and have to keep passing: they catch a continuation that fires too eagerly (a window not yet in the half, one moved by hand since it was placed, one whose process has quit), one that wraps at the edge of the desk, adjacency that counts a display above, below, or touching only at a corner, and a continuation Restore cannot undo. Co-Authored-By: Claude Opus 5.5 (1M context) --- Loadstone/Core/ScreenGeometry.swift | 10 ++ Loadstone/Core/Tile.swift | 7 +- LoadstoneTests/ScreenGeometryTests.swift | 58 +++++++++++ LoadstoneTests/WindowDirectorTests.swift | 123 ++++++++++++++++++++++- 4 files changed, 195 insertions(+), 3 deletions(-) diff --git a/Loadstone/Core/ScreenGeometry.swift b/Loadstone/Core/ScreenGeometry.swift index 3ed1f6d..89e7361 100644 --- a/Loadstone/Core/ScreenGeometry.swift +++ b/Loadstone/Core/ScreenGeometry.swift @@ -57,6 +57,16 @@ 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 puts 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: Side, in displays: [Display] = Display.all) -> Display? { + nil + } + 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 diff --git a/Loadstone/Core/Tile.swift b/Loadstone/Core/Tile.swift index b13e8ae..05f0681 100644 --- a/Loadstone/Core/Tile.swift +++ b/Loadstone/Core/Tile.swift @@ -185,6 +185,11 @@ extension Tile { } } +/// A side of a display, and so a direction across the desk. +enum Side: Sendable { + case left, right +} + enum SnapZones { /// Depth of the edge bands, and how far past an outer edge the pointer may overshoot. static let edgeThickness: CGFloat = 16 @@ -237,8 +242,6 @@ enum SnapZones { return nil } - private enum Side { case left, right } - private static func thirdAlongWidth(_ x: CGFloat, frame: CGRect) -> Tile { let t = (x - frame.minX) / max(frame.width, 1) if t < 1.0 / 3.0 { return .leftThird } diff --git a/LoadstoneTests/ScreenGeometryTests.swift b/LoadstoneTests/ScreenGeometryTests.swift index 42fbd44..3494816 100644 --- a/LoadstoneTests/ScreenGeometryTests.swift +++ b/LoadstoneTests/ScreenGeometryTests.swift @@ -107,4 +107,62 @@ final class ScreenGeometryTests: XCTestCase { XCTAssertEqual(ScreenGeometry.neighbor(of: above, delta: 1, in: appKitOrder), below) XCTAssertEqual(ScreenGeometry.neighbor(of: below, delta: 1, in: appKitOrder), left) } + + // MARK: Adjacent + + /// A display whose visible frame is its whole frame: adjacency only reads the arrangement. + private func display(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> Display { + let frame = CGRect(x: x, y: y, width: width, height: height) + return Display(frame: frame, visibleFrame: frame) + } + + func testAdjacentIsTheDisplayOnThatSide() { + XCTExpectFailure("ScreenGeometry.adjacent is a stub until the next commit") + XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .left, in: displays), left) + XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .right, in: displays), right) + } + + func testAdjacentIsTheNearestDisplayOnThatSide() { + XCTExpectFailure("ScreenGeometry.adjacent is a stub until the next commit") + XCTAssertEqual(ScreenGeometry.adjacent(to: right, toward: .left, in: displays), primary) + } + + func testAdjacentStopsAtTheEdgeOfTheDeskInsteadOfWrapping() { + XCTAssertNil(ScreenGeometry.adjacent(to: left, toward: .left, in: displays)) + XCTAssertNil(ScreenGeometry.adjacent(to: right, toward: .right, in: displays)) + } + + func testADisplayAboveOrBelowIsNotAdjacent() { + // A laptop under a monitor. Reading order puts the monitor just before the laptop, which + // suits Previous Display but would send a move to the left up onto the monitor. + let monitor = display(x: 0, y: 0, width: 2560, height: 1440) + let laptop = display(x: 524, y: -982, width: 1512, height: 982) + let desk = [monitor, laptop] + XCTAssertNil(ScreenGeometry.adjacent(to: laptop, toward: .left, in: desk)) + XCTAssertNil(ScreenGeometry.adjacent(to: laptop, toward: .right, in: desk)) + XCTAssertNil(ScreenGeometry.adjacent(to: monitor, toward: .left, in: desk)) + } + + func testADisplayTouchingOnlyAtACornerIsNotAdjacent() { + let corner = display(x: -1440, y: -900, width: 1440, height: 900) + XCTAssertNil(ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, corner])) + } + + func testOfAStackedPairTheDisplaySharingMoreHeightIsAdjacent() { + XCTExpectFailure("ScreenGeometry.adjacent is a stub until the next commit") + let upper = display(x: -1920, y: 700, width: 1920, height: 1080) // shares 380pt of the primary's height + let lower = display(x: -1920, y: -380, width: 1920, height: 1080) // shares 700pt + XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, upper, lower]), lower) + XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, lower, upper]), lower) + } + + func testAStackedPairSharingEqualHeightResolvesTheSameInAnyListOrder() { + XCTExpectFailure("ScreenGeometry.adjacent is a stub until the next commit") + let upper = display(x: -1920, y: 540, width: 1920, height: 1080) + let lower = display(x: -1920, y: -540, width: 1920, height: 1080) + let one = ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, upper, lower]) + let other = ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, lower, upper]) + XCTAssertNotNil(one) + XCTAssertEqual(one, other) + } } diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index bc9d21d..9039be2 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -9,6 +9,9 @@ final class WindowDirectorTests: XCTestCase { var cocoaFrame: CGRect? var writes: [CGRect] = [] var rejectWith: AXError? + /// Set to act like Terminal or iTerm2, which round a window's size down to whole character + /// cells and keep its top-left corner, so it never fills a tile exactly. + var grid: CGSize? init(frame: CGRect, identity: WindowIdentity? = .cgWindow(1, pid: 42)) { cocoaFrame = frame @@ -19,7 +22,13 @@ final class WindowDirectorTests: XCTestCase { func setCocoaFrame(_ frame: CGRect) -> AXError { if let rejectWith { return rejectWith } writes.append(frame) - cocoaFrame = frame + if let grid { + let width = (frame.width / grid.width).rounded(.down) * grid.width + let height = (frame.height / grid.height).rounded(.down) * grid.height + cocoaFrame = CGRect(x: frame.minX, y: frame.maxY - height, width: width, height: height) + } else { + cocoaFrame = frame + } return .success } } @@ -32,7 +41,15 @@ final class WindowDirectorTests: XCTestCase { frame: CGRect(x: 1920, y: -300, width: 2560, height: 1440), visibleFrame: CGRect(x: 1920, y: -300, width: 2560, height: 1415) ) + private let left = Display( + frame: CGRect(x: -1440, y: 0, width: 1440, height: 900), + visibleFrame: CGRect(x: -1440, y: 0, width: 1440, height: 875) + ) private let original = CGRect(x: 100, y: 100, width: 800, height: 600) + /// On the right-hand display and in no tile. + private let floating = CGRect(x: 2200, y: 100, width: 800, height: 600) + /// A Terminal character cell. No half's width or height here is a whole number of them. + private let terminalCell = CGSize(width: 7, height: 17) private func makeDirector() -> WindowDirector { WindowDirector(displays: { [self.primary, self.right] }) @@ -174,4 +191,108 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(director.perform(.restore, on: window), .nothingToRestore, "nothing moved, so there is nothing to restore to") } + + // MARK: Continuing across displays + + func testLeftHalfOnAWindowAlreadyThereContinuesToTheRightHalfOfTheDisplayToTheLeft() { + XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") + let window = FakeWindow(frame: Tile.leftHalf.frame(in: right.visibleFrame)) + XCTAssertEqual(makeDirector().perform(.tile(.leftHalf), on: window), .moved) + XCTAssertEqual(window.cocoaFrame, Tile.rightHalf.frame(in: primary.visibleFrame)) + } + + func testRightHalfOnAWindowAlreadyThereContinuesToTheLeftHalfOfTheDisplayToTheRight() { + XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") + let window = FakeWindow(frame: Tile.rightHalf.frame(in: primary.visibleFrame)) + makeDirector().perform(.tile(.rightHalf), on: window) + XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testAWindowNotInTheHalfYetSnapsOnItsOwnDisplayFirst() { + let window = FakeWindow(frame: floating) + makeDirector().perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testAtTheEdgeOfTheDeskAHalfStaysWhereItIs() { + let window = FakeWindow(frame: Tile.leftHalf.frame(in: primary.visibleFrame)) + XCTAssertEqual(makeDirector().perform(.tile(.leftHalf), on: window), .moved) + XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: primary.visibleFrame)) + } + + func testRepeatedLeftHalfWalksAWindowAcrossTheDeskOneHalfAtATime() { + XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") + // The window never fills a half, so each step has to be recognised from where Loadstone + // last put it, and each step has to record the half it actually landed in. + let window = FakeWindow(frame: floating) + window.grid = terminalCell + let director = WindowDirector(displays: { [self.primary, self.right, self.left] }) + + for _ in 0..<5 { director.perform(.tile(.leftHalf), on: window) } + + XCTAssertEqual(window.writes, [ + Tile.leftHalf.frame(in: right.visibleFrame), + Tile.rightHalf.frame(in: primary.visibleFrame), + Tile.leftHalf.frame(in: primary.visibleFrame), + Tile.rightHalf.frame(in: left.visibleFrame), + Tile.leftHalf.frame(in: left.visibleFrame), + ]) + } + + func testAWindowMovedSinceItWasPlacedGoesBackIntoTheHalfInsteadOfContinuing() { + let window = FakeWindow(frame: floating) + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.cocoaFrame = floating // dragged back out by hand + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testRestoreUndoesAContinuation() { + let window = FakeWindow(frame: Tile.leftHalf.frame(in: right.visibleFrame)) + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + + XCTAssertEqual(director.perform(.restore, on: window), .moved) + XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testAHalfReachedByDraggingContinuesToo() { + XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") + let window = FakeWindow(frame: floating) + window.grid = terminalCell + let director = makeDirector() + director.snap(.leftHalf, window: window, on: right) + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.rightHalf.frame(in: primary.visibleFrame)) + } + + func testAHalfCarriedOverByNextDisplayStillCountsAsThatHalf() { + XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") + let odd = Display( + frame: CGRect(x: 1920, y: 0, width: 1201, height: 901), + visibleFrame: CGRect(x: 1920, y: 0, width: 1201, height: 876) + ) + let window = FakeWindow(frame: Tile.leftHalf.frame(in: primary.visibleFrame)) + let director = WindowDirector(displays: { [self.primary, odd] }) + director.perform(.nextDisplay, on: window) + XCTAssertEqual(window.cocoaFrame?.width, 600.5, "Next Display maps proportionally, leaving it half a point wider than the tile") + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.cocoaFrame, Tile.rightHalf.frame(in: primary.visibleFrame)) + } + + func testForgettingAProcessDropsWhereItsWindowsWerePut() { + let window = FakeWindow(frame: floating) + window.grid = terminalCell + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + + director.forgetWindows(ofProcess: 42) + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } } From 68a816db6a43bba30602b89281725d303048b821 Mon Sep 17 00:00:00 2001 From: Morten Brudvik Date: Fri, 25 Sep 2026 16:54:26 +0200 Subject: [PATCH 02/44] Carry Left and Right Half on to the display beside the window Left Half on a window already in the left half now moves it into the right half of the display to its left, so repeated presses walk a window across the desk one half at a time; Right Half mirrors it. With no display beside it the window stays put as before, and with one display nothing changes. "Beside" is the new ScreenGeometry.adjacent: the nearest display lying wholly to that side and sharing some of its height, without wrapping. Not neighbor's reading order, which puts a monitor just before the laptop under it and would send a move to the left up onto the monitor. Of a stacked pair it takes the one sharing more height, then the higher one, so the answer never depends on AppKit's list order. "Already in the half" is either of two things. The window fills the tile to within a point, which also covers a half placed by Next Display (its proportional mapping lands a fraction off on odd widths) or one placed before Loadstone relaunched. Or it is exactly where Loadstone's last placement with that tile left it, as the window reported afterwards: Terminal and iTerm2 round to their character grid and some windows hold a minimum or fixed width, so they never fill a tile and a geometry check alone would never see them there. The director records those placements per window, drag snaps included, and drops them with the restore ledger when the process quits. The move goes through the same apply as every other command, so Restore still returns a window to where it was before Loadstone first touched it. Drops the expected-failure marks from the previous commit; every marked test failed on CI there for the expected reason. Walking each rule through a mutation (on paper, not run) showed nearest-first was unguarded, so that test now has a farther display sharing more height. Co-Authored-By: Claude Opus 5.5 (1M context) --- Loadstone/Core/ScreenGeometry.swift | 21 +++++++++- Loadstone/Core/Tile.swift | 14 +++++++ Loadstone/Core/WindowDirector.swift | 51 ++++++++++++++++++++++-- LoadstoneTests/ScreenGeometryTests.swift | 8 ++-- LoadstoneTests/WindowDirectorTests.swift | 5 --- 5 files changed, 85 insertions(+), 14 deletions(-) diff --git a/Loadstone/Core/ScreenGeometry.swift b/Loadstone/Core/ScreenGeometry.swift index 89e7361..64065d6 100644 --- a/Loadstone/Core/ScreenGeometry.swift +++ b/Loadstone/Core/ScreenGeometry.swift @@ -64,7 +64,26 @@ enum ScreenGeometry { /// 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: Side, in displays: [Display] = Display.all) -> Display? { - nil + let here = display.frame + // How far out from this display's edge on `side` the other one starts. Negative when + // the two overlap horizontally, 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 { diff --git a/Loadstone/Core/Tile.swift b/Loadstone/Core/Tile.swift index 05f0681..67bdb73 100644 --- a/Loadstone/Core/Tile.swift +++ b/Loadstone/Core/Tile.swift @@ -183,6 +183,20 @@ extension Tile { return workArea } } + + /// Where this tile sends a window that is already in it. Left Half on a window in the left + /// half moves it on to the right half of the display to its left, so pressing it again and + /// again walks the window across the desk one half at a time; Right Half mirrors that. Nil + /// for tiles that stay on their display. + var continuation: (toward: Side, landing: Tile)? { + switch self { + case .leftHalf: return (toward: .left, landing: .rightHalf) + case .rightHalf: return (toward: .right, landing: .leftHalf) + case .topHalf, .bottomHalf, .topLeft, .topRight, .bottomLeft, .bottomRight, + .leftThird, .centerThird, .rightThird, .leftTwoThirds, .rightTwoThirds, .maximize: + return nil + } + } } /// A side of a display, and so a direction across the desk. diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 88a5f01..3f2cc83 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -25,8 +25,17 @@ final class WindowDirector { /// dropped when their process quits (`forgetWindows(ofProcess:)`) because macOS reuses /// window ids and a new window could otherwise inherit a stale memory. private var originals: [WindowIdentity: CGRect] = [:] + /// The tile Loadstone last laid each window into, with the frame the window reported once it + /// had taken it. That is how a second Left or Right Half knows the window is still in that + /// half when its app never fills the tile exactly. Dropped with `originals`. + private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] + private struct Placement { + let tile: Tile + let frame: CGRect + } + init(displays: @escaping () -> [Display] = { Display.all }) { self.displays = displays } @@ -61,7 +70,14 @@ final class WindowDirector { switch command { case .tile(let tile): guard let display = display(for: current, in: displays) else { return .noDisplay } - return apply(tile.frame(in: display.visibleFrame), to: window, remembering: current) + // Left or Right Half again on a window already in that half carries it on into the + // opposite half of the display beside it. With nothing beside it, it stays put. + if let continuation = tile.continuation, + isPlaced(window, in: tile, on: display, current: current), + let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { + return place(window, in: continuation.landing, on: beside, remembering: current) + } + return place(window, in: tile, on: display, remembering: current) case .center: guard let display = display(for: current, in: displays) else { return .noDisplay } return apply(Layout.centered(current, in: display.visibleFrame), to: window, remembering: current) @@ -87,12 +103,13 @@ final class WindowDirector { @discardableResult func snap(_ tile: Tile, window: some MovableWindow, on display: Display) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } - return apply(tile.frame(in: display.visibleFrame), to: window, remembering: current) + return place(window, in: tile, on: display, remembering: current) } - /// Drops restore memory for every window of a process that has quit. + /// Drops everything remembered about the windows of a process that has quit. func forgetWindows(ofProcess pid: pid_t) { originals = originals.filter { $0.key.pid != pid } + placements = placements.filter { $0.key.pid != pid } } private func move(_ window: some MovableWindow, current: CGRect, delta: Int, in displays: [Display]) -> CommandOutcome { @@ -102,6 +119,25 @@ final class WindowDirector { return apply(Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame), to: window, remembering: current) } + /// Lays `tile` into `display`, then records where the window actually ended up, so that + /// pressing the same tile again can tell the window has not moved since. + private func place(_ window: some MovableWindow, in tile: Tile, on display: Display, remembering previous: CGRect) -> CommandOutcome { + let outcome = apply(tile.frame(in: display.visibleFrame), to: window, remembering: previous) + if outcome == .moved, let key = window.identity { + placements[key] = window.cocoaFrame.map { Placement(tile: tile, frame: $0) } + } + return outcome + } + + /// Whether `window` is already in `tile`: it fills the tile, or it is where Loadstone last + /// put it with that tile. The second covers apps that never fill a tile exactly, rounding to + /// a character grid (Terminal, iTerm2) or holding a minimum or fixed width. + private func isPlaced(_ window: some MovableWindow, in tile: Tile, on display: Display, current: CGRect) -> Bool { + if current.isWithinAPoint(of: tile.frame(in: display.visibleFrame)) { return true } + guard let key = window.identity, let last = placements[key] else { return false } + return last.tile == tile && current.isWithinAPoint(of: last.frame) + } + /// Writes `frame`, then records `previous` as the frame Restore should return to — but only /// once the window has accepted the write. Recording afterwards rather than before is what /// keeps a refused frame, or a command that never ran at all, from leaving behind a restore @@ -147,3 +183,12 @@ final class WindowDirector { } } } + +private extension CGRect { + /// Every edge within a point of `other`'s. Next Display maps a window proportionally, so a + /// half carried onto a width that does not divide by 2 lands a fraction of a point off. + func isWithinAPoint(of other: CGRect) -> Bool { + abs(minX - other.minX) <= 1 && abs(maxX - other.maxX) <= 1 + && abs(minY - other.minY) <= 1 && abs(maxY - other.maxY) <= 1 + } +} diff --git a/LoadstoneTests/ScreenGeometryTests.swift b/LoadstoneTests/ScreenGeometryTests.swift index 3494816..957511a 100644 --- a/LoadstoneTests/ScreenGeometryTests.swift +++ b/LoadstoneTests/ScreenGeometryTests.swift @@ -117,14 +117,14 @@ final class ScreenGeometryTests: XCTestCase { } func testAdjacentIsTheDisplayOnThatSide() { - XCTExpectFailure("ScreenGeometry.adjacent is a stub until the next commit") XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .left, in: displays), left) XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .right, in: displays), right) } func testAdjacentIsTheNearestDisplayOnThatSide() { - XCTExpectFailure("ScreenGeometry.adjacent is a stub until the next commit") - XCTAssertEqual(ScreenGeometry.adjacent(to: right, toward: .left, in: displays), primary) + // Beyond the primary, and sharing more of the right display's height than the primary does. + let farLeft = display(x: -2560, y: -300, width: 2560, height: 1440) + XCTAssertEqual(ScreenGeometry.adjacent(to: right, toward: .left, in: [farLeft, primary, right]), primary) } func testAdjacentStopsAtTheEdgeOfTheDeskInsteadOfWrapping() { @@ -149,7 +149,6 @@ final class ScreenGeometryTests: XCTestCase { } func testOfAStackedPairTheDisplaySharingMoreHeightIsAdjacent() { - XCTExpectFailure("ScreenGeometry.adjacent is a stub until the next commit") let upper = display(x: -1920, y: 700, width: 1920, height: 1080) // shares 380pt of the primary's height let lower = display(x: -1920, y: -380, width: 1920, height: 1080) // shares 700pt XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, upper, lower]), lower) @@ -157,7 +156,6 @@ final class ScreenGeometryTests: XCTestCase { } func testAStackedPairSharingEqualHeightResolvesTheSameInAnyListOrder() { - XCTExpectFailure("ScreenGeometry.adjacent is a stub until the next commit") let upper = display(x: -1920, y: 540, width: 1920, height: 1080) let lower = display(x: -1920, y: -540, width: 1920, height: 1080) let one = ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, upper, lower]) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 9039be2..a150618 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -195,14 +195,12 @@ final class WindowDirectorTests: XCTestCase { // MARK: Continuing across displays func testLeftHalfOnAWindowAlreadyThereContinuesToTheRightHalfOfTheDisplayToTheLeft() { - XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") let window = FakeWindow(frame: Tile.leftHalf.frame(in: right.visibleFrame)) XCTAssertEqual(makeDirector().perform(.tile(.leftHalf), on: window), .moved) XCTAssertEqual(window.cocoaFrame, Tile.rightHalf.frame(in: primary.visibleFrame)) } func testRightHalfOnAWindowAlreadyThereContinuesToTheLeftHalfOfTheDisplayToTheRight() { - XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") let window = FakeWindow(frame: Tile.rightHalf.frame(in: primary.visibleFrame)) makeDirector().perform(.tile(.rightHalf), on: window) XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) @@ -221,7 +219,6 @@ final class WindowDirectorTests: XCTestCase { } func testRepeatedLeftHalfWalksAWindowAcrossTheDeskOneHalfAtATime() { - XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") // The window never fills a half, so each step has to be recognised from where Loadstone // last put it, and each step has to record the half it actually landed in. let window = FakeWindow(frame: floating) @@ -259,7 +256,6 @@ final class WindowDirectorTests: XCTestCase { } func testAHalfReachedByDraggingContinuesToo() { - XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") let window = FakeWindow(frame: floating) window.grid = terminalCell let director = makeDirector() @@ -270,7 +266,6 @@ final class WindowDirectorTests: XCTestCase { } func testAHalfCarriedOverByNextDisplayStillCountsAsThatHalf() { - XCTExpectFailure("Left and Right Half do not continue across displays until the next commit") let odd = Display( frame: CGRect(x: 1920, y: 0, width: 1201, height: 901), visibleFrame: CGRect(x: 1920, y: 0, width: 1201, height: 876) From d96a52e0d49acc425aa476acf4bf5a03f425015c Mon Sep 17 00:00:00 2001 From: Morten Brudvik Date: Fri, 25 Sep 2026 16:57:02 +0200 Subject: [PATCH 03/44] Document carrying a half across displays README: the multi-display feature line, how a repeated left or right half walks a window across the screens and where it stops, and a troubleshooting entry for when it does not. CLAUDE.md: the continuation and its placement record in the director notes, ScreenGeometry.adjacent beside neighbor, and continuation among the switches a new Tile case must cover. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 6 +++--- README.md | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ca181f6..16a986d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,13 +58,13 @@ 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. +- **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. Left/Right Half on a window already in that half continue into the opposite half of the display beside it (`Tile.continuation`), staying put at the edge of the desk. "Already in" means the tile's frame to within a point, or exactly the frame the window reported after Loadstone's last placement with that tile (`placements`, recorded for drag snaps too and pruned with the restore ledger), because Terminal-style apps round to a character grid and never fill a tile. 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. +- **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: only a display wholly to that side and sharing some height counts, and it never wraps, so a laptop under a monitor has nothing to its left. ### 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_`); 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`, and `continuation` cover it, then it appears everywhere. `WindowCommand.id` strings are the UserDefaults keys where users' custom shortcuts live (`KeyboardShortcuts_`); 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` 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. diff --git a/README.md b/README.md index 24d8931..f69a9b6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Loadstone sits in the menu bar and stays out of the way. When you need two docs - **Thirds** — left, center, right, plus two-thirds - **Maximize, center, restore** — restore puts the window back where it was before Loadstone first moved it - **Drag to snap** — edges for halves, corners for quarters, top to maximize, bottom for thirds. Only a window that is actually being dragged snaps; selecting text or dragging a slider near an edge does nothing -- **Multi-display** — send a window to the next or previous screen, walking displays left to right as they sit on the desk +- **Multi-display** — press left or right half again and the window carries on into the screen beside it, one half at a time; or send it to the next or previous screen, walking displays left to right as they sit on the desk - **Custom shortcuts** — Magnet-style defaults, all editable - **Launch at login** — optional, from Settings - **Portrait screens** — the side edges split three ways along their length (corner, vertical third, corner); the bottom edge gives halves @@ -59,6 +59,8 @@ U I J K ``` +Press `⌃⌥←` again on a window that is already in the left half and it moves on to the right half of the screen to its left, so repeated presses walk it across your screens one half at a time. `⌃⌥→` does the same to the right. At the outer edge of the desk the window stays put, and a screen above or below does not count. + ## Drag to snap Drag a window by its title bar until the pointer reaches an edge or corner of the screen. A blue preview shows the tile; let go to snap. Only a window that is actually moving snaps, so selecting text or dragging a slider to an edge does nothing. @@ -150,6 +152,8 @@ Menu bar → Loadstone → **Loadstone Help** opens this page. **Next / previous display beeps.** Only one display is attached. +**Pressing `⌃⌥←` again does not move the window to the next screen.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not. And the window has to be in that half already: if you have moved it since, the first press puts it back into the half. + **macOS’s own edge tiling is off after Loadstone crashed or was force-quit.** Loadstone turns it off while running and back on when it quits normally; after an unclean exit, simply launching Loadstone again restores it. To put it back by hand, use System Settings → **Desktop & Dock** → **Windows**. **Reading the log.** Loadstone reports to the unified log under the subsystem `com.brudvik.loadstone`. In Terminal: From fbf630981cca45e73c2275619d2430e314d791fa Mon Sep 17 00:00:00 2001 From: Morten Brudvik Date: Fri, 25 Sep 2026 17:17:40 +0200 Subject: [PATCH 04/44] Test the placement record against refused, late, and outdated frames Review of the previous commits found four gaps. A refused write must not be recorded as a placement, or the next press throws a window that never reached the half onto another display. The code already guards this; nothing pinned it. An app that applies a frame only after the write returns reports its old frame when read straight back, and that stale frame gets recorded. Restore later returns the window to exactly that frame, and the next Left Half then carries a floating window off to the neighbouring display. A display whose resolution changes leaves the window's frame alone, so the record still matches and the next press carries the window on instead of refitting it to the new, wider half. Of a stacked pair sharing equal height, the higher display wins, as documented; only list-order independence was pinned. The stale-frame and changed-display tests carry a strict XCTExpectFailure until the next commit fixes them. The refused-write test and the tie-break assertion pin behaviour that already holds, and they pass now. Co-Authored-By: Claude Opus 5.5 (1M context) --- LoadstoneTests/ScreenGeometryTests.swift | 8 ++-- LoadstoneTests/WindowDirectorTests.swift | 61 +++++++++++++++++++++++- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/LoadstoneTests/ScreenGeometryTests.swift b/LoadstoneTests/ScreenGeometryTests.swift index 957511a..756459c 100644 --- a/LoadstoneTests/ScreenGeometryTests.swift +++ b/LoadstoneTests/ScreenGeometryTests.swift @@ -155,12 +155,10 @@ final class ScreenGeometryTests: XCTestCase { XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, lower, upper]), lower) } - func testAStackedPairSharingEqualHeightResolvesTheSameInAnyListOrder() { + func testOfAStackedPairSharingEqualHeightTheHigherIsAdjacentInAnyListOrder() { let upper = display(x: -1920, y: 540, width: 1920, height: 1080) let lower = display(x: -1920, y: -540, width: 1920, height: 1080) - let one = ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, upper, lower]) - let other = ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, lower, upper]) - XCTAssertNotNil(one) - XCTAssertEqual(one, other) + XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, upper, lower]), upper) + XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .left, in: [primary, lower, upper]), upper) } } diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index a150618..7e81028 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -12,6 +12,10 @@ final class WindowDirectorTests: XCTestCase { /// Set to act like Terminal or iTerm2, which round a window's size down to whole character /// cells and keep its top-left corner, so it never fills a tile exactly. var grid: CGSize? + /// Set to act like an app that applies a frame only after the write has returned, so the + /// frame read straight back is still the old one. `catchUp()` applies it. + var appliesLate = false + private var pending: CGRect? init(frame: CGRect, identity: WindowIdentity? = .cgWindow(1, pid: 42)) { cocoaFrame = frame @@ -22,15 +26,24 @@ final class WindowDirectorTests: XCTestCase { func setCocoaFrame(_ frame: CGRect) -> AXError { if let rejectWith { return rejectWith } writes.append(frame) + var landed = frame if let grid { let width = (frame.width / grid.width).rounded(.down) * grid.width let height = (frame.height / grid.height).rounded(.down) * grid.height - cocoaFrame = CGRect(x: frame.minX, y: frame.maxY - height, width: width, height: height) + landed = CGRect(x: frame.minX, y: frame.maxY - height, width: width, height: height) + } + if appliesLate { + pending = landed } else { - cocoaFrame = frame + cocoaFrame = landed } return .success } + + func catchUp() { + if let pending { cocoaFrame = pending } + pending = nil + } } private let primary = Display( @@ -279,6 +292,50 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.cocoaFrame, Tile.rightHalf.frame(in: primary.visibleFrame)) } + func testARefusedHalfIsNotRememberedAsPlaced() { + let window = FakeWindow(frame: floating) + let director = makeDirector() + window.rejectWith = .cannotComplete + director.perform(.tile(.leftHalf), on: window) + window.rejectWith = nil + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testAWindowStillReportingItsOldFrameIsNotRememberedAsPlaced() { + XCTExpectFailure("Placements record a frame the window has not moved to until the next commit") + // Read straight back, the frame is still the old one. Restore later returns the window to + // exactly that frame, where a remembered placement would throw it onto the next display. + let window = FakeWindow(frame: floating) + window.appliesLate = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.catchUp() + director.perform(.restore, on: window) + window.catchUp() + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testAHalfWhoseDisplayHasChangedSinceIsRefittedRatherThanCarriedOn() { + XCTExpectFailure("Placements outlive a change to the display until the next commit") + // A new resolution leaves the window where it was, which is no longer the left half. + var desk = [primary, right] + let director = WindowDirector(displays: { desk }) + let window = FakeWindow(frame: floating) + director.perform(.tile(.leftHalf), on: window) + let roomier = Display( + frame: CGRect(x: 1920, y: -300, width: 3008, height: 1692), + visibleFrame: CGRect(x: 1920, y: -300, width: 3008, height: 1667) + ) + desk = [primary, roomier] + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: roomier.visibleFrame)) + } + func testForgettingAProcessDropsWhereItsWindowsWerePut() { let window = FakeWindow(frame: floating) window.grid = terminalCell From 3d34e06805ab2e3ed79429638c086c819db023d1 Mon Sep 17 00:00:00 2001 From: Morten Brudvik Date: Fri, 25 Sep 2026 17:20:42 +0200 Subject: [PATCH 05/44] Record a placement by the frame asked for, once the window gets there A placement now remembers the frame Loadstone sent the window to rather than the tile, alongside where the window landed. The record counts only while that tile still has the same frame on the window's display, so a new resolution, or the Dock moving, gets the window refitted into the half before a press carries it on. Keying on the frame also subsumes the old tile check: no two tiles share a frame. It is also written only once the window's top-left corner has reached the target. An app that rounds or caps a size keeps that corner, so Terminal and fixed-width windows are still recorded; an app that applies the frame after the write returns reports its old frame when read straight back, and recording that is what let Restore followed by Left Half carry a floating window off to the next display. Drops the expected-failure marks from the previous commit, where both tests failed on CI for the expected reason. Comments that described per-window memory as Restore's alone now mention the placements too, and the README troubleshooting entry names the refit. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- Loadstone/App/AppDelegate.swift | 5 +-- Loadstone/Core/MovableWindow.swift | 3 +- Loadstone/Core/WindowDirector.swift | 39 ++++++++++++++++-------- LoadstoneTests/WindowDirectorTests.swift | 2 -- README.md | 2 +- 6 files changed, 33 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 16a986d..572fb6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ 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. Left/Right Half on a window already in that half continue into the opposite half of the display beside it (`Tile.continuation`), staying put at the edge of the desk. "Already in" means the tile's frame to within a point, or exactly the frame the window reported after Loadstone's last placement with that tile (`placements`, recorded for drag snaps too and pruned with the restore ledger), because Terminal-style apps round to a character grid and never fill a tile. Drag snaps pass the display the gesture ended on explicitly; the hotkey path uses the display under the window's centre. +- **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. Left/Right Half on a window already in that half continue into the opposite half of the display beside it (`Tile.continuation`), staying put at the edge of the desk. "Already in" means the tile's frame to within a point, or where the window landed the last time Loadstone sent it to that same frame (`placements`, recorded for drag snaps too and pruned with the restore ledger), because Terminal-style apps round to a character grid and never fill a tile. A placement is recorded only once the window's top-left corner has reached the target, since an app that applies a frame late reports its old one when read straight back; and a display change alters the tile's frame, so the window is refitted before it is carried on. 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. `ScreenGeometry.adjacent(to:toward:in:)` is the directional one behind the Left/Right Half continuation: only a display wholly to that side and sharing some height counts, and it never wraps, so a laptop under a monitor has nothing to its left. diff --git a/Loadstone/App/AppDelegate.swift b/Loadstone/App/AppDelegate.swift index 6bc8101..7117d84 100644 --- a/Loadstone/App/AppDelegate.swift +++ b/Loadstone/App/AppDelegate.swift @@ -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 half 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 diff --git a/Loadstone/Core/MovableWindow.swift b/Loadstone/Core/MovableWindow.swift index 4896abd..e7a74e9 100644 --- a/Loadstone/Core/MovableWindow.swift +++ b/Loadstone/Core/MovableWindow.swift @@ -1,6 +1,7 @@ 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 it remembers about it. 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. diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 3f2cc83..622b584 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -25,15 +25,16 @@ final class WindowDirector { /// dropped when their process quits (`forgetWindows(ofProcess:)`) because macOS reuses /// window ids and a new window could otherwise inherit a stale memory. private var originals: [WindowIdentity: CGRect] = [:] - /// The tile Loadstone last laid each window into, with the frame the window reported once it - /// had taken it. That is how a second Left or Right Half knows the window is still in that - /// half when its app never fills the tile exactly. Dropped with `originals`. + /// The tile frame Loadstone last sent each window to, and where the window reported itself + /// once there, which differs when its app rounds or caps the size. That is how a second Left + /// or Right Half knows the window is still in that half when it never fills the tile exactly. + /// Dropped with `originals` when the window's process quits. private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] private struct Placement { - let tile: Tile - let frame: CGRect + let target: CGRect + let landed: CGRect } init(displays: @escaping () -> [Display] = { Display.all }) { @@ -120,22 +121,29 @@ final class WindowDirector { } /// Lays `tile` into `display`, then records where the window actually ended up, so that - /// pressing the same tile again can tell the window has not moved since. + /// pressing the same tile again can tell the window has not moved since. Recorded only once + /// the window's top-left corner is where it was sent: an app that rounds or caps a size keeps + /// that corner, while one still reporting its old frame has not moved yet. private func place(_ window: some MovableWindow, in tile: Tile, on display: Display, remembering previous: CGRect) -> CommandOutcome { - let outcome = apply(tile.frame(in: display.visibleFrame), to: window, remembering: previous) + let target = tile.frame(in: display.visibleFrame) + let outcome = apply(target, to: window, remembering: previous) if outcome == .moved, let key = window.identity { - placements[key] = window.cocoaFrame.map { Placement(tile: tile, frame: $0) } + let landed = window.cocoaFrame.flatMap { $0.sharesTopLeft(with: target) ? $0 : nil } + placements[key] = landed.map { Placement(target: target, landed: $0) } } return outcome } - /// Whether `window` is already in `tile`: it fills the tile, or it is where Loadstone last - /// put it with that tile. The second covers apps that never fill a tile exactly, rounding to - /// a character grid (Terminal, iTerm2) or holding a minimum or fixed width. + /// Whether `window` is already in `tile`: it fills the tile, or it is where it landed the last + /// time Loadstone sent it to this same frame. The second covers apps that never fill a tile + /// exactly, rounding to a character grid (Terminal, iTerm2) or holding a minimum or fixed + /// width. A display change (a new resolution, the Dock moving) changes the tile's frame, so + /// the window is refitted before it is carried on. private func isPlaced(_ window: some MovableWindow, in tile: Tile, on display: Display, current: CGRect) -> Bool { - if current.isWithinAPoint(of: tile.frame(in: display.visibleFrame)) { return true } + let target = tile.frame(in: display.visibleFrame) + if current.isWithinAPoint(of: target) { return true } guard let key = window.identity, let last = placements[key] else { return false } - return last.tile == tile && current.isWithinAPoint(of: last.frame) + return last.target.isWithinAPoint(of: target) && current.isWithinAPoint(of: last.landed) } /// Writes `frame`, then records `previous` as the frame Restore should return to — but only @@ -191,4 +199,9 @@ private extension CGRect { abs(minX - other.minX) <= 1 && abs(maxX - other.maxX) <= 1 && abs(minY - other.minY) <= 1 && abs(maxY - other.maxY) <= 1 } + + /// Top-left corners within a point of each other. Cocoa space, so the top is `maxY`. + func sharesTopLeft(with other: CGRect) -> Bool { + abs(minX - other.minX) <= 1 && abs(maxY - other.maxY) <= 1 + } } diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 7e81028..04d8279 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -304,7 +304,6 @@ final class WindowDirectorTests: XCTestCase { } func testAWindowStillReportingItsOldFrameIsNotRememberedAsPlaced() { - XCTExpectFailure("Placements record a frame the window has not moved to until the next commit") // Read straight back, the frame is still the old one. Restore later returns the window to // exactly that frame, where a remembered placement would throw it onto the next display. let window = FakeWindow(frame: floating) @@ -320,7 +319,6 @@ final class WindowDirectorTests: XCTestCase { } func testAHalfWhoseDisplayHasChangedSinceIsRefittedRatherThanCarriedOn() { - XCTExpectFailure("Placements outlive a change to the display until the next commit") // A new resolution leaves the window where it was, which is no longer the left half. var desk = [primary, right] let director = WindowDirector(displays: { desk }) diff --git a/README.md b/README.md index f69a9b6..1a1d2b8 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Menu bar → Loadstone → **Loadstone Help** opens this page. **Next / previous display beeps.** Only one display is attached. -**Pressing `⌃⌥←` again does not move the window to the next screen.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not. And the window has to be in that half already: if you have moved it since, the first press puts it back into the half. +**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not. And the window has to be in that half already: if you have moved it since, or the screen's resolution has changed, the first press puts it back into the half. **macOS’s own edge tiling is off after Loadstone crashed or was force-quit.** Loadstone turns it off while running and back on when it quits normally; after an unclean exit, simply launching Loadstone again restores it. To put it back by hand, use System Settings → **Desktop & Dock** → **Windows**. From da9c2032847b2b91c038bc25d4ce83481efd8039 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 19:40:28 +0200 Subject: [PATCH 06/44] Nest Side inside Display Side named a direction across the desk but sat at module level in Tile.swift, between the Tile extension and SnapZones. SnapZones, Tile.continuation and ScreenGeometry.adjacent all reached for it. It describes a side of a display, so it belongs with Display, and a bare module-level Side is a name any later type could collide with. Call sites that write .left and .right are unchanged. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/Display.swift | 5 +++++ Loadstone/Core/ScreenGeometry.swift | 2 +- Loadstone/Core/Tile.swift | 9 ++------- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Loadstone/Core/Display.swift b/Loadstone/Core/Display.swift index cd3ebfe..f2a5a0d 100644 --- a/Loadstone/Core/Display.swift +++ b/Loadstone/Core/Display.swift @@ -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 + } } diff --git a/Loadstone/Core/ScreenGeometry.swift b/Loadstone/Core/ScreenGeometry.swift index 64065d6..500c183 100644 --- a/Loadstone/Core/ScreenGeometry.swift +++ b/Loadstone/Core/ScreenGeometry.swift @@ -63,7 +63,7 @@ enum ScreenGeometry { /// display only above or below never counts; reading order puts 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: Side, in displays: [Display] = Display.all) -> Display? { + 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 // the two overlap horizontally, and for any display on the other side. diff --git a/Loadstone/Core/Tile.swift b/Loadstone/Core/Tile.swift index 67bdb73..46cba21 100644 --- a/Loadstone/Core/Tile.swift +++ b/Loadstone/Core/Tile.swift @@ -188,7 +188,7 @@ extension Tile { /// half moves it on to the right half of the display to its left, so pressing it again and /// again walks the window across the desk one half at a time; Right Half mirrors that. Nil /// for tiles that stay on their display. - var continuation: (toward: Side, landing: Tile)? { + var continuation: (toward: Display.Side, landing: Tile)? { switch self { case .leftHalf: return (toward: .left, landing: .rightHalf) case .rightHalf: return (toward: .right, landing: .leftHalf) @@ -199,11 +199,6 @@ extension Tile { } } -/// A side of a display, and so a direction across the desk. -enum Side: Sendable { - case left, right -} - enum SnapZones { /// Depth of the edge bands, and how far past an outer edge the pointer may overshoot. static let edgeThickness: CGFloat = 16 @@ -263,7 +258,7 @@ enum SnapZones { return .rightThird } - private static func thirdAlongHeight(_ y: CGFloat, frame: CGRect, side: Side) -> Tile { + private static func thirdAlongHeight(_ y: CGFloat, frame: CGRect, side: Display.Side) -> Tile { let t = (y - frame.minY) / max(frame.height, 1) switch side { case .left: From d45170597960bfa77797f43d704f541afa31b08b Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 19:42:40 +0200 Subject: [PATCH 07/44] Pin continuations as inverse pairs and correct their comments A test now checks that every tile with a continuation lands on one whose continuation leads straight back, the other way, so pressing Right Half after a Left Half carry sends the window back into the half it left, though beside a stacked pair not always on the display it left. It holds today; it fails if a landing row or a direction is edited on one side only, or if the landing tile loses its continuation. The WindowCommand doc listed what a new case must cover but not continuation, which a new Tile also needs, since that switch is exhaustive. adjacent's doc said reading order puts a monitor before the laptop under it, which is true only when the monitor's left edge is at or left of the laptop's. The gap comment's "overlap horizontally" read as either axis; it names the x ranges now. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/ScreenGeometry.swift | 9 +++++---- Loadstone/Core/Tile.swift | 4 ++-- LoadstoneTests/TileLayoutTests.swift | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Loadstone/Core/ScreenGeometry.swift b/Loadstone/Core/ScreenGeometry.swift index 500c183..ad6cbc0 100644 --- a/Loadstone/Core/ScreenGeometry.swift +++ b/Loadstone/Core/ScreenGeometry.swift @@ -60,13 +60,14 @@ enum ScreenGeometry { /// 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 puts 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. + /// 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 - // the two overlap horizontally, and for any display on the other side. + // 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 diff --git a/Loadstone/Core/Tile.swift b/Loadstone/Core/Tile.swift index 46cba21..d913066 100644 --- a/Loadstone/Core/Tile.swift +++ b/Loadstone/Core/Tile.swift @@ -40,8 +40,8 @@ enum Tile: String, CaseIterable, Sendable { /// Everything a user can ask Loadstone to do to a window. This is the single source of truth /// for the hotkey list, the status-bar menu, and the Shortcuts settings pane: adding a case -/// (or a `Tile`) fails to compile until `id`, `title`, `defaultShortcut`, and `section` cover it, -/// and then it appears everywhere automatically. +/// (or a `Tile`) fails to compile until `id`, `title`, `defaultShortcut`, and `section` cover +/// it (for a `Tile`, `continuation` too), and then it appears everywhere automatically. enum WindowCommand: Hashable, Sendable { case tile(Tile) case center diff --git a/LoadstoneTests/TileLayoutTests.swift b/LoadstoneTests/TileLayoutTests.swift index b274e9d..647769d 100644 --- a/LoadstoneTests/TileLayoutTests.swift +++ b/LoadstoneTests/TileLayoutTests.swift @@ -105,6 +105,20 @@ final class TileLayoutTests: XCTestCase { } } + /// Pressing the opposite half after a carry must bring the window straight back, so every + /// continuation's landing has to lead back to the tile it came from, the other way. + func testEveryContinuationLeadsBackTheOtherWay() { + for tile in Tile.allCases { + guard let there = tile.continuation else { continue } + guard let back = there.landing.continuation else { + XCTFail("\(tile) lands on \(there.landing), which carries nowhere") + continue + } + XCTAssertEqual(back.landing, tile, "\(tile)") + XCTAssertNotEqual(back.toward, there.toward, "\(tile)") + } + } + func testCenterKeepsSize() { let current = CGRect(x: 0, y: 0, width: 200, height: 100) let centered = Layout.centered(current, in: area) From 31310305eb5637eb10bd8dfbfb1fc848bd0b798e Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 19:44:00 +0200 Subject: [PATCH 08/44] Drop the placement when another command moves the window An app that applies a frame late reports its old frame when read straight back. When that old frame already shares the target's top-left corner (a window flush in the display's top-left, zoomed, or in Top Left, Top Half or Left Third), it is recorded as where the half left the window. Once the app catches up, anything that returns the window to exactly that frame, Restore included, makes the next Left Half carry it to the other display instead of halving it. Restore, Center, and Next and Previous Display now drop the window's placement once the window accepts their frame. The entry describes where a tile put the window, and after Loadstone has moved it elsewhere it can only mislead. Refusing to record a read-back equal to the pre-write frame would not do: a Terminal window already at its grid frame in the half reads back unchanged on every press after a relaunch and would never carry on. A frame the window is returned to by something outside Loadstone (a title-bar double-click, a drag) can still match a stale entry; place() documents it. Closing it would take an AXObserver on the window. The late-apply test that brought the window back with Restore now drags it back by hand, since Restore clears the placement on its own and the test would otherwise stop guarding the top-left check. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 26 +++++++++-- LoadstoneTests/WindowDirectorTests.swift | 59 +++++++++++++++++++++++- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 622b584..f80884d 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -28,7 +28,8 @@ final class WindowDirector { /// The tile frame Loadstone last sent each window to, and where the window reported itself /// once there, which differs when its app rounds or caps the size. That is how a second Left /// or Right Half knows the window is still in that half when it never fills the tile exactly. - /// Dropped with `originals` when the window's process quits. + /// The next tile replaces the entry; Restore, Center and a display move drop it, and so does + /// the window's process quitting, along with `originals`. private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] @@ -81,7 +82,7 @@ final class WindowDirector { return place(window, in: tile, on: display, remembering: current) case .center: guard let display = display(for: current, in: displays) else { return .noDisplay } - return apply(Layout.centered(current, in: display.visibleFrame), to: window, remembering: current) + return relocate(window, to: Layout.centered(current, in: display.visibleFrame), remembering: current) case .restore: // Restore must not record: it would store the current frame and then "restore" to it. // The memory is dropped only once the window has actually accepted the old frame, so @@ -89,7 +90,7 @@ final class WindowDirector { guard let key = window.identity, let original = originals[key] else { return .nothingToRestore } - let outcome = apply(original, to: window, remembering: nil) + let outcome = relocate(window, to: original, remembering: nil) if outcome == .moved { originals.removeValue(forKey: key) } return outcome case .nextDisplay: @@ -117,13 +118,30 @@ final class WindowDirector { guard let display = display(for: current, in: displays), let neighbor = ScreenGeometry.neighbor(of: display, delta: delta, in: displays) else { return .noDisplay } guard neighbor != display else { return .noOtherDisplay } - return apply(Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame), to: window, remembering: current) + return relocate(window, to: Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame), remembering: current) + } + + /// Writes a frame that is not a tile (Center, Restore, a display move) and, once the window + /// accepts it, drops the window's placement. The placement says where a tile put the window; + /// once Loadstone has moved it anywhere else it can only mislead, most of all when it holds + /// a stale read-back that the window can later be put back on. + private func relocate(_ window: some MovableWindow, to frame: CGRect, remembering previous: CGRect?) -> CommandOutcome { + let outcome = apply(frame, to: window, remembering: previous) + if outcome == .moved, let key = window.identity { placements.removeValue(forKey: key) } + return outcome } /// Lays `tile` into `display`, then records where the window actually ended up, so that /// pressing the same tile again can tell the window has not moved since. Recorded only once /// the window's top-left corner is where it was sent: an app that rounds or caps a size keeps /// that corner, while one still reporting its old frame has not moved yet. + /// + /// An app that applies the frame late, and whose old frame already shared the target's + /// top-left, reads back that old frame and has it recorded. Once Loadstone moves the window + /// again the entry goes; but returned to exactly that frame by anything else (a title-bar + /// double-click, a drag), the window is carried on at the next press. Closing that would + /// take watching the window, with an AXObserver dropping the entry when it moves anywhere + /// but its landing. private func place(_ window: some MovableWindow, in tile: Tile, on display: Display, remembering previous: CGRect) -> CommandOutcome { let target = tile.frame(in: display.visibleFrame) let outcome = apply(target, to: window, remembering: previous) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 04d8279..b2f85ea 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -61,6 +61,9 @@ final class WindowDirectorTests: XCTestCase { private let original = CGRect(x: 100, y: 100, width: 800, height: 600) /// On the right-hand display and in no tile. private let floating = CGRect(x: 2200, y: 100, width: 800, height: 600) + /// On the right-hand display, flush with the top-left corner of its visible frame, so it + /// shares that corner with the display's left half without being in it. + private let flushTopLeft = CGRect(x: 1920, y: 515, width: 800, height: 600) /// A Terminal character cell. No half's width or height here is a whole number of them. private let terminalCell = CGSize(width: 7, height: 17) @@ -304,13 +307,30 @@ final class WindowDirectorTests: XCTestCase { } func testAWindowStillReportingItsOldFrameIsNotRememberedAsPlaced() { - // Read straight back, the frame is still the old one. Restore later returns the window to - // exactly that frame, where a remembered placement would throw it onto the next display. + // Read straight back, the frame is still the old one. Dragged back to exactly that frame, + // the window would be thrown onto the next display by a remembered placement. let window = FakeWindow(frame: floating) window.appliesLate = true let director = makeDirector() director.perform(.tile(.leftHalf), on: window) window.catchUp() + window.cocoaFrame = floating + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + // An app that applies frames late reports the old frame when read straight back. A window + // flush in the display's top-left corner already shares the half's top-left, so that stale + // frame is recorded as where the half left it. The tests below put the window back on it + // after another command has moved it elsewhere, and expect the next Left Half to halve it. + + func testRestoreForgetsWhereAHalfPutTheWindow() { + let window = FakeWindow(frame: flushTopLeft) + window.appliesLate = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.catchUp() director.perform(.restore, on: window) window.catchUp() @@ -318,6 +338,41 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testCenterForgetsWhereAHalfPutTheWindow() { + let window = FakeWindow(frame: flushTopLeft) + window.appliesLate = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.catchUp() + director.perform(.center, on: window) + window.catchUp() + window.cocoaFrame = flushTopLeft // dragged back by hand + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testMovingToAnotherDisplayForgetsWhereAHalfPutTheWindow() throws { + let window = FakeWindow(frame: flushTopLeft) + window.appliesLate = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.catchUp() + window.cocoaFrame = flushTopLeft // dragged back by hand + director.perform(.nextDisplay, on: window) + window.catchUp() + director.perform(.previousDisplay, on: window) + window.catchUp() + let back = try XCTUnwrap(window.cocoaFrame) + XCTAssertEqual(back.minX, flushTopLeft.minX, accuracy: 1, "the round trip lands back on the recorded frame") + XCTAssertEqual(back.maxX, flushTopLeft.maxX, accuracy: 1) + XCTAssertEqual(back.minY, flushTopLeft.minY, accuracy: 1) + XCTAssertEqual(back.maxY, flushTopLeft.maxY, accuracy: 1) + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + func testAHalfWhoseDisplayHasChangedSinceIsRefittedRatherThanCarriedOn() { // A new resolution leaves the window where it was, which is no longer the left half. var desk = [primary, right] From 8fcb3af8f0009968f34be4d365957b953f652219 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 19:46:24 +0200 Subject: [PATCH 09/44] Pin the refused-write and top-left checks on a placement The refused-half test started from a frame that does not share the half's top-left corner, so the top-left check threw the read-back away on its own and deleting the refusal check in place() left every test passing. It now starts flush in the display's top-left corner, where only the refusal keeps the unchanged frame from being recorded. A refused continuation must keep both the placement and the Restore frame: the next press still carries the window on, and Restore still returns it to where it was before Loadstone first moved it. Two late-apply tests pin each half of the top-left check: an old frame on the half's left edge but below its top, and one at its top but right of its left edge. Each returns the window by hand rather than with Restore, which clears the placement on its own. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 52 +++++++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index b2f85ea..ecdb448 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -296,16 +296,36 @@ final class WindowDirectorTests: XCTestCase { } func testARefusedHalfIsNotRememberedAsPlaced() { - let window = FakeWindow(frame: floating) + // The refused window stays where it was, which shares the half's top-left, so only the + // refusal itself keeps that frame from being recorded as where the half left it. + let window = FakeWindow(frame: flushTopLeft) let director = makeDirector() window.rejectWith = .cannotComplete - director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(director.perform(.tile(.leftHalf), on: window), .rejected(.cannotComplete)) window.rejectWith = nil director.perform(.tile(.leftHalf), on: window) XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testARefusedContinuationKeepsThePlacementAndTheRestoreFrame() { + let window = FakeWindow(frame: floating) + window.grid = terminalCell + let director = makeDirector() + director.snap(.leftHalf, window: window, on: right) + let placed = window.cocoaFrame + + window.rejectWith = .cannotComplete + XCTAssertEqual(director.perform(.tile(.leftHalf), on: window), .rejected(.cannotComplete)) + XCTAssertEqual(window.cocoaFrame, placed) + window.rejectWith = nil + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.rightHalf.frame(in: primary.visibleFrame)) + XCTAssertEqual(director.perform(.restore, on: window), .moved) + XCTAssertEqual(window.writes.last, floating) + } + func testAWindowStillReportingItsOldFrameIsNotRememberedAsPlaced() { // Read straight back, the frame is still the old one. Dragged back to exactly that frame, // the window would be thrown onto the next display by a remembered placement. @@ -320,6 +340,34 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testAnOldFrameOnlyOnTheHalfsLeftEdgeIsNotRememberedAsPlaced() { + // Flush with the display's left edge, below its top. + let start = CGRect(x: 1920, y: 100, width: 800, height: 600) + let window = FakeWindow(frame: start) + window.appliesLate = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.catchUp() + window.cocoaFrame = start + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testAnOldFrameOnlyAtTheHalfsTopIsNotRememberedAsPlaced() { + // Flush with the display's top, right of its left edge. + let start = CGRect(x: 2200, y: 515, width: 800, height: 600) + let window = FakeWindow(frame: start) + window.appliesLate = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.catchUp() + window.cocoaFrame = start + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + // An app that applies frames late reports the old frame when read straight back. A window // flush in the display's top-left corner already shares the half's top-left, so that stale // frame is recorded as where the half left it. The tests below put the window back on it From a96e2c388585d86cb349ed58dae63bc7531d520f Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 19:47:45 +0200 Subject: [PATCH 10/44] Take a window's display from where Loadstone put it A window whose minimum width exceeds the whole width of the display to its left spills back across the seam when carried into that display's right half, so its centre lands on the display it came from. The next Left Half then resolved that display, put the window into its left half, and further presses alternated between the two. While a window is still where Loadstone last put it, within a point of the placement's landing, its display is now the one holding the frame it was sent to, falling back to the display under its centre. Tiles, Center, and Next and Previous Display all resolve it this way, so they agree about where the window is. The fake window gains a minimum width that, like the grid, keeps the top-left corner. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 31 +++++++++++++------- LoadstoneTests/WindowDirectorTests.swift | 36 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index f80884d..b5cdb5e 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -28,8 +28,9 @@ final class WindowDirector { /// The tile frame Loadstone last sent each window to, and where the window reported itself /// once there, which differs when its app rounds or caps the size. That is how a second Left /// or Right Half knows the window is still in that half when it never fills the tile exactly. - /// The next tile replaces the entry; Restore, Center and a display move drop it, and so does - /// the window's process quitting, along with `originals`. + /// It also decides which display a window still sitting where it landed is on. The next tile + /// replaces the entry; Restore, Center and a display move drop it, and so does the window's + /// process quitting, along with `originals`. private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] @@ -63,7 +64,9 @@ final class WindowDirector { } } - /// Runs `command` on `window`, using the display under the window's centre. + /// Runs `command` on `window`, on the display it is on: the one Loadstone last put it on while + /// it is still where it landed, otherwise the one under its centre. Left or Right Half on a + /// window already in that half carries it on to the display beside it. @discardableResult func perform(_ command: WindowCommand, on window: some MovableWindow) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } @@ -71,7 +74,7 @@ final class WindowDirector { switch command { case .tile(let tile): - guard let display = display(for: current, in: displays) else { return .noDisplay } + guard let display = display(for: current, key: window.identity, in: displays) else { return .noDisplay } // Left or Right Half again on a window already in that half carries it on into the // opposite half of the display beside it. With nothing beside it, it stays put. if let continuation = tile.continuation, @@ -81,7 +84,7 @@ final class WindowDirector { } return place(window, in: tile, on: display, remembering: current) case .center: - guard let display = display(for: current, in: displays) else { return .noDisplay } + guard let display = display(for: current, key: window.identity, in: displays) else { return .noDisplay } return relocate(window, to: Layout.centered(current, in: display.visibleFrame), remembering: current) case .restore: // Restore must not record: it would store the current frame and then "restore" to it. @@ -115,7 +118,7 @@ final class WindowDirector { } private func move(_ window: some MovableWindow, current: CGRect, delta: Int, in displays: [Display]) -> CommandOutcome { - guard let display = display(for: current, in: displays), + guard let display = display(for: current, key: window.identity, in: displays), let neighbor = ScreenGeometry.neighbor(of: display, delta: delta, in: displays) else { return .noDisplay } guard neighbor != display else { return .noOtherDisplay } return relocate(window, to: Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame), remembering: current) @@ -180,10 +183,18 @@ final class WindowDirector { originals[key] = current } - /// The display under the window's centre, or the primary display when the window is off - /// every display (after a disconnect) so it can still be brought back. - private func display(for frame: CGRect, in displays: [Display]) -> Display? { - ScreenGeometry.display(containing: frame, in: displays) ?? displays.first + /// The display a window at `frame` is on. While it is still where Loadstone last put it, that + /// is the display holding the frame it was sent to: a window held wider than that display + /// spills onto the next, and its centre can land there, which would send the next command + /// from the wrong display. Otherwise the display under the window's centre, or the primary + /// display when the window is off every display (after a disconnect) so it can still be + /// brought back. + private func display(for frame: CGRect, key: WindowIdentity?, in displays: [Display]) -> Display? { + if let key, let last = placements[key], frame.isWithinAPoint(of: last.landed), + let placedOn = ScreenGeometry.display(containing: last.target, in: displays) { + return placedOn + } + return ScreenGeometry.display(containing: frame, in: displays) ?? displays.first } private func report(_ outcome: CommandOutcome, for command: WindowCommand, pid: pid_t?) { diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index ecdb448..4771894 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -12,6 +12,9 @@ final class WindowDirectorTests: XCTestCase { /// Set to act like Terminal or iTerm2, which round a window's size down to whole character /// cells and keep its top-left corner, so it never fills a tile exactly. var grid: CGSize? + /// Set to act like an app that will not make the window narrower than this. Like the grid, + /// it keeps the top-left corner, so the window grows to the right. + var minimumWidth: CGFloat? /// Set to act like an app that applies a frame only after the write has returned, so the /// frame read straight back is still the old one. `catchUp()` applies it. var appliesLate = false @@ -32,6 +35,9 @@ final class WindowDirectorTests: XCTestCase { let height = (frame.height / grid.height).rounded(.down) * grid.height landed = CGRect(x: frame.minX, y: frame.maxY - height, width: width, height: height) } + if let minimumWidth { + landed.size.width = max(landed.width, minimumWidth) + } if appliesLate { pending = landed } else { @@ -252,6 +258,36 @@ final class WindowDirectorTests: XCTestCase { ]) } + func testRepeatedLeftHalfKeepsAWindowWiderThanTheDisplayToItsLeftOnThatDisplay() { + // The left display is 1440 wide, and this window will not go below 1501. In the right + // half of it, which starts at x = -720, the window reaches past the seam at x = 0: its + // centre is at -720 + 1501 / 2 = 30.5, on primary. It still belongs to the left display. + let window = FakeWindow(frame: CGRect(x: 100, y: 100, width: 1501, height: 600)) + window.minimumWidth = 1501 + let director = WindowDirector(displays: { [self.primary, self.right, self.left] }) + + for _ in 0..<4 { director.perform(.tile(.leftHalf), on: window) } + + XCTAssertEqual(window.writes, [ + Tile.leftHalf.frame(in: primary.visibleFrame), + Tile.rightHalf.frame(in: left.visibleFrame), + Tile.leftHalf.frame(in: left.visibleFrame), + Tile.leftHalf.frame(in: left.visibleFrame), + ]) + } + + func testNextDisplayMovesAWindowOnFromTheDisplayItWasPlacedOn() throws { + // As above, the right half of the left display leaves this window's centre on primary. + let window = FakeWindow(frame: CGRect(x: -1400, y: 100, width: 1501, height: 600)) + window.minimumWidth = 1501 + let director = WindowDirector(displays: { [self.primary, self.right, self.left] }) + director.perform(.tile(.rightHalf), on: window) + let placed = try XCTUnwrap(window.cocoaFrame) + + director.perform(.nextDisplay, on: window) + XCTAssertEqual(window.writes.last, Layout.mapped(placed, from: left.visibleFrame, to: primary.visibleFrame)) + } + func testAWindowMovedSinceItWasPlacedGoesBackIntoTheHalfInsteadOfContinuing() { let window = FakeWindow(frame: floating) let director = makeDirector() From daa470feae3ba550749ad898675a3f30ffd7ef82 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 19:49:12 +0200 Subject: [PATCH 11/44] Read a window's identity and a tile's frame once per command On an AXWindow, identity is computed through AX calls, with a title read as well when the window id is unavailable, and one tile command read it up to four times: in the display lookup, in isPlaced, in apply's restore bookkeeping, and in place. perform and snap now read it once and pass the key down to place, isPlaced, apply, rememberIfNeeded, relocate, move and the display lookup. Each tile's frame is also computed once: isPlaced and place take the target frame instead of recomputing it from the tile and display. place() records or drops the placement in two plain branches rather than through a flatMap and a map over optionals. A window id cannot change with a write, so for it nothing changes. A title-based identity is now read only before the write, and the Restore frame and the placement are recorded under that title. A window whose title follows its size, as Terminal's default title does, goes by another title once resized, so neither is found again. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 85 +++++++++++++++-------------- 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index b5cdb5e..0ff6a88 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -70,36 +70,40 @@ final class WindowDirector { @discardableResult func perform(_ command: WindowCommand, on window: some MovableWindow) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } + // Read once and passed down: on an AXWindow it is computed through AX calls, with a + // title read as well when the window id is unavailable. + let key = window.identity let displays = self.displays() switch command { case .tile(let tile): - guard let display = display(for: current, key: window.identity, in: displays) else { return .noDisplay } + guard let display = display(for: current, key: key, in: displays) else { return .noDisplay } + let target = tile.frame(in: display.visibleFrame) // Left or Right Half again on a window already in that half carries it on into the // opposite half of the display beside it. With nothing beside it, it stays put. if let continuation = tile.continuation, - isPlaced(window, in: tile, on: display, current: current), + isPlaced(current, in: target, key: key), let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { - return place(window, in: continuation.landing, on: beside, remembering: current) + return place(window, key: key, at: continuation.landing.frame(in: beside.visibleFrame), remembering: current) } - return place(window, in: tile, on: display, remembering: current) + return place(window, key: key, at: target, remembering: current) case .center: - guard let display = display(for: current, key: window.identity, in: displays) else { return .noDisplay } - return relocate(window, to: Layout.centered(current, in: display.visibleFrame), remembering: current) + guard let display = display(for: current, key: key, in: displays) else { return .noDisplay } + return relocate(window, key: key, to: Layout.centered(current, in: display.visibleFrame), remembering: current) case .restore: // Restore must not record: it would store the current frame and then "restore" to it. // The memory is dropped only once the window has actually accepted the old frame, so // an app that refuses the write can still be restored on a later attempt. - guard let key = window.identity, let original = originals[key] else { + guard let key, let original = originals[key] else { return .nothingToRestore } - let outcome = relocate(window, to: original, remembering: nil) + let outcome = relocate(window, key: key, to: original, remembering: nil) if outcome == .moved { originals.removeValue(forKey: key) } return outcome case .nextDisplay: - return move(window, current: current, delta: 1, in: displays) + return move(window, key: key, current: current, delta: 1, in: displays) case .previousDisplay: - return move(window, current: current, delta: -1, in: displays) + return move(window, key: key, current: current, delta: -1, in: displays) } } @@ -108,7 +112,7 @@ final class WindowDirector { @discardableResult func snap(_ tile: Tile, window: some MovableWindow, on display: Display) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } - return place(window, in: tile, on: display, remembering: current) + return place(window, key: window.identity, at: tile.frame(in: display.visibleFrame), remembering: current) } /// Drops everything remembered about the windows of a process that has quit. @@ -117,27 +121,28 @@ final class WindowDirector { placements = placements.filter { $0.key.pid != pid } } - private func move(_ window: some MovableWindow, current: CGRect, delta: Int, in displays: [Display]) -> CommandOutcome { - guard let display = display(for: current, key: window.identity, in: displays), + private func move(_ window: some MovableWindow, key: WindowIdentity?, current: CGRect, delta: Int, in displays: [Display]) -> CommandOutcome { + guard let display = display(for: current, key: key, in: displays), let neighbor = ScreenGeometry.neighbor(of: display, delta: delta, in: displays) else { return .noDisplay } guard neighbor != display else { return .noOtherDisplay } - return relocate(window, to: Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame), remembering: current) + let mapped = Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame) + return relocate(window, key: key, to: mapped, remembering: current) } /// Writes a frame that is not a tile (Center, Restore, a display move) and, once the window /// accepts it, drops the window's placement. The placement says where a tile put the window; /// once Loadstone has moved it anywhere else it can only mislead, most of all when it holds /// a stale read-back that the window can later be put back on. - private func relocate(_ window: some MovableWindow, to frame: CGRect, remembering previous: CGRect?) -> CommandOutcome { - let outcome = apply(frame, to: window, remembering: previous) - if outcome == .moved, let key = window.identity { placements.removeValue(forKey: key) } + private func relocate(_ window: some MovableWindow, key: WindowIdentity?, to frame: CGRect, remembering previous: CGRect?) -> CommandOutcome { + let outcome = apply(frame, to: window, key: key, remembering: previous) + if outcome == .moved, let key { placements.removeValue(forKey: key) } return outcome } - /// Lays `tile` into `display`, then records where the window actually ended up, so that - /// pressing the same tile again can tell the window has not moved since. Recorded only once - /// the window's top-left corner is where it was sent: an app that rounds or caps a size keeps - /// that corner, while one still reporting its old frame has not moved yet. + /// Sends the window to `target`, a tile's frame, then records where it actually ended up, so + /// that pressing the same tile again can tell the window has not moved since. Recorded only + /// once the window's top-left corner is where it was sent: an app that rounds or caps a size + /// keeps that corner, while one still reporting its old frame has not moved yet. /// /// An app that applies the frame late, and whose old frame already shared the target's /// top-left, reads back that old frame and has it recorded. Once Loadstone moves the window @@ -145,25 +150,25 @@ final class WindowDirector { /// double-click, a drag), the window is carried on at the next press. Closing that would /// take watching the window, with an AXObserver dropping the entry when it moves anywhere /// but its landing. - private func place(_ window: some MovableWindow, in tile: Tile, on display: Display, remembering previous: CGRect) -> CommandOutcome { - let target = tile.frame(in: display.visibleFrame) - let outcome = apply(target, to: window, remembering: previous) - if outcome == .moved, let key = window.identity { - let landed = window.cocoaFrame.flatMap { $0.sharesTopLeft(with: target) ? $0 : nil } - placements[key] = landed.map { Placement(target: target, landed: $0) } + private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, remembering previous: CGRect) -> CommandOutcome { + let outcome = apply(target, to: window, key: key, remembering: previous) + guard outcome == .moved, let key else { return outcome } + if let landed = window.cocoaFrame, landed.sharesTopLeft(with: target) { + placements[key] = Placement(target: target, landed: landed) + } else { + placements.removeValue(forKey: key) } return outcome } - /// Whether `window` is already in `tile`: it fills the tile, or it is where it landed the last - /// time Loadstone sent it to this same frame. The second covers apps that never fill a tile - /// exactly, rounding to a character grid (Terminal, iTerm2) or holding a minimum or fixed - /// width. A display change (a new resolution, the Dock moving) changes the tile's frame, so - /// the window is refitted before it is carried on. - private func isPlaced(_ window: some MovableWindow, in tile: Tile, on display: Display, current: CGRect) -> Bool { - let target = tile.frame(in: display.visibleFrame) + /// Whether a window at `current` is already in the tile whose frame is `target`: it fills the + /// tile, or it is where it landed the last time Loadstone sent it to this same frame. The + /// second covers apps that never fill a tile exactly, rounding to a character grid (Terminal, + /// iTerm2) or holding a minimum or fixed width. A display change (a new resolution, the Dock + /// moving) changes the tile's frame, so the window is refitted before it is carried on. + private func isPlaced(_ current: CGRect, in target: CGRect, key: WindowIdentity?) -> Bool { if current.isWithinAPoint(of: target) { return true } - guard let key = window.identity, let last = placements[key] else { return false } + guard let key, let last = placements[key] else { return false } return last.target.isWithinAPoint(of: target) && current.isWithinAPoint(of: last.landed) } @@ -171,16 +176,16 @@ final class WindowDirector { /// once the window has accepted the write. Recording afterwards rather than before is what /// keeps a refused frame, or a command that never ran at all, from leaving behind a restore /// entry that a later Restore would act on. - private func apply(_ frame: CGRect, to window: some MovableWindow, remembering previous: CGRect?) -> CommandOutcome { + private func apply(_ frame: CGRect, to window: some MovableWindow, key: WindowIdentity?, remembering previous: CGRect?) -> CommandOutcome { let error = window.setCocoaFrame(frame) guard error == .success else { return .rejected(error) } - if let previous { rememberIfNeeded(window, current: previous) } + if let previous { rememberIfNeeded(previous, for: key) } return .moved } - private func rememberIfNeeded(_ window: some MovableWindow, current: CGRect) { - guard let key = window.identity, originals[key] == nil else { return } - originals[key] = current + private func rememberIfNeeded(_ frame: CGRect, for key: WindowIdentity?) { + guard let key, originals[key] == nil else { return } + originals[key] = frame } /// The display a window at `frame` is on. While it is still where Loadstone last put it, that From b5b6752af83c2997d28c0e90852d7b13475f38da Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 19:50:59 +0200 Subject: [PATCH 12/44] Log why a half is or is not carried on to the next display A move that succeeds is silent, so a report that ctrl-opt-left does not reach the other screen left no trace of why. Four info lines, readable with the README's log command, cover the decisions that explain it: - carrying on, naming the destination display's frame, so a rejection logged after it has context; - in the half with no display beside it on that side, the edge of the desk; - a placement that does not match, with the window's frame, the frame it was sent to, where it landed, and the tile's frame now; not on a first press with nothing recorded; - a read-back off the target's top-left, so nothing was recorded. place and isPlaced take the command for their lines; a drag snap passes its tile's. The continuation comment now says what happens with nothing beside the window: the half is applied again, with no beep. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 32 ++++++++++++++++++----------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 0ff6a88..2c1e8f7 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -80,13 +80,17 @@ final class WindowDirector { guard let display = display(for: current, key: key, in: displays) else { return .noDisplay } let target = tile.frame(in: display.visibleFrame) // Left or Right Half again on a window already in that half carries it on into the - // opposite half of the display beside it. With nothing beside it, it stays put. - if let continuation = tile.continuation, - isPlaced(current, in: target, key: key), - let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { - return place(window, key: key, at: continuation.landing.frame(in: beside.visibleFrame), remembering: current) + // opposite half of the display beside it. With nothing beside it, the half is applied + // again, which leaves the window where it is, with no beep. + if let continuation = tile.continuation, isPlaced(current, in: target, key: key, for: command) { + if let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { + Log.ax.info("\(command.id, privacy: .public): carrying on to the display at \(String(describing: beside.frame), privacy: .public)") + let landing = continuation.landing.frame(in: beside.visibleFrame) + return place(window, key: key, at: landing, remembering: current, for: command) + } + Log.ax.info("\(command.id, privacy: .public): in the half, with no display to the \(String(describing: continuation.toward), privacy: .public) of \(String(describing: display.frame), privacy: .public)") } - return place(window, key: key, at: target, remembering: current) + return place(window, key: key, at: target, remembering: current, for: command) case .center: guard let display = display(for: current, key: key, in: displays) else { return .noDisplay } return relocate(window, key: key, to: Layout.centered(current, in: display.visibleFrame), remembering: current) @@ -112,7 +116,7 @@ final class WindowDirector { @discardableResult func snap(_ tile: Tile, window: some MovableWindow, on display: Display) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } - return place(window, key: window.identity, at: tile.frame(in: display.visibleFrame), remembering: current) + return place(window, key: window.identity, at: tile.frame(in: display.visibleFrame), remembering: current, for: .tile(tile)) } /// Drops everything remembered about the windows of a process that has quit. @@ -150,12 +154,14 @@ final class WindowDirector { /// double-click, a drag), the window is carried on at the next press. Closing that would /// take watching the window, with an AXObserver dropping the entry when it moves anywhere /// but its landing. - private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, remembering previous: CGRect) -> CommandOutcome { + private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, remembering previous: CGRect, for command: WindowCommand) -> CommandOutcome { let outcome = apply(target, to: window, key: key, remembering: previous) guard outcome == .moved, let key else { return outcome } - if let landed = window.cocoaFrame, landed.sharesTopLeft(with: target) { - placements[key] = Placement(target: target, landed: landed) + let readBack = window.cocoaFrame + if let readBack, readBack.sharesTopLeft(with: target) { + placements[key] = Placement(target: target, landed: readBack) } else { + Log.ax.info("\(command.id, privacy: .public): read back \(readBack.map(String.init(describing:)) ?? "no frame", privacy: .public), off the top-left of \(String(describing: target), privacy: .public), so the placement is not recorded") placements.removeValue(forKey: key) } return outcome @@ -166,10 +172,12 @@ final class WindowDirector { /// second covers apps that never fill a tile exactly, rounding to a character grid (Terminal, /// iTerm2) or holding a minimum or fixed width. A display change (a new resolution, the Dock /// moving) changes the tile's frame, so the window is refitted before it is carried on. - private func isPlaced(_ current: CGRect, in target: CGRect, key: WindowIdentity?) -> Bool { + private func isPlaced(_ current: CGRect, in target: CGRect, key: WindowIdentity?, for command: WindowCommand) -> Bool { if current.isWithinAPoint(of: target) { return true } guard let key, let last = placements[key] else { return false } - return last.target.isWithinAPoint(of: target) && current.isWithinAPoint(of: last.landed) + if last.target.isWithinAPoint(of: target) && current.isWithinAPoint(of: last.landed) { return true } + Log.ax.info("\(command.id, privacy: .public): last placement does not match: the window is at \(String(describing: current), privacy: .public), was sent to \(String(describing: last.target), privacy: .public) and landed at \(String(describing: last.landed), privacy: .public); the tile is now \(String(describing: target), privacy: .public)") + return false } /// Writes `frame`, then records `previous` as the frame Restore should return to — but only From 03f28963e7655c5518360cf5021f846d58a4864c Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 19:51:57 +0200 Subject: [PATCH 13/44] Correct the director's comments on placements and near-misses isWithinAPoint said a half carried by Next Display lands only a fraction of a point off. From an odd width W1 onto an even W2 it is off by W2 / (2 * W1), over a point once the new display is more than twice as wide (1201 onto 2560 is 1.07pt), and the next press then refits the window; from an even width onto an odd one the tile's own flooring leaves it half a point off. The comment now gives both. isPlaced listed windows of a fixed width among those that never fill a tile. For a size AX reports unsettable the AX size write fails, and AXWindow returns that error from its first write, so such a window is never placed; the cases are a minimum width or an app that accepts a size write and ignores it. place() says a read-back that misses the corner drops any earlier entry, and that an app rounding or capping a size normally keeps the corner. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 2c1e8f7..513c5ba 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -146,7 +146,8 @@ final class WindowDirector { /// Sends the window to `target`, a tile's frame, then records where it actually ended up, so /// that pressing the same tile again can tell the window has not moved since. Recorded only /// once the window's top-left corner is where it was sent: an app that rounds or caps a size - /// keeps that corner, while one still reporting its old frame has not moved yet. + /// normally keeps that corner, while one still reporting its old frame has not moved yet. A + /// read-back that misses the corner drops any earlier entry rather than leaving it standing. /// /// An app that applies the frame late, and whose old frame already shared the target's /// top-left, reads back that old frame and has it recorded. Once Loadstone moves the window @@ -169,9 +170,10 @@ final class WindowDirector { /// Whether a window at `current` is already in the tile whose frame is `target`: it fills the /// tile, or it is where it landed the last time Loadstone sent it to this same frame. The - /// second covers apps that never fill a tile exactly, rounding to a character grid (Terminal, - /// iTerm2) or holding a minimum or fixed width. A display change (a new resolution, the Dock - /// moving) changes the tile's frame, so the window is refitted before it is carried on. + /// second covers apps that never fill a tile exactly: rounding to a character grid (Terminal, + /// iTerm2), holding a minimum width, or accepting a size write and ignoring it. A display + /// change (a new resolution, the Dock moving) changes the tile's frame, so the window is + /// refitted before it is carried on. private func isPlaced(_ current: CGRect, in target: CGRect, key: WindowIdentity?, for command: WindowCommand) -> Bool { if current.isWithinAPoint(of: target) { return true } guard let key, let last = placements[key] else { return false } @@ -236,7 +238,10 @@ final class WindowDirector { private extension CGRect { /// Every edge within a point of `other`'s. Next Display maps a window proportionally, so a - /// half carried onto a width that does not divide by 2 lands a fraction of a point off. + /// half carried to another display can miss that display's half where a width is odd: by + /// half a point onto an odd width, and by W2 / (2 * W1) from an odd width W1 onto W2. That + /// is over a point once the new display is more than twice as wide (1201 onto 2560 is + /// 1.07pt), and the next press then refits the window instead of carrying it on. func isWithinAPoint(of other: CGRect) -> Bool { abs(minX - other.minX) <= 1 && abs(maxX - other.maxX) <= 1 && abs(minY - other.minY) <= 1 && abs(maxY - other.maxY) <= 1 From c4fa7456335d7064843930b16696209656608e0f Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 19:56:35 +0200 Subject: [PATCH 14/44] Bring the docs and comments in line with the placement fixes CLAUDE.md gives the half continuation its own bullet after Director and describes it as the code has it: what counts as already in the half, when a placement is recorded and what drops it, the refit after a display change, the display taken from the recorded target while the window is on its landing, staying put with no beep at the edge of the desk, the info-level log lines, and the late-applying-app residual. The Geometry bullet names Display.Side and says a display above or below never counts as beside. The README says the window stays where it is at the edge of the desk without a beep, that the menu items and rebound keys behave the same, and the troubleshooting entry covers windows that size in steps, Dock and layout changes, screens touching only at a corner, and reading the log. Three comments followed: every tile, not only a half, records where it put a window; the WindowIdentity doc had an ambiguous "it"; and the title-identity fallback logged at launch affects repeated halves as well as Restore. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 7 ++++--- Loadstone/App/AppDelegate.swift | 2 +- Loadstone/Core/AXWindow.swift | 2 +- Loadstone/Core/MovableWindow.swift | 2 +- README.md | 4 ++-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 572fb6f..dd1b0be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `, 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 @@ -58,9 +58,10 @@ 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. Left/Right Half on a window already in that half continue into the opposite half of the display beside it (`Tile.continuation`), staying put at the edge of the desk. "Already in" means the tile's frame to within a point, or where the window landed the last time Loadstone sent it to that same frame (`placements`, recorded for drag snaps too and pruned with the restore ledger), because Terminal-style apps round to a character grid and never fill a tile. A placement is recorded only once the window's top-left corner has reached the target, since an app that applies a frame late reports its old one when read straight back; and a display change alters the tile's frame, so the window is refitted before it is carried on. Drag snaps pass the display the gesture ended on explicitly; the hotkey path uses the display under the window's centre. +- **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; hotkeys and the menu use the display holding the window's placement while the window is still on its landing (below), otherwise the display under its centre, or the primary when it is on none. The identity is read once per command, since on `AXWindow` it costs AX calls. +- **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`). "Already in" means within a point of the tile's frame, or at the landing recorded when Loadstone last sent the window to that same frame: `placements` keeps the target and the read-back per `WindowIdentity`, because Terminal-style apps round to a character grid and others hold a minimum width, so they never fill a tile. Every tile records one, drag snaps included, but only once the read-back has the target's top-left corner, since an app that applies a frame late reports its old one when read straight back; a read-back off that corner drops the entry instead. The next tile overwrites the entry; Restore, Center and Next/Previous Display drop it once the window accepts their frame; so does `forgetWindows(ofProcess:)` when the owning process quits. A refused write leaves the placement and the ledger as they were. A display change (resolution, Dock, arrangement) alters the tile's frame, so the first press refits the window and only the next carries it on. While a window is within a point of its landing, its display is the one holding the recorded target: a window held wider than that display spills onto the next, and its centre can sit there. At the edge of the desk the half is applied again, so the window stays put with no beep. Info-level `Log.ax` lines say why a press did or did not carry on: the destination display, nothing on that side, a placement that no longer matches, a read-back off the top-left. Residual: a late-applying app whose old frame already shares the target's top-left has that old frame recorded as its landing, and if something outside Loadstone (a drag, a title-bar double-click) returns the window to exactly that frame, the next press carries it on; closing that needs an `AXObserver` dropping the entry when the window moves. - **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. `ScreenGeometry.adjacent(to:toward:in:)` is the directional one behind the Left/Right Half continuation: only a display wholly to that side and sharing some height counts, and it never wraps, so a laptop under a monitor has nothing to its left. +- **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 diff --git a/Loadstone/App/AppDelegate.swift b/Loadstone/App/AppDelegate.swift index 7117d84..192086f 100644 --- a/Loadstone/App/AppDelegate.swift +++ b/Loadstone/App/AppDelegate.swift @@ -24,7 +24,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { dragMonitor = EdgeDragMonitor() dragMonitor?.start() - // The director's memory of each window (Restore's frame, where a half put it) is keyed by + // 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( diff --git a/Loadstone/Core/AXWindow.swift b/Loadstone/Core/AXWindow.swift index fb7fb25..cc069cd 100644 --- a/Loadstone/Core/AXWindow.swift +++ b/Loadstone/Core/AXWindow.swift @@ -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) diff --git a/Loadstone/Core/MovableWindow.swift b/Loadstone/Core/MovableWindow.swift index e7a74e9..6a9366f 100644 --- a/Loadstone/Core/MovableWindow.swift +++ b/Loadstone/Core/MovableWindow.swift @@ -1,7 +1,7 @@ import ApplicationServices /// How `WindowDirector` recognises a window across commands, so Restore and a repeated half can -/// find what it remembers about it. +/// 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. diff --git a/README.md b/README.md index 1a1d2b8..b76dff6 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ U I J K ``` -Press `⌃⌥←` again on a window that is already in the left half and it moves on to the right half of the screen to its left, so repeated presses walk it across your screens one half at a time. `⌃⌥→` does the same to the right. At the outer edge of the desk the window stays put, and a screen above or below does not count. +Press the Left Half shortcut (`⌃⌥←` by default) again on a window that is already in the left half and it moves on to the right half of the screen to its left, so repeated presses walk it across your screens one half at a time. Right Half (`⌃⌥→`) does the same to the right, and the menu items behave the same way. At the outer edge of the desk the window stays where it is, with no beep, and a screen above or below does not count. ## Drag to snap @@ -152,7 +152,7 @@ Menu bar → Loadstone → **Loadstone Help** opens this page. **Next / previous display beeps.** Only one display is attached. -**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not. And the window has to be in that half already: if you have moved it since, or the screen's resolution has changed, the first press puts it back into the half. +**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not, and neither does one touching only at a corner. And the window has to be in that half already: if you have moved it since, or the resolution, the Dock, or the screen layout has changed, the first press puts it back into the half and the next one carries it on. Windows that size in steps, such as Terminal, never fill the half exactly, so they count as in the half only if Loadstone put them there since it last started. The log (below) says why a press did not carry the window on; those are info messages, so keep `--info`. **macOS’s own edge tiling is off after Loadstone crashed or was force-quit.** Loadstone turns it off while running and back on when it quits normally; after an unclean exit, simply launching Loadstone again restores it. To put it back by hand, use System Settings → **Desktop & Dock** → **Windows**. From e80bdb6d6b001f069f64fade2e368394de785bbe Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 20:30:02 +0200 Subject: [PATCH 15/44] Pin the odd-width near-miss of a half carried by Next Display Next Display maps a window proportionally, so a left half of an odd width W1 lands W2 / (2 * W1) short of the half on an even W2, and |W2 / (2 * W1) - 1/2| short on an odd W2, where the half's own flooring leans the same way. isWithinAPoint's comment gave only the first and said it goes over a point once the new display is more than twice as wide, which does not hold onto an odd width: that takes three times. Two tests carry a half from a display 1201 wide onto one 2560 wide, 1.07pt off, which the next Left Half refits, and onto one 2561 wide, 0.57pt off, which it carries on. Every other display move in the suite starts from an even width, so neither side of the boundary was exercised. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 9 +++--- LoadstoneTests/WindowDirectorTests.swift | 37 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 513c5ba..0b9c954 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -238,10 +238,11 @@ final class WindowDirector { private extension CGRect { /// Every edge within a point of `other`'s. Next Display maps a window proportionally, so a - /// half carried to another display can miss that display's half where a width is odd: by - /// half a point onto an odd width, and by W2 / (2 * W1) from an odd width W1 onto W2. That - /// is over a point once the new display is more than twice as wide (1201 onto 2560 is - /// 1.07pt), and the next press then refits the window instead of carrying it on. + /// half it carries to another display misses that display's half where a width is odd: by + /// half a point from an even width onto an odd one, and from an odd width W1 by + /// W2 / (2 * W1) onto an even W2 (over a point once W2 > 2 * W1) and by + /// |W2 / (2 * W1) - 1/2| onto an odd W2 (over a point once W2 > 3 * W1). Over a point, the + /// next press refits the window instead of carrying it on. func isWithinAPoint(of other: CGRect) -> Bool { abs(minX - other.minX) <= 1 && abs(maxX - other.maxX) <= 1 && abs(minY - other.minY) <= 1 && abs(maxY - other.maxY) <= 1 diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 4771894..bbf233b 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -331,6 +331,43 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.cocoaFrame, Tile.rightHalf.frame(in: primary.visibleFrame)) } + func testAHalfCarriedFromAnOddWidthOntoAnEvenOneMoreThanTwiceAsWideIsRefittedFirst() throws { + let (window, director, _, wide) = leftHalfCarriedByNextDisplay(onto: 2560) + let carried = try XCTUnwrap(window.cocoaFrame) + XCTAssertEqual(Tile.leftHalf.frame(in: wide.visibleFrame).width - carried.width, 2560 / 2402, accuracy: 1e-9, + "W2 / (2 * W1) short of the half, over a point") + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: wide.visibleFrame)) + } + + func testAHalfCarriedFromAnOddWidthOntoAnOddOneMoreThanTwiceAsWideStillCountsAsThatHalf() throws { + let (window, director, narrow, wide) = leftHalfCarriedByNextDisplay(onto: 2561) + let carried = try XCTUnwrap(window.cocoaFrame) + XCTAssertEqual(Tile.leftHalf.frame(in: wide.visibleFrame).width - carried.width, 2561 / 2402 - 0.5, accuracy: 1e-9, + "the half's own flooring takes half a point off the miss, leaving it under a point") + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.rightHalf.frame(in: narrow.visibleFrame)) + } + + /// A window in the left half of a display 1201 wide, moved by Next Display onto a display + /// `width` wide to its right. + private func leftHalfCarriedByNextDisplay(onto width: CGFloat) -> (window: FakeWindow, director: WindowDirector, narrow: Display, wide: Display) { + let narrow = Display( + frame: CGRect(x: 0, y: 0, width: 1201, height: 901), + visibleFrame: CGRect(x: 0, y: 0, width: 1201, height: 877) + ) + let wide = Display( + frame: CGRect(x: 1201, y: 0, width: width, height: 1441), + visibleFrame: CGRect(x: 1201, y: 0, width: width, height: 1415) + ) + let window = FakeWindow(frame: Tile.leftHalf.frame(in: narrow.visibleFrame)) + let director = WindowDirector(displays: { [narrow, wide] }) + director.perform(.nextDisplay, on: window) + return (window, director, narrow, wide) + } + func testARefusedHalfIsNotRememberedAsPlaced() { // The refused window stays where it was, which shares the half's top-left, so only the // refusal itself keeps that frame from being recorded as where the half left it. From e019e6a7577f3a5e440ff50845cddc40d23989ab Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 20:31:22 +0200 Subject: [PATCH 16/44] Pin the display lookup and when a placement survives or goes Three checks in the director had no test, and deleting any of them left the suite passing: - display(for:) takes the recorded display only while the window is still where it landed. Without that, a window tiled on one display and dragged to another by hand is thrown back to the first by the next half. - Center, Restore and a display move drop the placement only once the window accepts their frame. A refusal that leaves the window where it was must keep it, so the next Left Half still carries a step-sized window on. - A tile whose read-back misses the target's top-left drops the earlier entry. Left standing, a stale landing the window is later returned to by hand carries it to another display instead of halving it. The refused-continuation test asserted that the refused window had not moved, which only checks the fake; that assertion goes. Its Restore half stays: it fails if a refusal overwrites the Restore frame. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 43 ++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index bbf233b..7878510 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -298,6 +298,16 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testAWindowMovedToAnotherDisplaySinceItWasPlacedIsTiledOnTheDisplayItIsNowOn() { + let window = FakeWindow(frame: floating) + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.cocoaFrame = original // dragged onto primary by hand + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: primary.visibleFrame)) + } + func testRestoreUndoesAContinuation() { let window = FakeWindow(frame: Tile.leftHalf.frame(in: right.visibleFrame)) let director = makeDirector() @@ -386,11 +396,9 @@ final class WindowDirectorTests: XCTestCase { window.grid = terminalCell let director = makeDirector() director.snap(.leftHalf, window: window, on: right) - let placed = window.cocoaFrame window.rejectWith = .cannotComplete XCTAssertEqual(director.perform(.tile(.leftHalf), on: window), .rejected(.cannotComplete)) - XCTAssertEqual(window.cocoaFrame, placed) window.rejectWith = nil director.perform(.tile(.leftHalf), on: window) @@ -399,6 +407,22 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, floating) } + func testARefusedCommandThatLeftTheWindowWhereItWasKeepsThePlacement() { + for command in [WindowCommand.center, .restore, .nextDisplay] { + let window = FakeWindow(frame: floating) + window.grid = terminalCell + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + + window.rejectWith = .cannotComplete + XCTAssertEqual(director.perform(command, on: window), .rejected(.cannotComplete), "\(command)") + window.rejectWith = nil + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.rightHalf.frame(in: primary.visibleFrame), "\(command)") + } + } + func testAWindowStillReportingItsOldFrameIsNotRememberedAsPlaced() { // Read straight back, the frame is still the old one. Dragged back to exactly that frame, // the window would be thrown onto the next display by a remembered placement. @@ -494,6 +518,21 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testATileWhoseReadBackMissesForgetsWhereAHalfPutTheWindow() { + let window = FakeWindow(frame: flushTopLeft) + window.appliesLate = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.catchUp() + // Read straight back, the window is still in the left half, off the right half's top-left. + director.perform(.tile(.rightHalf), on: window) + window.catchUp() + window.cocoaFrame = flushTopLeft // dragged back by hand + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + func testAHalfWhoseDisplayHasChangedSinceIsRefittedRatherThanCarriedOn() { // A new resolution leaves the window where it was, which is no longer the left half. var desk = [primary, right] From a0b9e25b83059f53c3dd72fe111f2a6eed2a95e9 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 20:32:34 +0200 Subject: [PATCH 17/44] Drop a window's placement in apply, and look it up in one place Every frame a window accepts from Loadstone invalidates where a tile last left it. That rule lived in two places: relocate() for Center, Restore and the display moves, and the branch of place() taken when a read-back misses the target's top-left. apply() is the one call both make, so it drops the placement once the write is accepted; place() then records a fresh one when the read-back has the target's top-left, and relocate() goes away. display(for:) and isPlaced both looked up the placement and checked that the window was still within a point of where it landed. They now share standingPlacement(for:at:), which returns the entry only then. The comment on dropping the placement no longer says the record "can only mislead". It costs a step-sized or minimum-width window one refit press after, say, a Next then Previous Display round trip back to where it landed, which is the price of never carrying a window on from a record that a later accepted write has made stale. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 56 +++++++++++++++-------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 0b9c954..c043724 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -28,9 +28,9 @@ final class WindowDirector { /// The tile frame Loadstone last sent each window to, and where the window reported itself /// once there, which differs when its app rounds or caps the size. That is how a second Left /// or Right Half knows the window is still in that half when it never fills the tile exactly. - /// It also decides which display a window still sitting where it landed is on. The next tile - /// replaces the entry; Restore, Center and a display move drop it, and so does the window's - /// process quitting, along with `originals`. + /// It also decides which display a window still sitting where it landed is on. Every frame + /// the window accepts from Loadstone drops the entry, and a tile then records a new one; the + /// window's process quitting drops it too, along with `originals`. private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] @@ -93,7 +93,7 @@ final class WindowDirector { return place(window, key: key, at: target, remembering: current, for: command) case .center: guard let display = display(for: current, key: key, in: displays) else { return .noDisplay } - return relocate(window, key: key, to: Layout.centered(current, in: display.visibleFrame), remembering: current) + return apply(Layout.centered(current, in: display.visibleFrame), to: window, key: key, remembering: current) case .restore: // Restore must not record: it would store the current frame and then "restore" to it. // The memory is dropped only once the window has actually accepted the old frame, so @@ -101,7 +101,7 @@ final class WindowDirector { guard let key, let original = originals[key] else { return .nothingToRestore } - let outcome = relocate(window, key: key, to: original, remembering: nil) + let outcome = apply(original, to: window, key: key, remembering: nil) if outcome == .moved { originals.removeValue(forKey: key) } return outcome case .nextDisplay: @@ -130,24 +130,14 @@ final class WindowDirector { let neighbor = ScreenGeometry.neighbor(of: display, delta: delta, in: displays) else { return .noDisplay } guard neighbor != display else { return .noOtherDisplay } let mapped = Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame) - return relocate(window, key: key, to: mapped, remembering: current) - } - - /// Writes a frame that is not a tile (Center, Restore, a display move) and, once the window - /// accepts it, drops the window's placement. The placement says where a tile put the window; - /// once Loadstone has moved it anywhere else it can only mislead, most of all when it holds - /// a stale read-back that the window can later be put back on. - private func relocate(_ window: some MovableWindow, key: WindowIdentity?, to frame: CGRect, remembering previous: CGRect?) -> CommandOutcome { - let outcome = apply(frame, to: window, key: key, remembering: previous) - if outcome == .moved, let key { placements.removeValue(forKey: key) } - return outcome + return apply(mapped, to: window, key: key, remembering: current) } /// Sends the window to `target`, a tile's frame, then records where it actually ended up, so /// that pressing the same tile again can tell the window has not moved since. Recorded only /// once the window's top-left corner is where it was sent: an app that rounds or caps a size - /// normally keeps that corner, while one still reporting its old frame has not moved yet. A - /// read-back that misses the corner drops any earlier entry rather than leaving it standing. + /// normally keeps that corner, while one still reporting its old frame has not moved yet, so + /// a read-back that misses the corner leaves the window with no entry. /// /// An app that applies the frame late, and whose old frame already shared the target's /// top-left, reads back that old frame and has it recorded. Once Loadstone moves the window @@ -163,7 +153,6 @@ final class WindowDirector { placements[key] = Placement(target: target, landed: readBack) } else { Log.ax.info("\(command.id, privacy: .public): read back \(readBack.map(String.init(describing:)) ?? "no frame", privacy: .public), off the top-left of \(String(describing: target), privacy: .public), so the placement is not recorded") - placements.removeValue(forKey: key) } return outcome } @@ -176,19 +165,27 @@ final class WindowDirector { /// refitted before it is carried on. private func isPlaced(_ current: CGRect, in target: CGRect, key: WindowIdentity?, for command: WindowCommand) -> Bool { if current.isWithinAPoint(of: target) { return true } + if standingPlacement(for: key, at: current)?.target.isWithinAPoint(of: target) == true { return true } guard let key, let last = placements[key] else { return false } - if last.target.isWithinAPoint(of: target) && current.isWithinAPoint(of: last.landed) { return true } Log.ax.info("\(command.id, privacy: .public): last placement does not match: the window is at \(String(describing: current), privacy: .public), was sent to \(String(describing: last.target), privacy: .public) and landed at \(String(describing: last.landed), privacy: .public); the tile is now \(String(describing: target), privacy: .public)") return false } - /// Writes `frame`, then records `previous` as the frame Restore should return to — but only - /// once the window has accepted the write. Recording afterwards rather than before is what - /// keeps a refused frame, or a command that never ran at all, from leaving behind a restore - /// entry that a later Restore would act on. + /// Writes `frame`, then records `previous` as the frame Restore should return to and drops + /// the window's placement — but only once the window has accepted the write. Recording + /// afterwards rather than before is what keeps a refused frame, or a command that never ran + /// at all, from leaving behind a restore entry that a later Restore would act on. + /// + /// The placement said where a tile left the window, which has now been sent somewhere else; + /// for a tile, `place` records a fresh one. After any other write, a step-sized or + /// minimum-width window brought back to where it landed (a Next then Previous Display round + /// trip, say) takes one refit press before it carries on. That is the price of never + /// carrying a window on from a stale record, such as the old frame an app that applies + /// frames late reads back, which Restore returns the window to. private func apply(_ frame: CGRect, to window: some MovableWindow, key: WindowIdentity?, remembering previous: CGRect?) -> CommandOutcome { let error = window.setCocoaFrame(frame) guard error == .success else { return .rejected(error) } + if let key { placements.removeValue(forKey: key) } if let previous { rememberIfNeeded(previous, for: key) } return .moved } @@ -205,13 +202,20 @@ final class WindowDirector { /// display when the window is off every display (after a disconnect) so it can still be /// brought back. private func display(for frame: CGRect, key: WindowIdentity?, in displays: [Display]) -> Display? { - if let key, let last = placements[key], frame.isWithinAPoint(of: last.landed), - let placedOn = ScreenGeometry.display(containing: last.target, in: displays) { + if let placement = standingPlacement(for: key, at: frame), + let placedOn = ScreenGeometry.display(containing: placement.target, in: displays) { return placedOn } return ScreenGeometry.display(containing: frame, in: displays) ?? displays.first } + /// The window's placement while the window at `frame` is still where it landed, to within a + /// point; nil once something has moved or resized it since. + private func standingPlacement(for key: WindowIdentity?, at frame: CGRect) -> Placement? { + guard let key, let placement = placements[key], frame.isWithinAPoint(of: placement.landed) else { return nil } + return placement + } + private func report(_ outcome: CommandOutcome, for command: WindowCommand, pid: pid_t?) { let pid = pid ?? 0 switch outcome { From a4cf3079c5f1251ab8bc504a960961b2ec2fb1aa Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 20:34:12 +0200 Subject: [PATCH 18/44] Drop the placement when a refused write still moved the window AXWindow writes the size, then the position, then the size again, and returns the first error. An app that takes the size and then refuses the position, or times out on it, leaves the window resized at its old top-left while the command reports a refusal. A refusal kept the placement, so if that resize put the window back on a stale landing (an app that applies frames late, whose old frame shared the half's top-left), the next Left Half carried it to another display with no hand move at all. A refused write now reads the frame again when there is a placement at stake, and drops it unless the window is still within a point of where it was before the write. A refusal that left the window untouched keeps the placement, as before. The fake window can act like that app: it takes the new size at its old top-left and fails the write. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 41 ++++++++++++++++-------- LoadstoneTests/WindowDirectorTests.swift | 26 +++++++++++++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index c043724..a931089 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -86,14 +86,14 @@ final class WindowDirector { if let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { Log.ax.info("\(command.id, privacy: .public): carrying on to the display at \(String(describing: beside.frame), privacy: .public)") let landing = continuation.landing.frame(in: beside.visibleFrame) - return place(window, key: key, at: landing, remembering: current, for: command) + return place(window, key: key, at: landing, from: current, for: command) } Log.ax.info("\(command.id, privacy: .public): in the half, with no display to the \(String(describing: continuation.toward), privacy: .public) of \(String(describing: display.frame), privacy: .public)") } - return place(window, key: key, at: target, remembering: current, for: command) + return place(window, key: key, at: target, from: current, for: command) case .center: guard let display = display(for: current, key: key, in: displays) else { return .noDisplay } - return apply(Layout.centered(current, in: display.visibleFrame), to: window, key: key, remembering: current) + return apply(Layout.centered(current, in: display.visibleFrame), to: window, key: key, from: current, remembering: current) case .restore: // Restore must not record: it would store the current frame and then "restore" to it. // The memory is dropped only once the window has actually accepted the old frame, so @@ -101,7 +101,7 @@ final class WindowDirector { guard let key, let original = originals[key] else { return .nothingToRestore } - let outcome = apply(original, to: window, key: key, remembering: nil) + let outcome = apply(original, to: window, key: key, from: current, remembering: nil) if outcome == .moved { originals.removeValue(forKey: key) } return outcome case .nextDisplay: @@ -116,7 +116,7 @@ final class WindowDirector { @discardableResult func snap(_ tile: Tile, window: some MovableWindow, on display: Display) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } - return place(window, key: window.identity, at: tile.frame(in: display.visibleFrame), remembering: current, for: .tile(tile)) + return place(window, key: window.identity, at: tile.frame(in: display.visibleFrame), from: current, for: .tile(tile)) } /// Drops everything remembered about the windows of a process that has quit. @@ -130,7 +130,7 @@ final class WindowDirector { let neighbor = ScreenGeometry.neighbor(of: display, delta: delta, in: displays) else { return .noDisplay } guard neighbor != display else { return .noOtherDisplay } let mapped = Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame) - return apply(mapped, to: window, key: key, remembering: current) + return apply(mapped, to: window, key: key, from: current, remembering: current) } /// Sends the window to `target`, a tile's frame, then records where it actually ended up, so @@ -145,8 +145,8 @@ final class WindowDirector { /// double-click, a drag), the window is carried on at the next press. Closing that would /// take watching the window, with an AXObserver dropping the entry when it moves anywhere /// but its landing. - private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, remembering previous: CGRect, for command: WindowCommand) -> CommandOutcome { - let outcome = apply(target, to: window, key: key, remembering: previous) + private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, from current: CGRect, for command: WindowCommand) -> CommandOutcome { + let outcome = apply(target, to: window, key: key, from: current, remembering: current) guard outcome == .moved, let key else { return outcome } let readBack = window.cocoaFrame if let readBack, readBack.sharesTopLeft(with: target) { @@ -171,10 +171,11 @@ final class WindowDirector { return false } - /// Writes `frame`, then records `previous` as the frame Restore should return to and drops - /// the window's placement — but only once the window has accepted the write. Recording - /// afterwards rather than before is what keeps a refused frame, or a command that never ran - /// at all, from leaving behind a restore entry that a later Restore would act on. + /// Writes `frame` to the window at `current`, then records `previous` as the frame Restore + /// should return to and drops the window's placement — but only once the window has accepted + /// the write. Recording afterwards rather than before is what keeps a refused frame, or a + /// command that never ran at all, from leaving behind a restore entry that a later Restore + /// would act on. /// /// The placement said where a tile left the window, which has now been sent somewhere else; /// for a tile, `place` records a fresh one. After any other write, a step-sized or @@ -182,9 +183,21 @@ final class WindowDirector { /// trip, say) takes one refit press before it carries on. That is the price of never /// carrying a window on from a stale record, such as the old frame an app that applies /// frames late reads back, which Restore returns the window to. - private func apply(_ frame: CGRect, to window: some MovableWindow, key: WindowIdentity?, remembering previous: CGRect?) -> CommandOutcome { + /// + /// A refused write keeps the placement while the window is still at `current`. Part of the + /// write can take before the refusal, though: AXWindow sets the size before the position, so + /// an app that takes the size and then refuses the position, or times out on it, leaves the + /// window resized at its old top-left, which can be exactly a stale frame recorded as where + /// it landed. So a refusal reads the frame again and drops the placement unless the window is + /// still within a point of `current`. + private func apply(_ frame: CGRect, to window: some MovableWindow, key: WindowIdentity?, from current: CGRect, remembering previous: CGRect?) -> CommandOutcome { let error = window.setCocoaFrame(frame) - guard error == .success else { return .rejected(error) } + guard error == .success else { + if let key, placements[key] != nil, window.cocoaFrame?.isWithinAPoint(of: current) != true { + placements.removeValue(forKey: key) + } + return .rejected(error) + } if let key { placements.removeValue(forKey: key) } if let previous { rememberIfNeeded(previous, for: key) } return .moved diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 7878510..49ff92f 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -18,6 +18,10 @@ final class WindowDirectorTests: XCTestCase { /// Set to act like an app that applies a frame only after the write has returned, so the /// frame read straight back is still the old one. `catchUp()` applies it. var appliesLate = false + /// Set to act like AXWindow writing to an app that takes the size and then refuses the + /// position, or times out on it: the window has the new size at its old top-left corner, + /// and the write fails with this error. + var refusesPositionWith: AXError? private var pending: CGRect? init(frame: CGRect, identity: WindowIdentity? = .cgWindow(1, pid: 42)) { @@ -29,6 +33,10 @@ final class WindowDirectorTests: XCTestCase { func setCocoaFrame(_ frame: CGRect) -> AXError { if let rejectWith { return rejectWith } writes.append(frame) + if let refusesPositionWith, let old = cocoaFrame { + cocoaFrame = CGRect(x: old.minX, y: old.maxY - frame.height, width: frame.width, height: frame.height) + return refusesPositionWith + } var landed = frame if let grid { let width = (frame.width / grid.width).rounded(.down) * grid.width @@ -533,6 +541,24 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testARefusedWriteThatStillResizedTheWindowForgetsWhereAHalfPutIt() { + // Top Left's size takes and its position is refused, which leaves the window in the top-left + // quadrant: back on the old frame Left Half read back, without being moved by hand. + let topLeft = Tile.topLeft.frame(in: right.visibleFrame) + let window = FakeWindow(frame: topLeft) + window.appliesLate = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.catchUp() + window.appliesLate = false + window.refusesPositionWith = .cannotComplete + XCTAssertEqual(director.perform(.tile(.topLeft), on: window), .rejected(.cannotComplete)) + window.refusesPositionWith = nil + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + func testAHalfWhoseDisplayHasChangedSinceIsRefittedRatherThanCarriedOn() { // A new resolution leaves the window where it was, which is no longer the left half. var desk = [primary, right] From af12bc814b652cec764975133bf3435db7bfbffb Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 20:36:56 +0200 Subject: [PATCH 19/44] Record under a title-based identity as it reads after the write When the window id is unavailable a window is known by its title, and Terminal's default title carries the window's size in character cells. Reading the identity once before the write stored the Restore frame and the placement under the title from before the resize, which the next command, looking the window up by the title it has then, never finds: Restore after Left Half returned nothingToRestore, and a Terminal window took an extra press on every display before it carried on. Lookups still use the one identity read before the write. Once a write is accepted, apply() reads a title-based identity again and records the Restore frame under it, and place() records the placement under it. A window id cannot change across a write, so it is not read again. The placement dropped is the one under the key the window was looked up by, so a window returned by hand to where a half left it, and to the title it had there, is not carried on from a record a display move should have cleared. The fake window can take its identity from a title that follows its size, as Terminal's does. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 40 ++++++++++++++------ LoadstoneTests/WindowDirectorTests.swift | 48 +++++++++++++++++++++++- 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index a931089..1d13fcf 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -70,8 +70,9 @@ final class WindowDirector { @discardableResult func perform(_ command: WindowCommand, on window: some MovableWindow) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } - // Read once and passed down: on an AXWindow it is computed through AX calls, with a - // title read as well when the window id is unavailable. + // Read once for every lookup and passed down: on an AXWindow it is computed through AX + // calls, with a title read as well when the window id is unavailable. `apply` reads a + // title-based one again after a write, to record under. let key = window.identity let displays = self.displays() @@ -93,7 +94,7 @@ final class WindowDirector { return place(window, key: key, at: target, from: current, for: command) case .center: guard let display = display(for: current, key: key, in: displays) else { return .noDisplay } - return apply(Layout.centered(current, in: display.visibleFrame), to: window, key: key, from: current, remembering: current) + return apply(Layout.centered(current, in: display.visibleFrame), to: window, key: key, from: current, remembering: current).outcome case .restore: // Restore must not record: it would store the current frame and then "restore" to it. // The memory is dropped only once the window has actually accepted the old frame, so @@ -101,7 +102,7 @@ final class WindowDirector { guard let key, let original = originals[key] else { return .nothingToRestore } - let outcome = apply(original, to: window, key: key, from: current, remembering: nil) + let outcome = apply(original, to: window, key: key, from: current, remembering: nil).outcome if outcome == .moved { originals.removeValue(forKey: key) } return outcome case .nextDisplay: @@ -130,7 +131,7 @@ final class WindowDirector { let neighbor = ScreenGeometry.neighbor(of: display, delta: delta, in: displays) else { return .noDisplay } guard neighbor != display else { return .noOtherDisplay } let mapped = Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame) - return apply(mapped, to: window, key: key, from: current, remembering: current) + return apply(mapped, to: window, key: key, from: current, remembering: current).outcome } /// Sends the window to `target`, a tile's frame, then records where it actually ended up, so @@ -146,15 +147,15 @@ final class WindowDirector { /// take watching the window, with an AXObserver dropping the entry when it moves anywhere /// but its landing. private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, from current: CGRect, for command: WindowCommand) -> CommandOutcome { - let outcome = apply(target, to: window, key: key, from: current, remembering: current) - guard outcome == .moved, let key else { return outcome } + let applied = apply(target, to: window, key: key, from: current, remembering: current) + guard applied.outcome == .moved, let key = applied.key else { return applied.outcome } let readBack = window.cocoaFrame if let readBack, readBack.sharesTopLeft(with: target) { placements[key] = Placement(target: target, landed: readBack) } else { Log.ax.info("\(command.id, privacy: .public): read back \(readBack.map(String.init(describing:)) ?? "no frame", privacy: .public), off the top-left of \(String(describing: target), privacy: .public), so the placement is not recorded") } - return outcome + return applied.outcome } /// Whether a window at `current` is already in the tile whose frame is `target`: it fills the @@ -190,17 +191,32 @@ final class WindowDirector { /// window resized at its old top-left, which can be exactly a stale frame recorded as where /// it landed. So a refusal reads the frame again and drops the placement unless the window is /// still within a point of `current`. - private func apply(_ frame: CGRect, to window: some MovableWindow, key: WindowIdentity?, from current: CGRect, remembering previous: CGRect?) -> CommandOutcome { + /// + /// Returns the outcome and the key the window goes by after the write, which the Restore + /// frame here and a tile's placement in `place` are recorded under. That is `key`, except + /// that a title-based one is read again once the write is accepted (`keyAfterWrite`); the + /// placement dropped is still the one under `key`, which the window was looked up by. + private func apply(_ frame: CGRect, to window: some MovableWindow, key: WindowIdentity?, from current: CGRect, remembering previous: CGRect?) -> (outcome: CommandOutcome, key: WindowIdentity?) { let error = window.setCocoaFrame(frame) guard error == .success else { if let key, placements[key] != nil, window.cocoaFrame?.isWithinAPoint(of: current) != true { placements.removeValue(forKey: key) } - return .rejected(error) + return (.rejected(error), key) } if let key { placements.removeValue(forKey: key) } - if let previous { rememberIfNeeded(previous, for: key) } - return .moved + let settled = keyAfterWrite(key, of: window) + if let previous { rememberIfNeeded(previous, for: settled) } + return (.moved, settled) + } + + /// The key the next command will look `window` up by, once a write under `key` has been + /// accepted. A window id cannot change with a write, but a title can: Terminal's default + /// title carries the window's size in character cells, so what is recorded under the title + /// from before a resize would never be found again. + private func keyAfterWrite(_ key: WindowIdentity?, of window: some MovableWindow) -> WindowIdentity? { + guard case .fallback = key else { return key } + return window.identity } private func rememberIfNeeded(_ frame: CGRect, for key: WindowIdentity?) { diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 49ff92f..29df12b 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -5,7 +5,11 @@ import ApplicationServices @MainActor final class WindowDirectorTests: XCTestCase { private final class FakeWindow: MovableWindow { - var identity: WindowIdentity? + var identity: WindowIdentity? { + guard titleFollowsSize, let cocoaFrame else { return assignedIdentity } + let cell = grid ?? CGSize(width: 1, height: 1) + return .fallback(pid: 42, title: "\(Int(cocoaFrame.width / cell.width))×\(Int(cocoaFrame.height / cell.height))") + } var cocoaFrame: CGRect? var writes: [CGRect] = [] var rejectWith: AXError? @@ -22,11 +26,16 @@ final class WindowDirectorTests: XCTestCase { /// position, or times out on it: the window has the new size at its old top-left corner, /// and the write fails with this error. var refusesPositionWith: AXError? + /// Set to act like Terminal when the window id is unavailable: the identity falls back to + /// the title, and Terminal's default title carries the window's size in character cells + /// ("80×24"), so a write that resizes the window renames it. + var titleFollowsSize = false + private let assignedIdentity: WindowIdentity? private var pending: CGRect? init(frame: CGRect, identity: WindowIdentity? = .cgWindow(1, pid: 42)) { cocoaFrame = frame - self.identity = identity + assignedIdentity = identity } @discardableResult @@ -202,6 +211,16 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(director.perform(.restore, on: window), .nothingToRestore) } + func testAWindowWhoseTitleFollowsItsSizeCanStillBeRestored() { + let window = FakeWindow(frame: floating) + window.titleFollowsSize = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + + XCTAssertEqual(director.perform(.restore, on: window), .moved) + XCTAssertEqual(window.cocoaFrame, floating) + } + func testARejectedCommandIsNotRememberedForRestore() { let window = FakeWindow(frame: original) let director = makeDirector() @@ -335,6 +354,31 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.rightHalf.frame(in: primary.visibleFrame)) } + func testAWindowWhoseTitleFollowsItsSizeCarriesOnAtTheSecondPress() { + let window = FakeWindow(frame: floating) + window.grid = terminalCell + window.titleFollowsSize = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.rightHalf.frame(in: primary.visibleFrame)) + } + + func testMovingToAnotherDisplayForgetsWhereAHalfPutAWindowWhoseTitleFollowsItsSize() throws { + let window = FakeWindow(frame: floating) + window.grid = terminalCell + window.titleFollowsSize = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + let landed = try XCTUnwrap(window.cocoaFrame) + director.perform(.nextDisplay, on: window) + window.cocoaFrame = landed // dragged back by hand, which gives it back that title + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + func testAHalfCarriedOverByNextDisplayStillCountsAsThatHalf() { let odd = Display( frame: CGRect(x: 1920, y: 0, width: 1201, height: 901), From df4d30cd0bb3192fc343963794feec3bbaa2ffd0 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 20:38:23 +0200 Subject: [PATCH 20/44] Move a window to another display from the one under its centre Next and Previous Display map the frame proportionally from the display the window is on to the one beside it. Taking that display from the placement differs from the centre only for a window held larger than its tile with its centre off that display, and mapped from a display narrower than itself the window comes out wider than the display it goes to. A 1501pt-wide window in the right half of a 1440pt display, sent on by Previous Display, was asked to be 2668pt wide on the display it wrapped to, and only 1280pt of that was on the desk. Display moves go by the display under the window's centre again. Tiles and Center keep the display the window was placed on, which is what stops a wide window bouncing between two displays; a test now pins that for Center, which nothing did. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 32 ++++++++++++++++-------- LoadstoneTests/WindowDirectorTests.swift | 22 +++++++++++++--- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 1d13fcf..2df3de1 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -64,9 +64,10 @@ final class WindowDirector { } } - /// Runs `command` on `window`, on the display it is on: the one Loadstone last put it on while - /// it is still where it landed, otherwise the one under its centre. Left or Right Half on a - /// window already in that half carries it on to the display beside it. + /// Runs `command` on `window`. A tile or Center works on the display Loadstone last put the + /// window on while it is still where it landed, otherwise the one under its centre; Next and + /// Previous Display move it on from the one under its centre. Left or Right Half on a window + /// already in that half carries it on to the display beside it. @discardableResult func perform(_ command: WindowCommand, on window: some MovableWindow) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } @@ -126,8 +127,12 @@ final class WindowDirector { placements = placements.filter { $0.key.pid != pid } } + /// Moves the window `delta` displays along from the one under its centre, mapping its frame + /// proportionally. That can differ from the display Loadstone put it on only when the window + /// is held wider than that display and mostly off it, and mapped from a display narrower + /// than itself the window would grow on the way, most of it past the edge of the desk. private func move(_ window: some MovableWindow, key: WindowIdentity?, current: CGRect, delta: Int, in displays: [Display]) -> CommandOutcome { - guard let display = display(for: current, key: key, in: displays), + guard let display = display(under: current, in: displays), let neighbor = ScreenGeometry.neighbor(of: display, delta: delta, in: displays) else { return .noDisplay } guard neighbor != display else { return .noOtherDisplay } let mapped = Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame) @@ -224,18 +229,23 @@ final class WindowDirector { originals[key] = frame } - /// The display a window at `frame` is on. While it is still where Loadstone last put it, that - /// is the display holding the frame it was sent to: a window held wider than that display - /// spills onto the next, and its centre can land there, which would send the next command - /// from the wrong display. Otherwise the display under the window's centre, or the primary - /// display when the window is off every display (after a disconnect) so it can still be - /// brought back. + /// The display a tile or Center works on for a window at `frame`. While the window is still + /// where Loadstone last put it, that is the display holding the frame it was sent to: a + /// window held wider than that display spills onto the next, and its centre can land there, + /// which would send the next half from the wrong display and bounce the window between the + /// two. Otherwise the display under its centre. private func display(for frame: CGRect, key: WindowIdentity?, in displays: [Display]) -> Display? { if let placement = standingPlacement(for: key, at: frame), let placedOn = ScreenGeometry.display(containing: placement.target, in: displays) { return placedOn } - return ScreenGeometry.display(containing: frame, in: displays) ?? displays.first + return display(under: frame, in: displays) + } + + /// The display under the centre of a window at `frame`, or the primary display when the + /// window is off every display (after a disconnect) so it can still be brought back. + private func display(under frame: CGRect, in displays: [Display]) -> Display? { + ScreenGeometry.display(containing: frame, in: displays) ?? displays.first } /// The window's placement while the window at `frame` is still where it landed, to within a diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 29df12b..d278c8b 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -303,7 +303,7 @@ final class WindowDirectorTests: XCTestCase { ]) } - func testNextDisplayMovesAWindowOnFromTheDisplayItWasPlacedOn() throws { + func testCenterWorksOnTheDisplayAWiderWindowWasPlacedOn() throws { // As above, the right half of the left display leaves this window's centre on primary. let window = FakeWindow(frame: CGRect(x: -1400, y: 100, width: 1501, height: 600)) window.minimumWidth = 1501 @@ -311,8 +311,24 @@ final class WindowDirectorTests: XCTestCase { director.perform(.tile(.rightHalf), on: window) let placed = try XCTUnwrap(window.cocoaFrame) - director.perform(.nextDisplay, on: window) - XCTAssertEqual(window.writes.last, Layout.mapped(placed, from: left.visibleFrame, to: primary.visibleFrame)) + director.perform(.center, on: window) + XCTAssertEqual(window.writes.last, Layout.centered(placed, in: left.visibleFrame)) + } + + func testDisplayMovesMapAWindowFromTheDisplayUnderItsCentre() throws { + // The right half of the left display leaves this window mostly on primary. Mapped from the + // left display, which is narrower than the window, it would grow on the way: Previous + // Display would ask for 2668pt of the right-hand display, most of it off the desk. + for (command, destination) in [(WindowCommand.nextDisplay, right), (.previousDisplay, left)] { + let window = FakeWindow(frame: CGRect(x: -1400, y: 100, width: 1501, height: 600)) + window.minimumWidth = 1501 + let director = WindowDirector(displays: { [self.primary, self.right, self.left] }) + director.perform(.tile(.rightHalf), on: window) + let placed = try XCTUnwrap(window.cocoaFrame) + + director.perform(command, on: window) + XCTAssertEqual(window.writes.last, Layout.mapped(placed, from: primary.visibleFrame, to: destination.visibleFrame), "\(command)") + } } func testAWindowMovedSinceItWasPlacedGoesBackIntoTheHalfInsteadOfContinuing() { From b0cd6864c0532fa0e1f66d592f220b6371eaeca6 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 20:41:37 +0200 Subject: [PATCH 21/44] Log why a half was not carried on only where the press does not say isPlaced logged whenever a window had any placement that did not match, including one from another tile, which made it a predicate with a side effect, and it said nothing about the case that most needs a word: a Terminal window already in the half with nothing recorded, say after a relaunch, which a press only refits. A placement now records the tile that made it: for a continuation the half the window moved into, for a drag the tile it snapped to. isPlaced is a plain predicate. When Left or Right Half does not carry a window on, perform logs at info level only when the placement by this same tile no longer holds (the window has moved since or the display has changed), with the window's frame, where it was sent and landed, and the tile's frame now; or when no placement stands and the window has the half's top-left corner without filling it, saying it is fitted into the half first and the next press carries it on. A first press, or one on a window where another tile put it, logs nothing. place() takes the tile instead of a command, so a drag snap passes its own; its read-back line tells a frame it could not read from one off the target's top-left. The frame a continuation sends the window to is called onward, leaving "landed" for where the window reported itself. place()'s comment adds the second case its single read-back leaves open: a late-applying app's window held wider than the display it is carried to never has the move recorded, and carried to the left it bounces back. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 103 ++++++++++++++++++---------- 1 file changed, 66 insertions(+), 37 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 2df3de1..0724783 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -25,16 +25,20 @@ final class WindowDirector { /// dropped when their process quits (`forgetWindows(ofProcess:)`) because macOS reuses /// window ids and a new window could otherwise inherit a stale memory. private var originals: [WindowIdentity: CGRect] = [:] - /// The tile frame Loadstone last sent each window to, and where the window reported itself - /// once there, which differs when its app rounds or caps the size. That is how a second Left - /// or Right Half knows the window is still in that half when it never fills the tile exactly. - /// It also decides which display a window still sitting where it landed is on. Every frame - /// the window accepts from Loadstone drops the entry, and a tile then records a new one; the - /// window's process quitting drops it too, along with `originals`. + /// The tile Loadstone last put each window in, the frame it sent the window to, and where + /// the window reported itself once there (where it landed), which differs when its app rounds + /// or caps the size. That is how a second Left or Right Half knows the window is still in + /// that half when it never fills the tile exactly, and which display a tile or Center works + /// on while the window is still where it landed. Every frame the window accepts from + /// Loadstone drops the entry, and a tile then records a new one; the window's process + /// quitting drops it too, along with `originals`. private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] private struct Placement { + /// The tile that sent the window there: for a continuation, the half it moved into rather + /// than the one pressed. + let tile: Tile let target: CGRect let landed: CGRect } @@ -83,16 +87,21 @@ final class WindowDirector { let target = tile.frame(in: display.visibleFrame) // Left or Right Half again on a window already in that half carries it on into the // opposite half of the display beside it. With nothing beside it, the half is applied - // again, which leaves the window where it is, with no beep. - if let continuation = tile.continuation, isPlaced(current, in: target, key: key, for: command) { - if let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { - Log.ax.info("\(command.id, privacy: .public): carrying on to the display at \(String(describing: beside.frame), privacy: .public)") - let landing = continuation.landing.frame(in: beside.visibleFrame) - return place(window, key: key, at: landing, from: current, for: command) + // again, which leaves the window where it is, with no beep. A window not in the half + // is fitted into it, with a line in the log where the press alone does not say why. + if let continuation = tile.continuation { + if isPlaced(current, in: target, key: key) { + if let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { + Log.ax.info("\(command.id, privacy: .public): carrying on to the display at \(String(describing: beside.frame), privacy: .public)") + let onward = continuation.landing.frame(in: beside.visibleFrame) + return place(window, key: key, at: onward, by: continuation.landing, from: current) + } + Log.ax.info("\(command.id, privacy: .public): in the half, with no display to the \(String(describing: continuation.toward), privacy: .public) of \(String(describing: display.frame), privacy: .public)") + } else { + logWhyNotCarriedOn(tile, at: current, target: target, key: key) } - Log.ax.info("\(command.id, privacy: .public): in the half, with no display to the \(String(describing: continuation.toward), privacy: .public) of \(String(describing: display.frame), privacy: .public)") } - return place(window, key: key, at: target, from: current, for: command) + return place(window, key: key, at: target, by: tile, from: current) case .center: guard let display = display(for: current, key: key, in: displays) else { return .noDisplay } return apply(Layout.centered(current, in: display.visibleFrame), to: window, key: key, from: current, remembering: current).outcome @@ -118,7 +127,7 @@ final class WindowDirector { @discardableResult func snap(_ tile: Tile, window: some MovableWindow, on display: Display) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } - return place(window, key: window.identity, at: tile.frame(in: display.visibleFrame), from: current, for: .tile(tile)) + return place(window, key: window.identity, at: tile.frame(in: display.visibleFrame), by: tile, from: current) } /// Drops everything remembered about the windows of a process that has quit. @@ -139,26 +148,33 @@ final class WindowDirector { return apply(mapped, to: window, key: key, from: current, remembering: current).outcome } - /// Sends the window to `target`, a tile's frame, then records where it actually ended up, so - /// that pressing the same tile again can tell the window has not moved since. Recorded only - /// once the window's top-left corner is where it was sent: an app that rounds or caps a size - /// normally keeps that corner, while one still reporting its old frame has not moved yet, so - /// a read-back that misses the corner leaves the window with no entry. + /// Sends the window to `target`, the frame of `tile`, then records where it actually ended + /// up, so that pressing the same tile again can tell the window has not moved since. Recorded + /// only once the window's top-left corner is where it was sent: an app that rounds or caps a + /// size normally keeps that corner, while one still reporting its old frame has not moved + /// yet, so a read-back that misses the corner leaves the window with no entry. /// - /// An app that applies the frame late, and whose old frame already shared the target's - /// top-left, reads back that old frame and has it recorded. Once Loadstone moves the window - /// again the entry goes; but returned to exactly that frame by anything else (a title-bar - /// double-click, a drag), the window is carried on at the next press. Closing that would - /// take watching the window, with an AXObserver dropping the entry when it moves anywhere - /// but its landing. - private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, from current: CGRect, for command: WindowCommand) -> CommandOutcome { + /// That read-back is the one look this takes, which leaves two cases open, both from an app + /// that applies the frame late. If the window's old frame already shared the target's + /// top-left, that old frame is recorded as where it landed. Once Loadstone writes the window + /// again the entry goes, but put back on exactly that frame by anything else (a title-bar + /// double-click, a drag), the window is carried on at the next press. And a window carried on + /// to another display reads back its old frame, off the target's top-left, so the move goes + /// unrecorded; one held wider than that display then has its next half go by the display + /// under its centre, which is the one it came from, and it bounces between the two. Closing + /// either would take watching the window, with an AXObserver recording where it settles and + /// dropping the entry when it moves anywhere else. + private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, by tile: Tile, from current: CGRect) -> CommandOutcome { let applied = apply(target, to: window, key: key, from: current, remembering: current) guard applied.outcome == .moved, let key = applied.key else { return applied.outcome } - let readBack = window.cocoaFrame - if let readBack, readBack.sharesTopLeft(with: target) { - placements[key] = Placement(target: target, landed: readBack) + guard let readBack = window.cocoaFrame else { + Log.ax.info("\(tile.rawValue, privacy: .public): could not read the frame back, so the placement is not recorded") + return applied.outcome + } + if readBack.sharesTopLeft(with: target) { + placements[key] = Placement(tile: tile, target: target, landed: readBack) } else { - Log.ax.info("\(command.id, privacy: .public): read back \(readBack.map(String.init(describing:)) ?? "no frame", privacy: .public), off the top-left of \(String(describing: target), privacy: .public), so the placement is not recorded") + Log.ax.info("\(tile.rawValue, privacy: .public): read back \(String(describing: readBack), privacy: .public), off the top-left of \(String(describing: target), privacy: .public), so the placement is not recorded") } return applied.outcome } @@ -169,12 +185,25 @@ final class WindowDirector { /// iTerm2), holding a minimum width, or accepting a size write and ignoring it. A display /// change (a new resolution, the Dock moving) changes the tile's frame, so the window is /// refitted before it is carried on. - private func isPlaced(_ current: CGRect, in target: CGRect, key: WindowIdentity?, for command: WindowCommand) -> Bool { - if current.isWithinAPoint(of: target) { return true } - if standingPlacement(for: key, at: current)?.target.isWithinAPoint(of: target) == true { return true } - guard let key, let last = placements[key] else { return false } - Log.ax.info("\(command.id, privacy: .public): last placement does not match: the window is at \(String(describing: current), privacy: .public), was sent to \(String(describing: last.target), privacy: .public) and landed at \(String(describing: last.landed), privacy: .public); the tile is now \(String(describing: target), privacy: .public)") - return false + private func isPlaced(_ current: CGRect, in target: CGRect, key: WindowIdentity?) -> Bool { + current.isWithinAPoint(of: target) + || standingPlacement(for: key, at: current)?.target.isWithinAPoint(of: target) == true + } + + /// Logs why Left or Right Half is fitting a window into the half rather than carrying it on, + /// where the press alone does not say. Either the window has a placement by this same tile + /// that no longer holds, because the window has moved since or the display has changed; or, + /// with no placement standing, the window has the half's top-left corner without filling it, + /// like a Terminal window Loadstone has not put there since it started, or one whose record + /// another command dropped. A first press into the half, and one on a window where another + /// tile put it, need no explaining. + private func logWhyNotCarriedOn(_ tile: Tile, at current: CGRect, target: CGRect, key: WindowIdentity?) { + guard let key else { return } + if let last = placements[key], last.tile == tile { + Log.ax.info("\(tile.rawValue, privacy: .public): the last placement no longer holds: the window is at \(String(describing: current), privacy: .public), was sent to \(String(describing: last.target), privacy: .public) and landed at \(String(describing: last.landed), privacy: .public); the tile is now \(String(describing: target), privacy: .public)") + } else if standingPlacement(for: key, at: current) == nil, current.sharesTopLeft(with: target) { + Log.ax.info("\(tile.rawValue, privacy: .public): the window at \(String(describing: current), privacy: .public) has the top-left of \(String(describing: target), privacy: .public) without filling it, and nothing records Loadstone putting it there, so it is fitted into the half first and the next press carries it on") + } } /// Writes `frame` to the window at `current`, then records `previous` as the frame Restore From 414cab2698aad7ae68f148aca95cf2cbc3988fae Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 20:42:18 +0200 Subject: [PATCH 22/44] Tighten the comments on display moves and refused writes move()'s comment said the display a window was placed on differs from the one under its centre only for a window wider than that display. A window held wider than its tile is enough: one with a 1000pt minimum width in the right third of a 1440pt display has its centre on the next display over. apply()'s comment now says that a refused write keeps the placement only if the frame, read again, is within a point of where it was, which also covers a frame that can no longer be read. The comment in the partly-applied-write test is wrapped to the file's width. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 9 +++++---- LoadstoneTests/WindowDirectorTests.swift | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 0724783..a33650b 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -137,9 +137,10 @@ final class WindowDirector { } /// Moves the window `delta` displays along from the one under its centre, mapping its frame - /// proportionally. That can differ from the display Loadstone put it on only when the window - /// is held wider than that display and mostly off it, and mapped from a display narrower - /// than itself the window would grow on the way, most of it past the edge of the desk. + /// proportionally. The display Loadstone put it on differs from that one only for a window + /// held wider than its tile and mostly past that display's edge. Mapped from there, the + /// window would keep that overhang in proportion and grow on the way when that display is + /// the narrower, which can leave most of it off the desk. private func move(_ window: some MovableWindow, key: WindowIdentity?, current: CGRect, delta: Int, in displays: [Display]) -> CommandOutcome { guard let display = display(under: current, in: displays), let neighbor = ScreenGeometry.neighbor(of: display, delta: delta, in: displays) else { return .noDisplay } @@ -223,7 +224,7 @@ final class WindowDirector { /// write can take before the refusal, though: AXWindow sets the size before the position, so /// an app that takes the size and then refuses the position, or times out on it, leaves the /// window resized at its old top-left, which can be exactly a stale frame recorded as where - /// it landed. So a refusal reads the frame again and drops the placement unless the window is + /// it landed. So after a refusal the placement is kept only if the frame, read again, is /// still within a point of `current`. /// /// Returns the outcome and the key the window goes by after the write, which the Restore diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index d278c8b..3a23310 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -602,8 +602,8 @@ final class WindowDirectorTests: XCTestCase { } func testARefusedWriteThatStillResizedTheWindowForgetsWhereAHalfPutIt() { - // Top Left's size takes and its position is refused, which leaves the window in the top-left - // quadrant: back on the old frame Left Half read back, without being moved by hand. + // Top Left's size takes and its position is refused, which leaves the window in the + // top-left quadrant: back on the old frame Left Half read back, with no hand move. let topLeft = Tile.topLeft.frame(in: right.visibleFrame) let window = FakeWindow(frame: topLeft) window.appliesLate = true From 1a8205012c6d255539dec15a40d6be5513bb7075 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 20:55:42 +0200 Subject: [PATCH 23/44] Bring the docs and comments in line with the director's final shape CLAUDE.md's Continuation bullet ran to 324 words, repeated the doc comments, and listed the log lines one by one, so it would drift with them. It is now a short list of the rules an edit must keep: what counts as in the half, when a placement is recorded and when it goes (any accepted write, a refused one that moved the window anyway, the process quitting), the refit after a display change, which display tiles, Center and display moves go by, that info lines say why a half was not carried on, and the two late-apply residuals. The Director bullet says display moves go by the window's centre and that a title-based identity is read again after a write. The AX layer bullet said AXWindowTests run in CI; CI skips them, so they only run on a developer machine. A new Tile also has to cover frame(in:), an exhaustive switch; the WindowCommand doc and CLAUDE.md now list it. The README's troubleshooting entry adds that another Loadstone command clears the record for a step-sized window, even one that brings it back, and scopes the log promise to a window that looks to be in the half, rather than any press that does not carry a window on. The inverse-pair test said the opposite half brings the window straight back; beside a stacked pair it heads back the way it came but not always onto the same display. The comments on placements and on the fallback identity mention a refused write that moved the window and the title read again after a write. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 15 +++++++++++---- Loadstone/Core/MovableWindow.swift | 4 +++- Loadstone/Core/Tile.swift | 3 ++- Loadstone/Core/WindowDirector.swift | 5 +++-- LoadstoneTests/TileLayoutTests.swift | 6 ++++-- README.md | 2 +- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd1b0be..9e7dead 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,14 +58,21 @@ 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; hotkeys and the menu use the display holding the window's placement while the window is still on its landing (below), otherwise the display under its centre, or the primary when it is on none. The identity is read once per command, since on `AXWindow` it costs AX calls. -- **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`). "Already in" means within a point of the tile's frame, or at the landing recorded when Loadstone last sent the window to that same frame: `placements` keeps the target and the read-back per `WindowIdentity`, because Terminal-style apps round to a character grid and others hold a minimum width, so they never fill a tile. Every tile records one, drag snaps included, but only once the read-back has the target's top-left corner, since an app that applies a frame late reports its old one when read straight back; a read-back off that corner drops the entry instead. The next tile overwrites the entry; Restore, Center and Next/Previous Display drop it once the window accepts their frame; so does `forgetWindows(ofProcess:)` when the owning process quits. A refused write leaves the placement and the ledger as they were. A display change (resolution, Dock, arrangement) alters the tile's frame, so the first press refits the window and only the next carries it on. While a window is within a point of its landing, its display is the one holding the recorded target: a window held wider than that display spills onto the next, and its centre can sit there. At the edge of the desk the half is applied again, so the window stays put with no beep. Info-level `Log.ax` lines say why a press did or did not carry on: the destination display, nothing on that side, a placement that no longer matches, a read-back off the top-left. Residual: a late-applying app whose old frame already shares the target's top-left has that old frame recorded as its landing, and if something outside Loadstone (a drag, a title-bar double-click) returns the window to exactly that frame, the next press carries it on; closing that needs an `AXObserver` dropping the entry when the window moves. -- **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. +- **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, 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 top-left: a late-applying app reads back its old frame. + - 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 (AX sets the size before the position) and the process quitting. + - A display change 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, and would bounce between the two. Next/Previous Display go by the centre, since mapping from a display narrower than the window grows it. + - Info-level `Log.ax` lines say when a press carries a window on and, where the press alone does not show it, why not. + - 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; and a carry-over goes unrecorded, so a window wider than the display it reaches bounces back. +- **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`, `section`, and `continuation` cover it, then it appears everywhere. `WindowCommand.id` strings are the UserDefaults keys where users' custom shortcuts live (`KeyboardShortcuts_`); 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_`); 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` 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. diff --git a/Loadstone/Core/MovableWindow.swift b/Loadstone/Core/MovableWindow.swift index 6a9366f..c228252 100644 --- a/Loadstone/Core/MovableWindow.swift +++ b/Loadstone/Core/MovableWindow.swift @@ -7,7 +7,9 @@ enum WindowIdentity: Hashable, Sendable { /// 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 new title. case fallback(pid: pid_t, title: String) var pid: pid_t { diff --git a/Loadstone/Core/Tile.swift b/Loadstone/Core/Tile.swift index d913066..473b6e5 100644 --- a/Loadstone/Core/Tile.swift +++ b/Loadstone/Core/Tile.swift @@ -41,7 +41,8 @@ enum Tile: String, CaseIterable, Sendable { /// Everything a user can ask Loadstone to do to a window. This is the single source of truth /// for the hotkey list, the status-bar menu, and the Shortcuts settings pane: adding a case /// (or a `Tile`) fails to compile until `id`, `title`, `defaultShortcut`, and `section` cover -/// it (for a `Tile`, `continuation` too), and then it appears everywhere automatically. +/// it (for a `Tile`, `frame(in:)` and `continuation` too), and then it appears everywhere +/// automatically. enum WindowCommand: Hashable, Sendable { case tile(Tile) case center diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index a33650b..d8eecb9 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -30,8 +30,9 @@ final class WindowDirector { /// or caps the size. That is how a second Left or Right Half knows the window is still in /// that half when it never fills the tile exactly, and which display a tile or Center works /// on while the window is still where it landed. Every frame the window accepts from - /// Loadstone drops the entry, and a tile then records a new one; the window's process - /// quitting drops it too, along with `originals`. + /// Loadstone drops the entry, and a tile then records a new one; a refused frame that moved + /// the window anyway drops it, and so does the window's process quitting, along with + /// `originals`. private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] diff --git a/LoadstoneTests/TileLayoutTests.swift b/LoadstoneTests/TileLayoutTests.swift index 647769d..7b06597 100644 --- a/LoadstoneTests/TileLayoutTests.swift +++ b/LoadstoneTests/TileLayoutTests.swift @@ -105,8 +105,10 @@ final class TileLayoutTests: XCTestCase { } } - /// Pressing the opposite half after a carry must bring the window straight back, so every - /// continuation's landing has to lead back to the tile it came from, the other way. + /// Pressing the opposite half after a carry must send the window back the way it came, into + /// the tile it left, though not always on the same display: beside a stacked pair, `adjacent` + /// picks the one sharing more height. So every continuation's landing has to lead back to + /// the tile it came from, the other way. func testEveryContinuationLeadsBackTheOtherWay() { for tile in Tile.allCases { guard let there = tile.continuation else { continue } diff --git a/README.md b/README.md index b76dff6..4f9cb14 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Menu bar → Loadstone → **Loadstone Help** opens this page. **Next / previous display beeps.** Only one display is attached. -**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not, and neither does one touching only at a corner. And the window has to be in that half already: if you have moved it since, or the resolution, the Dock, or the screen layout has changed, the first press puts it back into the half and the next one carries it on. Windows that size in steps, such as Terminal, never fill the half exactly, so they count as in the half only if Loadstone put them there since it last started. The log (below) says why a press did not carry the window on; those are info messages, so keep `--info`. +**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not, and neither does one touching only at a corner. And the window has to be in that half already: if you have moved it since, or the resolution, the Dock, or the screen layout has changed, the first press puts it back into the half and the next one carries it on. Windows that size in steps, such as Terminal, never fill the half exactly, so they count as in the half only if Loadstone put them there since it last started and nothing has moved them since. That includes Loadstone’s own commands, even a next display followed by a previous display that brings the window back to the same spot. When a window that looks to be in the half is put back into it rather than carried on, the log (below) says why; those are info messages, so keep `--info`. **macOS’s own edge tiling is off after Loadstone crashed or was force-quit.** Loadstone turns it off while running and back on when it quits normally; after an unclean exit, simply launching Loadstone again restores it. To put it back by hand, use System Settings → **Desktop & Dock** → **Windows**. From 935e42d0d61f49f6082e4a772105e4f209e7beb6 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 21:27:07 +0200 Subject: [PATCH 24/44] Pin what a standing placement has to match to count as the half Two checks in the placement lookup had no test. A standing placement counts for a half only when the frame Loadstone sent the window to is the half's own; a window that Maximize, Top Left, Top Half, Left Third or Left Two Thirds left in place shares the half's top-left corner, and matching on that corner alone throws it onto the next display instead of halving it. And a placement stands only while the whole frame is within a point of where the window landed; matching on the top-left alone keeps it standing for a window resized by hand from its bottom-right corner, which the next press then carries on. One test covers each tile that shares a half's top-left, Top Right before Right Half included. Another resizes a halved window in place. Each fails under its mutation, and no other test does. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 3a23310..a666633 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -341,6 +341,40 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testAWindowResizedSinceItWasPlacedGoesBackIntoTheHalfInsteadOfContinuing() throws { + let window = FakeWindow(frame: floating) + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + let landed = try XCTUnwrap(window.cocoaFrame) + // Resized by hand from its bottom-right corner, which keeps the top-left where it landed. + window.cocoaFrame = CGRect(x: landed.minX, y: landed.maxY - 601, width: 901, height: 601) + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testAWindowAnotherTileLeftAtTheHalfsTopLeftGoesIntoTheHalfInsteadOfContinuing() { + // Each first tile shares the half's top-left corner, so the window still stands where + // that tile put it, with the corner the half would give it. Only a placement sent to the + // half's own frame counts as in the half. + let cases: [(first: Tile, half: Tile, start: CGRect, display: Display)] = [ + (.maximize, .leftHalf, floating, right), + (.topLeft, .leftHalf, floating, right), + (.topHalf, .leftHalf, floating, right), + (.leftThird, .leftHalf, floating, right), + (.leftTwoThirds, .leftHalf, floating, right), + (.topRight, .rightHalf, original, primary), + ] + for (first, half, start, display) in cases { + let window = FakeWindow(frame: start) + let director = makeDirector() + director.perform(.tile(first), on: window) + + director.perform(.tile(half), on: window) + XCTAssertEqual(window.writes.last, half.frame(in: display.visibleFrame), "\(first) then \(half)") + } + } + func testAWindowMovedToAnotherDisplaySinceItWasPlacedIsTiledOnTheDisplayItIsNowOn() { let window = FakeWindow(frame: floating) let director = makeDirector() From 402252738c56f220ef6262876df7961116e8b119 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 21:28:08 +0200 Subject: [PATCH 25/44] Pin that an unreadable frame after a refusal drops the placement After a refused write the frame is read again, and the placement is kept only if the window is still within a point of where it was. A hung app can time out on that read too, and then nothing shows the window has stayed put, so the placement goes; the next press refits the window instead of carrying it on from a record nobody could check. No test covered the unreadable case, and treating it as unmoved passed the suite. The fake window gains a flag that leaves its frame unreadable once it has refused a write. The test fails when a nil read keeps the placement, and no other test does. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 26 +++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index a666633..ee81cc3 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -30,6 +30,9 @@ final class WindowDirectorTests: XCTestCase { /// the title, and Terminal's default title carries the window's size in character cells /// ("80×24"), so a write that resizes the window renames it. var titleFollowsSize = false + /// Set to act like a hung app, whose frame cannot be read either once it has refused a + /// write: the refusal leaves `cocoaFrame` nil until the test sets it again. + var unreadableOnceRefused = false private let assignedIdentity: WindowIdentity? private var pending: CGRect? @@ -40,7 +43,10 @@ final class WindowDirectorTests: XCTestCase { @discardableResult func setCocoaFrame(_ frame: CGRect) -> AXError { - if let rejectWith { return rejectWith } + if let rejectWith { + if unreadableOnceRefused { cocoaFrame = nil } + return rejectWith + } writes.append(frame) if let refusesPositionWith, let old = cocoaFrame { cocoaFrame = CGRect(x: old.minX, y: old.maxY - frame.height, width: frame.width, height: frame.height) @@ -653,6 +659,24 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testARefusedWriteThatLeftTheFrameUnreadableForgetsWhereAHalfPutIt() throws { + // A hung app refuses Center and does not answer the read after it either, so nothing + // shows that the window is still where the half left it. + let window = FakeWindow(frame: floating) + window.grid = terminalCell + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + let landed = try XCTUnwrap(window.cocoaFrame) + window.rejectWith = .cannotComplete + window.unreadableOnceRefused = true + XCTAssertEqual(director.perform(.center, on: window), .rejected(.cannotComplete)) + window.rejectWith = nil + window.cocoaFrame = landed // answering again, and still where it landed + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + func testAHalfWhoseDisplayHasChangedSinceIsRefittedRatherThanCarriedOn() { // A new resolution leaves the window where it was, which is no longer the left half. var desk = [primary, right] From 553949ab29282bb925be6312250f40e158c091e0 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 21:30:23 +0200 Subject: [PATCH 26/44] Carry a window's Restore frame across when a write renames it A window known by its title, when the window id is unavailable, can go by a new title after every resize: Terminal's default title carries its size in character cells. The Restore frame is recorded under the title read after the write, but an entry under the title from before it was never moved. After Left Half and then Maximize, the first entry stayed under the half's title and Maximize recorded the Left Half frame under its own, so Restore returned the window to the half instead of to where it was before Loadstone first touched it. When a write that records a Restore frame is accepted, a frame recorded under the key the window was looked up by now moves to the key it goes by afterwards. Restore does not move it, since it removes its entry under the old key once the window accepts the frame. Nothing already recorded under the new key is overwritten: that entry is either the window's own from its first command, as when it has been resized by hand in between, or another same-titled window's, which the title cannot tell apart from this one. The first test failed before the fix, with Restore returning the Left Half frame. The second passed before it and fails when the frame moves across over an existing entry. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- Loadstone/Core/WindowDirector.swift | 23 ++++++++++++++------ LoadstoneTests/WindowDirectorTests.swift | 27 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9e7dead..c87378d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ 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. 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, because a title can follow the window's size (Terminal's does). +- **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 top-left: a late-applying app reads back its old frame. diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index d8eecb9..819e108 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -21,7 +21,8 @@ final class WindowDirector { /// Pre-Loadstone frame per window. Recorded on the first command of any kind (tile, center, /// display move), never overwritten, removed by `.restore`, so Restore returns the window - /// to where it was before Loadstone first touched it, not to the previous tile. Entries are + /// to where it was before Loadstone first touched it, not to the previous tile. A window + /// known by a title that a write changes takes its entry along to the new title. Entries are /// dropped when their process quits (`forgetWindows(ofProcess:)`) because macOS reuses /// window ids and a new window could otherwise inherit a stale memory. private var originals: [WindowIdentity: CGRect] = [:] @@ -230,8 +231,9 @@ final class WindowDirector { /// /// Returns the outcome and the key the window goes by after the write, which the Restore /// frame here and a tile's placement in `place` are recorded under. That is `key`, except - /// that a title-based one is read again once the write is accepted (`keyAfterWrite`); the - /// placement dropped is still the one under `key`, which the window was looked up by. + /// that a title-based one is read again once the write is accepted (`keyAfterWrite`). The + /// placement dropped is still the one under `key`, which the window was looked up by, and a + /// Restore frame recorded under `key` moves across to the new one. private func apply(_ frame: CGRect, to window: some MovableWindow, key: WindowIdentity?, from current: CGRect, remembering previous: CGRect?) -> (outcome: CommandOutcome, key: WindowIdentity?) { let error = window.setCocoaFrame(frame) guard error == .success else { @@ -242,7 +244,7 @@ final class WindowDirector { } if let key { placements.removeValue(forKey: key) } let settled = keyAfterWrite(key, of: window) - if let previous { rememberIfNeeded(previous, for: settled) } + if let previous { rememberIfNeeded(previous, for: settled, lookedUpBy: key) } return (.moved, settled) } @@ -255,9 +257,18 @@ final class WindowDirector { return window.identity } - private func rememberIfNeeded(_ frame: CGRect, for key: WindowIdentity?) { + /// Records `frame` under `key`, the key the window goes by after the write, as where Restore + /// returns it, unless something is recorded there already. A frame recorded under `oldKey`, + /// the one the window was looked up by, moves across instead: the two differ only for a + /// title-based identity whose write renamed the window, and that frame is the one the window's + /// first command recorded. + private func rememberIfNeeded(_ frame: CGRect, for key: WindowIdentity?, lookedUpBy oldKey: WindowIdentity?) { guard let key, originals[key] == nil else { return } - originals[key] = frame + if let oldKey, let first = originals.removeValue(forKey: oldKey) { + originals[key] = first + } else { + originals[key] = frame + } } /// The display a tile or Center works on for a window at `frame`. While the window is still diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index ee81cc3..1d815bf 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -227,6 +227,33 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.cocoaFrame, floating) } + func testRestoreAfterTwoResizesReturnsAWindowWhoseTitleFollowsItsSizeToWhereItStarted() { + let window = FakeWindow(frame: floating) + window.titleFollowsSize = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + director.perform(.tile(.maximize), on: window) + + XCTAssertEqual(director.perform(.restore, on: window), .moved) + XCTAssertEqual(window.cocoaFrame, floating) + } + + func testTheRestoreFrameOfAWindowWhoseTitleFollowsItsSizeIsNeverOverwritten() { + // Resized by hand, the window goes by a title nothing is recorded under, so Center records + // its frame there. Left Half then gives it back the title its first command recorded under, + // which already holds the frame from before Loadstone first touched it. + let window = FakeWindow(frame: floating) + window.titleFollowsSize = true + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.cocoaFrame = CGRect(x: 2300, y: 200, width: 901, height: 601) + director.perform(.center, on: window) + director.perform(.tile(.leftHalf), on: window) + + XCTAssertEqual(director.perform(.restore, on: window), .moved) + XCTAssertEqual(window.cocoaFrame, floating) + } + func testARejectedCommandIsNotRememberedForRestore() { let window = FakeWindow(frame: original) let director = makeDirector() From 179238a69dc178053c8436b8a99c1faeb6aa5f9e Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 21:33:25 +0200 Subject: [PATCH 27/44] Log a half press that leaves the window where it was Left or Right Half on a window that does not count as in the half fits the window into it, with an info line saying why where the press alone does not. Two cases were wrong. A window that another tile left where the half leaves it too, such as one with a 1300pt minimum width put in Left Third of a 2560pt display, stayed exactly where it was and nothing was logged, because a placement by another tile was standing; only the next press carried it on. And the line for a window with the half's top-left that does not fill it promised that the next press would carry it on, which at the edge of the desk, or on a single display, it does not. The reason is now worked out after the fit, from the frame read back, which place() returns when it records a placement. A window that reads back where it was is logged as such, with the note that it now counts as in the half; that covers the minimum-width window, a Terminal window after a relaunch, and one whose record another command dropped. The top-left line no longer says what the next press does. A refused fit logs only the refusal. Checked with log show after running probe tests that were not kept. Before, the second press on the minimum-width window logged nothing, and a Terminal window in the left half of the leftmost display was told the next press would carry it on, which then logged that there was no display to the left. After, that second press logs why the window did not move, and the Terminal window is told the next press counts it as in the half. Maximize then Left Half, a plain first press, and a refused press log nothing. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 59 +++++++++++++++++------------ 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 819e108..f596616 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -96,14 +96,14 @@ final class WindowDirector { if let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { Log.ax.info("\(command.id, privacy: .public): carrying on to the display at \(String(describing: beside.frame), privacy: .public)") let onward = continuation.landing.frame(in: beside.visibleFrame) - return place(window, key: key, at: onward, by: continuation.landing, from: current) + return place(window, key: key, at: onward, by: continuation.landing, from: current).outcome } Log.ax.info("\(command.id, privacy: .public): in the half, with no display to the \(String(describing: continuation.toward), privacy: .public) of \(String(describing: display.frame), privacy: .public)") } else { - logWhyNotCarriedOn(tile, at: current, target: target, key: key) + return fit(window, key: key, into: tile, at: target, from: current) } } - return place(window, key: key, at: target, by: tile, from: current) + return place(window, key: key, at: target, by: tile, from: current).outcome case .center: guard let display = display(for: current, key: key, in: displays) else { return .noDisplay } return apply(Layout.centered(current, in: display.visibleFrame), to: window, key: key, from: current, remembering: current).outcome @@ -129,7 +129,7 @@ final class WindowDirector { @discardableResult func snap(_ tile: Tile, window: some MovableWindow, on display: Display) -> CommandOutcome { guard let current = window.cocoaFrame else { return .frameUnreadable } - return place(window, key: window.identity, at: tile.frame(in: display.visibleFrame), by: tile, from: current) + return place(window, key: window.identity, at: tile.frame(in: display.visibleFrame), by: tile, from: current).outcome } /// Drops everything remembered about the windows of a process that has quit. @@ -167,19 +167,21 @@ final class WindowDirector { /// under its centre, which is the one it came from, and it bounces between the two. Closing /// either would take watching the window, with an AXObserver recording where it settles and /// dropping the entry when it moves anywhere else. - private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, by tile: Tile, from current: CGRect) -> CommandOutcome { + /// + /// Returns the outcome and, when a placement was recorded, where the window landed. + private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, by tile: Tile, from current: CGRect) -> (outcome: CommandOutcome, landed: CGRect?) { let applied = apply(target, to: window, key: key, from: current, remembering: current) - guard applied.outcome == .moved, let key = applied.key else { return applied.outcome } + guard applied.outcome == .moved, let key = applied.key else { return (applied.outcome, nil) } guard let readBack = window.cocoaFrame else { Log.ax.info("\(tile.rawValue, privacy: .public): could not read the frame back, so the placement is not recorded") - return applied.outcome + return (applied.outcome, nil) } - if readBack.sharesTopLeft(with: target) { - placements[key] = Placement(tile: tile, target: target, landed: readBack) - } else { + guard readBack.sharesTopLeft(with: target) else { Log.ax.info("\(tile.rawValue, privacy: .public): read back \(String(describing: readBack), privacy: .public), off the top-left of \(String(describing: target), privacy: .public), so the placement is not recorded") + return (applied.outcome, nil) } - return applied.outcome + placements[key] = Placement(tile: tile, target: target, landed: readBack) + return (applied.outcome, readBack) } /// Whether a window at `current` is already in the tile whose frame is `target`: it fills the @@ -193,20 +195,29 @@ final class WindowDirector { || standingPlacement(for: key, at: current)?.target.isWithinAPoint(of: target) == true } - /// Logs why Left or Right Half is fitting a window into the half rather than carrying it on, - /// where the press alone does not say. Either the window has a placement by this same tile - /// that no longer holds, because the window has moved since or the display has changed; or, - /// with no placement standing, the window has the half's top-left corner without filling it, - /// like a Terminal window Loadstone has not put there since it started, or one whose record - /// another command dropped. A first press into the half, and one on a window where another - /// tile put it, need no explaining. - private func logWhyNotCarriedOn(_ tile: Tile, at current: CGRect, target: CGRect, key: WindowIdentity?) { - guard let key else { return } - if let last = placements[key], last.tile == tile { - Log.ax.info("\(tile.rawValue, privacy: .public): the last placement no longer holds: the window is at \(String(describing: current), privacy: .public), was sent to \(String(describing: last.target), privacy: .public) and landed at \(String(describing: last.landed), privacy: .public); the tile is now \(String(describing: target), privacy: .public)") - } else if standingPlacement(for: key, at: current) == nil, current.sharesTopLeft(with: target) { - Log.ax.info("\(tile.rawValue, privacy: .public): the window at \(String(describing: current), privacy: .public) has the top-left of \(String(describing: target), privacy: .public) without filling it, and nothing records Loadstone putting it there, so it is fitted into the half first and the next press carries it on") + /// Fits a window that is not in the half `tile` into it, at `target`, and logs why it was not + /// carried on where the press alone does not say. Either the window had a placement by this + /// same tile that no longer held, because the window had moved since or the display had + /// changed; or it reads back where it was, so the press changed nothing that shows, like a + /// Terminal window Loadstone has not put in the half since it started, one whose record + /// another command dropped, or a minimum-width window that Left Third left where Left Half + /// leaves it too; or, with no placement standing, it had the half's top-left corner without + /// filling it, and the fit can be too small to see. A fit the window visibly takes, from + /// anywhere else or from where another tile put it, needs no explaining, and a refused one + /// is reported as a refusal. + private func fit(_ window: some MovableWindow, key: WindowIdentity?, into tile: Tile, at target: CGRect, from current: CGRect) -> CommandOutcome { + let last = key.flatMap { placements[$0] } + let hadStandingPlacement = standingPlacement(for: key, at: current) != nil + let placed = place(window, key: key, at: target, by: tile, from: current) + guard placed.outcome == .moved else { return placed.outcome } + if let last, last.tile == tile { + Log.ax.info("\(tile.rawValue, privacy: .public): the last placement no longer held: the window was at \(String(describing: current), privacy: .public), had been sent to \(String(describing: last.target), privacy: .public) and landed at \(String(describing: last.landed), privacy: .public); the tile is now \(String(describing: target), privacy: .public)") + } else if let landed = placed.landed, landed.isWithinAPoint(of: current) { + Log.ax.info("\(tile.rawValue, privacy: .public): nothing recorded the window at \(String(describing: current), privacy: .public) in this half, so it was fitted into \(String(describing: target), privacy: .public) and read back where it was; that is recorded as in the half, so the next press counts it as there") + } else if !hadStandingPlacement, current.sharesTopLeft(with: target) { + Log.ax.info("\(tile.rawValue, privacy: .public): the window at \(String(describing: current), privacy: .public) had the top-left of \(String(describing: target), privacy: .public) without filling it, and nothing recorded Loadstone putting it there, so it was fitted into the half rather than carried on") } + return placed.outcome } /// Writes `frame` to the window at `current`, then records `previous` as the frame Restore From 6ab76c0b90e4797f46420d510f591e68ecfbe89a Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 21:35:00 +0200 Subject: [PATCH 28/44] Correct the director's and WindowCommand's docs on their edge cases place() said a late-applying app's wide window bounces after any carry, going by the display under its centre, "the one it came from". A window keeps its top-left and sticks out to the right, so that holds only for a carry to the left; carried right, its centre lands on the display it reached. The doc and CLAUDE.md's residual now name the direction. apply() said it drops the placement only once the window accepts the write, then described a refusal that drops it too; the first paragraph now says a refusal keeps it only if the window is seen to have stayed put. It also called a refit press the price of never carrying a window on from a stale record, which place() shows can still happen after a hand move; it is scoped to records another Loadstone write made stale. move() said the placed display differs from the one under the centre only for a window held wider than its tile; one held taller can differ too. CLAUDE.md said mapping from a narrower display grows the window, which is true only onto a wider one; what matters is that it comes out wider than the display it goes to. The WindowCommand doc listed id among what a new Tile needs, though .tile(let tile) covers it, and said a new command appears everywhere once it compiles. It also needs a branch in WindowDirector.perform, which the compiler asks for, and an entry in all, which it does not. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++-- Loadstone/Core/Tile.swift | 10 ++++++---- Loadstone/Core/WindowDirector.swift | 31 ++++++++++++++++------------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c87378d..bc443ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,9 +64,9 @@ Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) - Only a tile records a placement, drag snaps included, and only if the read-back has the target's top-left: a late-applying app reads back its old frame. - 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 (AX sets the size before the position) and the process quitting. - A display change 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, and would bounce between the two. Next/Previous Display go by the centre, since mapping from a display narrower than the window grows it. + - 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, and 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. - Info-level `Log.ax` lines say when a press carries a window on and, where the press alone does not show it, why not. - - 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; and a carry-over goes unrecorded, so a window wider than the display it reaches bounces back. + - 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; and a carry-over goes unrecorded, so a window carried left onto a display narrower than itself bounces back. - **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. diff --git a/Loadstone/Core/Tile.swift b/Loadstone/Core/Tile.swift index 473b6e5..306e9d4 100644 --- a/Loadstone/Core/Tile.swift +++ b/Loadstone/Core/Tile.swift @@ -39,10 +39,12 @@ enum Tile: String, CaseIterable, Sendable { } /// Everything a user can ask Loadstone to do to a window. This is the single source of truth -/// for the hotkey list, the status-bar menu, and the Shortcuts settings pane: adding a case -/// (or a `Tile`) fails to compile until `id`, `title`, `defaultShortcut`, and `section` cover -/// it (for a `Tile`, `frame(in:)` and `continuation` too), and then it appears everywhere -/// automatically. +/// for the hotkey list, the status-bar menu, and the Shortcuts settings pane. Adding a `Tile` +/// fails to compile until `title`, `defaultShortcut`, `section`, `frame(in:)`, and +/// `continuation` cover it, and then it appears everywhere. A new case here also needs `id` and +/// a branch in `WindowDirector.perform`, which the compiler asks for, and an entry in `all`, +/// which it does not: left out of `all`, a command compiles and never reaches the menu, the +/// hotkeys, or the settings pane. enum WindowCommand: Hashable, Sendable { case tile(Tile) case center diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index f596616..ee98b4e 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -140,9 +140,9 @@ final class WindowDirector { /// Moves the window `delta` displays along from the one under its centre, mapping its frame /// proportionally. The display Loadstone put it on differs from that one only for a window - /// held wider than its tile and mostly past that display's edge. Mapped from there, the - /// window would keep that overhang in proportion and grow on the way when that display is - /// the narrower, which can leave most of it off the desk. + /// held wider or taller than its tile and mostly past that display's edge. Mapped from there, + /// the window would keep that overhang in proportion: one wider than that display would come + /// out wider than the display it goes to, which can leave most of it off the desk. private func move(_ window: some MovableWindow, key: WindowIdentity?, current: CGRect, delta: Int, in displays: [Display]) -> CommandOutcome { guard let display = display(under: current, in: displays), let neighbor = ScreenGeometry.neighbor(of: display, delta: delta, in: displays) else { return .noDisplay } @@ -163,10 +163,11 @@ final class WindowDirector { /// again the entry goes, but put back on exactly that frame by anything else (a title-bar /// double-click, a drag), the window is carried on at the next press. And a window carried on /// to another display reads back its old frame, off the target's top-left, so the move goes - /// unrecorded; one held wider than that display then has its next half go by the display - /// under its centre, which is the one it came from, and it bounces between the two. Closing - /// either would take watching the window, with an AXObserver recording where it settles and - /// dropping the entry when it moves anywhere else. + /// unrecorded. A window held wider than its tile keeps its top-left and sticks out to the + /// right, so one carried left onto a display narrower than itself reaches back over the + /// display it came from, with its centre there; its next Left Half goes by that display, and + /// it bounces between the two. Closing either would take watching the window, with an + /// AXObserver recording where it settles and dropping the entry when it moves anywhere else. /// /// Returns the outcome and, when a placement was recorded, where the window landed. private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, by tile: Tile, from current: CGRect) -> (outcome: CommandOutcome, landed: CGRect?) { @@ -220,18 +221,20 @@ final class WindowDirector { return placed.outcome } - /// Writes `frame` to the window at `current`, then records `previous` as the frame Restore - /// should return to and drops the window's placement — but only once the window has accepted - /// the write. Recording afterwards rather than before is what keeps a refused frame, or a - /// command that never ran at all, from leaving behind a restore entry that a later Restore - /// would act on. + /// Writes `frame` to the window at `current` and, once the window has accepted the write, + /// records `previous` as the frame Restore should return to and drops the window's placement. + /// A refused write records nothing, and keeps the placement only if the window is seen to + /// have stayed put (below). Recording afterwards rather than before is what keeps a refused + /// frame, or a command that never ran at all, from leaving behind a restore entry that a + /// later Restore would act on. /// /// The placement said where a tile left the window, which has now been sent somewhere else; /// for a tile, `place` records a fresh one. After any other write, a step-sized or /// minimum-width window brought back to where it landed (a Next then Previous Display round /// trip, say) takes one refit press before it carries on. That is the price of never - /// carrying a window on from a stale record, such as the old frame an app that applies - /// frames late reads back, which Restore returns the window to. + /// carrying a window on from a record that another Loadstone write has made stale, such as + /// the old frame an app that applies frames late reads back, which Restore returns the + /// window to. /// /// A refused write keeps the placement while the window is still at `current`. Part of the /// write can take before the refusal, though: AXWindow sets the size before the position, so From e7fa8ab6e799c8583b2f09315b8a2fde27a15c38 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:27:34 +0200 Subject: [PATCH 29/44] Send the director's info lines through an injectable diagnose closure The lines that say why a half press did or did not carry a window on are decisions in their own right: fit() picks one of three, or none, from the placement it found and the frame read back, and Placement.tile is read only there. Written straight to Log.ax, no test could see them, so loosening or inverting any of those conditions left the suite passing. WindowDirector(displays:diagnose:) takes a (String) -> Void that defaults to Log.ax.info with the whole line public, as each piece was before. The carry-on, edge, fit and unrecorded-placement lines go through it; report()'s error and notice lines stay as they are. With plain string interpolation the String(describing:) wrappers OSLog needed are gone. CLAUDE.md lists the seam with the others. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++-- Loadstone/Core/WindowDirector.swift | 23 +++++++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc443ba..bab8ebf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) - 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 (AX sets the size before the position) and the process quitting. - A display change 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, and 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. - - Info-level `Log.ax` lines say when a press carries a window on and, where the press alone does not show it, why not. + - Info-level `Log.ax` lines say when a press carries a window on and, where the press alone does not show it, why not. 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; and a carry-over goes unrecorded, so a window carried left onto a display narrower than itself bounces back. - **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. @@ -74,7 +74,7 @@ Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) - `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_`); 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` 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` 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. diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index ee98b4e..c2de87e 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -36,6 +36,9 @@ final class WindowDirector { /// `originals`. private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] + /// Takes the info-level lines that say why a half press did or did not carry a window on, + /// and why a placement went unrecorded. + private let diagnose: (String) -> Void private struct Placement { /// The tile that sent the window there: for a continuation, the half it moved into rather @@ -45,8 +48,12 @@ final class WindowDirector { let landed: CGRect } - init(displays: @escaping () -> [Display] = { Display.all }) { + init( + displays: @escaping () -> [Display] = { Display.all }, + diagnose: @escaping (String) -> Void = { Log.ax.info("\($0, privacy: .public)") } + ) { self.displays = displays + self.diagnose = diagnose } /// Runs `command` on the frontmost app's focused window and tells the user when it can't. @@ -94,11 +101,11 @@ final class WindowDirector { if let continuation = tile.continuation { if isPlaced(current, in: target, key: key) { if let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { - Log.ax.info("\(command.id, privacy: .public): carrying on to the display at \(String(describing: beside.frame), privacy: .public)") + diagnose("\(command.id): carrying on to the display at \(beside.frame)") let onward = continuation.landing.frame(in: beside.visibleFrame) return place(window, key: key, at: onward, by: continuation.landing, from: current).outcome } - Log.ax.info("\(command.id, privacy: .public): in the half, with no display to the \(String(describing: continuation.toward), privacy: .public) of \(String(describing: display.frame), privacy: .public)") + diagnose("\(command.id): in the half, with no display to the \(continuation.toward) of \(display.frame)") } else { return fit(window, key: key, into: tile, at: target, from: current) } @@ -174,11 +181,11 @@ final class WindowDirector { let applied = apply(target, to: window, key: key, from: current, remembering: current) guard applied.outcome == .moved, let key = applied.key else { return (applied.outcome, nil) } guard let readBack = window.cocoaFrame else { - Log.ax.info("\(tile.rawValue, privacy: .public): could not read the frame back, so the placement is not recorded") + diagnose("\(tile.rawValue): could not read the frame back, so the placement is not recorded") return (applied.outcome, nil) } guard readBack.sharesTopLeft(with: target) else { - Log.ax.info("\(tile.rawValue, privacy: .public): read back \(String(describing: readBack), privacy: .public), off the top-left of \(String(describing: target), privacy: .public), so the placement is not recorded") + diagnose("\(tile.rawValue): read back \(readBack), off the top-left of \(target), so the placement is not recorded") return (applied.outcome, nil) } placements[key] = Placement(tile: tile, target: target, landed: readBack) @@ -212,11 +219,11 @@ final class WindowDirector { let placed = place(window, key: key, at: target, by: tile, from: current) guard placed.outcome == .moved else { return placed.outcome } if let last, last.tile == tile { - Log.ax.info("\(tile.rawValue, privacy: .public): the last placement no longer held: the window was at \(String(describing: current), privacy: .public), had been sent to \(String(describing: last.target), privacy: .public) and landed at \(String(describing: last.landed), privacy: .public); the tile is now \(String(describing: target), privacy: .public)") + diagnose("\(tile.rawValue): the last placement no longer held: the window was at \(current), had been sent to \(last.target) and landed at \(last.landed); the tile is now \(target)") } else if let landed = placed.landed, landed.isWithinAPoint(of: current) { - Log.ax.info("\(tile.rawValue, privacy: .public): nothing recorded the window at \(String(describing: current), privacy: .public) in this half, so it was fitted into \(String(describing: target), privacy: .public) and read back where it was; that is recorded as in the half, so the next press counts it as there") + diagnose("\(tile.rawValue): nothing recorded the window at \(current) in this half, so it was fitted into \(target) and read back where it was; that is recorded as in the half, so the next press counts it as there") } else if !hadStandingPlacement, current.sharesTopLeft(with: target) { - Log.ax.info("\(tile.rawValue, privacy: .public): the window at \(String(describing: current), privacy: .public) had the top-left of \(String(describing: target), privacy: .public) without filling it, and nothing recorded Loadstone putting it there, so it was fitted into the half rather than carried on") + diagnose("\(tile.rawValue): the window at \(current) had the top-left of \(target) without filling it, and nothing recorded Loadstone putting it there, so it was fitted into the half rather than carried on") } return placed.outcome } From cfe58562fb3301c50e89003e50db7c2ec7c9184f Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:27:34 +0200 Subject: [PATCH 30/44] Pin which half presses explain themselves in the log Seven tests read the director's diagnose lines. Silent: a first press from anywhere but the half's top-left, Left Half after Maximize, and a press after a placement by another tile, including a carried window nudged by hand and then sent to the half it did not land in. One line each: a first press from the half's top-left with nothing recorded, a placement by the same half that no longer holds, a Terminal-style or minimum-width window that reads back where it was, the edge of the desk, and carrying on. Each was seen failing under a mutation, one build per mutation, then reverted: dropping the standing-placement check or the top-left check from the third case, dropping the same-tile check from the first, recording a carry by the pressed tile rather than the half it moved into, disabling the first or second case, and removing the edge, carry-on or top-left line. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 124 +++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 1d815bf..2b894cc 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -100,6 +100,15 @@ final class WindowDirectorTests: XCTestCase { WindowDirector(displays: { [self.primary, self.right] }) } + /// The lines a director diagnoses, which the app sends to the log at info level. + private final class Diagnostics { + var lines: [String] = [] + } + + private func makeDirector(diagnosingInto diagnostics: Diagnostics) -> WindowDirector { + WindowDirector(displays: { [self.primary, self.right] }, diagnose: { diagnostics.lines.append($0) }) + } + func testTilePlacesTheWindowOnTheDisplayUnderItsCentre() { let window = FakeWindow(frame: original) let outcome = makeDirector().perform(.tile(.leftHalf), on: window) @@ -731,4 +740,119 @@ final class WindowDirectorTests: XCTestCase { director.perform(.tile(.leftHalf), on: window) XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) } + + // MARK: What the log says + + // A half press that does not carry a window on explains itself only where the press alone + // does not show why. Each test below clears the lines before the press it is about. + + func testAFirstPressIsLoggedOnlyForAWindowThatHadTheHalfsTopLeft() { + // From anywhere else the fit shows. From the half's top-left corner, with nothing to say + // how the window got there, it can be too small to see. + let diagnostics = Diagnostics() + makeDirector(diagnosingInto: diagnostics).perform(.tile(.leftHalf), on: FakeWindow(frame: floating)) + XCTAssertEqual(diagnostics.lines, []) + + makeDirector(diagnosingInto: diagnostics).perform(.tile(.leftHalf), on: FakeWindow(frame: flushTopLeft)) + XCTAssertEqual(diagnostics.lines.count, 1, "\(diagnostics.lines)") + XCTAssertTrue(diagnostics.lines.allSatisfy { $0.contains("had the top-left") }, "\(diagnostics.lines)") + } + + func testAHalfAfterMaximizeIsNotLogged() { + // Maximize leaves the window with the half's top-left, but Loadstone put it there, and + // the fit into the half shows. + let window = FakeWindow(frame: floating) + let diagnostics = Diagnostics() + let director = makeDirector(diagnosingInto: diagnostics) + director.perform(.tile(.maximize), on: window) + diagnostics.lines.removeAll() + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(diagnostics.lines, []) + } + + func testAPlacementByAnotherTileIsNotLoggedAsNoLongerHolding() { + // Put in Top Left and dragged out by hand. + let window = FakeWindow(frame: floating) + let diagnostics = Diagnostics() + let director = makeDirector(diagnosingInto: diagnostics) + director.perform(.tile(.topLeft), on: window) + window.cocoaFrame = floating + diagnostics.lines.removeAll() + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(diagnostics.lines, []) + + // Carried on by Left Half, the window was placed by Right Half, the half it moved into. + let carried = FakeWindow(frame: Tile.leftHalf.frame(in: right.visibleFrame), identity: .cgWindow(2, pid: 42)) + director.perform(.tile(.leftHalf), on: carried) + carried.cocoaFrame = carried.cocoaFrame?.offsetBy(dx: -41, dy: -43) // nudged by hand + diagnostics.lines.removeAll() + + director.perform(.tile(.leftHalf), on: carried) + XCTAssertEqual(diagnostics.lines, []) + } + + func testAPlacementByTheSameHalfThatNoLongerHoldsIsLogged() { + // Put in Left Half and dragged out by hand. + let window = FakeWindow(frame: floating) + window.grid = terminalCell + let diagnostics = Diagnostics() + let director = makeDirector(diagnosingInto: diagnostics) + director.perform(.tile(.leftHalf), on: window) + window.cocoaFrame = floating + diagnostics.lines.removeAll() + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(diagnostics.lines.count, 1, "\(diagnostics.lines)") + XCTAssertTrue(diagnostics.lines.allSatisfy { $0.contains("no longer held") }, "\(diagnostics.lines)") + + // Carried on by Left Half into primary's right half, nudged by hand, then Right Half. + let carried = FakeWindow(frame: Tile.leftHalf.frame(in: right.visibleFrame), identity: .cgWindow(2, pid: 42)) + director.perform(.tile(.leftHalf), on: carried) + carried.cocoaFrame = carried.cocoaFrame?.offsetBy(dx: -41, dy: -43) + diagnostics.lines.removeAll() + + director.perform(.tile(.rightHalf), on: carried) + XCTAssertEqual(diagnostics.lines.count, 1, "\(diagnostics.lines)") + XCTAssertTrue(diagnostics.lines.allSatisfy { $0.contains("no longer held") }, "\(diagnostics.lines)") + } + + func testAWindowThatReadsBackWhereItWasIsLogged() throws { + // A Terminal window already in the half, with nothing recorded since Loadstone started. + let terminal = FakeWindow(frame: floating) + terminal.grid = terminalCell + makeDirector().perform(.tile(.leftHalf), on: terminal) + // A window that will not go below 1501pt, which Left Third leaves where Left Half would. + let wide = FakeWindow(frame: floating, identity: .cgWindow(2, pid: 42)) + wide.minimumWidth = 1501 + let diagnostics = Diagnostics() + let director = makeDirector(diagnosingInto: diagnostics) + director.perform(.tile(.leftThird), on: wide) + + for window in [terminal, wide] { + let before = try XCTUnwrap(window.cocoaFrame) + diagnostics.lines.removeAll() + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.cocoaFrame, before, "the press changed nothing that shows") + XCTAssertEqual(diagnostics.lines.count, 1, "\(diagnostics.lines)") + XCTAssertTrue(diagnostics.lines.allSatisfy { $0.contains("read back where it was") }, "\(diagnostics.lines)") + } + } + + func testAHalfAtTheEdgeOfTheDeskIsLogged() { + let window = FakeWindow(frame: Tile.leftHalf.frame(in: primary.visibleFrame)) + let diagnostics = Diagnostics() + makeDirector(diagnosingInto: diagnostics).perform(.tile(.leftHalf), on: window) + XCTAssertEqual(diagnostics.lines.count, 1, "\(diagnostics.lines)") + XCTAssertTrue(diagnostics.lines.allSatisfy { $0.contains("no display to the left") }, "\(diagnostics.lines)") + } + + func testCarryingAWindowOnIsLogged() { + let window = FakeWindow(frame: Tile.leftHalf.frame(in: right.visibleFrame)) + let diagnostics = Diagnostics() + makeDirector(diagnosingInto: diagnostics).perform(.tile(.leftHalf), on: window) + XCTAssertEqual(diagnostics.lines, ["leftHalf: carrying on to the display at \(primary.frame)"]) + } } From 842423ae288fe91ee902c664bd9587be9c69191c Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:28:11 +0200 Subject: [PATCH 31/44] Pin that a refusal is judged against where the window was The partly refused write test used Top Left, whose size-only write leaves the window exactly on the Top Left frame. That is where it was sent as well as off where it was, so a director that dropped the placement only when the window reached the requested frame, or could not be read, passed the whole suite. Top Right is the size of Top Left on the right display, so the same size-only write leaves the window on the stale landing and not where it was sent. The test now presses Top Right and asserts where the partial write left the window. With apply() dropping the placement when the window reached the requested frame or cannot be read, rather than when it is no longer where it was, this test alone fails. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 2b894cc..f5c31ff 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -678,8 +678,10 @@ final class WindowDirectorTests: XCTestCase { } func testARefusedWriteThatStillResizedTheWindowForgetsWhereAHalfPutIt() { - // Top Left's size takes and its position is refused, which leaves the window in the - // top-left quadrant: back on the old frame Left Half read back, with no hand move. + // Top Right is the size of Top Left here. Its size takes and its position is refused, + // which leaves the window in the top-left quadrant: back on the old frame Left Half read + // back, with no hand move, and not where Top Right sent it. What counts is that the + // window left where it was, not whether it reached the frame asked for. let topLeft = Tile.topLeft.frame(in: right.visibleFrame) let window = FakeWindow(frame: topLeft) window.appliesLate = true @@ -688,7 +690,8 @@ final class WindowDirectorTests: XCTestCase { window.catchUp() window.appliesLate = false window.refusesPositionWith = .cannotComplete - XCTAssertEqual(director.perform(.tile(.topLeft), on: window), .rejected(.cannotComplete)) + XCTAssertEqual(director.perform(.tile(.topRight), on: window), .rejected(.cannotComplete)) + XCTAssertEqual(window.cocoaFrame, topLeft) window.refusesPositionWith = nil director.perform(.tile(.leftHalf), on: window) From 5acc5e7a6253aef5ffbe96eff05223d66d749333 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:28:56 +0200 Subject: [PATCH 32/44] Pin that Restore stays one-shot and survives a refusal Two rules of the Restore ledger had no test. rememberIfNeeded moves a title-based entry to the window's new title rather than copying it; a copy left behind under the old title answers a second Restore once the window has its old title back, so Restore stops being one-shot. And Restore drops its entry only once the window accepts the old frame, as CLAUDE.md and Restore's comment say, so a refused Restore can be tried again. The one-shot test for a window whose title follows its size fails alone when the entry is copied instead of moved. The retried Restore test fails alone when Restore drops its entry whatever the outcome. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index f5c31ff..2a34be4 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -263,6 +263,32 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.cocoaFrame, floating) } + func testRestoreIsOneShotForAWindowWhoseTitleFollowsItsSize() { + // Center keeps the size, so it records under the title the window already has. Left Half + // renames the window, and the entry moves across rather than staying under both titles. + let window = FakeWindow(frame: floating) + window.titleFollowsSize = true + let director = makeDirector() + director.perform(.center, on: window) + director.perform(.tile(.leftHalf), on: window) + + XCTAssertEqual(director.perform(.restore, on: window), .moved) + XCTAssertEqual(window.cocoaFrame, floating) + XCTAssertEqual(director.perform(.restore, on: window), .nothingToRestore) + } + + func testARefusedRestoreCanBeRetried() { + let window = FakeWindow(frame: original) + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.rejectWith = .cannotComplete + XCTAssertEqual(director.perform(.restore, on: window), .rejected(.cannotComplete)) + window.rejectWith = nil + + XCTAssertEqual(director.perform(.restore, on: window), .moved) + XCTAssertEqual(window.cocoaFrame, original) + } + func testARejectedCommandIsNotRememberedForRestore() { let window = FakeWindow(frame: original) let director = makeDirector() From b964b7e52397c42990dfd268e4a4a92e514531d1 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:29:56 +0200 Subject: [PATCH 33/44] Document the stale title a late-applying app records a placement under With a title-based identity whose title follows the window's size, an app that applies frames late reports the old title as well as the old frame straight after a write. When that old frame shares the target's top-left, the placement is recorded under the stale title, and a later write, looked up by the new one, does not drop it, so a tile of Loadstone's own can put the window back on the stale landing and the next half press carries it on. It takes the window id lookup failing, a late-applying app and a title that follows the size all at once, and the fix on offer, dropping every title-based placement of the process on each write, would cost the app's other windows a refit press. So it joins the late-app residuals in place()'s doc and CLAUDE.md, and the fallback identity's doc says the director records under the title it reads after the write rather than the new one. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- Loadstone/Core/MovableWindow.swift | 2 +- Loadstone/Core/WindowDirector.swift | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bab8ebf..a7abc35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) - A display change 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, and 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. - Info-level `Log.ax` lines say when a press carries a window on and, where the press alone does not show it, why not. 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; and a carry-over goes unrecorded, so a window carried left onto a display narrower than itself bounces back. + - 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. - **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. diff --git a/Loadstone/Core/MovableWindow.swift b/Loadstone/Core/MovableWindow.swift index c228252..7822016 100644 --- a/Loadstone/Core/MovableWindow.swift +++ b/Loadstone/Core/MovableWindow.swift @@ -9,7 +9,7 @@ enum WindowIdentity: Hashable, Sendable { /// Weaker fallback when the window id is unavailable: titles change (browser tabs, "edited" /// 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 new title. + /// 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 { diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index c2de87e..7781a83 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -168,8 +168,11 @@ final class WindowDirector { /// that applies the frame late. If the window's old frame already shared the target's /// top-left, that old frame is recorded as where it landed. Once Loadstone writes the window /// again the entry goes, but put back on exactly that frame by anything else (a title-bar - /// double-click, a drag), the window is carried on at the next press. And a window carried on - /// to another display reads back its old frame, off the target's top-left, so the move goes + /// double-click, a drag), the window is carried on at the next press. A window known by a + /// title that follows its size is carried on even when a Loadstone tile put it back: the + /// title read after the write is stale as well, so the entry sits under the old title, which + /// the next write, looked up by the new one, does not drop. And a window carried on to + /// another display reads back its old frame, off the target's top-left, so the move goes /// unrecorded. A window held wider than its tile keeps its top-left and sticks out to the /// right, so one carried left onto a display narrower than itself reaches back over the /// display it came from, with its centre there; its next Left Half goes by that display, and From 4a133d36a5820cad851fcd9566b110754f4a6939 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:30:53 +0200 Subject: [PATCH 34/44] Correct the docs on what drops a placement and what needs a refit - fit() said a window that reads back where it was is one the press left where it was. An app that applies the frame late, with the window at the half's top-left, reads back its old frame too, and the window then moves into the half. The doc now lists that case. - The placements doc and CLAUDE.md left out that a refused write after which the frame cannot be read drops the placement, which apply() does and a test pins. CLAUDE.md credits AXWindow, not AX, with setting the size before the position. - CLAUDE.md said a window wider than its tile, with its centre on the next display, would bounce between the two. Going by the centre would tile it from the wrong display; only one wider than the display it was placed on bounces. - The README and CLAUDE.md said any window needs a refit press after any display change, the screen layout included. A window that fills the half moves with its screen and still fills the new half, so it carries on at the first press. The README now counts moving a step-sized window's screen as moving the window, and CLAUDE.md names a new resolution or a moved Dock as what refits any window. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +++--- Loadstone/Core/WindowDirector.swift | 21 +++++++++++---------- README.md | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a7abc35..c01e964 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,9 +62,9 @@ Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) - **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 top-left: a late-applying app reads back its old frame. - - 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 (AX sets the size before the position) and the process quitting. - - A display change 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, and 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. + - 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. - Info-level `Log.ax` lines say when a press carries a window on and, where the press alone does not show it, why not. 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. - **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. diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 7781a83..a2816f9 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -31,9 +31,9 @@ final class WindowDirector { /// or caps the size. That is how a second Left or Right Half knows the window is still in /// that half when it never fills the tile exactly, and which display a tile or Center works /// on while the window is still where it landed. Every frame the window accepts from - /// Loadstone drops the entry, and a tile then records a new one; a refused frame that moved - /// the window anyway drops it, and so does the window's process quitting, along with - /// `originals`. + /// Loadstone drops the entry, and a tile then records a new one; a refused frame drops it if + /// the window moved anyway or its frame can no longer be read, and so does the window's + /// process quitting, along with `originals`. private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] /// Takes the info-level lines that say why a half press did or did not carry a window on, @@ -209,13 +209,14 @@ final class WindowDirector { /// Fits a window that is not in the half `tile` into it, at `target`, and logs why it was not /// carried on where the press alone does not say. Either the window had a placement by this /// same tile that no longer held, because the window had moved since or the display had - /// changed; or it reads back where it was, so the press changed nothing that shows, like a - /// Terminal window Loadstone has not put in the half since it started, one whose record - /// another command dropped, or a minimum-width window that Left Third left where Left Half - /// leaves it too; or, with no placement standing, it had the half's top-left corner without - /// filling it, and the fit can be too small to see. A fit the window visibly takes, from - /// anywhere else or from where another tile put it, needs no explaining, and a refused one - /// is reported as a refusal. + /// changed; or it reads back where it was: a Terminal window Loadstone has not put in the + /// half since it started, one whose record another command dropped, or a minimum-width + /// window that Left Third left where Left Half leaves it too, none of which the press moves, + /// or a window at the half's top-left whose app applies the frame late and still reports the + /// old one, which moves once the app catches up; or, with no placement standing, it had the + /// half's top-left corner without filling it, and the fit can be too small to see. A fit the + /// window visibly takes, from anywhere else or from where another tile put it, needs no + /// explaining, and a refused one is reported as a refusal. private func fit(_ window: some MovableWindow, key: WindowIdentity?, into tile: Tile, at target: CGRect, from current: CGRect) -> CommandOutcome { let last = key.flatMap { placements[$0] } let hadStandingPlacement = standingPlacement(for: key, at: current) != nil diff --git a/README.md b/README.md index 4f9cb14..35c873c 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Menu bar → Loadstone → **Loadstone Help** opens this page. **Next / previous display beeps.** Only one display is attached. -**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not, and neither does one touching only at a corner. And the window has to be in that half already: if you have moved it since, or the resolution, the Dock, or the screen layout has changed, the first press puts it back into the half and the next one carries it on. Windows that size in steps, such as Terminal, never fill the half exactly, so they count as in the half only if Loadstone put them there since it last started and nothing has moved them since. That includes Loadstone’s own commands, even a next display followed by a previous display that brings the window back to the same spot. When a window that looks to be in the half is put back into it rather than carried on, the log (below) says why; those are info messages, so keep `--info`. +**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not, and neither does one touching only at a corner. And the window has to be in that half already: if you have moved it since, or the resolution or the Dock has changed, the first press puts it back into the half and the next one carries it on. Windows that size in steps, such as Terminal, never fill the half exactly, so they count as in the half only if Loadstone put them there since it last started and nothing has moved them since. That includes dragging their screen to a new spot in System Settings → Displays, and Loadstone’s own commands, even a next display followed by a previous display that brings the window back to the same spot. When a window that looks to be in the half is put back into it rather than carried on, the log (below) says why; those are info messages, so keep `--info`. **macOS’s own edge tiling is off after Loadstone crashed or was force-quit.** Loadstone turns it off while running and back on when it quits normally; after an unclean exit, simply launching Loadstone again restores it. To put it back by hand, use System Settings → **Desktop & Dock** → **Windows**. From b0f97c21f6cc04eeb9cbc7a5df8d9226c99289a2 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:31:27 +0200 Subject: [PATCH 35/44] Keep the late-apply test comment over only the tests it describes A block comment introduces the tests that put a late-applying app's window back on the stale frame recorded for it. Three tests below it were about something else: a refused write after which the frame cannot be read, a display that changed since, and a process that quit. The first moves next to the other refused-command tests, the other two next to the display tests before them. No test changes. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 92 ++++++++++++------------ 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 2a34be4..933a4cf 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -548,6 +548,34 @@ final class WindowDirectorTests: XCTestCase { return (window, director, narrow, wide) } + func testAHalfWhoseDisplayHasChangedSinceIsRefittedRatherThanCarriedOn() { + // A new resolution leaves the window where it was, which is no longer the left half. + var desk = [primary, right] + let director = WindowDirector(displays: { desk }) + let window = FakeWindow(frame: floating) + director.perform(.tile(.leftHalf), on: window) + let roomier = Display( + frame: CGRect(x: 1920, y: -300, width: 3008, height: 1692), + visibleFrame: CGRect(x: 1920, y: -300, width: 3008, height: 1667) + ) + desk = [primary, roomier] + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: roomier.visibleFrame)) + } + + func testForgettingAProcessDropsWhereItsWindowsWerePut() { + let window = FakeWindow(frame: floating) + window.grid = terminalCell + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + + director.forgetWindows(ofProcess: 42) + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + func testARefusedHalfIsNotRememberedAsPlaced() { // The refused window stays where it was, which shares the half's top-left, so only the // refusal itself keeps that frame from being recorded as where the half left it. @@ -593,6 +621,24 @@ final class WindowDirectorTests: XCTestCase { } } + func testARefusedWriteThatLeftTheFrameUnreadableForgetsWhereAHalfPutIt() throws { + // A hung app refuses Center and does not answer the read after it either, so nothing + // shows that the window is still where the half left it. + let window = FakeWindow(frame: floating) + window.grid = terminalCell + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + let landed = try XCTUnwrap(window.cocoaFrame) + window.rejectWith = .cannotComplete + window.unreadableOnceRefused = true + XCTAssertEqual(director.perform(.center, on: window), .rejected(.cannotComplete)) + window.rejectWith = nil + window.cocoaFrame = landed // answering again, and still where it landed + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + func testAWindowStillReportingItsOldFrameIsNotRememberedAsPlaced() { // Read straight back, the frame is still the old one. Dragged back to exactly that frame, // the window would be thrown onto the next display by a remembered placement. @@ -724,52 +770,6 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) } - func testARefusedWriteThatLeftTheFrameUnreadableForgetsWhereAHalfPutIt() throws { - // A hung app refuses Center and does not answer the read after it either, so nothing - // shows that the window is still where the half left it. - let window = FakeWindow(frame: floating) - window.grid = terminalCell - let director = makeDirector() - director.perform(.tile(.leftHalf), on: window) - let landed = try XCTUnwrap(window.cocoaFrame) - window.rejectWith = .cannotComplete - window.unreadableOnceRefused = true - XCTAssertEqual(director.perform(.center, on: window), .rejected(.cannotComplete)) - window.rejectWith = nil - window.cocoaFrame = landed // answering again, and still where it landed - - director.perform(.tile(.leftHalf), on: window) - XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) - } - - func testAHalfWhoseDisplayHasChangedSinceIsRefittedRatherThanCarriedOn() { - // A new resolution leaves the window where it was, which is no longer the left half. - var desk = [primary, right] - let director = WindowDirector(displays: { desk }) - let window = FakeWindow(frame: floating) - director.perform(.tile(.leftHalf), on: window) - let roomier = Display( - frame: CGRect(x: 1920, y: -300, width: 3008, height: 1692), - visibleFrame: CGRect(x: 1920, y: -300, width: 3008, height: 1667) - ) - desk = [primary, roomier] - - director.perform(.tile(.leftHalf), on: window) - XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: roomier.visibleFrame)) - } - - func testForgettingAProcessDropsWhereItsWindowsWerePut() { - let window = FakeWindow(frame: floating) - window.grid = terminalCell - let director = makeDirector() - director.perform(.tile(.leftHalf), on: window) - - director.forgetWindows(ofProcess: 42) - - director.perform(.tile(.leftHalf), on: window) - XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) - } - // MARK: What the log says // A half press that does not carry a window on explains itself only where the press alone From b329622e03948749e5257dd32a89c89436657bbc Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:31:36 +0200 Subject: [PATCH 36/44] Say that CI runs AccessibilityAuthTests The suite's comment said that, like AXWindowTests, it is skipped in CI. ci.yml skips only AXWindowTests, and CI's test count includes these three. They pass there because the runner's test host is untrusted, and the cross-application one skips itself when there is no other app to probe. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/AccessibilityAuthTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoadstoneTests/AccessibilityAuthTests.swift b/LoadstoneTests/AccessibilityAuthTests.swift index ff40049..b06ba5d 100644 --- a/LoadstoneTests/AccessibilityAuthTests.swift +++ b/LoadstoneTests/AccessibilityAuthTests.swift @@ -3,8 +3,8 @@ import ApplicationServices import XCTest @testable import Loadstone -/// These need real Accessibility answers from the system, so like `AXWindowTests` they are -/// skipped in CI and run on a developer machine. +/// These need real Accessibility answers from the system. Unlike `AXWindowTests` they run in CI +/// as well as on a developer machine, and one that finds no other app to probe skips itself. /// /// They rely on the test host being *untrusted*, which it is: the Debug build is ad-hoc signed /// and gets a fresh identity on every rebuild, so TCC never has a grant for it. Each test says From c9aaafd9b452364b4f3b0ffccae37ccf09d866fb Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:58:47 +0200 Subject: [PATCH 37/44] Say exactly which refits the log explains The README promised that the log says why a window that looks to be in the half is refitted rather than carried on. fit() logs three cases: Loadstone last put the window in that half, the press left it where it was, or it had the half's top-left with no placement standing. A window a few points off the half by any other route is fitted with no line, though the move can be too small to see. The README, CLAUDE.md and fit()'s doc now name those cases, and the comment in perform points to fit()'s list. Apart from the README, they said a line is logged where the press alone does not show why, which also missed that the first case is logged even when the fit plainly shows. The comment over the log tests said each test clears the lines before its press; three start from empty lines instead, and it now says so. It also names the carry and edge-of-desk lines those tests cover. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- Loadstone/Core/WindowDirector.swift | 23 ++++++++++++----------- LoadstoneTests/WindowDirectorTests.swift | 7 +++++-- README.md | 2 +- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c01e964..f5aadfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) - 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. - - Info-level `Log.ax` lines say when a press carries a window on and, where the press alone does not show it, why not. They go through the director's `diagnose` closure, which is how the tests see them. + - 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. 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. - **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. diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index a2816f9..c025797 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -97,7 +97,7 @@ final class WindowDirector { // Left or Right Half again on a window already in that half carries it on into the // opposite half of the display beside it. With nothing beside it, the half is applied // again, which leaves the window where it is, with no beep. A window not in the half - // is fitted into it, with a line in the log where the press alone does not say why. + // is fitted into it, with a line in the log in the cases `fit` lists. if let continuation = tile.continuation { if isPlaced(current, in: target, key: key) { if let beside = ScreenGeometry.adjacent(to: display, toward: continuation.toward, in: displays) { @@ -207,16 +207,17 @@ final class WindowDirector { } /// Fits a window that is not in the half `tile` into it, at `target`, and logs why it was not - /// carried on where the press alone does not say. Either the window had a placement by this - /// same tile that no longer held, because the window had moved since or the display had - /// changed; or it reads back where it was: a Terminal window Loadstone has not put in the - /// half since it started, one whose record another command dropped, or a minimum-width - /// window that Left Third left where Left Half leaves it too, none of which the press moves, - /// or a window at the half's top-left whose app applies the frame late and still reports the - /// old one, which moves once the app catches up; or, with no placement standing, it had the - /// half's top-left corner without filling it, and the fit can be too small to see. A fit the - /// window visibly takes, from anywhere else or from where another tile put it, needs no - /// explaining, and a refused one is reported as a refusal. + /// carried on in three cases. Either the window had a placement by this same tile that no + /// longer held, because the window had moved since or the display had changed; or it reads + /// back where it was: a Terminal window Loadstone has not put in the half since it started, + /// one whose record another command dropped, or a minimum-width window that Left Third left + /// where Left Half leaves it too, none of which the press moves, or a window at the half's + /// top-left whose app applies the frame late and still reports the old one, which moves once + /// the app catches up; or, with no placement standing, it had the half's top-left corner + /// without filling it, and the fit can be too small to see. Otherwise the fit goes unlogged: + /// from where another tile put the window, or from anywhere else, it normally shows, though + /// from a few points off the half it can be too small to see as well. A refused fit is + /// reported as a refusal. private func fit(_ window: some MovableWindow, key: WindowIdentity?, into tile: Tile, at target: CGRect, from current: CGRect) -> CommandOutcome { let last = key.flatMap { placements[$0] } let hadStandingPlacement = standingPlacement(for: key, at: current) != nil diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 933a4cf..25ce1e6 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -772,8 +772,11 @@ final class WindowDirectorTests: XCTestCase { // MARK: What the log says - // A half press that does not carry a window on explains itself only where the press alone - // does not show why. Each test below clears the lines before the press it is about. + // A half press logs carrying a window on and stopping at the edge of the desk. A fit into + // the half explains itself when Loadstone last put the window in that half, when the press + // leaves it where it was, or when it had the half's top-left with no placement standing, and + // otherwise goes unlogged. Each test starts from empty lines, or clears them, before the + // press it is about. func testAFirstPressIsLoggedOnlyForAWindowThatHadTheHalfsTopLeft() { // From anywhere else the fit shows. From the half's top-left corner, with nothing to say diff --git a/README.md b/README.md index 35c873c..3e569a7 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Menu bar → Loadstone → **Loadstone Help** opens this page. **Next / previous display beeps.** Only one display is attached. -**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not, and neither does one touching only at a corner. And the window has to be in that half already: if you have moved it since, or the resolution or the Dock has changed, the first press puts it back into the half and the next one carries it on. Windows that size in steps, such as Terminal, never fill the half exactly, so they count as in the half only if Loadstone put them there since it last started and nothing has moved them since. That includes dragging their screen to a new spot in System Settings → Displays, and Loadstone’s own commands, even a next display followed by a previous display that brings the window back to the same spot. When a window that looks to be in the half is put back into it rather than carried on, the log (below) says why; those are info messages, so keep `--info`. +**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not, and neither does one touching only at a corner. And the window has to be in that half already: if you have moved it since, or the resolution or the Dock has changed, the first press puts it back into the half and the next one carries it on. Windows that size in steps, such as Terminal, never fill the half exactly, so they count as in the half only if Loadstone put them there since it last started and nothing has moved them since. That includes dragging their screen to a new spot in System Settings → Displays, and Loadstone’s own commands, even a next display followed by a previous display that brings the window back to the same spot. When a window that looks to be in the half is put back into it rather than carried on, the log (below) says why when Loadstone last put it in that half, when the press left it where it was, and when it sat at the half’s top-left corner without Loadstone having put it there; those are info messages, so keep `--info`. **macOS’s own edge tiling is off after Loadstone crashed or was force-quit.** Loadstone turns it off while running and back on when it quits normally; after an unclean exit, simply launching Loadstone again restores it. To put it back by hand, use System Settings → **Desktop & Dock** → **Windows**. From 7075d8ee08c62b3d213cc6e68093898b4c4b1475 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Fri, 25 Sep 2026 22:58:54 +0200 Subject: [PATCH 38/44] Correct the docs on the placed display and a renamed Restore frame display(for:) tied the placed-display rule to a window wider than the display. It matters as well for a narrower one held wider than its tile: a 1301pt-wide window in Right Third of a 1920pt display has its centre on the next one, and only the rule tiles its next Left Half on the display it was placed on. The doc now matches CLAUDE.md. rememberIfNeeded said the frame moving to a new title is the one the window's first command recorded. When a hand resize renamed the window in between, it is the one the first command under the old title recorded, and the earlier entry is orphaned, as WindowIdentity.fallback notes. The doc now says that. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index c025797..e4f9072 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -286,8 +286,10 @@ final class WindowDirector { /// Records `frame` under `key`, the key the window goes by after the write, as where Restore /// returns it, unless something is recorded there already. A frame recorded under `oldKey`, /// the one the window was looked up by, moves across instead: the two differ only for a - /// title-based identity whose write renamed the window, and that frame is the one the window's - /// first command recorded. + /// title-based identity whose write renamed the window, and that frame is the one recorded by + /// the window's first command under the old title, or one that moved across to it since. A + /// title changed by anything else, a hand resize included, orphans the entry, as + /// `WindowIdentity.fallback` notes. private func rememberIfNeeded(_ frame: CGRect, for key: WindowIdentity?, lookedUpBy oldKey: WindowIdentity?) { guard let key, originals[key] == nil else { return } if let oldKey, let first = originals.removeValue(forKey: oldKey) { @@ -299,9 +301,9 @@ final class WindowDirector { /// The display a tile or Center works on for a window at `frame`. While the window is still /// where Loadstone last put it, that is the display holding the frame it was sent to: a - /// window held wider than that display spills onto the next, and its centre can land there, - /// which would send the next half from the wrong display and bounce the window between the - /// two. Otherwise the display under its centre. + /// window held wider than its tile can have its centre on the next display, which would send + /// the next tile from the wrong display, and one wider than the display it was placed on + /// would bounce between the two. Otherwise the display under its centre. private func display(for frame: CGRect, key: WindowIdentity?, in displays: [Display]) -> Display? { if let placement = standingPlacement(for: key, at: frame), let placedOn = ScreenGeometry.display(containing: placement.target, in: displays) { From e13183f7fa8f2ddc4eeaf686e0ced36246647112 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Sat, 26 Sep 2026 00:10:42 +0200 Subject: [PATCH 39/44] Record a half that macOS pulled below a menu bar as placed On a MacBook with a monitor to its left whose top reaches above the MacBook's menu bar, Left Half carried a window with a 2001pt minimum width into the monitor's right half, and it landed 32pt lower than it was sent. macOS keeps a window's top below the menu bar of the display holding most of it, and 1041pt of this one hung over the MacBook. place() recorded a placement only when the read-back shared the target's top-left, so the carry went unrecorded, and the next press went by the display under the window's centre, the MacBook, and sent it back: the bounce the continuation is meant to prevent. A placement is now recorded when the read-back has the target's left edge and either its top-left corner or, once the window has moved, a top no higher than the target's. macOS only ever pulls a window down. An app that applies frames late reads back the frame from before the write, which has not moved, so that still counts only with the corner. The line logged for an unrecorded placement says what the read-back missed: the left edge, the top from above, or the top without the window having moved. FakeWindow takes a constrain closure that moves the window after its app has placed it. Two of the new tests set it to keep the window's top below the menu bar of the display holding most of it, on a laptop with a monitor to its left that reaches 32pt higher; the others shift the window 41pt, which macOS does not, to pin the rule's edges. Co-Authored-By: Claude Opus 5.5 --- Loadstone/Core/WindowDirector.swift | 71 +++++++++----- LoadstoneTests/WindowDirectorTests.swift | 115 ++++++++++++++++++++++- 2 files changed, 157 insertions(+), 29 deletions(-) diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index e4f9072..fd55ef2 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -28,12 +28,12 @@ final class WindowDirector { private var originals: [WindowIdentity: CGRect] = [:] /// The tile Loadstone last put each window in, the frame it sent the window to, and where /// the window reported itself once there (where it landed), which differs when its app rounds - /// or caps the size. That is how a second Left or Right Half knows the window is still in - /// that half when it never fills the tile exactly, and which display a tile or Center works - /// on while the window is still where it landed. Every frame the window accepts from - /// Loadstone drops the entry, and a tile then records a new one; a refused frame drops it if - /// the window moved anyway or its frame can no longer be read, and so does the window's - /// process quitting, along with `originals`. + /// or caps the size, or when macOS pulls the window down below a menu bar. That is how a + /// second Left or Right Half knows the window is still in that half when it never fills the + /// tile exactly, and which display a tile or Center works on while the window is still where + /// it landed. Every frame the window accepts from Loadstone drops the entry, and a tile then + /// records a new one; a refused frame drops it if the window moved anyway or its frame can no + /// longer be read, and so does the window's process quitting, along with `originals`. private var placements: [WindowIdentity: Placement] = [:] private let displays: () -> [Display] /// Takes the info-level lines that say why a half press did or did not carry a window on, @@ -160,9 +160,15 @@ final class WindowDirector { /// Sends the window to `target`, the frame of `tile`, then records where it actually ended /// up, so that pressing the same tile again can tell the window has not moved since. Recorded - /// only once the window's top-left corner is where it was sent: an app that rounds or caps a - /// size normally keeps that corner, while one still reporting its old frame has not moved - /// yet, so a read-back that misses the corner leaves the window with no entry. + /// only if the read-back has the target's left edge and either its top-left corner or, once + /// the window has moved, a top no higher than the target's (`missedLanding` says which + /// failed). An app that rounds or caps a size normally keeps the corner. macOS keeps a + /// window's top below the menu bar of the display holding most of it, so a window held wider + /// than its tile and hanging mostly over a display whose menu bar is lower than the tile's + /// top is pulled down below that menu bar. It has still landed, and left unrecorded it would + /// bounce, as below; macOS never pulls a window up, so a read-back above the target does not + /// count. A read-back where the window was before the write is what an app still reporting + /// its old frame gives, so that counts only with the corner. /// /// That read-back is the one look this takes, which leaves two cases open, both from an app /// that applies the frame late. If the window's old frame already shared the target's @@ -172,12 +178,13 @@ final class WindowDirector { /// title that follows its size is carried on even when a Loadstone tile put it back: the /// title read after the write is stale as well, so the entry sits under the old title, which /// the next write, looked up by the new one, does not drop. And a window carried on to - /// another display reads back its old frame, off the target's top-left, so the move goes - /// unrecorded. A window held wider than its tile keeps its top-left and sticks out to the - /// right, so one carried left onto a display narrower than itself reaches back over the - /// display it came from, with its centre there; its next Left Half goes by that display, and - /// it bounces between the two. Closing either would take watching the window, with an - /// AXObserver recording where it settles and dropping the entry when it moves anywhere else. + /// another display reads back its old frame, which has not moved and is off the target's + /// top-left, so the move goes unrecorded. A window held wider than its tile keeps its + /// top-left and sticks out to the right, so one carried left onto a display narrower than + /// itself reaches back over the display it came from, with its centre there; its next Left + /// Half goes by that display, and it bounces between the two. Closing either would take + /// watching the window, with an AXObserver recording where it settles and dropping the entry + /// when it moves anywhere else. /// /// Returns the outcome and, when a placement was recorded, where the window landed. private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, by tile: Tile, from current: CGRect) -> (outcome: CommandOutcome, landed: CGRect?) { @@ -187,14 +194,25 @@ final class WindowDirector { diagnose("\(tile.rawValue): could not read the frame back, so the placement is not recorded") return (applied.outcome, nil) } - guard readBack.sharesTopLeft(with: target) else { - diagnose("\(tile.rawValue): read back \(readBack), off the top-left of \(target), so the placement is not recorded") + if let missed = missedLanding(readBack, at: target, from: current) { + diagnose("\(tile.rawValue): read back \(readBack), \(missed), so the placement is not recorded") return (applied.outcome, nil) } placements[key] = Placement(tile: tile, target: target, landed: readBack) return (applied.outcome, readBack) } + /// What keeps a window sent from `current` to `target`, which reads back at `readBack`, from + /// counting as landed there, or nil when nothing does. Cocoa space, so the top is `maxY`. + private func missedLanding(_ readBack: CGRect, at target: CGRect, from current: CGRect) -> String? { + if abs(readBack.minX - target.minX) > 1 { return "off the left edge of \(target)" } + if readBack.maxY > target.maxY + 1 { return "above the top of \(target)" } + if readBack.maxY < target.maxY - 1, readBack.isWithinAPoint(of: current) { + return "below the top of \(target) without having moved" + } + return nil + } + /// Whether a window at `current` is already in the tile whose frame is `target`: it fills the /// tile, or it is where it landed the last time Loadstone sent it to this same frame. The /// second covers apps that never fill a tile exactly: rounding to a character grid (Terminal, @@ -209,15 +227,16 @@ final class WindowDirector { /// Fits a window that is not in the half `tile` into it, at `target`, and logs why it was not /// carried on in three cases. Either the window had a placement by this same tile that no /// longer held, because the window had moved since or the display had changed; or it reads - /// back where it was: a Terminal window Loadstone has not put in the half since it started, - /// one whose record another command dropped, or a minimum-width window that Left Third left - /// where Left Half leaves it too, none of which the press moves, or a window at the half's - /// top-left whose app applies the frame late and still reports the old one, which moves once - /// the app catches up; or, with no placement standing, it had the half's top-left corner - /// without filling it, and the fit can be too small to see. Otherwise the fit goes unlogged: - /// from where another tile put the window, or from anywhere else, it normally shows, though - /// from a few points off the half it can be too small to see as well. A refused fit is - /// reported as a refusal. + /// back where it was, at the half's top-left, the one place `place` records a window that + /// has not moved: a Terminal window Loadstone has not put in the half since it started, one + /// whose record another command dropped, or a minimum-width window that Left Third left where + /// Left Half leaves it too, none of which the press moves, or a window whose app applies the + /// frame late and still reports the old one, which moves once the app catches up; or, with no + /// placement standing, it had the half's top-left corner without filling it, and the fit can + /// be too small to see. Otherwise the fit goes unlogged: from where another tile put the + /// window, or from anywhere else, it normally shows, though from a few points off the half it + /// can be too small to see as well. A fit left unrecorded has `place` say what its read-back + /// missed, and a refused fit is reported as a refusal. private func fit(_ window: some MovableWindow, key: WindowIdentity?, into tile: Tile, at target: CGRect, from current: CGRect) -> CommandOutcome { let last = key.flatMap { placements[$0] } let hadStandingPlacement = standingPlacement(for: key, at: current) != nil diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 25ce1e6..3978dda 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -19,6 +19,10 @@ final class WindowDirectorTests: XCTestCase { /// Set to act like an app that will not make the window narrower than this. Like the grid, /// it keeps the top-left corner, so the window grows to the right. var minimumWidth: CGFloat? + /// Set to act like macOS, which moves a window after its app has sized and placed it, as + /// in keeping its top below a menu bar (`keptBelowTheMenuBar`). Applied to where the + /// window lands, after the grid and the minimum width. + var constrain: ((CGRect) -> CGRect)? /// Set to act like an app that applies a frame only after the write has returned, so the /// frame read straight back is still the old one. `catchUp()` applies it. var appliesLate = false @@ -61,6 +65,9 @@ final class WindowDirectorTests: XCTestCase { if let minimumWidth { landed.size.width = max(landed.width, minimumWidth) } + if let constrain { + landed = constrain(landed) + } if appliesLate { pending = landed } else { @@ -95,6 +102,29 @@ final class WindowDirectorTests: XCTestCase { private let flushTopLeft = CGRect(x: 1920, y: 515, width: 800, height: 600) /// A Terminal character cell. No half's width or height here is a whole number of them. private let terminalCell = CGSize(width: 7, height: 17) + /// A laptop with a 32pt menu bar, and to its left a monitor with none, their tops level, so + /// the monitor's visible frame reaches 32pt higher than the laptop's. + private let laptop = Display( + frame: CGRect(x: 0, y: 0, width: 1201, height: 901), + visibleFrame: CGRect(x: 0, y: 0, width: 1201, height: 869) + ) + private let monitor = Display( + frame: CGRect(x: -1441, y: 48, width: 1441, height: 853), + visibleFrame: CGRect(x: -1441, y: 48, width: 1441, height: 853) + ) + + /// Where macOS leaves a window that lands at `frame` with `displays` attached: it keeps the + /// window's top below the menu bar of the display holding the largest part of it, pulling + /// the window down when it reaches higher. + private static func keptBelowTheMenuBar(_ frame: CGRect, among displays: [Display]) -> CGRect { + func area(on display: Display) -> CGFloat { + let part = display.frame.intersection(frame) + return part.isNull ? 0 : part.width * part.height + } + guard let holder = displays.max(by: { area(on: $0) < area(on: $1) }), + frame.maxY > holder.visibleFrame.maxY else { return frame } + return frame.offsetBy(dx: 0, dy: holder.visibleFrame.maxY - frame.maxY) + } private func makeDirector() -> WindowDirector { WindowDirector(displays: { [self.primary, self.right] }) @@ -371,6 +401,45 @@ final class WindowDirectorTests: XCTestCase { ]) } + func testRepeatedLeftHalfWalksAWindowThatMacOSPullsBelowAMenuBarWithoutBouncing() { + // The monitor's right half starts at x = -721 and is 721 wide, so this window, which will + // not go below 1501, reaches 780 onto the laptop: most of it, so macOS pulls it down 32pt, + // below the laptop's menu bar, off the half's top-left. Its centre, at + // -721 + 1501 / 2 = 29.5, is on the laptop too. In the monitor's left half, 1441 of it is + // on the monitor and it keeps the half's top. + let window = FakeWindow(frame: CGRect(x: 100, y: 100, width: 1501, height: 600)) + window.minimumWidth = 1501 + let desk = [laptop, monitor] + window.constrain = { Self.keptBelowTheMenuBar($0, among: desk) } + let director = WindowDirector(displays: { desk }) + + for _ in 0..<4 { director.perform(.tile(.leftHalf), on: window) } + + XCTAssertEqual(window.writes, [ + Tile.leftHalf.frame(in: laptop.visibleFrame), + Tile.rightHalf.frame(in: monitor.visibleFrame), + Tile.leftHalf.frame(in: monitor.visibleFrame), + Tile.leftHalf.frame(in: monitor.visibleFrame), + ]) + } + + func testAWindowThatMacOSPulledBelowAMenuBarCountsAsInTheHalf() throws { + // Right Half from the monitor leaves 780 of this window on the laptop, as above. Pulled + // down off the half's top-left, it still counts as in the half, so the next Right Half + // carries it on. + let window = FakeWindow(frame: CGRect(x: -1301, y: 100, width: 1501, height: 600)) + window.minimumWidth = 1501 + let desk = [laptop, monitor] + window.constrain = { Self.keptBelowTheMenuBar($0, among: desk) } + let director = WindowDirector(displays: { desk }) + director.perform(.tile(.rightHalf), on: window) + let landed = try XCTUnwrap(window.cocoaFrame) + XCTAssertEqual(landed.maxY, laptop.visibleFrame.maxY, "pulled down below the laptop's menu bar") + + XCTAssertEqual(director.perform(.tile(.rightHalf), on: window), .moved) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: laptop.visibleFrame)) + } + func testCenterWorksOnTheDisplayAWiderWindowWasPlacedOn() throws { // As above, the right half of the left display leaves this window's centre on primary. let window = FakeWindow(frame: CGRect(x: -1400, y: 100, width: 1501, height: 600)) @@ -654,7 +723,8 @@ final class WindowDirectorTests: XCTestCase { } func testAnOldFrameOnlyOnTheHalfsLeftEdgeIsNotRememberedAsPlaced() { - // Flush with the display's left edge, below its top. + // Flush with the display's left edge, below its top, as a window macOS pulled down below + // a menu bar would be; but read straight back, the window has not moved. let start = CGRect(x: 1920, y: 100, width: 800, height: 600) let window = FakeWindow(frame: start) window.appliesLate = true @@ -681,6 +751,28 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testAWindowThatLandsAboveTheHalfsTopIsNotRememberedAsPlaced() { + // macOS only pulls a window down, below a menu bar, so this one went higher some other way. + let window = FakeWindow(frame: floating) + window.constrain = { $0.offsetBy(dx: 0, dy: 41) } + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testAWindowThatLandsOffTheHalfsLeftEdgeIsNotRememberedAsPlaced() { + // Moved, and level with the half's top, but 41pt right of its left edge. + let window = FakeWindow(frame: floating) + window.constrain = { $0.offsetBy(dx: 41, dy: 0) } + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + // An app that applies frames late reports the old frame when read straight back. A window // flush in the display's top-left corner already shares the half's top-left, so that stale // frame is recorded as where the half left it. The tests below put the window back on it @@ -775,8 +867,8 @@ final class WindowDirectorTests: XCTestCase { // A half press logs carrying a window on and stopping at the edge of the desk. A fit into // the half explains itself when Loadstone last put the window in that half, when the press // leaves it where it was, or when it had the half's top-left with no placement standing, and - // otherwise goes unlogged. Each test starts from empty lines, or clears them, before the - // press it is about. + // otherwise goes unlogged. A tile whose placement goes unrecorded says what its read-back + // missed. Each test starts from empty lines, or clears them, before the press it is about. func testAFirstPressIsLoggedOnlyForAWindowThatHadTheHalfsTopLeft() { // From anywhere else the fit shows. From the half's top-left corner, with nothing to say @@ -873,6 +965,23 @@ final class WindowDirectorTests: XCTestCase { } } + func testAPlacementThatGoesUnrecordedSaysWhatTheReadBackMissed() { + let offTheLeftEdge = FakeWindow(frame: floating) + offTheLeftEdge.constrain = { $0.offsetBy(dx: 41, dy: 0) } + let aboveTheTop = FakeWindow(frame: floating) + aboveTheTop.constrain = { $0.offsetBy(dx: 0, dy: 41) } + // Flush with the display's left edge, below its top, and still reporting that frame. + let late = FakeWindow(frame: CGRect(x: 1920, y: 100, width: 800, height: 600)) + late.appliesLate = true + + for (window, missed) in [(offTheLeftEdge, "off the left edge of"), (aboveTheTop, "above the top of"), (late, "without having moved")] { + let diagnostics = Diagnostics() + makeDirector(diagnosingInto: diagnostics).perform(.tile(.leftHalf), on: window) + XCTAssertEqual(diagnostics.lines.count, 1, "\(diagnostics.lines)") + XCTAssertTrue(diagnostics.lines.allSatisfy { $0.contains(missed) && $0.contains("not recorded") }, "\(diagnostics.lines)") + } + } + func testAHalfAtTheEdgeOfTheDeskIsLogged() { let window = FakeWindow(frame: Tile.leftHalf.frame(in: primary.visibleFrame)) let diagnostics = Diagnostics() From 8b15965c09fe90d5f6a5a80fbc8d8165ec9c503d Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Sat, 26 Sep 2026 00:11:55 +0200 Subject: [PATCH 40/44] Describe the recording rule for a window pulled below a menu bar CLAUDE.md said a placement is recorded only when the read-back has the target's top-left. place() also records one with the target's left edge and a lower top once the window has moved, because macOS pulls a wide window below the menu bar of the display holding most of it. The Continuation bullet now states that rule, and the logging bullet says that an unrecorded placement logs what its read-back missed. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f5aadfd..7d0d7c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,11 +61,11 @@ Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) - **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 top-left: a late-applying app reads back its old frame. + - 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, 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, and a late-applying app reads back its old frame, which has not moved. - 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. - - 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. They go through the director's `diagnose` closure, which is how the tests see them. + - 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. - **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. From 9d80d1fbd58fca520c6c2d1742fbd4bfeee54cdb Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Sat, 26 Sep 2026 00:30:59 +0200 Subject: [PATCH 41/44] Say that the fake window's constrain need not act like macOS FakeWindow.constrain was documented as acting like macOS, but three of the tests that set it shift the window in ways macOS never does: 41pt up, which one of them says outright, or 41pt right. The doc now says it moves the window after its app has sized and placed it, as macOS does in keeping its top below a menu bar, or as something else might. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 3978dda..799d8e7 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -19,9 +19,9 @@ final class WindowDirectorTests: XCTestCase { /// Set to act like an app that will not make the window narrower than this. Like the grid, /// it keeps the top-left corner, so the window grows to the right. var minimumWidth: CGFloat? - /// Set to act like macOS, which moves a window after its app has sized and placed it, as - /// in keeping its top below a menu bar (`keptBelowTheMenuBar`). Applied to where the - /// window lands, after the grid and the minimum width. + /// Set to move the window after its app has sized and placed it, as macOS does in keeping + /// its top below a menu bar (`keptBelowTheMenuBar`), or as something else might. Applied + /// to where the window lands, after the grid and the minimum width. var constrain: ((CGRect) -> CGRect)? /// Set to act like an app that applies a frame only after the write has returned, so the /// frame read straight back is still the old one. `catchUp()` applies it. From 1f949ef93890247d06cb0ae4d91b1b7783e826a3 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Sat, 26 Sep 2026 00:31:42 +0200 Subject: [PATCH 42/44] Pin that a resize alone counts as the window having moved place() counts a landing below the target's top once the read-back differs from the frame before the write on any edge. Comparing only the top-left would pass every existing test, but it misses a real case: Top Right on a monitor, then Right Half, both pulled down to the same neighbour's menu bar, land with the same top-left and differ only in height. The Right Half would go unrecorded, and the next press would go by the display under the window's centre and fit it into the laptop's right half instead of carrying it on. The new test walks that case. Swapping the whole-frame comparison for a top-left one fails it, and only it. Co-Authored-By: Claude Opus 5.5 --- LoadstoneTests/WindowDirectorTests.swift | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index 799d8e7..d442e6f 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -440,6 +440,27 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: laptop.visibleFrame)) } + func testATopRightThenRightHalfPulledToTheSameCornerStillCountsAsInTheHalf() throws { + // Top Right on the monitor leaves 780 of this window on the laptop too, so it is pulled + // down to the laptop's menu bar, and so is the Right Half that follows. Both land with + // the same top-left, one taller than the other: the top-left alone cannot tell the second + // landing from a window that never moved, but the whole frame can. + let window = FakeWindow(frame: CGRect(x: -1301, y: 100, width: 1501, height: 600)) + window.minimumWidth = 1501 + let desk = [laptop, monitor] + window.constrain = { Self.keptBelowTheMenuBar($0, among: desk) } + let director = WindowDirector(displays: { desk }) + director.perform(.tile(.topRight), on: window) + let corner = try XCTUnwrap(window.cocoaFrame) + director.perform(.tile(.rightHalf), on: window) + let half = try XCTUnwrap(window.cocoaFrame) + XCTAssertEqual(CGPoint(x: half.minX, y: half.maxY), CGPoint(x: corner.minX, y: corner.maxY), "the same top-left") + XCTAssertNotEqual(half.height, corner.height) + + director.perform(.tile(.rightHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: laptop.visibleFrame)) + } + func testCenterWorksOnTheDisplayAWiderWindowWasPlacedOn() throws { // As above, the right half of the left display leaves this window's centre on primary. let window = FakeWindow(frame: CGRect(x: -1400, y: 100, width: 1501, height: 600)) From dfeb4210976dd7118c01a7907cb76ba38a079192 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Sat, 26 Sep 2026 00:34:46 +0200 Subject: [PATCH 43/44] Keep a pulled-down half placed when pressed at the edge of the desk A window more than twice as wide as the leftmost display, whose top reaches above its neighbour's menu bar, is pulled down below that menu bar in either half of it. Left Half carried it there and recorded it, since it had moved. At the edge of the desk the next Left Half sends it to the same frame again, and it reads back where it was: below the half's top without having moved, so the placement went unrecorded. The press after that went by the display under the window's centre, the neighbour, and fitted the window into that display's left half. A window that reads back where it was now also counts when it was already in the tile before the write (isPlaced): it fills the tile or stands where Loadstone last recorded it landing from this same frame, so recording it again adds nothing. That is asked before the write, which drops the placement it goes by. A frame where another tile left the window still counts only with the target's top-left, so a stale read-back from an app that applies frames late is recorded no more than before. The new edge-of-desk test fails without the exception or with it asked after the write, and the new late-apply test fails if any standing placement, whatever frame it was for, lets the read-back count. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- Loadstone/Core/WindowDirector.swift | 33 +++++++++++------- LoadstoneTests/WindowDirectorTests.swift | 44 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d0d7c7..4b4e3f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) - **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, 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, and a late-applying app reads back its old frame, which has not moved. + - 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. diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index fd55ef2..bae9765 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -161,14 +161,20 @@ final class WindowDirector { /// Sends the window to `target`, the frame of `tile`, then records where it actually ended /// up, so that pressing the same tile again can tell the window has not moved since. Recorded /// only if the read-back has the target's left edge and either its top-left corner or, once - /// the window has moved, a top no higher than the target's (`missedLanding` says which - /// failed). An app that rounds or caps a size normally keeps the corner. macOS keeps a - /// window's top below the menu bar of the display holding most of it, so a window held wider - /// than its tile and hanging mostly over a display whose menu bar is lower than the tile's - /// top is pulled down below that menu bar. It has still landed, and left unrecorded it would - /// bounce, as below; macOS never pulls a window up, so a read-back above the target does not - /// count. A read-back where the window was before the write is what an app still reporting - /// its old frame gives, so that counts only with the corner. + /// the window has moved or if it was already in the tile, a top no higher than the target's + /// (`missedLanding` says which failed). An app that rounds or caps a size normally keeps the + /// corner. macOS keeps a window's top below the menu bar of the display holding most of it, + /// so a window held wider than its tile and hanging mostly over a display whose menu bar is + /// lower than the tile's top is pulled down below that menu bar. It has still landed, and + /// left unrecorded it would bounce, as below; macOS never pulls a window up, so a read-back + /// above the target does not count. A read-back where the window was before the write is what + /// an app still reporting its old frame gives, so that counts only with the corner, unless + /// the window was already in the tile (`isPlaced`). Then it fills the tile or stands where it + /// landed the last time Loadstone sent it to this same frame, and recording that again adds + /// nothing. That is what Left or Right Half reads back at the edge of the desk from a window + /// pulled below a menu bar, which left unrecorded would go by the display under its centre at + /// the next press. Where another tile left the window says nothing of where this one does, so + /// that still counts only with the corner. /// /// That read-back is the one look this takes, which leaves two cases open, both from an app /// that applies the frame late. If the window's old frame already shared the target's @@ -188,13 +194,15 @@ final class WindowDirector { /// /// Returns the outcome and, when a placement was recorded, where the window landed. private func place(_ window: some MovableWindow, key: WindowIdentity?, at target: CGRect, by tile: Tile, from current: CGRect) -> (outcome: CommandOutcome, landed: CGRect?) { + // Asked before the write, which drops the placement it goes by. + let wasPlaced = isPlaced(current, in: target, key: key) let applied = apply(target, to: window, key: key, from: current, remembering: current) guard applied.outcome == .moved, let key = applied.key else { return (applied.outcome, nil) } guard let readBack = window.cocoaFrame else { diagnose("\(tile.rawValue): could not read the frame back, so the placement is not recorded") return (applied.outcome, nil) } - if let missed = missedLanding(readBack, at: target, from: current) { + if let missed = missedLanding(readBack, at: target, from: current, wasPlaced: wasPlaced) { diagnose("\(tile.rawValue): read back \(readBack), \(missed), so the placement is not recorded") return (applied.outcome, nil) } @@ -203,11 +211,12 @@ final class WindowDirector { } /// What keeps a window sent from `current` to `target`, which reads back at `readBack`, from - /// counting as landed there, or nil when nothing does. Cocoa space, so the top is `maxY`. - private func missedLanding(_ readBack: CGRect, at target: CGRect, from current: CGRect) -> String? { + /// counting as landed there, or nil when nothing does. `wasPlaced` says whether the window + /// was already in that tile before the write (`isPlaced`). Cocoa space, so the top is `maxY`. + private func missedLanding(_ readBack: CGRect, at target: CGRect, from current: CGRect, wasPlaced: Bool) -> String? { if abs(readBack.minX - target.minX) > 1 { return "off the left edge of \(target)" } if readBack.maxY > target.maxY + 1 { return "above the top of \(target)" } - if readBack.maxY < target.maxY - 1, readBack.isWithinAPoint(of: current) { + if readBack.maxY < target.maxY - 1, readBack.isWithinAPoint(of: current), !wasPlaced { return "below the top of \(target) without having moved" } return nil diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index d442e6f..d784d53 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -423,6 +423,32 @@ final class WindowDirectorTests: XCTestCase { ]) } + func testRepeatedLeftHalfKeepsAWindowPulledBelowAMenuBarAtTheEdgeOfTheDesk() { + // A monitor 601 wide, with no menu bar, its top level with the laptop's. In either of + // its halves this window, which will not go below 1501, has most of itself on the laptop, + // so macOS pulls it down 32pt, and its centre is on the laptop too. At the edge of the + // desk, Left Half sends it to the frame it is already in, and it reads back where it was. + let narrow = Display( + frame: CGRect(x: -601, y: 48, width: 601, height: 853), + visibleFrame: CGRect(x: -601, y: 48, width: 601, height: 853) + ) + let window = FakeWindow(frame: CGRect(x: 100, y: 100, width: 1501, height: 600)) + window.minimumWidth = 1501 + let desk = [laptop, narrow] + window.constrain = { Self.keptBelowTheMenuBar($0, among: desk) } + let director = WindowDirector(displays: { desk }) + + for _ in 0..<5 { director.perform(.tile(.leftHalf), on: window) } + + XCTAssertEqual(window.writes, [ + Tile.leftHalf.frame(in: laptop.visibleFrame), + Tile.rightHalf.frame(in: narrow.visibleFrame), + Tile.leftHalf.frame(in: narrow.visibleFrame), + Tile.leftHalf.frame(in: narrow.visibleFrame), + Tile.leftHalf.frame(in: narrow.visibleFrame), + ]) + } + func testAWindowThatMacOSPulledBelowAMenuBarCountsAsInTheHalf() throws { // Right Half from the monitor leaves 780 of this window on the laptop, as above. Pulled // down off the half's top-left, it still counts as in the half, so the next Right Half @@ -772,6 +798,24 @@ final class WindowDirectorTests: XCTestCase { XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) } + func testAnOldFrameWhereAnotherTileLeftTheWindowIsNotRememberedAsPlaced() { + // Pulled down 41pt wherever it lands, the window is recorded where Left Third left it. + // Read straight back after Left Half, it is still there, below the half's top without + // having moved: where another tile landed it, which says nothing of where the half does. + let window = FakeWindow(frame: floating) + window.constrain = { $0.offsetBy(dx: 0, dy: -41) } + let director = makeDirector() + director.perform(.tile(.leftThird), on: window) + let third = window.cocoaFrame + window.appliesLate = true + director.perform(.tile(.leftHalf), on: window) + window.catchUp() + window.cocoaFrame = third // dragged back by hand + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + func testAWindowThatLandsAboveTheHalfsTopIsNotRememberedAsPlaced() { // macOS only pulls a window down, below a menu bar, so this one went higher some other way. let window = FakeWindow(frame: floating) From d1ad265985fd465106701aa024b77f905dd46033 Mon Sep 17 00:00:00 2001 From: mortenbrudvik Date: Sat, 26 Sep 2026 09:07:44 +0200 Subject: [PATCH 44/44] Document wide and minimum-width windows in half continuation The live hand-check of Left/Right Half continuation turned up cases the docs did not cover or described misleadingly. README: the troubleshooting entry named only step-sized windows as ones that count as in the half by Loadstone's record; a window with a minimum width wider than the half follows the same rule. A new entry explains why such a window ends up wider than the half (and can pass for full width), that pressing again still carries it on, and that with "Displays have separate Spaces" on a window wider than a screen can look as if it stayed put or vanished when it moves on. CLAUDE.md: say that going by the centre for Next/Previous Display does not help a window wider than the display under its centre, since Layout.mapped is proportional and unclamped (known, unhandled), and add how the screens mislead a hand test so it is judged by the frame. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 ++- README.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b4e3f0..4b81251 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,9 +64,10 @@ Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) - 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. + - 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. diff --git a/README.md b/README.md index 3e569a7..62e9c58 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,9 @@ Menu bar → Loadstone → **Loadstone Help** opens this page. **Next / previous display beeps.** Only one display is attached. -**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not, and neither does one touching only at a corner. And the window has to be in that half already: if you have moved it since, or the resolution or the Dock has changed, the first press puts it back into the half and the next one carries it on. Windows that size in steps, such as Terminal, never fill the half exactly, so they count as in the half only if Loadstone put them there since it last started and nothing has moved them since. That includes dragging their screen to a new spot in System Settings → Displays, and Loadstone’s own commands, even a next display followed by a previous display that brings the window back to the same spot. When a window that looks to be in the half is put back into it rather than carried on, the log (below) says why when Loadstone last put it in that half, when the press left it where it was, and when it sat at the half’s top-left corner without Loadstone having put it there; those are info messages, so keep `--info`. +**Pressing `⌃⌥←` again does not move the window to the screen beside it.** Only a screen to the side counts, as arranged in System Settings → Displays; one above or below does not, and neither does one touching only at a corner. And the window has to be in that half already: if you have moved it since, or the resolution or the Dock has changed, the first press puts it back into the half and the next one carries it on. Windows that size in steps, such as Terminal, or that will not get as narrow as the half, never fill it exactly, so they count as in the half only if Loadstone put them there since it last started and nothing has moved them since. That includes dragging their screen to a new spot in System Settings → Displays, and Loadstone’s own commands, even a next display followed by a previous display that brings the window back to the same spot. When a window that looks to be in the half is put back into it rather than carried on, the log (below) says why when Loadstone last put it in that half, when the press left it where it was, and when it sat at the half’s top-left corner without Loadstone having put it there; those are info messages, so keep `--info`. + +**A window ends up wider than the half, even nearly full width.** Its app will not let it get that narrow, so it lines up with the half’s left edge and sticks out to the right (a third or a corner does the same). Pressing the half again still carries it on. With “Displays have separate Spaces” on (System Settings → **Desktop & Dock**), as it is by default, macOS draws a window only on the screen holding most of it, so one wider than a whole screen can look as if it stayed put, or vanished, when it moves on. **macOS’s own edge tiling is off after Loadstone crashed or was force-quit.** Loadstone turns it off while running and back on when it quits normally; after an unclean exit, simply launching Loadstone again restores it. To put it back by hand, use System Settings → **Desktop & Dock** → **Windows**.