Repository navigation
Carry Left and Right Half on to the screen beside the window - #2
Merged
Merged
Conversation
Pressing Left Half on a window already in the left half re-applies the same frame today, so the second press does nothing. The next commit makes it carry the window on to the right half of the display beside it, one half at a time, stopping at the edge of the desk; Right Half mirrors it. The tests come first. Those describing the new behaviour carry a strict XCTExpectFailure, so this commit passes CI only if every one of them fails against the current code; the next commit drops the marks along with the implementation. ScreenGeometry.adjacent is a stub returning nil so they compile, and Side moves out of SnapZones so both can share it. The unmarked tests pass today and have to keep passing: they catch a continuation that fires too eagerly (a window not yet in the half, one moved by hand since it was placed, one whose process has quit), one that wraps at the edge of the desk, adjacency that counts a display above, below, or touching only at a corner, and a continuation Restore cannot undo. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Left Half on a window already in the left half now moves it into the right half of the display to its left, so repeated presses walk a window across the desk one half at a time; Right Half mirrors it. With no display beside it the window stays put as before, and with one display nothing changes. "Beside" is the new ScreenGeometry.adjacent: the nearest display lying wholly to that side and sharing some of its height, without wrapping. Not neighbor's reading order, which puts a monitor just before the laptop under it and would send a move to the left up onto the monitor. Of a stacked pair it takes the one sharing more height, then the higher one, so the answer never depends on AppKit's list order. "Already in the half" is either of two things. The window fills the tile to within a point, which also covers a half placed by Next Display (its proportional mapping lands a fraction off on odd widths) or one placed before Loadstone relaunched. Or it is exactly where Loadstone's last placement with that tile left it, as the window reported afterwards: Terminal and iTerm2 round to their character grid and some windows hold a minimum or fixed width, so they never fill a tile and a geometry check alone would never see them there. The director records those placements per window, drag snaps included, and drops them with the restore ledger when the process quits. The move goes through the same apply as every other command, so Restore still returns a window to where it was before Loadstone first touched it. Drops the expected-failure marks from the previous commit; every marked test failed on CI there for the expected reason. Walking each rule through a mutation (on paper, not run) showed nearest-first was unguarded, so that test now has a farther display sharing more height. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
README: the multi-display feature line, how a repeated left or right half walks a window across the screens and where it stops, and a troubleshooting entry for when it does not. CLAUDE.md: the continuation and its placement record in the director notes, ScreenGeometry.adjacent beside neighbor, and continuation among the switches a new Tile case must cover. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of the previous commits found four gaps. A refused write must not be recorded as a placement, or the next press throws a window that never reached the half onto another display. The code already guards this; nothing pinned it. An app that applies a frame only after the write returns reports its old frame when read straight back, and that stale frame gets recorded. Restore later returns the window to exactly that frame, and the next Left Half then carries a floating window off to the neighbouring display. A display whose resolution changes leaves the window's frame alone, so the record still matches and the next press carries the window on instead of refitting it to the new, wider half. Of a stacked pair sharing equal height, the higher display wins, as documented; only list-order independence was pinned. The stale-frame and changed-display tests carry a strict XCTExpectFailure until the next commit fixes them. The refused-write test and the tie-break assertion pin behaviour that already holds, and they pass now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A placement now remembers the frame Loadstone sent the window to rather than the tile, alongside where the window landed. The record counts only while that tile still has the same frame on the window's display, so a new resolution, or the Dock moving, gets the window refitted into the half before a press carries it on. Keying on the frame also subsumes the old tile check: no two tiles share a frame. It is also written only once the window's top-left corner has reached the target. An app that rounds or caps a size keeps that corner, so Terminal and fixed-width windows are still recorded; an app that applies the frame after the write returns reports its old frame when read straight back, and recording that is what let Restore followed by Left Half carry a floating window off to the next display. Drops the expected-failure marks from the previous commit, where both tests failed on CI for the expected reason. Comments that described per-window memory as Restore's alone now mention the placements too, and the README troubleshooting entry names the refit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Side named a direction across the desk but sat at module level in Tile.swift, between the Tile extension and SnapZones. SnapZones, Tile.continuation and ScreenGeometry.adjacent all reached for it. It describes a side of a display, so it belongs with Display, and a bare module-level Side is a name any later type could collide with. Call sites that write .left and .right are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A test now checks that every tile with a continuation lands on one whose continuation leads straight back, the other way, so pressing Right Half after a Left Half carry sends the window back into the half it left, though beside a stacked pair not always on the display it left. It holds today; it fails if a landing row or a direction is edited on one side only, or if the landing tile loses its continuation. The WindowCommand doc listed what a new case must cover but not continuation, which a new Tile also needs, since that switch is exhaustive. adjacent's doc said reading order puts a monitor before the laptop under it, which is true only when the monitor's left edge is at or left of the laptop's. The gap comment's "overlap horizontally" read as either axis; it names the x ranges now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app that applies a frame late reports its old frame when read straight back. When that old frame already shares the target's top-left corner (a window flush in the display's top-left, zoomed, or in Top Left, Top Half or Left Third), it is recorded as where the half left the window. Once the app catches up, anything that returns the window to exactly that frame, Restore included, makes the next Left Half carry it to the other display instead of halving it. Restore, Center, and Next and Previous Display now drop the window's placement once the window accepts their frame. The entry describes where a tile put the window, and after Loadstone has moved it elsewhere it can only mislead. Refusing to record a read-back equal to the pre-write frame would not do: a Terminal window already at its grid frame in the half reads back unchanged on every press after a relaunch and would never carry on. A frame the window is returned to by something outside Loadstone (a title-bar double-click, a drag) can still match a stale entry; place() documents it. Closing it would take an AXObserver on the window. The late-apply test that brought the window back with Restore now drags it back by hand, since Restore clears the placement on its own and the test would otherwise stop guarding the top-left check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refused-half test started from a frame that does not share the half's top-left corner, so the top-left check threw the read-back away on its own and deleting the refusal check in place() left every test passing. It now starts flush in the display's top-left corner, where only the refusal keeps the unchanged frame from being recorded. A refused continuation must keep both the placement and the Restore frame: the next press still carries the window on, and Restore still returns it to where it was before Loadstone first moved it. Two late-apply tests pin each half of the top-left check: an old frame on the half's left edge but below its top, and one at its top but right of its left edge. Each returns the window by hand rather than with Restore, which clears the placement on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A window whose minimum width exceeds the whole width of the display to its left spills back across the seam when carried into that display's right half, so its centre lands on the display it came from. The next Left Half then resolved that display, put the window into its left half, and further presses alternated between the two. While a window is still where Loadstone last put it, within a point of the placement's landing, its display is now the one holding the frame it was sent to, falling back to the display under its centre. Tiles, Center, and Next and Previous Display all resolve it this way, so they agree about where the window is. The fake window gains a minimum width that, like the grid, keeps the top-left corner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On an AXWindow, identity is computed through AX calls, with a title read as well when the window id is unavailable, and one tile command read it up to four times: in the display lookup, in isPlaced, in apply's restore bookkeeping, and in place. perform and snap now read it once and pass the key down to place, isPlaced, apply, rememberIfNeeded, relocate, move and the display lookup. Each tile's frame is also computed once: isPlaced and place take the target frame instead of recomputing it from the tile and display. place() records or drops the placement in two plain branches rather than through a flatMap and a map over optionals. A window id cannot change with a write, so for it nothing changes. A title-based identity is now read only before the write, and the Restore frame and the placement are recorded under that title. A window whose title follows its size, as Terminal's default title does, goes by another title once resized, so neither is found again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A move that succeeds is silent, so a report that ctrl-opt-left does not reach the other screen left no trace of why. Four info lines, readable with the README's log command, cover the decisions that explain it: - carrying on, naming the destination display's frame, so a rejection logged after it has context; - in the half with no display beside it on that side, the edge of the desk; - a placement that does not match, with the window's frame, the frame it was sent to, where it landed, and the tile's frame now; not on a first press with nothing recorded; - a read-back off the target's top-left, so nothing was recorded. place and isPlaced take the command for their lines; a drag snap passes its tile's. The continuation comment now says what happens with nothing beside the window: the half is applied again, with no beep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isWithinAPoint said a half carried by Next Display lands only a fraction of a point off. From an odd width W1 onto an even W2 it is off by W2 / (2 * W1), over a point once the new display is more than twice as wide (1201 onto 2560 is 1.07pt), and the next press then refits the window; from an even width onto an odd one the tile's own flooring leaves it half a point off. The comment now gives both. isPlaced listed windows of a fixed width among those that never fill a tile. For a size AX reports unsettable the AX size write fails, and AXWindow returns that error from its first write, so such a window is never placed; the cases are a minimum width or an app that accepts a size write and ignores it. place() says a read-back that misses the corner drops any earlier entry, and that an app rounding or capping a size normally keeps the corner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md gives the half continuation its own bullet after Director and describes it as the code has it: what counts as already in the half, when a placement is recorded and what drops it, the refit after a display change, the display taken from the recorded target while the window is on its landing, staying put with no beep at the edge of the desk, the info-level log lines, and the late-applying-app residual. The Geometry bullet names Display.Side and says a display above or below never counts as beside. The README says the window stays where it is at the edge of the desk without a beep, that the menu items and rebound keys behave the same, and the troubleshooting entry covers windows that size in steps, Dock and layout changes, screens touching only at a corner, and reading the log. Three comments followed: every tile, not only a half, records where it put a window; the WindowIdentity doc had an ambiguous "it"; and the title-identity fallback logged at launch affects repeated halves as well as Restore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Next Display maps a window proportionally, so a left half of an odd width W1 lands W2 / (2 * W1) short of the half on an even W2, and |W2 / (2 * W1) - 1/2| short on an odd W2, where the half's own flooring leans the same way. isWithinAPoint's comment gave only the first and said it goes over a point once the new display is more than twice as wide, which does not hold onto an odd width: that takes three times. Two tests carry a half from a display 1201 wide onto one 2560 wide, 1.07pt off, which the next Left Half refits, and onto one 2561 wide, 0.57pt off, which it carries on. Every other display move in the suite starts from an even width, so neither side of the boundary was exercised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three checks in the director had no test, and deleting any of them left the suite passing: - display(for:) takes the recorded display only while the window is still where it landed. Without that, a window tiled on one display and dragged to another by hand is thrown back to the first by the next half. - Center, Restore and a display move drop the placement only once the window accepts their frame. A refusal that leaves the window where it was must keep it, so the next Left Half still carries a step-sized window on. - A tile whose read-back misses the target's top-left drops the earlier entry. Left standing, a stale landing the window is later returned to by hand carries it to another display instead of halving it. The refused-continuation test asserted that the refused window had not moved, which only checks the fake; that assertion goes. Its Restore half stays: it fails if a refusal overwrites the Restore frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every frame a window accepts from Loadstone invalidates where a tile last left it. That rule lived in two places: relocate() for Center, Restore and the display moves, and the branch of place() taken when a read-back misses the target's top-left. apply() is the one call both make, so it drops the placement once the write is accepted; place() then records a fresh one when the read-back has the target's top-left, and relocate() goes away. display(for:) and isPlaced both looked up the placement and checked that the window was still within a point of where it landed. They now share standingPlacement(for:at:), which returns the entry only then. The comment on dropping the placement no longer says the record "can only mislead". It costs a step-sized or minimum-width window one refit press after, say, a Next then Previous Display round trip back to where it landed, which is the price of never carrying a window on from a record that a later accepted write has made stale. No change in behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AXWindow writes the size, then the position, then the size again, and returns the first error. An app that takes the size and then refuses the position, or times out on it, leaves the window resized at its old top-left while the command reports a refusal. A refusal kept the placement, so if that resize put the window back on a stale landing (an app that applies frames late, whose old frame shared the half's top-left), the next Left Half carried it to another display with no hand move at all. A refused write now reads the frame again when there is a placement at stake, and drops it unless the window is still within a point of where it was before the write. A refusal that left the window untouched keeps the placement, as before. The fake window can act like that app: it takes the new size at its old top-left and fails the write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the window id is unavailable a window is known by its title, and Terminal's default title carries the window's size in character cells. Reading the identity once before the write stored the Restore frame and the placement under the title from before the resize, which the next command, looking the window up by the title it has then, never finds: Restore after Left Half returned nothingToRestore, and a Terminal window took an extra press on every display before it carried on. Lookups still use the one identity read before the write. Once a write is accepted, apply() reads a title-based identity again and records the Restore frame under it, and place() records the placement under it. A window id cannot change across a write, so it is not read again. The placement dropped is the one under the key the window was looked up by, so a window returned by hand to where a half left it, and to the title it had there, is not carried on from a record a display move should have cleared. The fake window can take its identity from a title that follows its size, as Terminal's does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Next and Previous Display map the frame proportionally from the display the window is on to the one beside it. Taking that display from the placement differs from the centre only for a window held larger than its tile with its centre off that display, and mapped from a display narrower than itself the window comes out wider than the display it goes to. A 1501pt-wide window in the right half of a 1440pt display, sent on by Previous Display, was asked to be 2668pt wide on the display it wrapped to, and only 1280pt of that was on the desk. Display moves go by the display under the window's centre again. Tiles and Center keep the display the window was placed on, which is what stops a wide window bouncing between two displays; a test now pins that for Center, which nothing did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isPlaced logged whenever a window had any placement that did not match, including one from another tile, which made it a predicate with a side effect, and it said nothing about the case that most needs a word: a Terminal window already in the half with nothing recorded, say after a relaunch, which a press only refits. A placement now records the tile that made it: for a continuation the half the window moved into, for a drag the tile it snapped to. isPlaced is a plain predicate. When Left or Right Half does not carry a window on, perform logs at info level only when the placement by this same tile no longer holds (the window has moved since or the display has changed), with the window's frame, where it was sent and landed, and the tile's frame now; or when no placement stands and the window has the half's top-left corner without filling it, saying it is fitted into the half first and the next press carries it on. A first press, or one on a window where another tile put it, logs nothing. place() takes the tile instead of a command, so a drag snap passes its own; its read-back line tells a frame it could not read from one off the target's top-left. The frame a continuation sends the window to is called onward, leaving "landed" for where the window reported itself. place()'s comment adds the second case its single read-back leaves open: a late-applying app's window held wider than the display it is carried to never has the move recorded, and carried to the left it bounces back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
move()'s comment said the display a window was placed on differs from the one under its centre only for a window wider than that display. A window held wider than its tile is enough: one with a 1000pt minimum width in the right third of a 1440pt display has its centre on the next display over. apply()'s comment now says that a refused write keeps the placement only if the frame, read again, is within a point of where it was, which also covers a frame that can no longer be read. The comment in the partly-applied-write test is wrapped to the file's width. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md's Continuation bullet ran to 324 words, repeated the doc comments, and listed the log lines one by one, so it would drift with them. It is now a short list of the rules an edit must keep: what counts as in the half, when a placement is recorded and when it goes (any accepted write, a refused one that moved the window anyway, the process quitting), the refit after a display change, which display tiles, Center and display moves go by, that info lines say why a half was not carried on, and the two late-apply residuals. The Director bullet says display moves go by the window's centre and that a title-based identity is read again after a write. The AX layer bullet said AXWindowTests run in CI; CI skips them, so they only run on a developer machine. A new Tile also has to cover frame(in:), an exhaustive switch; the WindowCommand doc and CLAUDE.md now list it. The README's troubleshooting entry adds that another Loadstone command clears the record for a step-sized window, even one that brings it back, and scopes the log promise to a window that looks to be in the half, rather than any press that does not carry a window on. The inverse-pair test said the opposite half brings the window straight back; beside a stacked pair it heads back the way it came but not always onto the same display. The comments on placements and on the fallback identity mention a refused write that moved the window and the title read again after a write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two checks in the placement lookup had no test. A standing placement counts for a half only when the frame Loadstone sent the window to is the half's own; a window that Maximize, Top Left, Top Half, Left Third or Left Two Thirds left in place shares the half's top-left corner, and matching on that corner alone throws it onto the next display instead of halving it. And a placement stands only while the whole frame is within a point of where the window landed; matching on the top-left alone keeps it standing for a window resized by hand from its bottom-right corner, which the next press then carries on. One test covers each tile that shares a half's top-left, Top Right before Right Half included. Another resizes a halved window in place. Each fails under its mutation, and no other test does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a refused write the frame is read again, and the placement is kept only if the window is still within a point of where it was. A hung app can time out on that read too, and then nothing shows the window has stayed put, so the placement goes; the next press refits the window instead of carrying it on from a record nobody could check. No test covered the unreadable case, and treating it as unmoved passed the suite. The fake window gains a flag that leaves its frame unreadable once it has refused a write. The test fails when a nil read keeps the placement, and no other test does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A window known by its title, when the window id is unavailable, can go by a new title after every resize: Terminal's default title carries its size in character cells. The Restore frame is recorded under the title read after the write, but an entry under the title from before it was never moved. After Left Half and then Maximize, the first entry stayed under the half's title and Maximize recorded the Left Half frame under its own, so Restore returned the window to the half instead of to where it was before Loadstone first touched it. When a write that records a Restore frame is accepted, a frame recorded under the key the window was looked up by now moves to the key it goes by afterwards. Restore does not move it, since it removes its entry under the old key once the window accepts the frame. Nothing already recorded under the new key is overwritten: that entry is either the window's own from its first command, as when it has been resized by hand in between, or another same-titled window's, which the title cannot tell apart from this one. The first test failed before the fix, with Restore returning the Left Half frame. The second passed before it and fails when the frame moves across over an existing entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Left or Right Half on a window that does not count as in the half fits the window into it, with an info line saying why where the press alone does not. Two cases were wrong. A window that another tile left where the half leaves it too, such as one with a 1300pt minimum width put in Left Third of a 2560pt display, stayed exactly where it was and nothing was logged, because a placement by another tile was standing; only the next press carried it on. And the line for a window with the half's top-left that does not fill it promised that the next press would carry it on, which at the edge of the desk, or on a single display, it does not. The reason is now worked out after the fit, from the frame read back, which place() returns when it records a placement. A window that reads back where it was is logged as such, with the note that it now counts as in the half; that covers the minimum-width window, a Terminal window after a relaunch, and one whose record another command dropped. The top-left line no longer says what the next press does. A refused fit logs only the refusal. Checked with log show after running probe tests that were not kept. Before, the second press on the minimum-width window logged nothing, and a Terminal window in the left half of the leftmost display was told the next press would carry it on, which then logged that there was no display to the left. After, that second press logs why the window did not move, and the Terminal window is told the next press counts it as in the half. Maximize then Left Half, a plain first press, and a refused press log nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
place() said a late-applying app's wide window bounces after any carry, going by the display under its centre, "the one it came from". A window keeps its top-left and sticks out to the right, so that holds only for a carry to the left; carried right, its centre lands on the display it reached. The doc and CLAUDE.md's residual now name the direction. apply() said it drops the placement only once the window accepts the write, then described a refusal that drops it too; the first paragraph now says a refusal keeps it only if the window is seen to have stayed put. It also called a refit press the price of never carrying a window on from a stale record, which place() shows can still happen after a hand move; it is scoped to records another Loadstone write made stale. move() said the placed display differs from the one under the centre only for a window held wider than its tile; one held taller can differ too. CLAUDE.md said mapping from a narrower display grows the window, which is true only onto a wider one; what matters is that it comes out wider than the display it goes to. The WindowCommand doc listed id among what a new Tile needs, though .tile(let tile) covers it, and said a new command appears everywhere once it compiles. It also needs a branch in WindowDirector.perform, which the compiler asks for, and an entry in all, which it does not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lines that say why a half press did or did not carry a window on are decisions in their own right: fit() picks one of three, or none, from the placement it found and the frame read back, and Placement.tile is read only there. Written straight to Log.ax, no test could see them, so loosening or inverting any of those conditions left the suite passing. WindowDirector(displays:diagnose:) takes a (String) -> Void that defaults to Log.ax.info with the whole line public, as each piece was before. The carry-on, edge, fit and unrecorded-placement lines go through it; report()'s error and notice lines stay as they are. With plain string interpolation the String(describing:) wrappers OSLog needed are gone. CLAUDE.md lists the seam with the others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven tests read the director's diagnose lines. Silent: a first press from anywhere but the half's top-left, Left Half after Maximize, and a press after a placement by another tile, including a carried window nudged by hand and then sent to the half it did not land in. One line each: a first press from the half's top-left with nothing recorded, a placement by the same half that no longer holds, a Terminal-style or minimum-width window that reads back where it was, the edge of the desk, and carrying on. Each was seen failing under a mutation, one build per mutation, then reverted: dropping the standing-placement check or the top-left check from the third case, dropping the same-tile check from the first, recording a carry by the pressed tile rather than the half it moved into, disabling the first or second case, and removing the edge, carry-on or top-left line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The partly refused write test used Top Left, whose size-only write leaves the window exactly on the Top Left frame. That is where it was sent as well as off where it was, so a director that dropped the placement only when the window reached the requested frame, or could not be read, passed the whole suite. Top Right is the size of Top Left on the right display, so the same size-only write leaves the window on the stale landing and not where it was sent. The test now presses Top Right and asserts where the partial write left the window. With apply() dropping the placement when the window reached the requested frame or cannot be read, rather than when it is no longer where it was, this test alone fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two rules of the Restore ledger had no test. rememberIfNeeded moves a title-based entry to the window's new title rather than copying it; a copy left behind under the old title answers a second Restore once the window has its old title back, so Restore stops being one-shot. And Restore drops its entry only once the window accepts the old frame, as CLAUDE.md and Restore's comment say, so a refused Restore can be tried again. The one-shot test for a window whose title follows its size fails alone when the entry is copied instead of moved. The retried Restore test fails alone when Restore drops its entry whatever the outcome. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a title-based identity whose title follows the window's size, an app that applies frames late reports the old title as well as the old frame straight after a write. When that old frame shares the target's top-left, the placement is recorded under the stale title, and a later write, looked up by the new one, does not drop it, so a tile of Loadstone's own can put the window back on the stale landing and the next half press carries it on. It takes the window id lookup failing, a late-applying app and a title that follows the size all at once, and the fix on offer, dropping every title-based placement of the process on each write, would cost the app's other windows a refit press. So it joins the late-app residuals in place()'s doc and CLAUDE.md, and the fallback identity's doc says the director records under the title it reads after the write rather than the new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fit() said a window that reads back where it was is one the press left where it was. An app that applies the frame late, with the window at the half's top-left, reads back its old frame too, and the window then moves into the half. The doc now lists that case. - The placements doc and CLAUDE.md left out that a refused write after which the frame cannot be read drops the placement, which apply() does and a test pins. CLAUDE.md credits AXWindow, not AX, with setting the size before the position. - CLAUDE.md said a window wider than its tile, with its centre on the next display, would bounce between the two. Going by the centre would tile it from the wrong display; only one wider than the display it was placed on bounces. - The README and CLAUDE.md said any window needs a refit press after any display change, the screen layout included. A window that fills the half moves with its screen and still fills the new half, so it carries on at the first press. The README now counts moving a step-sized window's screen as moving the window, and CLAUDE.md names a new resolution or a moved Dock as what refits any window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A block comment introduces the tests that put a late-applying app's window back on the stale frame recorded for it. Three tests below it were about something else: a refused write after which the frame cannot be read, a display that changed since, and a process that quit. The first moves next to the other refused-command tests, the other two next to the display tests before them. No test changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The suite's comment said that, like AXWindowTests, it is skipped in CI. ci.yml skips only AXWindowTests, and CI's test count includes these three. They pass there because the runner's test host is untrusted, and the cross-application one skips itself when there is no other app to probe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README promised that the log says why a window that looks to be in the half is refitted rather than carried on. fit() logs three cases: Loadstone last put the window in that half, the press left it where it was, or it had the half's top-left with no placement standing. A window a few points off the half by any other route is fitted with no line, though the move can be too small to see. The README, CLAUDE.md and fit()'s doc now name those cases, and the comment in perform points to fit()'s list. Apart from the README, they said a line is logged where the press alone does not show why, which also missed that the first case is logged even when the fit plainly shows. The comment over the log tests said each test clears the lines before its press; three start from empty lines instead, and it now says so. It also names the carry and edge-of-desk lines those tests cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
display(for:) tied the placed-display rule to a window wider than the display. It matters as well for a narrower one held wider than its tile: a 1301pt-wide window in Right Third of a 1920pt display has its centre on the next one, and only the rule tiles its next Left Half on the display it was placed on. The doc now matches CLAUDE.md. rememberIfNeeded said the frame moving to a new title is the one the window's first command recorded. When a hand resize renamed the window in between, it is the one the first command under the old title recorded, and the earlier entry is orphaned, as WindowIdentity.fallback notes. The doc now says that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a MacBook with a monitor to its left whose top reaches above the MacBook's menu bar, Left Half carried a window with a 2001pt minimum width into the monitor's right half, and it landed 32pt lower than it was sent. macOS keeps a window's top below the menu bar of the display holding most of it, and 1041pt of this one hung over the MacBook. place() recorded a placement only when the read-back shared the target's top-left, so the carry went unrecorded, and the next press went by the display under the window's centre, the MacBook, and sent it back: the bounce the continuation is meant to prevent. A placement is now recorded when the read-back has the target's left edge and either its top-left corner or, once the window has moved, a top no higher than the target's. macOS only ever pulls a window down. An app that applies frames late reads back the frame from before the write, which has not moved, so that still counts only with the corner. The line logged for an unrecorded placement says what the read-back missed: the left edge, the top from above, or the top without the window having moved. FakeWindow takes a constrain closure that moves the window after its app has placed it. Two of the new tests set it to keep the window's top below the menu bar of the display holding most of it, on a laptop with a monitor to its left that reaches 32pt higher; the others shift the window 41pt, which macOS does not, to pin the rule's edges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md said a placement is recorded only when the read-back has the target's top-left. place() also records one with the target's left edge and a lower top once the window has moved, because macOS pulls a wide window below the menu bar of the display holding most of it. The Continuation bullet now states that rule, and the logging bullet says that an unrecorded placement logs what its read-back missed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FakeWindow.constrain was documented as acting like macOS, but three of the tests that set it shift the window in ways macOS never does: 41pt up, which one of them says outright, or 41pt right. The doc now says it moves the window after its app has sized and placed it, as macOS does in keeping its top below a menu bar, or as something else might. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
place() counts a landing below the target's top once the read-back differs from the frame before the write on any edge. Comparing only the top-left would pass every existing test, but it misses a real case: Top Right on a monitor, then Right Half, both pulled down to the same neighbour's menu bar, land with the same top-left and differ only in height. The Right Half would go unrecorded, and the next press would go by the display under the window's centre and fit it into the laptop's right half instead of carrying it on. The new test walks that case. Swapping the whole-frame comparison for a top-left one fails it, and only it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A window more than twice as wide as the leftmost display, whose top reaches above its neighbour's menu bar, is pulled down below that menu bar in either half of it. Left Half carried it there and recorded it, since it had moved. At the edge of the desk the next Left Half sends it to the same frame again, and it reads back where it was: below the half's top without having moved, so the placement went unrecorded. The press after that went by the display under the window's centre, the neighbour, and fitted the window into that display's left half. A window that reads back where it was now also counts when it was already in the tile before the write (isPlaced): it fills the tile or stands where Loadstone last recorded it landing from this same frame, so recording it again adds nothing. That is asked before the write, which drops the placement it goes by. A frame where another tile left the window still counts only with the target's top-left, so a stale read-back from an app that applies frames late is recorded no more than before. The new edge-of-desk test fails without the exception or with it asked after the write, and the new late-apply test fails if any standing placement, whatever frame it was for, lets the read-back count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The live hand-check of Left/Right Half continuation turned up cases the docs did not cover or described misleadingly. README: the troubleshooting entry named only step-sized windows as ones that count as in the half by Loadstone's record; a window with a minimum width wider than the half follows the same rule. A new entry explains why such a window ends up wider than the half (and can pass for full width), that pressing again still carries it on, and that with "Displays have separate Spaces" on a window wider than a screen can look as if it stayed put or vanished when it moves on. CLAUDE.md: say that going by the centre for Next/Previous Display does not help a window wider than the display under its centre, since Layout.mapped is proportional and unclamped (known, unhandled), and add how the screens mislead a hand test so it is judged by the frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Pressing
⌃⌥←on a window already in the left half now moves it into the right half of the screen to its left, so repeated presses walk a window across the desk one half at a time.⌃⌥→mirrors it.With no screen beside it, the window stays put as before, with no beep. Everything else is unchanged, with two exceptions. A window held wider than the tile Loadstone last put it in can reach over a seam, its centre on the next screen; while it stays there, tiles and Center use the tile's screen, not the one under its centre. And when a window's id is unavailable, so Loadstone goes by its title, Restore returns a window whose title follows its size, as Terminal's does, to where it started rather than to an earlier tile.
The branch also corrects two notes about CI:
AXWindowTestsis skipped there, andAccessibilityAuthTestsruns there.Decisions
⌃⌥⌘←/⌃⌥⌘→still wrap.ScreenGeometry.adjacent: the nearest screen lying wholly to that side and sharing some of its height. Not Next Display's reading order, which can send a left press on a laptop under a monitor up onto the monitor.⌃⌥⌘←/⌃⌥⌘→leave none behind. A record kept past them is more likely to mislead than help: a late-applying app can have its old frame recorded, and Restore puts the window back on exactly that frame. The price is one refit press for a step-sized or minimum-width window brought back to where it landed, say by a⌃⌥⌘→then⌃⌥⌘←round trip. The placement also goes when the app quits. A refused write records nothing, and the placement survives it only if the window is seen to have stayed where it was, since Loadstone sets the size first and an app can take it and refuse the position. A frame that can't be read after a refusal drops it too.⌃⌥←would send it back.⌃⌥⌘←/⌃⌥⌘→still go by the centre: mapped proportionally from a screen narrower than the window, it would come out wider than the screen it goes to, possibly mostly off the desk. Anywhere else, the screen under the centre decides, as before.Review
Six rounds, the last three with a second reviewer confirming each finding. Every finding was fixed or is listed under Known limitations; the commit messages have the detail.
A hand check on a real desk then found macOS pulling a wide window down below a menu bar, so its carry went unrecorded and it bounced back; fixed, and pinned by a test that imitates the constraint. A review of that fix found the same bounce one press later, at the edge of the desk, for a window more than twice as wide as the screen there; fixed too.
Testing
ScreenGeometryTests, 53 inWindowDirectorTests, 1 inTileLayoutTests.e462871added 17 tests, 9 with a strictXCTExpectFailure, andfbf6309added 3, 2 marked; CI showed each marked test failing for the expected reason before the next commit fixed the code. The rest pinned behaviour that already held. From the second review round on, each test for a behaviour change was seen failing on a Mac before its fix, in the same commit.diagnoseclosure the director takes (Log.axby default). Not mutation-proven: the above-or-below and no-wrap adjacency tests, and four early director tests: a window not yet in the half, a half staying put at the edge of the desk, a window moved since, Restore after a carry.3d34e06: 122 tests, 0 failures (AXWindowTestsskipped there as always). Every commit up to there passes CI; later ones haven't run there yet.At
dfeb421, the whole suite with nothing skipped,AXWindowTestsincluded:Executed 177 tests, with 0 failures (0 unexpected),** TEST SUCCEEDED **. A clean build of the app and the tests gives 0 compiler warnings.By hand, with the real hotkeys, on a MacBook (1728×1117, menu bar 32pt) with an ASUS MB16AC (1920×1080) to its left:
⌃⌥←walked the window across one half at a time and stopped at the edge, every frame exact, with the carry and edge-of-desk lines logged.⌃⌥←: passed. Snapped at the shared edge, the window carried on at the next press; nudged 1pt after the snap, it still counted as in the half.⌃⌥←went by the MacBook, under the window's centre, and sent the window back. With the fix, the pulled-down landing was recorded,⌃⌥←walked the window on to the ASUS's left half and stopped there with the edge-of-desk line logged, and⌃⌥→from the pulled-down half carried it back.⌃⌥⌘→on that window over the seam: passed. It moved on from the MacBook, the screen under its centre, and wrapped to the ASUS at (-2987, 42, 2223, 1075), the mapping from the MacBook to the pixel. A window wider than the screen it moves to comes out wider still and hangs off the desk's edge, as onmain.⌃⌥←left the window where it was and logged why; the second carried it on, top exact, since 960 of its 1001pt were on the ASUS.Known limitations
⌃⌥⌘→then⌃⌥⌘←round trip can;⌃⌥⌘←or⌃⌥⌘→from an odd-width screen onto one more than twice as wide (three times, if its width is odd).⌃⌥←carries it on instead of halving it. With the window id also unavailable and a title that follows the size, as Terminal's does, the record sits under the stale title, where Loadstone's commands don't clear it, so a Loadstone tile that puts the window back there does the same. Closing this needs anAXObserverthat drops the record when the window moves; left out.⌃⌥←onto a screen narrower than the window's minimum width, it reads back its old frame, off the new half's top-left, so the carry isn't recorded. Its centre sits on the screen it came from, so the next⌃⌥←goes by that screen and sends it back. The sameAXObserverwould close it; left out.🤖 Generated with Claude Code