Skip to content

Route key-chord shortcuts through Carbon hot keys - #39

Merged
andyhtran merged 1 commit into
mainfrom
ft-carbon
Jul 26, 2026
Merged

Route key-chord shortcuts through Carbon hot keys#39
andyhtran merged 1 commit into
mainfrom
ft-carbon

Conversation

@andyhtran

Copy link
Copy Markdown
Owner

Summary

The app sat in the system keystroke-delivery path: one filter tap on keyDown/keyUp/flagsChanged, serviced on the main run loop, saw every keystroke in the session and could stall input everywhere if the main thread was busy.

Key chords now register with RegisterEventHotKey. The window server matches them and delivers them as ordinary app events, so no keystroke waits on this process — and no Accessibility grant is needed, so the default shortcuts work on first launch.

An event tap remains only for bare-modifier shortcuts (Fn/Globe push-to-talk), which Carbon cannot express. It is created only when such a shortcut is bound, its mask is exactly .flagsChanged, and it runs on a dedicated thread rather than the main run loop. A second listen-only tap watches for ordinary keys, and only while the modifier is held, to tell Fn-as-a-shortcut from Fn+arrow.

A Carbon registration swallows its chord unconditionally, so gating a shortcut now means registering and unregistering it rather than filtering at fire time. Settings changes and recording-state edges re-derive the registration set.

Breaking change

Fn+key chords can no longer be bound. The hot-key API has no Fn modifier, and registering the chord without it would swallow the bare key system-wide — binding Fn+W would stop W reaching any app.

  • Stored Fn chords are flagged with a warning marker in the menu bar and settings rows, with a tooltip prompting a re-record.
  • Arrow keys and F-keys saved by older builds are migrated, not flagged: macOS reports the Fn flag for the whole function-key group whether or not Fn is held, so those were never real Fn chords.
  • Bare Fn (Globe) as a push-to-talk shortcut is unaffected.

Worth a release-notes line for existing users. Suggests a minor version bump rather than a patch.

Notes for review

  • ShortcutBackend decides which mechanism serves a shortcut; HotKeyBindingPlan resolves collisions deterministically, since the OS awards a contested chord by arrival order.
  • Press/release bookkeeping is shared by both backends (HotKeyPressTracker), so a registration that disappears under a held key gets its release completed by hand instead of leaving a hold-style action running.
  • The starvation watchdog moved to ModifierTapMonitor and its policy is now unit-testable (TapStarvationPolicy). KeyDownObserver deliberately has none — it is listen-only and disabled outside modifier holds.
  • The shortcut recorder's tap is unchanged in purpose but is now main-actor confined, with the raw CGEvent copied out before any hop.

Testing

  • Clean-scratch swift build — no warnings.
  • swift test — 146 tests across 29 suites pass, including new coverage for backend classification, binding-plan precedence, press tracking, Fn press/release swallow symmetry, and migration of stored Fn flags on function-group keys.

🤖 Generated with Claude Code

The app previously sat in the system keystroke-delivery path: one filter
tap on keyDown/keyUp/flagsChanged, serviced on the main run loop, saw
every keystroke in the session and could stall input everywhere if the
main thread was busy.

Key chords now register with RegisterEventHotKey instead. The window
server matches them and delivers them as ordinary app events, so no
keystroke waits on this process, and no Accessibility grant is needed —
the default shortcuts work on first launch.

An event tap remains only for bare-modifier shortcuts (Fn/Globe
push-to-talk), which Carbon cannot express. It is created only when such
a shortcut is bound, its mask is exactly .flagsChanged, and it runs on a
dedicated thread. A second listen-only tap watches for ordinary keys, and
only while the modifier is held, to tell Fn-as-a-shortcut from Fn+arrow.

Because a Carbon registration swallows its chord unconditionally, gating
a shortcut now means registering and unregistering it rather than
filtering at fire time, so settings and recording-state changes re-derive
the registration set.

Breaking change: Fn+key chords can no longer be bound. The hot-key API
has no Fn modifier, and registering the chord without it would swallow
the bare key system-wide. Stored Fn chords are flagged in the UI for
re-recording; arrow and F-keys saved by older builds are migrated, since
those report the Fn flag intrinsically and were never real Fn chords.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@andyhtran
andyhtran merged commit c2c4333 into main Jul 26, 2026
1 check passed
@andyhtran
andyhtran deleted the ft-carbon branch July 26, 2026 03:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant