diff --git a/CLAUDE.md b/CLAUDE.md index ca181f6..4b81251 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,15 +58,24 @@ Tests run inside the real app as host (`TEST_HOST`), so `AppDelegate.application Data flows in one direction: input → `WindowDirector` → `MovableWindow` (AX) → screen geometry. - **Input.** `HotkeyCenter` registers one global hotkey per `WindowCommand` through the KeyboardShortcuts package (Carbon hotkeys; fire on key-up). `EdgeDragMonitor` owns the `SnapPreview` overlay and the mouse monitors (behind `MouseEventMonitoring`, so the setting-driven install/uninstall lifecycle is testable without Accessibility trust) but makes no decisions: it feeds events to `DragSnapTracker`, a pure state machine that resolves the window under the pointer only after the drag threshold, arms only once that window's origin has moved (so text-selection and slider drags never snap), and reads the origin only while the pointer is inside a snap zone — and then at most once per `originPollInterval` (50ms), because each read is a synchronous AX round trip on the main thread and polling at event rate stutters the drag. -- **Director.** `WindowDirector` runs a `WindowCommand` on a `MovableWindow` and returns a `CommandOutcome`; the no-argument `perform(_:)` overload resolves the focused window, beeps, logs, and triggers the Accessibility prompt. It keeps the restore ledger (first frame per `WindowIdentity`, one-shot restore, pruned when the owning process quits because macOS reuses window ids). The ledger is written *after* the window accepts a frame, never before, so a refused write or a command that never ran leaves nothing for a later Restore to act on; Restore likewise drops its entry only once the old frame has been accepted. Drag snaps pass the display the gesture ended on explicitly; the hotkey path uses the display under the window's centre. -- **AX layer.** `AXWindow` is the only file that handles Accessibility-space (top-left origin) coordinates; everything it exposes is Cocoa space. It validates the window role in its initializer, returns `AXError` from writes instead of swallowing them, wraps every frame write in a save-disable-restore of `AXEnhancedUserInterface`, applies size → position → size (apps clamp a position-first write to the old display), and resolves the private `_AXUIElementGetWindow` SPI with `dlsym` so a missing symbol degrades to a title-based identity. A 0.25s AX messaging timeout is installed at launch. `AXWindowTests` drives this file through real AX calls against windows the test host owns: a process may read and write its own Accessibility hierarchy untrusted, so those tests need no TCC grant and run in CI. What they cannot reach is another app: a refused frame, a hang past the messaging timeout, or the `AXEnhancedUserInterface` dance. -- **Geometry.** `Tile.frame(in:)` floors the left/bottom pieces and gives the remainder to the right/top ones so complementary tiles meet exactly on odd widths; `leftTwoThirds` is `2 * thirdW` wide for the same reason. `SnapZones` maps a pointer against the *full* screen frame, tiles are laid into the *visible* frame. `ScreenGeometry` flips about the primary display's top edge (`NSScreen.screens[0]`, never `NSScreen.main`). `Display` is a value snapshot of an `NSScreen` so multi-display logic is testable. `ScreenGeometry.neighbor(of:delta:in:)` walks displays in reading order (x ascending, then top before bottom), not `NSScreen.screens` order, so Next/Previous Display follow the desk layout. +- **Director.** `WindowDirector` runs a `WindowCommand` on a `MovableWindow` and returns a `CommandOutcome`; the no-argument `perform(_:)` overload resolves the focused window, beeps, logs, and triggers the Accessibility prompt. It keeps the restore ledger (first frame per `WindowIdentity`, one-shot restore, pruned when the owning process quits because macOS reuses window ids). The ledger is written *after* the window accepts a frame, never before, so a refused write or a command that never ran leaves nothing for a later Restore to act on; Restore likewise drops its entry only once the old frame has been accepted. Drag snaps pass the display the gesture ended on explicitly. From hotkeys and the menu, a tile or Center uses the display holding the window's placement while the window is still where it landed (below); otherwise, and always for Next/Previous Display, the display under the window's centre, or the primary when it is on none. The identity is read once per command, since on `AXWindow` it costs AX calls. A title-based one is read again after an accepted write, and the ledger and the placement are recorded under the new one, a ledger entry under the old one moving across, because a title can follow the window's size (Terminal's does). +- **Continuation.** Left/Right Half on a window already in that half carry it into the opposite half of the display beside it (`Tile.continuation`, `ScreenGeometry.adjacent`); at the edge of the desk they apply the half again, with no beep. Rules to keep (the detail is in `WindowDirector`): + - "Already in" means within a point of the tile, or of where the window landed when Loadstone last sent it to that same frame (`placements`): grid-rounding and minimum-width windows never fill a tile. + - Only a tile records a placement, drag snaps included, and only if the read-back has the target's left edge and either its top-left or, once the window has moved or if it was already in that tile, a top no higher than the target's: macOS pulls a window down below the menu bar of the display holding most of it, so a wide window carried onto a display that reaches above that menu bar lands lower than it was sent; a late-applying app reads back its old frame, which has not moved; and a pulled-down window given the same half again at the edge of the desk reads back where it was, which that half has already recorded. + - Any write the window accepts drops its placement (a tile then records a fresh one), as do a refused write that moved the window anyway (`AXWindow` sets the size before the position) or left its frame unreadable, and the process quitting. + - A new resolution or a moved Dock alters the tile's frame, so the first press refits the window and the next carries it on. + - While the window is where it landed, tiles and Center use the display holding the target: a window wider than its tile can have its centre on the next display, which would send the next half from the wrong one, and one wider than the display it was placed on would bounce between the two. Next/Previous Display go by the centre, since mapped from a display narrower than the window it comes out wider than the display it goes to. Going by the centre avoids that only for a window narrower than the display under its centre: `Layout.mapped` is proportional and unclamped, so a wider one (one wider than every display, say) still comes out wider than the display it goes to and can hang far off the edge of the desk (known, unhandled). + - Info-level `Log.ax` lines say when a half press carries a window on or stops at the edge of the desk, and why it refits the window instead when Loadstone last put it in that half, the press leaves it where it was, or it had the half's top-left with no placement standing. Otherwise the fit is left unlogged, even one from a few points off the half that is too small to see. A placement left unrecorded logs what the read-back missed. They go through the director's `diagnose` closure, which is how the tests see them. + - Residuals, from late-applying apps, which only an `AXObserver` would close: an old frame sharing the target's top-left is recorded as where the window landed, so a window put back there by hand is carried on (one known by a title that follows its size even when Loadstone put it back, since the title reads back stale too and the entry stays under it); and a carry-over goes unrecorded, so a window carried left onto a display narrower than itself bounces back. + - Reading a hand test: with "Displays have separate Spaces" on (the macOS default), a window spanning the seam between two displays is drawn only on the one holding most of it, so a wide window carried onto the next display can look as if it stayed put or vanished; and a window whose minimum width exceeds the half stays wider than it, which can pass for maximized. Go by the frame (CGWindowList), not by what the screens show. +- **AX layer.** `AXWindow` is the only file that handles Accessibility-space (top-left origin) coordinates; everything it exposes is Cocoa space. It validates the window role in its initializer, returns `AXError` from writes instead of swallowing them, wraps every frame write in a save-disable-restore of `AXEnhancedUserInterface`, applies size → position → size (apps clamp a position-first write to the old display), and resolves the private `_AXUIElementGetWindow` SPI with `dlsym` so a missing symbol degrades to a title-based identity. A 0.25s AX messaging timeout is installed at launch. `AXWindowTests` drives this file through real AX calls against windows the test host owns: a process may read and write its own Accessibility hierarchy untrusted, so those tests need no TCC grant. CI skips them (`-skip-testing`) because its runner has no window server that answers AX, so they run only on a developer machine, and a change to `AXWindow` has to be tested there. What they cannot reach is another app: a refused frame, a hang past the messaging timeout, or the `AXEnhancedUserInterface` dance. +- **Geometry.** `Tile.frame(in:)` floors the left/bottom pieces and gives the remainder to the right/top ones so complementary tiles meet exactly on odd widths; `leftTwoThirds` is `2 * thirdW` wide for the same reason. `SnapZones` maps a pointer against the *full* screen frame, tiles are laid into the *visible* frame. `ScreenGeometry` flips about the primary display's top edge (`NSScreen.screens[0]`, never `NSScreen.main`). `Display` is a value snapshot of an `NSScreen` so multi-display logic is testable. `ScreenGeometry.neighbor(of:delta:in:)` walks displays in reading order (x ascending, then top before bottom), not `NSScreen.screens` order, so Next/Previous Display follow the desk layout. `ScreenGeometry.adjacent(to:toward:in:)` is the directional one behind the Left/Right Half continuation: it takes a `Display.Side` and never wraps (nil at the edge of the desk), and only a display wholly to that side and sharing some height counts, so a monitor above or below never counts as beside it, nor does one touching only at a corner. Of several, it picks the nearest, then the one sharing more height, then the higher, whatever order AppKit lists them in. ### Invariants worth knowing before editing -- `WindowCommand` is the single source of truth for hotkeys, the status-bar menu, and the Shortcuts pane. Adding a `Tile` case fails to compile until `title`, `defaultShortcut`, and `section` cover it, then it appears everywhere. `WindowCommand.id` strings are the UserDefaults keys where users' custom shortcuts live (`KeyboardShortcuts_`); 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. +- 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/App/AppDelegate.swift b/Loadstone/App/AppDelegate.swift index 6bc8101..192086f 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 tile put it) is keyed by + // window id, which macOS reuses; drop a process's entries when it quits so a new window + // cannot inherit them. terminationObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main ) { notification in 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/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/MovableWindow.swift b/Loadstone/Core/MovableWindow.swift index 4896abd..7822016 100644 --- a/Loadstone/Core/MovableWindow.swift +++ b/Loadstone/Core/MovableWindow.swift @@ -1,12 +1,15 @@ import ApplicationServices -/// How `WindowDirector` recognises a window across commands, so Restore can find its memory. +/// How `WindowDirector` recognises a window across commands, so Restore and a repeated half can +/// find what the director remembers about the window. enum WindowIdentity: Hashable, Sendable { /// Stable for the window's lifetime. macOS reuses ids after a window closes, which is why /// the director drops entries when their process quits. case cgWindow(CGWindowID, pid: pid_t) /// Weaker fallback when the window id is unavailable: titles change (browser tabs, "edited" - /// markers), which orphans the memory. + /// markers), which orphans the memory. A title that follows the window's size, as + /// Terminal's does, changes with every resize, so the director reads the identity again + /// after each write the window accepts and records under the title it reads then. case fallback(pid: pid_t, title: String) var pid: pid_t { diff --git a/Loadstone/Core/ScreenGeometry.swift b/Loadstone/Core/ScreenGeometry.swift index 3ed1f6d..ad6cbc0 100644 --- a/Loadstone/Core/ScreenGeometry.swift +++ b/Loadstone/Core/ScreenGeometry.swift @@ -57,6 +57,36 @@ enum ScreenGeometry { return ordered[(index + delta).modulo(ordered.count)] } + /// The display beside `display` toward `side`, for a move that heads one way rather than + /// round a cycle: the nearest display lying wholly to that side and sharing some of its + /// height, and of a stacked pair equally near, the one sharing more. Unlike `neighbor`, a + /// display only above or below never counts; reading order can put a monitor just before + /// the laptop under it, which would send a leftward move up. Nil when nothing is beside it, + /// so a move toward the edge of the desk stops there instead of wrapping. + static func adjacent(to display: Display, toward side: Display.Side, in displays: [Display] = Display.all) -> Display? { + let here = display.frame + // How far out from this display's edge on `side` the other one starts. Negative when + // their x ranges overlap (one above or below the other), and for any display on the + // other side. + func gap(_ other: CGRect) -> CGFloat { + switch side { + case .left: return here.minX - other.maxX + case .right: return other.minX - here.maxX + } + } + func sharedHeight(_ other: CGRect) -> CGFloat { + min(here.maxY, other.maxY) - max(here.minY, other.minY) + } + // Nearest, then most shared height, then the higher one, so that the answer never + // depends on the order AppKit lists displays in. + func rank(_ other: Display) -> (CGFloat, CGFloat, CGFloat) { + (gap(other.frame), -sharedHeight(other.frame), -other.frame.maxY) + } + return displays + .filter { gap($0.frame) >= 0 && sharedHeight($0.frame) > 0 } + .min { rank($0) < rank($1) } + } + private static func readingOrder(_ a: Display, _ b: Display) -> Bool { if a.frame.minX != b.frame.minX { return a.frame.minX < b.frame.minX } return a.frame.maxY > b.frame.maxY diff --git a/Loadstone/Core/Tile.swift b/Loadstone/Core/Tile.swift index b13e8ae..306e9d4 100644 --- a/Loadstone/Core/Tile.swift +++ b/Loadstone/Core/Tile.swift @@ -39,9 +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, -/// 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 @@ -183,6 +186,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: Display.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 + } + } } enum SnapZones { @@ -237,8 +254,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 } @@ -246,7 +261,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: diff --git a/Loadstone/Core/WindowDirector.swift b/Loadstone/Core/WindowDirector.swift index 88a5f01..bae9765 100644 --- a/Loadstone/Core/WindowDirector.swift +++ b/Loadstone/Core/WindowDirector.swift @@ -21,14 +21,39 @@ 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] = [:] + /// 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, 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, + /// and why a placement went unrecorded. + private let diagnose: (String) -> Void - init(displays: @escaping () -> [Display] = { Display.all }) { + 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 + } + + 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. @@ -52,33 +77,57 @@ final class WindowDirector { } } - /// Runs `command` on `window`, using the display under the window's centre. + /// 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 } + // 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() 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) + 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, 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 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) { + 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 + } + 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) + } + } + return place(window, key: key, at: target, by: tile, from: current).outcome 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) + 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 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 = apply(original, to: window, 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: - 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) } } @@ -87,43 +136,223 @@ 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, key: window.identity, at: tile.frame(in: display.visibleFrame), by: tile, from: current).outcome } - /// 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 { - guard let display = display(for: current, in: displays), + /// 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 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 } guard neighbor != display else { return .noOtherDisplay } - return apply(Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame), to: window, remembering: current) + let mapped = Layout.mapped(current, from: display.visibleFrame, to: neighbor.visibleFrame) + return apply(mapped, to: window, key: key, from: current, remembering: current).outcome + } + + /// 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 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 + /// 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. 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, 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?) { + // 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, wasPlaced: wasPlaced) { + 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. `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), !wasPlaced { + 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, + /// 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?) -> Bool { + current.isWithinAPoint(of: target) + || standingPlacement(for: key, at: current)?.target.isWithinAPoint(of: target) == true + } + + /// 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, 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 + 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 { + 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) { + 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) { + 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 } - /// 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. - private func apply(_ frame: CGRect, to window: some MovableWindow, remembering previous: CGRect?) -> CommandOutcome { + /// 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 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 + /// 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 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 + /// 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, 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 { return .rejected(error) } - if let previous { rememberIfNeeded(window, current: previous) } - return .moved + guard error == .success else { + if let key, placements[key] != nil, window.cocoaFrame?.isWithinAPoint(of: current) != true { + placements.removeValue(forKey: key) + } + return (.rejected(error), key) + } + if let key { placements.removeValue(forKey: key) } + let settled = keyAfterWrite(key, of: window) + if let previous { rememberIfNeeded(previous, for: settled, lookedUpBy: key) } + 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(_ window: some MovableWindow, current: CGRect) { - guard let key = window.identity, originals[key] == nil else { return } - originals[key] = current + /// 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 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) { + 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 + /// where Loadstone last put it, that is the display holding the frame it was sent to: a + /// 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) { + return placedOn + } + return display(under: frame, in: displays) } - /// 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? { + /// 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 + /// 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 { @@ -147,3 +376,21 @@ final class WindowDirector { } } } + +private extension CGRect { + /// Every edge within a point of `other`'s. Next Display maps a window proportionally, so a + /// 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 + } + + /// 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/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 diff --git a/LoadstoneTests/ScreenGeometryTests.swift b/LoadstoneTests/ScreenGeometryTests.swift index 42fbd44..756459c 100644 --- a/LoadstoneTests/ScreenGeometryTests.swift +++ b/LoadstoneTests/ScreenGeometryTests.swift @@ -107,4 +107,58 @@ 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() { + XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .left, in: displays), left) + XCTAssertEqual(ScreenGeometry.adjacent(to: primary, toward: .right, in: displays), right) + } + + func testAdjacentIsTheNearestDisplayOnThatSide() { + // 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() { + 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() { + 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 testOfAStackedPairSharingEqualHeightTheHigherIsAdjacentInAnyListOrder() { + let upper = display(x: -1920, y: 540, width: 1920, height: 1080) + let lower = display(x: -1920, y: -540, width: 1920, height: 1080) + 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/TileLayoutTests.swift b/LoadstoneTests/TileLayoutTests.swift index b274e9d..7b06597 100644 --- a/LoadstoneTests/TileLayoutTests.swift +++ b/LoadstoneTests/TileLayoutTests.swift @@ -105,6 +105,22 @@ final class TileLayoutTests: XCTestCase { } } + /// 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 } + 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) diff --git a/LoadstoneTests/WindowDirectorTests.swift b/LoadstoneTests/WindowDirectorTests.swift index bc9d21d..d784d53 100644 --- a/LoadstoneTests/WindowDirectorTests.swift +++ b/LoadstoneTests/WindowDirectorTests.swift @@ -5,23 +5,81 @@ 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? + /// 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 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. + 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? + /// 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 + /// 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? init(frame: CGRect, identity: WindowIdentity? = .cgWindow(1, pid: 42)) { cocoaFrame = frame - self.identity = identity + assignedIdentity = identity } @discardableResult func setCocoaFrame(_ frame: CGRect) -> AXError { - if let rejectWith { return rejectWith } + if let rejectWith { + if unreadableOnceRefused { cocoaFrame = nil } + return rejectWith + } writes.append(frame) - cocoaFrame = 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 + 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 let constrain { + landed = constrain(landed) + } + if appliesLate { + pending = landed + } else { + cocoaFrame = landed + } return .success } + + func catchUp() { + if let pending { cocoaFrame = pending } + pending = nil + } } private let primary = Display( @@ -32,12 +90,55 @@ 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) + /// 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) + /// 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] }) } + /// 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) @@ -155,6 +256,69 @@ 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 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 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() @@ -174,4 +338,727 @@ 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() { + 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() { + 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() { + // 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 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 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 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 + // 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 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)) + 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(.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() { + 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 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() + 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() + director.perform(.tile(.leftHalf), on: window) + + XCTAssertEqual(director.perform(.restore, on: window), .moved) + XCTAssertEqual(window.cocoaFrame, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + func testAHalfReachedByDraggingContinuesToo() { + 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 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), + 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 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 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. + let window = FakeWindow(frame: flushTopLeft) + let director = makeDirector() + window.rejectWith = .cannotComplete + 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) + + window.rejectWith = .cannotComplete + XCTAssertEqual(director.perform(.tile(.leftHalf), on: window), .rejected(.cannotComplete)) + 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 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 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. + 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)) + } + + func testAnOldFrameOnlyOnTheHalfsLeftEdgeIsNotRememberedAsPlaced() { + // 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 + 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)) + } + + 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) + 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 + // 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() + + director.perform(.tile(.leftHalf), on: window) + 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 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 testARefusedWriteThatStillResizedTheWindowForgetsWhereAHalfPutIt() { + // 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 + let director = makeDirector() + director.perform(.tile(.leftHalf), on: window) + window.catchUp() + window.appliesLate = false + window.refusesPositionWith = .cannotComplete + XCTAssertEqual(director.perform(.tile(.topRight), on: window), .rejected(.cannotComplete)) + XCTAssertEqual(window.cocoaFrame, topLeft) + window.refusesPositionWith = nil + + director.perform(.tile(.leftHalf), on: window) + XCTAssertEqual(window.writes.last, Tile.leftHalf.frame(in: right.visibleFrame)) + } + + // MARK: What the log says + + // 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. 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 + // 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 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() + 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)"]) + } } diff --git a/README.md b/README.md index 24d8931..62e9c58 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 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 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,10 @@ 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, 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**. **Reading the log.** Loadstone reports to the unified log under the subsystem `com.brudvik.loadstone`. In Terminal: