Files
Abue Ammar 22f7ea8e98 Let a snippet take its own global shortcut (#1229)
Each snippet's Settings row records a shortcut that runs the same expansion
funnel as its launcher row. Typed text now clears held modifiers, so the
shortcut's own keys no longer ride along on every character.
2026-09-30 02:52:34 +06:00

18 KiB

Hotkeys (in-house, zero dependencies)

Features/HotKeys/ holds:

  • KeyShortcut — Sendable model, Carbon keycode + modifiers, layout-aware glyphs via UCKeyTranslate.
  • HotKeyBinding — what an action is bound to: .combo(KeyShortcut), .doubleTap(DoubleTapModifier), .globe, or .doubleGlobe.
  • HotKeyCenter — the Carbon RegisterEventHotKey layer, pausable.
  • DoubleTapModifier / DoubleTapDetector — the double-tap recognizer.
  • GlobeTapDetector / ModifierTapMonitor — Globe recognition and the shared modifier-only tap.

HotKeyManager owns them all: persistence, conflict lookup, and dispatch. Every action reads and writes one HotKeyBinding, so the four cases share persistence, conflict detection, the recorder and the keycap rendering — only the engine differs.

Invariants

  • Hotkeys persist as JSON strings under hotkey.<action> UserDefaults keys, and HotKeyAction.defaultsKey is the one place that computes a key — it is also the HotKeyCenter registration id, so the two cannot drift.
  • A command's shortcut and its launcher row run the same funnel. HotKeyAction.command(CommandID) is parameterised over the whole catalog and dispatches through LauncherCoordinator.runCommand, so a new built-in command arrives bindable with no hotkey plumbing of its own, and there is one behaviour per command rather than one per invocation route.
  • A command that opens a palette mode toggles it. Every one of them enters through PaletteCoordinator.togglePalette(mode:), so a second press closes what the first opened. From a launcher row the palette is in .launcher, so the row always re-points instead.
  • HotKeyBinding is the one thing an action is bound to, with four cases and two engines. A .combo is a Carbon registration; .doubleTap, .globe and .doubleGlobe are recognized by ModifierTapMonitor, because Carbon cannot see a lone modifier at all. Its Codable is the synthesised one.
  • KeyShortcut's hand-written init(from:) is a correctness seam, not a format one: it routes every decode through the initializer that masks device modifier bits off.
  • The modifier-only detectors stay Foundation-only and pure for hotkey-test, with the clock injected as a parameter. Every CGEvent call lives in Service/ModifierTapMonitor.swift, which is listen-only, installs only while a modifier-only shortcut is bound, and never prompts for Accessibility.
  • KeyShortcut.hyperChord(includesShift:) is the only spelling of the Hyper chord, read by both the ✦ collapse and the re-point below. HyperKeyTap composes its own flags because it also needs the left-side device bits, which no display path wants.

Persistence

Bindings persist as JSON strings under hotkey.<action> UserDefaults keys, computed in one place — HotKeyAction.defaultsKey, which doubles as the HotKeyCenter registration id. The set of bound bundle IDs lives in boundAppBundleIDs and is re-registered on launch. After every AppIndex scan, HotKeyManager.removeAppBindings clears the binding of an app that is gone from both the index and LaunchServices: its Settings row went with it, so nothing else could clear the chord. Requiring both keeps a dropped search scope from deleting a working shortcut, and running on unchanged scans too covers LaunchServices still resolving an app for a few seconds after it is trashed.

Window management's shortcuts are also spelled as typeable chords (ctrl+option+left) in the opt-in settings file. HotKeySpelling is that grammar; the file applies through setBinding, so UserDefaults stays the one store either way.

System Settings panes use boundPaneBundleIDs; custom commands, quicklinks, window layouts, rooms and custom window sizes use their stable UUIDs in boundCustomCommandIDs, boundQuicklinkIDs, boundWindowLayoutIDs, boundWindowRoomIDs and boundCustomWindowSizeIDs. Those five are the per-item case — unlike a fixed catalog, there is no allCases to walk — so each needs an index for start() to re-register from and to prune bindings whose record was deleted while Tinycast wasn't running. That prune is why QuicklinkStore loads at launch even when the feature is off (see quicklinks.md). Apple Shortcuts keep the same kind of index in boundAppleShortcutIDs, pruned not at launch but after the first successful read of the library, since a failed read looks exactly like deletion (see apple-shortcuts.md).

Snippets index StoredSnippet.ID, the file's path, in boundSnippetIDs. The store runs only while the feature is on, so they are swept not at launch but on every snapshot, by removeSnippetBindings; a file that fails to parse still counts, since it is mid-edit rather than gone. A rename outside Tinycast or a new Snippets Folder therefore drops the shortcut, and none travels in a backup, where an imported snippet lands at a new path (see snippets.md).

HotKeyBinding takes the synthesised Codable, so a .combo writes {"combo":{"_0":{"carbonKeyCode":N,"carbonModifiers":N}}} and a .doubleTap writes {"doubleTap":{"_0":"command"}}. KeyShortcut keeps a hand-written init(from:) — not a format seam, but the guarantee that every decode runs through the initializer that masks device modifier bits off. SettingsBackup.HotkeyBackup stores the same values, so the backup file carries this shape too; only export → import within one build is guaranteed to round-trip.

Every built-in command is bindable: CommandID.hotKeyAction answers .command(self) by default, and names the three exceptions. Open in Browser and Run Shell Command are query-driven — their input is the typed text a chord has none of — and Quit is withheld so no chord can terminate the app outright. The list is a deny-list rather than an allow-list, so a new command still arrives bindable without an edit there. A binding therefore persists under hotkey.<command raw value>, as in hotkey.command:clipboard-history, which is also what puts a recorder on the command's row and a keycap on every launcher row. That row is in exactly one pane — Settings ▸ Commands, or the feature's own pane when SettingsTab.ownedCommands names it. hotkey.togglePalette is the one fixed action with no command row. HotKeyManager names them all through CommandID, so a conflict callout spells an action exactly as its command row does.

Like a window command, the chord registers regardless of the launcher row. Search Files and Notes both re-check their feature switches before opening; see file-search.md and notes.md. A hidden launcher row does not disable its shortcut, but disabling the feature does. SettingsBackup.HotkeyBackup carries them as one commands map keyed by CommandID raw value.

System actions and window commands are the fixed-catalog case: they persist under hotkey.systemAction.<raw-id> and hotkey.windowCommand.<raw-id> and need no bound-ID index, because start() and conflictOwner can just iterate allCases and register no-ops on an unbound item. A registered window-command shortcut still runs nothing while the feature switch is off — WindowCommandCoordinator.runWindowCommand re-checks it (see window-management.md); a system-action shortcut likewise goes through SystemActionCoordinator.runSystemAction(id:), so the confirmation gate holds for a hotkey exactly as it does for the palette.

Modifier-only shortcuts

Globe/fn can be bound once (.globe) or twice (.doubleGlobe). The recorder waits briefly after the first release so another press can select the double binding; otherwise it saves the single one. Globally, a single Globe fires on release when no double Globe action is bound. When both are bound, the single action waits until the double-tap window expires. Another modifier, key, or mouse click cancels the gesture, even while a single tap awaits the second. Both the monitor and the recorder check the physical kVK_Function keycode, not just the fn flag, because F-keys also carry that flag. It shares the double-tap's listen-only monitor, permission warning, lifecycle and pause while recording. macOS may perform its own Globe action too; set “Press fn/Globe key to” to “Do Nothing” in Keyboard settings if it conflicts. Globe+key chords use Carbon registration, like other combos.

Double-tap modifiers

Any action can instead be bound to a double-tapped lone modifier — ⌃, ⌥, ⇧ or ⌘. Carbon cannot register a modifier-only shortcut at all, so this is a separate engine that meets the combo path only at HotKeyBinding.

DoubleTapDetector is the recognizer: Foundation-only, pure, and clock-injected (now is a caller- supplied monotonic timestamp), so Tests/hotkey-test.swift drives it without an event tap. A tap is a press that starts from no modifiers held, keeps exactly one of the four held with no fn alongside, sees no key press or mouse click, and is released within maxHold (250 ms — the same window HyperKeyTap calls a quick press). A double-tap is a second tap of the same modifier starting within maxGap (300 ms) of the first one's release.

Only momentary keys may feed hasOtherModifiers. Caps Lock must not: maskAlphaShift tracks the latch, not a press, so testing it would disqualify every tap for as long as Caps Lock is on and silently kill the feature. Caps Lock is still ineligible as a binding — that is what the Hyper Key is for.

It fires on the second release, not the second press. The modifier is then already up when the action runs, so the palette never opens with a phantom ⌘ held and focus restoration isn't polluted — and "double-tap and hold" is a deliberate non-event.

ModifierTapMonitor is the one platform file. It is a listen-only CGEventTap and it installs only while a modifier-only shortcut is bound, so users who never use the feature pay nothing. Two details are load-bearing:

  • It is .tailAppendEventTap, unlike the two head-inserted taps, so it observes events after HyperKeyTap's rewrite. A Hyper-remapped right-side modifier therefore arrives as the full ⌃⌥⇧⌘ chord and correctly reads as "not a lone modifier" — the left-side twin still double-taps.
  • Like every keyboard tap it needs the Accessibility grant, and it never prompts for it. The binding records regardless; the recorder shows an inline warning that opens System Settings, and the one-second health timer installs the tap the moment the grant lands.

⇧ is bindable this way even though KeyShortcut rejects a bare ⇧ combo: a double-tap is unambiguous where a bare ⇧ combo would shadow typing.

The Hyper Key

HyperKeyTap turns one physical key — Caps Lock or a right-side modifier — into the ⌃⌥(⇧)⌘ chord system-wide. It is a modifying CGEventTap, a separate layer from HotKeyCenter because Carbon cannot intercept a lone key at all. The rewritten flags flow onward into Carbon matching, so existing combo hotkeys fire from Hyper+key with no extra registration.

Which key is chosen persists as a HyperKey string raw value in AppSettings — renaming a case is a migration, and a removed case decodes to .none. F-keys are deliberately not candidates: the top-row media functions fire below the tap, so binding F1 as Hyper still dimmed the display.

Caps Lock has to stop being Caps Lock

The caps-lock toggle — both the LED and the latch — happens below every CGEventTap, so no tap can suppress it. The key must therefore stop being Caps Lock at the source: while Caps Lock serves as Hyper, CapsLockRemap installs an IOKit UserKeyMapping remapping it to F18, the same mechanism hidutil uses. The tap then intercepts F18 in its place. The remap is cleared on unbind and on quit, and never survives a reboot. Remaps apply on a serial queue, so rapid on→off→on toggles land in call order instead of racing as independent detached tasks and leaving the wrong final state.

Because the remap is asynchronous, there is a fallback for the window before it takes hold: the key still arrives as Caps Lock, so the tap rides the modifier path instead. The LED toggles during that window — that is un-remapped HID behaviour, not something Tinycast can stop.

Once remapped, Caps Lock arrives as keyDown/keyUp rather than flagsChanged. Both ends are converted into Left Control flagsChanged transitions, so everything downstream sees the Hyper chord move with the key rather than a swallowed press. That conversion is also why the fn bit is scrubbed from both ends: every function key reports NX_SECONDARYFNMASK, harmless on a keyDown but read as a real fn press once the event is a flagsChanged, which fired anything bound to fn on every Hyper press. Only the Hyper key's own two events are scrubbed, so Hyper+F-key and Hyper+arrow keep the fn bit they are entitled to. A classic IOHIDSystem connection reads and drives the Caps Lock LED and lock state; it is used only by the explicit Quick Press toggle and the one-time unlatch when the remap is installed.

Press tracking uses toggle semantics

flagsChanged does not describe its own direction, so a modifier-style Hyper key is tracked by toggling. The obvious alternative — querying CGEventSource key state — races the release, inverting the state machine and breaking Quick Press. A missed release therefore lingers only until the watchdog or the next press clears it. Work that posts events or touches IOKit is deferred to the next runloop turn rather than run inside the tap callback, where it would risk re-entrancy.

The flags OR'd into every rewritten event are the generic ⌃⌥(⇧)⌘ masks plus the left-side device bits (NX_DEVICE…KEYMASK, from IOLLEvent.h). Some consumers distinguish sides, and generic-only flags do not always read as fully pressed. The Hyper key's own residue is scrubbed in the same pass: Caps Lock's alpha-shift bit, or — for a key modifier outside the Hyper set — its generic mask and both device bits. Events the tap posts carry a "TYCT" marker in .eventSourceUserData, the same FourCC HotKeyCenter uses, so the tap never reacts to its own synthetics.

A Quick Press key is posted with flags cleared explicitly, like every other synthetic in the app. A keyboard event built from .combinedSessionState inherits the source's modifiers, and the release that ended the hold is still in flight a runloop turn later — so the Escape went out as ⌃⌥⇧⌘Escape. Terminals read the raw 0x1B and did not care; a focused field editor and any exact-match keymap swallowed it, which is why Quick Press worked in Ghostty but never in Zed or the palette itself.

✦ is the notation, not a preference

Any combo whose modifiers are a superset of the chord renders with the Hyper set replaced by a single ✦ keycap — ✦G, or ✦⇧G when a modifier survives the subtraction. It is unconditional: there is no toggle, because collapsing the chord is what the feature is. The one condition is a Hyper key being configured at all — AppCore hands KeyShortcut.displayedHyperChord a nil chord otherwise, and a literal ⌃⌥⌘G renders as itself. That hook is a closure rather than a value so reading it inside a view body registers the AppSettings dependency, and every keycap re-renders the moment a toggle moves.

Include Shift re-points what is already recorded

A KeyShortcut stores absolute Carbon modifiers, captured from the already-rewritten flags, so a chord recorded under one Include Shift setting is stale under the other: it would stop collapsing to ✦ and stop firing, because Carbon is registered for ⌃⌥⌘ while the tap has started emitting ⌃⌥⇧⌘. So flipping the toggle re-points every stored combo — HotKeyManager.retargetHyperBindings, driven by the same AppCore.track observation the feature switches use, swapping the stale chord for the current one through setBinding so persistence and re-registration stay on one path. A re-point that would land on a chord another action already holds is skipped rather than clobbering it; that row keeps its literal keycaps. retargetingHyper is idempotent, which is what makes a settings import a no-op rather than a corruption. Nothing is re-pointed while the Hyper key is .none — and the Settings row is disabled there, so the toggle cannot move without a chord to mean.

Lifecycle

Like every keyboard tap it needs the Accessibility grant and never prompts for it. A one-second watchdog runs while a key is configured: it retries installation until the grant lands, notices revocation, revives a tap the system disabled on timeout or user input, and clears a stuck hold. On fast user switching another session owns the keyboard, so half-held state is dropped and rewriting stops until this session is active again. The HID remap outlives the process, so applicationWillTerminate hands the key back to the system before exiting.

Recorder

The settings recorder (Features/HotKeys/UI/ShortcutRecorder.swift) is deliberately not a focusable control: the active recorder is HotKeyManager.recordingAction state, and keys are captured by local NSEvent monitors while both engines are paused. It records combos, double-tapped modifiers and single or double Globe taps by feeding its .flagsChanged / .keyDown monitors into the same pure detectors as the global monitor, so recording needs no event tap and no permission.

Setting recordingAction is what starts and stops the capture, so there is exactly one ShortcutCaptureSession (HotKeys/Service/) for the app rather than one per row — which is what lets the callout above the field render the live state from outside the row that opened it. The field itself only ever shows the binding; the prompt, the live preview and the conflict message all live in the callout. See ui.md.