From 91976bea269a50ad52fd82766ba023e55766067e Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 17 Aug 2026 07:12:16 -0700 Subject: [PATCH] Remove `fn` as a dictation-key option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fn` was the one trigger whose down/up state came from the shared `kCGEventFlagMaskSecondaryFn` bit rather than an `NX_DEVICE*` per-side bit, so it sat outside the invariant the other two options hold: one mask names exactly one physical key. Dropping it leaves right ⌘ and right ⌥, both right-side device bits. No migration code needed: `TriggerKey`'s `rawValue` *is* the persisted keycode and `fromPersisted(_:)` maps anything unrecognized to right ⌘, so an existing `fn` binding decodes to the default rather than an invalid selection. Covered by a regression test alongside the two options removed before it (right ⌃, Caps Lock). The Settings picker iterates `allCases`, so it loses the row with no UI change. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- README.md | 2 +- Sources/BlurtEngine/Hotkey/TriggerKey.swift | 9 +++------ Sources/BlurtEngine/README.md | 2 +- Tests/BlurtEngineTests/TriggerKeyTests.swift | 10 ++++++++-- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f42505a2..437e58b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -656,7 +656,7 @@ The dictation trigger is a **single lone modifier key** (tap-to-toggle or hold-t in-house. Four pieces, three of them pure engine logic: - **`TriggerKey`** (`Hotkey/TriggerKey.swift`) — enum of the curated lone momentary modifiers usable - as the trigger (right ⌘, right ⌥, `fn`), `rawValue` = the macOS virtual keycode, plus `label` + as the trigger (right ⌘, right ⌥), `rawValue` = the macOS virtual keycode, plus `label` ("right ⌘") and the device-modifier masks the event source needs. Right-side modifiers are chosen because a solo press rarely collides with app shortcuts. - **`TriggerKeyStore`** — persists the chosen keycode in `UserDefaults` (`BlurtTriggerKeyCode`), diff --git a/README.md b/README.md index f8afbd19..813b2256 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ utterance. restored around it. If the target app quit while you were speaking, the text stays on the clipboard instead of vanishing. - **One key, no chords** — dictation is triggered by a single lone modifier - (right ⌘ by default; right ⌥ and `fn` also available). Tap to toggle, hold + (right ⌘ by default; right ⌥ also available). Tap to toggle, hold for push-to-talk. The event tap swallows nothing: a lone modifier types nothing anyway, and combos like ⌘C pass through untouched. - **Polished in one step** — each utterance rides to AssemblyAI's dictation API diff --git a/Sources/BlurtEngine/Hotkey/TriggerKey.swift b/Sources/BlurtEngine/Hotkey/TriggerKey.swift index f27952ac..570b6eb9 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerKey.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerKey.swift @@ -1,11 +1,10 @@ /// A lone momentary modifier key usable as the single dictation trigger. The raw /// value is the macOS virtual key code, so `TriggerKey(rawValue:)` decodes a -/// persisted keycode directly. Curated to right-side modifiers (rarely used in -/// app shortcuts, so a solo press maps cleanly to "dictate") and `fn`. +/// persisted keycode directly. Curated to right-side modifiers, which are rarely +/// used in app shortcuts, so a solo press maps cleanly to "dictate". public enum TriggerKey: Int, CaseIterable, Sendable, Hashable { case rightCommand = 54 case rightOption = 61 - case function = 63 public var keyCode: Int { rawValue } @@ -27,12 +26,11 @@ public enum TriggerKey: Int, CaseIterable, Sendable, Hashable { /// tap's down/up tracking on keyboards where both keys are in play (a leading /// suspect for the duplicate-paste reports on third-party keyboards). The /// device bit names exactly one physical side, so the bound key's own state is - /// unambiguous. `fn` has no left/right split, so it uses the secondary-fn bit. + /// unambiguous — which is also why every option here is a right-side key. public var deviceModifierMask: UInt64 { switch self { case .rightCommand: return 0x10 // NX_DEVICERCMDKEYMASK case .rightOption: return 0x40 // NX_DEVICERALTKEYMASK - case .function: return 0x80_0000 // kCGEventFlagMaskSecondaryFn } } @@ -41,7 +39,6 @@ public enum TriggerKey: Int, CaseIterable, Sendable, Hashable { switch self { case .rightCommand: return "right ⌘" case .rightOption: return "right ⌥" - case .function: return "fn" } } } diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index d2e06bfa..2b295e38 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -239,7 +239,7 @@ The engine carries the _decision_ half of Blurt's self-update, for the same reas The engine ships the _decision logic_ for a lone-modifier trigger; the host supplies the event source (in Blurt, a `CGEventTap` — see `App/Blurt/Blurt/Hotkey/DictationKeyTap.swift` for the reference wiring). -- **`TriggerKey`** — the curated lone modifiers usable as a trigger (right ⌘, right ⌥, `fn`), with keycodes, display labels, and the device-modifier masks the event source needs. +- **`TriggerKey`** — the curated lone modifiers usable as a trigger (right ⌘, right ⌥), with keycodes, display labels, and the device-modifier masks the event source needs. - **`TriggerKeyStore`** — persists the chosen key in `UserDefaults` (`BlurtTriggerKeyCode`), defaulting to right ⌘. - **`DictationKeyGate`** — a pure, clock-free state machine that turns `modifierDown(at:)` / `modifierUp(at:)` / `otherKeyDown()` into `.start` / `.stop` / `.cancel` / `.none`. Recording starts the instant the modifier goes down; on key-up, a release held ≥ `holdThreshold` (default 1 s) is push-to-talk (stop), a shorter release latches tap-to-toggle (next tap stops). A modifier+key combo from idle cancels the fresh capture; over a latched recording it passes through as a normal shortcut. Callers pass monotonic timestamps, so every decision is deterministic and unit-tested (`DictationKeyGateTests`, `HotkeyRaceTests`). - **`DictationKeyRouter`** — the recommended layer over the gate: reduce each raw event to `.flagsChanged(keyCode:triggerFlagIsOn:)` / `.keyDown(keyCode:)` and `handle(_:at:)` applies the filters every event source needs — only the bound keycode's flag changes count, and only genuine down/up _edges_ reach the gate (`flagsChanged` deliveries re-report the bit whether or not it changed, so a repeat must not double-start a dictation). `reset()` / `rebind(triggerKeyCode:)` clear state that can no longer be trusted (dropped events, a rebound trigger) and return whether they discarded a live recording. Unit-tested (`DictationKeyRouterTests`). diff --git a/Tests/BlurtEngineTests/TriggerKeyTests.swift b/Tests/BlurtEngineTests/TriggerKeyTests.swift index 9096704b..d267ce7c 100644 --- a/Tests/BlurtEngineTests/TriggerKeyTests.swift +++ b/Tests/BlurtEngineTests/TriggerKeyTests.swift @@ -8,7 +8,6 @@ struct TriggerKeyTests { func keyCodes() { #expect(TriggerKey.rightCommand.keyCode == 54) #expect(TriggerKey.rightOption.keyCode == 61) - #expect(TriggerKey.function.keyCode == 63) } @Test("every case has a non-empty label") @@ -39,6 +38,14 @@ struct TriggerKeyTests { #expect(TriggerKey.fromPersisted(57) == .rightCommand) } + @Test("a persisted fn keycode (a removed option) falls back to right ⌘") + func removedFunctionFallsBack() { + // `fn` (keycode 63) was dropped as an option; anyone who had it saved must + // decode to the default rather than an invalid selection. + #expect(TriggerKey(rawValue: 63) == nil) + #expect(TriggerKey.fromPersisted(63) == .rightCommand) + } + // The hotkey tap reads the *device-dependent* modifier bit (which physical // side toggled) rather than the generic command/option/control mask, which is // shared by both the left and right keys. Reading the shared mask can't tell a @@ -48,7 +55,6 @@ struct TriggerKeyTests { func deviceMasks() { #expect(TriggerKey.rightCommand.deviceModifierMask == 0x10) // NX_DEVICERCMDKEYMASK #expect(TriggerKey.rightOption.deviceModifierMask == 0x40) // NX_DEVICERALTKEYMASK - #expect(TriggerKey.function.deviceModifierMask == 0x80_0000) // kCGEventFlagMaskSecondaryFn } @Test("right-⌘ mask does not collide with the left-⌘ or generic ⌘ bit")