Skip to content

Make the default display ordering deterministic - #1960

Open
AkiyamaKunka wants to merge 2 commits into
rxhanson:mainfrom
AkiyamaKunka:fix/transitive-screen-ordering
Open

AkiyamaKunka wants to merge 2 commits into
rxhanson:mainfrom
AkiyamaKunka:fix/transitive-screen-ordering

Conversation

@AkiyamaKunka

Copy link
Copy Markdown
Contributor

What

In the default display-ordering mode (.yThenMinX, set in Defaults.swift), ScreenDetection.order(screens:) sorts displays with a comparator that compares some pairs by their Y position and others by their X position:

if screen2.frame.maxY <= screen1.frame.minY { return true }   // one is entirely above the other → by Y
if screen1.frame.maxY <= screen2.frame.minY { return false }
return screen1.frame.minX < screen2.frame.minX                // otherwise (they overlap in Y) → by X

That predicate isn't a strict weak ordering. For a "staircase" layout where A sits above B and B above C but A and C don't overlap vertically, it reports A < B, B < C, and C < A — a cycle. Array.sorted(by:) given an invalid comparator returns an unspecified order, so the result depends on the order NSScreen.screens happens to list the displays in. Since that enumeration order isn't guaranteed to be spatial, the same physical layout can come out three different ways:

order([a, b, c]) -> [a, b, c]
order([c, b, a]) -> [b, c, a]
order([c, a, b]) -> [c, a, b]

screensOrdered drives next/previous-display cycling (adjacent) and the "move to display N" mapping (SpecificDisplayCalculation), so on affected layouts both become inconsistent from one call to the next.

The fix

Group the displays into horizontal bands of vertically-overlapping screens (top band first) and sort each band left-to-right. Banding is a deterministic function of the layout, so the ordering no longer depends on enumeration order. It produces the same result as before on clean rows, columns and grids — and on bottom-aligned mixed-height rows — and only changes the previously-undefined staircase case, where the connected band now simply reads left-to-right.

The other two modes (.midX, .minX) are single-key sorts and were already valid; they're untouched.

Tests

Added ScreenOrderingTests:

  • two tests that fail on main — the ordering must be independent of enumeration order, and the diagonal layout must have one deterministic order;
  • two regression guards — a 2×2 grid still reads top-to-bottom / left-to-right, and a bottom-aligned mixed-height row stays left-to-right.

The full test suite passes (729 tests).

This may also be behind the inconsistent next/previous-display behavior reported in #1383, though that report mentions multiple Spaces as well, so I haven't assumed it's the whole story.

AkiyamaKunka and others added 2 commits November 5, 2026 20:11
ScreenDetection.order(screens:) in the default .yThenMinX mode sorted
displays with a comparator that compares some pairs by Y and others by
X. That predicate is not a strict weak ordering: for a staircase layout
(A above B, B above C, with A and C disjoint) it yields A < B, B < C,
and C < A, so Array.sorted(by:) returned an order that depended on how
NSScreen.screens happened to enumerate the displays. The same layout
could order three different ways, making next/previous-display cycling
and the move-to-display-N mapping inconsistent.

Group displays into horizontal bands of vertically-overlapping screens
(top band first) and sort each band left-to-right. This is deterministic
and matches the previous result on clean rows, columns and grids; it
only changes the previously-undefined staircase case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
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