From f9d3399020450855b4961be9ebfabea5349a7b3f Mon Sep 17 00:00:00 2001 From: Josh Guice Date: Mon, 18 May 2026 10:48:47 -0700 Subject: [PATCH 1/2] feat: add custom dictionary corrections --- Cargo.lock | 7 + Cargo.toml | 3 +- README.md | 2 +- docs/CONFIGURATION.md | 59 +- .../specs/2026-04-28-custom-dictionary.md | 337 ++++++++++++ src/config.rs | 44 +- src/dictionary.rs | 506 ++++++++++++++++++ src/macos.rs | 90 +++- src/macos_ui.rs | 25 +- src/main.rs | 117 +++- 10 files changed, 1167 insertions(+), 23 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-28-custom-dictionary.md create mode 100644 src/dictionary.rs diff --git a/Cargo.lock b/Cargo.lock index e6c5c45..31eb82a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1919,6 +1919,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1990,6 +1996,7 @@ dependencies = [ "tokio", "toml", "transcribe-rs", + "unicode-segmentation", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a9c9168..44f0af1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ log = "0.4" nix = { version = "0.29", features = ["signal"] } serde = { version = "1", features = ["derive"] } toml = "0.8" +unicode-segmentation = "1" [target.'cfg(target_os = "linux")'.dependencies] evdev = { version = "0.13", features = ["tokio"] } @@ -59,7 +60,7 @@ objc2-app-kit = { version = "0.2", features = [ "NSBox", "NSUserInterfaceLayout", ] } -objc2-foundation = { version = "0.2", features = ["NSString", "NSArray", "NSThread", "NSTimer", "NSDate", "NSGeometry"] } +objc2-foundation = { version = "0.2", features = ["NSString", "NSArray", "NSData", "NSThread", "NSTimer", "NSDate", "NSGeometry"] } [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index d1a0a0a..a2d5fe0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Installs the matching `.rpm` / `.deb`, fetches the model, and starts the user se ## Docs - [Install](docs/INSTALL.md) — requirements, manual package install, from source, verify. -- [Configuration](docs/CONFIGURATION.md) — `config.toml`, env vars, PTT-key aliases, recording indicator. +- [Configuration](docs/CONFIGURATION.md) — `config.toml`, custom dictionary corrections, env vars, PTT-key aliases, recording indicator. - [Architecture](docs/ARCHITECTURE.md) — daemon/watcher on Linux, single-process on macOS. - [Troubleshooting](docs/TROUBLESHOOTING.md) - [Uninstall](docs/UNINSTALL.md) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b8e7577..09a196a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,6 +1,6 @@ # Configuration -The basics — hold-to-talk, default key, changing the key — are in the [README quickstart](../README.md#quickstart). This page is the full reference: config file format, env var overrides, the PTT-key alias table, the recording indicator, and how services are laid out on Linux. +The basics — hold-to-talk, default key, changing the key — are in the [README quickstart](../README.md#quickstart). This page is the full reference: config file format, custom dictionary corrections, env var overrides, the PTT-key alias table, the recording indicator, and how services are laid out on Linux. ## Config file @@ -34,21 +34,66 @@ auto_paste = true # a single pasteboard and the auto-paste flow already uses it. write_clipboard = false +# macOS: restore the previous clipboard contents after auto-pasting. +# false = leave dictated text in the clipboard after paste. +restore_clipboard_after_paste = true + # Drop fillers (uh, um, er, ah, erm, hmm) and collapse stuttered # repetitions (`I I I think` → `I think`). filter_filler_words = true ``` +## Custom dictionary + +utter's dictionary is a separate TOML file for correcting repeated +speech-to-text mistakes. It is local-only and is reloaded on the next dictation, +so dictionary edits do not require restarting utter. + +- Linux: `~/.config/utter/dictionary.toml` +- macOS: `~/Library/Application Support/utter/dictionary.toml` + +Use the CLI on either platform: + +```bash +utter dictionary add LUFS --replace luffs +utter dictionary add AcmeCloud --replace "acme cloud" --replace "acme clout" +utter dictionary list +utter dictionary remove LUFS +utter dictionary path +``` + +Dictionary file format: + +```toml +version = 1 + +[[entries]] +term = "LUFS" +replace = ["luffs"] + +[[entries]] +term = "AcmeCloud" +replace = ["acme cloud", "acme clout"] +``` + +`term` is pasted exactly as written, including casing and punctuation. Each +`replace` phrase is a heard-as transcription that should be rewritten to +`term`; at least one `--replace` phrase is required when adding an entry. +Matching is case-insensitive, Unicode-aware, phrase-boundary-aware, and +non-recursive. Terms and replacement phrases are limited to 60 displayed +characters. + ## Env var overrides Every field above is overridable at runtime via an environment variable with the same name, upper-cased and prefixed `UTTER_` — e.g. `UTTER_AUTO_PASTE=0` wins over `auto_paste = true` in the file. Useful for one-off runs (`UTTER_AUTO_PASTE=0 utter daemon`) or systemd-drop-in tweaks without editing the file: -| Env var | Values | Overrides field | Purpose | -|-----------------------------|-----------|-----------------------|-------------------------------------------------------------------------| -| `UTTER_KEY` | name/code | `key` | PTT key. | -| `UTTER_AUTO_PASTE` | `0` / `1` | `auto_paste` | Synthesize the paste shortcut. | -| `UTTER_WRITE_CLIPBOARD` | `0` / `1` | `write_clipboard` | Also write the regular clipboard (Linux only). | -| `UTTER_FILTER_FILLER_WORDS` | `0` / `1` | `filter_filler_words` | Drop fillers (uh/um/er/ah/erm/hmm), collapse stutters. | +| Env var | Values | Overrides field | Purpose | +|----------------------------------------|-----------|-----------------------------------|-------------------------------------------------------------------------| +| `UTTER_KEY` | name/code | `key` | PTT key. | +| `UTTER_AUTO_PASTE` | `0` / `1` | `auto_paste` | Synthesize the paste shortcut. | +| `UTTER_WRITE_CLIPBOARD` | `0` / `1` | `write_clipboard` | Also write the regular clipboard (Linux only). | +| `UTTER_RESTORE_CLIPBOARD_AFTER_PASTE` | `0` / `1` | `restore_clipboard_after_paste` | Restore previous clipboard contents after macOS auto-paste. | +| `UTTER_FILTER_FILLER_WORDS` | `0` / `1` | `filter_filler_words` | Drop fillers (uh/um/er/ah/erm/hmm), collapse stutters. | These stay env-only (third-party tools, not utter's config): diff --git a/docs/superpowers/specs/2026-04-28-custom-dictionary.md b/docs/superpowers/specs/2026-04-28-custom-dictionary.md new file mode 100644 index 0000000..a7404fc --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-custom-dictionary.md @@ -0,0 +1,337 @@ +# Custom dictionary — design + +Reference behavior: Wispr Flow's dictionary includes replacement rules that fix +consistent wrong output after transcription. utter should support that model +while preserving its local-only, fast, no-cloud architecture. + +## Goal + +Users can teach utter names, acronyms, product names, jargon, and repeated +misrecognitions. The feature must work the same way on Linux and macOS. The +management surface can differ by platform: + +- Linux: CLI first. +- macOS: same CLI plus a native menu/window UI later. +- Runtime behavior: shared Rust code, same dictionary file format, same + correction semantics. + +Success for the first implementation: a user can add a correction for a repeated +speech-to-text mistake, dictate, and get corrected text pasted without any +network call or service restart. + +## Non-negotiables + +1. **Local only.** No cloud calls, no telemetry, no remote dictionary sync. +2. **Fast.** Dictionary processing should be negligible compared with Parakeet + inference. Target: under 1 ms for post-processing with a few hundred entries; + recognition-time boosting, if added through `transcribe-rs`, should stay under + 5% transcription latency overhead in normal dictionaries. +3. **Cross-platform semantics.** Linux and macOS may expose different UIs, but + they load the same file and use the same correction engine. +4. **No restart for dictionary edits.** The daemon should pick up dictionary + changes on the next dictation. +5. **Config separation.** The dictionary is user content, not daemon/service + configuration. Store it separately from `config.toml`. + +## Dictionary file + +Path: + +- Linux: `~/.config/utter/dictionary.toml` +- macOS: `~/Library/Application Support/utter/dictionary.toml` + +Schema v1: + +```toml +version = 1 + +[[entries]] +term = "API" +replace = ["a pie", "A.P.I."] + +[[entries]] +term = "QMK" +replace = ["cue em kay"] + +[[entries]] +term = "McKenzie" +replace = ["Mackenzie", "MacKenzie"] + +[[entries]] +term = "Draft" +replace = ["Draught"] +``` + +Rules: + +- `term` is the desired output and the future recognition-time vocabulary word. +- `replace` is required for CLI-created entries. Each string is a + post-transcription trigger rewritten to `term`. +- Term-only entries may exist after manual file edits, but phase 1 pasted output + changes only when a `replace` trigger matches. +- Empty or whitespace-only `term` / `replace` values are invalid. +- `term` is limited to 60 Unicode grapheme clusters for v1, matching Wispr's + user-facing constraint and keeping future UI rows predictable. Replacement + triggers use the same limit unless real-world examples show a need for longer + phrases. +- Duplicate `term`s are merged by CLI/UI tools. Manual duplicate entries are + accepted at load time but normalized on the next CLI/UI write. +- Matching is case-insensitive by default, but output always uses the exact + casing and punctuation from `term`. +- Matching is Unicode-aware from day one. Users should be able to correct names + with accents, non-ASCII punctuation, and emoji. +- Replacements are phrase-aware, not blind substring replacement. `draft` must + not rewrite `redraft` or `drafted`; an emoji replacement should still work as + an exact symbol match. +- Apply the longest matching trigger first. Ties use file order. +- Replacements are not recursive. One left-to-right pass prevents loops like + `a -> b`, `b -> a`. + +Examples: + +- `utter dictionary add LUFS --replace luffs` rewrites `luffs` to `LUFS`. +- `utter dictionary add AcmeCloud --replace "acme cloud" --replace "acme clout"` + rewrites those heard-as forms to `AcmeCloud`. +- If the desired output is spelled with punctuation, make that the term and add + the heard-as form: `utter dictionary add "C++" --replace "see plus plus"`. + +Possible future fields, not v1: + +```toml +boost = true +boost_weight = 1.5 +case_sensitive = false +notes = "Project-specific acronym" +``` + +Do not add these until the implementation needs them. + +## CLI + +Add a cross-platform `dictionary` subcommand: + +```bash +utter dictionary add LUFS --replace luffs +utter dictionary add AcmeCloud --replace "acme cloud" +utter dictionary add API --replace "a pie" +utter dictionary remove API +utter dictionary list +utter dictionary path +``` + +Behavior: + +- `add TERM` without at least one `--replace WRONG` exits with an error because + term-only entries do not affect phase 1 corrections. +- `add TERM --replace WRONG` creates the entry if missing and appends `WRONG` if + it is not already present. +- `remove TERM` removes the full entry. +- `list` prints a stable, human-readable table. +- `path` prints the dictionary file path for manual editing. +- Writes are atomic: write a temp file in the same directory, then rename. +- CLI writes normalize duplicate entries and duplicate replacements. + +CSV import can be a follow-up: + +```bash +utter dictionary import words.csv +``` + +Import format should mirror Wispr-style usage: + +- One column: vocabulary terms. +- Two columns: `wrong,correct`, treated as `term = correct`, `replace += wrong`. + +## Runtime flow + +Current flow: + +```text +audio -> Parakeet -> filler/stutter cleanup -> trailing space -> paste +``` + +Dictionary v1 flow: + +```text +audio + -> Parakeet + -> filler/stutter cleanup if enabled + -> dictionary post-processing + -> trailing space + -> paste +``` + +Loading: + +- Add a shared `src/dictionary.rs` module. +- Daemon tracks dictionary path, last modified time, and last-good parsed + dictionary. +- On each `stop`, before post-processing, reload if the file mtime changed. +- If the dictionary file is missing, use an empty dictionary. +- If parsing fails, log a warning and keep using the last-good dictionary. A bad + manual edit should not break dictation. + +Ordering: + +1. Raw transcript from Parakeet. +2. Optional filler/stutter cleanup. +3. Dictionary replacements. +4. Trim and append utter's existing trailing space. +5. Emit text. + +This order lets users write replacements against the cleaned text they actually +see, while preserving the existing filler cleanup behavior. + +## Shared implementation shape + +New pure module: + +```text +src/dictionary.rs + Dictionary + DictionaryEntry + DictionaryStore + default_path() + load() + save_atomic() + add_term() + add_replacement() + remove_term() + apply_replacements() +``` + +The module must have no platform-specific behavior except path resolution via +`dirs::config_dir()`, matching `Config::default_path()`. + +The macOS UI should call this same module rather than implementing its own file +editing logic. That keeps Linux CLI, macOS CLI, and macOS UI aligned. + +## Recognition-time vocabulary boosting + +Post-processing fixes consistent mistakes but does not improve recognition. To +match the vocabulary side of Wispr's feature, investigate a local-only Parakeet +hotword path in `transcribe-rs`. + +Current `transcribe-rs` status: + +- utter uses `transcribe-rs = { version = "0.3", features = ["onnx"] }`. +- Current resolved crate: `transcribe-rs 0.3.11`. +- `ParakeetParams` currently exposes language and timestamp granularity, but no + hotword/vocabulary parameter. +- Parakeet decoding is a greedy loop over vocabulary logits; adding a local + score bonus before argmax is plausible. + +Prototype API, preferably upstream: + +```rust +pub struct ParakeetParams { + pub language: Option, + pub timestamp_granularity: Option, + pub vocabulary: Vec, + pub vocabulary_boost: f32, +} +``` + +Prototype algorithm: + +1. Compile dictionary `term`s into Parakeet token sequences using the model + vocabulary. +2. Build a trie of hotword token sequences. +3. During greedy decode, track active hotword prefixes. +4. Before argmax, add a small bonus to logits for tokens that continue an active + hotword prefix or start a hotword. +5. Keep default behavior byte-for-byte unchanged when `vocabulary` is empty. + +Acceptance criteria for adopting boosting: + +- No cloud dependency. +- No model retraining. +- No meaningful startup cost for typical dictionaries. +- No more than 5% latency overhead on short dictations with a few hundred terms. +- Quality improves for targeted terms without creating obvious false positives + in ordinary dictation. + +If upstream will not take it quickly, use a fork: + +```toml +[patch.crates-io] +transcribe-rs = { git = "https://github.com//transcribe-rs", rev = "..." } +``` + +Pin by commit SHA, not a moving branch, before release packaging. + +## macOS UI + +Do not block v1 on the UI. The first implementation should ship the shared file +format and CLI on both platforms. + +Later macOS UI: + +- Menu item: "Dictionary..." +- Window with search, add, edit, delete. +- Each entry shows `term` and replacement phrases. +- UI writes through `src/dictionary.rs` so behavior remains identical to CLI. +- No separate macOS-only dictionary format. + +## Tests + +Unit tests: + +- Missing dictionary file loads as empty. +- TOML round-trip. +- Duplicate terms/replacements normalize on write. +- Replacement matching is case-insensitive. +- Replacement output honors the exact case and punctuation in `term`. +- Replacement matching respects word/phrase boundaries. +- Longest match wins. +- Replacements are non-recursive. +- Bad TOML keeps last-good dictionary in `DictionaryStore`. + +CLI tests where practical: + +- `dictionary add TERM` fails with a clear error. +- `dictionary add TERM --replace WRONG` +- `dictionary remove TERM` +- `dictionary list` + +Manual smoke: + +1. Add `LUFS --replace luffs`. +2. Dictate text that Parakeet produces as "luffs". +3. Confirm pasted output says `LUFS`. +4. Add `AcmeCloud --replace "acme cloud"`. +5. Dictate text that Parakeet produces as "acme cloud". +6. Confirm pasted output says `AcmeCloud`. +7. Edit dictionary while daemon is running. +8. Confirm next dictation picks up the edit without restart. + +## Rollout + +Phase 1: + +- Add shared dictionary file/module. +- Add CLI management. +- Apply post-transcription replacements. +- Update `docs/CONFIGURATION.md` and README docs. + +Phase 2: + +- Prototype Parakeet vocabulary boosting in `transcribe-rs`. +- Decide upstream PR vs fork. +- Wire dictionary `term`s into `ParakeetParams`. + +Phase 3: + +- Add macOS dictionary UI over the shared module. +- Add CSV import if still wanted. + +## Resolved product decisions + +- Phase 1 correction behavior is explicit: `replace` triggers rewrite to the + exact `term` the user provided. The CLI requires at least one `--replace` + trigger because term-only entries do not change phase 1 pasted output. +- Terms and replacement triggers are limited to 60 Unicode grapheme clusters for + v1. +- Matching is Unicode-aware from day one. +- Dictionary entries are global only for v1. App/context-specific dictionaries + are out of scope. diff --git a/src/config.rs b/src/config.rs index c3935b1..c845444 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,9 @@ pub struct Config { /// Also write dictations to the regular clipboard alongside the /// primary selection. Default leaves the regular clipboard untouched. pub write_clipboard: bool, + /// On macOS, restore the previous pasteboard contents after utter + /// writes dictation text and synthesizes Cmd+V. Ignored on Linux. + pub restore_clipboard_after_paste: bool, /// Drop filler words (uh, um, er, ah, erm, hmm) and collapse stuttered /// repetitions (`I I I think` → `I think`, `wh wh wh what` → `what`) /// before emitting text. @@ -32,6 +35,7 @@ impl Default for Config { key: "rightmeta".to_string(), auto_paste: true, write_clipboard: false, + restore_clipboard_after_paste: true, filter_filler_words: true, } } @@ -54,7 +58,8 @@ impl Config { format!( "# utter configuration. Managed by `utter set-key` and edited by hand.\n\ # Env vars (UTTER_KEY, UTTER_AUTO_PASTE, UTTER_WRITE_CLIPBOARD,\n\ - # UTTER_FILTER_FILLER_WORDS) override any value set here.\n\ + # UTTER_RESTORE_CLIPBOARD_AFTER_PASTE, UTTER_FILTER_FILLER_WORDS)\n\ + # override any value set here.\n\ \n\ # PTT key: named alias (rightmeta, capslock, f13, ...) or numeric evdev\n\ # keycode as a string.\n\ @@ -67,12 +72,17 @@ impl Config { # users). Default leaves the regular clipboard untouched.\n\ write_clipboard = {write_clipboard}\n\ \n\ + # macOS: restore the previous clipboard contents after auto-pasting.\n\ + # false = leave dictated text in the clipboard after paste.\n\ + restore_clipboard_after_paste = {restore_clipboard_after_paste}\n\ + \n\ # Drop fillers (uh, um, er, ah, erm, hmm) and collapse stuttered\n\ # repetitions (`I I I think` → `I think`).\n\ filter_filler_words = {filter_filler_words}\n", key = self.key, auto_paste = self.auto_paste, write_clipboard = self.write_clipboard, + restore_clipboard_after_paste = self.restore_clipboard_after_paste, filter_filler_words = self.filter_filler_words, ) } @@ -91,6 +101,11 @@ impl Config { self.write_clipboard = parse_bool_env("UTTER_WRITE_CLIPBOARD", v).unwrap_or(self.write_clipboard); } + if let Some(v) = env.get("UTTER_RESTORE_CLIPBOARD_AFTER_PASTE") { + self.restore_clipboard_after_paste = + parse_bool_env("UTTER_RESTORE_CLIPBOARD_AFTER_PASTE", v) + .unwrap_or(self.restore_clipboard_after_paste); + } if let Some(v) = env.get("UTTER_FILTER_FILLER_WORDS") { self.filter_filler_words = parse_bool_env("UTTER_FILTER_FILLER_WORDS", v).unwrap_or(self.filter_filler_words); @@ -135,14 +150,19 @@ impl Config { } // Used by Linux's wl_copy path indirectly via the field, and by tests. - // The macOS UI temporarily hides the toggle (no snapshot-and-restore - // around Cmd+V yet); kept available for the imminent follow-up PR. #[allow(dead_code)] pub fn with_write_clipboard(mut self, v: bool) -> Self { self.write_clipboard = v; self } + // Consumed by the macOS menu-bar toggle. Linux ignores the setting. + #[allow(dead_code)] + pub fn with_restore_clipboard_after_paste(mut self, v: bool) -> Self { + self.restore_clipboard_after_paste = v; + self + } + // Consumed only by the macOS menu-bar toggles today; Linux UI // would use it once built. Keep it available. #[allow(dead_code)] @@ -226,6 +246,10 @@ mod tests { assert_eq!(c.key, "rightmeta"); assert!(c.auto_paste, "auto_paste on by default"); assert!(!c.write_clipboard, "write_clipboard off by default — don't pollute"); + assert!( + c.restore_clipboard_after_paste, + "restore_clipboard_after_paste on by default" + ); assert!(c.filter_filler_words, "filter_filler_words on by default"); } @@ -235,6 +259,7 @@ mod tests { key: "capslock".to_string(), auto_paste: false, write_clipboard: true, + restore_clipboard_after_paste: false, filter_filler_words: false, }; let text = original.to_toml(); @@ -249,6 +274,7 @@ mod tests { assert_eq!(c.key, "f13"); assert!(c.auto_paste, "other fields default"); assert!(!c.write_clipboard); + assert!(c.restore_clipboard_after_paste); } #[test] @@ -282,12 +308,14 @@ mod tests { ("UTTER_KEY", "f13"), ("UTTER_AUTO_PASTE", "0"), ("UTTER_WRITE_CLIPBOARD", "1"), + ("UTTER_RESTORE_CLIPBOARD_AFTER_PASTE", "0"), ("UTTER_FILTER_FILLER_WORDS", "0"), ]); let c = base.with_env_overrides(&e); assert_eq!(c.key, "f13"); assert!(!c.auto_paste); assert!(c.write_clipboard); + assert!(!c.restore_clipboard_after_paste); assert!(!c.filter_filler_words); } @@ -297,6 +325,7 @@ mod tests { key: "capslock".to_string(), auto_paste: false, write_clipboard: true, + restore_clipboard_after_paste: false, filter_filler_words: false, }; let c = base.clone().with_env_overrides(&env(&[("PATH", "/usr/bin")])); @@ -308,9 +337,11 @@ mod tests { let c = Config::default().with_env_overrides(&env(&[ ("UTTER_AUTO_PASTE", "false"), ("UTTER_WRITE_CLIPBOARD", "true"), + ("UTTER_RESTORE_CLIPBOARD_AFTER_PASTE", "false"), ])); assert!(!c.auto_paste); assert!(c.write_clipboard); + assert!(!c.restore_clipboard_after_paste); } #[test] @@ -367,6 +398,7 @@ mod tests { let c = Config { auto_paste: false, write_clipboard: true, + restore_clipboard_after_paste: false, filter_filler_words: false, key: "rightmeta".to_string(), }; @@ -375,6 +407,10 @@ mod tests { // Other fields preserved. assert_eq!(updated.auto_paste, c.auto_paste); assert_eq!(updated.write_clipboard, c.write_clipboard); + assert_eq!( + updated.restore_clipboard_after_paste, + c.restore_clipboard_after_paste + ); assert_eq!(updated.filter_filler_words, c.filter_filler_words); } @@ -386,10 +422,12 @@ mod tests { .clone() .with_auto_paste(false) .with_write_clipboard(true) + .with_restore_clipboard_after_paste(false) .with_filter_filler_words(false); assert!(!flipped.auto_paste); assert!(flipped.write_clipboard); + assert!(!flipped.restore_clipboard_after_paste); assert!(!flipped.filter_filler_words); // Unrelated fields preserved. assert_eq!(flipped.key, base.key); diff --git a/src/dictionary.rs b/src/dictionary.rs new file mode 100644 index 0000000..c6111d6 --- /dev/null +++ b/src/dictionary.rs @@ -0,0 +1,506 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; +use unicode_segmentation::UnicodeSegmentation; + +const CURRENT_VERSION: u32 = 1; +const MAX_GRAPHEMES: usize = 60; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Dictionary { + pub version: u32, + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DictionaryEntry { + pub term: String, + #[serde(default)] + pub replace: Vec, +} + +pub struct DictionaryStore { + path: PathBuf, + last_modified: Option, + dictionary: Dictionary, +} + +impl Default for Dictionary { + fn default() -> Self { + Self { + version: CURRENT_VERSION, + entries: Vec::new(), + } + } +} + +impl Dictionary { + pub fn default_path() -> Result { + Ok(dirs::config_dir() + .context("no XDG config dir")? + .join("utter/dictionary.toml")) + } + + pub fn load_from(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let text = + std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + Self::from_toml(&text) + } + + pub fn from_toml(text: &str) -> Result { + let dictionary: Self = toml::from_str(text).context("parse dictionary TOML")?; + dictionary.validate()?; + Ok(dictionary) + } + + pub fn to_toml(&self) -> String { + toml::to_string_pretty(self).expect("dictionary serialization should not fail") + } + + pub fn save_atomic(&self, path: &Path) -> Result<()> { + self.validate()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + + let tmp = path.with_extension(format!("toml.tmp.{}", std::process::id())); + std::fs::write(&tmp, self.to_toml()).with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, path) + .with_context(|| format!("rename {} to {}", tmp.display(), path.display()))?; + Ok(()) + } + + pub fn add_term(&mut self, term: &str) -> Result { + validate_value("term", term)?; + if self.entries.iter().any(|entry| entry.term == term) { + return Ok(false); + } + self.entries.push(DictionaryEntry { + term: term.to_string(), + replace: Vec::new(), + }); + Ok(true) + } + + pub fn add_replacement(&mut self, term: &str, replacement: &str) -> Result { + validate_value("term", term)?; + validate_value("replacement", replacement)?; + + let entry = if let Some(entry) = self.entries.iter_mut().find(|entry| entry.term == term) { + entry + } else { + self.entries.push(DictionaryEntry { + term: term.to_string(), + replace: Vec::new(), + }); + self.entries + .last_mut() + .expect("entry was just pushed and must exist") + }; + + if entry + .replace + .iter() + .any(|existing| existing.to_lowercase() == replacement.to_lowercase()) + { + return Ok(false); + } + entry.replace.push(replacement.to_string()); + Ok(true) + } + + pub fn remove_term(&mut self, term: &str) -> bool { + let before = self.entries.len(); + self.entries.retain(|entry| entry.term != term); + self.entries.len() != before + } + + pub fn normalize(&mut self) { + let mut normalized: Vec = Vec::with_capacity(self.entries.len()); + for entry in std::mem::take(&mut self.entries) { + if let Some(existing) = normalized.iter_mut().find(|e| e.term == entry.term) { + for replacement in entry.replace { + if !existing + .replace + .iter() + .any(|r| r.to_lowercase() == replacement.to_lowercase()) + { + existing.replace.push(replacement); + } + } + } else { + let mut deduped = DictionaryEntry { + term: entry.term, + replace: Vec::new(), + }; + for replacement in entry.replace { + if !deduped + .replace + .iter() + .any(|r| r.to_lowercase() == replacement.to_lowercase()) + { + deduped.replace.push(replacement); + } + } + normalized.push(deduped); + } + } + self.entries = normalized; + } + + pub fn apply_replacements(&self, text: &str) -> String { + let rules = self.rules(); + if rules.is_empty() || text.is_empty() { + return text.to_string(); + } + + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < text.len() { + if let Some((end, term)) = rules + .iter() + .find_map(|rule| rule.matches_at(text, i).map(|end| (end, rule.term))) + { + out.push_str(term); + i = end; + continue; + } + + let ch = text[i..] + .chars() + .next() + .expect("i always points at a char boundary"); + out.push(ch); + i += ch.len_utf8(); + } + out + } + + fn validate(&self) -> Result<()> { + if self.version != CURRENT_VERSION { + return Err(anyhow!( + "unsupported dictionary version {} (expected {CURRENT_VERSION})", + self.version + )); + } + for entry in &self.entries { + validate_value("term", &entry.term)?; + for replacement in &entry.replace { + validate_value("replacement", replacement)?; + } + } + Ok(()) + } + + fn rules(&self) -> Vec> { + let mut rules = Vec::new(); + for entry in &self.entries { + for replacement in &entry.replace { + rules.push(Rule::new(replacement, &entry.term)); + } + } + rules.sort_by(|a, b| b.graphemes.cmp(&a.graphemes)); + rules + } +} + +impl DictionaryStore { + pub fn new(path: PathBuf) -> Self { + Self { + path, + last_modified: None, + dictionary: Dictionary::default(), + } + } + + pub fn reload_if_changed(&mut self) { + let modified = match std::fs::metadata(&self.path).and_then(|m| m.modified()) { + Ok(modified) => Some(modified), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + log::warn!("dictionary metadata {}: {e:#}", self.path.display()); + return; + } + }; + + if modified == self.last_modified { + return; + } + + match Dictionary::load_from(&self.path) { + Ok(dictionary) => { + self.dictionary = dictionary; + self.last_modified = modified; + log::info!( + "dictionary loaded from {} ({} entr{})", + self.path.display(), + self.dictionary.entries.len(), + if self.dictionary.entries.len() == 1 { + "y" + } else { + "ies" + }, + ); + } + Err(e) => { + log::warn!( + "dictionary parse failed for {}; keeping last-good dictionary: {e:#}", + self.path.display() + ); + } + } + } + + pub fn dictionary(&self) -> &Dictionary { + &self.dictionary + } +} + +struct Rule<'a> { + trigger: &'a str, + trigger_lower: String, + term: &'a str, + chars: usize, + graphemes: usize, +} + +impl<'a> Rule<'a> { + fn new(trigger: &'a str, term: &'a str) -> Self { + Self { + trigger, + trigger_lower: trigger.to_lowercase(), + term, + chars: trigger.chars().count(), + graphemes: trigger.graphemes(true).count(), + } + } + + fn matches_at(&self, text: &str, start: usize) -> Option { + if !text.is_char_boundary(start) { + return None; + } + let end = end_after_chars(text, start, self.chars)?; + let candidate = &text[start..end]; + if candidate.to_lowercase() != self.trigger_lower { + return None; + } + if !has_word_boundary_before(text, start, self.trigger) { + return None; + } + if !has_word_boundary_after(text, end, self.trigger) { + return None; + } + Some(end) + } +} + +fn validate_value(name: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(anyhow!("{name} cannot be empty")); + } + let count = value.graphemes(true).count(); + if count > MAX_GRAPHEMES { + return Err(anyhow!( + "{name} is {count} characters; max is {MAX_GRAPHEMES}" + )); + } + Ok(()) +} + +fn end_after_chars(text: &str, start: usize, count: usize) -> Option { + let mut end = start; + for _ in 0..count { + let ch = text.get(end..)?.chars().next()?; + end += ch.len_utf8(); + } + Some(end) +} + +fn has_word_boundary_before(text: &str, start: usize, trigger: &str) -> bool { + let Some(first) = trigger.chars().next() else { + return false; + }; + if !is_word_char(first) { + return true; + } + previous_char(text, start).map_or(true, |ch| !is_word_char(ch)) +} + +fn has_word_boundary_after(text: &str, end: usize, trigger: &str) -> bool { + let Some(last) = trigger.chars().next_back() else { + return false; + }; + if !is_word_char(last) { + return true; + } + text.get(end..) + .and_then(|rest| rest.chars().next()) + .map_or(true, |ch| !is_word_char(ch)) +} + +fn previous_char(text: &str, start: usize) -> Option { + text.get(..start)?.chars().next_back() +} + +fn is_word_char(ch: char) -> bool { + ch.is_alphanumeric() || ch == '_' +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dict(entries: Vec) -> Dictionary { + Dictionary { + version: CURRENT_VERSION, + entries, + } + } + + #[test] + fn missing_file_loads_empty() { + let tmp = tempfile::tempdir().unwrap(); + let dictionary = Dictionary::load_from(&tmp.path().join("dictionary.toml")).unwrap(); + assert_eq!(dictionary, Dictionary::default()); + } + + #[test] + fn toml_roundtrips() { + let original = dict(vec![DictionaryEntry { + term: "AcmeCloud".to_string(), + replace: vec!["acme cloud".to_string(), "acme clout".to_string()], + }]); + let parsed = Dictionary::from_toml(&original.to_toml()).unwrap(); + assert_eq!(parsed, original); + } + + #[test] + fn add_and_normalize_dedupes_entries_and_replacements() { + let mut dictionary = dict(vec![ + DictionaryEntry { + term: "LUFS".to_string(), + replace: vec!["luffs".to_string()], + }, + DictionaryEntry { + term: "LUFS".to_string(), + replace: vec!["Luffs".to_string(), "lufz".to_string()], + }, + ]); + dictionary.normalize(); + assert_eq!(dictionary.entries.len(), 1); + assert_eq!(dictionary.entries[0].replace, vec!["luffs", "lufz"]); + + assert!(!dictionary.add_replacement("LUFS", "LUFZ").unwrap()); + assert!(dictionary.add_replacement("LUFS", "loofs").unwrap()); + } + + #[test] + fn replacement_matching_is_case_insensitive_but_output_is_exact_term() { + let dictionary = dict(vec![DictionaryEntry { + term: "AcmeCloud".to_string(), + replace: vec!["acme cloud".to_string()], + }]); + assert_eq!( + dictionary.apply_replacements("I use ACME CLOUD."), + "I use AcmeCloud." + ); + } + + #[test] + fn replacement_matching_respects_word_boundaries() { + let dictionary = dict(vec![DictionaryEntry { + term: "Draft".to_string(), + replace: vec!["draught".to_string()], + }]); + assert_eq!( + dictionary.apply_replacements("draught redraught draughted"), + "Draft redraught draughted" + ); + } + + #[test] + fn longest_match_wins() { + let dictionary = dict(vec![ + DictionaryEntry { + term: "AI".to_string(), + replace: vec!["a i".to_string()], + }, + DictionaryEntry { + term: "OpenAI".to_string(), + replace: vec!["open a i".to_string()], + }, + ]); + assert_eq!( + dictionary.apply_replacements("open a i shipped it"), + "OpenAI shipped it" + ); + } + + #[test] + fn replacements_are_not_recursive() { + let dictionary = dict(vec![ + DictionaryEntry { + term: "b".to_string(), + replace: vec!["a".to_string()], + }, + DictionaryEntry { + term: "c".to_string(), + replace: vec!["b".to_string()], + }, + ]); + assert_eq!(dictionary.apply_replacements("a b"), "b c"); + } + + #[test] + fn unicode_and_emoji_replacements_work() { + let dictionary = dict(vec![ + DictionaryEntry { + term: "Beyonce".to_string(), + replace: vec!["Beyoncé".to_string()], + }, + DictionaryEntry { + term: "✅".to_string(), + replace: vec![":check:".to_string()], + }, + ]); + assert_eq!( + dictionary.apply_replacements("Beyoncé :check:"), + "Beyonce ✅" + ); + } + + #[test] + fn validates_sixty_grapheme_limit() { + let sixty = "🙂".repeat(60); + let sixty_one = "🙂".repeat(61); + assert!(validate_value("term", &sixty).is_ok()); + assert!(validate_value("term", &sixty_one).is_err()); + } + + #[test] + fn store_keeps_last_good_dictionary_when_reload_fails() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("dictionary.toml"); + let original = dict(vec![DictionaryEntry { + term: "LUFS".to_string(), + replace: vec!["luffs".to_string()], + }]); + original.save_atomic(&path).unwrap(); + + let mut store = DictionaryStore::new(path.clone()); + store.reload_if_changed(); + assert_eq!(store.dictionary().apply_replacements("luffs"), "LUFS"); + + std::fs::write(&path, "not = valid = toml").unwrap(); + store.last_modified = None; + store.reload_if_changed(); + assert_eq!(store.dictionary().apply_replacements("luffs"), "LUFS"); + } +} diff --git a/src/macos.rs b/src/macos.rs index 487f2da..5409424 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -458,10 +458,86 @@ pub async fn stop_audio(capture: AudioCapture) -> Result> { // Pasteboard -> synthesize Cmd+V. No ydotool, no wl-copy, no subprocess. // Custom pasteboard type "com.utter.dictation" rides alongside plain text so // users who run Paste / Maccy / Alfred can configure their clipboard manager -// to filter dictations out of history. +// to filter dictations out of history. By default, auto-paste also snapshots +// the previous pasteboard contents and restores them after Cmd+V. const UTTER_PASTEBOARD_TYPE: &str = "com.utter.dictation"; +struct PasteboardSnapshot { + entries: Vec<(objc2::rc::Retained, Vec)>, +} + +fn snapshot_pasteboard() -> Result { + use objc2_app_kit::NSPasteboard; + + unsafe { + let pb = NSPasteboard::generalPasteboard(); + let Some(types) = pb.types() else { + return Ok(PasteboardSnapshot { entries: Vec::new() }); + }; + + let mut entries = Vec::new(); + for index in 0..types.len() { + if let Some(data_type) = types.get_retained(index) { + if let Some(data) = pb.dataForType(&data_type) { + entries.push((data_type, data.bytes().to_vec())); + } + } + } + Ok(PasteboardSnapshot { entries }) + } +} + +fn restore_pasteboard(snapshot: PasteboardSnapshot) -> Result<()> { + use objc2_app_kit::NSPasteboard; + use objc2_foundation::{NSArray, NSData, NSString}; + + unsafe { + let pb = NSPasteboard::generalPasteboard(); + let custom_type = NSString::from_str(UTTER_PASTEBOARD_TYPE); + let still_holds_utter_text = pb + .types() + .map(|types| { + (0..types.len()).any(|index| { + types + .get(index) + .map(|data_type| data_type.isEqualToString(&custom_type)) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + if !still_holds_utter_text { + log::warn!( + "pasteboard changed before restore; leaving current clipboard contents alone" + ); + return Ok(()); + } + + pb.clearContents(); + if snapshot.entries.is_empty() { + return Ok(()); + } + + let types = NSArray::from_vec( + snapshot + .entries + .iter() + .map(|(data_type, _)| data_type.clone()) + .collect(), + ); + pb.declareTypes_owner(&types, None); + + for (data_type, bytes) in snapshot.entries { + let data = NSData::with_bytes(&bytes); + if !pb.setData_forType(Some(&data), &data_type) { + log::warn!("failed to restore one pasteboard data type"); + } + } + } + Ok(()) +} + fn write_pasteboard(text: &str) -> Result<()> { use objc2::rc::Retained; use objc2_app_kit::{NSPasteboard, NSPasteboardTypeString}; @@ -535,10 +611,21 @@ pub async fn emit_text(text: &str, cfg: &Config) -> Result<()> { // reactor stays free. Each chunk is microseconds on Apple Silicon. let text = text.to_string(); let auto_paste = cfg.auto_paste; + let restore_clipboard_after_paste = cfg.restore_clipboard_after_paste; tokio::task::spawn_blocking(move || -> Result<()> { + let snapshot = if auto_paste && restore_clipboard_after_paste { + Some(snapshot_pasteboard()?) + } else { + None + }; + write_pasteboard(&text)?; if auto_paste { synthesize_cmd_v()?; + if let Some(snapshot) = snapshot { + std::thread::sleep(std::time::Duration::from_millis(150)); + restore_pasteboard(snapshot)?; + } } Ok(()) }) @@ -1163,4 +1250,3 @@ pub async fn run_set_key(dry_run: bool, timeout_secs: u64) -> Result<()> { ); Ok(()) } - diff --git a/src/macos_ui.rs b/src/macos_ui.rs index 2c45fed..738236e 100644 --- a/src/macos_ui.rs +++ b/src/macos_ui.rs @@ -69,6 +69,11 @@ declare_class!( flip_config_flag("auto_paste", |c| c.auto_paste, |c, v| c.with_auto_paste(v), sender); } + #[method(toggleRestoreClipboardAfterPaste:)] + fn toggle_restore_clipboard_after_paste(&self, sender: Option<&AnyObject>) { + flip_config_flag("restore_clipboard_after_paste", |c| c.restore_clipboard_after_paste, |c, v| c.with_restore_clipboard_after_paste(v), sender); + } + #[method(toggleFilterFillerWords:)] fn toggle_filter_filler_words(&self, sender: Option<&AnyObject>) { flip_config_flag("filter_filler_words", |c| c.filter_filler_words, |c, v| c.with_filter_filler_words(v), sender); @@ -212,11 +217,13 @@ pub fn run_status_bar_app( cfg.auto_paste, env.contains_key("UTTER_AUTO_PASTE"), ); - // write_clipboard is intentionally not exposed on macOS yet: macOS - // has only one pasteboard, and the auto-paste flow needs text on - // it to do Cmd+V. A future PR will implement true "leave the - // clipboard untouched" via snapshot-and-restore around the paste, - // and re-add this toggle. + add_toggle( + &menu, mtm, target_ref, + "Restore clipboard after paste", + sel!(toggleRestoreClipboardAfterPaste:), + cfg.restore_clipboard_after_paste, + env.contains_key("UTTER_RESTORE_CLIPBOARD_AFTER_PASTE"), + ); add_toggle( &menu, mtm, target_ref, "Filter filler words", @@ -380,6 +387,14 @@ fn notify_restart(flag_name: &str, new_value: bool) { "utter: auto-paste disabled".to_string(), "Dictations are written to the clipboard; paste them yourself with Cmd+V. Restart utter to apply.".to_string(), ), + ("restore_clipboard_after_paste", true) => ( + "utter: clipboard restore enabled".to_string(), + "After auto-paste, utter will restore the previous clipboard contents. Restart utter to apply.".to_string(), + ), + ("restore_clipboard_after_paste", false) => ( + "utter: clipboard restore disabled".to_string(), + "After auto-paste, dictated text will stay in the clipboard. Restart utter to apply.".to_string(), + ), ("filter_filler_words", true) => ( "utter: filler-word filtering enabled".to_string(), "Words like 'uh', 'um', and stuttered repeats will be removed before pasting. Restart utter to apply.".to_string(), diff --git a/src/main.rs b/src/main.rs index 430e66f..bef51d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,8 @@ use transcribe_rs::onnx::Quantization; mod config; use config::Config; +mod dictionary; +use dictionary::{Dictionary, DictionaryStore}; #[cfg(target_os = "macos")] mod macos; @@ -83,6 +85,37 @@ enum Cmd { #[arg(long, default_value = "20")] timeout: u64, }, + /// Manage custom dictionary corrections. + Dictionary { + #[command(subcommand)] + cmd: DictionaryCmd, + }, +} + +#[derive(Subcommand)] +enum DictionaryCmd { + /// Add a desired output term and heard-as replacement phrases. + #[command( + override_usage = "utter dictionary add TERM --replace HEARD_AS [--replace HEARD_AS ...]", + override_help = "Add a dictionary correction.\n\nUsage:\n utter dictionary add TERM --replace HEARD_AS [--replace HEARD_AS ...]\n\nArguments:\n TERM Exact text Utter should paste.\n HEARD_AS Phrase Utter currently transcribes. Repeat --replace for a list.\n\nExamples:\n utter dictionary add LUFS --replace luffs\n utter dictionary add Chikin --replace Chicken --replace \"check in\"\n\nOptions:\n -h, --help Print help" + )] + Add { + /// Desired output text, with exact casing/punctuation to paste. + #[arg(value_name = "TERM")] + term: String, + /// Misheard phrase Utter should rewrite to TERM. Repeat for multiple triggers. + #[arg(long = "replace", value_name = "HEARD_AS")] + replacements: Vec, + }, + /// Remove a dictionary term and all of its replacement phrases. + Remove { + /// Desired output term to remove. + term: String, + }, + /// List dictionary terms and replacement phrases. + List, + /// Print the dictionary file path. + Path, } #[cfg(target_os = "linux")] @@ -144,6 +177,69 @@ async fn dispatch(cli: Cli) -> Result<()> { } Some(Cmd::Watch { key }) => run_watcher(key.as_deref()).await, Some(Cmd::SetKey { dry_run, timeout }) => run_set_key(dry_run, timeout).await, + Some(Cmd::Dictionary { cmd }) => run_dictionary(cmd), + } +} + +fn run_dictionary(cmd: DictionaryCmd) -> Result<()> { + let path = Dictionary::default_path()?; + match cmd { + DictionaryCmd::Add { term, replacements } => { + if replacements.is_empty() { + return Err(anyhow!( + "dictionary add needs at least one --replace phrase.\n\nTERM is the exact text to paste. --replace is what Utter currently transcribes.\nExample: utter dictionary add LUFS --replace luffs\nMultiple phrases: utter dictionary add AcmeCloud --replace \"acme cloud\" --replace \"acme clout\"" + )); + } + let mut dictionary = Dictionary::load_from(&path)?; + dictionary.add_term(&term)?; + let mut added_replacements = 0usize; + for replacement in replacements { + if dictionary.add_replacement(&term, &replacement)? { + added_replacements += 1; + } + } + dictionary.normalize(); + dictionary.save_atomic(&path)?; + + if added_replacements == 0 { + println!("No changes; `{term}` already has those replacements."); + } else { + println!( + "Saved `{term}` with {added_replacements} replacement{} to {}.", + if added_replacements == 1 { "" } else { "s" }, + path.display() + ); + } + Ok(()) + } + DictionaryCmd::Remove { term } => { + let mut dictionary = Dictionary::load_from(&path)?; + if !dictionary.remove_term(&term) { + return Err(anyhow!("dictionary term not found: {term}")); + } + dictionary.save_atomic(&path)?; + println!("Removed `{term}` from {}.", path.display()); + Ok(()) + } + DictionaryCmd::List => { + let dictionary = Dictionary::load_from(&path)?; + if dictionary.entries.is_empty() { + println!("Dictionary is empty ({})", path.display()); + return Ok(()); + } + for entry in dictionary.entries { + if entry.replace.is_empty() { + println!("{} (no replacements)", entry.term); + } else { + println!("{} <- {}", entry.term, entry.replace.join(" | ")); + } + } + Ok(()) + } + DictionaryCmd::Path => { + println!("{}", path.display()); + Ok(()) + } } } @@ -272,6 +368,7 @@ struct Daemon { model: Arc>, state: Mutex, config: Config, + dictionary: Mutex, } async fn run_daemon(model_override: Option) -> Result<()> { @@ -279,10 +376,11 @@ async fn run_daemon(model_override: Option) -> Result<()> { let env = config::utter_env_snapshot(); let cfg = Config::load_or_migrate(&config_path, &env)?; log::info!( - "config loaded from {} (auto_paste={}, write_clipboard={}, filter_filler_words={})", + "config loaded from {} (auto_paste={}, write_clipboard={}, restore_clipboard_after_paste={}, filter_filler_words={})", config_path.display(), cfg.auto_paste, cfg.write_clipboard, + cfg.restore_clipboard_after_paste, cfg.filter_filler_words, ); @@ -315,6 +413,7 @@ async fn run_daemon(model_override: Option) -> Result<()> { model: Arc::new(Mutex::new(model)), state: Mutex::new(State::Idle), config: cfg, + dictionary: Mutex::new(DictionaryStore::new(Dictionary::default_path()?)), }); let sock_cleanup = socket.clone(); @@ -448,13 +547,18 @@ async fn stop_and_transcribe(daemon: &Daemon) -> Result { } else { text.trim().to_string() }; + let corrected = { + let mut store = daemon.dictionary.lock().await; + store.reload_if_changed(); + store.dictionary().apply_replacements(&cleaned) + }; // Append a trailing space so consecutive dictations don't smash together // (Parakeet ends sentences with "." but no whitespace). - let out = if cleaned.is_empty() { + let out = if corrected.is_empty() { String::new() } else { - format!("{cleaned} ") + format!("{corrected} ") }; if !out.is_empty() { emit_text(&out, &daemon.config).await; @@ -622,7 +726,12 @@ async fn emit_text(text: &str, cfg: &Config) { #[cfg(target_os = "macos")] async fn emit_text(text: &str, cfg: &Config) { let t0 = Instant::now(); - log::info!("emit: start (len={}, auto_paste={})", text.len(), cfg.auto_paste); + log::info!( + "emit: start (len={}, auto_paste={}, restore_clipboard_after_paste={})", + text.len(), + cfg.auto_paste, + cfg.restore_clipboard_after_paste + ); if let Err(e) = macos::emit_text(text, cfg).await { log::warn!("emit: {e:#}"); } From 52e827d28a88aa21a48e4f216f53d3ad448fe8bd Mon Sep 17 00:00:00 2001 From: Josh Guice Date: Mon, 18 May 2026 11:58:53 -0700 Subject: [PATCH 2/2] chore: satisfy clippy lints in dictionary and macOS UI sort_by_key+Reverse, is_none_or, and redundant borrow/unsafe cleanups. Fixes the failing CI clippy job on PR #10. --- src/dictionary.rs | 6 +++--- src/macos_onboarding.rs | 26 +++++++++++++------------- src/macos_ui.rs | 8 ++++---- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/dictionary.rs b/src/dictionary.rs index c6111d6..22ac91e 100644 --- a/src/dictionary.rs +++ b/src/dictionary.rs @@ -206,7 +206,7 @@ impl Dictionary { rules.push(Rule::new(replacement, &entry.term)); } } - rules.sort_by(|a, b| b.graphemes.cmp(&a.graphemes)); + rules.sort_by_key(|rule| std::cmp::Reverse(rule.graphemes)); rules } } @@ -330,7 +330,7 @@ fn has_word_boundary_before(text: &str, start: usize, trigger: &str) -> bool { if !is_word_char(first) { return true; } - previous_char(text, start).map_or(true, |ch| !is_word_char(ch)) + previous_char(text, start).is_none_or(|ch| !is_word_char(ch)) } fn has_word_boundary_after(text: &str, end: usize, trigger: &str) -> bool { @@ -342,7 +342,7 @@ fn has_word_boundary_after(text: &str, end: usize, trigger: &str) -> bool { } text.get(end..) .and_then(|rest| rest.chars().next()) - .map_or(true, |ch| !is_word_char(ch)) + .is_none_or(|ch| !is_word_char(ch)) } fn previous_char(text: &str, start: usize) -> Option { diff --git a/src/macos_onboarding.rs b/src/macos_onboarding.rs index 6ed88e9..8df9661 100644 --- a/src/macos_onboarding.rs +++ b/src/macos_onboarding.rs @@ -167,7 +167,7 @@ impl UtterOnboardingTarget { pub fn show(mtm: MainThreadMarker, on_start: Box) { unsafe { let target = UtterOnboardingTarget::new(mtm); - let target_ref: &AnyObject = &*target; + let target_ref: &AnyObject = ⌖ let window = build_window(mtm); let content = build_content(mtm, target_ref); @@ -281,7 +281,7 @@ fn build_content(mtm: MainThreadMarker, target: &AnyObject) -> ContentViews { true, NSColor::labelColor(), ); - stack.addArrangedSubview(&*title_as_view(&title)); + stack.addArrangedSubview(&title_as_view(&title)); let subtitle = make_label( mtm, @@ -295,9 +295,9 @@ fn build_content(mtm: MainThreadMarker, target: &AnyObject) -> ContentViews { subtitle.setMaximumNumberOfLines(0); subtitle.setUsesSingleLineMode(false); subtitle.setPreferredMaxLayoutWidth(580.0 - 48.0); - stack.addArrangedSubview(&*title_as_view(&subtitle)); + stack.addArrangedSubview(&title_as_view(&subtitle)); - stack.addArrangedSubview(&*make_separator(mtm)); + stack.addArrangedSubview(&make_separator(mtm)); // Shared minimum width for the bold name column so the three status // labels ("Pending" / "After Input Monitoring") line up at the same @@ -313,7 +313,7 @@ fn build_content(mtm: MainThreadMarker, target: &AnyObject) -> ContentViews { sel!(grantMic:), name_col_width, ); - stack.addArrangedSubview(&*mic_row.view); + stack.addArrangedSubview(&mic_row.view); let im_row = make_row( mtm, @@ -323,7 +323,7 @@ fn build_content(mtm: MainThreadMarker, target: &AnyObject) -> ContentViews { sel!(grantInputMonitoring:), name_col_width, ); - stack.addArrangedSubview(&*im_row.view); + stack.addArrangedSubview(&im_row.view); let ax_row = make_row( mtm, @@ -335,9 +335,9 @@ fn build_content(mtm: MainThreadMarker, target: &AnyObject) -> ContentViews { ); // AX button is visible from the start; refresh_status() drives its // title ("Waiting…") and enabled state until IM is granted. - stack.addArrangedSubview(&*ax_row.view); + stack.addArrangedSubview(&ax_row.view); - stack.addArrangedSubview(&*make_separator(mtm)); + stack.addArrangedSubview(&make_separator(mtm)); // Footer: status text on the left, Start button on the right. let footer = NSStackView::new(mtm); @@ -353,7 +353,7 @@ fn build_content(mtm: MainThreadMarker, target: &AnyObject) -> ContentViews { false, NSColor::secondaryLabelColor(), ); - footer.addArrangedSubview(&*title_as_view(&footer_status)); + footer.addArrangedSubview(&title_as_view(&footer_status)); let spacer = make_spacer(mtm); footer.addArrangedSubview(&spacer); @@ -365,7 +365,7 @@ fn build_content(mtm: MainThreadMarker, target: &AnyObject) -> ContentViews { mtm, ); start_button.setEnabled(false); - footer.addArrangedSubview(&*button_as_view(&start_button)); + footer.addArrangedSubview(&button_as_view(&start_button)); stack.addArrangedSubview(&footer); @@ -438,7 +438,7 @@ fn make_row( .widthAnchor() .constraintEqualToConstant(name_col_width) .setActive(true); - row.addArrangedSubview(&*title_as_view(&name_label)); + row.addArrangedSubview(&title_as_view(&name_label)); let status_label = make_label( mtm, @@ -447,7 +447,7 @@ fn make_row( false, NSColor::secondaryLabelColor(), ); - row.addArrangedSubview(&*title_as_view(&status_label)); + row.addArrangedSubview(&title_as_view(&status_label)); let spacer = make_spacer(mtm); row.addArrangedSubview(&spacer); @@ -458,7 +458,7 @@ fn make_row( Some(action), mtm, ); - row.addArrangedSubview(&*button_as_view(&button)); + row.addArrangedSubview(&button_as_view(&button)); Row { view: row, diff --git a/src/macos_ui.rs b/src/macos_ui.rs index 738236e..3f82d0f 100644 --- a/src/macos_ui.rs +++ b/src/macos_ui.rs @@ -142,7 +142,7 @@ pub fn run_status_bar_app( // (SF Symbols require macOS 11+; utter already requires 13). let symbol_name = NSString::from_str("waveform.circle.fill"); let a11y = NSString::from_str("utter"); - let icon: Option> = unsafe { + let icon: Option> = { msg_send_id![ objc2::class!(NSImage), imageWithSystemSymbolName: &*symbol_name, @@ -158,7 +158,7 @@ pub fn run_status_bar_app( // 20pt + Regular weight visually matches adjacent system // menu bar icons. setSize alone isn't enough; SF Symbols // need a point-size configuration to actually render larger. - let config: Option> = unsafe { + let config: Option> = { msg_send_id![ objc2::class!(NSImageSymbolConfiguration), configurationWithPointSize: 18.0 as CGFloat, @@ -166,7 +166,7 @@ pub fn run_status_bar_app( ] }; if let Some(config) = config { - let sized: Option> = unsafe { + let sized: Option> = { msg_send_id![&*icon, imageWithSymbolConfiguration: &*config] }; if let Some(sized) = sized { @@ -197,7 +197,7 @@ pub fn run_status_bar_app( // so we leak this single Retained at the end so the target outlives // the menu items that point at it. let target = UtterMenuTarget::new(mtm); - let target_ref: &AnyObject = &*target; + let target_ref: &AnyObject = ⌖ // Capture env once to compute env-override greyout per toggle. let env = crate::config::utter_env_snapshot();