A ⌘-Tab replacement for macOS, in the spirit of Command-Tab Plus 2. Switches between applications or between individual windows, chosen in Settings → Behavior.
./build.sh # produces build/Cmd-Tab.app
./build.sh --install # also copies to /Applications and launches itRequires Xcode. Built against the macOS 26.5 SDK with Swift 6.3; verified running on macOS 27.0.
Deployment floor is macOS 14 (Package.swift).
Grant System Settings → Privacy & Security → Accessibility → Cmd-Tab. Nothing happens until you do — the app polls for the permission and stays inert meanwhile. It needs it twice over: the event tap cannot receive keystrokes without it, and window mode cannot enumerate or raise windows without it.
The switcher itself never needs Screen Recording: tiles are app icons, and window titles come
from the Accessibility API rather than CGWindowListCopyWindowInfo. The one exception is the
optional window preview (Settings → Behavior) — its live thumbnails are captured
with ScreenCaptureKit, so turning it on prompts for Screen Recording. Leave it off and that second
permission is never touched.
The trigger below is ⌘-Tab by default and rebindable in Settings → Shortcuts. In the table it stands in for whatever combination is bound; the held modifier is whatever that combination uses.
| Key | Action |
|---|---|
| ⌘-Tab | Open the switcher / advance |
| ⌘-⇧-Tab | Go backwards |
| ⌘-← / ⌘-→ | Move the selection by one tile |
| ⌘-↑ / ⌘-↓ | Move the selection by one row — columns tiles in the grid, one in the list, keeping the column when it wraps. With a filter active it lands on the nearest tile the filter allows, since filtering dims tiles rather than removing them |
| type | Filter the list by app / window name |
| ⌫ | Delete the last character of the filter |
| ⌥W | Close the highlighted window (with Enable window actions on) — the ⌥ keys act on the tile without leaving the switcher; see Window actions |
| ⌥-Space | Mark or unmark the highlighted tile (with Enable window actions on). ⌥-click marks whatever tile the pointer is on. Marks are a session and clear when the switcher closes; with any marked, ⌥Q/⌥⇧Q/⌥W/⌥H/⌥M act on the whole set instead of just the highlighted tile |
| ⌥T | Tile 2-4 marked window tiles side by side on the first one's display; a no-op with an app tile marked, or more than four |
| ⌘-1 … ⌘-9, ⌘-0 | Switch straight to that tile (no filter active). 0 is the tenth tile |
| Esc | Dismiss the switcher — always, filter or not. While the panel is up it owns every key on the machine, so this is the one exit that must never depend on any other state |
| Release ⌘ | Switch to the selection — unless Stay open is on, which keeps the panel up |
| Tab (⌘ released) | With Stay open, switches to the selection, the way releasing ⌘ otherwise would. ⇧-Tab still steps backwards, and a session opened from the menu bar works the same way — it has no chord to release either |
While the switcher is open, just start typing to narrow the list. Matching is fuzzy and ranked:
each space-separated word must match as a subsequence, so vsc finds Visual Studio Code and
saf 2 still finds Safari's second window. The match is on the tile's title and its app name,
case-insensitively, and the better of the two scores wins so a strong title match is not diluted by
the app name trailing after it.
Ranking is the point of the scoring: a prefix beats a substring beats scattered characters, matches
at word boundaries and in consecutive runs score higher, and shorter candidates win ties. The
selection lands on the best match rather than the first one in list order — typing chr with
Character Viewer ahead of Chrome used to highlight the wrong one and make you arrow past it, which
is exactly the work filtering is meant to save.
Installed apps you could launch are offered alongside whatever the query found running, appended
after it and capped at five. This used to fire only when the query matched nothing at all, on the
argument that padding a list which already has what you asked for is noise. The argument was right
about the noise and wrong about the cliff it produced: typing ter with something running that
matched showed one list, and one more character — the moment the last running match dropped out —
replaced it wholesale with a different one. The set of tiles changed discontinuously in the middle of
a word, which is the opposite of what a filter should feel like.
What keeps that safe is the ranking rule underneath it: something running always outranks something launchable, whatever they score. A query reaching anything already open still lands on it, so the gesture that has always meant "go to the one I have" never starts second copies of things; a launch tile takes the highlight only when nothing running answered, which is exactly what happened before. The suggestions are offered, not imposed. Turn them off entirely with Launch apps from search. Because you are still holding ⌘, the character each key would type is read from the event honouring your keyboard layout, so it follows the physical keys rather than assuming a US layout.
A key held with ⌥ or ⌃ is taken as deliberate rather than typed, so it never reaches the query. A digit still jumps to that tile when the filter is empty; once you have started a query, digits type into it instead (so you can find 1Password). The number badges hide while a filter is active, since the jump is off. Esc backs out of the query first and only dismisses the switcher on a second press, the way a search field behaves.
The mouse works too: while the switcher is open, moving over a tile highlights it (the highlight
and caption follow the cursor) and clicking a tile switches to it. Neither goes through SwiftUI,
because the panel is deliberately non-activating and never becomes key: the highlight is driven by
polling the cursor position on a timer, and clicks are caught in the panel's sendEvent. The poll
is used rather than a global mouse-moved monitor because such a monitor only sees events bound for
other apps — so whenever Cmd-Tab itself is frontmost the highlight would stop following the cursor.
With Preview windows enabled, pausing over an app tile also floats live thumbnails of
that app's windows beside it — see Window preview.
When a query matches nothing running and nothing launchable, three more fallbacks can offer to do
something with the query itself, each its own setting under Fallback and each off by default:
Open as URL when the text looks like an address (has a dot with no spaces in it, or already
names a scheme — localhost:3000 counts), Search the web through a template you choose
(%s stands for the query, percent-encoded), and Run as shell command, which runs the text
verbatim with no confirmation and no visible output. The first two open through your default
browser and carry its icon; the ordering is fixed — URL, then search, then shell — and whichever
of them is offered is highlighted, since there is nothing running to prefer over it. They are
off by default for the reason the setting after them is: unlike a launch suggestion, which only
ever chooses among apps already on the machine, all three leave it, and the shell one runs
whatever was typed with no way to see what it did. It is also the one thing in this app that
driving it from a script can never reach, whatever is enabled.
The pointer gets one of the window actions as a control rather than a key: with Enable window
actions on, the tile under the cursor carries a small × in its top-left corner, and clicking it
closes that window — the same thing ⌥W does. It is the only action a click can reach. Quit and
force-quit are marked destructive and ask before they run, and a control that ends a process on one
stray click is exactly what that flag exists to prevent; closing a window is recoverable in the way
quitting an app is not. That is the same line the cmdtab:// scheme draws, for the same reason.
With Enable window actions on, ⌥-Space marks the highlighted tile — a check appears in its top-right corner — and ⌥-click marks whatever tile the pointer is on, without moving the highlight. Marks are a session: they exist only while the panel is up and vanish the moment it closes, by any route. With one or more tiles marked, ⌥Q, ⌥⇧Q, ⌥W, ⌥H and ⌥M act on every marked tile instead of just the highlighted one — quit and force-quit still ask first, but once, naming the count rather than listing every app. ⌥⇧H (hide others) and ⌥F (zoom) always act on the highlighted tile alone and ignore marks, since neither has a sensible reading for a set. A second ⌥T tiles 2-4 marked window tiles side by side — halves, thirds or quarters, depending on the count — on the first marked tile's display; anything else marked (an app tile, or more than four) makes it do nothing.
The first nine tiles carry their number in the bottom-right corner. The number switches immediately rather than moving the highlight — waiting for ⌘ to come up would make it slower than the arrow keys. A digit past the end of the list does nothing, but is still swallowed: letting it through would fire ⌘-7 in whatever app is sitting behind the panel.
Tiles carry small badges besides the number: a minimized window shows a –, a hidden app an eye-slash, a not-running favourite an ↗ (and a dimmed icon), a marked tile a check in the opposite corner from the number, and — in window mode with more than one display — the window's display number.
Both the number row and the keypad work. The mapping is by physical key position, so it follows the keys labelled 0–9 on ANSI-style layouts.
Menu bar → Settings… opens a System Settings-shaped window: a sidebar of seven tabs, each with its own gradient icon badge, and a search field above them. Typing in the search field replaces the tab list with matching settings, named by the tab and section they live in; picking one switches to that tab, scrolls to the section and outlines it for a moment.
The menu bar item also carries a Shortcuts submenu, rebuilt each time it opens, listing every chord currently bound and grouped by kind. It is the same flattened list the Overview audits, filtered to the rows that are actually bound, so a forgotten chord is one click away rather than a hunt through Settings. If the Overview would report a conflict — now including chords macOS itself already owns, such as Mission Control or Spotlight — a line at the top says how many and opens Settings on the Overview itself, whichever tab was showing before.
Clicking a tiling arrangement, a direct activation or hide/show-all performs it there and then —
the same set cmdtab:// exposes, drawn along the same line and for the same reason. An in-switcher
window action or a mouse gesture is listed with its chord but greyed out, since those can close a
window or end a process and belong behind a keyboard someone is sitting at. macOS's own shortcuts
never appear as rows here — this submenu is "what have I bound", not "what is macOS holding" —
though they still count toward the conflict line above. The chord itself is
drawn as text rather than set as the item's real key equivalent: a key equivalent would ask macOS to
claim that combination for as long as the menu bar item exists, which is the very combination the
event tap already owns.
Content is built from titled sections whose rows sit inside a rounded card — label and explanation on the left, control on the right — so the explanation for a setting sits under it rather than in a tooltip.
| Setting | What it does | Default |
|---|---|---|
| Show menu-bar icon | Off leaves no menu-bar item; reopening Cmd-Tab from Finder is then the way back to Settings. | On |
| Start at login | Registers the app as a login item via SMAppService. |
Off |
| Menu-bar glyph | Which artwork the menu-bar item shows. The menu shows each glyph rather than only its name. | Command |
| Settings file | Export or import every preference, favourites and exclusions included, as JSON. | — |
| Import shortcuts | Carries the chords over from another switcher — see below. | — |
| Reset to defaults | Clears every Cmd-Tab preference. | — |
| Keep settings in a config file | Mirrors every preference to ~/.config/cmdtab/config.json (honouring XDG_CONFIG_HOME). Two-way and live: edits to the file apply without a relaunch, changes made in Settings are written back. The file is written in place rather than atomically, so a symlink into a dotfiles repo survives every save — an atomic write replaces the inode and would quietly break it. On launch the file wins over local defaults, which is what makes a fresh checkout come up configured — and an install that has never touched either of these two switches turns this one on by itself when a config.json is already sitting there, so the checkout really is the whole of the setup. Only ever never touched: unticking writes the decision down, so a file left behind by someone who deliberately turned this off is not revived at the next launch. Unticking leaves the file on disk: it may be tracked, and deleting a tracked file because a checkbox changed is not ours to do. |
Off |
| Sync settings over iCloud | Keeps the settings file at ~/Library/Mobile Documents/com~apple~CloudDocs/Cmd-Tab/config.json instead, where every Mac signed into the account reads and writes the same one — a change on any of them turns up on the others, picked up by the same watcher that notices an edit made in an editor. Independent of the switch above: turning this on alone starts mirroring, with no need to opt into a dotfiles file first. There is only ever one mirror, so with both switches on the file lives in iCloud Drive — a second copy under ~/.config would diverge the moment either changed, leaving two files each claiming to be the settings and nothing to say which wins. Reached by its CloudDocs path rather than through url(forUbiquityContainerIdentifier:), which wants the ubiquity-container entitlement and a provisioning profile naming a team; a plain file in iCloud Drive syncs just as well, needs no entitlement, and is visible in Finder — which for a config file whose point is being editable is the better side of the trade. Turning it on with nothing in iCloud yet seeds it from the file you were using, so your settings are published rather than blanked; turning it on where a file already exists lets that file win, the same rule launch follows. An undownloaded copy (iCloud's .config.json.icloud placeholder) suspends writing until it lands, which is what stops a second Mac overwriting the first's settings during setup. Turning it back off leaves the cloud copy alone — the other Macs are still syncing against it. Greyed out, with the reason given, when iCloud Drive is off. Simultaneous edits on two Macs are resolved by iCloud, which keeps both and leaves a conflicted copy. |
Off |
| Restore macOS ⌘-Tab | Hands the system switcher back without quitting. The takeover is otherwise undone only by a clean quit, which is no help in the case that matters — the trigger is bound to something unreachable and ⌘-Tab does nothing. Cmd-Tab keeps its own trigger, so both respond until it is restarted. | — |
Start at login lives in the system's Login Items, not our defaults.
Import shortcuts… reads the hotkeys already set up in Rectangle, Rectangle Pro or AltTab and offers to carry over the ones there is a matching action for — the tiling chords, and from AltTab the hold-to-switch chord that corresponds to the trigger here. Nothing is written until you agree: it shows what it found first, split into what will be imported, what has no equivalent here and is left alone, and anything it could not read. A chord already bound to something in Cmd-Tab is reported and skipped rather than overwriting what you have, because the settings you arrived with are the ones you chose.
Two gaps, both deliberate. AltTab stores each shortcut twice — once as the readable string its own interface shows, once as an archived object belonging to a framework this app does not link — and only the readable string is parsed, so an exotic chord comes back as unreadable rather than as a guess. Command-Tab Plus 2 is absent: its format is not published and no copy was available to read, and inventing one risks importing nothing while claiming success, or importing the wrong keys.
build.sh signs with a self-signed certificate called Cmd-Tab Local. That is deliberate for
local use: macOS keys the Accessibility and Screen Recording grants to the app's designated
requirement, so signing with the same certificate every time keeps both permissions across
rebuilds. Ad-hoc signing has no certificate, so the requirement falls back to the code hash, which
changes on every build — hence the re-granting. Create it once in Keychain Access → Certificate
Assistant → Create a Certificate (name Cmd-Tab Local, type Code Signing, self-signed).
That certificate is trusted by nothing else. Distributing the app needs a Developer ID Application certificate (a paid Apple Developer Program membership), the hardened runtime, a secure timestamp, and notarisation:
./release.sh # build + Developer ID sign + verify
./release.sh --notarize # ... and submit to Apple, staple, re-zip
VERSION=1.2.0 BUILD=42 ./release.sh # stamp a version into the built bundle only| Variable | What it does |
|---|---|
CODESIGN_IDENTITY |
Signing identity. Defaults to the sole Developer ID Application in the keychain; set it when several teams' certificates are installed. |
NOTARY_PROFILE |
A stored notarytool credential profile, created once with xcrun notarytool store-credentials. |
VERSION / BUILD |
Override CFBundleShortVersionString / CFBundleVersion in the built bundle. The tracked Resources/Info.plist is never touched, so a release build leaves the working tree clean. |
Release builds are universal (UNIVERSAL=1, set by release.sh). A host-architecture build is
right for local work and wrong for a download: an Apple Silicon-only bundle will not launch on an
Intel Mac, and generate_appcast reads the slices out of the bundle and writes
<sparkle:hardwareRequirements>arm64 into the feed, turning that into something the updater
enforces. The second slice costs about ten seconds.
Notes that cost time if missed:
release.shrefuses to fall back to ad-hoc signing. A release that signed itself ad-hoc would notarise nothing and install nowhere, and would do it silently.- Signing drops
--deep, which Apple advises against for distribution. There is no nested code to need it — one executable, no frameworks, no helpers. - No entitlements are required today: the app is unsandboxed, loads no third-party code, and reaches
Accessibility and ScreenCaptureKit through TCC rather than entitlements.
build.shpicks upResources/CmdTab.entitlementsautomatically if that file ever appears. - The release prints the designated requirement. Changing the certificate changes it, and every user then re-grants Accessibility and Screen Recording with no warning from anywhere else.
- Packaging uses
ditto, notzip: the Notary Service needs the bundle's symlinks and extended attributes intact. The zip is rebuilt after stapling, since the ticket lives inside the bundle and a zip made before stapling does not carry it. - Signing is inside-out and no longer uses
--deep. See Updates — the bundle has nested code now, and signing it outside-in produces something that passes a localcodesign --verifyand fails notarisation.
./release.sh --adhoc is the path that needs neither a Developer ID certificate nor a paid
membership: it builds the same universal, versioned bundle, signs it ad-hoc, packages it, and
writes an appcast entry — it just never touches Apple's Notary Service. release.yml picks this
mode automatically: with no DEVELOPER_ID_P12_BASE64 secret configured, a pushed tag runs
--adhoc instead of --notarize and nothing else about the workflow changes.
./release.sh --adhocThe cost is entirely on the download side. Gatekeeper trusts a Developer ID signature's team
identity; an ad-hoc signature carries none, so the first launch of a downloaded (quarantined)
Cmd-Tab.app says it's damaged or from an unidentified developer. Either of these clears it:
- Right-click
Cmd-Tab.app→ Open → Open in the dialog (once; not needed again after). xattr -cr /path/to/Cmd-Tab.appin Terminal, which strips the quarantine flag directly.
Sparkle's own update signing (the EdDSA key, SPARKLE_PRIVATE_KEY) is unrelated to Apple's program
and is required either way — ad-hoc releases still get real, verified auto-updates. Switching to
notarised releases later needs no changes here beyond adding the Developer ID secrets: release.yml
reads their presence, not a mode flag, to decide.
Cmd-Tab updates itself with Sparkle. Before that, a release was a file on GitHub that existing installs knew nothing about: every user stayed on whatever version they first downloaded until they happened to revisit the repository.
Settings → About → Updates has the three controls — check now, check automatically (on), install automatically (off). Automatic installation is off by default deliberately: this app owns ⌘-Tab for the whole machine, and replacing itself mid-session is worth a prompt.
The feed is HTTPS, but that is not what the trust rests on. Every archive is signed with an
EdDSA key whose public half is baked into Info.plist as SUPublicEDKey, and Sparkle verifies
that signature before unpacking anything. A compromised Pages host or a hijacked download can serve
a different file; it cannot serve one this app will install.
The private half lives in the maintainer's login keychain and in the SPARKLE_PRIVATE_KEY repo
secret. It is not recoverable. Losing it means no existing install can ever be updated again —
every future release would be signed by a key they do not trust, and the only route forward is
asking every user to download a new build by hand. Back it up:
generate_keys -x sparkle-private-key.txt # then store it somewhere real, and delete the filerelease.yml checks the imported key against SUPublicEDKey before it builds and fails the release
if they disagree, because the alternative is a feed that looks fine and that no client will install.
Sparkle ships as a binary XCFramework, so it is the first dependency to leave a mark outside the
compile. build.sh now has to:
- Copy the macOS slice into
Contents/Frameworks. SwiftPM links against the XCFramework but does nothing to embed it, so without this the app compiles, signs, launches, and dies on the first line that touches Sparkle with a dyld error naming a path under.build. - Add an rpath (
@executable_path/../Frameworks). SwiftPM bakes in one pointing at.build, which is right forswift runand wrong for everything a user will do. - Sign inside-out. Sparkle is not one binary — the framework wraps
Autoupdate,Updater.appand two XPC services, each separately signable. Signatures nest, so an inner signature invalidates every seal already covering it.--deepis gone from both paths: Apple advises against it for distribution, and it re-signs nested code with the outer options, replacing signatures that were built correctly with ones that were not.build.shends withcodesign --verify --deep --strict, which is the check that catches the mistake locally instead of several minutes into a notarisation run.
A local build never updates itself. build.sh strips SUFeedURL from any bundle that is not a
release, so a --install build cannot notice that the published version is newer than the working
tree and helpfully replace what you were testing. Updater.isConfigured reads that absence and
greys out the controls with the reason.
release.sh --notarize ends by writing build/releases/appcast.xml, appending to the published
feed rather than replacing it — a regenerated appcast listing only the newest release strands
anyone more than one version behind, since an item Sparkle cannot see is an update it will never
take. Set APPCAST_URL to have it fetch the live feed first.
| Variable | What it does |
|---|---|
APPCAST_URL |
The published feed to append to. Skipped if unset, which starts a fresh appcast. |
DOWNLOAD_URL_PREFIX |
What each <enclosure url> is built from. Defaults to this repo's release-download path for the version being built. |
Two workflows, both on macos-26 — pinned rather than macos-latest, since this project reaches
for private SkyLight symbols and has a #available(macOS 26.0, *) branch, so a runner picked by
anything other than the OS this app ships against is testing a version nobody decided to support.
The pin tracks that shipping floor, not the machine development happens on; those have come apart, and deliberately. Nothing either workflow runs is version-dependent — the pure-logic suite and the bundle-assembly check both hold on any recent macOS — so a dev machine running ahead of the runner costs CI nothing. The version-dependent layer is the Accessibility harness, which is opt-in and runs by hand, against whatever macOS is in front of you. That is the point of it: a new OS is exactly when it earns its keep.
ci.yml — on every push and pull request. Builds, runs the suite, then assembles the app bundle
and checks it came out whole. That last step is not redundant with the build: the Info.plist copy,
the icon and the menu-bar PNG flattening are all steps that can break without the compiler noticing,
and a glyph left in a subfolder is a blank menu bar at runtime with nothing else to catch it.
The suite is worth running on every push precisely because it is cheap — 509 pure-logic tests in about a tenth of a second, no window server, no Accessibility grant, no running apps. The parts that cannot be tested that way are the parts where a silent regression costs the most, which is what the harness below is for.
Nine more tests that use real windows, the real Accessibility API and the real window server, and are skipped unless asked for:
CMDTAB_AX_HARNESS=1 swift test --filter AccessibilityHarnessTestsThey cover the layer the pure suite cannot reach, and it is the layer where being wrong is most
expensive. WindowClassification encodes a subrole table Apple has never documented and that has
already changed under this app once — the AXDialog trap, and Finder on top of it — and the table in
this README was established by hand, against TextEdit, on one OS version. The harness re-derives it
on whatever macOS is running: it opens a window, minimizes it, and asserts the subrole flips
AXStandardWindow → AXDialog and back while the window stays switchable. WindowTiler writes
frames through an API whose hosts clamp and round them, so a computed frame is checked by writing it
to a real window and reading it back — the halves, maximize, a gap, restore, and a one-edge resize
with the other three edges asserted unmoved. WindowNavigator.onScreen() is checked to actually find
three real windows, in the coordinate space WindowNeighbors.pick assumes.
A macOS point release can break any of that without a single other test going red; the symptom would be a user reporting that half their windows had vanished from the switcher.
Two guards, because it needs the Accessibility grant and it puts windows on someone's screen: the
environment variable, and AXIsProcessTrusted(). It says which one is missing rather than failing.
The grant is on the binary that runs the tests, so the first run prompts and the run after it
measures something — which is why this is opt-in rather than part of the suite. The value is in
running it deliberately against a new macOS, not a hundred times a day.
One thing about it is worth writing down, because it cost an hour and looks exactly like a
permissions problem no amount of granting fixes: an XCTest bundle has to call
NSApplication.finishLaunching() before any of this works. AppKit installs its Accessibility
server as part of launching and XCTest never calls NSApplication.run(), so without that line every
AXWindows read against our own pid returns kAXErrorNotImplemented (-25208). Measured with an
identical probe one line apart: -25208 and zero windows against 0 and one window.
release.yml — on a v* tag. First decides its mode: notarize if
DEVELOPER_ID_P12_BASE64 is configured as a repo secret, adhoc otherwise. In notarize mode it
imports the Developer ID certificate into a throwaway keychain and stores notarisation credentials;
either way it imports the Sparkle key and checks it against SUPublicEDKey, runs ./release.sh --notarize or ./release.sh --adhoc accordingly, publishes the GitHub release (with a Gatekeeper
note in the release body for adhoc), and only then pushes the appcast to gh-pages. Ordering is
deliberate: an appcast entry pointing at a release that failed to upload is an update every client
tries and every client fails.
It runs the same release.sh a maintainer runs by hand. A CI-only build path is one that stops
working without anyone noticing until the day they need it.
| Secret | What it is | Needed for |
|---|---|---|
DEVELOPER_ID_P12_BASE64 |
The Developer ID Application certificate and key, as base64 -i cert.p12. |
notarize mode only — its absence is what selects adhoc mode |
DEVELOPER_ID_P12_PASSWORD |
The password set when exporting that .p12. |
notarize mode |
KEYCHAIN_PASSWORD |
Any string. Unlocks the temporary keychain the job creates and destroys. | notarize mode |
NOTARY_APPLE_ID |
Apple ID for the Notary Service. | notarize mode |
NOTARY_TEAM_ID |
The 10-character team identifier. | notarize mode |
NOTARY_PASSWORD |
An app-specific password, not the account password. | notarize mode |
SPARKLE_PRIVATE_KEY |
The EdDSA private key, as printed by generate_keys -x. |
both modes — unrelated to Apple's program |
Until the first six exist, every tagged release runs in adhoc mode automatically — see Ad-hoc releases.
GitHub Pages must be serving the gh-pages branch for SUFeedURL to resolve. The workflow creates
that branch on the first release and writes a .nojekyll alongside the appcast, which stops Pages
running the XML through Jekyll and mangling it.
| Setting | What it does | Default |
|---|---|---|
| Switcher shortcut | The combination that opens the switcher. Click, then press a new combination — a modifier (⌘/⌥/⌃) is required, since the switcher stays open only while it is held. The native ⌘-Tab is suppressed only while the shortcut is ⌘-Tab; a custom combination leaves the system switcher alone. | ⌘-Tab |
| Cycle app windows | A second shortcut showing only the frontmost app's windows. Off by default — ⌘-` is a shortcut apps use themselves. | Off, ⌘-` |
| Scoped shortcuts | Extra triggers that open the switcher on part of the window list: this app's windows, all windows, windows on the current display, windows on the current Desktop, minimized windows, or browser and terminal tabs. Held and released like the main trigger and never sticky — a scoped cycle is a jump, not a panel to browse. Unbound when added, since choosing the scope and choosing the chord are separate decisions. Persists as scopedTriggers. See Tab search for the tabs scope. |
None |
| Overview | Every binding in the app in one list, with cross-store conflicts flagged, including chords macOS itself already owns (Mission Control, Spotlight, the screenshot combinations, and anything bound in System Settings → Keyboard → Keyboard Shortcuts → App Shortcuts). Each pane warns about clashes inside its own store; nothing could see across them, and the kinds of binding are spread over several stores — a tiling chord and a direct activation on the same keys produced no warning anywhere. A system chord is read from the two preference domains it lives in (AppleSymbolicHotKeys and NSUserKeyEquivalents) and treated like any other family: shown under the system's own name for it, flagged if a Cmd-Tab binding sits on the same keys, silent if it's present but switched off in System Settings, since then it isn't claiming anything. One honest gap: an id with no entry in AppleSymbolicHotKeys at all is at its OS default, which may still be enabled, and this can't see that — only entries actually present in the plist are checked. Shows which of a clashing pair actually fires. |
— |
| In-switcher keys | The keys the panel handles while it is open, listed for reference: Tab/⇧Tab and ←/→ move the selection, Return switches, 1–9/0 jump, typing filters, ⌫ deletes a filter character, ⎋ closes. Not rebindable. | — |
The tabs scope above lists one tile per tab, not per window: every running regular app that exposes tabs through Accessibility contributes the tabs of its frontmost window only, picked from Accessibility rather than any per-app integration — there is no AppleScript, no browser extension, and nothing to install in the browser. Typing filters exactly the way it does everywhere else in the switcher, against the tab's own title and the owning app's name, so "saf" narrows to Safari's tabs the same way it would narrow to Safari itself in the app list. Picking a tile activates the app, raises that tab's window, and presses the tab — no fallback if the press fails; the window is still raised.
Finding "the tabs" has no API to ask for directly, so this is a search: walk the frontmost window,
at most 12 levels deep, for the first AXTabGroup with two or more AXRadioButton descendants, and
treat those as the tabs. One search rather than a table of per-app rules, because every tab strip
measured — Safari, Chrome-family browsers, Ghostty — turns out to have that shape, just at
different depths. The walk only ever happens when a tabs-scoped trigger opens the switcher, never on
the switcher's ordinary background refresh, and it is capped at roughly 150ms across every app so
one slow responder cannot hold the panel up — whatever was found by then is what shows.
Only the frontmost window of each app is searched, not every window it has open: a browser with several windows shows the tabs of the one you were last in, the same "which window" question every other part of the switcher answers by picking the front one rather than asking you to choose.
See Known limitations for the two apps this does not reach.
Global hotkeys that snap the focused window — they fire with nothing open and act on whatever you are looking at, which is why they are not on the Shortcuts tab with the switcher's own triggers.
| Setting | What it does | Default |
|---|---|---|
| Enable window tiling | The master switch for the sizing arrangements. Off by default: each binding is a real global hotkey, so while tiling is on Cmd-Tab takes those combinations away from whatever app is in front. Nothing is claimed until you ask for it. Two groups below are deliberately outside it — Displays and Focus: a move changes no layout and a focus chord changes no window at all, and a chord that silently did nothing because of a checkbox captioned about tiling is the failure this split exists to avoid. | Off |
| Cycle widths | Press the same half twice to step the window through ½ → ⅔ → ⅓ of the screen on that side. The cycle resets when you tile a different window or pick a different arrangement. | On |
| Gap | Space left around a tiled window, as one value on one slider, 0–100 px in steps of 2. A tile edge lying against the screen takes the full gap; an edge where two windows meet takes half of it, so neighbours sit exactly one gap apart — the same distance as each is from the outside. 0 keeps them flush. Applies to the keyboard arrangements and to drag-snapping, whose preview is inset to match so it shows where the window will actually land. Not applied to Center (it keeps the window's own size), Restore (it puts back a frame you chose) or the display moves. This is Rectangle's GapCalculation arithmetic, including its consequence that a middle third is half a gap wider than the outer two. Persisted as windowTilingGap. A version of this app briefly split the setting into four per-edge values; those keys (windowTilingGapTop and friends) are read once and collapsed to the widest of them, which never tightens spacing someone had deliberately opened up. |
0 (off) |
| Halves | Left, right, top, bottom. | ⌃⌘ ← → ↑ ↓ |
| Thirds | Left, middle, right — a third of the width, full height. | ⌃⌘ 1 2 3 |
| Corners | Top-left, top-right, bottom-left, bottom-right — each a quarter of the usable area. | ⌃⌘ U I J K |
| Thirds (rows) | Top and bottom — full width, a third of the height. Unbound: pressing ⌃⌘↑ three times already lands there, so these are for anyone who wants one press and a fixed destination rather than a position in the width cycle. | Unbound |
| Two-thirds | Left and right — the ⅔ half of a ⅔/⅓ pair. Unbound for the same reason: ⌃⌘← twice is the same tile. | Unbound |
| Make larger / smaller | Grows or shrinks the window by a twentieth of the screen a press, anchored on the window's own centre so a press of each returns it to where it started. Measured against the screen rather than the window, so the step is the same size whatever it is aimed at; clamped to the display, and with a floor so repeated shrinking cannot end at a window too small to grab. | ⌃⌘ = − |
| Grow / shrink an edge | Eight rows: move one edge outward or inward by the same twentieth of the screen, with the other three pinned. The difference from Make larger is the whole point — that one moves all four edges around the centre and keeps the window where it is, these change one boundary and leave the rest alone, which is what tuning a tile against its neighbour actually needs. A window snapped to the left half can have its right edge pushed out over the gap without its left edge leaving the screen edge it was snapped to. Growing stops at the display and shrinking stops at a window you can still grab; at either limit the press writes nothing at all rather than producing a sliver. | Unbound |
| Maximize height / width | Fills the screen in one axis and leaves the other exactly as it was. Pressing one and then the other is Maximize, precisely — the preserved axis is copied from the window rather than recomputed, which is what makes the pair compose. Neither takes a gap: the axis they promise not to touch is one the user positioned, and TilingGap insets all four edges. |
Unbound |
| Almost maximize | Nine tenths of the usable area, centred — the size for one window you are working in without losing the fact that there is a desktop behind it. No gap either, and for the opposite reason: its margin is the point, so insetting a frame that has already been inset would make the gap setting mean two different things depending on which key produced the window. | Unbound |
| Nudge | Moves the window without resizing it, a twentieth of the screen a press. Stops at the screen edge rather than walking the window off it — a chord pressed repeatedly and without looking must not be able to lose one. | Unbound |
| Swap | Exchanges the focused window's frame with the window in that direction. The two take each other's exact frames — no gap, no fraction — so the inverse is the same chord the other way. Gated on the tiling switch, unlike Focus below: a swap moves two windows, which is a layout change however you describe it. | Unbound |
| Move to previous / next display | Keeps the window's size and its relative position on the new display. ⇧ on the halves' own arrows: same key, "throw it further". Fires whether or not tiling is on. | ⌃⇧⌘ ← → |
| Move to display 1 … 4 | Names the destination instead of counting to it. On two displays "next display" is exact; on three it is two presses and a guess about which way round they are, and guessing wrong puts the window on the far screen. These land on the same display every time, wherever the window starts. The numbers are the ones on the window tiles' own display badges — both count NSScreen.screens, so the number here, the number on the badge and the display it lands on are one number rather than three that happen to agree. Rows appear only for displays you actually have: four cases exist because the raw values are a fixed URL grammar, and a row captioned "Move to display 3" on a laptop is a control that cannot do anything. Ungoverned by the tiling switch, like the two relative moves. |
Unbound |
| Take the pointer along | Warp the cursor onto the window after a display move, so the next click and the next hover are where you are looking. Off by default — every display is on screen at once, so the move is already visible wherever the pointer is, and moving someone's pointer is a liberty worth asking for. (Following a Desktop move defaults to on, and the difference is not inconsistency: without it you are left looking at the space the window has just left.) | Off |
| Restore the layout when displays change | Remembers where every window sat under each set of monitors and puts them back when that set returns — the undo macOS has never had for undocking. See Restoring a layout across a display change. | Off |
| Focus left / right / up / down | Moves the keyboard to the nearest window in that direction. The other half of tiling, which could always place windows and never let you walk between them. Live whether or not tiling is on, since focus resizes nothing. Unbound by default: ⌃⌘, ⌃⇧⌘ and ⌃⌥⌘ arrows are all spoken for, and the only combination left is the four-modifier ⌃⌥⇧⌘. | Unbound |
| Focus follows the pointer | Rest the cursor over a window and the keyboard goes to it, with no click. Off by default, with a rest delay you set. See Focus follows the pointer. | Off, 250 ms |
| Move to previous / next desktop | Sends the focused window to the next desktop (Space) along, stopping at the first and last rather than wrapping. ⌥ on the halves' own arrows, one row along from the display moves' ⇧ — same key again, "further still". Off by default, and the only move with a switch of its own: macOS offers no way to move another app's window between desktops, so this performs the gesture instead — it picks the window up, opens Mission Control for a moment and drops it on the destination's thumbnail. That takes over the pointer for roughly six-tenths of a second, or about 1.3s with Follow the window on, which is still not something to claim on your behalf. It acts on the frontmost app's focused window, and because it has to grab that window it declines — with a line in the log rather than a half-move — if the title bar is covered at the moment it presses, or if the window is full screen or minimized. The synthetic drag is posted with the modifier flags explicitly cleared: a leftMouseDown inherits the live modifier state, and since the chord that triggered it is still under your fingers, a ⌃ riding along would make the press a Control-click — a right click — and nothing would move. Two settings sit with it. Follow the window (on) switches you to the desktop it landed on, by pressing that desktop's own thumbnail — a real transition performed by macOS, not the bookkeeping-only private Space switch. And the window is put back on the frame it started with: the drag genuinely carries it up to the Spaces Bar, so macOS drops it wherever the gesture ended, which read as the move shoving windows to the left. The synthetic drag is also stamped as ours (SyntheticEvent) so Snap by dragging and the modifier-drag ignore it — without that, the route across the top of the screen is the maximize snap zone, and a moved window arrived maximized or inset by the gap. Persisted as windowTilingDesktopMoves and windowTilingFollowsDesktopMove. |
Off / On, ⌃⌥⌘ ← → |
| Snap by dragging | Drag a window to a screen edge or corner and drop it to tile there — edges give halves, the top gives maximize, corners give quarters, the centre of the screen gives full screen, with a translucent preview of where it will land. Grab the window anywhere, not just its titlebar: what tells a window drag from a text selection is not where the press landed but whether the window actually moved — origin changed, size unchanged — which is also how Rectangle's SnappingManager decides. Independent of the shortcuts, so you can have either or both. Off by default. |
Off |
| Move and resize with the mouse | Hold a modifier and drag anywhere in a window to move it; hold the other and drag to resize from the corner of the quarter you pressed in, with the opposite corner pinned. Defaults are ⌃⌥ to move and ⌃⌘ to resize — Rectangle's — and both are recorded rather than picked from a list: click the row and hold any combination of ⌃⌥⇧⌘, released to commit. At least one of ⌃/⌥/⌘ is required, since ⇧ alone would make every drag on the machine a window drag. Unlike Snap by dragging, which watches passively, this one owns the drag: a real event tap swallows the mouse while the modifier is held, so a move across a document does not select text on the way. While the chord is held, the window under the cursor is outlined so it is never a guess which one the gesture will grab — an outline, where the snap preview is a filled block, because "this is the window" and "this is where it lands" should not look alike. Snaps like a titlebar drag: carry the cursor to a screen edge or corner and that zone lights up in the same overlay drag-snapping uses — let go there and the window tiles to it, gaps included — while a drop away from any edge leaves the free move or resize where you put it. Both gestures snap, since a resize dragged into a corner means what a move dragged there does. The zone geometry is shared with DragSnap, so an edge snaps identically however you reach it. Independent of the tiling switch. Persisted as windowMouseDragEnabled, windowMouseDragMoveModifiers, windowMouseDragResizeModifiers. Or skip the button entirely: hold the chord and point. The window under the cursor is outlined, a dot marks where the cursor started, moving away from it in any of eight directions lights up that destination, and releasing the chord snaps the window there — staying within 45pt of the dot means the whole screen. This is the gesture Rectangle Pro inherited from Hookshot, and it needs no grab at all: the window is never clicked, focused, or brought forward. The dot's colour is selectable, defaulting to the system accent; the outline and the landing block are fixed at light grey on black — Rectangle's own footprint styling (FootprintWindow: borderColor = .lightGray, fillColor = .black, borderWidth = 2, alpha 0.3) — and are not configurable — they are large and translucent, and read as the system's own highlighting, where the dot is 14pt of solid colour and the one mark worth making yours. |
Off, ⌃⌥ / ⌃⌘ |
| Maximize | Fills the usable area, so a maximized window sits under the menu bar rather than behind it. | ⌃⌘↩ |
| Center | Keeps the window's size and centres it; a window bigger than the screen is clamped to it. | ⌃⌘C |
| Restore previous size | Back to where the window was before you first tiled it — saved once per window, so it is not merely the previous tile. Press it again and you go back to the tile you just undid: restoring records the frame it is about to replace, so the chord toggles between the two rather than firing once and going quiet. One level of undo per window, which is what makes the second press unambiguous. It is the same remember the frame before the change mechanism every other arrangement uses, so it obeys the per-app Never tile rule like the rest of them, and cmdtab://tile/restore reaches it without a chord. |
⌃⌘Z |
| Hide all windows | Hide every app to clear the screen to the desktop. | Unbound |
| Show all windows | Bring back exactly what Hide all hid — apps you hid yourself stay hidden, since undoing a decision this feature never made would be wrong. | Unbound |
Every binding is user-defined: click a shortcut and press a new combination, ⌫ clears it, ⎋ cancels. A cleared arrangement hands its chord back to whatever app wants it and stays cleared — persisted as an empty pair, so it is told apart from "this install predates the binding", which is what takes the default. The recorders are live whether or not tiling is switched on, since setting the keys up before enabling the feature is the natural order to do it in.
A chord needs at least one of ⌘/⌥/⌃ — a bare key would fire on every keystroke in every app. Assigning one chord to two arrangements is allowed rather than refused (you may be mid-way through swapping a pair around), but the row says which of the two will actually fire and both turn amber. A chord one of the switcher triggers already claims is refused, because there is no workflow where binding it is a step towards anything: the tap matches both triggers before tiling, so the binding could only ever open the switcher. Rows also re-check this on every render, since changing the switcher shortcut later can strand a tiling chord that was fine when it was set.
Tiling goes inert while Cmd-Tab itself is frontmost. Otherwise the tap would consume every bound chord before the shortcut recorder could see it — no already-assigned combination could be re-recorded, and the keypress would snap the settings window instead of binding. Both key edges are swallowed, so nothing downstream sees a key-up with no matching press.
Restore points are keyed by the window element itself (CFEqual), never by pid: two windows of one
app must not share a slot, or restoring the second would move it to a frame the first once had. The
table evicts oldest-touched first at 128 entries rather than clearing, so tiling one more window
than the cap cannot silently strand every window you are still working with, and a window you keep
restoring keeps its slot. Every arrangement records the window's frame before it writes a new one,
but only when there is nothing recorded yet — that is what keeps the anchor at "before any of this
started" instead of letting it creep forward one tile at a time. Restore is the single exception,
and deliberately: it overwrites, with the frame it is about to replace, which is the whole of what
makes it a toggle.
Geometry is computed in Accessibility's top-left-origin space against each screen's visibleFrame,
and the target screen is the one the window most overlaps rather than merely touches — a window
straddling two displays tiles on the one it is actually being used on. Frames are applied as
position → size → position: some apps clamp a move against their current size and others clamp a
resize against the screen edge from their old origin, and setting the origin twice around the resize
is what makes both land. Key repeat is swallowed but ignored, so holding ⌃⌘← does not strobe the
window through every width.
| Setting | What it does | Default |
|---|---|---|
| Switch between | Applications — one tile per running app, the way ⌘-Tab has always worked — or Windows, one tile per open window across every app, each carrying its own title. Window tiles get a smaller icon to pay for the title, and in window mode Hide apps with no windows and Preview windows are both moot and disabled. Persists as switcherMode. |
Applications |
| Show delay | How long to wait before drawing the panel, so a quick tap switches with no flash. | 0 ms |
| Stay open | Releasing the trigger leaves the switcher up instead of switching. The selection then moves with the arrows, ⇧-Tab, scroll or the mouse, and Tab switches to it — with the chord up there is no release left to do that job, so Tab takes over as the go key (⇧-Tab keeps its usual job of stepping backwards, or a released session would have no way to reverse-cycle) (Return, a click and 1–9/0 switch too; Escape backs out). A stay-open session dismisses itself after 20 s idle, 60 s outright, or a click anywhere outside it, so it can never sit on the keyboard. | Off |
| Order | Recently used (an MRU list kept from activation notifications) or alphabetical. | Recently used |
| Group windows by app | Window mode only. On keeps each app's windows in a run, which is what the list has always done — not by decision, but because it is built by walking the sorted app list. Off ranks every window against every other by when you last used it, so one tap of the trigger reaches the window you were in before this one whichever app it belongs to. That order was not previously reachable at all. Alphabetical always groups: sorted by name, an ungrouped list would scatter one app's windows wherever the alphabet put them. Persists as groupWindowsByApp. |
On |
| Desktops | Which Desktops window tiles may come from — all of them, or only the one in front. Application tiles are never affected, since an app is not on a Desktop. Read fresh each time the switcher opens rather than when the list was built: a window's own Space does not change when you switch Desktops but which Space is in front does, and switching to an empty Desktop activates no app and so rebuilds nothing. Persists as windowSpaceScope. |
All desktops |
| Hide apps with no windows | An app whose windows are all minimized counts as empty. | Off |
| Position | Screen centre, the active screen's centre, or near the cursor. | Screen centre |
| Show on | Which displays get a panel. | Automatic |
| Preview windows | See Window preview. | Off |
Each persists in UserDefaults (hotkeyKeyCode/hotkeyModifiers, sortOrder, stickyMode,
showDelayMs, hideEmptyApps, panelPosition, panelScreens).
Layout comes first, because it decides what everything below it is describing:
| Layout | What it draws | Wraps on |
|---|---|---|
| Grid | Icons in a wrapping grid, with the selected target's name in a caption underneath. | Width — a row fills until it reaches 86% of the screen, then wraps. |
| List | One target per row: a smaller icon with the name beside it, and the ⌘-number hint, display and Space markers along the trailing edge. No caption — every row already carries its name. | Height — rows stack downward and start a second column when they would run off the screen, each column filling top to bottom so the reading order still matches the order the trigger steps through. |
Both are driven by the same sliders: a list row's icon is 42% of the icon-size setting, and the
row's width scales with it too, so one control still scales the whole panel. Max columns caps
the wrap in either layout (0 = automatic). Persists as switcherLayout.
A list row never carries a live thumbnail, whatever the thumbnail settings say. At the height a row gives it, a capture reads as a smear rather than a preview, and it would cost a Screen Recording capture per row to draw it.
With Panels → All displays every panel used to be an exact copy of the first, tile size and column count included. That is wrong the moment the desk is mixed: a tile sized for a 6K display is a postage stamp on the laptop lid beside it, and a laptop-sized panel spills off a smaller external. Each panel now derives its own tile size and column count from the screen it is actually on, out of that screen's visible area and backing scale. The sliders stay one shared setting — what varies per display is only how much of that setting survives contact with the screen — and Max columns still applies everywhere as a ceiling. There is no per-display appearance setting and none is planned: the same sliders, landing sensibly on each screen, is the whole of it.
The sliders below it are the Metrics the panel lays itself out from:
| Slider | Range (px) | Default | What it moves |
|---|---|---|---|
| Icon size | 32–128 | 64 | Icon edge length. Window mode uses 75% of it, having given up room to the title, and scales in step. |
| Icon spacing | 0–48 | 18 | Slack around each icon, inside its highlight. Sets how far apart icons sit, and stacks with panel padding at the edges. |
| Panel padding | 0–36 | 10 | The frosted border above, below and beside the tiles — this is padding inside the glass. |
| Title spacing | 0–28 | 2 | Gap between an icon and its name: the caption in app mode, the in-tile title in window mode. |
Distance from an icon to the glass is iconSpacing / 2 + panelPadding — the two stack, which is
why tightening only one of them disappoints. The gap between neighbouring highlights is
iconSpacing + tileGap, and tileGap is deliberately not adjustable down to zero: touching
highlights read as one smeared blob rather than two tiles.
The preview is a real panel — same glass, same metrics, real icons — because the switcher itself cannot be seen while the settings window is frontmost. Dragging a slider also resizes an already-open switcher live.
Each value persists in UserDefaults (iconSize, iconSpacing, panelPadding, titleSpacing)
and is clamped on read, so a hand-edited plist cannot produce an unusable panel.
Below the sliders are three more controls:
| Setting | What it does | Default |
|---|---|---|
| Highlight colour | The tint of the selected tile. Persists as a hex string (highlightColorHex). |
System accent |
| Appearance | Forces the panel Light or Dark, or matches the system. | Match system |
| Material | The frosted glass behind the tiles. Defaults to Under-window — the most see-through of them — with the custom blur on at 40, since heavy frost is what keeps a see-through panel legible over busy content. The old near-solid look is the Classic theme. | Under-window |
| Setting | What it does | Default |
|---|---|---|
| Restore the layout when displays change | Remembers where every window sat under each set of monitors and puts them back when that set returns. Persists as restoreLayoutOnDisplayChange. |
Off |
Docking and undocking is the one thing that scrambles a carefully arranged screen with no way to undo it: macOS evacuates the windows off a display that has gone and does not remember where they were when it comes back. This watches the desk, keeps the layout under each arrangement of displays, and puts it back when that arrangement returns.
It has to have seen a desk before it can restore it. The first plug or unplug after switching this on only learns; the one after that restores. That is inherent rather than a limitation to fix — there is no layout to put back until there has been one.
A desk is identified by its displays' hardware UUIDs, sorted, so the same monitors rearranged left-to-right in Displays settings are the same desk: reordering two monitors does not mean you want a different window layout, it means you want the same one on the monitors it was on. Resolution is not part of the identity either, because frames are stored as a fraction of the display they were on rather than as absolute coordinates — the right half of a 2560-wide monitor comes back as the right half of whatever is there now, where absolute coordinates would put it off the edge of a smaller screen while looking perfectly plausible.
This is not the saved-layouts feature that was removed in 19deabc, and the difference is the
point. That one was named layouts — "Work", "Writing" — restorable by chord and persisted to disk,
and its two hard problems were both about a layout outliving the moment it was captured: which live
window a saved record refers to after a restart, and what a stored frame means once the monitor it
was measured on is gone. This has neither, by construction. Nothing is written to disk and nothing
spans longer than a cable being pulled out, so a window is identified by its CGWindowID — exact,
free, and stable for as long as the window exists — and a window closed in the meantime is simply
not in the list. What it keeps of the older design is the half that was right: fractional frames
keyed by display UUID, which is WindowLayouts.swift's own reasoning and the reason DisplayArea
still carries a id.
The one cost is a timer. The interesting moment has no notification in front of it —
didChangeScreenParametersNotification arrives after macOS has moved the windows off the display
that just went, so by the time anything is told, the positions worth remembering are gone. Something
has to have been watching, and that is one CGWindowListCopyWindowInfo every five seconds, only
while the setting is on. It is the same call that already runs on every left click of the machine
when the drag gesture is enabled.
Restoring waits 1.2s after the desk settles, because macOS does its own tidying over that window and a restore that raced it would be overwritten. A window already where it belongs is not written at all — most of the list, usually — and an app carrying the never tile rule is left alone, since that rule says "do not rearrange my windows" and a rescue is a rearrangement however well meant.
| Setting | What it does | Default |
|---|---|---|
| Focus the window under the pointer | Rest the cursor over a window and the keyboard goes to it, with no click. Persists as focusFollowsMouseEnabled. |
Off |
| Rest for | How long the pointer must be still first. 100–1000 ms. Persists as focusFollowsMouseDelay. |
250 ms |
The X11 behaviour, which macOS has never offered. Off by default because it changes where every keystroke on the machine lands, which is not a thing to switch on for someone.
The delay is the whole feature. With none, every sweep of the pointer across the screen on the way somewhere else re-focuses each window it crosses — which steals keystrokes mid-sentence and does it invisibly. The delay is what turns "the cursor passed over this" into "the cursor stopped here", and the slider bottoms out at 100 ms rather than zero: that is a floor on a footgun, not a limitation.
Nothing happens while a mouse button is held (a drag, a text selection or a menu held open all belong to the window they started in), while any modifier is held (a chord in progress — the switcher's trigger, the hold-and-point gesture, or a shortcut part-way through), or while the switcher is open (the panel is non-activating and the frontmost app behind it is what a commit measures itself against, so re-focusing something underneath would change what "the previous app" means while you are looking at a list built from the old answer). Cmd-Tab's own windows are excluded outright: this app is an accessory without a Dock tile, and hovering over Settings on the way past should not make it frontmost.
macOS raises a window as part of focusing it, so the window you rest over comes to the front. There is no "focus without raise" to offer — it is not something the public APIs can express for another app's window — so it is not offered as a setting that would not work.
Four chords — focus left, right, up, down — that move the keyboard to the nearest window in that direction. The tiler could always place windows and never let you walk between them; four windows in the four quarters and the only route to the one on the right was the switcher, which is a list ordered by recency with no notion that "the one on the right" is something a person can mean.
Unbound out of the box. Every arrow combination is already spoken for — ⌃⌘ is the halves, ⌃⇧⌘ the display moves, ⌃⌥⌘ the Desktop moves — which leaves only the four-modifier ⌃⌥⇧⌘, and claiming that on someone's behalf is the guess the app declines to make everywhere else. The rows are one click from bound, or reach them by URL (below) and spend no chord at all.
Built on CGWindowListCopyWindowInfo rather than Accessibility: the whole question is "where is
every window", and that is one cheap call to the window server against 2n rounds of IPC to every
app on the machine. It also excludes the right things for free — minimized windows and windows on
other Desktops are not on screen, and "the window to the left" can only mean one you can see.
Picking the neighbour is three rules, and each is there because the one above it is not enough:
- Overlap beats distance. A candidate whose perpendicular span overlaps the origin's always wins over one that does not, however much nearer. Without it a directional chord wanders diagonally.
- Then the smallest gap between the two windows' facing edges — not between their centres. A full-height window on the left, a half-screen window filling the right, and a small palette floating just inside the right window's leading edge: the palette's centre is far nearer, so a nearest-centre measure sends the keyboard to the palette every time. Measured from the edges, "next to" means what it looks like. This one took a test to find.
- Then nearest across the axis of travel, which separates two windows stacked in the next column.
Ties resolve to whichever is nearer the front, since the window list arrives in z-order.
Swap uses the same neighbour rule and exchanges the two frames verbatim.
| Setting | What it does | Default |
|---|---|---|
| Preview windows | App mode only: pausing on a tile — with the pointer or with the keyboard — floats live thumbnails of that app's windows beside it. Persists as windowPreviewOnHover, which still says "on hover": the setting has since grown to cover the keyboard, and renaming a defaults key turns everyone's preference back off on the update that renames it. |
Off |
It follows the selection, not only the cursor. For a long time this was a mouse feature in a switcher whose whole premise is that your hands are on the keyboard: cycling with ⌘-Tab showed you icons, and the only way to see what was actually inside an app was to let go and reach for the mouse. Pausing on a tile now floats the strip whichever way you got there.
Both paths share one debounce, and that is what keeps it cheap: a tap of ⌘-Tab is over long before the 0.28s elapses, so the common gesture captures nothing at all, and cycling quickly through ten tiles fires one capture — for the tile you stopped on — rather than ten. The last input wins where the two disagree, with one exception: while the cursor is on the strip you are picking a window out of it, so the keyboard does not re-point the strip out from under your hand.
This is the one feature that uses Screen Recording. Thumbnails are captured with ScreenCaptureKit into a second non-activating panel that never takes the mouse or keyboard, so the tile behind it — not the preview — stays the switch target. Turning the setting on prompts for the permission; a previous denial is routed to System Settings instead, since macOS only shows the prompt once. The grant takes effect on the next launch, which is standard for Screen Recording.
The window list is enumerated with onScreenWindowsOnly: false, so windows on other Spaces are
included — ScreenCaptureKit captures a window's backing surface even when its Space isn't frontmost,
which is exactly what you want when previewing an app you aren't currently looking at. Ordering
follows the switcher's own Accessibility window list (the same order window mode shows) when the
window IDs resolve, and falls back to a size filter otherwise. Every capture then passes a
blank-content check — a downsampled alpha test — and anything essentially transparent is dropped.
That check is what removes the Electron/Catalyst phantom backing windows those apps expose (a
hidden second "window" with no real content) without needing to special-case them. The one kind of
window that can't be shown is a minimized one: it has no live surface, so it captures blank and
is dropped. Captures are debounced per hovered tile, run concurrently, and reuse a briefly cached
window list so a cursor sweeping across tiles does not re-enumerate the system each time.
The window server keeps a layer-0 surface — last title, full-size frame, still capturable — long after the app has closed the window, and nothing it exposes separates one from a live window. Measured on Ghostty: seven surfaces for two open tabs, four of them the same closed tab at the identical frame, every one capturing 100% opaque pixels so the blank check above never rejected any of them. The strip filled with convincing screenshots of tabs that had closed hours earlier.
Only the app can say which of its windows are real, so Accessibility gets a veto over
ScreenCaptureKit's list: a window sitting in the Dock that the app does not claim is dropped. That
works — until the app stops answering. Ghostty's AXWindows returns success with zero elements
for long stretches while AXFocusedWindow on the same element resolves fine and the app-level
AXChildren is [AXMenuBar]. Measured over three minutes it was empty 30% of the time, in unbroken
runs of up to 27 seconds. An empty list disarms the veto — an empty answer is indistinguishable from
no answer, and Chromium and Electron really do give no answer — so the app that leaks the most
surfaces was also the one that switched off the only defence against them.
So the app is asked three ways, and what makes it work is that the second one answers when the first does not.
Its window list, over Accessibility. Exact where it works, and the only channel that yields window ids. It is also the one that goes quiet, and it under-reports for an app using native window tabbing — publishing one tab's window and not its siblings.
Its Window menu, which is the list AppKit maintains itself, one item per window, minimized
windows included. Measured with AXWindows at zero, it named both live Ghostty tabs and none of the
four dead surfaces sharing their frame. It is found by the action selector its items carry
(makeKeyAndOrderFront:) rather than by looking for a menu called "Window", which is localized and
would find nothing on a French system. It gives titles rather than ids, so the comparison drops
leading decoration first: a terminal's title carries a status glyph that changes several times a
second, and the two lists are read a moment apart — ◐ Features and improvements from the window
server against ◑ Features and improvements from the menu, the same window one frame later.
The two are unioned, never ranked. Each under-reports where the other does not, and a union can only keep more windows than either alone. Ghostty is the case that needs it: Accessibility named one tab, the menu named both, and the strip shows two.
The last answer that worked, for two minutes, when neither current channel says anything. Stale by definition, so it is bounded — two minutes is four times the longest gap measured — and it may veto, never supplement: the minimized windows the strip offers always come from the live read, so a stale list can remove a tile but can never invent one.
Two rules bound what any of that can cost. Nothing is removed unless the answer accounts for at least one window the strip can see — an answer correlating with nothing is not describing this app, and emptying the strip on the strength of it is how a working preview becomes a blank one. And nothing is ever removed that is on screen or on a Space: only the unverifiable set is at risk, so no mistake here can take away a window you are looking at.
Underneath all three, identical unverifiable surfaces collapse to the frontmost, which needs no cooperation from the app at all. Four tiles carrying one name and one picture are not four choices. It is scoped to the same unverifiable set, and for the same reason: two live windows may legitimately share a title and a frame — two empty terminals in one directory, two Finder windows of one folder.
Measured end to end on the case that prompted all of this: seven tiles before, two after, three runs in a row, with the other apps on the desk unchanged at one apiece — including Spotify, whose menu names no windows at all and which therefore keeps every tile it has.
| Setting | What it does | Default |
|---|---|---|
| Thumbnail tiles | Window mode only: draws a live capture of each window as its tile instead of the app icon, with the app's icon inset in the corner. Persists as windowThumbnailTiles. |
Off |
Window mode's tiles are app icons, which is fine for the first window of an app and useless for the fifth — five identical Chrome icons distinguished only by a truncated title is the case this exists for. The icon stays, inset in the corner: a thumbnail alone loses the app identity the icon carried for free.
Off by default, for the same reason the window preview is. Capture needs Screen Recording, and this app's permission story is that it needs Accessibility and nothing else; leaving this off keeps that true. Turning it on flips Screen Recording from optional to required for the mode you are using — a real cost, and the user's call.
It is a separate object from the window preview (TileThumbnails vs WindowCapture) because it is a
different problem. That one captures one app's windows on a deliberate pause, debounced
and cancelled on movement. This captures every tile the moment the panel opens, so the flow is
inverted: nothing waits. The panel draws icons immediately and each tile swaps to its capture
when that capture lands, in batches of four so a thirty-window list does not start thirty
ScreenCaptureKit round trips against the window server that is, at that moment, compositing the
panel that just appeared.
Captures are diffed against what has already been taken, so the fresh list that folds in a moment after the panel opens does not re-capture what is already on screen. They are dropped when the session ends — a thumbnail is a photograph of a moment, and showing the next session what the last one looked like is worse than showing it an icon. Minimized windows keep their icon: they have no live surface, so they capture blank. So do the Electron and Catalyst phantom backing windows, caught by the same downsampled-alpha check the window preview uses.
Per-app overrides are the settings that only became askable once the switcher grew a global apps/windows mode and a window tiler: always list this app window-by-window even in application mode, and never let a tiling shortcut touch this app (which the drag gesture honours too). Only apps carrying an override are stored, and a row whose overrides are all switched off deletes itself.
An override can also snap an app's first window to an arrangement as it opens, and — first — move it
to a chosen display. "Display 2, left half" means the left half of display 2, not whichever
display the app happened to launch on: the move runs before the arrangement, and if display 2 isn't
plugged in right now the move is skipped and the arrangement still applies wherever the window
actually opened. The display is numbered the same way the tile badges and the display1…4 tiling
chords are, and the picker offers as many rows as are plugged in right now — but keeps the choice
even once that display is unplugged, so a laptop's external monitor coming and going does not mean
re-entering the rule every time it is plugged back in. There is deliberately no equivalent for the
Desktop: see Known limitations for why that move is not automated.
Window title rules answer the same three questions per-window instead of per-app, matched by a
regular expression against the window's title rather than by which app owns it — a picture-in-picture
player, a screen-share overlay, or the one Zoom window mid-meeting, where every other window of the
same app should behave as usual. A rule can be scoped to one app or left at Any app for a title
that names itself the same way regardless of which app opened it. The match is case-insensitive and
anchoring is entirely up to the pattern — Zoom matches the word anywhere in the title, ^Zoom only
a title that starts with it. A rule and the per-app override above are never in tension: either one
hiding, expanding, or protecting a window is enough, and clearing one leaves the other exactly as it
was. An invalid pattern is kept rather than rejected — it saves as you type — and is treated as
matching nothing, with a red outline on the row until it compiles. Rules are stored under
titleRules, in UserDefaults and mirrored to config.json the same way every other setting is.
Two of the three actions have an honest limit. Always list individually widens the whole app into per-window tiles rather than singling out just the matching window — an app with a matching window shows all of its windows individually, not that one alone, because the switcher's per-app expansion has no finer-grained way to say "this one, not its siblings". And never tile matched by title only protects a window from the tiling chords, the titlebar drag, and the modifier-drag — it does not yet reach the window-swap chord or a desktop's restored layout, both of which still check only the per-app override.
Direct activation gives an app its own chord: pressing it jumps straight there, launching the app if it isn't running. For the handful of apps you reach for all day the switcher is pure overhead. Entries are added unbound — picking the app and picking the combination are two decisions, and guessing a free chord on your behalf is how you shadow something of yours.
One list of every running app, with two controls per row. They are opposite answers to the same question — should this app be in the switcher — so they share a row rather than two panes listing the same apps twice.
Favorite (the star) pins an app. In app mode a favourite that isn't running still appears as a launchable tile (dimmed, with an ↗ badge) — in its own slot with pinning on, at the end of the list without it; picking it launches the app instead of switching.
Exclude (the switch) keeps an app out of the switcher in either mode — excluding an app also removes all of its windows from window mode.
The two are mutually exclusive on a row: excluding an app masks its star, so a row can never claim an app is both pinned into the switcher and kept out of it. Masking, not deleting — the favourite stays on disk, and turning the switch back off brings the star back in the position it always held. The switcher agrees either way, since an excluded app is dropped from the launch tiles regardless. Starring an app is the answer that moves both settings: with the switch on the star is masked, so turning it on can only mean "and stop excluding this".
Nothing has to reconcile the two keys, then — settings written before they shared a pane, or imported from a file that sets each one on its own, can contradict each other and still display honestly, with no rewriting of anything the user did not ask to change.
Favorite order is the list of starred apps in the order they are used, which the alphabetical list below cannot show. Drag a row onto another and it takes that place — a whole row is a target you can hit, where the 2pt gap an insertion line asks for is not. Excluded favourites are left out of it, since their star is masked below and a row here would have the pane arguing with itself; they keep their stored place regardless, so a drag across one steps over it rather than displacing it. New favourites land at the end, which is what "add" has always meant here.
Pin favorites to the front (on by default) is what makes that order worth having for an app that
is always running. The favourites that are running take the first slots of the switcher in
application mode, in the user's order, and the sort decides only what follows them. So Finder is in
the same place every time bar the apps opened or quit since, and ⌘-Tab then 1 reaches it. With
nothing starred it changes nothing at all, which is why it can be on by default.
The favourites that are not running stay at the very end of the list, in the same order, behind every app that is actually open — a launch tile is a favourite that would have to start first, and no press that means "switch" should have to walk past one. Starring an app orders it among the open apps; it does not promote an app that isn't open over one that is. An app that is neither running nor installed contributes nothing rather than leaving a gap.
Turning the setting off drops the first block too: every app falls wherever the sort puts it, and the favourites are only the launcher tiles at the end.
Application mode only: a window list has as many tiles per app as the app has windows, so no app can hold a slot in it, and favourites that aren't running go back to being appended at the end there.
Pinning breaks one piece of arithmetic. ⌘-Tab's oldest habit — tap to go back, tap again to return —
works because the frontmost app is tile 0 and a tap therefore lands on tile 1. With favourites at
the front the previous app can be anywhere, so tapIndex looks it up in the MRU instead of counting
to one. The slots stay fixed and the tap still goes back to the app you just left. Switching the
setting off puts the tap back on tile 1, which by then is the same tile the lookup would have found.
Both settings are keyed by bundle identifier, not pid, so they survive the app quitting, relaunching
under a new pid, and Cmd-Tab restarting. They persist in UserDefaults under favoriteBundleIDs
(an array, in the user's order — that is the order the launch tiles appear in) and
excludedBundleIDs.
An app with either setting stays in the list once it quits, so the setting can always be undone; an app that has since been uninstalled shows its raw bundle id and a placeholder icon rather than vanishing. Add App… reaches an app that isn't running, since by definition it can't be in the list, and offers either setting: pre-excluding an app is only possible here, because a row appears after the app has run and the switcher has already shown it. An app with no bundle identifier can never be set either way — there is nothing stable to key it on.
Version and build, the two permissions with their live status, and a link to the repository.
This replaced the menu bar's About Cmd-Tab item and the standard AppKit about panel it opened. That panel can show a name, an icon and a version and nothing else — the permission state, which is the thing anyone actually opens About to check when the switcher has stopped responding, had no place in it. The menu is now just Settings… and Quit, plus whichever status line applies when something is wrong.
Both permission rows are re-read every time the tab appears rather than cached at launch, since the usual reason to be looking at them is that you have just granted something in System Settings.
Every global action has a URL, so Raycast, Alfred, Shortcuts, Keyboard Maestro, a Stream Deck key
and a line of open can reach the whole feature set without spending a hotkey:
open 'cmdtab://tile/leftHalf' # any WindowArrangement, by its stored name
open 'cmdtab://tile/focusRight' # including the families that ship unbound
open 'cmdtab://tile/nudgeUp'
open 'cmdtab://tile/growRight' # one edge, the other three pinned
open 'cmdtab://tile/almostMaximize'
open 'cmdtab://tile/restore' # and again to toggle back to the tile
open 'cmdtab://tile/display2' # by name, not by counting
open 'cmdtab://activate/com.apple.Safari'
open 'cmdtab://windows/hideAll' # or showAllChords are the scarce resource here — almost every row in the Windows tab apologises for taking a
combination away from whatever app is in front, three families ship unbound because there is no
arrow left to give them, and the Overview exists because it is genuinely hard to keep track of what
is claimed. This is the way out of that, and it cost one CFBundleURLTypes entry plus a parser,
because the work was already done: every action is an enum case with a stable raw value, and those
raw values are the grammar. The name for an action is discoverable by reading your own
config.json, and adding an arrangement adds its URL for free.
Two things are deliberately not reachable.
Nothing that ends a process or closes a window. No quit, no force-quit, no close. A URL scheme
can be invoked by any web page you visit, with no prompt and no visible trace, and the difference
between "a page rearranged my windows" and "a page closed my unsaved document" is the difference
between a curiosity and a bug report. Rearranging is recoverable and visible; the in-switcher
actions that are not stay behind the keyboard, where a person is present. URLCommandTests asserts
that boundary rather than leaving it to a comment.
The Desktop-move switch is still obeyed, where the tiling switch is not. That is not inconsistency: the tiling switch exists to stop this app claiming global chords from other applications, and a URL claims nothing from anybody — refusing to tile because a checkbox about hotkeys is off would be a setting doing something it never described. The Desktop switch is not about chords at all. It guards a gesture that seizes the pointer and flashes Mission Control for the better part of a second, and consent to that is not something a URL should route around.
integrations/ packages the same open cmdtab://... calls above as a Raycast extension and an
Alfred workflow, so tiling, activation and hide/show are reachable from a launcher's own search
rather than a hand-typed open. Neither integration hard-codes the arrangement list: both read
integrations/arrangements.json, so adding a WindowArrangement case — or a future verb like
restore or layout — is one edit there instead of one per launcher. See each subdirectory's
README for installing it.
Activating an app whose windows are all minimized leaves you looking at its menu bar and an empty desktop, so app mode restores one window on the way in. If anything of the app's is already on screen, its arrangement is left alone. Window mode does not need this — there the specific window is unminimized by name.
The restore runs off the main thread. focus() is called from inside the event tap callback, and
every Accessibility call is IPC that can block on a wedged app; the system kills a tap that
stalls. It also gets its own queue rather than TargetProvider's, which can be busy enumerating
every window on the system — a restore stuck behind a full refresh would land visibly late.
macOS reports a window's subrole as AXDialog while it is minimized. The same window flips
AXStandardWindow → AXDialog on minimize and back on restore (verified on macOS 26.5 with
TextEdit):
| State | AXRole |
AXSubrole |
AXMinimized |
|---|---|---|---|
| Up | AXWindow |
AXStandardWindow |
false |
| Minimized | AXWindow |
AXDialog |
true |
So filtering windows on AXSubrole == AXStandardWindow silently drops every minimized window —
silently because an empty list is indistinguishable from an app that has no windows. That filter
is why minimized windows never used to appear in window mode at all, which in turn made the
minimized badge in SwitcherView unreachable.
AX.isSwitchableWindow therefore accepts a window that is either standard or minimized.
AX.isWindow is the broader role-only check, used when deciding whether anything of an app's is
on screen: an app showing only a dialog still has something up, and restoring a minimized window
over it would be wrong. The role check is also what keeps Finder's desktop — an AXScrollArea
that turns up in AXWindows — out of the list.
Finder's own browser windows report AXDialog even while up, so the minimized escape hatch
above never reached them and Finder was missing from window mode entirely. The discriminator is the
minimize button: a window the user can send to the Dock is one they can switch back to, which
is the same question the filter is asking, while an alert or a modal has no such control. Measured
on macOS 26.5:
| Window | AXRole |
AXSubrole |
AXMinimized |
Minimize button |
|---|---|---|---|---|
| Finder browser, up | AXWindow |
AXDialog |
false |
yes |
| Finder browser, minimized | AXWindow |
AXDialog |
true |
yes |
| Finder desktop | AXScrollArea |
— | false |
no |
The escape hatch is scoped to AXDialog rather than "any non-standard subrole", so floating
palettes and system dialogs stay out however they are decorated. The decision itself lives in
WindowClassification.isSwitchable — a pure function over those four facts, covered by
WindowClassificationTests, because a subrole table that shifts on the next macOS is worth pinning
down rather than re-deriving by hand. The Accessibility reads are passed as autoclosures so a
standard window, which is the common case, is settled by its subrole alone and pays for no further
IPC.
The Dock owns ⌘-Tab at a level no public API can intercept, so taking it over is two moves:
- Disable the system switcher via
CGSSetSymbolicHotKeyEnabled(1, false)and(2, false)— symbolic hot key IDs for "move focus to next/previous application". This is a private SkyLight entry point, the same one AltTab and Command-Tab Plus rely on. - Claim the keystroke with a session-level
CGEventTap, which swallows ⌘-Tab before the focused app sees it.
The private symbol is resolved with dlsym, not linked. If a future macOS drops it, we lose the
takeover and log it rather than failing to launch. Verified present on macOS 26.5; note that its
sibling CGSGetSymbolicHotKeyEnabled is already gone on this OS, which is why the disabled
state is tracked in-process rather than queried back.
The state lives in the window server's memory and is never written to
com.apple.symbolichotkeys, so logging out always restores it, even after a crash. Short of
that: quit Cmd-Tab, or use Restore System ⌘-Tab in the menu bar. Quit, SIGTERM, SIGINT and
SIGHUP all restore it on the way out. SIGKILL and hard crashes cannot — log out.
build.sh signs with a self-signed certificate called Cmd-Tab Local. A certificate's common
name is bound into the certificate itself, so issuing one under a new name changes the designated
requirement and costs a single Accessibility re-grant.
The certificate is what keeps the Accessibility grant alive across rebuilds. macOS keys the permission to the app's designated requirement; signed with a certificate, that requirement is
identifier "com.cmdtab.CmdTab" and certificate leaf = H"<cert hash>"
— no code hash, so recompiling does not invalidate it. Ad-hoc signing has no certificate, so the
requirement falls back to the code hash and every build looks like a brand new app. build.sh
falls back to ad-hoc if the identity is missing, and says so.
To recreate the identity on another machine (or after deleting it):
openssl req -x509 -newkey rsa:2048 -sha256 -days 7300 -nodes \
-keyout key.pem -out cert.pem \
-subj "/CN=Cmd-Tab Local" \
-addext "basicConstraints=critical,CA:false" \
-addext "keyUsage=critical,digitalSignature" \
-addext "extendedKeyUsage=critical,codeSigning"
# -legacy and a non-empty password are both required; the security tool cannot read
# OpenSSL 3's default PKCS#12 encryption, and rejects an empty-password MAC.
openssl pkcs12 -export -legacy -inkey key.pem -in cert.pem \
-out cmdtab.p12 -name "Cmd-Tab Local" -passout pass:cmdtab
security import cmdtab.p12 -k ~/Library/Keychains/login.keychain-db \
-P cmdtab -T /usr/bin/codesign
# Trust is scoped to code signing only; without it codesign reports CSSMERR_TP_NOT_TRUSTED.
security add-trusted-cert -r trustRoot -p codeSign \
-k ~/Library/Keychains/login.keychain-db cert.pemChanging the certificate changes the requirement, so Accessibility has to be granted once more after that. Remove the identity in Keychain Access to undo it.
- Per-window ordering within an app is a best-effort MRU: it is updated when an app activates (which fires when you click into a window or switch to it), read from that app's focused window. Switching between windows of the same, already-active app by clicking doesn't fire an activation, so that case falls back to Accessibility z-order until the next activation catches up. Cross-app ordering is full MRU, tracked from activation notifications.
- Windows on other Spaces are listed, and switching to one follows macOS's normal Space-switch behavior. The window preview shows them too — only minimized windows can't be previewed, since they have no live surface to capture.
- Moving a window to another desktop (Space) works, but not through any API — it is the one
feature here that drives the interface rather than calling into it, so it is off by default
(
windowTilingDesktopMoves) and costs about 0.6s of the pointer per press, or 1.3s if it follows. Every private SkyLight route is a dead end on macOS 26:CGSMoveWindowsToManagedSpace,SLSMoveWindowsToManagedSpace, theRemoveWindowsFromSpaces/AddWindowsToSpacespair and theSLSSpaceSetCompatID/SLSSetWindowListWorkspacetrick are all accepted and then silently ignored for a window the calling process does not own. All of them resolve, so a caller cannot tell success from failure — and the control that settles what that means is that the identical call moves the calling process's own window first time. They are gated on ownership, not broken, and the tools that get around it inject into Dock and require SIP partially disabled. Three cheaper workarounds were measured dead too, andDesktopMover's header records them so nobody re-measures them: synthetic ⌃-arrow (the window server refuses synthetic input for its own symbolic hotkeys, checked with those hotkeys verified enabled), a drag paired withSpaceMover's private Space switch (the display switches, the window stays — the carry needs a real transition), and dragging to the screen edge and holding (macOS does no edge-switching for window drags). What is left is the gesture a person performs, which is whatDesktopMoverdoes. It no longer assumes the displays are laid out side by side, which it used to: Mission Control draws a Spaces Bar per display, and picking both the bar and the thumbnail by horizontal position paired a correct x with the wrong bar whenever two displays shared an x range — which is every vertically stacked desk. The fix turns on a distinction the old note had already half-found. A Desktop button reports a y outside the group it sits in, so containment genuinely cannot be used on one; a Spaces Bar is a group, and a group reports its own rectangle. So the bar is picked by containment, and the buttons are then scoped to the bar that was picked rather than both being guessed from x. Each selection falls back down a chain — bar, then display x, then everything — because a wrong bar is a move to the wrong Desktop where no bar is a window that does not move at all. Both are pure functions now, withDesktopMoverLayoutTestscovering side-by-side, stacked, offset and single-display desks. Moving to another display is a different thing entirely: ⌃⇧⌘-←/→, plain Accessibility geometry, instant, and on whether or not tiling is. - Moving focus by direction only reaches windows that are on screen. It is built on the window server's own list, which is what makes it one cheap call instead of Accessibility IPC to every app — and that list excludes minimized windows and windows on other Desktops. That is the right answer for the question being asked ("the window to the left" can only mean one you can see), but it does mean a directional chord will not travel to another Desktop the way picking a tile does.
- Restoring a layout across a display change has to have seen the desk before. There is no notification ahead of a display arriving or leaving — the system's own comes after it has already moved the windows — so the layout is sampled on a five-second timer while the setting is on, and a desk is only restorable once it has been sampled under that arrangement. The first plug or unplug after switching the setting on learns; the one after that restores.
- Focus follows the pointer raises the window it focuses. macOS treats the two as one action for another app's window, so the X11 "focus without raise" is not on offer here rather than being an omission. The rest delay is what keeps it from firing on windows the pointer merely crosses.
- There is no "keep this window on top". It is gated on window ownership, the same wall the
Desktop move runs into, and the measurements say so plainly:
SLSSetWindowLevelon another app's window returns success and changes nothing —SLSGetWindowLevelreads the level back unchanged — while the identical call on a window this process owns reads back the level it was given, which is the control that turns "our call is wrong" into "the call is refused".SLSOrderWindowdoes not even pretend: it returns 1000. Accessibility has no floating or level attribute to set, so there is nothing to ask for politely either. The tools that manage it inject into Dock and need SIP partially disabled, which is not a trade this app makes for one convenience. - Live window thumbnails are optional in both modes and off by default: the window preview in app mode, and Thumbnail tiles in window mode. Without either, tiles are app icons and Screen Recording is never touched.
- The settings window is the one place Cmd-Tab activates, so while it is frontmost we are the frontmost app and a ⌘-Tab lands one target further along than usual. Close it and ordering is normal again.
- Tab search does not reach VS Code or Xcode. VS Code's tab strip is not built from an
AXTabGroupwithin the search's depth bound, so it is never found; Xcode is slow enough answering Accessibility that walking it on every tabs-scoped session was not worth the wait. Both are the same outcome as any other app with no tabs: neither shows up in the list, and nothing indicates why.
| File | Role |
|---|---|
SystemSwitcher.swift |
The private SkyLight shim that disables the Dock's switcher |
SpaceMover.swift |
Private SkyLight shim for reading a window's Space and switching to it |
DesktopMover.swift |
Moves a window between desktops by performing the drag-into-Mission-Control gesture |
SyntheticEvent.swift |
Marks the mouse events this app posts, so its own gestures ignore them |
FavoritesStore.swift |
Pinned apps, in the user's order, shown as launchable tiles when not running |
EventTap.swift |
Session event tap; swallows keys, self-heals if the system disables it |
SwitcherController.swift |
State machine — decides what to swallow and when to commit |
TapState.swift |
Everything that decision needs, as one value the callback can read without reaching into the controller |
TapRouting.swift |
Which binding claims a keystroke when the switcher is closed, and whether it is swallowed — pure, and tested |
TriggerModifiers.swift |
The modifier arithmetic behind the trigger chord — pure, and tested |
SessionOpen.swift |
When a session begins — pure, and tested |
SessionRelease.swift |
When a session ends, and whether the panel closes or stays — pure, and tested |
SwitcherSettings.swift |
Every preference the switcher reads, as one value applied in a single pass |
RecencyList.swift |
The bounded MRU list behind both "Recently used" orders |
TargetProvider.swift |
Enumerates apps/windows, maintains MRU, caches off-thread |
SwitcherPanel.swift |
Non-activating overlay window |
PanelGroup.swift |
The set of panels — one, or one per display when mirroring — and the cursor poll they share |
SwitcherView.swift |
SwiftUI tile grid and list, and the Metrics both are laid out from |
SwitcherModel.swift |
Resolves the chosen title font, so the switcher and the Settings preview agree |
WindowPreview.swift |
Window-preview capture (ScreenCaptureKit), its floating panel, and the three ways it asks an app which of its windows are real — see Windows that are not there any more |
PreviewCoordinator.swift |
Owns the hover preview: the debounce before a capture starts, and the grace period before it goes |
TileThumbnails.swift |
Live window captures drawn as tile artwork in window mode |
SwitchTarget.swift |
An app or window, and how to raise it |
AX.swift |
Shared Accessibility helpers, with the messaging timeout baked in |
ExclusionStore.swift |
The set of excluded apps, persisted by bundle identifier |
AppearanceStore.swift |
The four appearance values, persisted |
BehaviorStore.swift |
Every tunable that is not an appearance slider, coalescing a batch of writes into one onChange |
Theme.swift |
A named bundle of visual settings — shareable precisely because it cannot change what a key does |
SettingsWindow.swift |
Settings window shell, the search index, and the General/Shortcuts/Behavior tabs |
SettingsChrome.swift |
The settings window's vocabulary: pages, section cards, rows, sidebar badges |
SettingsAppearance.swift |
The Appearance tab — layout, theme, panel and the metric sliders |
SettingsApps.swift |
The Apps tab — the app list with its favourite and exclude controls |
SettingsAbout.swift |
The About tab — version, permission status, source link |
SettingsWindows.swift |
The Windows tab — the tiling switches and their shortcut recorders |
SettingsRecorders.swift |
The one armed key recorder, whichever kind it is — two at once would swallow keys app-wide |
SettingsGlobalRecorder.swift |
Records one global chord, with the keyboard monitor owned by the store rather than the view |
SettingsIO.swift |
Export, import and reset over the owned keys, reloading the live stores so nothing needs a relaunch |
WindowTiling.swift |
Tiling geometry, the binding store, and the Accessibility frame writer |
WindowNavigation.swift |
Which window lies in a direction from another — pure, and tested — and the focus and swap chords built on it |
PowerState.swift |
Whether to poll sparingly, and by how much — see Two timers |
DisplayLayouts.swift |
The window layout under each set of displays, and putting it back when one returns |
FocusFollowsMouse.swift |
Focus the window the pointer comes to rest over |
URLCommands.swift |
The cmdtab:// grammar, and what is deliberately outside it |
DragSnap.swift |
Drag-to-edge snapping: gesture inference, zone geometry, preview overlay |
MouseWindowDrag.swift |
Modifier-drag to move or resize: mouse event tap, frame maths, recorded chords |
ShortcutAudit.swift |
Every binding in one list, and cross-store conflict detection |
FuzzyMatch.swift |
Subsequence matching with scoring, for type-to-filter |
InstalledApps.swift |
The installed-app catalogue behind launch-from-search |
AppRules.swift |
Per-app overrides (expand windows, never tile) |
LaunchArrangement.swift |
Snaps an app's first window to a chosen arrangement (and, first, a chosen display) as it opens |
TitleRules.swift |
Per-window rules matched by regex against a window's title, unioned with the per-app ones |
GlobalActions.swift |
Direct-activation and hide/show-all chords, and what they do |
SwitcherShortcuts.swift |
The window actions available while the switcher is open, and their extra-modifier bindings |
ScopedTriggers.swift |
Extra triggers that open a narrowed window list |
ConfigFile.swift |
The ~/.config mirror: file watching, write-back, live apply |
WindowClassification.swift |
Whether an Accessibility window belongs in the switcher — pure, and tested |
Updater.swift |
Sparkle, and the update preferences surfaced in Settings → About |
Migration.swift |
One-off rewrites of stored settings between releases; each deletable in time |
FrontProcess.swift |
Brings one window forward without dragging its siblings off the Desktops they live on |
DockBadges.swift |
Reads other apps' Dock badges out of the Dock's Accessibility tree |
Permissions.swift |
The Accessibility and Screen Recording grants, and how each is asked for |
LoginItemStore.swift |
Launch at login, backed by SMAppService so the system's list stays the truth |
MainLoopMonitor.swift |
Measures how long the main run loop stays busy — the number the tap's survival turns on |
Log.swift |
The loggers, and the verbose tap tracing that exists only in a live stream |
AppDelegate.swift |
Wires every store to the controller at launch, and owns the menu bar item |
main.swift |
Entry point, and the recovery path for a ⌘-Tab takeover that outlived its process |
The event tap callback runs on the main run loop, and the system kills a tap that stalls. Every
Accessibility call is IPC to another process and can block on a wedged app, so enumeration never
happens inside the callback: TargetProvider keeps a cache refreshed off-thread from workspace
notifications, the panel opens instantly from that cache, and a fresh list folds in a moment
later without moving the highlight. Per-app Accessibility messaging is capped at 250ms so one
hung app cannot hang the switcher.
The panel is a .nonactivatingPanel that never becomes key. If it activated, we would be the
frontmost app and the switch target would be wrong. It has no key handling at all — the event tap
is the only input path.
flagsChanged events are never swallowed. Other apps need to track modifier state, and it
guarantees releasing ⌘ always dismisses the panel even if the state machine gets confused.
The two paragraphs above used to be conventions. The package builds in Swift 6 language mode,
so they are now checked: SwitcherController and TargetProvider are @MainActor, and the work
that legitimately leaves the main thread has to say so.
That is worth more here than in most apps. This one runs four background queues doing Accessibility
IPC (TargetProvider, MouseWindowDrag, SwitchTarget.focusQueue, WindowTiling) alongside a tap
callback on the main run loop, and the cost of getting it wrong is not a corrupted value — it is a
stalled tap, which the system responds to by killing it and taking every keystroke on the machine
with it. Under Swift 5 a DispatchQueue.async added to any method could break the invariant
silently.
A clean build — debug and release — is warning-free. What survives the migration is a small set of
@unchecked Sendable / nonisolated(unsafe) annotations, each a place where the compiler cannot
see a guarantee that genuinely holds. Each carries its reasoning at the declaration:
| What | Why it cannot be checked | Why it is safe |
|---|---|---|
SwitchTarget and its Kind |
NSImage and AXUIElement are not Sendable |
Built once on axQueue, never mutated after handoff. The icons are only drawn; AXUIElement is a thread-safe opaque handle this app messages off-thread by design. |
WindowTiler.Target |
.element carries an AXUIElement |
Crossing onto the tiler's queue is the point — resolve runs the Accessibility walk there rather than on the main thread. |
WindowPreview.Entry |
SCWindow is not Sendable |
A read-only descriptor from SCShareableContent, handed to a task group precisely so captures run concurrently. |
EventTap's event and handler |
CGEvent is not Sendable; assumeIsolated takes a sending closure |
The run-loop source is on CFRunLoopGetMain, so the callback is the main thread. The event arrives as a parameter and leaves as the return value, with no other reference to it. |
Permissions.waitForTrust's timer |
Timer is not Sendable and its callback is nonisolated |
One reference, written once before the timer can fire and read only from the run loop it is scheduled on. |
SystemSwitcher.isNativeDisabled, SwitchTarget.focusGeneration, the two dlopen handles |
Nonisolated global mutable state | The dlopen handles are lets initialised once by the runtime's thread-safe lazy-static machinery. focusGeneration is touched only from the serial focusQueue. isNativeDisabled is a Bool, which cannot tear, and a redundant restore is a no-op. |
Almost everything here is driven by a notification. Two things are not, because there is no
notification in front of them: Accessibility being revoked (AppDelegate.watchForRevokedTrust),
and the window layout worth putting back after a display change (DisplayLayouts, whose reason
is spelt out under Restoring a layout). Both poll at
five seconds. The trust watch is the more expensive of the two by a distance, because it is the one
timer that runs for the whole life of a healthy process — including on a laptop sitting in a bag.
Neither is watching for something that happens quickly. Nobody revokes Accessibility in a hurry, and
a layout worth remembering is one that has been sat in for a while. So on battery — or in Low Power
Mode on any power source, which is the user asking every process to do less — both stretch to thirty
seconds (PowerState), and both carry a tolerance so the run loop can batch the wakeup with whatever
else it has to do rather than waking the CPU on its own schedule. The only thing that costs is how
stale an answer may be at the moment it matters: half a minute rather than five seconds, on a
question whose answer changes a few times a year.
The state is re-read on the tick and the timer re-armed if it changed, rather than subscribing to a power notification — one comparison against a cached flag, on a timer that has just woken the process anyway, against a run-loop source and its teardown for a transition that happens a few times a day. Deliberately not a setting: how often a background timer should fire is not a question anyone can answer about software they did not write.
nonisolated static on every helper in TargetProvider is not an escape hatch but the opposite:
without it, @MainActor on the class would drag the Accessibility walk onto the thread the whole
design exists to keep it off. MainActor.assumeIsolated appears at each point where a
TargetProvider callback re-enters the actor — always assumeIsolated rather than a Task, since
the provider has already hopped back to the main thread and a Task would defer the work to a later
turn, letting a session end in between.
The Settings window is fully labelled for VoiceOver. That work is concentrated in SettingsChrome
rather than spread across the seven tabs: every row there is a label on the left and a control on
the right, and every control is built with labelsHidden() so the checkboxes line up down the
card's edge — which reads correctly and announces as nothing, because a hidden label is hidden from
VoiceOver too. SettingsRow states that association once, so it cannot be forgotten by the next row
someone adds. Sliders announce their formatted value rather than a percentage (several of them are
pixel sizes), and the sidebar tabs and the card-shaped radio buttons carry .isSelected, which is
otherwise conveyed only by a background tint or a filled-in circle.
The switcher panel is a harder case. It is a .nonactivatingPanel that never becomes key and has no
key handling of its own — the event tap is the only input path, which is what makes the switcher work
at all — so driving it the way an assistive technology drives an ordinary window is not something it
can offer. The first half of the answer was to stop the tiles being anonymous: each announces its
title, app name, minimized/hidden/not-running state, notification count, display and Desktop, and the
digit that jumps to it, all of which was previously carried by a dimmed icon, a badge glyph and a
small corner number and none of which was in any text.
That half was necessary and, on its own, useless. Labels on a panel nothing can focus are labels
nobody ever hears. VoiceOver reads a tile when it is explored, and there is nothing here to explore
with — so the actual experience of holding ⌘ and cycling was a switcher that said nothing at all, in
a session where the user cannot see which tile is highlighted and the keys are being swallowed
machine-wide. The panel now announces the highlighted tile as the selection moves, using the same
sentence the tile carries (TileDescription.text, one function so the two cannot drift). Announced
rather than focused, because taking focus is the one thing this panel must never do; posted at high
priority so a fast cycle is not coalesced away, since the last press is the one whose answer matters.
It costs nothing when VoiceOver is off, which is nearly always: one cached Bool read before any
string is built. Suppressed when the highlight has not actually moved — a background refresh can fold
a new app into the list and shift every index below it — so it is keyed on the target rather than the
index, and a ⌘-Tab that lands somewhere new always speaks, including the first tile of a session.
Every string in the settings window is localizable, and the app ships in English and French.
The French translation is the first thing that has ever exercised any of this. A catalogue with one language in it is a catalogue where every check is a check that a string equals itself: the lookup path, the compile step, the bundle layout and the fallback behaviour were all written and none of them had ever resolved a key to anything but the English it started as. Adding a second language is what turns the infrastructure from plausible into tested, and it found two real faults on the way in.
The catalogue was missing a third of the interface. 88 strings were absent, and the pattern
behind them is worth naming: the entire Windows tab is built by mapping over WindowArrangement, so
its row titles never appear as literals at a call site the way every other title does — "Left half"
and "Maximize" were untranslatable while the sentence explaining them three lines below was fine. In
English that is invisible, because a missing key renders as the key and the keys are the English
text. StringCatalogTests now asserts every enum-derived row title against the live enums, so adding
an arrangement or an in-switcher action fails the suite until its name is translatable, and asserts
that every entry is translated into every language the catalogue claims — with a short list of the
words that are genuinely identical in both (Diagnostics, Position, Session, Version) rather
than a rule that would wave through exactly the strings most likely to have been forgotten.
CI checked the wrong half. It verified that en.lproj reached the bundle, which proves nothing:
English renders correctly when the catalogue never reached the bundle at all. It now checks
fr.lproj too, which is the only version of that check that can fail.
Adding a third language is filling in a column of
Sources/CmdTab/Resources/Localizable.xcstrings — the tests say what "complete" means, and
build.sh compiles every locale it finds.
The obvious way to localize SwiftUI is to type the view parameters LocalizedStringKey, which makes
string literals at the call sites localizable for free. It does not work for this codebase. The
long explanations under each settings row are assembled with + across several source lines to stay
inside the line limit, and a concatenation is not a literal — so nine out of ten subtitles here would
silently stop being localizable the moment they were wrapped. That failure is invisible: a missing
key renders as the key, and the keys are the English text.
So the lookup happens at the point of display instead, in SettingsChrome.text(_:), which every
title, subtitle, footer and section heading passes through. One place, four hundred call sites, and
it handles literals and concatenations alike. Its sibling SettingsChrome.verbatim(_:) is for text
that is data rather than interface — the per-app rows are titled with the names of the user's
applications, and looking those up would mean an app called "General" could come back as the
sidebar's General tab.
Two things about the build are worth knowing, because both fail silently:
- SwiftPM does not compile String Catalogs. Declared as a package resource, the
.xcstringsis copied verbatim into aCmdTab_CmdTab.bundlewhere nothing can read it —LocalizedStringKeyresolves againstBundle.main, and an uncompiled catalogue is not a strings table in any case.build.shrunsxcrun xcstringstool compileand writes the resulting<locale>.lprojdirectories straight intoContents/Resources. - Every failure mode looks like success. A missing catalogue, an empty one, or one that never
reached the bundle all render exactly the English text that was passed in.
StringCatalogTestsguards the file's shape and CI checks thaten.lproj/Localizable.stringsactually landed in the bundle, because nothing else would notice until a translation existed.
The two paragraphs above are the reasons this code is shaped the way it is, and until recently
nothing anywhere checked that either one held. A regression in them fails no test — it shows up as
the system killing the tap and the user losing every keystroke on the machine. Signpost (in
Log.swift) instruments them:
| Interval | Category | What it answers |
|---|---|---|
handle |
tap |
How long the tap callback took. The one that must stay short. |
refresh |
targets |
How long a full enumeration took, and how many targets it produced. |
windows |
targets |
One app's AXWindows read, tagged with its pid — where the 250ms cap bites. |
xcrun xctrace record --template 'os_signpost' --attach Cmd-Tab --output trace.traceA refresh interval overlapping a handle interval is the design working; one nested inside it
is the bug the off-thread cache exists to prevent. A windows interval sitting at 250ms names the
app that is hanging, which is the difference between "the machine is busy" and "Slack is wedged".
Signposts cost close to nothing unattached — OSSignposter checks whether anyone is listening
before formatting — which is what makes it acceptable to leave one in a per-keystroke callback.
The bundle identifier is com.cmdtab.CmdTab and the bundle is Cmd-Tab.app. The Swift target is
CmdTab because a module name cannot contain a hyphen; only the bundle carries the hyphenated name.
