Skip to content

Hold-or-toggle hotkey and Escape to cancel (#38) - #55

Merged
dinooo13 merged 7 commits into
mainfrom
feature/38-toggle-escape
Sep 23, 2026
Merged

dinooo13 merged 7 commits into
mainfrom
feature/38-toggle-escape

Conversation

@dinooo13

@dinooo13 dinooo13 commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Closes #38.

Rebased onto main after #54 and #56 merged. The conflicts were resolved as on the integration branch (see "Merge with #54" below), and the recording cap is main's 10 minutes. Every commit builds on its own.

Commits

50b5667 Tests: the polish key beside a toggle key
3aeeb5f Docs: toggle key and Escape
ca0a301 Hotkey: Escape discards the recording
63f7dd0 Overlay: show a latched recording
5f92a2a Hotkey: toggle key, hold-or-toggle gesture, bounce settle
7e18c32 Hotkey: monitor events carry the instant the key moved
db11e8c App: settings path override for a test copy

What

  1. Toggle key. Settings.toggleHotkey, empty (off) by default, and one settings row, "Toggle key". A chord of its own latches when released, however long it was held. The same chord as the key makes that key hybrid: a release under 400 ms latches, a longer hold stops at release. A latched recording ends on the next press of any chord, on Escape, or at the 120 s cap. A toggle chord equal to the stored chord follows the Accessibility stand-in, so a hybrid key stays hybrid. Without Accessibility the toggle chord is registered with Carbon like the key. It has no stand-in of its own, and the row says why when Carbon cannot register it.
  2. HotkeyGestureTracker (Core, a value type with no clock of its own) turns role-tagged press, release and cancel events into start, stop and discard actions. The instants come in with the events. The only timer it needs is requested through Outcome.settle and answered with timerFired(token:). The coordinator carries out the actions and publishes isLatched. A start the state machine refuses resets the tracker. Every way a recording ends also resets it, which is how the cap ends a latched recording exactly as it does today.
  3. Bounce. A press of a chord within 50 ms of its own release never acts. The first such bounce turns on a 50 ms settle before every stopping release for the rest of the app's run, and a bounce inside that settle resumes the hold. It is announced once as keyboardBounceObserved and logged in the hotkey category. There is no separate 30 ms press debounce, because both monitors already emit alternating press and release events.
  4. Escape. HotkeyMonitor.setCancelKeyEnabled(_:) is turned on when a recording starts and off however it ends. On the tap, HotkeyChordSet catches Escape before the chord trackers see it. It swallows the key-down, the repeats and the key-up, and Escape with an engaged chord's own modifiers still counts. Cmd+Option+Esc passes through, and so does a chord or send key that contains Escape. Carbon registers a bare Escape in hot key slot 7 while a recording is on, always through DispatchQueue.main.async. escapePressed() emits recordingDiscarded, which plays the stop sound, then cancels. Nothing is transcribed, pasted or timed.
  5. Overlay. While latched, the red dot turns into a stop square: in Compact and Live it morphs in place, and Minimal shows the square next to four level bars. The menu status line reads "Recording — press %@ to stop". The menu bar glyph does not change.
  6. Recorder. In fields that allow it, Delete with nothing pending clears the field. The toggle key and the send key allow it; the send key could not be turned off from the UI before.
  7. PLADDER_SETTINGS_PATH is a development-only override of the settings file. It also skips the SpeakUp migration. It is documented in CLAUDE.md.
  8. German translations for every new string (Start/Stopp-Taste, and so on). The key coverage check passed: every new literal is in Localizable.xcstrings and marked translated.

Tests

263 tests in 22 suites in PladderCoreTests, 55 more than the 208 on e5af597. All other targets are unchanged. Three consecutive runs were green.

  • HotkeyGestureTrackerTests (22 tests): hold, toggle and hybrid; a release exactly at the threshold; any chord ends a latch; the closing press's release is ignored; another chord's press or interruption is ignored; interruption discards; bounce turns deferral on; settle; bounce resumes the hold; submit survives a bounce; a latch is never deferred; a bounce never ends a latch; reset keeps deferral.
  • CancelKeyTests (8 tests, HotkeyChordSet): Escape only while enabled; the key-up is swallowed after disabling; foreign modifiers pass through; the chord's own modifiers still cancel; Escape never reaches the trackers, so no .cancelled; a chord or send key containing Escape is not the cancel key; reset keeps the cancel key.
  • HotkeyCodingTests: a missing or empty toggleHotkey means off; round trip.
  • ToggleHotkeyTests (16 tests, coordinator): hybrid tap latches and the next tap inserts; hybrid hold; a hold is timed by the event's own instants, not the dequeue time; a plain toggle chord; push-to-talk stays plain beside it; either chord ends a latch; the chords the monitor is started with (second chord, empty, hybrid stand-in); the cap in toggle mode; an interrupted hybrid press is still silent; a toggle-key change while recording stops the mic; a refused start does not latch; deferral and bounce during settle; the bounce is announced once.
  • EscapeTests (6 tests): Escape discards, pastes nothing and announces recordingStarted, recordingDiscarded; the release after Escape does nothing; Escape while idle does nothing; Escape while latched; output device restored; the cancel key is on only while recording (dictation, interrupted press, cap → [true, false] × 3).

No existing test assertion changed. FakeHotkey gained send(_:), escape() and cancelKeyEnabled as new lines; HotkeyChordSetTests.swift is untouched.

Deviations from the plan and addendum

  • Names follow On-device LLM polish on a second hotkey via Apple Foundation Models #36's layer, as the addendum asks: .dictate, start(chords:), HotkeyMonitorEvent, and HotkeyChordSet (which carries the Escape rule). The plan's commit 2 ("roles, bindings and a tagged event stream") was already done by e5af597, so that slot holds the timestamp commit below.
  • Events carry their instant (new optional HotkeyMonitorEvent.instant, stamped by the tap and by Carbon). The plan read .now in the coordinator's loop. That loop awaits hotkeyPressed, which waits for capture.start(), so a release queued behind the press would be timed late. With a slow Bluetooth microphone every tap would count as a hold. The field is optional, so On-device LLM polish on a second hotkey via Apple Foundation Models #36's constructors and tests are unchanged, and a fake that sends no instant is read as now.
  • Escape on the stream is HotkeyEvent.escape, tagged .dictate; the coordinator ignores the role. This was the smallest change to HotkeyMonitorEvent: the struct stays as it is, and there is one new enum case.
  • No escape() and no .cancel action in the gesture tracker. The coordinator handles .escape directly through escapePressed(), and cancelRecording resets the tracker. That also covers a recording started from a direct hotkeyPressed() call.
  • The last release survives reset(), although the plan cleared it. hotkeyReleased resets the tracker on every stop, and clearing the last release there would let a bounce straight after a stop start a new recording.
  • Deferral persists across monitor restarts for the app's run. The plan kept it per monitor session. The permission poll and Secure Input swap monitors now and then, and the keyboard does not change when they do.
  • Hybrid detection compares the toggle chord with the stored chord or the override. The plan compared it with the effective chord only. With the stored chord hybrid Right Command standing in as Option+Space, the plan's rule would have registered Right Command as a second chord, which Carbon cannot do.
  • Footer. The existing footer literal was kept, and one new literal was added beneath it in a VStack, the same pattern On-device LLM polish on a second hotkey via Apple Foundation Models #36's plan uses. This keeps the German valid and makes the merge mechanical. Rewording the old key was dropped.
  • Toggle-key warning text is "Without Accessibility, %@ cannot be detected, so the toggle key is off until Accessibility is granted." This says why it is not listened for, as addendum item 5 asks, rather than the plan's "Record a combination…".
  • Class, file and line references follow e5af597, not main. cycleRole (On-device LLM polish on a second hotkey via Apple Foundation Models #36) is still set and cleared but no longer filters the stream: the gesture tracker's own role checks do that job.

Critical path

Nothing between recordingStopped and inserted changes: finish(), the pipeline and the output are untouched (git diff e5af597 -- Sources/PladderCore/DictationCoordinator.swift has no hunk after onEvent(.recordingStopped)). hotkeyReleased gains a single endGesture() call after its guard and before recordingStopped. That call cancels the settle task, resets the tracker, clears the latch flag, and flips the cancel-key flag. On the tap the flag flip is a lock-protected Bool. On Carbon it is a lock plus a DispatchQueue.main.async, so UnregisterEventHotKey never runs inline. The one wait users can feel is the adaptive 50 ms settle. It sits before recordingStopped and only happens after a bounce has been seen. It is off by default, and BENCHMARKS.md says how to recognise it in the log.

pladder-cli bench measures the engine, not the coordinator, so it cannot see this change. For completeness, on the integration build of #36, #37 and #38 (M1, same procedure as main): 10s 0.254 s (main 0.248), 30s 0.436 (0.434), 60s 0.596 (0.601), 2m 0.932 (1.009), 5m 2.017 (2.079), 10m 3.760 (3.803). Every difference is within noise.

Live-test recipe

  1. ./scripts/bundle.sh in the worktree. Never use --install, which runs pkill -x Pladder.
  2. Read the live chords, read-only: cat ~/Library/Application\ Support/Pladder/settings.json. Before the first launch, write the test copy's own file with chords that differ from the live ones. A copy that starts from the defaults listens for Option+Space and would fire alongside the copy in use. For example (Control+Shift+D for both; copy the engineID from the live file): mkdir -p /tmp/pladder-38 && printf '{"engineID":"<live engineID>","hotkey":{"keyCodes":[2,56,59]},"toggleHotkey":{"keyCodes":[2,56,59]}}' > /tmp/pladder-38/settings.json
  3. PLADDER_SETTINGS_PATH=/tmp/pladder-38/settings.json "$PWD/dist/Pladder.app/Contents/MacOS/Pladder" &
  4. Settings, General: the "Toggle key" row reads Control + Shift + D with no warning. Click the field and press Delete: it reads "None". Record it again.
  5. Hybrid tap in TextEdit: tap Control+Shift+D, and the start sound plays and the square appears. The menu line reads "Recording — press Control + Shift + D to stop". Tap again: the stop sound plays and the text is pasted.
  6. Hybrid hold: hold, speak and release. The text is pasted at release and the dot stays round.
  7. Escape: tap to latch, speak, press Escape. The stop sound plays and nothing is pasted, with no release-to-paste line. Then hold, press Escape while holding, and let go: the same, and the release does nothing. While idle, Escape must still close a Spotlight window.
  8. Two chords: set Toggle key = Control+Shift+E. Control+Shift+D is plain push-to-talk. Control+Shift+E latches, and either chord ends the latch.
  9. Styles: in Minimal the square sits beside four bars; in Live the square sits before the words.
  10. Carbon: pkill -f "$PWD/dist/Pladder.app", then CODESIGN_IDENTITY=- ./scripts/bundle.sh and relaunch as in step 3 without granting Accessibility. Repeat steps 5 to 7: after each stop the pill shows "Copied — press ⌘V", Escape cancels only while recording, and Spotlight gets it back afterwards.
  11. Clean up: pkill -f "$PWD/dist/Pladder.app"; rm -rf /tmp/pladder-38.

The bounce path cannot be reproduced on demand. The tracker tests cover it, and the keyboard bounce observed log line is how a field report will be confirmed. No copy was launched from this worktree: with default settings it would listen for Option+Space next to the copy in use.

For the reviewer

  • DictationCoordinator.startHotkey / act / endGesture, and the start/stop/cancel-key ordering in hotkeyPressed (enable before the await; endGesture() in the catch).
  • CarbonHotkeyMonitor.syncCancelKey: generation and staleness handling, teardown in stop(), and the handle branch for slot 7.
  • HotkeyChordSet.cancelKeyDown: which modifiers are allowed (collapsed sides of the engaged chords only).
  • Bounce rule edge: a hybrid key that bounces inside a short (<400 ms) hold latches, and the real release then does nothing. The user taps again to stop. Accepted.
  • Test timing: several coordinator tests sleep 80 ms before a second press so that press is not read as a bounce.

Merge notes for #36

The expected conflicts are all adjacent lines and mechanical:

  • startHotkey: the chords line (var chords plus the toggle line here, .polish there).
  • cancelRecording / hotkeyPressed: endGesture() and the cancel-key enable here, willPolish there.
  • The Settings fields, CodingKeys and decoder.
  • SettingsView: rows, and the footer VStack, where both branches add a second FootnoteText.
  • Event cases, AppModel.handle, EventLog.
  • Localizable.xcstrings: sorted keys, so keep both blocks.

HotkeyRole is now dictate, polish, toggle, so Carbon IDs are 0, 1 and 2 for the roles and 7 for Escape. The polish role has no mode in startGesture and therefore holds; act(.start(role)) passes the role to hotkeyPressed(role:), so willPolish routing works as is. Whichever branch merges second should add a settings warning for a toggle chord equal to the polish chord: two roles on one chord would both fire.

Merge with #54

The conflicts are adjacent lines. On the integration branch: startHotkey registers dictate, toggle and polish, and drops a polish chord that is already another role's chord. The polish role's gesture mode is .hold. The polish row gets allowsEmpty (Delete clears) and a warning when it equals the toggle key. The footer carries three literals.

🤖 Generated with Claude Code

@dinooo13

Copy link
Copy Markdown
Owner Author

Live test (integration build, M1, installed to /Applications)

The installed app was started with open --env PLADDER_SETTINGS_PATH=/tmp/…/settings.json. The key and the toggle key were both Control+Shift+D (hybrid), and chords were posted as HID events. Launching the bare binary from a shell does not work for this: it inherits the shell's TCC identity and records silence. Launching through open --env runs it as its own app with its own grants. CLAUDE.md's recipe should say so; noted for a follow-up.

  • Hold (push-to-talk): exact text pasted, release-to-paste 0.158 s.
  • Hybrid tap: a 150 ms tap latched the recording. The pill showed the red stop square with live level bars. A second tap pasted the text exactly (0.230 s).
  • Escape while latched: nothing pasted, no timing line, capture stopped.
  • Escape while idle: it reached Spotlight and closed it. It is not taken globally.
  • Settings: the Toggle key row is present, and Delete on a field clears it to "None".
  • One keyboard bounce observed line appeared. It came from my own synthetic modifier events (a key-up then a re-press within 50 ms), not from a real keyboard. The settle then applied for that run, as designed.

Not tested live: the Carbon path (no Accessibility), and a separate toggle chord (the unit tests cover it).

dinooo13 and others added 7 commits September 23, 2026 22:46
`PLADDER_SETTINGS_PATH` points a copy launched from a worktree at a
settings file of its own. Every recorder commit is saved at once, so a
test copy that shared the live file would rewrite the chords of the copy
in daily use. With the override set the legacy SpeakUp migration is
skipped too, so the test copy starts from the defaults.

Development only: no UI, and nothing changes for a normal launch.
CLAUDE.md's "Working on this Mac" section says how to launch such a copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`HotkeyMonitorEvent` gains an optional `instant`, stamped by the tap
with the one clock read it already makes per event, and by Carbon in its
handler. The next commit times a hold from press to release to decide
between push-to-talk and a latched toggle; timing it from when the
coordinator dequeues the event would be wrong, because the press waits
for the microphone to start and the release queued behind it would look
that much longer. A slow Bluetooth microphone would turn every tap into
a hold.

Optional, so the existing constructors and the chord set's tests are
untouched; a source that does not stamp, a test fake, is read as now.
No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A second recordable chord, "Toggle key", off by default. One press
starts a recording, the next press of any chord stops and pastes it, so
a two-minute dictation no longer needs a key held for two minutes. Set
to the same combination as the push-to-talk key it makes that key
hybrid: a release sooner than 400 ms after the press latches, a later
one stops as before. A different combination is a chord of its own
with its plain behaviour. A toggle chord equal to the stored chord
follows the Accessibility stand-in, so a hybrid key stays hybrid.

`HotkeyGestureTracker`, a clockless value type in Core, decides hold,
toggle or hybrid from the role-tagged presses and releases and their
instants; the coordinator carries out its start, stop and discard, and
publishes `isLatched`. A press the state machine refuses resets it, so a
tap while the engine loads cannot leave a latch behind; every end of a
recording resets it, which is what makes the 120 s cap end a latched
recording exactly as it ends a held one.

Bounce: a press of a chord within 50 ms of its release is the keyboard,
not the user, and never starts, stops or latches anything. The first one
seen turns on a 50 ms settle before every stopping release for the rest
of the app's run, and a bounce inside the settle resumes the hold. Until
then nothing waits, so a healthy keyboard never pays for it; the event
`keyboardBounceObserved` is logged once in the hotkey category. No
separate press debounce: both monitors already report alternating
presses and releases per chord.

Settings: a "Toggle key" row under the key, with a warning when the
chord cannot be detected without Accessibility (no stand-in of its own)
or macOS owns it. The recorder now clears a field with Delete where
that is allowed, which the toggle key and the send key are; the send
key's empty chord always meant off but could not be set from the UI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A recording that carries on after its chord was let go must not look
like one that stops when the key comes up, or the user walks away from
an open microphone. The red dot squares off into a stop sign while the
coordinator's `isLatched` is set: in Compact and Live the dot morphs in
place (one rounded rectangle whose corners animate, so it does not
swap), and the Minimal disc shows the square beside a four-bar wave
instead of the dot-then-bars intro, so the level stays visible.

The Menu style has no pill, so the status line is its cue: "Recording —
press <key> to stop", naming the toggle key when it is a chord of its
own and the key or its stand-in when it is hybrid. The menu bar glyph
is unchanged. Settings replicas keep the round dot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
While a recording is on, a plain Escape drops it without transcribing
or pasting, and the app plays the stop sound so the user hears the
microphone go off. Between recordings Escape is never taken: the
coordinator tells the monitor through a new `setCancelKeyEnabled(_:)`
when a recording starts and when it ends, however it ends.

On the tap `HotkeyChordSet` catches Escape before the chord trackers
see it, so the interrupted-press rule never adds a `.cancelled`, and
swallows its repeats and key-up even if the cancel key was turned off
in between. Escape with an engaged chord's own modifiers still counts,
so it works while Option+Space is held; Cmd+Option+Escape (Force Quit)
passes through, and a chord or send key that contains Escape keeps it.
Under Secure Event Input the tap sees no key-downs, so a modifier-only
chord's recording cannot be cancelled there; it ends as before.

Carbon registers Escape as a hot key of its own in the last ID slot when
a recording starts and unregisters it when it ends, always from the main
queue and never inline, so the release path never waits on the window
server. Its mask is empty, so only a bare Escape cancels without
Accessibility.

`HotkeyEvent.escape` carries it on the stream; it belongs to no chord,
so the monitors tag it `.dictate` and the coordinator ignores the role.
The coordinator's `escapePressed()` emits `recordingDiscarded` and then
cancels as an interrupted press does. On the release path the only new
statement is the cancel-key flag, before `recordingStopped`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLAUDE.md: decisions rows for the toggle key (hybrid rule, 400 ms,
adaptive 50 ms bounce settle, why off by default, the Carbon and
stand-in rules, the lone-modifier caveat) and for Escape (taken only
while recording, how the tap and Carbon differ, the Secure Event Input
gap); the send-key and cap rows say what they do for a latched
recording; a pluggability line for adding a hotkey role.

README: an FAQ entry for toggling. BENCHMARKS.md: what a "keyboard
bounce observed" line means for the release-to-paste number, and where
a latched recording's measurement starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A polish chord equal to the toggle chord is not registered, and a short
polish press stops and polishes rather than latching like a toggle press.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dinooo13
dinooo13 force-pushed the feature/38-toggle-escape branch from a8dc250 to 50b5667 Compare September 23, 2026 20:47
@dinooo13
dinooo13 merged commit 8213bf2 into main Sep 23, 2026
1 check passed
@dinooo13
dinooo13 deleted the feature/38-toggle-escape branch September 28, 2026 18:42
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.

Hold-or-toggle hotkey and Escape to cancel

1 participant