Skip to content

Keep a window's position when moving it to another display - #1863

Merged
rxhanson merged 4 commits into
rxhanson:mainfrom
anandghegde:feat/preserve-window-position-across-displays
Sep 24, 2026
Merged

rxhanson merged 4 commits into
rxhanson:mainfrom
anandghegde:feat/preserve-window-position-across-displays

Conversation

@anandghegde

@anandghegde anandghegde commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1666.

Moving a window to the next, previous or a specific display centers it on the destination, which throws away the spot the user put it in. #1666 asks for that spot to be kept, and your comment on the issue sketched the behaviour:

My thought is that if there are window edges touching the screen edge, then we'd want that to be preserved when moving to the destination display. There would be some ambiguity/complexity on what to do with windows that only touch an edge on one side or don't expand fully, but it should be possible to get a result that feels intuitive. I would even say that should be the default behavior, and make the current centering behavior available as a terminal command option.

The rule

DisplayTransfer works out each axis on its own, and both axes use the same rule: an edge that was against the source screen edge is put back against the matching destination screen edge. The three cases you called out fall out of that one rule rather than needing to be decided separately:

the window on that axis what happens
against both edges follows both, so it spans the destination - a maximized window stays maximized, a left half stays full height
against one edge keeps its size and stays against that edge, so a window parked in a corner arrives in the same corner
against neither keeps its size, and its center keeps the same relative spot, so a window in the right third arrives in the right third

Sizes only change where the window has to follow the screen edges. That is the part I went back and forth on, and I landed there because it is what dragging a window across displays yourself does, and because scaling everything proportionally shrinks a 400x300 utility window to 236x197 on the way from a 27" to a laptop. A window that is larger than the destination display is the one exception - it gets cut down to fit.

The distance from an edge is carried over rather than flattened to zero, which is what makes this work with gaps turned on: with a 10pt gap nothing is ever flush, so "against the edge" has to allow for the gap, and the same gap has to come out the other side. edgeTolerance is 4 + Defaults.gapSize.

Three edges, changing orientation

One exception, from your testing: a window against three screen edges - spanning one axis and against one edge of the other, like a left half or a top half - moving between a landscape and a portrait display. Following the rule above stretched a left half into a tall sliver down the whole portrait display. It now keeps its size and is centered along the middle one of its three edges (a left half sits against the left edge, centered vertically; a top half sits against the top edge, centered horizontally). If the window is longer than the destination on that axis, it just spans it. Moves that don't change orientation, maximized windows and windows against only two opposite edges are unaffected.

Defaults

This is on by default for nextDisplay, previousDisplay and the specific display actions, and can be turned off in Settings > Behavior > Keep window position when moving across displays, backed by keepWindowPositionOnDisplayChange (an OptionalBoolDefault, on unless set to 2):

defaults write com.knollsoft.Rectangle keepWindowPositionOnDisplayChange -int 2

Turned off, display moves behave exactly as on main: the window is centered, or, with attemptMatchOnNextPrevDisplay on and no action to replay, mapped proportionally as in #1809.

attemptMatchOnNextPrevDisplay is untouched in intent and still off by default: with a replayable action it replays that snap on the destination, which resizes the window to the new display instead of keeping its size. I retitled its section in TerminalCommands.md to say what it now does.

A maximized window still goes through autoMaximize rather than the transfer, so it stays recorded as maximized on the destination and can be restored from there.

Relationship to #1809

relativePositionedRect from #1809 is kept, and still used when the setting is off and attemptMatchOnNextPrevDisplay is on with no action to replay. With the setting on, the edge rule above covers that case instead.

Tests

RectangleTests/DisplayTransferTests.swift, 26 cases: the three branches, both directions between a 1512x945 built-in and a 2560x1415 external placed to the right and lower down (so a transfer that forgets to translate coordinates is caught), a 1440x2560 portrait display for the three-edge orientation cases (left/right/top/bottom halves, gaps, a window too long to center, maximized and two-edge windows unaffected), the #1723 geometries under the edge rule, gaps, windows hanging off the edge, windows too large for the destination, identical displays, empty frames, and a sweep asserting the result always fits on the destination. The original NextPrevDisplayMappingTests for relativePositionedRect are unchanged from main.

Full suite, run on main merged in plus your two macOS26 build commits applied locally (not part of this branch): 442 tests, 0 failures.

@rxhanson

Copy link
Copy Markdown
Owner

Thanks! I pushed a branch, macOS26. That simply reverts a few changes to allow building on macOS 26. It should be possible to rebase or cherry pick your changes onto that branch just for testing if you desire to go that route. I'll be testing out your changes regardless, and seeing what I think makes the most sense to set as the default behavior. So if you don't want to test on that branch, that's ok with me. (This is actually a rare scenario to have this kind of breakage between macOS releases).

Moving a window to the next, previous or a specific display centered it
on the destination, which loses the spot the user had put it in.

DisplayTransfer works out each axis on its own from whether the window
was against a screen edge: against both edges it follows both and spans
the destination, against one it keeps its size and stays against that
edge, against neither it keeps its size and its center keeps the same
relative spot. The distance from an edge is carried over rather than
flattened, so a window snapped with gaps arrives with the same gaps.

This is now the default for next/previous and specific display moves.
The old centering is available as centerOnDisplayChange, and
attemptMatchOnNextPrevDisplay still replays the last Rectangle action
where there is one.

Closes rxhanson#1666
The test double overrides setFrame(_:adjustSizeFirst:), but
AccessibilityElement.setFrame gained an adjustPosition parameter in
"Add optional snapping animations and a blurred snap preview" (rxhanson#1849),
so the override no longer matches and the test target does not compile:

  RectangleTests.swift:6153:23: error: method does not override any
  method from its superclass

This is not related to the rest of the branch - it reproduces on a clean
checkout - but the test target has to build before any of the tests can
run.
@anandghegde
anandghegde force-pushed the feat/preserve-window-position-across-displays branch from d2d0663 to 0675d33 Compare September 21, 2026 03:41
@anandghegde

Copy link
Copy Markdown
Contributor Author

Thanks — that branch was exactly what was needed. I cherry-picked onto macOS26 and it builds, and that let me run the Xcode test target for the first time. Two things came out of it, both now pushed.

1. The test target didn't compile, on a clean macOS26 too.

RectangleTests.swift:6153:23: error: method does not override any method from its superclass

AccessibilityElement.setFrame gained an adjustPosition parameter in #1849, and the test double's override still has the old signature. I verified this on a pristine macOS26 before touching anything. Fixed in its own commit (RectangleTests: match the current setFrame signature) since it's unrelated to this PR — drop or cherry-pick it as you prefer, but nothing in the test target can run until it lands somewhere.

2. With that out of the way, my change broke four existing tests — which I'd flagged as unverified and which turned out to be a real gap.

NextPrevDisplayMappingTests calls NextPrevDisplayCalculation.relativePositionedRect, which this PR removes, so it didn't even compile. That was the third item in my "what I could not verify" list, and the Swift compiler had been hiding it behind the error above.

I rewrote the class to assert the same four #1723 geometries against DisplayTransfer instead of deleting it, so the behaviour change is visible in the diff rather than silently dropped:

#1723 geometry proportional (old) edge rule (new)
right third, full height, 3000x2000 → 1500x1000 (1000, 0, 500, 1000) (500, 0, 1000, 1000) — keeps its 1000pt width, stays in the right corner
centered quarter, 2560x1440 → 1280x720 (480, 180, 320, 360) (320, 0, 640, 720) — keeps its size, stays centered; the destination is exactly as tall as the window
60%-wide window hanging off the right edge, 1000x1000 → 500x500 (200, 0, 300, 500) (0, 0, 500, 500) — wider than the whole destination, so cut down to it
identical source and destination identity identity

The first row is the one to look at if you're weighing defaults: under the old mapping a right-third window stays a right third, under this one it keeps its 1000pt width and becomes two thirds of a smaller display. That's the size-preserving trade-off from the PR description, made concrete. If you'd rather that case scaled, that's relativePositionedRect back as a third branch for "against one edge and wider than N% of the destination" — say the word and I'll add it.

Full suite: 431 tests, 0 failures, including the 15 in DisplayTransferTests. So items 3 and the NextPrevDisplayCalculation / SpecificDisplayCalculation wiring from the PR description are now verified. Still open, and still yours to judge: how the one-edge case actually feels on real displays, and displays of different orientation or scaling — I only have the one display here.

@rxhanson

Copy link
Copy Markdown
Owner

Thanks for walking through the test issues, I will test it out soon. I'm getting ready to draft a release this week, and I'll aim to get this in so it makes it into the release.

@rxhanson

Copy link
Copy Markdown
Owner

Finally got a chance to test this out. I like it! Two things:

  1. Moving from landscape to portrait or vice-versa when there are three edges on the display edge doesn't really translate well (as perhaps you expected). I think in this scenario, the window should be centered along the middle edge of the three (if that makes sense, let me know if that's not clear). I was trying to think if it made sense to pick a corner based on the positioning of the displays, but that seems sort of arbitrary whereas centering makes no assumption.
  2. I do think that this should be the default behavior, but since this is a departure from previous behavior, we'll want a toggle for it. If you don't mind adding one in, that'd be great. You'll want to merge in main so that you get my update with the new Behavior tab. It can be added there and I'll move it into the right place.

A window against three screen edges - spanning one axis and against one
edge of the other, like a left half or a top half - was stretched along
the whole of the spanning axis when it moved between a landscape and a
portrait display, turning a left half into a tall sliver. It now keeps
its size and is centered along the middle one of its three edges.
Picking a corner instead would mean guessing from how the displays are
arranged; centering makes no assumption.

Keeping a window's position is now a setting, "Keep window position
when moving across displays" in the Behavior tab, backed by
keepWindowPositionOnDisplayChange. It is on by default. Turned off,
display moves behave as they did before this branch: the window is
centered, or with attemptMatchOnNextPrevDisplay on and no action to
replay, mapped proportionally as in rxhanson#1809. relativePositionedRect and
its tests are restored for that path, and the centerOnDisplayChange
terminal command is gone, since the setting replaces it.
@anandghegde

Copy link
Copy Markdown
Contributor Author

Thanks for testing it! Both done in cd9625e, with main merged in (98b9bfe).

1. Three edges, changing orientation. That made sense. When a window is against three screen edges (it spans one axis and touches one edge of the other, like a left half or a top half) and the move goes landscape to portrait or back, it now keeps its size and is centered along the middle edge of the three. A left half lands against the left edge, centered vertically. A top half lands against the top edge, centered horizontally. Gaps against that edge are kept. If the window is longer than the destination on that axis, it just spans it. Moves that keep the same orientation, maximized windows and windows touching only two opposite edges behave as before. This lives in DisplayTransfer, and I added 8 cases with a portrait display to DisplayTransferTests.

2. Toggle. It's in the Behavior tab, next to "Move cursor along with window across displays": Keep window position when moving across displays, with a short caption. It's backed by Defaults.keepWindowPositionOnDisplayChange (key keepWindowPositionOnDisplayChange, an OptionalBoolDefault), and it's included in config export/import. I read "should be the default behavior" as on by default: it's on unless the user turns it off (-int 2). If you meant off by default, that's a one-line change. When it's off, display moves work exactly as on main: the window is centered, or, with attemptMatchOnNextPrevDisplay on and no action to replay, mapped proportionally as in #1809. So I restored relativePositionedRect and its original tests for that path. The setting replaces the centerOnDisplayChange terminal command, so I removed that. I didn't add the two new strings to Localizable.xcstrings, since xcodebuild doesn't extract them. Xcode should pick them up when you move the toggle.

Full suite: 442 tests, 0 failures. That run was main merged in plus your two macOS26 build commits applied locally. Those commits aren't on this branch. Plain main still fails in actool on Xcode 26.4.1. I've updated the PR description to match.

@rxhanson

Copy link
Copy Markdown
Owner

Excellent!

@rxhanson
rxhanson merged commit e321257 into rxhanson:main Sep 24, 2026
1 check failed
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.

Preserve Window Position When Moving Between Displays

2 participants