Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions Sources/BlurtEngine/Hotkey/TriggerKey.swift
Original file line number Diff line number Diff line change
@@ -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 }

Expand All @@ -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
}
}

Expand All @@ -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"
}
}
}
2 changes: 1 addition & 1 deletion Sources/BlurtEngine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
10 changes: 8 additions & 2 deletions Tests/BlurtEngineTests/TriggerKeyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
Loading