# Hotkey bindings

> HotkeyBinding variants: Carbon RegisterEventHotKey combos, Fn/Globe via CGEventTap with suppression, and bare modifier keys. DictationMode hold vs toggle semantics, isValidShortcut reserved-system checks, and HotkeyManager dedicated tap threads.

- Repository: cartesia-ai/InkIt
- GitHub: https://github.com/cartesia-ai/InkIt
- Human docs: https://www.grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b
- Complete Markdown: https://www.grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b/llms-full.txt

## Source Files

- `InkIt/SettingsStore.swift`
- `InkIt/HotkeyManager.swift`
- `InkIt/AppCoordinator.swift`
- `InkIt/SettingsView.swift`

---

---
title: "Hotkey bindings"
description: "HotkeyBinding variants: Carbon RegisterEventHotKey combos, Fn/Globe via CGEventTap with suppression, and bare modifier keys. DictationMode hold vs toggle semantics, isValidShortcut reserved-system checks, and HotkeyManager dedicated tap threads."
---

InkIt routes global dictation through `HotkeyBinding` (three registration backends in `HotkeyManager`), `DictationMode` (hold vs toggle gesture semantics in `AppCoordinator`), and persisted `SettingsStore` keys (`hotkeyKind`, `hotkeyKeyCode`, `hotkeyModifiers`, `dictationMode`). The default binding is Fn/Globe (`.fn`), claimed via a suppressing `CGEventTap` when Accessibility is granted.

## HotkeyBinding variants

`HotkeyBinding` is an `Equatable` enum with three cases. Each case maps to a distinct registration path in `HotkeyManager.register(binding:)`.

| Variant | Case | Registration backend | Suppression |
|---------|------|---------------------|-------------|
| Key combo | `.carbon(keyCode:modifiers:)` | Carbon `RegisterEventHotKey` | N/A (Carbon handles delivery) |
| Fn / Globe | `.fn` | `CGEventTap` at `cghidEventTap`, or passive `NSEvent` monitor | Active tap returns `nil` on Fn transitions |
| Bare modifier | `.modifierKey(keyCode:)` | Listen-only `CGEventTap`, or passive `NSEvent` monitor | Never suppressed (modifier must work in combos) |

```swift
enum HotkeyBinding: Equatable {
    case carbon(keyCode: UInt32, modifiers: UInt32)
    case fn
    case modifierKey(keyCode: UInt32)
}
```

### Carbon combos

Carbon bindings use `RegisterEventHotKey` with a fixed `EventHotKeyID` (signature `"INKS"`, id `1`). Press and release arrive as `kEventHotKeyPressed` / `kEventHotKeyReleased` without key repeat, even when another app is frontmost. An `InstallEventHandler` on `kEventClassKeyboard` dispatches `onPress` / `onRelease` to the main queue.

Carbon cannot represent Fn as a modifier, and cannot bind a lone modifier key — those cases use event taps instead.

### Fn / Globe

The Fn key (🌐 Globe on Apple keyboards) is not a standard Carbon modifier. `HotkeyManager` installs a `CGEventTap` on `flagsChanged` events, watches `.maskSecondaryFn`, and on Fn down/up:

1. Fires `onPress` / `onRelease` on the main queue.
2. Returns `nil` to swallow the event so macOS does not fire Globe actions (Emoji picker, Dictation, etc.).

<Warning>
Fn suppression requires Accessibility permission. Without it, `CGEvent.tapCreate` fails and `HotkeyManager` falls back to passive `NSEvent` global/local monitors that observe Fn but cannot suppress system Globe behavior.
</Warning>

### Bare modifier keys

Eight physical modifier keys are supported as standalone hotkeys (left and right are distinct):

| Key | `keyCode` constant |
|-----|-------------------|
| ⌘ Cmd | `kVK_Command`, `kVK_RightCommand` |
| ⌥ Opt | `kVK_Option`, `kVK_RightOption` |
| ⌃ Ctrl | `kVK_Control`, `kVK_RightControl` |
| ⇧ Shift | `kVK_Shift`, `kVK_RightShift` |

The modifier tap is **listen-only** (`options: .listenOnly`): it observes press/release for the specific `keyCode` but always passes events through so the modifier continues to work in normal shortcuts like ⌘+E.

## HotkeyManager architecture

`HotkeyManager` owns exactly one active backend at a time. `register(binding:)` calls `unregister()` first, then switches on the binding variant.

```mermaid
flowchart TB
    subgraph SettingsStore
        HB[hotkey: HotkeyBinding]
    end

    subgraph AppCoordinator
        RH[registerHotkey]
        HP[handleHotkeyPress]
        HR[handleHotkeyRelease]
    end

    subgraph HotkeyManager
        REG[register binding]
        CARBON[Carbon RegisterEventHotKey]
        FN_TAP[Fn CGEventTap suppressing]
        FN_PASS[Fn NSEvent passive monitor]
        MOD_TAP[Modifier listen-only tap]
        MOD_PASS[Modifier NSEvent passive monitor]
    end

    HB --> RH
    RH --> REG
    REG -->|carbon| CARBON
    REG -->|fn| FN_TAP
    FN_TAP -.->|no Accessibility| FN_PASS
    REG -->|modifierKey| MOD_TAP
    MOD_TAP -.->|tapCreate fails| MOD_PASS
    CARBON --> HP
    CARBON --> HR
    FN_TAP --> HP
    FN_TAP --> HR
    FN_PASS --> HP
    FN_PASS --> HR
    MOD_TAP --> HP
    MOD_TAP --> HR
    MOD_PASS --> HP
    MOD_PASS --> HR
```

### Dedicated tap threads

Fn and bare-modifier `CGEventTap` callbacks run on dedicated threads (`com.cartesia.InkIt.FnEventTap`, `com.cartesia.InkIt.ModifierEventTap`), not the main run loop. An active `flagsChanged` tap blocks every modifier-key press until its callback returns; servicing it on the main run loop would freeze Cmd/Shift/… system-wide during main-thread stalls (AX walks, heavy SwiftUI layout).

Each tap thread:

1. Adds the `CFMachPort` run-loop source to its own `CFRunLoop`.
2. Enables the tap.
3. Runs `CFRunLoopRun()` until `unregister()` stops the loop and tears down the source.

The callback itself only toggles state and async-hops to main for `onPress`/`onRelease`, keeping the tap thread responsive.

`FnKeyCapture` (used by `HotkeyRecorder` during shortcut recording) follows the same dedicated-thread pattern (`com.cartesia.InkIt.FnKeyCapture`).

### Callback contract

| Property | Type | Fired when |
|----------|------|------------|
| `onPress` | `(() -> Void)?` | Hotkey down (or toggle press while idle) |
| `onRelease` | `(() -> Void)?` | Hotkey up (hold mode only acts on release) |

Both callbacks are always dispatched to the main queue via `DispatchQueue.main.async`.

## DictationMode semantics

`DictationMode` controls how `AppCoordinator` interprets press and release events.

| Mode | Raw value | Display name | Press behavior | Release behavior |
|------|-----------|--------------|----------------|------------------|
| Hold | `hold` | Hold to talk | `startDictation()` | `stopDictation()` (unless error dwell active) |
| Toggle | `toggle` | Hands-free | If recording → `stopDictation()`; else → `startDictation()` | Ignored |

```mermaid
stateDiagram-v2
    [*] --> idle
    idle --> recording: hold press / toggle press
    recording --> finalizing: hold release / toggle press
    finalizing --> rewriting: STT complete
    rewriting --> pasting: polish done
    pasting --> idle: paste OK
    recording --> idle: empty transcript
    idle --> recording: press during heldInHistory or error
```

In hold mode, `isHotkeyHeld` keeps error notices visible for the entire press; they clear after release with a 1.5s dwell. In toggle mode, the second press is the stop signal — functionally equivalent to releasing in hold mode.

`SettingsStore.dictationModeVerb` returns `"Press"` for toggle and `"Hold"` for hold. Home, the notch HUD, and onboarding cues all read this single source of truth.

Default: `.hold`.

## isValidShortcut reserved-system checks

`HotkeyBinding.isValidShortcut` returns `true` for `.fn` and `.modifierKey` unconditionally. Only `.carbon` combos are validated against macOS-reserved shortcuts.

Rejected patterns:

| Pattern | Example | Reason |
|---------|---------|--------|
| ⌘ + common letter | ⌘C, ⌘V, ⌘Z, ⌘A, … | Standard edit/menu shortcuts |
| ⌘ + Space | ⌘Space | Spotlight |
| ⌃ + Space (no other mods) | ⌃Space | Input method switcher |
| ⌘⌥ + Esc | ⌘⌥Esc | Force Quit |
| ⌘⌃ + Q (no other mods) | ⌘⌃Q | Lock Screen |
| ⌘⇧ + 3/4/5 | ⌘⇧3 | Screenshots |
| ⌘ + Tab (no other mods) | ⌘Tab | App switcher |

On load, if a persisted carbon binding fails `isValidShortcut`, `SettingsStore` falls back to `.fn`.

The `HotkeyRecorder` rejects bare keys (no modifier) and shows a toast: `{keys} is invalid. Please try another.`

## Persistence

Hotkey state is stored in `UserDefaults` via `SettingsStore`:

<ParamField body="hotkeyKind" type="string">
Binding type: `"carbon"`, `"fn"`, or `"modifier"`.
</ParamField>

<ParamField body="hotkeyKeyCode" type="int">
Physical key code. Used for carbon combos and bare modifiers.
</ParamField>

<ParamField body="hotkeyModifiers" type="int">
Carbon modifier mask (`cmdKey`, `optionKey`, `controlKey`, `shiftKey`). Only for `"carbon"` kind.
</ParamField>

<ParamField body="dictationMode" type="string">
`"hold"` or `"toggle"`. Default `"hold"`.
</ParamField>

Default hotkey on first launch: `.fn` (`hotkeyKind` unset or any value other than `"carbon"` / `"modifier"`).

Legacy carbon default if kind is `"carbon"` but stored combo is invalid: falls back to `.fn`. Stored carbon fallback values are `kVK_Space` with `controlKey | optionKey` (⌃⌥Space).

Changing `settings.hotkey` triggers `saveHotkey()` and re-registration via `AppCoordinator.registerHotkey()`.

## Registration lifecycle

`AppCoordinator` owns a private `HotkeyManager` instance and wires callbacks at init:

```swift
hotkey.onPress = { [weak self] in Task { @MainActor in self?.handleHotkeyPress() } }
hotkey.onRelease = { [weak self] in Task { @MainActor in self?.handleHotkeyRelease() } }
```

| Phase | Registration state |
|-------|-------------------|
| Before onboarding completes | Hotkey unregistered; HUD dismissed |
| Onboarding "Try it" trial | `ensureHotkeyRegistration()` — hotkey active, HUD shown |
| After onboarding completes | `registerHotkey()` on every launch when HUD is live |
| Settings hotkey editing | `unregisterHotkey()` while recorder is active; restored on save/cancel |
| Accessibility granted | Re-register so Fn upgrades from passive monitor to suppressing tap |
| Accessibility revoked | Re-register so Fn downgrades to passive monitor (avoids dead tap swallowing keys) |

Pressing the hotkey without Accessibility shows `"Accessibility needed"` in the notch HUD and throttles opening System Settings to once per 10 seconds.

## Recording a new shortcut

`HotkeyRecorder` in Settings (Dictation pane) captures bindings through two local `NSEvent` monitors plus `FnKeyCapture`:

<Steps>
<Step title="Unregister the live hotkey">
`coordinator.unregisterHotkey()` prevents the current binding from firing while recording.
</Step>
<Step title="Capture Carbon combos">
`keyDown` monitor: requires at least one modifier; validates via `isValidShortcut`; Esc cancels.
</Step>
<Step title="Capture Fn">
`flagsChanged` with `.function`, `kVK_Function` keyDown, or `FnKeyCapture` tap — all save `.fn`.
</Step>
<Step title="Capture bare modifiers">
On `flagsChanged`, a lone modifier is held as `modifierCandidate` on press; saved as `.modifierKey(keyCode:)` on release only if no other key was pressed while it was down.
</Step>
<Step title="Persist and re-register">
`settings.hotkey = captured`; `coordinator.registerHotkey()`; toast `"Shortcut saved"`.
</Step>
</Steps>

Click outside the recorder, window resign-key, or Esc cancels editing and restores the previous registration.

## Display helpers

`HotkeyConversion` provides cross-layer utilities:

| Function | Purpose |
|----------|---------|
| `carbonModifiers(from:)` | `NSEvent.ModifierFlags` → Carbon mask |
| `displayString(keyCode:modifiers:)` | e.g. `⌃⌥S` |
| `displayTokens(for:)` | Keycap tokens for UI, e.g. `["⌃ Ctrl", "⌥ Opt", "S"]` |
| `modifierLabel(for:)` | Bare modifier label, e.g. `⌥ Opt →` for right Option |
| `isModifierKeyCode(_:)` | Whether keyCode is one of the eight bare modifiers |
| `isFunctionKey(_:)` | F1–F19 detection for recorder display |

`SettingsStore.hotkeyDisplayString` and `InkItApp` Home/status UI consume these tokens.

## Failure modes

| Symptom | Likely cause | Code behavior |
|---------|--------------|---------------|
| Fn opens Emoji/Dictation | No Accessibility; passive monitor active | Upgrade path: grant AX → auto re-register |
| Modifier keys freeze briefly | Tap serviced on main run loop (historical bug) | Fixed: dedicated tap threads; DEBUG `MainThreadWatchdog` guards regressions |
| Recorded shortcut not saved | Reserved-system combo | `isValidShortcut == false` → error toast |
| Hotkey silent after editing Settings | Recorder still active / registration not restored | Cancel or save triggers `registerHotkey()` |
| Globe works but dictation does not | Missing Cartesia key or mic permission | Separate guards in `startDictation()` |

<Note>
Carbon `RegisterEventHotKey` failure logs `InkIt: RegisterEventHotKey failed (%d)` but does not surface a user-visible error. Modifier and Fn paths degrade to passive monitors rather than failing entirely.
</Note>

## Related pages

<CardGroup>
<Card title="Configure hotkeys and mode" href="/configure-hotkeys-and-mode">
Record a shortcut in Settings, switch hold vs hands-free, and verify registration after Accessibility is granted.
</Card>
<Card title="Dictation state machine" href="/dictation-state-machine">
How hotkey press/release drives `DictationState` transitions from idle through recording, finalizing, and paste.
</Card>
<Card title="Permissions model" href="/permissions-model">
Accessibility requirement for Fn suppression and paste; microphone requirement before recording starts.
</Card>
<Card title="Settings reference" href="/settings-reference">
Full `UserDefaults` key listing including `hotkeyKind`, `hotkeyKeyCode`, `hotkeyModifiers`, and `dictationMode`.
</Card>
</CardGroup>
