diff --git a/CLAUDE.md b/CLAUDE.md index 2400f19..514b919 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,14 +43,16 @@ swift run -c release pladder-cli polish # run the polish prompt ov | Interrupted press | A non-chord key within 1 s of the chord press cancels the recording without transcribing | Anyone who records a lone Command key shares it with Cmd+C, Cmd+V and Cmd+Tab; the overlay waits 150 ms before showing so those never flash it. Option+Space shares no modifier with them | | Secure Event Input | `IsSecureEventInputEnabled()` polled with the grant; sustained 3 s and a chord Carbon can register → Carbon monitor until it clears | A password field or Terminal's Secure Keyboard Entry stops taps receiving key events; modifier-only chords are unaffected and stay on the tap | | Without Accessibility | Carbon `RegisterEventHotKey` plus clipboard-only output | A standard account cannot grant Accessibility without an admin. Carbon needs no permission but wants exactly one regular key and collapses left and right, so modifier-only chords are refused in the recorder; the transcript is left on the clipboard and the overlay says "press ⌘V". A stored chord Carbon cannot register, a lone Right Command say, is stood in for by the default Option+Space and the menu names it; the stored chord returns with the grant. `CopySymbolicHotKeys` only feeds the warning that an enabled macOS shortcut owns the recorded chord. `AppModel` polls the grant every two seconds and swaps the monitor in both directions | -| Send key | Press Right Option (configurable) while the hotkey is held and Return is posted 50 ms after Cmd+V | Sends a chat message or runs a command without a second trip to the keyboard; the Return is posted from a detached task so it stays off the release-to-paste path | +| Send key | Press Right Option (configurable) while the hotkey is held and Return is posted 50 ms after Cmd+V | Sends a chat message or runs a command without a second trip to the keyboard; the Return is posted from a detached task so it stays off the release-to-paste path. Only for a held recording: a latched one ends on the closing press, before any send key could arm | | Polish hotkey | "Dictate and polish": a second recordable chord, off by default. A dictation started with it runs the usual pipeline, then Apple's on-device model (FoundationModels, `PladderRefine`) with a fixed cleanup prompt, then pastes | Self-corrections, spoken punctuation, number words and lists are beyond the deterministic processors, and the model runs on device with nothing to download. It costs one to three seconds, so it never touches the normal hotkey's path: the branch is one Bool read; the session is created and prewarmed at key-down; transcripts under four words skip it; anything the model cannot do (Apple Intelligence off, refusal, the 8 s timeout) pastes the text as dictated. Logged as its own `polished release-to-paste` line | | Learned corrections | After a paste the field is watched through Accessibility for up to 60 s; a word the user corrects that passes a token diff, a phonetic gate (Soundex or edit distance ≤ 2) and a yes/no review by the on-device model becomes one menu line, "Learned “x” → “y”? Add / Dismiss" | Nothing runs before Cmd+V is posted: the hook is in `AppModel.handle(.inserted)`, the watcher lives on its own thread and reads only the pasted range plus a margin, the review runs on a detached task. Present only with Accessibility and Apple Intelligence, absent otherwise, no setting, no change to the menu bar glyph. Dismissed pairs go to `dismissed-corrections.json`, not settings, so a bug there can never cost the dictionary. Pure case changes are never proposed. Terminals and TUIs expose a screen buffer, not a field, so nothing is learned there | +| Toggle key | Another recordable chord, off by default. A chord of its own latches at release however long the press; equal to the push-to-talk chord it makes that key hybrid: a tap under 400 ms latches, a longer hold stops at release. The next press of any chord, Escape or the 10 min cap ends a latched recording; the overlay's dot squares off while it is latched | Two-minute dictations should not need a key held for two minutes. Handy and VoiceInk default to hybrid on one key; here it is opt-in, because a stray tap would otherwise leave the microphone open until the cap pastes two minutes of room noise. Hold, toggle and hybrid are decided in `HotkeyGestureTracker`, a clockless value type timed by the instant each monitor stamps on its events, so both monitors behave alike and a press that waits for the microphone cannot make the next release look longer. A same-chord press within 50 ms of its release is a bounce (some Bluetooth keyboards do this mid-hold): it never acts, and the first one seen turns on a 50 ms settle before every stopping release for the rest of the run, so only a keyboard that needs it pays for it and the release path is otherwise untouched. No separate press debounce: both monitors already report alternating presses and releases. Without Accessibility the toggle chord registers with Carbon like the key; one Carbon cannot register has no stand-in, except that a hybrid chord follows the key's. With a lone modifier as a hybrid key, the Command of a later Cmd+C ends a latched recording | +| Escape | Discards a recording without transcribing and plays the stop sound; taken only while a recording is on | Never taken globally, so Escape keeps closing dialogs. On the tap `HotkeyChordSet` catches it before the chord trackers, so the interrupted-press rule never sees it, and Escape with the chord's own modifiers held still counts; Carbon registers the bare key around each recording, from the main queue so the release path never waits on it. Under Secure Event Input a modifier-only chord stays on the tap, where no key-down arrives, so Escape cannot cancel there | | Output | Clipboard + simulated Cmd+V; the old clipboard is restored off the critical path | Universal, fast | | Post-processing | Filler remover, dictionary replacer, fuzzy custom-word corrector, whitespace normaliser, in that order | No latency, no network. An earlier Apple Intelligence step was removed from this path unmeasured; the model is back behind the polish hotkey only | | Mute while dictating | Off by default; `kAudioDevicePropertyMute` on the default output device 200 ms into a recording, restored off the release path | Music or a call otherwise goes into the microphone. The delay means a tap-and-release never toggles anything; a device the user had already muted is left alone, and the device that was muted is the one unmuted even if the default changed meanwhile | | UI language | Follows the macOS system language; no setting | String Catalogs (`Localizable.xcstrings` in the app, `KeyNames.xcstrings` in `PladderSystem`) are compiled by `swift build`; `bundle.sh` merges their `.lproj` folders into `Pladder.app/Contents/Resources`, so `Bundle.main` serves them and no code names a bundle. `swift run` shows English. Core and Engines emit enum cases; the app turns them into text. German first; more languages are catalog contributions | -| Recording cap | 10 min | Keeps the microphone from staying on when a key-up is lost | +| Recording cap | 10 min | Keeps the microphone from staying on when a key-up is lost. The cap ends a latched recording the same way | | Benchmark | A script run by hand, not a test | A benchmark that fails on noise gets ignored | ## Pluggability rules @@ -60,6 +62,7 @@ swift run -c release pladder-cli polish # run the polish prompt ov - Adding a processor: implement `TextProcessor` in its own file, append a factory to `processorFactories` in `AppModel`. The pipeline is rebuilt when settings change, never per dictation. A processor sits on the critical path, so the benchmark rule applies. - Adding a prompt: build an `OnDeviceLanguageModel(instructions:)` in `PladderRefine` and call `respond(to:)` or `respond(to:generating:)`; availability, prewarm, timeout and the drain of an abandoned call come with it. The coordinator only ever sees `TranscriptRefiner`. - The correction learner's two seams are protocols in `PladderCore`, `PastedTextObserver` and `CorrectionReviewer`, with fakes in the tests; the Accessibility and Foundation Models implementations live in `PladderSystem` (`AXPasteObserver`) and `PladderRefine` (`FoundationModelsCorrectionReviewer`). +- Adding a hotkey role: a case in `HotkeyRole`, a `Hotkey` field in `Settings` with `[]` meaning off, an entry in the chords `startHotkey` hands the monitor and a mode in `startGesture`, a `HotkeyRecorderField` row. The monitors and trackers need nothing. - Adding a language: add a `` localization to both catalogs; nothing else. Adding a *string*: the key is the exact English text, and `PladderCore` never holds one — it emits an enum case and `Sources/Pladder/StatusText.swift` words it. - Engine and capture are actors. The coordinator is `@MainActor` because it drives UI. It owns the state machine and nothing else; every dependency is injected, so tests run it with in-memory fakes. @@ -81,3 +84,4 @@ The developer dictates with a running Pladder all day, often into Claude session - Never `pkill -x Pladder` or `killall Pladder`: that also kills the copy in use, mid-recording, and the text is lost. Stop only the copy you launched: `pkill -f "$PWD/dist/Pladder.app"`. - Do not post synthetic hotkey events unless the user has asked for a live UI test. Every running copy reacts to them, so they start, cut short, or paste the user's recordings. - Do not edit `~/Library/Application Support/Pladder/settings.json`; it is the live configuration. +- A copy launched for testing gets its own settings file with `PLADDER_SETTINGS_PATH=/tmp//settings.json "$PWD/dist/Pladder.app/Contents/MacOS/Pladder"`, and a different chord from the copy in use, so the two never fire together. Launched as the bare binary so the environment reaches it; its command line still contains `$PWD/dist/Pladder.app`, so the `pkill -f` above stops it. diff --git a/README.md b/README.md index 32415ca..c3b53fe 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,9 @@ Yes. Press the send key, Right Option by default, at any point while you hold th **Can it clean up what I said?** Record a key for Dictate and polish in Settings and hold that instead. The dictation goes through Apple Intelligence on your Mac before it is pasted, which takes a second or two. It needs Apple Intelligence turned on in System Settings; without it that key pastes the text as dictated. +**Can I toggle instead of holding?** +Yes. Record a toggle key in Settings: one tap starts a recording, the next tap inserts it. Give it the same combination as the push-to-talk key and that key does both: tap to start and tap again to insert, or hold and release as before. Escape discards a recording either way. + **What about long dictations?** Recordings stop at 10 minutes, so a lost key-up never leaves the microphone on. Audio longer than 15 seconds is transcribed in overlapping windows. diff --git a/Sources/Pladder/AppModel.swift b/Sources/Pladder/AppModel.swift index 946a701..0751a59 100644 --- a/Sources/Pladder/AppModel.swift +++ b/Sources/Pladder/AppModel.swift @@ -103,6 +103,9 @@ final class AppModel { /// for Add or Dismiss. Kept for the app's life or until answered. private(set) var proposals: [CorrectionProposal] = [] static let maximumProposals = 3 + /// Hotkey behaviour worth knowing about after the fact, such as a + /// keyboard that bounces. + private static let hotkeyLog = Logger(subsystem: "de.dinooo13.pladder", category: "hotkey") /// Settings live in the coordinator (it reacts to hotkey/engine changes); /// this forwards and persists. Applying the appearance covers every @@ -174,7 +177,10 @@ final class AppModel { url: Self.settingsURL, defaults: Settings(engineID: FluidAudioIncrementalEngine.engineID) ) - Self.migrateLegacySettings(to: Self.settingsURL) + // A test copy starts from the defaults, not from an old install. + if Self.settingsPathOverride == nil { + Self.migrateLegacySettings(to: Self.settingsURL) + } self.store = store // One read: the store moves an undecodable file aside on load, so a @@ -246,8 +252,19 @@ final class AppModel { proposalRelay.handler = { [weak self] proposal in self?.propose(proposal) } } + /// `PLADDER_SETTINGS_PATH` points a copy launched for testing at a file + /// of its own, so it neither reads nor writes the configuration of the + /// copy in daily use; every recorder commit is saved at once. Development + /// only: no UI, and a normal launch never has it set. + static var settingsPathOverride: String? { + guard let path = ProcessInfo.processInfo.environment["PLADDER_SETTINGS_PATH"], + !path.isEmpty else { return nil } + return path + } + static var settingsURL: URL { - FileManager.default + if let path = settingsPathOverride { return URL(filePath: path) } + return FileManager.default .homeDirectoryForCurrentUser .appending(path: "Library/Application Support/Pladder/settings.json") } @@ -329,6 +346,15 @@ final class AppModel { ) case .failed: releaseInstant = nil + case .recordingDiscarded: + // Escape: no paste follows, so no timing line either, but the + // microphone did go off and the user should hear it. + releaseInstant = nil + if settings.playSounds { SoundPlayer.playStop() } + case .keyboardBounceObserved: + // That wait comes before `recordingStopped`, so the timing line + // cannot show it; this line is what explains a felt delay. + Self.hotkeyLog.notice("keyboard bounce observed: releases now settle for 50 ms before stopping") } } @@ -395,7 +421,8 @@ final class AppModel { // seeing key-downs, so a chord with a regular key is dead there. // Carbon can take over only for a chord it can register, and a // modifier-only chord is unaffected by secure input anyway, so both - // stay on the tap and nothing swaps. + // stay on the tap and nothing swaps. The push-to-talk chord alone + // decides; the toggle chord follows whichever monitor is up. let wantsTap = accessibilityTrusted && !(sustained && settings.hotkey.canBeRegisteredWithoutAccessibility) let flipped = wantsTap != hotkeyUsesTap @@ -506,7 +533,10 @@ final class AppModel { /// One line describing what the app is doing right now. var statusLine: String { switch coordinator.state { - case .recording: return String(localized: "Recording…") + case .recording: + // The Menu style has no pill, so this line is its latched cue. + guard coordinator.isLatched else { return String(localized: "Recording…") } + return String(localized: "Recording — press \(stopKeyName) to stop") case .transcribing: return String(localized: "Transcribing…") case .polishing: return String(localized: "Polishing…") case .inserting: return String(localized: "Inserting…") @@ -536,6 +566,17 @@ final class AppModel { return settings.hotkey.sideAgnosticDisplayName } + /// What ends a latched recording. Any chord does; this names the one that + /// latched it: the toggle key when it is a chord of its own, otherwise + /// the key, or what stands in for it. + private var stopKeyName: String { + let toggle = settings.toggleHotkey + if !toggle.isEmpty, toggle.canonical != settings.hotkey.canonical { + return hotkeyUsesTap ? toggle.displayName : toggle.sideAgnosticDisplayName + } + return standInHotkey?.sideAgnosticDisplayName ?? effectiveHotkeyName + } + /// True while a working Accessibility grant is being ignored because /// Secure Event Input has the tap deaf and Carbon is standing in. var usesCarbonForSecureInput: Bool { accessibilityTrusted && !hotkeyUsesTap } diff --git a/Sources/Pladder/Overlay/OverlayController.swift b/Sources/Pladder/Overlay/OverlayController.swift index 0baf764..2de04f0 100644 --- a/Sources/Pladder/Overlay/OverlayController.swift +++ b/Sources/Pladder/Overlay/OverlayController.swift @@ -79,6 +79,8 @@ final class OverlayController { // Read so a new partial re-arms this too: between two passes the // state stays `.recording` and nothing else would fire. _ = coordinator.partialTranscript + // A latch changes nothing else: the state stays `.recording`. + _ = coordinator.isLatched } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self, self.running else { return } @@ -103,6 +105,7 @@ final class OverlayController { } model.state = state model.partialTranscript = coordinator.partialTranscript + model.latched = coordinator.isLatched cancelHide() schedulePresent() case .transcribing: @@ -269,6 +272,7 @@ final class OverlayController { self.model.presentation = .hidden self.model.state = .idle self.model.partialTranscript = nil + self.model.latched = false } } } diff --git a/Sources/Pladder/Overlay/OverlayView.swift b/Sources/Pladder/Overlay/OverlayView.swift index c63fef3..bc92403 100644 --- a/Sources/Pladder/Overlay/OverlayView.swift +++ b/Sources/Pladder/Overlay/OverlayView.swift @@ -27,6 +27,10 @@ final class OverlayModel { /// What the engine has heard so far, for the Live Transcript style. Nil in /// every other style, and nil again the moment the key is released. var partialTranscript: String? + /// The chord was let go and the microphone is still on: a toggle press or + /// a hybrid tap. The red dot squares off into a stop sign so the user can + /// tell a latched recording from a held one. + var latched = false init() {} } @@ -131,7 +135,8 @@ struct OverlayView: View { glass: model.glass, partial: model.partialTranscript, presentation: model.presentation, - animationSpeed: model.speed + animationSpeed: model.speed, + latched: model.latched ) // Glass carries its own edge highlight; this is only enough shadow // to lift the pill off a light desktop. The flat background gets @@ -169,6 +174,9 @@ struct OverlayPill: View { /// expanding out of it. Settings replicas never fly, so they keep the /// default. var animationSpeed: OverlayAnimationSpeed = .quick + /// A latched recording: the dot is drawn as a stop square. Settings + /// replicas keep the default. + var latched = false @Namespace private var glassNamespace /// Minimal shows a pulsing dot for the first 0.7 s, then the bars. @@ -281,7 +289,7 @@ struct OverlayPill: View { switch state { case .recording(let level): HStack(spacing: 10) { - RecordingDot() + RecordingDot(latched: latched) LevelBars(level: level, count: 14, maxHeight: 32, opacity: 1, seeded: isPreview) } case .transcribing: @@ -341,7 +349,7 @@ struct OverlayPill: View { switch state { case .recording(let level): HStack(spacing: 10) { - RecordingDot() + RecordingDot(latched: latched) LevelBars(level: level, count: 8, maxHeight: 24, opacity: 1, seeded: isPreview) LiveTranscriptText( text: partial ?? "", @@ -389,7 +397,17 @@ struct OverlayPill: View { switch state { case .recording(let level): ZStack { - if showsDot { + if latched { + // The square and a narrower wave side by side: the disc + // still shows the level, and the square says the + // microphone stays on without the key. 8 + 6 + 21 pt fits + // the 44 pt disc. + HStack(spacing: 6) { + RecordingDot(latched: true) + LevelBars(level: level, count: 4, maxHeight: 20, opacity: 0.8, seeded: isPreview) + } + .transition(.opacity) + } else if showsDot { // The pulse is Minimal's own start-of-take cue. A row // style flying in keeps the dot at the row's size, so // the dot it hands over to on arrival is the same dot. @@ -512,14 +530,20 @@ struct LiveTranscriptText: View { } /// The red "live" dot, shared by Compact, Minimal and the settings replicas. +/// Latched, it squares off into a stop sign: the recording carries on until +/// the next press. struct RecordingDot: View { var size: CGFloat = 8 + var latched = false var body: some View { - Circle() + // One shape whose corners animate: a square with half-size corners is + // the circle, so the dot morphs rather than swaps. + RoundedRectangle(cornerRadius: latched ? 2 : size / 2, style: .continuous) .fill(.red) .frame(width: size, height: size) .shadow(color: .red.opacity(0.6), radius: 4) + .animation(.smooth(duration: 0.2), value: latched) } } diff --git a/Sources/Pladder/Resources/Localizable.xcstrings b/Sources/Pladder/Resources/Localizable.xcstrings index 9ea0e29..8ee67e3 100644 --- a/Sources/Pladder/Resources/Localizable.xcstrings +++ b/Sources/Pladder/Resources/Localizable.xcstrings @@ -31,6 +31,16 @@ } } }, + "%@ is also the toggle key, so it never polishes. Record a different combination.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist auch die Start/Stopp-Taste, deshalb wird damit nie überarbeitet. Nimm eine andere Kombination auf." + } + } + } + }, "%@ is part of the push-to-talk key, so it can never be pressed separately.": { "localizations": { "de": { @@ -841,6 +851,26 @@ } } }, + "Press the key or combination to use. Escape cancels, Delete clears.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Taste oder Kombination drücken. Esc bricht ab, die Löschtaste leert das Feld." + } + } + } + }, + "Press the key or combination to use. Escape cancels, Delete clears. Without Accessibility the key must include a regular key, for example Control+Shift+D.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Taste oder Kombination drücken. Esc bricht ab, die Löschtaste leert das Feld. Ohne Bedienungshilfen braucht die Kombination eine normale Taste, zum Beispiel Ctrl + Umschalttaste + D." + } + } + } + }, "Press the key or combination to use. Escape cancels.": { "localizations": { "de": { @@ -941,6 +971,16 @@ } } }, + "Recording — press %@ to stop": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufnahme läuft — zum Beenden %@ drücken" + } + } + } + }, "Recording…": { "localizations": { "de": { @@ -1121,6 +1161,16 @@ } } }, + "Tap the toggle key to start recording and tap it again to insert. Set it to the same combination as the key and a short tap toggles while a hold still works as before. Escape discards a recording. Delete clears a field.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tipp die Start/Stopp-Taste an, um die Aufnahme zu starten, und tipp sie noch mal an, um einzufügen. Gibst du ihr dieselbe Kombination wie der Taste, startet und stoppt ein kurzes Antippen, und Halten funktioniert wie bisher. Esc verwirft eine Aufnahme. Die Löschtaste leert ein Feld." + } + } + } + }, "The Apple Intelligence model is still downloading, so this key pastes the text as dictated for now.": { "localizations": { "de": { @@ -1171,6 +1221,16 @@ } } }, + "Toggle key": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Start/Stopp-Taste" + } + } + } + }, "Transcribing…": { "localizations": { "de": { @@ -1231,6 +1291,16 @@ } } }, + "Without Accessibility, %@ cannot be detected, so the toggle key is off until Accessibility is granted.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ohne Bedienungshilfen wird %@ nicht erkannt, deshalb ist die Start/Stopp-Taste aus, bis die Bedienungshilfen erlaubt sind." + } + } + } + }, "Without Accessibility, %@ cannot be detected, so this key is off until Accessibility is granted.": { "localizations": { "de": { diff --git a/Sources/Pladder/Settings/HotkeyRecorder.swift b/Sources/Pladder/Settings/HotkeyRecorder.swift index be1bab5..0380004 100644 --- a/Sources/Pladder/Settings/HotkeyRecorder.swift +++ b/Sources/Pladder/Settings/HotkeyRecorder.swift @@ -13,7 +13,7 @@ import SwiftUI /// side is ignored and the left-hand key is stored. The chord is committed /// once every key has been let go, so a chord of several keys can be built up /// in any order. -/// Escape on its own cancels. +/// Escape on its own cancels; Delete on its own clears, where that is allowed. struct HotkeyRecorderField: View { @Binding var hotkey: Hotkey /// Called with `true` while recording. The caller suspends the global @@ -23,6 +23,9 @@ struct HotkeyRecorderField: View { /// Carbon, which needs exactly one regular key, so modifier-only chords /// are refused instead of being stored and silently never firing. var requiresRegularKey = false + /// Delete with nothing pending commits an empty chord, which turns the + /// key off. The push-to-talk key can never be empty. + var allowsEmpty = false /// The shortcuts macOS owns, so a chord that collides with one can be /// warned about while it is being pressed rather than after it is stored. var systemShortcuts: Set = [] @@ -37,6 +40,7 @@ struct HotkeyRecorderField: View { } else { recorder.begin( requiresRegularKey: requiresRegularKey, + allowsEmpty: allowsEmpty, systemShortcuts: systemShortcuts ) { hotkey = $0 } } @@ -71,14 +75,18 @@ struct HotkeyRecorderField: View { return recorder.pending?.displayName ?? String(localized: "Press keys…") } - /// Four whole sentences rather than fragments glued together: a - /// translation cannot be assembled from clauses. + /// Whole sentences rather than fragments glued together: a translation + /// cannot be assembled from clauses. private var help: String { switch (recorder.isRecording, requiresRegularKey) { case (true, false): - String(localized: "Press the key or combination to use. Escape cancels.") + allowsEmpty + ? String(localized: "Press the key or combination to use. Escape cancels, Delete clears.") + : String(localized: "Press the key or combination to use. Escape cancels.") case (true, true): - String(localized: "Press the key or combination to use. Escape cancels. Without Accessibility the key must include a regular key, for example Control+Shift+D.") + allowsEmpty + ? String(localized: "Press the key or combination to use. Escape cancels, Delete clears. Without Accessibility the key must include a regular key, for example Control+Shift+D.") + : String(localized: "Press the key or combination to use. Escape cancels. Without Accessibility the key must include a regular key, for example Control+Shift+D.") case (false, false): String(localized: "Click, then press the key or combination to use.") case (false, true): @@ -113,6 +121,7 @@ final class HotkeyRecorder { private var resignObserver: (any NSObjectProtocol)? private var commit: ((Hotkey) -> Void)? private var requiresRegularKey = false + private var allowsEmpty = false private var systemShortcuts: Set = [] /// Shown for the whole session while Secure Event Input is on, and put /// back whenever a chord notice is cleared. @@ -120,12 +129,14 @@ final class HotkeyRecorder { func begin( requiresRegularKey: Bool = false, + allowsEmpty: Bool = false, systemShortcuts: Set = [], commit: @escaping (Hotkey) -> Void ) { cancel() self.commit = commit self.requiresRegularKey = requiresRegularKey + self.allowsEmpty = allowsEmpty self.systemShortcuts = systemShortcuts // A warning, not a refusal: the recorder reads the settings window's // own key events, which secure input does not gate, so recording @@ -182,6 +193,12 @@ final class HotkeyRecorder { end() return true } + if allowsEmpty, Int(key.keyCode) == kVK_Delete, heldModifiers.isEmpty, pending == nil { + let commit = self.commit + end() + commit?(Hotkey(keyCodes: [])) + return true + } heldKeys.insert(key.keyCode) keysChanged() return true @@ -239,6 +256,7 @@ final class HotkeyRecorder { resignObserver = nil commit = nil requiresRegularKey = false + allowsEmpty = false systemShortcuts = [] secureInputNotice = nil refusedModifiers = nil diff --git a/Sources/Pladder/Settings/SettingsView.swift b/Sources/Pladder/Settings/SettingsView.swift index 64b0f8f..50bdf7a 100644 --- a/Sources/Pladder/Settings/SettingsView.swift +++ b/Sources/Pladder/Settings/SettingsView.swift @@ -93,10 +93,27 @@ private struct GeneralSettingsView: View { .foregroundStyle(.orange) .fixedSize(horizontal: false, vertical: true) } + LabeledContent("Toggle key") { + HotkeyRecorderField( + hotkey: $model.settings.toggleHotkey, + onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 }, + requiresRegularKey: model.hotkeyNeedsRegularKey, + allowsEmpty: true, + systemShortcuts: model.systemShortcuts + ) + } + if let warning = toggleKeyWarning { + Label(warning, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } LabeledContent("Send key") { HotkeyRecorderField( hotkey: $model.settings.submitKey, - onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 } + onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 }, + // Empty has always meant off; now the field can say so. + allowsEmpty: true ) } if let warning = submitKeyWarning { @@ -113,6 +130,7 @@ private struct GeneralSettingsView: View { hotkey: $model.settings.polishHotkey, onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 }, requiresRegularKey: model.hotkeyNeedsRegularKey, + allowsEmpty: true, systemShortcuts: model.systemShortcuts ) } @@ -129,6 +147,7 @@ private struct GeneralSettingsView: View { // another key can add its own line. VStack(alignment: .leading, spacing: 4) { FootnoteText("Hold to record, release to insert. Press the send key while recording and Return is pressed after the text. Click a field and press any key combination to assign it.") + FootnoteText("Tap the toggle key to start recording and tap it again to insert. Set it to the same combination as the key and a short tap toggles while a hold still works as before. Escape discards a recording. Delete clears a field.") FootnoteText("Hold the Dictate and polish key instead and Apple Intelligence cleans up the transcript on this Mac before it is pasted: self-corrections, spoken punctuation and numbers, lists. That takes a second or two.") } } @@ -265,6 +284,23 @@ private struct GeneralSettingsView: View { return String(localized: "Without a modifier, \(hotkey.displayName) can no longer be typed in other apps while Pladder is running.") } + /// Nothing for an empty toggle key, or one equal to the key: that one is + /// hybrid, and whatever stands in for the key stands in for it too. + /// Otherwise a chord Carbon cannot register listens for nothing without + /// Accessibility, and has no stand-in of its own, since the only candidate + /// is the push-to-talk stand-in; and macOS may own the chord. + private var toggleKeyWarning: String? { + let toggle = model.settings.toggleHotkey + guard !toggle.isEmpty, toggle.canonical != model.settings.hotkey.canonical else { return nil } + if !model.accessibilityTrusted && !toggle.canBeRegisteredWithoutAccessibility { + return String(localized: "Without Accessibility, \(toggle.displayName) cannot be detected, so the toggle key is off until Accessibility is granted.") + } + if let owner = model.systemShortcutConflict(for: toggle) { + return conflictWarning(owner: owner, chord: toggle) + } + return nil + } + /// An enabled macOS shortcut is dispatched by the window server before /// either monitor sees the keys, so the chord may simply never arrive. private func conflictWarning(owner: Hotkey, chord: Hotkey) -> String { @@ -296,6 +332,9 @@ private struct GeneralSettingsView: View { if polish == (model.standInHotkey ?? model.settings.hotkey) { return String(localized: "\(polish.displayName) is also the push-to-talk key, so it never polishes. Record a different combination.") } + if polish.canonical == model.settings.toggleHotkey.canonical { + return String(localized: "\(polish.displayName) is also the toggle key, so it never polishes. Record a different combination.") + } if let reason = model.polishAvailability.polishKeyText { return reason } diff --git a/Sources/PladderCore/DictationCoordinator.swift b/Sources/PladderCore/DictationCoordinator.swift index 26f34f9..bfe5c7e 100644 --- a/Sources/PladderCore/DictationCoordinator.swift +++ b/Sources/PladderCore/DictationCoordinator.swift @@ -6,6 +6,14 @@ import Observation /// Flow: hotkey pressed -> capture starts -> hotkey released -> capture stops -> /// engine transcribes -> pipeline processes -> (polish hotkey: model refines ->) /// output inserts -> idle. +/// +/// A press of the toggle chord, or a tap of a hybrid chord shorter than +/// `holdThreshold`, leaves the recording running with `isLatched` set until +/// the next press of any chord, Escape, or the cap. `HotkeyGestureTracker` +/// decides which; the coordinator only carries out what it says. +/// +/// Escape is the cancel key while a recording is on, and only then: the +/// monitor is told at the start and the end of every recording. @MainActor @Observable public final class DictationCoordinator { @@ -23,6 +31,11 @@ public final class DictationCoordinator { /// can keep the pill up across the release. public private(set) var willPolish = false + /// True while a recording continues after its chord was let go: a toggle + /// press or a hybrid tap. The overlay draws it differently so the user + /// knows the microphone is still on. + public private(set) var isLatched = false + /// The transcribe → process → insert work for the most recent release. /// Exposed so callers (and tests) can await completion of a cycle. public private(set) var inFlight: Task? @@ -58,7 +71,8 @@ public final class DictationCoordinator { /// which is what happens when Accessibility is granted. Nil means "listen /// for the stored chord". Applies to the dictate chord only; the polish /// chord has no stand-in and is simply not registered when Carbon cannot - /// take it. + /// take it. A toggle chord equal to the stored chord follows the + /// override, so a stood-in hybrid key stays hybrid. public var hotkeyOverride: Hotkey? { didSet { guard hotkeyOverride != oldValue else { return } @@ -85,6 +99,17 @@ public final class DictationCoordinator { /// real, for example while a secure password field has focus and global /// monitors receive nothing, and this keeps the microphone from staying on. public var maximumDuration: Duration = .seconds(600) + /// A hybrid chord released sooner than this after its press latches the + /// recording; a later release stops it. Handy and VoiceInk use 300 to + /// 500 ms. + public var holdThreshold: Duration = .milliseconds(400) + /// A press this soon after a release of the same chord is the keyboard + /// bouncing, not the user. See `HotkeyGestureTracker`. + public var bounceWindow: Duration = .milliseconds(50) + /// Seeds the gesture tracker: every stopping release waits `bounceWindow` + /// first. A real keyboard turns this on by bouncing once; tests turn it + /// on here. + public var deferReleases = false /// How long an error stays on screen before returning to idle. public var errorDisplayDuration: Duration = .seconds(2) /// How long the "press ⌘V" hint stays on screen before returning to idle. @@ -113,6 +138,11 @@ public final class DictationCoordinator { /// Only one of the two is ever on screen, so they share a task. private var transientResetTask: Task? private var maxDurationTask: Task? + /// Hold, toggle or hybrid: what each chord's press and release mean. + /// Rebuilt with the monitor, never per dictation. + private var gesture = HotkeyGestureTracker(modes: [:]) + /// Waits out the bounce window of a deferred release. + private var settleTask: Task? /// Wall-clock time of each stage between the hotkey release and the paste. public struct CycleTiming: Sendable, Equatable { @@ -144,6 +174,15 @@ public final class DictationCoordinator { case recordingStopped case inserted(Transcript, CycleTiming) case failed(DictationFailure) + /// Escape ended the recording; nothing is transcribed. The app plays + /// the stop sound so the user hears the microphone go off, unlike an + /// interrupted press, which is silent by design. + case recordingDiscarded + /// The gesture tracker has seen a same-chord press inside the bounce + /// window, and from now on holds every stopping release for + /// `bounceWindow` first. Emitted once, so a felt delay has an + /// explanation in the log. + case keyboardBounceObserved } public init( @@ -186,6 +225,7 @@ public final class DictationCoordinator { levelTask?.cancel() transientResetTask?.cancel() maxDurationTask?.cancel() + settleTask?.cancel() if state.isRecording { Task { await cancelRecording() } } @@ -229,10 +269,12 @@ public final class DictationCoordinator { // so this runs only on real changes. pipeline = makePipeline(settings) if old.hotkey != settings.hotkey || old.submitKey != settings.submitKey - || old.polishHotkey != settings.polishHotkey { + || old.polishHotkey != settings.polishHotkey + || old.toggleHotkey != settings.toggleHotkey { // The old key's release will never arrive on the new stream. // The submit key counts too: the restarted monitor would never - // deliver the pending release for the old configuration. + // deliver the pending release for the old configuration. So does + // the toggle key, which may also turn the key hybrid or back. if state.isRecording { Task { await cancelRecording() } } @@ -396,33 +438,119 @@ public final class DictationCoordinator { private func startHotkey() { hotkeyTask?.cancel() hotkeyMonitor.stop() + startGesture() var chords: [HotkeyRole: Hotkey] = [.dictate: hotkeyOverride ?? settings.hotkey] - // A polish chord that is the dictate chord would fire both trackers - // at once; the settings row says why it does nothing. - if !settings.polishHotkey.isEmpty, settings.polishHotkey != chords[.dictate] { + if let toggle = separateToggleChord { chords[.toggle] = toggle } + // A polish chord that is already another role's chord would fire both + // trackers at once; the settings row says why it does nothing. + let polish = settings.polishHotkey.canonical + if !polish.isEmpty, !chords.values.contains(where: { $0.canonical == polish }) { chords[.polish] = settings.polishHotkey } let stream = hotkeyMonitor.start(chords: chords, submitKey: settings.submitKey) hotkeyTask = Task { [weak self] in for await tagged in stream { guard let self else { return } + // When the key moved, not when this loop got to it: a press + // waits here for the microphone to start. + let at = tagged.instant ?? .now + // Only the chord that started the recording may end it, and + // the gesture tracker is what knows which one that is: with + // nested chords the other tracker reports the hand-over as its + // own release. switch tagged.event { - case .pressed: await self.hotkeyPressed(role: tagged.role) - // Only the chord that started the recording may end it: with - // nested chords the other tracker reports the hand-over as - // its own release. + case .pressed: + let wasDeferring = self.gesture.deferReleases + let outcome = self.gesture.pressed(tagged.role, at: at) + if self.gesture.deferReleases, !wasDeferring { self.onEvent(.keyboardBounceObserved) } + await self.act(outcome) case .released(let submit): - if tagged.role == self.cycleRole { self.hotkeyReleased(submit: submit) } + await self.act(self.gesture.released(tagged.role, submit: submit, at: at)) // Another key went down right after the chord: the user typed // Cmd+C, not a dictation. Drop the audio without transcribing, // without a stop sound and without a timing line. case .cancelled: - if tagged.role == self.cycleRole { await self.cancelRecording() } + await self.act(self.gesture.interrupted(tagged.role)) + // Escape while a recording is on: drop it, and say so. + case .escape: + await self.escapePressed() } } } } + /// A toggle chord equal to the push-to-talk chord, stored or standing in + /// for it, is not a second chord but the hybrid mode of the first: two + /// roles cannot share a chord, and a stand-in that replaces a hybrid + /// chord keeps it hybrid. + private var toggleIsHybrid: Bool { + let toggle = settings.toggleHotkey.canonical + guard !toggle.isEmpty else { return false } + return toggle == settings.hotkey.canonical || toggle == hotkeyOverride?.canonical + } + + /// The toggle chord when it is a chord of its own; nil when it is off or + /// hybrid. + private var separateToggleChord: Hotkey? { + settings.toggleHotkey.isEmpty || toggleIsHybrid ? nil : settings.toggleHotkey + } + + /// A fresh tracker for a fresh monitor session. A bounce seen before is + /// remembered: the keyboard has not changed because the monitor did. + private func startGesture() { + settleTask?.cancel() + settleTask = nil + isLatched = false + gesture = HotkeyGestureTracker( + modes: [.dictate: toggleIsHybrid ? .hybrid : .hold, .polish: .hold, .toggle: .toggle], + holdThreshold: holdThreshold, + bounceWindow: bounceWindow, + deferReleases: deferReleases || gesture.deferReleases + ) + } + + /// Carries out what the gesture tracker decided. + private func act(_ outcome: HotkeyGestureTracker.Outcome) async { + if let settle = outcome.settle { armSettle(settle) } + switch outcome.action { + case .start(let role): + await hotkeyPressed(role: role) + // A press the state machine refused (engine loading, a cycle in + // flight, a microphone that failed) must not leave the tracker + // holding or latching a recording that never began. + if !state.isRecording { gesture.reset() } + case .stop(let submit): + hotkeyReleased(submit: submit) + case .discard: + await cancelRecording() + case nil: + break + } + isLatched = gesture.isLatched && state.isRecording + } + + private func armSettle(_ settle: HotkeyGestureTracker.Settle) { + settleTask?.cancel() + settleTask = Task { [weak self] in + try? await Task.sleep(for: settle.after) + guard let self, !Task.isCancelled else { return } + // A stale token, one a bounce overtook, is ignored by the tracker. + await self.act(self.gesture.timerFired(token: settle.token)) + } + } + + /// The recording ended, however: the gesture starts over and Escape is + /// the system's again. Synchronous, and the monitor call only flips a + /// flag or queues work on the main thread, so on the release path this + /// costs nothing before `recordingStopped`. + private func endGesture() { + settleTask?.cancel() + settleTask = nil + gesture.reset() + isLatched = false + hotkeyMonitor.setCancelKeyEnabled(false) + } + /// Public so tests and a menu item can drive the state machine directly. /// `role` is the chord that was pressed; it decides what the dictation /// goes through at release. @@ -447,6 +575,8 @@ public final class DictationCoordinator { // Flip state before the await so the overlay reacts on key-down and a // second concurrent press cannot start capture twice. state = .recording(level: 0) + // Escape cancels from here on, and while the microphone comes up. + hotkeyMonitor.setCancelKeyEnabled(true) do { let levels = try await capture.start() guard state.isRecording else { @@ -501,6 +631,7 @@ public final class DictationCoordinator { } } catch { willPolish = false + endGesture() fail(.microphone(detail: error.localizedDescription)) } } @@ -510,6 +641,9 @@ public final class DictationCoordinator { /// With `submit`, Return follows the pasted text. public func hotkeyReleased(submit: Bool = false) { guard state.isRecording else { return } + // Whatever ended it — the chord, a toggle press, the cap — a latched + // recording is over and the next press starts a new one. + endGesture() levelTask?.cancel() maxDurationTask?.cancel() feedTask?.cancel() @@ -635,6 +769,7 @@ public final class DictationCoordinator { /// Cancel an in-progress recording without transcribing. public func cancelRecording() async { guard state.isRecording else { return } + endGesture() levelTask?.cancel() maxDurationTask?.cancel() stopWarmupLoop() @@ -657,6 +792,15 @@ public final class DictationCoordinator { drainPendingUnloads() } + /// Escape while recording: drop the audio without transcribing and say + /// so, before the microphone has finished stopping, so the stop sound is + /// not late. Public so tests can drive it. + public func escapePressed() async { + guard state.isRecording else { return } + onEvent(.recordingDiscarded) + await cancelRecording() + } + /// The text is on the clipboard but nothing pasted it, so say so for a /// moment before going idle. Not a busy state: a press cancels the hint /// and starts the next dictation. diff --git a/Sources/PladderCore/HotkeyChordSet.swift b/Sources/PladderCore/HotkeyChordSet.swift index a9d5817..1ac7f97 100644 --- a/Sources/PladderCore/HotkeyChordSet.swift +++ b/Sources/PladderCore/HotkeyChordSet.swift @@ -16,6 +16,16 @@ import Foundation /// `.released` (after it), and the second `.pressed`. The coordinator only /// lets the chord that started a recording end it, so the hand-over's /// release cannot cut the second recording short. +/// +/// **The cancel key.** While `cancelKeyEnabled`, a plain Escape is reported +/// as `.escape` and swallowed, and so are its repeats and its key-up, even +/// once the cancel key has been turned off again: an app must never see a +/// key-up without its key-down. It is checked before the trackers see the +/// key, so the interruption rule never turns it into a `.cancelled` as well. +/// "Plain" allows the modifiers of an engaged chord, so Escape while +/// Option+Space is still held counts, and nothing else: Cmd+Option+Escape is +/// Force Quit and passes through. A chord or send key that contains Escape +/// keeps it as its own key. public struct HotkeyChordSet: Sendable, Equatable { public struct Outcome: Sendable, Equatable { public var events: [HotkeyMonitorEvent] @@ -33,6 +43,12 @@ public struct HotkeyChordSet: Sendable, Equatable { private let roles: [HotkeyRole] private var trackers: [HotkeyChordTracker] + /// Set by the monitor from `setCancelKeyEnabled`; kept across `reset()`. + public var cancelKeyEnabled = false + /// From a swallowed Escape key-down until its key-up. + private var isSwallowingCancelKey = false + private static let cancelKey: UInt16 = 0x35 // kVK_Escape + /// Empty chords are dropped: they never fire. public init( chords: [HotkeyRole: Hotkey], @@ -52,13 +68,36 @@ public struct HotkeyChordSet: Sendable, Equatable { modifiers: Set, at instant: ContinuousClock.Instant = .now ) -> Outcome { - fanOut { $0.keyDown(key, isRepeat: isRepeat, modifiers: modifiers, at: instant) } + if key == Self.cancelKey, let outcome = cancelKeyDown(isRepeat: isRepeat, modifiers: modifiers) { + return outcome + } + return fanOut { $0.keyDown(key, isRepeat: isRepeat, modifiers: modifiers, at: instant) } } public mutating func keyUp( _ key: UInt16, modifiers: Set, at instant: ContinuousClock.Instant = .now ) -> Outcome { - fanOut { $0.keyUp(key, modifiers: modifiers, at: instant) } + if key == Self.cancelKey, isSwallowingCancelKey { + isSwallowingCancelKey = false + return Outcome(swallow: true) + } + return fanOut { $0.keyUp(key, modifiers: modifiers, at: instant) } + } + + /// Nil when this Escape is not the cancel key and goes to the trackers + /// like any other key. + private mutating func cancelKeyDown(isRepeat: Bool, modifiers: Set) -> Outcome? { + if isRepeat { return isSwallowingCancelKey ? Outcome(swallow: true) : nil } + guard cancelKeyEnabled else { return nil } + let ownedByAChord = trackers.contains { + $0.hotkey.keyCodes.contains(Self.cancelKey) || $0.submitKey.keyCodes.contains(Self.cancelKey) + } + guard !ownedByAChord else { return nil } + let allowed = Hotkey.collapsingSides( + Set(trackers.filter(\.isEngaged).flatMap(\.hotkey.modifierKeyCodes))) + guard Hotkey.collapsingSides(modifiers).isSubset(of: allowed) else { return nil } + isSwallowingCancelKey = true + return Outcome(events: [HotkeyMonitorEvent(role: .dictate, event: .escape)], swallow: true) } public mutating func flagsChanged( @@ -69,6 +108,7 @@ public struct HotkeyChordSet: Sendable, Equatable { /// Every engaged chord is released; events were lost, so none says submit. public mutating func reset() -> [HotkeyMonitorEvent] { + isSwallowingCancelKey = false var events: [HotkeyMonitorEvent] = [] for index in trackers.indices { if let event = trackers[index].reset() { diff --git a/Sources/PladderCore/HotkeyGestureTracker.swift b/Sources/PladderCore/HotkeyGestureTracker.swift new file mode 100644 index 0000000..7debf4d --- /dev/null +++ b/Sources/PladderCore/HotkeyGestureTracker.swift @@ -0,0 +1,184 @@ +import Foundation + +/// Turns role-tagged presses and releases into what the recording should do: +/// start it, stop it, or drop it. Pure value type, no I/O and no clock of its +/// own: every event carries its instant, and the one timer it needs is asked +/// for through `Outcome.settle` and answered with `timerFired(token:)`. +/// +/// Each role has a mode: +/// - `hold` stops at release: push-to-talk. +/// - `toggle` latches at release, however long the press: the recording +/// carries on until the next press of any chord. +/// - `hybrid` is both on one chord: a release sooner than `holdThreshold` +/// after the press latches, a later one stops. A tap starts a long +/// dictation, a hold is push-to-talk as before. +/// +/// A role without a mode is `hold`. +/// +/// **Bounce.** Some Bluetooth keyboards report a held key as released and +/// pressed again a few milliseconds apart. A press of a role within +/// `bounceWindow` of that role's last release is such a bounce: it never +/// starts, stops or latches anything. The first one seen turns on +/// `deferReleases`, from then on a release that would stop the recording +/// waits `bounceWindow` first (`settling`), and a bounce inside that wait +/// resumes the hold as if the release never happened. Until a bounce is +/// seen, nothing waits: a healthy keyboard never pays for the protection, +/// and a bouncing one loses one hold early and is then protected. A latch +/// is never deferred, because nothing waits on it. +/// +/// No separate press debounce: both monitors already report strictly +/// alternating presses and releases per chord, so the only press that can +/// follow another closely is one that follows a release closely, and that is +/// the bounce rule. +public struct HotkeyGestureTracker: Sendable { + public enum Mode: Sendable, Equatable { + case hold, toggle, hybrid + } + + public enum Action: Sendable, Equatable { + /// Start a recording for this role. + case start(HotkeyRole) + /// Stop the recording and transcribe it. + case stop(submit: Bool) + /// The press was interrupted by a shortcut: drop the recording. + case discard + } + + /// A request for `timerFired(token:)` after `after`. + public struct Settle: Sendable, Equatable { + public var token: UInt64 + public var after: Duration + + public init(token: UInt64, after: Duration) { + self.token = token + self.after = after + } + } + + public struct Outcome: Sendable, Equatable { + public var action: Action? + public var settle: Settle? + + public init(action: Action? = nil, settle: Settle? = nil) { + self.action = action + self.settle = settle + } + } + + private enum Phase: Sendable, Equatable { + case idle + /// The chord is down and the recording running. + case held(HotkeyRole, since: ContinuousClock.Instant, submit: Bool) + /// The chord was let go and the recording carries on. + case latched(HotkeyRole) + /// The chord was let go and the stop waits out the bounce window. + case settling(HotkeyRole, since: ContinuousClock.Instant, submit: Bool, token: UInt64) + } + + public let modes: [HotkeyRole: Mode] + public let holdThreshold: Duration + public let bounceWindow: Duration + /// True once a bounce has been seen; a stopping release then waits + /// `bounceWindow` before it stops. + public private(set) var deferReleases: Bool + + private var phase: Phase = .idle + private var lastRelease: (role: HotkeyRole, at: ContinuousClock.Instant)? + private var nextToken: UInt64 = 0 + + public init( + modes: [HotkeyRole: Mode], + holdThreshold: Duration = .milliseconds(400), + bounceWindow: Duration = .milliseconds(50), + deferReleases: Bool = false + ) { + self.modes = modes + self.holdThreshold = holdThreshold + self.bounceWindow = bounceWindow + self.deferReleases = deferReleases + } + + /// True while a recording carries on after its chord was let go. + public var isLatched: Bool { + if case .latched = phase { return true } + return false + } + + public mutating func pressed(_ role: HotkeyRole, at instant: ContinuousClock.Instant) -> Outcome { + if let last = lastRelease, last.role == role, instant - last.at <= bounceWindow { + deferReleases = true + if case .settling(let held, let since, let submit, _) = phase, held == role { + // The release was the keyboard, not the user: carry on as if + // the key had stayed down. The pending settle is now stale. + phase = .held(role, since: since, submit: submit) + } + return Outcome() + } + switch phase { + case .idle: + phase = .held(role, since: instant, submit: false) + return Outcome(action: .start(role)) + case .latched: + // Any chord ends a latched recording. Its release arrives in idle + // and is ignored. + phase = .idle + return Outcome(action: .stop(submit: false)) + case .held, .settling: + // A second chord while one is held: its own tracker has already + // released or interrupted the first, and that event is what acts. + return Outcome() + } + } + + public mutating func released( + _ role: HotkeyRole, submit: Bool, at instant: ContinuousClock.Instant + ) -> Outcome { + lastRelease = (role, instant) + guard case .held(let held, let since, let wasSubmit) = phase, held == role else { + return Outcome() + } + // The chord tracker clears its send-key latch on every release, so a + // hold resumed after a bounce would otherwise forget it. + let submit = submit || wasSubmit + let latches: Bool = switch modes[role] ?? .hold { + case .hold: false + case .toggle: true + case .hybrid: instant - since < holdThreshold + } + if latches { + phase = .latched(role) + return Outcome() + } + guard deferReleases else { + phase = .idle + return Outcome(action: .stop(submit: submit)) + } + nextToken &+= 1 + phase = .settling(role, since: since, submit: submit, token: nextToken) + return Outcome(settle: Settle(token: nextToken, after: bounceWindow)) + } + + /// The press was interrupted by another key. Only the chord that is held + /// can be; with nested chords the other one's interruption is the hand-over. + public mutating func interrupted(_ role: HotkeyRole) -> Outcome { + guard case .held(let held, _, _) = phase, held == role else { return Outcome() } + phase = .idle + return Outcome(action: .discard) + } + + public mutating func timerFired(token: UInt64) -> Outcome { + guard case .settling(_, _, let submit, let pending) = phase, pending == token else { + return Outcome() + } + phase = .idle + return Outcome(action: .stop(submit: submit)) + } + + /// Back to idle, for when the recording ended some other way: the cap, + /// a refused start, a monitor swap. The last release is kept, so a bounce + /// right after a stop is still recognised, and so is `deferReleases`: the + /// keyboard has not changed. + public mutating func reset() { + phase = .idle + } +} diff --git a/Sources/PladderCore/Models/Settings.swift b/Sources/PladderCore/Models/Settings.swift index 59032af..461060a 100644 --- a/Sources/PladderCore/Models/Settings.swift +++ b/Sources/PladderCore/Models/Settings.swift @@ -43,6 +43,10 @@ public struct Settings: Codable, Sendable, Equatable { /// A dictation started with this chord runs through the on-device model /// before it is pasted. Empty, the default, means there is no such chord. public var polishHotkey: Hotkey + /// A chord that starts a recording on one press and ends it on the next. + /// The same chord as `hotkey` makes that key hybrid: a tap latches, a hold + /// stops at release. Empty, the default, turns it off. + public var toggleHotkey: Hotkey /// Processor IDs that are turned off. Absent means enabled. public var disabledProcessors: Set public var dictionary: [DictionaryEntry] @@ -69,6 +73,7 @@ public struct Settings: Codable, Sendable, Equatable { hotkey: Hotkey = .optionSpace, submitKey: Hotkey = .rightOption, polishHotkey: Hotkey = Hotkey(keyCodes: []), + toggleHotkey: Hotkey = Hotkey(keyCodes: []), disabledProcessors: Set = [], dictionary: [DictionaryEntry] = [], appendTrailingSpace: Bool = true, @@ -84,6 +89,7 @@ public struct Settings: Codable, Sendable, Equatable { self.hotkey = hotkey self.submitKey = submitKey self.polishHotkey = polishHotkey + self.toggleHotkey = toggleHotkey self.disabledProcessors = disabledProcessors self.dictionary = dictionary self.appendTrailingSpace = appendTrailingSpace @@ -101,6 +107,7 @@ public struct Settings: Codable, Sendable, Equatable { private enum CodingKeys: String, CodingKey { case engineID, hotkey, submitKey, polishHotkey, disabledProcessors, dictionary, appendTrailingSpace, launchAtLogin, playSounds, appearance case overlayStyle, overlayGlass, overlayAnimationSpeed, muteOutputWhileDictating + case toggleHotkey } public init(from decoder: Decoder) throws { @@ -114,6 +121,8 @@ public struct Settings: Codable, Sendable, Equatable { submitKey = try c.decodeIfPresent(Hotkey.self, forKey: .submitKey) ?? .rightOption // Empty means off, like the submit key. polishHotkey = try c.decodeIfPresent(Hotkey.self, forKey: .polishHotkey) ?? Hotkey(keyCodes: []) + // Like the submit key, empty is meaningful: off. + toggleHotkey = try c.decodeIfPresent(Hotkey.self, forKey: .toggleHotkey) ?? Hotkey(keyCodes: []) disabledProcessors = try c.decodeIfPresent(Set.self, forKey: .disabledProcessors) ?? [] dictionary = try c.decodeIfPresent([DictionaryEntry].self, forKey: .dictionary) ?? [] appendTrailingSpace = try c.decodeIfPresent(Bool.self, forKey: .appendTrailingSpace) ?? true diff --git a/Sources/PladderCore/Protocols/HotkeyMonitor.swift b/Sources/PladderCore/Protocols/HotkeyMonitor.swift index 203f7d1..930f2c9 100644 --- a/Sources/PladderCore/Protocols/HotkeyMonitor.swift +++ b/Sources/PladderCore/Protocols/HotkeyMonitor.swift @@ -1,9 +1,11 @@ import Foundation /// Which chord fired. The coordinator starts a recording for either and -/// decides at release what the dictation goes through. +/// decides at release what the dictation goes through. `toggle` is only a +/// chord of its own when it differs from the dictate chord; equal, it makes +/// the dictate chord hybrid instead (see `HotkeyGestureTracker`). public enum HotkeyRole: String, Sendable, Hashable, CaseIterable, Comparable { - case dictate, polish + case dictate, polish, toggle public static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue } } @@ -12,10 +14,17 @@ public enum HotkeyRole: String, Sendable, Hashable, CaseIterable, Comparable { public struct HotkeyMonitorEvent: Sendable, Equatable { public var role: HotkeyRole public var event: HotkeyEvent + /// When the key moved, stamped by the monitor as the keystroke arrives. + /// The coordinator times holds from this rather than from when it gets + /// round to the event: a press waits for the microphone to start, and a + /// release queued behind it must not look longer than it was. Nil from a + /// source that does not stamp, a test fake say; the reader uses now. + public var instant: ContinuousClock.Instant? - public init(role: HotkeyRole, event: HotkeyEvent) { + public init(role: HotkeyRole, event: HotkeyEvent, instant: ContinuousClock.Instant? = nil) { self.role = role self.event = event + self.instant = instant } } @@ -29,6 +38,12 @@ public protocol HotkeyMonitor: Sendable { /// calling `stop()` ends monitoring. func start(chords: [HotkeyRole: Hotkey], submitKey: Hotkey) -> AsyncStream func stop() + /// While true a plain Escape is the cancel key: reported as `.escape` and + /// kept from other apps. The coordinator turns it on when a recording + /// starts and off when it ends, so Escape is never taken system wide + /// between recordings. Must return at once: it is called on the release + /// path. `start` and `stop` turn it off. + func setCancelKeyEnabled(_ enabled: Bool) } public enum HotkeyEvent: Sendable, Equatable { @@ -42,6 +57,10 @@ public enum HotkeyEvent: Sendable, Equatable { /// recording is dropped without transcribing. Only the event tap can /// produce this; Carbon never sees the interrupting key. case cancelled + /// The cancel key went down while enabled (`setCancelKeyEnabled`): drop + /// the recording. Belongs to no chord, so no chord tracker produces it; + /// the monitors tag it `.dictate` and the coordinator ignores the role. + case escape } /// The push-to-talk chord: one or more physical keys that must be held together. diff --git a/Sources/PladderSystem/CarbonHotkeyMonitor.swift b/Sources/PladderSystem/CarbonHotkeyMonitor.swift index 3042823..823c250 100644 --- a/Sources/PladderSystem/CarbonHotkeyMonitor.swift +++ b/Sources/PladderSystem/CarbonHotkeyMonitor.swift @@ -23,6 +23,12 @@ import os /// - the send key is not supported: it would need a second observer of the /// keyboard, and posting the Return it asks for needs Accessibility anyway. /// `submitKey` is therefore ignored and every release says `submit: false`. +/// - the cancel key has to be a hot key of its own, and a hot key is taken +/// from every app, so Escape is registered when a recording starts and +/// unregistered when it ends; that is why the monitor has to be told when +/// one is on (`setCancelKeyEnabled`). Its mask is empty, so only a bare +/// Escape cancels here, where the tap also accepts Escape with the chord's +/// own modifiers still held. /// /// Each chord is its own hot key; a chord that cannot be registered is /// skipped on its own, the others still work. @@ -47,6 +53,11 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { /// after a `stop()`; this makes each role's events strictly /// alternating. var pressed: Set = [] + /// Escape, registered only while a recording is on. + var cancelKey: CancelKey? + /// What the coordinator last asked for. Remembered so an enable that + /// lands before the session's registration is honoured by it. + var cancelKeyWanted = false /// Bumped by every `start`/`stop` so a registration that was scheduled /// onto the main thread and then superseded quietly undoes itself, and /// so events for an old hot key are ignored. @@ -141,11 +152,29 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { state.pressed = [] return result } + let cancelKey: CancelKey? = lock.withLock { + defer { state.cancelKey = nil; state.cancelKeyWanted = false } + return state.cancelKey + } // `finish()` may run `onTermination` synchronously; the lock is // released and the registration is already detached, so that is a // no-op. continuation?.finish() Self.tearDown(registration) + Self.tearDown(cancelKey) + } + + /// Never registers or unregisters inline: the coordinator calls this on + /// the release path, and a Carbon call there would wait on the window + /// server. The main queue does it straight after. + public func setCancelKeyEnabled(_ enabled: Bool) { + let generation: UInt32 = lock.withLock { + state.cancelKeyWanted = enabled + return state.generation + } + DispatchQueue.main.async { [weak self] in + self?.syncCancelKey(generation: generation) + } } // MARK: Registration @@ -158,6 +187,11 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { var handler: EventHandlerRef? } + /// Escape's hot key. Only ever created and destroyed on the main thread. + private struct CancelKey: @unchecked Sendable { + var hotKey: EventHotKeyRef + } + /// One chord to register: which role it is, the ID its events carry, and /// Carbon's spelling of it. private struct HotKeyEntry: Sendable { @@ -174,6 +208,11 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { return (generation &<< 3) | index } + /// The cancel key takes the last of the eight slots, clear of the roles. + private static func cancelKeyID(generation: UInt32) -> UInt32 { + (generation &<< 3) | 7 + } + /// Main thread only. private func register(_ entries: [HotKeyEntry], generation: UInt32) { guard lock.withLock({ state.generation == generation && state.registration == nil }) @@ -227,6 +266,48 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { return false } if stale { Self.tearDown(registration) } + // A recording that started before the handler was in place. + syncCancelKey(generation: generation) + } + + /// Main thread only. Brings Escape's registration in line with what the + /// coordinator last asked for. Needs the session's handler, which only + /// exists once a chord registered; without one no recording can start + /// from this monitor anyway. + private func syncCancelKey(generation: UInt32) { + let (wanted, current, hasHandler) = lock.withLock { () -> (Bool, CancelKey?, Bool) in + guard state.generation == generation else { return (false, nil, false) } + return (state.cancelKeyWanted, state.cancelKey, state.registration != nil) + } + if !wanted, let current { + let taken: Bool = lock.withLock { + guard state.generation == generation, state.cancelKey != nil else { return false } + state.cancelKey = nil + return true + } + if taken { UnregisterEventHotKey(current.hotKey) } + return + } + guard wanted, current == nil, hasHandler else { return } + var hotKey: EventHotKeyRef? + let id = EventHotKeyID(signature: Self.signature, id: Self.cancelKeyID(generation: generation)) + let registered = RegisterEventHotKey( + UInt32(kVK_Escape), 0, id, GetApplicationEventTarget(), 0, &hotKey) + guard registered == noErr, let hotKey else { + if registered == OSStatus(eventHotKeyExistsErr) { + Self.log.error("Escape is registered by another app; it cannot cancel a recording") + } else { + Self.log.error("Could not register Escape (\(registered, privacy: .public))") + } + return + } + let stale: Bool = lock.withLock { + guard state.generation == generation, state.cancelKeyWanted, state.cancelKey == nil + else { return true } + state.cancelKey = CancelKey(hotKey: hotKey) + return false + } + if stale { UnregisterEventHotKey(hotKey) } } private func unregister(generation: UInt32) { @@ -238,6 +319,11 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { Self.tearDown(registration) } + private static func tearDown(_ cancelKey: CancelKey?) { + guard let cancelKey else { return } + onMainThread { UnregisterEventHotKey(cancelKey.hotKey) } + } + private static func tearDown(_ registration: Registration?) { guard let registration, !registration.hotKeys.isEmpty || registration.handler != nil else { return } @@ -266,20 +352,29 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { } let kind = GetEventKind(event) + let now = ContinuousClock.now let (outcome, continuation) = lock.withLock { () -> (HotkeyMonitorEvent?, AsyncStream.Continuation?) in + // Escape while a recording is on. Only its press matters, and + // one that lands after it was let go is ignored. + if id.id == Self.cancelKeyID(generation: state.generation) { + guard Int(kind) == kEventHotKeyPressed, state.cancelKey != nil else { return (nil, nil) } + return (HotkeyMonitorEvent(role: .dictate, event: .escape, instant: now), state.continuation) + } // An event for a hot key we have already replaced. guard id.id >> 3 == state.generation & (UInt32.max >> 3), let role = state.roles[id.id] else { return (nil, nil) } switch Int(kind) { case kEventHotKeyPressed: guard state.pressed.insert(role).inserted else { return (nil, nil) } - return (HotkeyMonitorEvent(role: role, event: .pressed), state.continuation) + return (HotkeyMonitorEvent(role: role, event: .pressed, instant: now), state.continuation) case kEventHotKeyReleased: guard state.pressed.remove(role) != nil else { return (nil, nil) } // No send key here: posting the Return it asks for needs the // grant this monitor exists to do without. - return (HotkeyMonitorEvent(role: role, event: .released(submit: false)), state.continuation) + return ( + HotkeyMonitorEvent(role: role, event: .released(submit: false), instant: now), + state.continuation) default: return (nil, nil) } diff --git a/Sources/PladderSystem/GlobalHotkeyMonitor.swift b/Sources/PladderSystem/GlobalHotkeyMonitor.swift index d9836fc..e8f9c76 100644 --- a/Sources/PladderSystem/GlobalHotkeyMonitor.swift +++ b/Sources/PladderSystem/GlobalHotkeyMonitor.swift @@ -20,6 +20,12 @@ import PladderCore /// `CGEvent.tapCreate` returns nil and we simply try again every couple of /// seconds, so the hotkey comes alive the moment the user ticks the box. /// +/// Several chords share the tap through `HotkeyChordSet`, which also decides +/// when Escape is the cancel key. Under Secure Event Input the tap sees no +/// key-downs at all, so Escape cannot cancel on the tap then; a modifier-only +/// chord keeps the tap in that state (see `AppModel.refreshPermissions`), and +/// its recording ends by letting go, the next press, or the cap. +/// /// Which keys are down is `HotkeyChordSet`'s business; this class only /// translates events and owns the tap. It is `@unchecked Sendable`: all mutable /// state lives behind `lock`, and the tap is created and torn down on the tap @@ -96,6 +102,12 @@ public final class GlobalHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { removeTap(tap) } + /// A flag flip under the lock, nothing else: the tap reads it on the next + /// key event. + public func setCancelKeyEnabled(_ enabled: Bool) { + lock.withLock { state.chords?.cancelKeyEnabled = enabled } + } + // MARK: Tap /// Tap thread only. @@ -199,7 +211,10 @@ public final class GlobalHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { return (outcome, state.continuation) } - for event in outcome.events { continuation?.yield(event) } + for var event in outcome.events { + event.instant = now + continuation?.yield(event) + } return outcome.swallow } @@ -214,7 +229,11 @@ public final class GlobalHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { return (state.tap, events, state.continuation) } if let tap { CGEvent.tapEnable(tap: tap.port, enable: true) } - for event in events { continuation?.yield(event) } + let now = ContinuousClock.now + for var event in events { + event.instant = now + continuation?.yield(event) + } } // MARK: Helpers diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index ee4a105..36453c0 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -89,6 +89,12 @@ final class FakeHotkey: HotkeyMonitor, @unchecked Sendable { func cancel(_ role: HotkeyRole = .dictate) { continuation?.yield(HotkeyMonitorEvent(role: role, event: .cancelled)) } + /// Any event, for a test that stamps its own instants. + func send(_ event: HotkeyMonitorEvent) { continuation?.yield(event) } + /// Every `setCancelKeyEnabled` call, in order. + private(set) var cancelKeyEnabled: [Bool] = [] + func setCancelKeyEnabled(_ enabled: Bool) { cancelKeyEnabled.append(enabled) } + func escape() { continuation?.yield(HotkeyMonitorEvent(role: .dictate, event: .escape)) } } /// Stands in for the on-device model: records what it was asked, answers @@ -116,22 +122,6 @@ final class FakeRefiner: TranscriptRefiner, @unchecked Sendable { } } -/// Keeps every event the coordinator emits, for assertions about timing. -final class RecordedEvents: @unchecked Sendable { - private let lock = NSLock() - private var _events: [DictationCoordinator.Event] = [] - var events: [DictationCoordinator.Event] { lock.withLock { _events } } - func append(_ e: DictationCoordinator.Event) { lock.withLock { _events.append(e) } } - - /// The timing of the last `inserted` event, if there was one. - var lastTiming: DictationCoordinator.CycleTiming? { - for event in events.reversed() { - if case .inserted(_, let timing) = event { return timing } - } - return nil - } -} - /// Counts the two calls the coordinator makes. The real controller's timing /// is tested on its own; what matters here is that both ends are called, from /// every path that ends a recording. @@ -273,7 +263,7 @@ private func makeCoordinator( hotkeyMonitor: FakeHotkey? = nil, outputMuter: (any OutputMuter)? = nil, refiner: (any TranscriptRefiner)? = nil, - events: RecordedEvents? = nil + events: EventLog? = nil ) -> (DictationCoordinator, FakeOutput, FakeCapture) { let registry = EngineRegistry([ .init(id: EchoEngine.engineID, displayName: "Echo", detail: "") { @@ -366,7 +356,17 @@ func waitUntil(_ timeout: Duration = .seconds(2), _ condition: @MainActor () -> final class EventLog: @unchecked Sendable { private let lock = NSLock() private var _names: [String] = [] + private var _events: [DictationCoordinator.Event] = [] var names: [String] { lock.withLock { _names } } + var events: [DictationCoordinator.Event] { lock.withLock { _events } } + + /// The timing of the last `inserted` event, if there was one. + var lastTiming: DictationCoordinator.CycleTiming? { + for event in events.reversed() { + if case .inserted(_, let timing) = event { return timing } + } + return nil + } func append(_ event: DictationCoordinator.Event) { let name: String @@ -375,8 +375,13 @@ final class EventLog: @unchecked Sendable { case .recordingStopped: name = "recordingStopped" case .inserted: name = "inserted" case .failed: name = "failed" + case .keyboardBounceObserved: name = "keyboardBounceObserved" + case .recordingDiscarded: name = "recordingDiscarded" + } + lock.withLock { + _names.append(name) + _events.append(event) } - lock.withLock { _names.append(name) } } } @@ -1164,7 +1169,7 @@ final class EventLog: @unchecked Sendable { @Test func polishHotkeyRoutesThroughTheRefiner() async { let refiner = FakeRefiner() - let events = RecordedEvents() + let events = EventLog() let hotkey = FakeHotkey() let (c, output, _) = makeCoordinator( engineText: Self.sentence, settings: Self.settings(), @@ -1184,7 +1189,7 @@ final class EventLog: @unchecked Sendable { @Test func normalHotkeyNeverCallsTheRefiner() async { let refiner = FakeRefiner() - let events = RecordedEvents() + let events = EventLog() let hotkey = FakeHotkey() let (c, output, _) = makeCoordinator( engineText: Self.sentence, settings: Self.settings(), @@ -1219,7 +1224,7 @@ final class EventLog: @unchecked Sendable { @Test func shortTranscriptSkipsTheRefiner() async { let refiner = FakeRefiner() - let events = RecordedEvents() + let events = EventLog() let (c, output, _) = makeCoordinator( engineText: "one two three", settings: Self.settings(), refiner: refiner, events: events) c.start() @@ -1248,7 +1253,7 @@ final class EventLog: @unchecked Sendable { @Test func refinerReturningNilPastesThePlainText() async { // What an unavailable, refusing or timed-out model looks like here. let refiner = FakeRefiner(result: nil) - let events = RecordedEvents() + let events = EventLog() let (c, output, _) = makeCoordinator( engineText: Self.sentence, settings: Self.settings(), refiner: refiner, events: events) c.start() @@ -1328,6 +1333,35 @@ final class EventLog: @unchecked Sendable { #expect(Array(fake.lastChords.keys) == [.dictate]) } + @Test func polishChordEqualToTheToggleChordIsNotRegistered() async { + let fake = FakeHotkey() + var settings = Self.settings() + settings.toggleHotkey = Self.polishChord + let (c, _, _) = makeCoordinator(settings: settings, hotkeyMonitor: fake) + c.start() + #expect(await waitUntil { c.state == .idle }) + #expect(fake.lastChords == [.dictate: .optionSpace, .toggle: Self.polishChord]) + } + + @Test func polishKeyIsHeldNotLatchedBesideAToggleKey() async { + let fake = FakeHotkey() + let refiner = FakeRefiner() + var settings = Self.settings() + settings.toggleHotkey = Hotkey(0x3B, 0x3A, 0x31) + let (c, output, _) = makeCoordinator( + engineText: Self.sentence, settings: settings, hotkeyMonitor: fake, refiner: refiner) + c.start() + #expect(await waitUntil { c.state == .idle }) + fake.press(.polish) + #expect(await waitUntil { c.state.isRecording }) + try? await Task.sleep(for: .milliseconds(350)) + // A short press: the toggle key would latch here, the polish key stops. + fake.release(.polish) + #expect(await waitUntil { output.inserted.count == 1 }) + #expect(refiner.calls == [Self.sentence]) + #expect(!c.isLatched) + } + @Test func polishChordChangeRestartsTheMonitor() async { let fake = FakeHotkey() let (c, output, capture) = makeCoordinator(hotkeyMonitor: fake) @@ -1422,6 +1456,422 @@ final class EventLog: @unchecked Sendable { } } +// MARK: - Toggle key + +@MainActor +@Suite struct ToggleHotkeyTests { + /// Control + D: a toggle chord that is not the push-to-talk chord. + private static let controlD = Hotkey(0x3B, 0x02) + + private func hybridSettings() -> Settings { + var settings = Settings(engineID: EchoEngine.engineID) + settings.toggleHotkey = settings.hotkey + return settings + } + + private func separateSettings() -> Settings { + var settings = Settings(engineID: EchoEngine.engineID) + settings.toggleHotkey = Self.controlD + return settings + } + + @Test func aHybridTapLatchesAndTheNextTapInserts() async { + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator(settings: hybridSettings(), hotkeyMonitor: hotkey) + c.holdThreshold = .seconds(2) + c.start() + #expect(await waitUntil { c.state == .idle }) + #expect(hotkey.lastChords == [.dictate: .optionSpace]) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.release() + #expect(await waitUntil { c.isLatched }) + // Past the bounce window, or the press would be taken for a bounce. + try? await Task.sleep(for: .milliseconds(80)) + #expect(c.state.isRecording) + hotkey.press() + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted.count == 1) + #expect(!c.isLatched) + // The closing press's release arrives in idle and does nothing. + hotkey.release() + try? await Task.sleep(for: .milliseconds(30)) + #expect(c.state == .idle) + } + + @Test func aHybridHoldStopsAtRelease() async { + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator(settings: hybridSettings(), hotkeyMonitor: hotkey) + c.holdThreshold = .milliseconds(20) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + try? await Task.sleep(for: .milliseconds(60)) + hotkey.release() + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted.count == 1) + #expect(!c.isLatched) + } + + @Test func theHoldIsTimedByTheEventsOwnInstants() async { + // The release is delivered late, but it happened 100 ms after the + // press: a tap, however long the loop took to get to it. + let hotkey = FakeHotkey() + let (c, _, _) = makeCoordinator(settings: hybridSettings(), hotkeyMonitor: hotkey) + c.start() + #expect(await waitUntil { c.state == .idle }) + let pressed = ContinuousClock.now + hotkey.send(HotkeyMonitorEvent(role: .dictate, event: .pressed, instant: pressed)) + #expect(await waitUntil { c.state.isRecording }) + try? await Task.sleep(for: .milliseconds(500)) + hotkey.send(HotkeyMonitorEvent( + role: .dictate, event: .released(submit: false), instant: pressed + .milliseconds(100))) + #expect(await waitUntil { c.isLatched }) + await c.cancelRecording() + } + + @Test func aPlainToggleChordLatchesWhateverTheHoldLength() async { + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator(settings: separateSettings(), hotkeyMonitor: hotkey) + c.holdThreshold = .milliseconds(20) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press(.toggle) + #expect(await waitUntil { c.state.isRecording }) + try? await Task.sleep(for: .milliseconds(60)) + hotkey.release(.toggle) + #expect(await waitUntil { c.isLatched }) + #expect(c.state.isRecording) + // Past the bounce window, or the press would be taken for a bounce. + try? await Task.sleep(for: .milliseconds(80)) + hotkey.press(.toggle) + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted.count == 1) + } + + @Test func pushToTalkStaysPlainWhenTheChordsDiffer() async { + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator(settings: separateSettings(), hotkeyMonitor: hotkey) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.release() + #expect(await waitUntil { c.inFlight != nil }) + #expect(!c.isLatched) + await c.inFlight?.value + #expect(output.inserted.count == 1) + } + + @Test func eitherChordEndsALatchedRecording() async { + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator(settings: separateSettings(), hotkeyMonitor: hotkey) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press(.toggle) + #expect(await waitUntil { c.state.isRecording }) + hotkey.release(.toggle) + #expect(await waitUntil { c.isLatched }) + hotkey.press() + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted.count == 1) + } + + @Test func aDifferentToggleChordIsASecondChord() async { + let hotkey = FakeHotkey() + let (c, _, _) = makeCoordinator(settings: separateSettings(), hotkeyMonitor: hotkey) + c.start() + #expect(hotkey.lastChords == [.dictate: .optionSpace, .toggle: Self.controlD]) + } + + @Test func anEmptyToggleChordIsNoChord() async { + let hotkey = FakeHotkey() + let (c, _, _) = makeCoordinator(hotkeyMonitor: hotkey) + c.start() + #expect(hotkey.lastChords == [.dictate: .optionSpace]) + } + + @Test func aHybridChordFollowsTheStandIn() async { + var settings = Settings(engineID: EchoEngine.engineID) + settings.hotkey = .rightCommand + settings.toggleHotkey = .rightCommand + let hotkey = FakeHotkey() + let (c, _, _) = makeCoordinator(settings: settings, hotkeyMonitor: hotkey) + c.hotkeyOverride = .optionSpace + c.holdThreshold = .seconds(2) + c.start() + #expect(await waitUntil { c.state == .idle }) + // Not a second chord Carbon could not register: the stand-in is hybrid. + #expect(hotkey.lastChords == [.dictate: .optionSpace]) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.release() + #expect(await waitUntil { c.isLatched }) + await c.cancelRecording() + } + + @Test func theCapFiresInToggleMode() async { + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator(settings: hybridSettings(), hotkeyMonitor: hotkey) + c.holdThreshold = .seconds(2) + c.maximumDuration = .milliseconds(150) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.release() + #expect(await waitUntil { c.isLatched }) + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted.count == 1) + #expect(output.submitted == [false]) + #expect(!c.isLatched) + // The latch went with the recording: the next press starts afresh + // rather than ending a recording that is no longer there. + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + await c.cancelRecording() + } + + @Test func anInterruptedHybridPressStillDiscardsSilently() async { + let hotkey = FakeHotkey() + let events = EventLog() + let (c, output, capture) = makeCoordinator( + settings: hybridSettings(), hotkeyMonitor: hotkey, events: events) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.cancel() + #expect(await waitUntil { c.state == .idle }) + #expect(!c.isLatched) + #expect(await capture.stopCount == 1) + #expect(output.inserted.isEmpty) + #expect(events.names == ["recordingStarted"]) + } + + @Test func toggleKeyChangeWhileRecordingStopsTheMicrophone() async { + let (c, output, capture) = makeCoordinator() + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed() + #expect(c.state.isRecording) + c.settings.toggleHotkey = Self.controlD + #expect(await waitUntil { c.state == .idle }) + #expect(await capture.stopCount == 1) + #expect(output.inserted.isEmpty) + } + + @Test func aRefusedStartDoesNotLatch() async { + let hotkey = FakeHotkey() + let registry = EngineRegistry([ + .init(id: EngineID("flaky"), displayName: "Flaky", detail: "") { FlakyEngine(failures: 1) } + ]) + var settings = Settings(engineID: EngineID("flaky")) + settings.toggleHotkey = settings.hotkey + let capture = FakeCapture() + let c = DictationCoordinator( + settings: settings, registry: registry, + capture: capture, output: FakeOutput(), + hotkeyMonitor: hotkey, makePipeline: { _ in ProcessorPipeline([]) }) + c.start() + #expect(await waitUntil { c.state == .unavailable(.engineFailed(.loadFailed(detail: "boom"))) }) + // A tap while the engine is down: refused, and not remembered as a latch. + hotkey.press() + hotkey.release() + try? await Task.sleep(for: .milliseconds(80)) + #expect(!c.isLatched) + c.reloadEngine() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + await c.cancelRecording() + } + + // MARK: Bounce + + @Test func withDeferralAReleaseStopsAfterTheBounceWindow() async { + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator(hotkeyMonitor: hotkey) + c.deferReleases = true + c.bounceWindow = .milliseconds(30) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.release() + try? await Task.sleep(for: .milliseconds(5)) + #expect(c.state.isRecording) + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted.count == 1) + } + + @Test func aBounceDuringSettleKeepsRecording() async { + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator(hotkeyMonitor: hotkey) + c.deferReleases = true + c.bounceWindow = .milliseconds(30) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + // Release and bounce back inside the window, stamped so the loop's + // own scheduling cannot stretch the gap. + let released = ContinuousClock.now + hotkey.send(HotkeyMonitorEvent(role: .dictate, event: .released(submit: false), instant: released)) + hotkey.send(HotkeyMonitorEvent(role: .dictate, event: .pressed, instant: released + .milliseconds(10))) + try? await Task.sleep(for: .milliseconds(100)) + #expect(c.state.isRecording) + #expect(c.inFlight == nil) + hotkey.release() + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted.count == 1) + } + + @Test func aBounceIsAnnouncedOnce() async { + let hotkey = FakeHotkey() + let events = EventLog() + let (c, _, _) = makeCoordinator(hotkeyMonitor: hotkey, events: events) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + let released = ContinuousClock.now + hotkey.send(HotkeyMonitorEvent(role: .dictate, event: .released(submit: false), instant: released)) + hotkey.send(HotkeyMonitorEvent(role: .dictate, event: .pressed, instant: released + .milliseconds(10))) + hotkey.send(HotkeyMonitorEvent( + role: .dictate, event: .released(submit: false), instant: released + .milliseconds(20))) + hotkey.send(HotkeyMonitorEvent(role: .dictate, event: .pressed, instant: released + .milliseconds(30))) + #expect(await waitUntil { events.names.contains("keyboardBounceObserved") }) + await c.inFlight?.value + #expect(events.names.filter { $0 == "keyboardBounceObserved" }.count == 1) + } +} + +// MARK: - Escape + +@MainActor +@Suite struct EscapeTests { + @Test func escapeDiscardsWithoutPastingAndAnnouncesIt() async { + let hotkey = FakeHotkey() + let events = EventLog() + let (c, output, capture) = makeCoordinator(hotkeyMonitor: hotkey, events: events) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.escape() + #expect(await waitUntil { c.state == .idle }) + await c.inFlight?.value + try? await Task.sleep(for: .milliseconds(20)) + #expect(await capture.stopCount == 1) + #expect(output.inserted.isEmpty) + // No `recordingStopped`, so no timing line; `recordingDiscarded` is + // what plays the stop sound. + #expect(events.names == ["recordingStarted", "recordingDiscarded"]) + #expect(hotkey.cancelKeyEnabled == [true, false]) + } + + @Test func theReleaseAfterEscapeDoesNothing() async { + let hotkey = FakeHotkey() + let (c, output, capture) = makeCoordinator(hotkeyMonitor: hotkey) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.escape() + hotkey.release() + try? await Task.sleep(for: .milliseconds(50)) + #expect(c.state == .idle) + #expect(c.inFlight == nil) + #expect(await capture.stopCount == 1) + #expect(output.inserted.isEmpty) + } + + @Test func escapeWhileIdleDoesNothing() async { + let hotkey = FakeHotkey() + let events = EventLog() + let (c, _, capture) = makeCoordinator(hotkeyMonitor: hotkey, events: events) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.escape() + try? await Task.sleep(for: .milliseconds(30)) + #expect(c.state == .idle) + #expect(events.names.isEmpty) + #expect(await capture.stopCount == 0) + #expect(hotkey.cancelKeyEnabled.isEmpty) + } + + @Test func escapeWhileLatchedDiscards() async { + var settings = Settings(engineID: EchoEngine.engineID) + settings.toggleHotkey = settings.hotkey + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator(settings: settings, hotkeyMonitor: hotkey) + c.holdThreshold = .seconds(2) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.release() + #expect(await waitUntil { c.isLatched }) + hotkey.escape() + #expect(await waitUntil { c.state == .idle }) + #expect(!c.isLatched) + #expect(output.inserted.isEmpty) + // Not latched any more: the next press starts a recording. + try? await Task.sleep(for: .milliseconds(80)) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + await c.cancelRecording() + } + + @Test func escapePressedRestoresTheOutputDevice() async { + var settings = Settings(engineID: EchoEngine.engineID) + settings.muteOutputWhileDictating = true + let muter = FakeOutputMuter() + let (c, _, _) = makeCoordinator(settings: settings, outputMuter: muter) + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed() + await c.escapePressed() + #expect(await waitUntil { muter.endedCount == 1 }) + } + + @Test func theCancelKeyIsOnlyOnWhileRecording() async { + let hotkey = FakeHotkey() + let (c, _, _) = makeCoordinator(hotkeyMonitor: hotkey) + c.maximumDuration = .milliseconds(60) + c.start() + #expect(await waitUntil { c.state == .idle }) + // A dictation. + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.release() + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(hotkey.cancelKeyEnabled == [true, false]) + // An interrupted press, past the bounce window of that release. + try? await Task.sleep(for: .milliseconds(80)) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + hotkey.cancel() + #expect(await waitUntil { c.state == .idle }) + #expect(hotkey.cancelKeyEnabled == [true, false, true, false]) + // The cap. + await c.hotkeyPressed() + #expect(await waitUntil { !c.state.isRecording }) + await c.inFlight?.value + #expect(hotkey.cancelKeyEnabled == [true, false, true, false, true, false]) + } +} + @Suite struct SettingsStoreTests { @Test func roundTrip() throws { let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Tests/PladderCoreTests/HotkeyTests.swift b/Tests/PladderCoreTests/HotkeyTests.swift index f219ab4..19af81b 100644 --- a/Tests/PladderCoreTests/HotkeyTests.swift +++ b/Tests/PladderCoreTests/HotkeyTests.swift @@ -325,6 +325,25 @@ private let keyC: UInt16 = 0x08 let settings = try JSONDecoder().decode(Settings.self, from: Data(json.utf8)) #expect(settings.submitKey.keyCodes.isEmpty) } + + @Test func settingsWithoutToggleHotkeyIsOff() throws { + let json = #"{"engineID":"echo"}"# + let settings = try JSONDecoder().decode(Settings.self, from: Data(json.utf8)) + #expect(settings.toggleHotkey.isEmpty) + } + + @Test func settingsWithEmptyToggleHotkeyStaysEmpty() throws { + let json = #"{"engineID":"echo","toggleHotkey":{"keyCodes":[]}}"# + let settings = try JSONDecoder().decode(Settings.self, from: Data(json.utf8)) + #expect(settings.toggleHotkey.isEmpty) + } + + @Test func toggleHotkeyRoundTrips() throws { + var settings = Settings(engineID: EchoEngine.engineID) + settings.toggleHotkey = .optionSpace + let data = try JSONEncoder().encode(settings) + #expect(try JSONDecoder().decode(Settings.self, from: data).toggleHotkey == .optionSpace) + } } @Suite struct SystemWideRegistrationTests { @@ -555,3 +574,290 @@ private let keyC: UInt16 = 0x08 == .init(event: .released(submit: false))) } } + +/// Instants are passed in; nothing sleeps. +@Suite struct HotkeyGestureTrackerTests { + typealias Tracker = HotkeyGestureTracker + private let t0 = ContinuousClock.now + private func at(_ ms: Int) -> ContinuousClock.Instant { t0 + .milliseconds(ms) } + + private static let hold: [HotkeyRole: Tracker.Mode] = [.dictate: .hold] + private static let hybrid: [HotkeyRole: Tracker.Mode] = [.dictate: .hybrid] + private static let twoChords: [HotkeyRole: Tracker.Mode] = [.dictate: .hold, .toggle: .toggle] + + @Test func holdModeStopsAtRelease() { + var g = Tracker(modes: Self.hold) + #expect(g.pressed(.dictate, at: at(0)) == .init(action: .start(.dictate))) + #expect(g.released(.dictate, submit: false, at: at(100)) == .init(action: .stop(submit: false))) + #expect(!g.isLatched) + } + + @Test func submitIsCarriedThroughAHold() { + var g = Tracker(modes: Self.hold) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.released(.dictate, submit: true, at: at(2_000)) == .init(action: .stop(submit: true))) + } + + @Test func aRoleWithoutAModeHolds() { + var g = Tracker(modes: [:]) + _ = g.pressed(.polish, at: at(0)) + #expect(g.released(.polish, submit: false, at: at(100)).action == .stop(submit: false)) + } + + @Test func toggleModeLatchesAndStopsOnTheNextPress() { + var g = Tracker(modes: Self.twoChords) + #expect(g.pressed(.toggle, at: at(0)) == .init(action: .start(.toggle))) + // However long the press, a toggle chord latches. + #expect(g.released(.toggle, submit: false, at: at(2_000)) == .init()) + #expect(g.isLatched) + #expect(g.pressed(.toggle, at: at(9_000)) == .init(action: .stop(submit: false))) + #expect(!g.isLatched) + } + + @Test func hybridTapLatches() { + var g = Tracker(modes: Self.hybrid) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.released(.dictate, submit: false, at: at(200)) == .init()) + #expect(g.isLatched) + #expect(g.pressed(.dictate, at: at(5_000)) == .init(action: .stop(submit: false))) + } + + @Test func hybridHoldStops() { + var g = Tracker(modes: Self.hybrid) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.released(.dictate, submit: false, at: at(800)) == .init(action: .stop(submit: false))) + #expect(!g.isLatched) + } + + @Test func aReleaseAtTheThresholdIsAHold() { + var g = Tracker(modes: Self.hybrid) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.released(.dictate, submit: false, at: at(400)).action == .stop(submit: false)) + } + + @Test func aShorterThresholdIsRespected() { + var g = Tracker(modes: Self.hybrid, holdThreshold: .milliseconds(100)) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.released(.dictate, submit: false, at: at(150)).action == .stop(submit: false)) + } + + @Test func anyChordEndsALatchedRecording() { + var g = Tracker(modes: Self.twoChords) + _ = g.pressed(.toggle, at: at(0)) + _ = g.released(.toggle, submit: false, at: at(100)) + #expect(g.pressed(.dictate, at: at(3_000)) == .init(action: .stop(submit: false))) + } + + @Test func theReleaseOfTheEndingPressIsIgnored() { + var g = Tracker(modes: Self.hybrid) + _ = g.pressed(.dictate, at: at(0)) + _ = g.released(.dictate, submit: false, at: at(100)) + _ = g.pressed(.dictate, at: at(3_000)) + #expect(g.released(.dictate, submit: true, at: at(3_100)) == .init()) + // And the next press is a fresh start. + #expect(g.pressed(.dictate, at: at(6_000)) == .init(action: .start(.dictate))) + } + + @Test func aSecondChordWhileHeldIsIgnored() { + var g = Tracker(modes: Self.twoChords) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.pressed(.toggle, at: at(500)) == .init()) + #expect(g.released(.toggle, submit: false, at: at(600)) == .init()) + #expect(g.released(.dictate, submit: false, at: at(900)).action == .stop(submit: false)) + } + + @Test func interruptionDiscards() { + var g = Tracker(modes: Self.hybrid) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.interrupted(.dictate) == .init(action: .discard)) + #expect(!g.isLatched) + #expect(g.pressed(.dictate, at: at(2_000)) == .init(action: .start(.dictate))) + } + + @Test func anotherChordsInterruptionIsIgnored() { + // Nested chords: the other tracker's cancel is the hand-over. + var g = Tracker(modes: Self.twoChords) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.interrupted(.toggle) == .init()) + #expect(g.released(.dictate, submit: false, at: at(900)).action == .stop(submit: false)) + } + + @Test func aBouncePressIsIgnoredAndTurnsDeferralOn() { + var g = Tracker(modes: Self.hold) + _ = g.pressed(.dictate, at: at(0)) + // Not deferring yet: the first bounce costs this hold. + #expect(g.released(.dictate, submit: false, at: at(2_000)).action == .stop(submit: false)) + #expect(!g.deferReleases) + #expect(g.pressed(.dictate, at: at(2_010)) == .init()) + #expect(g.deferReleases) + #expect(g.released(.dictate, submit: false, at: at(2_020)) == .init()) + } + + @Test func withDeferralAReleaseSettlesFirst() { + var g = Tracker(modes: Self.hold, deferReleases: true) + _ = g.pressed(.dictate, at: at(0)) + #expect( + g.released(.dictate, submit: false, at: at(2_000)) + == .init(settle: .init(token: 1, after: .milliseconds(50)))) + #expect(g.timerFired(token: 1) == .init(action: .stop(submit: false))) + #expect(g.timerFired(token: 1) == .init()) + } + + @Test func aBounceDuringSettleResumesTheHold() { + var g = Tracker(modes: Self.hold, deferReleases: true) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.released(.dictate, submit: false, at: at(2_000)).settle?.token == 1) + #expect(g.pressed(.dictate, at: at(2_010)) == .init()) + #expect(g.released(.dictate, submit: false, at: at(3_000)).settle?.token == 2) + #expect(g.timerFired(token: 1) == .init()) + #expect(g.timerFired(token: 2) == .init(action: .stop(submit: false))) + } + + @Test func aBounceKeepsTheHoldsStart() { + // A hybrid hold that bounced at 500 ms is still a hold, not a new tap. + var g = Tracker(modes: Self.hybrid, deferReleases: true) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.released(.dictate, submit: false, at: at(500)).settle != nil) + _ = g.pressed(.dictate, at: at(510)) + #expect(g.released(.dictate, submit: false, at: at(600)).settle != nil) + #expect(!g.isLatched) + } + + @Test func submitSurvivesABounce() { + var g = Tracker(modes: Self.hold, deferReleases: true) + _ = g.pressed(.dictate, at: at(0)) + _ = g.released(.dictate, submit: true, at: at(2_000)) + _ = g.pressed(.dictate, at: at(2_010)) + let token = g.released(.dictate, submit: false, at: at(3_000)).settle?.token ?? 0 + #expect(g.timerFired(token: token) == .init(action: .stop(submit: true))) + } + + @Test func aLatchIsNeverDeferred() { + var g = Tracker(modes: Self.hybrid, deferReleases: true) + _ = g.pressed(.dictate, at: at(0)) + #expect(g.released(.dictate, submit: false, at: at(150)) == .init()) + #expect(g.isLatched) + } + + @Test func aBounceNeverEndsALatch() { + var g = Tracker(modes: Self.hybrid) + _ = g.pressed(.dictate, at: at(0)) + _ = g.released(.dictate, submit: false, at: at(150)) + #expect(g.pressed(.dictate, at: at(160)) == .init()) + #expect(g.isLatched) + } + + @Test func aBounceAfterTheClosingTapDoesNotStartAgain() { + var g = Tracker(modes: Self.hybrid) + _ = g.pressed(.dictate, at: at(0)) + _ = g.released(.dictate, submit: false, at: at(150)) + #expect(g.pressed(.dictate, at: at(4_000)).action == .stop(submit: false)) + _ = g.released(.dictate, submit: false, at: at(4_100)) + #expect(g.pressed(.dictate, at: at(4_110)) == .init()) + } + + @Test func resetKeepsDeferralAndClearsTheLatch() { + var g = Tracker(modes: Self.hybrid) + _ = g.pressed(.dictate, at: at(0)) + _ = g.released(.dictate, submit: false, at: at(100)) + _ = g.pressed(.dictate, at: at(110)) + #expect(g.isLatched && g.deferReleases) + g.reset() + #expect(!g.isLatched) + #expect(g.deferReleases) + #expect(g.pressed(.dictate, at: at(5_000)) == .init(action: .start(.dictate))) + } +} + +/// The Escape rule of `HotkeyChordSet`. +@Suite struct CancelKeyTests { + private let escapeKey: UInt16 = 0x35 + private let keyD: UInt16 = 0x02 + private static let escape = [HotkeyMonitorEvent(role: .dictate, event: .escape)] + + private func optionSpaceSet() -> HotkeyChordSet { + HotkeyChordSet(chords: [.dictate: .optionSpace, .toggle: Hotkey(leftControl, 0x02)]) + } + + @Test func escapeIsTheCancelKeyOnlyWhileEnabled() { + var set = optionSpaceSet() + #expect(set.keyDown(escapeKey, modifiers: []) == .init()) + #expect(set.keyUp(escapeKey, modifiers: []) == .init()) + set.cancelKeyEnabled = true + #expect(set.keyDown(escapeKey, modifiers: []) == .init(events: Self.escape, swallow: true)) + #expect(set.keyDown(escapeKey, isRepeat: true, modifiers: []) == .init(swallow: true)) + #expect(set.keyUp(escapeKey, modifiers: []) == .init(swallow: true)) + // And the next Escape is the cancel key again. + #expect(set.keyDown(escapeKey, modifiers: []).events == Self.escape) + } + + @Test func escapeKeyUpIsSwallowedAfterDisabling() { + var set = optionSpaceSet() + set.cancelKeyEnabled = true + _ = set.keyDown(escapeKey, modifiers: []) + set.cancelKeyEnabled = false + #expect(set.keyDown(escapeKey, isRepeat: true, modifiers: []) == .init(swallow: true)) + #expect(set.keyUp(escapeKey, modifiers: []) == .init(swallow: true)) + #expect(set.keyDown(escapeKey, modifiers: []) == .init()) + } + + @Test func escapeWithForeignModifiersPassesThrough() { + // Cmd+Option+Escape is Force Quit, recording or not. + var set = optionSpaceSet() + set.cancelKeyEnabled = true + _ = set.flagsChanged(modifiers: [leftCommand, leftOption]) + #expect(set.keyDown(escapeKey, modifiers: [leftCommand, leftOption]) == .init()) + #expect(set.keyUp(escapeKey, modifiers: [leftCommand, leftOption]) == .init()) + } + + @Test func escapeWithTheChordsOwnModifiersCancels() { + var set = optionSpaceSet() + set.cancelKeyEnabled = true + _ = set.flagsChanged(modifiers: [rightOption]) + #expect(set.keyDown(space, modifiers: [rightOption]).events == [HotkeyMonitorEvent(role: .dictate, event: .pressed)]) + #expect(set.keyDown(escapeKey, modifiers: [rightOption]) == .init(events: Self.escape, swallow: true)) + } + + @Test func escapeNeverReachesTheChordTrackers() { + // Inside the interruption window a foreign key would cancel the + // press; the cancel key is caught first, so only `.escape` comes out. + let start = ContinuousClock.now + var set = HotkeyChordSet(chords: [.dictate: .rightCommand]) + set.cancelKeyEnabled = true + _ = set.flagsChanged(modifiers: [rightCommand], at: start) + #expect( + set.keyDown(escapeKey, modifiers: [rightCommand], at: start + .milliseconds(100)) + == .init(events: Self.escape, swallow: true)) + #expect(set.keyUp(escapeKey, modifiers: [rightCommand], at: start + .milliseconds(150)) == .init(swallow: true)) + // The chord is still engaged: letting go is its ordinary release. + #expect( + set.flagsChanged(modifiers: [], at: start + .seconds(3)).events + == [HotkeyMonitorEvent(role: .dictate, event: .released(submit: false))]) + } + + @Test func aChordContainingEscapeIsNotTheCancelKey() { + var set = HotkeyChordSet(chords: [.dictate: Hotkey(leftOption, escapeKey)]) + set.cancelKeyEnabled = true + _ = set.flagsChanged(modifiers: [leftOption]) + #expect( + set.keyDown(escapeKey, modifiers: [leftOption]) + == .init(events: [HotkeyMonitorEvent(role: .dictate, event: .pressed)], swallow: true)) + } + + @Test func aSendKeyContainingEscapeIsNotTheCancelKey() { + var set = HotkeyChordSet(chords: [.dictate: .rightCommand], submitKey: Hotkey(escapeKey)) + set.cancelKeyEnabled = true + _ = set.flagsChanged(modifiers: [rightCommand]) + #expect(set.keyDown(escapeKey, modifiers: [rightCommand]) == .init(swallow: true)) + _ = set.keyUp(escapeKey, modifiers: [rightCommand]) + #expect(set.flagsChanged(modifiers: []).events == [HotkeyMonitorEvent(role: .dictate, event: .released(submit: true))]) + } + + @Test func resetKeepsTheCancelKey() { + var set = optionSpaceSet() + set.cancelKeyEnabled = true + _ = set.reset() + #expect(set.cancelKeyEnabled) + #expect(set.keyDown(escapeKey, modifiers: []).events == Self.escape) + } +} diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 2b976dc..f9ce84f 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -222,3 +222,9 @@ something new is contending with the main actor. The engine's own `processingTime` is logged beside the stages as `engine-time`; a large gap between `engine` and `engine-time` means the engine actor was busy with something else. + +A `keyboard bounce observed` line in the `hotkey` category means the keyboard +reported a held key as released and pressed again, and every release since +then has waited 50 ms before `recordingStopped`. That wait is felt but is not +in the `release-to-paste` number. A latched recording's release-to-paste runs +from the closing press.