Skip to content

Carry Left and Right Half on to the screen beside the window - #2

Merged
mortenbrudvik merged 44 commits into
mainfrom
halves-continue-across-displays
Sep 26, 2026
Merged

mortenbrudvik merged 44 commits into
mainfrom
halves-continue-across-displays

Conversation

@mortenbrudvik

@mortenbrudvik mortenbrudvik commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

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.

        Screen A                 Screen B
  ┌─────────┬─────────┐   ┌─────────┬─────────┐
  │    4    │    3    │   │    2    │    1    │
  └─────────┴─────────┘   └─────────┴─────────┘
  ⌃⌥← walks 1 → 2 → 3 → 4, then stays. ⌃⌥→ walks back.

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: AXWindowTests is skipped there, and AccessibilityAuthTests runs there.

Decisions

  • Stops at the outer edge, no wrap. The arrows point in a direction; wrapping would throw a window to the far right on a left press. At the edge the half is applied again, so the window stays put, with no beep. ⌃⌥⌘←/⌃⌥⌘→ still wrap.
  • "Beside" means beside in System Settings → Displays, via a new 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.
  • "Already in the half" means the window fills the half to within a point, or it is where it landed the last time Loadstone sent it to that same frame. The second covers Terminal and iTerm2, which round to their character grid, and windows with a minimum width, none of which ever fill a tile exactly. Every tile records the placement, drag snaps included, once the window has the left edge it was sent to and either the top-left corner or, if it has moved or was already in that tile, a top no higher than the one it was sent to. macOS pulls a window below the menu bar of the screen holding most of it, so a wide window carried onto a screen that reaches above that menu bar lands lower than sent, and given the same half again at the edge of the desk, it reads back where it was. An app that applies frames late reports its old frame, which hasn't moved, so that counts only with the corner or where the same tile already had the window.
  • Only a tile keeps the placement. Every frame the window accepts from Loadstone drops it, and a tile then records a fresh one, so Restore, Center and ⌃⌥⌘←/⌃⌥⌘→ 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.
  • For tiles and Center, a window still where a tile put it is on that tile's screen. A window held wider than the half reaches over the seam, and its centre can sit on the screen it came from, so going by the centre the next ⌃⌥← 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.
  • No setting. It changes a press that did nothing before, and the screen a tile or Center uses for a wide window a tile left hanging over a seam.

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

  • 61 new tests: 7 in ScreenGeometryTests, 53 in WindowDirectorTests, 1 in TileLayoutTests.
  • Test-first where behaviour changed. e462871 added 17 tests, 9 with a strict XCTExpectFailure, and fbf6309 added 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.
  • Mutation runs, each reverted afterwards, proved the tests for behaviour that already held: the tie-breaker, adjacency, continuations and within-a-point tolerance; the placement checks on refusals, top-left and whole-frame matching, read-back misses, the recording rule's left-edge, top and not-moved checks, the whole frame rather than the top-left deciding whether the window moved, and the exception for a window already in the tile, asked before the write and only for the same frame; lookup keys and the placed screen; Restore's ledger; and, with nine mutations, the log decisions, which the tests see through a diagnose closure the director takes (Log.ax by 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.
  • CI on 3d34e06: 122 tests, 0 failures (AXWindowTests skipped there as always). Every commit up to there passes CI; later ones haven't run there yet.
  • Full suite on a Mac:

At dfeb421, the whole suite with nothing skipped, AXWindowTests included: 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:

  1. TextEdit walk: passed. ⌃⌥← 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.
  2. Terminal walk: passed. Rounded to its cells at 860×1068, the window kept the half's top-left at every step.
  3. Drag-snap, then ⌃⌥←: 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.
  4. Laptop below a monitor: not run.
  5. A window wider than the screen to its left: failed at first, passed after the fix. A window with a 2001pt minimum width, carried onto the ASUS, landed 32pt low: 1041pt of it hung over the MacBook, and macOS kept its top below the MacBook's menu bar. Off the half's top-left, each carry went unrecorded, so the next ⌃⌥← 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.
  6. ⌃⌥⌘→ 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 on main.
  7. Relaunch: passed, on the right half. The first press refitted the window in place and logged why; the second counted it as in the half.
  8. Minimum width wider than the half, after Left Third: passed. With a 1001pt minimum, the first ⌃⌥← 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

  • Next to a stacked pair of screens, the opposite arrow doesn't always undo a step. Going left from the upper screen of a pair to a laptop, then right, lands on whichever screen of the pair shares more height with the laptop. Fixing that needs a memory of where the window came from; left out.
  • Some windows need one extra press. The first press refits the window into the half, and the next carries it on. This happens:
    • to a Terminal-style window put in a half before Loadstone launched, or by another tool;
    • to a step-sized or minimum-width window that another Loadstone command has moved since, even back to the same spot, as a ⌃⌥⌘→ then ⌃⌥⌘← round trip can;
    • to any window after the screen's resolution or the Dock changes, and to a step-sized or minimum-width one after its screen moves in the layout;
    • to a half sent with ⌃⌥⌘← or ⌃⌥⌘→ from an odd-width screen onto one more than twice as wide (three times, if its width is odd).
  • A late-applying app can still be carried on too early. An app that applies a frame only after Loadstone reads it back, with a window flush in the screen's top-left corner, has its old frame recorded as where the half left it. Loadstone's own commands clear that record, but if a drag or a title-bar double-click puts the window back on exactly that frame, the next ⌃⌥← 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 an AXObserver that drops the record when the window moves; left out.
  • A late-applying app's wide window can still bounce. Carried left by ⌃⌥← 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 same AXObserver would close it; left out.

🤖 Generated with Claude Code

mortenbrudvik and others added 30 commits September 25, 2026 16:50
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>
mortenbrudvik and others added 14 commits September 25, 2026 22:28
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>
@mortenbrudvik
mortenbrudvik merged commit 4e4258b into main Sep 26, 2026
1 check passed
@mortenbrudvik
mortenbrudvik deleted the halves-continue-across-displays branch September 26, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant