Skip to content

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

Open
anandghegde wants to merge 2 commits into
rxhanson:mainfrom
anandghegde:feat/preserve-window-position-across-displays
Open

anandghegde wants to merge 2 commits into
rxhanson:mainfrom
anandghegde:feat/preserve-window-position-across-displays

Conversation

@anandghegde

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.

Defaults

This is now the default for nextDisplay, previousDisplay and the specific display actions, and the old centering moved to a terminal command, as you asked:

defaults write com.knollsoft.Rectangle centerOnDisplayChange -int 1

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. Its documentation needed rewriting though, since it can no longer be described as "preserve position instead of centering" - I retitled that section 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

#1809 added relativePositionedRect for #1723, the proportional mapping used when attemptMatchOnNextPrevDisplay is on but there is no action to replay. This replaces it - the edge rule above covers the same case and handles maximized and gap-snapped windows, which proportional mapping gets wrong once it is the default rather than opt-in. Happy to put it back alongside if you would rather keep both.

Tests

RectangleTests/DisplayTransferTests.swift, 15 cases covering 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), 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.

What I could not verify

I could not build the app or run the test target. main does not build here: AppDelegate.swift:381 is inside if #available(macOS 27.0, *) and uses NSMenuItem.preferredImageVisibility, which is not in the macOS 26.4 SDK, so Xcode 26.4.1 is too old for this tree. I reproduced that on a pristine main before writing anything, so it is not from this change, but it does mean the three edited files have not been through the compiler and the Xcode test target has not been run.

What I did instead: DisplayTransfer.swift only needs CoreGraphics plus Defaults.gapSize, so I compiled the real file in a throwaway SwiftPM package with a stub for that one symbol, and ran DisplayTransferTests.swift against it verbatim apart from the @testable import line. All 15 pass. That covers the geometry, which is where the thinking is, but not the wiring in NextPrevDisplayCalculation / SpecificDisplayCalculation - those are small, but they are unverified and worth a look.

Also unverified, for the same reason plus having one display here:

  1. how this actually feels moving windows around on real displays, particularly the one-edge case, which is where you expected the ambiguity to be;
  2. displays of different orientation or scaling, where the size-preserving choice matters most;
  3. that no existing test in RectangleTests.swift depends on display moves centering.

If it turns out you would rather this not change the default, it is a two line change to gate it the other way round.

@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.

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