Skip to content

✨Next/previous screen order setting: Z-shaped or clockwise - #1156

Merged
mrkai77 merged 4 commits into
mrkai77:developfrom
anandghegde:feat/screen-order
Oct 5, 2026
Merged

mrkai77 merged 4 commits into
mrkai77:developfrom
anandghegde:feat/screen-order

Conversation

@anandghegde

Copy link
Copy Markdown
Contributor

Description

Adds an advanced setting for the order Next/Previous Screen cycles through screens: Z-shaped, Clockwise or Counterclockwise.

This implements what you described in #1000:

I'm thinking that it might make more sense to expose an advanced option that lets them choose how "Next/Previous Screen" determines the next screen: using a Z-shaped order, clockwise, or counter-clockwise.

and, for the 3×3 case raised in the same thread:

I'd expect it would cycle through the outer displays clockwise first, then move on to the inner displays, again in a clockwise order :)

Closes #1000

What changed

  • ScreenOrder (new, Loop/Utilities/ScreenOrder.swift) — the three orders, as a Defaults.Serializable enum. Its one entry point is sorted(_:frame:), which takes anything plus a way to get its frame, so the ordering has no dependency on NSScreen.
  • ScreenUtility.getOrderedScreens() now just asks Defaults[.screenOrder] to sort NSScreen.screens. The existing "vertically stacked Z" comparator moved into ScreenOrder.zShaped unchanged, so .zShaped is byte-for-byte the current behaviour, and it is the default — nothing changes for anyone who doesn't touch the setting.
  • Defaults.Keys.screenOrder, in the // Advanced block, iCloud: true like its neighbours.
  • A Screens section in AdvancedConfigurationView, between General and Radial Menu, holding a single LuminarePickerMenu.

How the rotational orders work

Ordering purely by angle around the centre of the layout (the approach suggested in the issue thread) breaks on the two arrangements that matter most: a single row, where every screen sits at ~0° or ~180° and the middle ones land arbitrarily, and 3×3, where the centre screen has no defined angle at all.

So instead the screens are peeled off in rings:

  1. Take the screens on the outside of the arrangement — the convex hull of their centres, keeping centres that lie on a hull edge so a straight row stays one ring instead of losing its middle screens.
  2. Walk that ring by angle about its own centroid, starting from the screen nearest the top left.
  3. Repeat with whatever is left inside.

A row or column has no inside, so it is a single ring and gets ordered along its dominant axis instead of by angle — left-to-right for a row, top-to-bottom for a column, since a row is the top of the circle and a column is its right-hand side. Two screens are a single cycle either way round. 3×3 gives the eight outer screens clockwise from the top left, then the middle one.

All of this runs on frame, i.e. AppKit's global coordinates where y points up, which is why ascending atan2 is counterclockwise here.

How has this been tested?

macOS 26.4.1, Xcode 26.4.1, branched off develop @ df26d56.

  • LoopTests/ScreenOrderTests.swift — 14 tests, all passing (xcodebuild test -only-testing:LoopTests/ScreenOrderTests -skipMacroValidation -parallel-testing-enabled NO). They cover a row, a row of mismatched-size displays whose centres don't line up, a column, a 2×2 block in both directions, the 3×3 from the issue, one screen, two screens, and two invariants that matter for this feature specifically: no screen is ever dropped or duplicated, and starting anywhere and stepping forward reaches every screen before wrapping.
  • Built and ran the app, confirmed the new section renders in Advanced, that all three options appear, and that choosing one persists (defaults read com.MrKai77.Loop screenOrder → 1 after picking Clockwise).
  • swiftformat --lint clean on all five files.

Please read this bit: I have one display on this machine, so I have not watched a window actually hop around a real multi-display setup. That is the reason the ordering is a pure function of frames rather than something reaching into NSScreen — every arrangement above is exercised as a unit test on synthetic frames, and ScreenUtility only supplies \.frame. But a test is not a pair of eyes, and I'd rather say so than imply coverage I don't have. @Caffeine19, if you still have the two-row layout from the issue, this is the branch to try.

One deliberate omission: I did not hand-edit Loop/Localizable.xcstrings. Every entry in it is fully translated Crowdin output with no English-only rows, so adding partial entries by hand seemed more likely to disrupt the sync than help. The four new strings ("Screens", "Next/previous screen order", and the three case names) are all in code and marked for extraction. Happy to add them if you'd prefer.

Screenshots

The new Screens section in Advanced, with the Next/previous screen order menu open showing Z-shaped, Clockwise and Counterclockwise

Checklist:

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation if applicable
  • I have no unrelated changes in this PR.

Please describe to which degree, if any, an LLM was used in creating this pull request.

Claude (via Claude Code) was used, and used heavily: it wrote the first draft of ScreenOrder.swift, the test file, and this description.

What I did with that output, since the policy asks:

  • I picked the ring-peeling approach myself after the model's first attempt did plain angular sorting, which I rejected because it mishandles a single row and has nothing sensible to say about the centre screen in 3×3 — the exact case you called out in the issue.
  • I hand-traced the expected order for every test case rather than trusting the generated expectations. One of them was wrong: aRowOfDifferentlySizedScreensIsStillWalkedLeftToRight was generated expecting ["main", "side", "laptop"], but the rotate-to-top-left step actually produces ["laptop", "main", "side"]. The code was right and the test was wrong, so I fixed the test.
  • While getting the tests to run I hit The test runner hung before establishing connection. Rather than assume it was my change, I ran an existing suite (KeybindResolverTests) and saw the same failure, which pointed at xcodebuild's parallel test clones tripping Loop's single-instance terminate broadcast — hence -parallel-testing-enabled NO above. Worth knowing independently of this PR.
  • I built the app, clicked through the new setting, and checked the written preference myself.

I understand every line here and am happy to explain or change any of it.

@mrkai77 mrkai77 changed the title Add a Next/previous screen order setting: Z-shaped, clockwise or counterclockwise ✨Next/previous screen order setting: Z-shaped, clockwise or counterclockwise Sep 24, 2026
@mrkai77

mrkai77 commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Thanks @anandghegde! I can’t get around to testing this at the moment, but I actually started working on something similar a few months ago on the kai/clockwise-screen branch. It wasn’t working perfectly for me, which is why I never merged it, and yours will likely work better!

One thing I do think would be worth adapting from my branch is the UI. It uses a LuminarePicker with custom SF Symbols to help illustrate what each setting would look like.

Would you be able to update the UI to use something similar, or transfer the relevant code over from that branch?

@anandghegde

Copy link
Copy Markdown
Contributor Author

Done in fb4935a. The screen order setting is now a LuminarePicker adapted from kai/clockwise-screen (9d54e1e), and I added you as a co-author on the commit. Z-shaped uses your arrow.trianglehead.swap.rotated custom symbol. Clockwise uses arrow.triangle.2.circlepath, and counterclockwise mirrors it, because the trianglehead clockwise/counterclockwise symbols need macOS 15 and Loop still supports macOS 13. With three options, the picker lays them out in three columns with each symbol above its name, like the size mode picker in custom actions.

@anandghegde

Copy link
Copy Markdown
Contributor Author

Conflict with develop resolved in 5b28cfb. The only clash was Defaults+Extensions.swift: screenOrder now follows your move of iCloud sync to Defaults.iCloud.add(...) (it stays iCloud-synced) and the hideOnNoSelectionForKeybinds rename. The picker UI and everything else is untouched, and the app target builds cleanly on the merged tree.

Heads-up from verifying that locally: the LoopTests target doesn't compile at develop tip (282ad0a) — CycleProgressStoreTests calls proposeCurrentSelection and an old cycleScreen(...) signature that CycleProgressStore no longer has. I reproduced it on a clean checkout of develop, so it's not from this branch, and dev-build only archives, so CI wouldn't catch it. ScreenOrderTests still compiles on the merged tree, but the suite can't execute until that's fixed.

anandghegde and others added 4 commits October 4, 2026 23:32
Next/Previous Screen walks the screens in a vertically stacked Z shape.
That is a sensible default, but on arrangements that wrap around, such as
two rows of displays, it is not the order people expect.

Adds a Screens section to Advanced with a "Next/previous screen order"
option offering Z-shaped, clockwise and counterclockwise. Z-shaped is the
default, so nothing changes until the setting is touched.

The rotational orders walk the outside of the arrangement first and then
whatever is left inside it, so a 3x3 arrangement visits the eight outer
screens before the middle one. An arrangement with no inside, such as a
single row, stays one cycle rather than losing the screens in the middle
of it. Each cycle starts at the screen nearest the top left.

The ordering is a pure function of the screen frames, so ScreenOrderTests
can cover arrangements that need more displays than are actually attached.
Replaces the "Next/previous screen order" menu with a LuminarePicker, so
each option shows a symbol of the path it takes through the screens.

UI adapted from kai/clockwise-screen (9d54e1e), including its
arrow.trianglehead.swap.rotated custom symbol for Z-shaped. Clockwise uses
arrow.triangle.2.circlepath, the pre-macOS 15 name of
arrow.trianglehead.2.clockwise.rotate.90, and counterclockwise mirrors it,
since arrow.trianglehead.2.counterclockwise.rotate.90 needs macOS 15 and
Loop still supports macOS 13.

With three options the picker uses three columns with the symbol above
the name, like the size mode picker in custom actions.

Co-authored-by: Kai Azim <hi@mrkai77.dev>
…algorithm

Removed the counterclockwise option since it was redundant, as next/previous screen can be bound the opposite way anyway.

The clockwise algorithm had to be reworked as I ran into some edge cases when testing it against different screen arrangements:
- Rows that weren't perfectly aligned (even off by 1px), or had a taller screen in the middle, would cycle left -> right -> middle
- Making a corner screen bigger would push its neighbor to the inside, so it got skipped until the end
- Staircase-like arrangements could start the cycle on a different screen depending on how macOS ordered them, which messed up the whole order

Now, instead of using centroids, it traces the outline of the arrangement clockwise and goes to each screen the first time the outline touches it. Gaps between screens get bridged over instead of walked into, and tiny dips (like screens being a few px off) still count as part of the screen below them. Any screens that are fully surrounded get walked afterwards, ring by ring. Also added regression tests for all of these too.
@anandghegde

Copy link
Copy Markdown
Contributor Author

Conflict with develop resolved in 5091a1d. Upstream's wording overhaul rewrote the string catalog, so the merge takes upstream's version and re-adds this branch's four new keys in catalog order. While rebasing I also noticed the previous upstream merge (5b28cfb) had dropped the screenOrder Defaults key from Defaults+Extensions.swift — restored it, since @Default(.screenOrder) and Defaults[.screenOrder] had been unresolved since that merge.

@mrkai77
mrkai77 force-pushed the feat/screen-order branch from 5091a1d to d789f9c Compare October 5, 2026 17:02
@mrkai77

mrkai77 commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Hey @anandghegde, thanks again for this! I finally got around to testing it, and ended up making a few changes on top of it while I was at it. Since I already had everything working locally, I figured it’d be easier to push them here directly rather than leave a long review :)

I also removed the counterclockwise option. Since it’s just the clockwise cycle in reverse, the same thing can already be done by using the “Previous Screen” action instead of “Next Screen”, so it felt redundant to have it as a separate setting. The bigger change is to how the clockwise order is calculated. While testing different arrangements, I ran into a few edge cases with the centroid-based approach:

  • Rows that weren’t perfectly aligned (even by 1px), or had a taller screen in the middle, would cycle left -> right -> middle
  • Making a corner screen bigger would push its neighbor inside the ring, so it’d only get visited at the very end
  • Staircase-like arrangements could start the cycle on a different screen depending on how macOS ordered them

So instead, it now traces the outline of the screen arrangement clockwise and goes to each screen the first time the outline reaches it. Gaps between screens get bridged rather than walked into, and small dips (like screens being a few pixels off) still count as part of the screen below them. I added regression tests for all of these too.

And I know I originally asked for a LuminarePicker, but after trying it out in settings, I ended up switching it to a LuminarePickerMenu labeled “Next/previous screen order”. With only two options left, it felt a bit heavy for its own section, and the menu fits in a lot better with the rest of the general settings.

Let me know if anything looks off!

@mrkai77
mrkai77 merged commit 995b1bd into mrkai77:develop Oct 5, 2026
1 check passed
@mrkai77 mrkai77 changed the title ✨Next/previous screen order setting: Z-shaped, clockwise or counterclockwise ✨Next/previous screen order setting: Z-shaped or clockwise Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ Option to make Next/Previous display movement follow clockwise order

2 participants