✨Next/previous screen order setting: Z-shaped or clockwise - #1156
Conversation
|
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 One thing I do think would be worth adapting from my branch is the UI. It uses a Would you be able to update the UI to use something similar, or transfer the relevant code over from that branch? |
|
Done in fb4935a. The screen order setting is now a |
|
Conflict with Heads-up from verifying that locally: the |
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.
|
Conflict with |
5091a1d to
d789f9c
Compare
|
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:
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 Let me know if anything looks off! |
Description
Adds an advanced setting for the order
Next/Previous Screencycles through screens: Z-shaped, Clockwise or Counterclockwise.This implements what you described in #1000:
and, for the 3×3 case raised in the same thread:
Closes #1000
What changed
ScreenOrder(new,Loop/Utilities/ScreenOrder.swift) — the three orders, as aDefaults.Serializableenum. Its one entry point issorted(_:frame:), which takes anything plus a way to get its frame, so the ordering has no dependency onNSScreen.ScreenUtility.getOrderedScreens()now just asksDefaults[.screenOrder]to sortNSScreen.screens. The existing "vertically stacked Z" comparator moved intoScreenOrder.zShapedunchanged, so.zShapedis 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// Advancedblock,iCloud: truelike its neighbours.Screenssection inAdvancedConfigurationView, between General and Radial Menu, holding a singleLuminarePickerMenu.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:
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 ascendingatan2is 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.defaults read com.MrKai77.Loop screenOrder→1after picking Clockwise).swiftformat --lintclean 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, andScreenUtilityonly 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
Checklist:
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:
aRowOfDifferentlySizedScreensIsStillWalkedLeftToRightwas 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.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 NOabove. Worth knowing independently of this PR.I understand every line here and am happy to explain or change any of it.