From a3589d73b1881d52488cca36e5b29b1e541cfefe Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:37:21 +0200 Subject: [PATCH 1/5] Polish: an experimental Processing toggle instead of a hotkey Closes #61. The 'Dictate and polish' chord is gone; the polish step is now a Processing-tab toggle, off by default, that puts every dictation through the refiner. A stored polish chord migrates to the toggle on load, so the feature turns on with the update. The release path is unchanged: the Bool is read at key-down and the refiner is still prewarmed before the user stops speaking. --- CLAUDE.md | 4 +- Sources/Pladder/AppModel.swift | 10 +- Sources/Pladder/Overlay/OverlayView.swift | 2 +- .../Pladder/Resources/Localizable.xcstrings | 132 ++++++++-------- Sources/Pladder/Settings/SettingsView.swift | 63 ++------ Sources/Pladder/StatusText.swift | 14 +- Sources/PladderCLI/main.swift | 4 +- .../PladderCore/DictationCoordinator.swift | 34 ++--- .../PladderCore/Models/DictationState.swift | 2 +- Sources/PladderCore/Models/Settings.swift | 44 +++++- .../PladderCore/Protocols/HotkeyMonitor.swift | 2 +- .../Protocols/TranscriptRefiner.swift | 4 +- .../PladderRefine/TranscriptPolisher.swift | 2 +- .../DictationCoordinatorTests.swift | 143 ++++++------------ .../HotkeyChordSetTests.swift | 28 ++-- Tests/PladderCoreTests/HotkeyTests.swift | 4 +- docs/BENCHMARKS.md | 6 +- 17 files changed, 223 insertions(+), 275 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a2d4c85..b6f3593 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,12 +44,12 @@ swift run -c release pladder-cli polish # run the polish prompt ov | 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. 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 | +| Polish toggle | A Processing-tab toggle (Experimental section), off by default, no key of its own. When on, every dictation 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 and sits on the normal hotkey's path, which is why it is marked experimental and off: the branch is one Bool read; the session is 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 is the same as for a held recording, in and out; only the menu's status line says which key stops it | 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 | +| 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 experimental polish toggle 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. The cap ends a latched recording the same way | diff --git a/Sources/Pladder/AppModel.swift b/Sources/Pladder/AppModel.swift index 0751a59..3667f7e 100644 --- a/Sources/Pladder/AppModel.swift +++ b/Sources/Pladder/AppModel.swift @@ -44,11 +44,11 @@ final class AppModel { /// someone visits System Settings. Never on a key press. private(set) var systemShortcuts: Set = [] - /// Whether Apple Intelligence can polish right now, for the polish key's - /// row. Polled with the permissions: it can be switched on or off in - /// System Settings while the app runs, and the read is cheap. The - /// coordinator never asks; an unavailable model makes the polish key a - /// plain dictation on its own. + /// Whether Apple Intelligence can polish right now, for the polish + /// toggle's row. Polled with the permissions: it can be switched on or + /// off in System Settings while the app runs, and the read is cheap. The + /// coordinator never asks; an unavailable model makes the toggle a + /// no-op that pastes as dictated. private(set) var polishAvailability: OnDeviceModelAvailability = TranscriptPolisher.availability /// The default chord, standing in for a stored chord Carbon cannot diff --git a/Sources/Pladder/Overlay/OverlayView.swift b/Sources/Pladder/Overlay/OverlayView.swift index c63fef3..e26b5d9 100644 --- a/Sources/Pladder/Overlay/OverlayView.swift +++ b/Sources/Pladder/Overlay/OverlayView.swift @@ -293,7 +293,7 @@ struct OverlayPill: View { .foregroundStyle(.primary) } case .polishing: - // Only a dictation started with the polish key gets here, and it + // Only a dictation on its way to the refiner gets here, and it // waits seconds rather than milliseconds, so the pill says why. HStack(spacing: 10) { ProgressView() diff --git a/Sources/Pladder/Resources/Localizable.xcstrings b/Sources/Pladder/Resources/Localizable.xcstrings index 8ee67e3..6d4724c 100644 --- a/Sources/Pladder/Resources/Localizable.xcstrings +++ b/Sources/Pladder/Resources/Localizable.xcstrings @@ -121,26 +121,6 @@ } } }, - "Apple Intelligence is off, so this key pastes the text as dictated. Turn it on in System Settings.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Apple Intelligence ist aus, deshalb fügt diese Taste den Text so ein, wie er diktiert wurde. Schalte es in den Systemeinstellungen ein." - } - } - } - }, - "Apple Intelligence is unavailable, so this key pastes the text as dictated.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Apple Intelligence ist nicht verfügbar, deshalb fügt diese Taste den Text so ein, wie er diktiert wurde." - } - } - } - }, "Applies your replacement rules. Edit them in the Dictionary tab.": { "localizations": { "de": { @@ -281,16 +261,6 @@ } } }, - "Dictate and polish": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Diktieren und überarbeiten" - } - } - } - }, "Dictionary": { "localizations": { "de": { @@ -431,16 +401,6 @@ } } }, - "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.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Hältst du stattdessen die Taste für Diktieren und überarbeiten, räumt Apple Intelligence das Transkript auf diesem Mac vor dem Einfügen auf: Selbstkorrekturen, gesprochene Satzzeichen und Zahlen, Listen. Das dauert ein bis zwei Sekunden." - } - } - } - }, "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.": { "localizations": { "de": { @@ -1171,16 +1131,6 @@ } } }, - "The Apple Intelligence model is still downloading, so this key pastes the text as dictated for now.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Das Modell für Apple Intelligence wird noch geladen, deshalb fügt diese Taste den Text vorerst so ein, wie er diktiert wurde." - } - } - } - }, "The send key needs Accessibility.": { "localizations": { "de": { @@ -1191,16 +1141,6 @@ } } }, - "This Mac cannot run Apple Intelligence, so this key pastes the text as dictated.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Dieser Mac kann Apple Intelligence nicht ausführen, deshalb fügt diese Taste den Text so ein, wie er diktiert wurde." - } - } - } - }, "This rule undoes another one, so it is ignored.": { "localizations": { "de": { @@ -1420,7 +1360,77 @@ } } } + }, + "Polish dictations": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diktate polieren" + } + } + } + }, + "Experimental": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Experimentell" + } + } + } + }, + "Apple Intelligence is off, so dictations paste as dictated. Turn it on in System Settings.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Apple Intelligence ist aus, deshalb fügen Diktate den Text so ein, wie er diktiert wurde. Schalte es in den Systemeinstellungen ein." + } + } + } + }, + "This Mac cannot run Apple Intelligence, so dictations paste as dictated.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Mac kann Apple Intelligence nicht ausführen, deshalb fügen Diktate den Text so ein, wie er diktiert wurde." + } + } + } + }, + "The Apple Intelligence model is still downloading, so dictations paste as dictated for now.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Modell für Apple Intelligence wird noch geladen, deshalb fügen Diktate den Text vorerst so ein, wie er diktiert wurde." + } + } + } + }, + "Apple Intelligence is unavailable, so dictations paste as dictated.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Apple Intelligence ist nicht verfügbar, deshalb fügen Diktate den Text so ein, wie er diktiert wurde." + } + } + } + }, + "Before the text is pasted, Apple Intelligence cleans it up on this Mac: self-corrections, spoken punctuation and numbers, lists. This adds a second or two to every dictation, and anything the model cannot fix is pasted as dictated.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bevor der Text eingefügt wird, bereinigt Apple Intelligence ihn auf diesem Mac: Selbstkorrekturen, gesprochene Satzzeichen und Zahlen, Listen. Das fügt jedem Diktat ein bis zwei Sekunden hinzu, und alles, was das Modell nicht beheben kann, wird so eingefügt, wie es diktiert wurde." + } + } + } } }, "version": "1.0" -} +} \ No newline at end of file diff --git a/Sources/Pladder/Settings/SettingsView.swift b/Sources/Pladder/Settings/SettingsView.swift index 50bdf7a..3b23619 100644 --- a/Sources/Pladder/Settings/SettingsView.swift +++ b/Sources/Pladder/Settings/SettingsView.swift @@ -116,25 +116,7 @@ private struct GeneralSettingsView: View { allowsEmpty: true ) } - if let warning = submitKeyWarning { - Label(warning, systemImage: "exclamationmark.triangle") - .font(.callout) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - // The polish hotkey's one row. It stays when Apple - // Intelligence is off, so the chord can be recorded before it - // is turned on; the warning says what the key does meanwhile. - LabeledContent("Dictate and polish") { - HotkeyRecorderField( - hotkey: $model.settings.polishHotkey, - onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 }, - requiresRegularKey: model.hotkeyNeedsRegularKey, - allowsEmpty: true, - systemShortcuts: model.systemShortcuts - ) - } - if let warning = polishKeyWarning { + if let warning = submitKeyWarning { Label(warning, systemImage: "exclamationmark.triangle") .font(.callout) .foregroundStyle(.orange) @@ -148,7 +130,6 @@ private struct GeneralSettingsView: View { 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.") } } @@ -320,34 +301,6 @@ private struct GeneralSettingsView: View { return String(localized: "\(submitKey.displayName) is part of the push-to-talk key, so it can never be pressed separately.") } - /// In order: nothing to say while the key is off; the push-to-talk - /// chord itself is never registered twice, so it would never polish; - /// Apple Intelligence cannot run, so the key pastes the text as dictated; - /// macOS owns the chord; Carbon cannot register it without - /// Accessibility, and there is no stand-in for this key; and the same - /// warning as the push-to-talk key for a chord without a modifier. - private var polishKeyWarning: String? { - let polish = model.settings.polishHotkey - guard !polish.isEmpty else { return nil } - 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 - } - if let owner = model.systemShortcutConflict(for: polish) { - return conflictWarning(owner: owner, chord: polish) - } - if !model.accessibilityTrusted, !polish.canBeRegisteredWithoutAccessibility { - return String(localized: "Without Accessibility, \(polish.displayName) cannot be detected, so this key is off until Accessibility is granted.") - } - guard polish.modifierKeyCodes.isEmpty else { return nil } - return String(localized: "Without a modifier, \(polish.displayName) can no longer be typed in other apps while Pladder is running.") - } - private var launchAtLogin: Binding { Binding( get: { LaunchAtLogin.isEnabled }, @@ -396,6 +349,20 @@ private struct ProcessingSettingsView: View { } footer: { FootnoteText("Separates consecutive dictations so pasted runs stay readable.") } + + Section { + Toggle("Polish dictations", isOn: $model.settings.polishDictations) + if let warning = model.polishAvailability.polishKeyText { + Label(warning, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } header: { + Text("Experimental") + } footer: { + FootnoteText("Before the text is pasted, Apple Intelligence cleans it up on this Mac: self-corrections, spoken punctuation and numbers, lists. This adds a second or two to every dictation, and anything the model cannot fix is pasted as dictated.") + } } .formStyle(.grouped) } diff --git a/Sources/Pladder/StatusText.swift b/Sources/Pladder/StatusText.swift index 4dd4c9a..c43b69d 100644 --- a/Sources/Pladder/StatusText.swift +++ b/Sources/Pladder/StatusText.swift @@ -67,20 +67,20 @@ extension DictationFailure { } extension OnDeviceModelAvailability { - /// The sentence under the Dictate and polish row, nil when the model can - /// run. Each says what the key does meanwhile: it still records and - /// pastes, only without the polish. + /// The sentence under the "Polish dictations" toggle, nil when the model + /// can run. Each says what dictations do meanwhile: they paste as + /// dictated, only without the polish. var polishKeyText: String? { switch self { case .available: nil case .appleIntelligenceNotEnabled: - String(localized: "Apple Intelligence is off, so this key pastes the text as dictated. Turn it on in System Settings.") + String(localized: "Apple Intelligence is off, so dictations paste as dictated. Turn it on in System Settings.") case .deviceNotEligible: - String(localized: "This Mac cannot run Apple Intelligence, so this key pastes the text as dictated.") + String(localized: "This Mac cannot run Apple Intelligence, so dictations paste as dictated.") case .modelNotReady: - String(localized: "The Apple Intelligence model is still downloading, so this key pastes the text as dictated for now.") + String(localized: "The Apple Intelligence model is still downloading, so dictations paste as dictated for now.") case .unavailable: - String(localized: "Apple Intelligence is unavailable, so this key pastes the text as dictated.") + String(localized: "Apple Intelligence is unavailable, so dictations paste as dictated.") } } } diff --git a/Sources/PladderCLI/main.swift b/Sources/PladderCLI/main.swift index bfd9374..e56f2c8 100644 --- a/Sources/PladderCLI/main.swift +++ b/Sources/PladderCLI/main.swift @@ -28,7 +28,7 @@ import PladderRefine // how many there were and what they cost. The // `identical:` column then also proves the live // passes leave the release's windows alone. -// pladder-cli polish run the polish hotkey's prompt over a transcript +// pladder-cli polish run the polisher over a transcript over a transcript // with Apple's on-device model: once cold, once // after prepare() and a two-second wait, the way // a real press warms it. Prints both timings. @@ -461,7 +461,7 @@ func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, li // MARK: - Polish /// Runs `TranscriptPolisher` over one transcript twice and prints what the -/// polish key would paste. The first run is cold (no `prepare()`), the second +/// polish toggle would paste. The first run is cold (no `prepare()`), the second /// warm, which is what a real press gets: the session is made and prewarmed /// at key-down, seconds before the release. func runPolish(_ path: String, instructionsPath: String?) async throws { diff --git a/Sources/PladderCore/DictationCoordinator.swift b/Sources/PladderCore/DictationCoordinator.swift index bfe5c7e..7d7411d 100644 --- a/Sources/PladderCore/DictationCoordinator.swift +++ b/Sources/PladderCore/DictationCoordinator.swift @@ -4,7 +4,7 @@ import Observation /// The push-to-talk state machine. Owns no I/O itself; everything is injected. /// /// Flow: hotkey pressed -> capture starts -> hotkey released -> capture stops -> -/// engine transcribes -> pipeline processes -> (polish hotkey: model refines ->) +/// engine transcribes -> pipeline processes -> (polish setting: model refines ->) /// output inserts -> idle. /// /// A press of the toggle chord, or a tap of a hybrid chord shorter than @@ -27,8 +27,10 @@ public final class DictationCoordinator { /// is cleared the moment the key is released. Nil in every other style. public private(set) var partialTranscript: String? - /// True from a polish-hotkey press until that cycle ends, so the overlay - /// can keep the pill up across the release. + /// True when the current dictation is on its way through the refiner, so + /// the overlay can keep the pill up across the release. Read off + /// `settings.polishDictations` at key-down, so flipping the setting + /// mid-recording cannot change this cycle. public private(set) var willPolish = false /// True while a recording continues after its chord was let go: a toggle @@ -69,9 +71,8 @@ public final class DictationCoordinator { /// Command, which the default Option+Space then stands in for. The stored /// chord is left untouched and comes back the moment this is cleared, /// 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. A toggle chord equal to the stored chord follows the + /// for the stored chord". Applies to the dictate chord only; a toggle + /// chord equal to the stored chord follows the /// override, so a stood-in hybrid key stays hybrid. public var hotkeyOverride: Hotkey? { didSet { @@ -122,8 +123,8 @@ public final class DictationCoordinator { /// Silences the speakers while the mic is open, when the setting is on. /// Nil in tests and wherever the app does not want the behaviour at all. private let outputMuter: (any OutputMuter)? - /// The polish hotkey's second pass. Nil where there is none, which makes - /// the polish key a plain dictation. + /// The polish setting's second pass. Nil where there is none, which makes + /// the polish a no-op. private let refiner: (any TranscriptRefiner)? private var hotkeyMonitor: any HotkeyMonitor private let makePipeline: @Sendable (Settings) -> ProcessorPipeline @@ -269,7 +270,6 @@ 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.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 @@ -441,12 +441,6 @@ public final class DictationCoordinator { startGesture() var chords: [HotkeyRole: Hotkey] = [.dictate: hotkeyOverride ?? settings.hotkey] 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 { @@ -502,7 +496,7 @@ public final class DictationCoordinator { settleTask = nil isLatched = false gesture = HotkeyGestureTracker( - modes: [.dictate: toggleIsHybrid ? .hybrid : .hold, .polish: .hold, .toggle: .toggle], + modes: [.dictate: toggleIsHybrid ? .hybrid : .hold, .toggle: .toggle], holdThreshold: holdThreshold, bounceWindow: bounceWindow, deferReleases: deferReleases || gesture.deferReleases @@ -552,8 +546,6 @@ public final class DictationCoordinator { } /// 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. public func hotkeyPressed(role: HotkeyRole = .dictate) async { // `.copied` is the hint from the previous dictation, not a busy state: // a press replaces it rather than being dropped. @@ -567,7 +559,7 @@ public final class DictationCoordinator { // the settings switch engines mid-recording. cycleEngine = loader.engine cycleRole = role - willPolish = role == .polish + willPolish = settings.polishDictations partialTranscript = nil // Read once, at press: switching the style mid-recording must not // leave the loop half live, with nothing warming the engine. @@ -721,8 +713,8 @@ public final class DictationCoordinator { becomeIdle() return } - // The polish key's one branch; on the normal path it costs a Bool - // read. A refiner that cannot help returns nil and the text goes + // The polish setting's one branch; off, it costs a Bool read. + // A refiner that cannot help returns nil and the text goes // out as dictated. var final = processed if willPolish, let refiner, Self.wordCount(processed) >= minimumPolishWords { diff --git a/Sources/PladderCore/Models/DictationState.swift b/Sources/PladderCore/Models/DictationState.swift index c585390..c15b87d 100644 --- a/Sources/PladderCore/Models/DictationState.swift +++ b/Sources/PladderCore/Models/DictationState.swift @@ -7,7 +7,7 @@ public enum DictationState: Equatable, Sendable { case recording(level: Float) case transcribing /// The model is cleaning the transcript; only a dictation started with - /// the polish hotkey gets here. + /// the refiner gets here. case polishing case inserting /// Transcript is on the clipboard for the user to paste; shown briefly, diff --git a/Sources/PladderCore/Models/Settings.swift b/Sources/PladderCore/Models/Settings.swift index 461060a..5746868 100644 --- a/Sources/PladderCore/Models/Settings.swift +++ b/Sources/PladderCore/Models/Settings.swift @@ -40,9 +40,10 @@ public struct Settings: Codable, Sendable, Equatable { /// end with Return, which sends a chat message or runs a command. Empty /// turns it off. public var submitKey: Hotkey - /// 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 + /// Every dictation runs through the on-device model before it is pasted. + /// Experimental and off by default: the model costs one to three seconds, + /// so this sits on the normal hotkey's release path. + public var polishDictations: Bool /// 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. @@ -72,7 +73,7 @@ public struct Settings: Codable, Sendable, Equatable { engineID: EngineID, hotkey: Hotkey = .optionSpace, submitKey: Hotkey = .rightOption, - polishHotkey: Hotkey = Hotkey(keyCodes: []), + polishDictations: Bool = false, toggleHotkey: Hotkey = Hotkey(keyCodes: []), disabledProcessors: Set = [], dictionary: [DictionaryEntry] = [], @@ -88,7 +89,7 @@ public struct Settings: Codable, Sendable, Equatable { self.engineID = engineID self.hotkey = hotkey self.submitKey = submitKey - self.polishHotkey = polishHotkey + self.polishDictations = polishDictations self.toggleHotkey = toggleHotkey self.disabledProcessors = disabledProcessors self.dictionary = dictionary @@ -105,9 +106,11 @@ public struct Settings: Codable, Sendable, Equatable { // Decoding tolerates missing keys so adding a field in a later version // never makes an existing settings file unreadable. private enum CodingKeys: String, CodingKey { - case engineID, hotkey, submitKey, polishHotkey, disabledProcessors, dictionary, appendTrailingSpace, launchAtLogin, playSounds, appearance + case engineID, hotkey, submitKey, polishDictations, disabledProcessors, dictionary, appendTrailingSpace, launchAtLogin, playSounds, appearance case overlayStyle, overlayGlass, overlayAnimationSpeed, muteOutputWhileDictating case toggleHotkey + // Read once for the migration, never written. + case polishHotkey } public init(from decoder: Decoder) throws { @@ -119,8 +122,12 @@ public struct Settings: Codable, Sendable, Equatable { // Unlike the hotkey, an empty submit key is meaningful: it is how the // feature is switched off. 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: []) + // Once a chord of its own, the polish is now a Processing toggle. + // A stored chord migrates to `true`, so the feature the user asked + // for turns on with the update; nothing is written under the old key. + let legacyPolish = try c.decodeIfPresent(Hotkey.self, forKey: .polishHotkey) ?? Hotkey(keyCodes: []) + polishDictations = try c.decodeIfPresent(Bool.self, forKey: .polishDictations) + ?? (!legacyPolish.isEmpty) // 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) ?? [] @@ -135,6 +142,27 @@ public struct Settings: Codable, Sendable, Equatable { muteOutputWhileDictating = try c.decodeIfPresent(Bool.self, forKey: .muteOutputWhileDictating) ?? false } + // Encoding mirrors the synthesized one, minus the legacy `polishHotkey` + // key that only the decoder above reads. + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(engineID, forKey: .engineID) + try c.encode(hotkey, forKey: .hotkey) + try c.encode(submitKey, forKey: .submitKey) + try c.encode(polishDictations, forKey: .polishDictations) + try c.encode(toggleHotkey, forKey: .toggleHotkey) + try c.encode(disabledProcessors, forKey: .disabledProcessors) + try c.encode(dictionary, forKey: .dictionary) + try c.encode(appendTrailingSpace, forKey: .appendTrailingSpace) + try c.encode(launchAtLogin, forKey: .launchAtLogin) + try c.encode(playSounds, forKey: .playSounds) + try c.encode(muteOutputWhileDictating, forKey: .muteOutputWhileDictating) + try c.encode(appearance, forKey: .appearance) + try c.encode(overlayStyle, forKey: .overlayStyle) + try c.encode(overlayGlass, forKey: .overlayGlass) + try c.encode(overlayAnimationSpeed, forKey: .overlayAnimationSpeed) + } + public func isProcessorEnabled(_ id: String) -> Bool { !disabledProcessors.contains(id) } diff --git a/Sources/PladderCore/Protocols/HotkeyMonitor.swift b/Sources/PladderCore/Protocols/HotkeyMonitor.swift index 930f2c9..dcba398 100644 --- a/Sources/PladderCore/Protocols/HotkeyMonitor.swift +++ b/Sources/PladderCore/Protocols/HotkeyMonitor.swift @@ -5,7 +5,7 @@ import Foundation /// 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, toggle + case dictate, toggle public static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue } } diff --git a/Sources/PladderCore/Protocols/TranscriptRefiner.swift b/Sources/PladderCore/Protocols/TranscriptRefiner.swift index 4b72b86..a80c76c 100644 --- a/Sources/PladderCore/Protocols/TranscriptRefiner.swift +++ b/Sources/PladderCore/Protocols/TranscriptRefiner.swift @@ -2,9 +2,9 @@ import Foundation /// A second pass over the processed transcript by something slow, such as an /// on-device language model. Runs only for a dictation started with the -/// polish hotkey; the normal path never calls it. +/// polish toggle; when it is off the coordinator never calls it. public protocol TranscriptRefiner: Sendable { - /// Called at key-down of the polish hotkey, while the user is still + /// Called at key-down of a polished dictation, while the user is still /// speaking, so the model's load is off the release path. Fire and forget. func prepare() async /// The polished text, or nil when the model could not help (unavailable, diff --git a/Sources/PladderRefine/TranscriptPolisher.swift b/Sources/PladderRefine/TranscriptPolisher.swift index 62a7f8b..8cfdfd1 100644 --- a/Sources/PladderRefine/TranscriptPolisher.swift +++ b/Sources/PladderRefine/TranscriptPolisher.swift @@ -12,7 +12,7 @@ struct PolishedTranscript { var cleanedText: String } -/// The polish hotkey's refiner: the cleanup prompt on `OnDeviceLanguageModel`. +/// The polish toggle's refiner: the cleanup prompt on `OnDeviceLanguageModel`. /// /// Best effort throughout. Unavailable, refused, timed out, empty: `refine` /// returns nil and the coordinator pastes the text as dictated. An actor for diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index 36453c0..5f23c7f 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -1153,21 +1153,20 @@ final class EventLog: @unchecked Sendable { } } -// MARK: - Polish hotkey +// MARK: - Polish toggle @MainActor -@Suite struct PolishHotkeyTests { +@Suite struct PolishToggleTests { /// Long enough to clear `minimumPolishWords`. private static let sentence = "send it on Friday please" - private static let polishChord = Hotkey(0x3B, 0x31) - private static func settings(polish: Hotkey = polishChord) -> Settings { + private static func settings(polish: Bool = true) -> Settings { var s = Settings(engineID: EchoEngine.engineID) - s.polishHotkey = polish + s.polishDictations = polish return s } - @Test func polishHotkeyRoutesThroughTheRefiner() async { + @Test func polishRoutesThroughTheRefiner() async { let refiner = FakeRefiner() let events = EventLog() let hotkey = FakeHotkey() @@ -1176,9 +1175,9 @@ final class EventLog: @unchecked Sendable { hotkeyMonitor: hotkey, refiner: refiner, events: events) c.start() #expect(await waitUntil { c.state == .idle }) - hotkey.press(.polish) + hotkey.press() #expect(await waitUntil { c.state.isRecording }) - hotkey.release(.polish) + hotkey.release() #expect(await waitUntil { c.inFlight != nil }) await c.inFlight?.value #expect(output.inserted == ["polished "]) @@ -1192,7 +1191,7 @@ final class EventLog: @unchecked Sendable { let events = EventLog() let hotkey = FakeHotkey() let (c, output, _) = makeCoordinator( - engineText: Self.sentence, settings: Self.settings(), + engineText: Self.sentence, settings: Self.settings(polish: false), hotkeyMonitor: hotkey, refiner: refiner, events: events) c.start() #expect(await waitUntil { c.state == .idle }) @@ -1214,7 +1213,7 @@ final class EventLog: @unchecked Sendable { let (c, output, _) = makeCoordinator(engineText: "", settings: Self.settings(), refiner: refiner) c.start() #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed(role: .polish) + await c.hotkeyPressed() c.hotkeyReleased() await c.inFlight?.value #expect(output.inserted.isEmpty) @@ -1229,7 +1228,7 @@ final class EventLog: @unchecked Sendable { engineText: "one two three", settings: Self.settings(), refiner: refiner, events: events) c.start() #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed(role: .polish) + await c.hotkeyPressed() c.hotkeyReleased() await c.inFlight?.value #expect(output.inserted == ["one two three "]) @@ -1243,7 +1242,7 @@ final class EventLog: @unchecked Sendable { engineText: "one two three four", settings: Self.settings(), refiner: refiner) c.start() #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed(role: .polish) + await c.hotkeyPressed() c.hotkeyReleased() await c.inFlight?.value #expect(refiner.calls == ["one two three four"]) @@ -1258,7 +1257,7 @@ final class EventLog: @unchecked Sendable { engineText: Self.sentence, settings: Self.settings(), refiner: refiner, events: events) c.start() #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed(role: .polish) + await c.hotkeyPressed() c.hotkeyReleased() await c.inFlight?.value #expect(refiner.calls == [Self.sentence]) @@ -1271,22 +1270,22 @@ final class EventLog: @unchecked Sendable { #expect(c.state == .idle) } - @Test func withoutARefinerThePolishKeyIsAPlainDictation() async { + @Test func withoutARefinerPolishIsAPlainDictation() async { let (c, output, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings()) c.start() #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed(role: .polish) + await c.hotkeyPressed() c.hotkeyReleased() await c.inFlight?.value #expect(output.inserted == [Self.sentence + " "]) } - @Test func polishPressWarmsTheRefiner() async { + @Test func polishSettingWarmsTheRefiner() async { let refiner = FakeRefiner() let (c, _, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings(), refiner: refiner) c.start() #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed(role: .polish) + await c.hotkeyPressed() #expect(await waitUntil { refiner.prepareCount == 1 }) #expect(c.state.isRecording) #expect(refiner.calls.isEmpty) @@ -1297,7 +1296,7 @@ final class EventLog: @unchecked Sendable { let (c, output, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings(), refiner: refiner) c.start() #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed(role: .polish) + await c.hotkeyPressed() #expect(c.willPolish) c.hotkeyReleased() #expect(await waitUntil { c.state == .polishing }) @@ -1309,99 +1308,42 @@ final class EventLog: @unchecked Sendable { #expect(output.inserted == ["polished "]) } - @Test func polishChordIsHandedToTheMonitor() async { - let fake = FakeHotkey() - let (c, _, _) = makeCoordinator(settings: Self.settings(), hotkeyMonitor: fake) - c.start() - #expect(await waitUntil { c.state == .idle }) - #expect(fake.lastChords == [.dictate: .optionSpace, .polish: Self.polishChord]) - } - - @Test func emptyPolishChordIsNotRegistered() async { - let fake = FakeHotkey() - let (c, _, _) = makeCoordinator(hotkeyMonitor: fake) - c.start() - #expect(await waitUntil { c.state == .idle }) - #expect(Array(fake.lastChords.keys) == [.dictate]) - } - - @Test func polishChordEqualToTheDictateChordIsNotRegistered() async { - let fake = FakeHotkey() - let (c, _, _) = makeCoordinator(settings: Self.settings(polish: .optionSpace), hotkeyMonitor: fake) - c.start() - #expect(await waitUntil { c.state == .idle }) - #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 { + @Test func aSecondChordIsStillRegisteredWhenPolishIsAToggle() 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) + let (c, _, _) = makeCoordinator(settings: settings, hotkeyMonitor: fake) 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) - c.start() - #expect(await waitUntil { c.state == .idle }) - #expect(fake.startCount == 1) - await c.hotkeyPressed() - #expect(c.state.isRecording) - c.settings.polishHotkey = Self.polishChord - #expect(fake.startCount == 2) - #expect(fake.lastChords[.polish] == Self.polishChord) - // Dropped like a hotkey change: the restarted monitor would never - // deliver the pending release. - #expect(await waitUntil { c.state == .idle }) - #expect(await capture.stopCount == 1) - #expect(output.inserted.isEmpty) + #expect(fake.lastChords == [.dictate: .optionSpace, .toggle: Hotkey(0x3B, 0x3A, 0x31)]) } @Test func theOverrideAppliesOnlyToTheDictateChord() async { let fake = FakeHotkey() let standIn = Hotkey(0x3B, 0x38, 0x31) - let (c, _, _) = makeCoordinator(settings: Self.settings(), hotkeyMonitor: fake) + var settings = Self.settings() + settings.toggleHotkey = Hotkey(0x3B, 0x3A, 0x31) + let (c, _, _) = makeCoordinator(settings: settings, hotkeyMonitor: fake) c.hotkeyOverride = standIn c.start() #expect(await waitUntil { c.state == .idle }) #expect(fake.lastChords[.dictate] == standIn) - #expect(fake.lastChords[.polish] == Self.polishChord) + #expect(fake.lastChords[.toggle] == Hotkey(0x3B, 0x3A, 0x31)) } @Test func aReleaseFromTheOtherChordIsIgnored() async { let hotkey = FakeHotkey() let refiner = FakeRefiner() + var settings = Self.settings(polish: false) + settings.toggleHotkey = Hotkey(0x3B, 0x3A, 0x31) let (c, output, _) = makeCoordinator( - engineText: Self.sentence, settings: Self.settings(), hotkeyMonitor: hotkey, refiner: refiner) + engineText: Self.sentence, settings: settings, hotkeyMonitor: hotkey, refiner: refiner) c.start() #expect(await waitUntil { c.state == .idle }) hotkey.press(.dictate) #expect(await waitUntil { c.state.isRecording }) - hotkey.release(.polish) - hotkey.cancel(.polish) + hotkey.release(.toggle) + hotkey.cancel(.toggle) // Give the stream a moment to deliver both; neither may end the take. try? await Task.sleep(for: .milliseconds(50)) #expect(c.state.isRecording) @@ -1412,16 +1354,16 @@ final class EventLog: @unchecked Sendable { #expect(refiner.calls.isEmpty) } - @Test func cancelledPolishPressDropsTheRecording() async { + @Test func cancelledPolishDictationDropsTheRecording() async { let hotkey = FakeHotkey() let refiner = FakeRefiner() let (c, output, capture) = makeCoordinator( engineText: Self.sentence, settings: Self.settings(), hotkeyMonitor: hotkey, refiner: refiner) c.start() #expect(await waitUntil { c.state == .idle }) - hotkey.press(.polish) + hotkey.press() #expect(await waitUntil { c.state.isRecording }) - hotkey.cancel(.polish) + hotkey.cancel() #expect(await waitUntil { c.state == .idle }) #expect(await capture.stopCount == 1) #expect(output.inserted.isEmpty) @@ -1429,15 +1371,15 @@ final class EventLog: @unchecked Sendable { #expect(!c.willPolish) } - @Test func submitWorksOnThePolishPath() async { + @Test func submitWorksOnThePolishedPath() async { let hotkey = FakeHotkey() let (c, output, _) = makeCoordinator( engineText: Self.sentence, settings: Self.settings(), hotkeyMonitor: hotkey, refiner: FakeRefiner()) c.start() #expect(await waitUntil { c.state == .idle }) - hotkey.press(.polish) + hotkey.press() #expect(await waitUntil { c.state.isRecording }) - hotkey.release(.polish, submit: true) + hotkey.release(submit: true) #expect(await waitUntil { c.inFlight != nil }) await c.inFlight?.value #expect(output.inserted == ["polished "]) @@ -1448,7 +1390,7 @@ final class EventLog: @unchecked Sendable { let (c, _, _) = makeCoordinator(settings: Self.settings(), refiner: FakeRefiner()) c.start() #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed(role: .polish) + await c.hotkeyPressed() #expect(c.willPolish) await c.cancelRecording() #expect(!c.willPolish) @@ -1883,7 +1825,7 @@ final class EventLog: @unchecked Sendable { var changed = defaults changed.hotkey = .rightOption changed.submitKey = Hotkey(0x24) - changed.polishHotkey = Hotkey(0x3B, 0x31) + changed.polishDictations = true changed.dictionary = [DictionaryEntry(from: "a", to: "b")] try store.save(changed) #expect(store.load() == changed) @@ -1897,7 +1839,7 @@ final class EventLog: @unchecked Sendable { #expect(decoded.dictionary.count == 1) #expect(decoded.hotkey == .optionSpace) #expect(decoded.submitKey == .rightOption) - #expect(decoded.polishHotkey.isEmpty) + #expect(!decoded.polishDictations) #expect(decoded.appendTrailingSpace == true) #expect(decoded.appearance == .system) #expect(decoded.overlayStyle == .compact) @@ -1905,6 +1847,15 @@ final class EventLog: @unchecked Sendable { #expect(decoded.overlayAnimationSpeed == .quick) } + @Test func legacyPolishChordMigratesToTheToggle() throws { + let json = #"{"engineID":"echo","polishHotkey":{"keyCodes":[59,31]}}"# + let decoded = try JSONDecoder().decode(Settings.self, from: Data(json.utf8)) + #expect(decoded.polishDictations) + // The toggle stays off over a legacy chord stored empty. + let off = try JSONDecoder().decode(Settings.self, from: Data(#"{"engineID":"echo","polishHotkey":{"keyCodes":[]}}"#.utf8)) + #expect(!off.polishDictations) + } + @Test func appearancePersists() throws { let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let url = dir.appendingPathComponent("settings.json") diff --git a/Tests/PladderCoreTests/HotkeyChordSetTests.swift b/Tests/PladderCoreTests/HotkeyChordSetTests.swift index efe2a71..6c50b18 100644 --- a/Tests/PladderCoreTests/HotkeyChordSetTests.swift +++ b/Tests/PladderCoreTests/HotkeyChordSetTests.swift @@ -15,10 +15,10 @@ private func event(_ role: HotkeyRole, _ event: HotkeyEvent) -> HotkeyMonitorEve @Suite struct HotkeyChordSetTests { @Test func eachChordReportsItsOwnRole() { - var set = HotkeyChordSet(chords: [.dictate: .optionSpace, .polish: Hotkey(leftControl, space)]) + var set = HotkeyChordSet(chords: [.dictate: .optionSpace, .toggle: Hotkey(leftControl, space)]) #expect(set.flagsChanged(modifiers: [leftControl]) == .init()) - #expect(set.keyDown(space, modifiers: [leftControl]) == .init(events: [event(.polish, .pressed)], swallow: true)) - #expect(set.keyUp(space, modifiers: [leftControl]) == .init(events: [event(.polish, .released(submit: false))], swallow: true)) + #expect(set.keyDown(space, modifiers: [leftControl]) == .init(events: [event(.toggle, .pressed)], swallow: true)) + #expect(set.keyUp(space, modifiers: [leftControl]) == .init(events: [event(.toggle, .released(submit: false))], swallow: true)) #expect(set.flagsChanged(modifiers: []) == .init()) #expect(set.flagsChanged(modifiers: [leftOption]) == .init()) @@ -28,7 +28,7 @@ private func event(_ role: HotkeyRole, _ event: HotkeyEvent) -> HotkeyMonitorEve @Test func swallowIsTheUnionOfTheTrackers() { // Only the polish tracker swallows the Space; the set still drops it. - var set = HotkeyChordSet(chords: [.dictate: .rightCommand, .polish: Hotkey(leftControl, space)]) + var set = HotkeyChordSet(chords: [.dictate: .rightCommand, .toggle: Hotkey(leftControl, space)]) #expect(set.flagsChanged(modifiers: [leftControl]) == .init()) #expect(set.keyDown(space, modifiers: [leftControl]).swallow) #expect(set.keyUp(space, modifiers: [leftControl]).swallow) @@ -37,7 +37,7 @@ private func event(_ role: HotkeyRole, _ event: HotkeyEvent) -> HotkeyMonitorEve } @Test func anEmptyChordIsIgnored() { - var set = HotkeyChordSet(chords: [.dictate: .optionSpace, .polish: Hotkey(keyCodes: [])]) + var set = HotkeyChordSet(chords: [.dictate: .optionSpace, .toggle: Hotkey(keyCodes: [])]) var single = HotkeyChordTracker(hotkey: .optionSpace) #expect(set.flagsChanged(modifiers: [leftOption]) == .init()) #expect(single.flagsChanged(modifiers: [leftOption]) == .init()) @@ -50,32 +50,32 @@ private func event(_ role: HotkeyRole, _ event: HotkeyEvent) -> HotkeyMonitorEve @Test func nestedChordsHandOverBetweenRoles() { let start = ContinuousClock.now - var set = HotkeyChordSet(chords: [.dictate: .rightCommand, .polish: Hotkey(rightCommand, rightOption)]) + var set = HotkeyChordSet(chords: [.dictate: .rightCommand, .toggle: Hotkey(rightCommand, rightOption)]) #expect(set.flagsChanged(modifiers: [rightCommand], at: start) == .init(events: [event(.dictate, .pressed)])) // Right Option inside the window: the lone Right Command was the // start of the longer chord, not a dictation. #expect( set.flagsChanged(modifiers: [rightCommand, rightOption], at: start + .milliseconds(100)) - == .init(events: [event(.dictate, .cancelled), event(.polish, .pressed)]) + == .init(events: [event(.dictate, .cancelled), event(.toggle, .pressed)]) ) #expect( set.flagsChanged(modifiers: [], at: start + .seconds(3)) - == .init(events: [event(.polish, .released(submit: false))]) + == .init(events: [event(.toggle, .released(submit: false))]) ) } @Test func resetReleasesEveryEngagedChord() { - var set = HotkeyChordSet(chords: [.dictate: .rightCommand, .polish: Hotkey(leftControl, space)]) + var set = HotkeyChordSet(chords: [.dictate: .rightCommand, .toggle: Hotkey(leftControl, space)]) _ = set.flagsChanged(modifiers: [leftControl]) _ = set.keyDown(space, modifiers: [leftControl]) - #expect(set.reset() == [event(.polish, .released(submit: false))]) + #expect(set.reset() == [event(.toggle, .released(submit: false))]) // Nothing is engaged after a reset. #expect(set.reset() == []) } @Test func theSubmitKeyArmsEitherChord() { var set = HotkeyChordSet( - chords: [.dictate: .rightCommand, .polish: Hotkey(leftControl, space)], + chords: [.dictate: .rightCommand, .toggle: Hotkey(leftControl, space)], submitKey: Hotkey(returnKey)) _ = set.flagsChanged(modifiers: [rightCommand]) #expect(set.keyDown(returnKey, modifiers: [rightCommand]).swallow) @@ -86,15 +86,15 @@ private func event(_ role: HotkeyRole, _ event: HotkeyEvent) -> HotkeyMonitorEve _ = set.keyDown(space, modifiers: [leftControl]) #expect(set.keyDown(returnKey, modifiers: [leftControl]).swallow) _ = set.keyUp(returnKey, modifiers: [leftControl]) - #expect(set.keyUp(space, modifiers: [leftControl]).events == [event(.polish, .released(submit: true))]) + #expect(set.keyUp(space, modifiers: [leftControl]).events == [event(.toggle, .released(submit: true))]) } @Test func eventsComeInRoleOrder() { // Built in the other order; the events still come out dictate first. let start = ContinuousClock.now - var set = HotkeyChordSet(chords: [.polish: Hotkey(rightCommand, rightOption), .dictate: .rightCommand]) + var set = HotkeyChordSet(chords: [.toggle: Hotkey(rightCommand, rightOption), .dictate: .rightCommand]) _ = set.flagsChanged(modifiers: [rightCommand], at: start) let handOver = set.flagsChanged(modifiers: [rightCommand, rightOption], at: start + .milliseconds(10)) - #expect(handOver.events.map(\.role) == [.dictate, .polish]) + #expect(handOver.events.map(\.role) == [.dictate, .toggle]) } } diff --git a/Tests/PladderCoreTests/HotkeyTests.swift b/Tests/PladderCoreTests/HotkeyTests.swift index 19af81b..3d621e5 100644 --- a/Tests/PladderCoreTests/HotkeyTests.swift +++ b/Tests/PladderCoreTests/HotkeyTests.swift @@ -600,8 +600,8 @@ private let keyC: UInt16 = 0x08 @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)) + _ = g.pressed(.toggle, at: at(0)) + #expect(g.released(.toggle, submit: false, at: at(100)).action == .stop(submit: false)) } @Test func toggleModeLatchesAndStopsOnTheNextPress() { diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index f9ce84f..8553251 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -193,15 +193,15 @@ The line looks like: release-to-paste 0.312 s: stop 0.012, engine 0.250, process 0.003, paste 0.014; audio 4.2 s ``` -A dictation started with the polish hotkey logs its own line, with the model +A polished dictation logs its own line, with the model call as a fifth stage: ``` polished release-to-paste 1.912 s: stop 0.012, engine 0.250, process 0.003, polish 1.620, paste 0.014; audio 6.1 s ``` -`polish` is the model call; it exists only for dictations started with the -polish hotkey, and the plain line is unchanged for everything else. It is +`polish` is the model call; it exists only for dictations run with the +experimental polish toggle on, and the plain line is unchanged for everything else. It is `0.000` when the transcript was under four words and the model was skipped. The prompt's own cost and output are measured with `swift run -c release pladder-cli polish `, which runs it cold and From 922b92714dcbaeb342c6889f6036dd62c507b20f Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:45:03 +0200 Subject: [PATCH 2/5] Overlay: a polished dictation's pill dives out again With the polish toggle on, the pill is held up across .transcribing and .polishing, and the idle hide only flew out for the clipboard hint, so the polished pill faded in place with no dive. Its model state is .polishing by then; include it with the flight path. --- Sources/Pladder/Overlay/OverlayController.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/Pladder/Overlay/OverlayController.swift b/Sources/Pladder/Overlay/OverlayController.swift index 0baf764..3c63161 100644 --- a/Sources/Pladder/Overlay/OverlayController.swift +++ b/Sources/Pladder/Overlay/OverlayController.swift @@ -182,7 +182,12 @@ final class OverlayController { // and an error fade in place. cancelSpinner() guard visible else { return } - scheduleHide(after: .zero, flight: model.state == .copied) + // The clipboard hint collapses into the disc and dives; so does a + // polished dictation's pill, which is a recording pill held up + // across the model pass. The spinner and an error fade in place. + scheduleHide( + after: .zero, + flight: model.state == .copied || model.state == .polishing) } } From d290a3af585d6cef4e4a72eb2b5338537595009e Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:51:52 +0200 Subject: [PATCH 3/5] Overlay: a polished dictation keeps the pill up and dives when done The bird-dive out had been moved solely elsewhere once the spinner- re-entry experiment scoped; now the keep-up runs across the whole model pass from .transcribing, the spinner only re-enters for plain transcriptions, .polishing morphs the row in place, and the finish's flight matches the settled move to its style. --- .../Pladder/Overlay/OverlayController.swift | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Sources/Pladder/Overlay/OverlayController.swift b/Sources/Pladder/Overlay/OverlayController.swift index 3c63161..395a529 100644 --- a/Sources/Pladder/Overlay/OverlayController.swift +++ b/Sources/Pladder/Overlay/OverlayController.swift @@ -114,15 +114,14 @@ final class OverlayController { if visible { scheduleHide(after: .zero, flight: false) } return } - // A polish cycle is seconds, not milliseconds: keep the pill up, - // say what is happening, and let `.polishing` and then `.idle` - // take over. + // The pill stays up so the flight out does not start before it + // has begun: the polish pass is seconds, not milliseconds, and + // the pill carries on showing the recording row across it. if coordinator.willPolish { cancelSpinner() model.partialTranscript = nil model.state = .transcribing cancelHide() - present(flight: true) return } // A new partial can re-run this while the spinner is already @@ -130,21 +129,34 @@ final class OverlayController { guard spinnerTask == nil else { return } if visible { scheduleHide(after: .zero, flight: true) } // Only a transcription that outlasts the delay — a cold engine, a - // long merge, a release inside a warm pass — brings the pill back. + // long merge, a release inside a warm pass, the polish pass that + // runs with `polishDictations` set — brings the pill back. spinnerTask = Task { [weak self] in try? await Task.sleep(for: Self.spinnerDelay) guard !Task.isCancelled, let self, self.running else { return } - guard self.coordinator.state == .transcribing, self.model.style != .menuBar else { return } + // Transcription still finishing, or the polish pass running: + // the state may have moved on between the delay and here. + switch self.coordinator.state { + case .transcribing, .polishing: break + default: return + } + guard self.model.style != .menuBar else { return } self.model.partialTranscript = nil self.model.state = .transcribing self.cancelHide() self.present(flight: true) } case .polishing: + // Menu never shows the pill (errors aside), so a polish pass in + // Menu style fades whatever may be on screen out. guard model.style != .menuBar else { if visible { scheduleHide(after: .zero, flight: false) } return } + // The pill is already up from `.transcribing` — kept there across + // the model pass — so this only morphs it onto the Polishing + // row. `present` stays for the case where the pass began without + // it, e.g. a discard in between: it would then fly in fresh. cancelSpinner() model.state = state cancelHide() From a1c7a4e1281f5751e96e2dfbf4cf7aaeb779afaf Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:45:47 +0200 Subject: [PATCH 4/5] Overlay: every polished dictation dives out, short ones included With polish on, a transcript under the polish minimum never reaches .polishing, so the pill held up on the Transcribing row faded in place at idle. The controller now remembers that it is holding the pill for a polish pass and dives it out however the pass ended. Reverts the spinner guard's .polishing case, which no path reached. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Pladder/Overlay/OverlayController.swift | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/Sources/Pladder/Overlay/OverlayController.swift b/Sources/Pladder/Overlay/OverlayController.swift index 395a529..026d7b8 100644 --- a/Sources/Pladder/Overlay/OverlayController.swift +++ b/Sources/Pladder/Overlay/OverlayController.swift @@ -16,6 +16,11 @@ final class OverlayController { private var presentTask: Task? private var running = false private var visible = false + /// The pill is being held up across a polish pass. It leaves by the dive + /// like a pasted dictation, not by the spinner's fade, and that has to + /// hold even when `.polishing` never comes: a transcript under the + /// polish minimum is pasted straight from `.transcribing`. + private var heldForPolish = false /// How long transcription has to run before the pill comes back to say so. /// A normal dictation is pasted well inside this, and progress shown for @@ -94,6 +99,7 @@ final class OverlayController { switch state { case .recording: cancelSpinner() + heldForPolish = false // Menu Bar relies on the menu bar glyph alone, so nothing is // presented. If the style was switched mid-dictation the pill may // already be up; fade it out the same way idle does. @@ -114,11 +120,11 @@ final class OverlayController { if visible { scheduleHide(after: .zero, flight: false) } return } - // The pill stays up so the flight out does not start before it - // has begun: the polish pass is seconds, not milliseconds, and - // the pill carries on showing the recording row across it. + // A polish cycle is seconds, not milliseconds: the pill stays up + // and says what is happening until `.idle` dives it out. if coordinator.willPolish { cancelSpinner() + heldForPolish = true model.partialTranscript = nil model.state = .transcribing cancelHide() @@ -129,18 +135,11 @@ final class OverlayController { guard spinnerTask == nil else { return } if visible { scheduleHide(after: .zero, flight: true) } // Only a transcription that outlasts the delay — a cold engine, a - // long merge, a release inside a warm pass, the polish pass that - // runs with `polishDictations` set — brings the pill back. + // long merge, a release inside a warm pass — brings the pill back. spinnerTask = Task { [weak self] in try? await Task.sleep(for: Self.spinnerDelay) guard !Task.isCancelled, let self, self.running else { return } - // Transcription still finishing, or the polish pass running: - // the state may have moved on between the delay and here. - switch self.coordinator.state { - case .transcribing, .polishing: break - default: return - } - guard self.model.style != .menuBar else { return } + guard self.coordinator.state == .transcribing, self.model.style != .menuBar else { return } self.model.partialTranscript = nil self.model.state = .transcribing self.cancelHide() @@ -153,11 +152,11 @@ final class OverlayController { if visible { scheduleHide(after: .zero, flight: false) } return } - // The pill is already up from `.transcribing` — kept there across - // the model pass — so this only morphs it onto the Polishing - // row. `present` stays for the case where the pass began without - // it, e.g. a discard in between: it would then fly in fresh. + // Normally the pill is already up from `.transcribing` and this + // only morphs it onto the Polishing row; `present` covers a + // release inside the present delay, where it flies in fresh. cancelSpinner() + heldForPolish = true model.state = state cancelHide() present(flight: true) @@ -171,6 +170,7 @@ final class OverlayController { // must never be silent. They fade in place rather than fly: an // alarm should be there at once, not arrive a moment later. cancelSpinner() + heldForPolish = false model.state = state cancelHide() present(flight: false) @@ -190,16 +190,14 @@ final class OverlayController { // clipboard hint — and leaves the screen with it. `scheduleHide` // resets the model once the panel is out. The clipboard hint // leaves the way a pasted dictation does, collapsing into the - // disc and diving, so the two paths end alike; only the spinner - // and an error fade in place. + // disc and diving, so the two paths end alike, and so does a + // pill held up across a polish pass; only the spinner and an + // error fade in place. cancelSpinner() + let flight = model.state == .copied || heldForPolish + heldForPolish = false guard visible else { return } - // The clipboard hint collapses into the disc and dives; so does a - // polished dictation's pill, which is a recording pill held up - // across the model pass. The spinner and an error fade in place. - scheduleHide( - after: .zero, - flight: model.state == .copied || model.state == .polishing) + scheduleHide(after: .zero, flight: flight) } } From 26ea90b25bd9e11a957c080bded7f7e3d7036436 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:07:44 +0200 Subject: [PATCH 5/5] Overlay demo: every overlay path on screen without dictating Pladder --overlay-demo plays a plain, slow, clipboard, polish and short polish dictation through the real coordinator and pill, with stand-ins for the engine, microphone, paste, refiner and hotkey, and logs each state change with its wall-clock time. scripts/overlay-demo.sh records the screen around it and cuts a labelled contact sheet per path from the release to the end of the fly-out. It is how the polished pill's dive was checked, and it never touches a copy of Pladder in use. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 + Sources/Pladder/OverlayDemo.swift | 183 ++++++++++++++++++++++++++++++ Sources/Pladder/PladderApp.swift | 8 +- scripts/overlay-demo.sh | 38 +++++++ scripts/overlay-sheets.swift | 86 ++++++++++++++ 5 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 Sources/Pladder/OverlayDemo.swift create mode 100755 scripts/overlay-demo.sh create mode 100644 scripts/overlay-sheets.swift diff --git a/CLAUDE.md b/CLAUDE.md index b6f3593..da38ab4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,7 @@ swift run -c release pladder-cli