From cd3d1725486b42e0c23d36ba17100e2a776638c1 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 15:56:53 -0800 Subject: [PATCH 01/18] linux: prototype settings window and correction dialogs (localflow-gui, Rune) - localflow-gui (linux/gui, its own Cargo workspace): a settings window, and edit/choose dialogs for localflowctl correct and dict review. - config: settings.json beside config.json holds the window's changes; its keys replace config.json's, so a read-only Home Manager config still works. - picker: "gui" picker kind; auto prefers localflow-gui when installed. - flake: localflow-gui package. --- linux/CHANGELOG.md | 10 + linux/README.md | 37 +- .../crates/lf-daemon/src/bin/localflowctl.rs | 4 +- linux/crates/lf-daemon/src/config.rs | 264 +- linux/crates/lf-daemon/src/picker.rs | 85 +- linux/crates/lf-daemon/src/picker/gui.rs | 71 + linux/flake.nix | 64 + linux/gui/Cargo.lock | 4387 +++++++++++++++++ linux/gui/Cargo.toml | 30 + linux/gui/src/main.rs | 161 + linux/gui/src/prompt.rs | 305 ++ linux/gui/src/settings.rs | 821 +++ 12 files changed, 6189 insertions(+), 50 deletions(-) create mode 100644 linux/crates/lf-daemon/src/picker/gui.rs create mode 100644 linux/gui/Cargo.lock create mode 100644 linux/gui/Cargo.toml create mode 100644 linux/gui/src/main.rs create mode 100644 linux/gui/src/prompt.rs create mode 100644 linux/gui/src/settings.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5fb243a8..444255ec 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -7,6 +7,16 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. ## [Unreleased] +### Added + +- A settings window and correction dialogs, `localflow-gui` (prototype, + built with Rune; the flake's `localflow-gui` package). Its changes go to a + new `settings.json` beside `config.json`, whose keys replace + `config.json`'s, so a read-only `config.json` from Home Manager can still + be adjusted. `"picker": "gui"` uses its dialogs for `localflowctl correct` + and `dict review`, and `auto` prefers them when `localflow-gui` is + installed. + ### Changed - The recommended model is now diff --git a/linux/README.md b/linux/README.md index 24433675..635787a8 100644 --- a/linux/README.md +++ b/linux/README.md @@ -208,9 +208,10 @@ invalid values stop the daemon with an error. Defaults: newlines; surrounding spaces are trimmed; `""` types prompts untagged. - `double_tap_ms`: 150-1000, or 0 to turn double-tap off. - `picker`: the menu `localflowctl correct` and `dict review` show text in: - `"auto"`, `"rofi"`, `"fuzzel"`, `"wofi"` or `"tofi"`. `auto` takes, for - each dialog, the first of rofi, fuzzel, wofi and tofi on `PATH` that can - show it. fuzzel and tofi cannot prefill an input box without putting the + `"auto"`, `"gui"`, `"rofi"`, `"fuzzel"`, `"wofi"` or `"tofi"`. `"gui"` is + LocalFlow's own windows (`localflow-gui`, see "Settings window"). + `auto` takes, for each dialog, the first of localflow-gui, rofi, fuzzel, + wofi and tofi on `PATH` that can show it. fuzzel and tofi cannot prefill an input box without putting the text on their command line, so they only show the suggestion dialog (`dict review`, and the second step of `correct`); with one of them configured, `correct` says so and does nothing. wofi is not used while @@ -232,6 +233,27 @@ invalid values stop the daemon with an error. Defaults: Check a configuration with `localflowd --check-config`. +### Settings window (prototype) + +`localflow-gui` (`nix build ./linux#localflow-gui`; built from `linux/gui`, +a Cargo workspace of its own, with [Rune](https://github.com/anomalyco/rune)) +is a settings window and the correction dialogs: + +- `localflow-gui` opens the settings. Changes are kept until **Apply**, + which writes them to `settings.json` beside `config.json` and restarts + the daemon (`systemctl --user try-restart localflowd`) when a key it reads + changed. A reset icon next to a setting takes it back to `config.json`. +- `settings.json` holds only keys whose value differs from `config.json`'s; + its keys replace `config.json`'s wherever the configuration is read, so a + `config.json` Home Manager links read-only into the Nix store still works. + It must be a private file (not a symlink, owned by you, no group or other + permissions); delete it to go back to `config.json` alone. +- `localflow-gui edit` and `localflow-gui choose` are the `"gui"` picker: + the text comes on stdin and the answer goes to stdout; Escape or closing + the window does nothing. They are built without Rune's accessibility + bridge. An input method (fcitx5, IBus) sees what is typed in the edit + box, as in any other window. + ## Run ```bash @@ -400,7 +422,7 @@ localflowctl correct ``` Bind it to a key (see the snippets in `packaging/`). Within two minutes of a -dictation being typed, it opens a picker (rofi or wofi; see `picker` above) +dictation being typed, it opens a picker (localflow-gui, rofi or wofi; see `picker` above) with that dictation's text; edit it to what you meant and press Return (Escape does nothing). **It never changes the text already typed into the other app**: fix that there as @@ -682,8 +704,9 @@ also works on compositors that have the protocol (Hyprland, below). LocalFlow asks for no particular key. Change them later in the desktop's shortcut settings (GNOME Settings > Keyboard > Keyboard Shortcuts; KDE System Settings > Keyboard > Shortcuts). -3. `localflowctl correct` needs rofi or wofi, which need layer-shell (KDE has it, - GNOME does not); bind it as an ordinary custom shortcut. +3. `localflowctl correct` needs localflow-gui, or rofi or wofi, which need + layer-shell (KDE has it, GNOME does not); bind it as an ordinary custom + shortcut. Behaviour: @@ -750,7 +773,7 @@ and phrase sections, `lf-asr` the bias graph and decision rule, and and correction sections. Chunked transcription (pause shortening, the 15 s windows and how their transcripts are joined) is pinned by `testdata/chunking-vectors.json`, run by `lf-chunk` and the Swift app; its -expected values come from the Swift implementation. The picker tests use fake rofi, fuzzel, wofi and +expected values come from the Swift implementation. The picker tests use fake localflow-gui, rofi, fuzzel, wofi and tofi scripts that record their arguments, environment, configuration and stdin; one also checks that the installed rofi reads the generated configuration back (it is skipped when rofi is not on `PATH`). The tray is diff --git a/linux/crates/lf-daemon/src/bin/localflowctl.rs b/linux/crates/lf-daemon/src/bin/localflowctl.rs index 35bc6750..3b49f8a8 100644 --- a/linux/crates/lf-daemon/src/bin/localflowctl.rs +++ b/linux/crates/lf-daemon/src/bin/localflowctl.rs @@ -8,7 +8,7 @@ //! killed. //! //! `localflowctl correct` teaches the custom dictionary from a correction of -//! the last dictation, in a picker (rofi or wofi; see `lf_daemon::picker`); +//! the last dictation, in a picker (localflow-gui, rofi or wofi; see `lf_daemon::picker`); //! it never changes the text already typed. `localflowctl dict …` manages //! the dictionary; `dict suggest` queues a word for review (agents use it) //! and `dict review` offers each pending suggestion in a picker (rofi, @@ -93,7 +93,7 @@ commands: correct edit the last dictation in a picker (within two minutes of typing it) to teach the dictionary; the text already typed is not changed. A word the audio confirms is offered for adding. - The picker is the configuration's \"picker\": rofi, fuzzel, + The picker is the configuration's \"picker\": gui, rofi, fuzzel, wofi or tofi (default auto: the first installed) dict add (strength 0.25 to 8, default 1), remove, list the custom dictionary; ignored lists words never suggested, unignore diff --git a/linux/crates/lf-daemon/src/config.rs b/linux/crates/lf-daemon/src/config.rs index 353e79c0..e4e5b8e5 100644 --- a/linux/crates/lf-daemon/src/config.rs +++ b/linux/crates/lf-daemon/src/config.rs @@ -1,6 +1,12 @@ //! `$XDG_CONFIG_HOME/localflow/config.json`. A missing file means defaults; //! an invalid file stops the daemon. Error messages name fields, never their //! values (macros are user content). +//! +//! `settings.json` beside it holds what the settings window +//! (`localflow-gui`) changed: its keys replace the config file's, so a +//! config file Home Manager links read-only into the Nix store can still be +//! adjusted from the window. It is a private file (a regular file, not a +//! symlink, owned by the user, no group or other permissions). use std::path::{Path, PathBuf}; @@ -9,7 +15,7 @@ use lf_keyboard::Rmlvo; use serde_json::{Map, Value}; use crate::log::Level; -use crate::private_file::{ReadError, read_regular}; +use crate::private_file::{ModeRule, ReadError, SyncDir, read_private, read_regular, write_atomic}; /// Largest accepted config file. const MAX_CONFIG_BYTES: u64 = 1 << 20; @@ -153,6 +159,125 @@ const MAX_DEVICE_NAME: usize = 256; /// Largest accepted `key_delay_ms`. pub const MAX_KEY_DELAY_MS: u64 = 50; +/// `settings.json` beside the config file `config`. +pub fn settings_path(config: &Path) -> PathBuf { + config.with_file_name("settings.json") +} + +/// The config file's keys and the settings file's, before they are +/// combined. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Layers { + pub file: Map, + pub settings: Map, +} + +impl Layers { + /// Reads both files; a missing one is empty. Each is checked on its own + /// first, so an error names the file that has it. + pub fn load(path: &Path, data_dir: &Path) -> Result { + let name = || path.display(); + let file = match read_regular(path, MAX_CONFIG_BYTES) { + Ok(Some(b)) => object(&b, data_dir).map_err(|e| format!("config {}: {e}", name()))?, + Ok(None) => Map::new(), + // `read_regular` checks neither owner nor mode. + Err(ReadError::NotRegular | ReadError::WrongOwner | ReadError::Mode(_)) => { + return Err(format!("config {} is not a regular file", name())); + } + Err(ReadError::TooLarge) => { + return Err(format!("config {} is larger than 1 MiB", name())); + } + Err(ReadError::Open(e) | ReadError::Metadata(e) | ReadError::Read(e)) => { + return Err(format!("config {}: {e}", name())); + } + }; + let settings_file = settings_path(path); + let sname = || settings_file.display(); + let settings = match read_private(&settings_file, MAX_CONFIG_BYTES, ModeRule::NoGroupOther) + { + Ok(Some(b)) => { + object(&b, data_dir).map_err(|e| format!("settings {}: {e}", sname()))? + } + Ok(None) => Map::new(), + Err(ReadError::NotRegular) => { + return Err(format!("settings {} is not a regular file", sname())); + } + Err(ReadError::WrongOwner) => { + return Err(format!("settings {} is owned by another user", sname())); + } + Err(ReadError::Mode(m)) => { + return Err(format!( + "settings {} has mode {m:04o}; it must not be readable or writable by group or others", + sname() + )); + } + Err(ReadError::TooLarge) => { + return Err(format!("settings {} is larger than 1 MiB", sname())); + } + Err(ReadError::Open(e) | ReadError::Metadata(e) | ReadError::Read(e)) => { + return Err(format!("settings {}: {e}", sname())); + } + }; + Ok(Layers { file, settings }) + } + + /// The settings' keys over the file's. + pub fn combined(&self) -> Map { + let mut map = self.file.clone(); + for (k, v) in &self.settings { + map.insert(k.clone(), v.clone()); + } + map + } + + /// The configuration the two files make. + pub fn config(&self, data_dir: &Path) -> Result { + Config::from_map(&self.combined(), data_dir) + } +} + +/// `bytes` as a JSON object that is a valid configuration on its own. +fn object(bytes: &[u8], data_dir: &Path) -> Result, String> { + // serde_json errors quote no input, only line and column. + let value: Value = serde_json::from_slice(bytes).map_err(|e| format!("invalid JSON: {e}"))?; + let Value::Object(map) = value else { + return Err("top level must be an object".into()); + }; + Config::from_map(&map, data_dir)?; + Ok(map) +} + +/// Replaces the settings file beside `config_path` with `settings` (mode +/// 0600), once the configuration they make with the config file is valid; +/// no settings remove the file. Returns that configuration. +pub fn write_settings( + config_path: &Path, + data_dir: &Path, + settings: &Map, +) -> Result { + let mut layers = Layers::load(config_path, data_dir)?; + layers.settings = settings.clone(); + let config = layers.config(data_dir)?; + let path = settings_path(config_path); + if settings.is_empty() { + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("settings {}: {e}", path.display())), + } + return Ok(config); + } + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + let mut bytes = serde_json::to_vec_pretty(&Value::Object(settings.clone())) + .map_err(|e| format!("settings: {e}"))?; + bytes.push(b'\n'); + write_atomic(&path, &bytes, SyncDir::Yes) + .map_err(|e| format!("settings {}: {e}", path.display()))?; + Ok(config) +} + impl Config { pub fn defaults(data_dir: &Path) -> Config { Config { @@ -182,23 +307,10 @@ impl Config { /// Reads `path`; a missing file yields the defaults. Symlinks are /// followed and neither owner nor mode is checked: home-manager links /// the config into the read-only Nix store. + /// `settings.json` beside it, when there is one, replaces its keys + /// (see the module docs). pub fn load(path: &Path, data_dir: &Path) -> Result { - let name = || path.display(); - let bytes = match read_regular(path, MAX_CONFIG_BYTES) { - Ok(Some(b)) => b, - Ok(None) => return Ok(Config::defaults(data_dir)), - // `read_regular` checks neither owner nor mode. - Err(ReadError::NotRegular | ReadError::WrongOwner | ReadError::Mode(_)) => { - return Err(format!("config {} is not a regular file", name())); - } - Err(ReadError::TooLarge) => { - return Err(format!("config {} is larger than 1 MiB", name())); - } - Err(ReadError::Open(e) | ReadError::Metadata(e) | ReadError::Read(e)) => { - return Err(format!("config {}: {e}", name())); - } - }; - Config::parse(&bytes, data_dir).map_err(|e| format!("config {}: {e}", name())) + Layers::load(path, data_dir)?.config(data_dir) } pub fn parse(bytes: &[u8], data_dir: &Path) -> Result { @@ -208,8 +320,13 @@ impl Config { let Value::Object(map) = value else { return Err("top level must be an object".into()); }; + Config::from_map(&map, data_dir) + } + + /// [`Config::parse`] for a parsed JSON object. + pub fn from_map(map: &Map, data_dir: &Path) -> Result { let mut c = Config::defaults(data_dir); - for (key, v) in &map { + for (key, v) in map { match key.as_str() { "version" => { if v.as_u64() != Some(1) { @@ -862,6 +979,117 @@ mod tests { assert!(!c.press_enter); } + /// A read-only config file in the store, as Home Manager installs it. + fn store_config(t: &crate::testing::TempDir, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let target = t.path().join("store-config.json"); + std::fs::write(&target, body).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o444)).unwrap(); + let path = t.path().join("config.json"); + std::os::unix::fs::symlink(&target, &path).unwrap(); + path + } + + fn settings(json: &str) -> Map { + match serde_json::from_str(json).unwrap() { + Value::Object(m) => m, + _ => unreachable!(), + } + } + + #[test] + fn settings_replace_the_config_files_keys() { + let t = crate::testing::TempDir::new("config-settings"); + let path = store_config( + &t, + r#"{"version": 1, "press_enter": false, "key_delay_ms": 0, "prompt_tag": "[a]"}"#, + ); + let d = Path::new("/d"); + // Without settings, the file alone. + let c = Config::load(&path, d).unwrap(); + assert!(!c.press_enter && c.key_delay_ms == 0 && c.prompt_tag == "[a]"); + + // Written settings win over the file; the file's other keys stay. + let c = write_settings( + &path, + d, + &settings(r#"{"press_enter": true, "spoken_numbers": false}"#), + ) + .unwrap(); + assert!(c.press_enter && !c.spoken_numbers && c.key_delay_ms == 0); + assert_eq!(Config::load(&path, d).unwrap(), c); + let layers = Layers::load(&path, d).unwrap(); + assert_eq!( + layers.settings, + settings(r#"{"press_enter": true, "spoken_numbers": false}"#) + ); + assert_eq!(layers.file["prompt_tag"], "[a]"); + + // A private file: 0600, not a symlink. + use std::os::unix::fs::MetadataExt; + let meta = std::fs::symlink_metadata(settings_path(&path)).unwrap(); + assert!(meta.is_file()); + assert_eq!(meta.mode() & 0o7777, 0o600); + + // Settings that make an invalid configuration are not written. + let e = write_settings(&path, d, &settings(r#"{"key_delay_ms": 51}"#)).unwrap_err(); + assert!(e.contains("key_delay_ms"), "{e}"); + assert!(Config::load(&path, d).unwrap().press_enter); + + // No settings remove the file: back to the config file alone. + let c = write_settings(&path, d, &Map::new()).unwrap(); + assert!(!c.press_enter); + assert!(!settings_path(&path).exists()); + assert_eq!(Config::load(&path, d).unwrap(), c); + // Removing a file that is gone already is fine. + write_settings(&path, d, &Map::new()).unwrap(); + } + + #[test] + fn settings_without_a_config_file_apply_over_the_defaults() { + let t = crate::testing::TempDir::new("config-settings-only"); + let path = t.path().join("localflow/config.json"); + let d = Path::new("/d"); + let c = write_settings(&path, d, &settings(r#"{"history": true}"#)).unwrap(); + assert!(c.history); + assert_eq!(Config::load(&path, d).unwrap(), c); + } + + #[test] + fn a_bad_settings_file_is_refused_and_named() { + use std::os::unix::fs::PermissionsExt; + let t = crate::testing::TempDir::new("config-settings-bad"); + let path = store_config(&t, r#"{"version": 1}"#); + let s = settings_path(&path); + let d = Path::new("/d"); + + // An invalid value: the error names the settings file and the key, + // not the value. + std::fs::write( + &s, + r#"{"prompt_tag": 7, "voice_macros": "synthetic-secret"}"#, + ) + .unwrap(); + std::fs::set_permissions(&s, std::fs::Permissions::from_mode(0o600)).unwrap(); + let e = Config::load(&path, d).unwrap_err(); + assert!(e.starts_with(&format!("settings {}", s.display())), "{e}"); + assert!(!e.contains("ecret"), "{e}"); + + // Readable by others. + std::fs::write(&s, r#"{"history": true}"#).unwrap(); + std::fs::set_permissions(&s, std::fs::Permissions::from_mode(0o644)).unwrap(); + let e = Config::load(&path, d).unwrap_err(); + assert!(e.contains("mode 0644"), "{e}"); + + // A symlink, even to a private file. + std::fs::remove_file(&s).unwrap(); + let target = t.path().join("elsewhere.json"); + std::fs::write(&target, r#"{"history": true}"#).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::os::unix::fs::symlink(&target, &s).unwrap(); + assert!(Config::load(&path, d).is_err()); + } + #[test] fn only_bounded_regular_files_load() { let t = crate::testing::TempDir::new("config-file"); diff --git a/linux/crates/lf-daemon/src/picker.rs b/linux/crates/lf-daemon/src/picker.rs index a22eaa40..4d145ef4 100644 --- a/linux/crates/lf-daemon/src/picker.rs +++ b/linux/crates/lf-daemon/src/picker.rs @@ -31,9 +31,12 @@ //! //! `"picker"` in `config.json` picks one: `"auto"` (the default) takes, for //! each operation, the first tool on `PATH` that supports it, in the order -//! rofi, fuzzel, wofi, tofi. Verdicts, from each version's manual and -//! source as packaged in nixpkgs, and run in a private headless sway: +//! gui (`localflow-gui`), rofi, fuzzel, wofi, tofi. Verdicts, from each +//! version's manual and source as packaged in nixpkgs, and run in a private +//! headless sway: //! +//! - **gui** (`localflow-gui`, LocalFlow's own windows): edit and choose. +//! See [`gui`]. //! - **rofi** (2.0.0): edit and choose. See [`rofi`]. //! - **fuzzel** (1.14.1): choose only. It can prefill its box only with //! `--search=TEXT` on its command line (its configuration file has no @@ -70,6 +73,7 @@ //! chooses its mode from `argv[0]`, so it is started as `tofi`. pub mod fuzzel; +pub mod gui; pub mod rofi; pub mod tofi; pub mod wofi; @@ -103,6 +107,7 @@ const ROWS: [(&str, Option<&str>); 3] = [ #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { + Gui, Rofi, Fuzzel, Wofi, @@ -111,11 +116,12 @@ pub enum Kind { impl Kind { /// In `auto` order. - pub const ALL: [Kind; 4] = [Kind::Rofi, Kind::Fuzzel, Kind::Wofi, Kind::Tofi]; + pub const ALL: [Kind; 5] = [Kind::Gui, Kind::Rofi, Kind::Fuzzel, Kind::Wofi, Kind::Tofi]; - /// Its name, which is also its program name and its `picker` value. + /// Its `picker` value. pub fn name(self) -> &'static str { match self { + Kind::Gui => "gui", Kind::Rofi => "rofi", Kind::Fuzzel => "fuzzel", Kind::Wofi => "wofi", @@ -123,10 +129,18 @@ impl Kind { } } + /// The program looked up on `PATH`. + pub fn program(self) -> &'static str { + match self { + Kind::Gui => "localflow-gui", + k => k.name(), + } + } + pub fn supports(self, op: Op) -> bool { match op { Op::Choose => true, - Op::Edit => matches!(self, Kind::Rofi | Kind::Wofi), + Op::Edit => matches!(self, Kind::Gui | Kind::Rofi | Kind::Wofi), } } } @@ -148,7 +162,7 @@ pub enum Choice { impl Choice { /// The accepted values, for error messages. - pub const VALUES: &str = "auto, rofi, fuzzel, wofi, tofi"; + pub const VALUES: &str = "auto, gui, rofi, fuzzel, wofi, tofi"; pub fn parse(s: &str) -> Option { if s == "auto" { @@ -174,7 +188,7 @@ pub enum Probe { /// Looks `kind` up on `PATH` and checks what it depends on. pub fn probe(kind: Kind) -> Probe { - let Some(path) = find_in_path(kind.name()) else { + let Some(path) = find_in_path(kind.program()) else { return Probe::Missing; }; if kind == Kind::Wofi @@ -217,19 +231,21 @@ pub enum NoPicker { impl std::fmt::Display for NoPicker { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - NoPicker::NoneFound(Op::Edit) => { - f.write_str("no picker that can edit text was found (install rofi or wofi)") - } - NoPicker::NoneFound(Op::Choose) => { - f.write_str("no picker was found (install rofi, fuzzel, wofi or tofi)") - } + NoPicker::NoneFound(Op::Edit) => f.write_str( + "no picker that can edit text was found (install localflow-gui, rofi or wofi)", + ), + NoPicker::NoneFound(Op::Choose) => f.write_str( + "no picker was found (install localflow-gui, rofi, fuzzel, wofi or tofi)", + ), NoPicker::Unsupported(k, _) => write!( f, "{} cannot edit text without putting it on its command line; \ - set \"picker\" to \"rofi\", \"wofi\" or \"auto\" to correct dictations", + set \"picker\" to \"gui\", \"rofi\", \"wofi\" or \"auto\" to correct dictations", k.name() ), - NoPicker::Missing(k) => write!(f, "{} is not installed (not found on PATH)", k.name()), + NoPicker::Missing(k) => { + write!(f, "{} is not installed (not found on PATH)", k.program()) + } NoPicker::Refused(k, why) => write!(f, "{} is not used: {why}", k.name()), } } @@ -402,6 +418,7 @@ impl Picker { return Err(Failure::Unsafe); } match self.kind { + Kind::Gui => Ok(gui::edit(prefill)), Kind::Rofi => rofi::edit(prefill), Kind::Wofi => wofi::edit(prefill), Kind::Fuzzel | Kind::Tofi => Err(Failure::Unsupported), @@ -411,6 +428,7 @@ impl Picker { /// The invocation that shows `rows`. pub fn choose_invocation(&self, rows: String) -> Invocation { match self.kind { + Kind::Gui => gui::choose(rows), Kind::Rofi => rofi::choose(rows), Kind::Fuzzel => fuzzel::choose(rows), Kind::Wofi => wofi::choose(rows), @@ -672,7 +690,13 @@ pub(crate) mod tests { for s in &seen { assert!(!s.contains("ynthetic") && !s.contains("quoted"), "{kind:?}"); } - assert!(read(&dir, "config").contains("ynthetic"), "{kind:?}"); + // It reaches the picker through its configuration + // descriptor or stdin. + assert!( + read(&dir, "config").contains("ynthetic") + || read(&dir, "stdin").contains("ynthetic"), + "{kind:?}" + ); } let _ = p.suggest(WORD); for s in [read(&dir, "args"), read(&dir, "arg0"), read(&dir, "env")] { @@ -697,7 +721,7 @@ pub(crate) mod tests { #[test] fn dismissal_and_exit_statuses() { let dir = TempDir::new("picker-exit"); - for kind in [Kind::Rofi, Kind::Fuzzel, Kind::Wofi] { + for kind in [Kind::Gui, Kind::Rofi, Kind::Fuzzel, Kind::Wofi] { let p = fake(&dir, kind, "echo leaked >&2; exit 1"); assert!(p.suggest(WORD).unwrap() == Answer::Dismissed, "{kind:?}"); let p = fake(&dir, kind, "echo 0; exit 10"); @@ -730,7 +754,7 @@ pub(crate) mod tests { #[test] fn answers_map_to_rows() { let dir = TempDir::new("picker-rows"); - for kind in [Kind::Rofi, Kind::Wofi] { + for kind in [Kind::Gui, Kind::Rofi, Kind::Wofi] { for (out, want) in [("0", ADD), ("1", NOT_NOW), ("2", DONT_SUGGEST)] { let p = fake(&dir, kind, &format!("echo {out}")); assert!(p.suggest(WORD).unwrap() == Answer::Chose(want), "{kind:?}"); @@ -799,7 +823,7 @@ pub(crate) mod tests { #[test] fn floods_and_bad_output_are_refused() { let dir = TempDir::new("picker-flood"); - for kind in [Kind::Rofi, Kind::Wofi] { + for kind in [Kind::Gui, Kind::Rofi, Kind::Wofi] { // Megabytes on both streams neither block nor get through. let p = fake( &dir, @@ -836,16 +860,31 @@ pub(crate) mod tests { let p = |s: &str| PathBuf::from(s); let installed = |names: &'static [&'static str]| { move |k: Kind| { - if names.contains(&k.name()) { - Probe::Found(PathBuf::from(format!("/bin/{}", k.name()))) + if names.contains(&k.program()) { + Probe::Found(PathBuf::from(format!("/bin/{}", k.program()))) } else { Probe::Missing } } }; let pick = |c, op, names| select(c, op, installed(names)).map(|p| (p.kind, p.program)); - // auto: rofi first, then fuzzel, wofi, tofi; edit skips those that - // cannot. + // auto: LocalFlow's own windows first, then rofi, fuzzel, wofi, + // tofi; edit skips those that cannot. + let with_gui = &["localflow-gui", "rofi", "fuzzel", "wofi", "tofi"]; + for op in [Op::Edit, Op::Choose] { + assert_eq!( + pick(Choice::Auto, op, with_gui), + Ok((Kind::Gui, p("/bin/localflow-gui"))) + ); + } + assert_eq!( + pick(Choice::Only(Kind::Gui), Op::Edit, &["rofi"]).unwrap_err(), + NoPicker::Missing(Kind::Gui) + ); + assert_eq!( + NoPicker::Missing(Kind::Gui).to_string(), + "localflow-gui is not installed (not found on PATH)" + ); let all = &["rofi", "fuzzel", "wofi", "tofi"]; assert_eq!( pick(Choice::Auto, Op::Edit, all), diff --git a/linux/crates/lf-daemon/src/picker/gui.rs b/linux/crates/lf-daemon/src/picker/gui.rs new file mode 100644 index 00000000..56fdce76 --- /dev/null +++ b/linux/crates/lf-daemon/src/picker/gui.rs @@ -0,0 +1,71 @@ +//! localflow-gui (LocalFlow's own windows, `linux/gui`): edit and choose. +//! +//! - `localflow-gui edit` reads the text to correct from stdin (a pipe) and +//! prints the edited text and a newline when it is accepted. +//! - `localflow-gui choose` reads the rows from stdin, one per line, and +//! prints the chosen row's number. +//! - Escape or closing the window exits 1 with no output. +//! - It keeps no history or cache, reads no configuration of its own, and +//! is built without the accessibility bridge. + +use super::{Dismissal, Invocation, Reply}; + +pub(super) fn edit(prefill: &str) -> Invocation { + Invocation { + args: vec!["edit"], + arg0: None, + env: &[], + config: None, + stdin: Some(prefill.to_owned()), + dismissal: Dismissal::Exit(&[1]), + reply: Reply::Index, + } +} + +pub(super) fn choose(rows: String) -> Invocation { + Invocation { + args: vec!["choose"], + arg0: None, + env: &[], + config: None, + stdin: Some(rows), + dismissal: Dismissal::Exit(&[1]), + reply: Reply::Index, + } +} + +#[cfg(test)] +mod tests { + use super::super::tests::{SECRET, args, fake, read}; + use super::super::{Answer, DONT_SUGGEST, Failure, Kind, suggestion_rows}; + use crate::testing::TempDir; + + #[test] + fn edit_passes_the_text_on_stdin_and_reads_the_edit_back() { + let dir = TempDir::new("gui-edit"); + let f = fake(&dir, Kind::Gui, "printf 'Synthetic corrected text\\n'"); + assert!(f.edit(SECRET).unwrap() == Answer::Chose("Synthetic corrected text".into())); + assert_eq!(args(&dir), ["edit"]); + assert_eq!(read(&dir, "stdin"), SECRET); + assert_eq!(read(&dir, "config"), ""); + } + + #[test] + fn choose_passes_rows_on_stdin_and_answers_with_the_row_number() { + let dir = TempDir::new("gui-choose"); + let f = fake(&dir, Kind::Gui, "echo 2"); + assert!(f.suggest("Kubernetes").unwrap() == Answer::Chose(DONT_SUGGEST)); + assert_eq!(args(&dir), ["choose"]); + assert_eq!(read(&dir, "stdin"), suggestion_rows("Kubernetes").unwrap()); + } + + #[test] + fn escape_is_a_dismissal_and_other_statuses_are_errors() { + let dir = TempDir::new("gui-exit"); + let f = fake(&dir, Kind::Gui, "exit 1"); + assert!(f.edit("x").unwrap() == Answer::Dismissed); + assert!(f.suggest("Kubernetes").unwrap() == Answer::Dismissed); + let f = fake(&dir, Kind::Gui, "echo 0; exit 2"); + assert_eq!(f.suggest("Kubernetes").err(), Some(Failure::Exit(Some(2)))); + } +} diff --git a/linux/flake.nix b/linux/flake.nix index 517eb903..d06c3cfe 100644 --- a/linux/flake.nix +++ b/linux/flake.nix @@ -141,12 +141,76 @@ '') m.files ) ); + + # localflow-gui (prototype): the settings window and the correction + # dialogs, drawn with Rune. A Cargo workspace of its own (gui/), so + # Rune's dependencies stay out of the localflow package. It builds + # lf-daemon for the configuration and picker types. + localflow-gui = pkgs.rustPlatform.buildRustPackage { + pname = "localflow-gui"; + version = "0.1.0"; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./Cargo.toml + ./crates + ./models.json + ./gui/Cargo.toml + ./gui/Cargo.lock + ./gui/src + ]; + }; + cargoRoot = "gui"; + buildAndTestSubdir = "gui"; + cargoLock = { + lockFile = ./gui/Cargo.lock; + # Rune is a pinned git revision (it is not on crates.io). + allowBuiltinFetchGit = true; + }; + nativeBuildInputs = [ + pkgs.makeBinaryWrapper + pkgs.pkg-config + pkgs.rustPlatform.bindgenHook + ]; + buildInputs = with pkgs; [ + libxkbcommon + pipewire + wayland + ]; + # winit and wgpu load the display and GPU libraries at run time. + postFixup = '' + wrapProgram $out/bin/localflow-gui --prefix LD_LIBRARY_PATH : ${ + lib.makeLibraryPath ( + with pkgs; + [ + libGL + libxkbcommon + vulkan-loader + wayland + xorg.libX11 + xorg.libXcursor + xorg.libXi + xorg.libxcb + ] + ) + } + ''; + meta = { + description = "LocalFlow's settings window and correction dialogs (prototype)"; + homepage = "https://github.com/ajbarryiii/LocalFlow"; + license = lib.licenses.mit; + mainProgram = "localflow-gui"; + platforms = [ "x86_64-linux" ]; + }; + }; in { # `default` is the program. `kea-b` and `kea-t` are model exports: # `export_path` can be "${localflow.packages..kea-b}". + # `localflow-gui` is the prototype settings window. packages.${system} = lib.mapAttrs model models // { default = localflow; + inherit localflow-gui; }; devShells.${system}.default = pkgs.mkShell { diff --git a/linux/gui/Cargo.lock b/linux/gui/Cargo.lock new file mode 100644 index 00000000..87a3e24d --- /dev/null +++ b/linux/gui/Cargo.lock @@ -0,0 +1,4387 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be" +dependencies = [ + "uuid", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.6", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.6", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.6", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "annotate-snippets", + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf1df16263783918b7da396077521f92b729ef7c429444dfcf03581f1448b77" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "parlance", + "read-fonts", + "smallvec", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lf-asr" +version = "0.1.0" +dependencies = [ + "lf-cpu", + "lf-model", + "lf-text", + "serde_json", +] + +[[package]] +name = "lf-chunk" +version = "0.1.0" +dependencies = [ + "lf-io-api", + "lf-text", + "unicode-normalization", + "unicode-properties", + "unicode-segmentation", +] + +[[package]] +name = "lf-cpu" +version = "0.1.0" +dependencies = [ + "lf-model", + "libc", +] + +[[package]] +name = "lf-daemon" +version = "0.1.0" +dependencies = [ + "async-channel", + "async-io", + "futures-lite", + "lf-asr", + "lf-chunk", + "lf-dbus", + "lf-dictation", + "lf-io-api", + "lf-keyboard", + "lf-media", + "lf-model", + "lf-pipewire", + "lf-portal", + "lf-wayland", + "libc", + "serde_json", + "unicode-segmentation", + "zbus", +] + +[[package]] +name = "lf-dbus" +version = "0.1.0" +dependencies = [ + "async-io", + "futures-lite", + "zbus", +] + +[[package]] +name = "lf-dictation" +version = "0.1.0" +dependencies = [ + "lf-text", + "unicode-normalization", + "unicode-properties", + "unicode-segmentation", +] + +[[package]] +name = "lf-gui" +version = "0.1.0" +dependencies = [ + "lf-daemon", + "rune", + "rune-kit", + "serde_json", +] + +[[package]] +name = "lf-io-api" +version = "0.1.0" +dependencies = [ + "libc", +] + +[[package]] +name = "lf-keyboard" +version = "0.1.0" +dependencies = [ + "lf-io-api", + "libc", + "xkbcommon", +] + +[[package]] +name = "lf-media" +version = "0.1.0" +dependencies = [ + "lf-dbus", + "lf-io-api", + "zbus", +] + +[[package]] +name = "lf-model" +version = "0.1.0" +dependencies = [ + "serde_json", + "sha2", +] + +[[package]] +name = "lf-pipewire" +version = "0.1.0" +dependencies = [ + "lf-io-api", + "libc", + "pipewire", +] + +[[package]] +name = "lf-portal" +version = "0.1.0" +dependencies = [ + "async-io", + "futures-lite", + "lf-dbus", + "lf-io-api", + "lf-keyboard", + "libc", + "reis", + "zbus", +] + +[[package]] +name = "lf-text" +version = "0.1.0" +dependencies = [ + "unicode-properties", +] + +[[package]] +name = "lf-wayland" +version = "0.1.0" +dependencies = [ + "lf-io-api", + "libc", + "wayland-backend", + "wayland-client", + "wayland-protocols-misc", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "libspa" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" +dependencies = [ + "bitflags 2.13.2", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix 1.1.6", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" +dependencies = [ + "bindgen", + "cc", + "system-deps", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-av-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.5", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + +[[package]] +name = "parley" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61bd0c0c1737f0b3d3726dc06b225083af213b61913e4fbae1670613a106c6ce" +dependencies = [ + "fontique", + "hashbrown 0.17.1", + "linebender_resource_handle", + "parlance", + "parley_engine", + "smallvec", +] + +[[package]] +name = "parley_data" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43b63f808f6cc8f4a3b07de0d62a977bddd2e0bb5c232bd9e93ba8ef92e5e4b" +dependencies = [ + "icu_collections", + "icu_properties", +] + +[[package]] +name = "parley_emoji" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c4482d912d7671a7cd2089794f90ef3bb5f5f8c738230fb0fcd50fa020f0ed3" + +[[package]] +name = "parley_engine" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74c0d373e462e504ad4436710a39f099e18e848e1ee711f70cd2dcd5a69b290" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "parley_emoji", + "skrifa", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pipewire" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" +dependencies = [ + "bitflags 2.13.2", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix 1.1.6", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reis" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b967ec6489a42067a20724f11987bb96178ea796873cbe1afd25fb93ee6f85f" +dependencies = [ + "enumflags2", + "log", + "rustix 1.1.6", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rune" +version = "0.0.1" +source = "git+https://github.com/anomalyco/rune?rev=1ad9cf69a22c86e9803b257220eae8ee6456c9ce#1ad9cf69a22c86e9803b257220eae8ee6456c9ce" +dependencies = [ + "accesskit", + "arboard", + "blake3", + "block2 0.5.1", + "block2 0.6.2", + "bytemuck", + "console_error_panic_hook", + "half", + "image", + "js-sys", + "libc", + "libloading", + "objc2 0.5.3", + "objc2 0.6.5", + "objc2-app-kit 0.2.2", + "objc2-av-foundation", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation 0.2.2", + "objc2-foundation 0.3.2", + "objc2-io-surface", + "objc2-metal 0.3.2", + "parley", + "png", + "pollster", + "portable-atomic", + "raw-window-handle", + "rune-motion", + "rune-staging", + "rune-taffy", + "rustc-demangle", + "rustc-hash 2.1.3", + "serde", + "serde_json", + "swash", + "taffy", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", + "web-sys", + "web-time", + "wgpu", + "wgpu-core", + "wgpu-hal", + "windows-sys 0.59.0", + "winit", + "x11rb", + "xkbcommon", + "zeroize", +] + +[[package]] +name = "rune-kit" +version = "0.0.1" +source = "git+https://github.com/anomalyco/rune?rev=1ad9cf69a22c86e9803b257220eae8ee6456c9ce#1ad9cf69a22c86e9803b257220eae8ee6456c9ce" +dependencies = [ + "rune", + "rune-motion", + "unicode-segmentation", +] + +[[package]] +name = "rune-motion" +version = "0.1.0" +source = "git+https://github.com/anomalyco/rune?rev=1ad9cf69a22c86e9803b257220eae8ee6456c9ce#1ad9cf69a22c86e9803b257220eae8ee6456c9ce" + +[[package]] +name = "rune-staging" +version = "0.0.1" +source = "git+https://github.com/anomalyco/rune?rev=1ad9cf69a22c86e9803b257220eae8ee6456c9ce#1ad9cf69a22c86e9803b257220eae8ee6456c9ce" +dependencies = [ + "wgpu", +] + +[[package]] +name = "rune-taffy" +version = "0.0.1" +source = "git+https://github.com/anomalyco/rune?rev=1ad9cf69a22c86e9803b257220eae8ee6456c9ce#1ad9cf69a22c86e9803b257220eae8ee6456c9ce" +dependencies = [ + "taffy", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "log", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "taffy" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f7bd2d97198df05186f93a3a3c306845f40e8b3c22bf9279cdd7e5cf5fea7d" +dependencies = [ + "serde", + "slotmap", + "smallvec", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.2.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.19+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a06f08a856b009bc7bbaf7d3e79808595d032dd19074c0c2ef26e49e03e6bdc" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "log", + "rustix 1.1.6", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.6", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.6", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "gpu-allocator", + "hashbrown 0.17.1", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.6", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yazi" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac366ea532fa8ee6a992cec9dea525b1dc284e69702f36c2ab5fbebadc81ec86" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.6", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/linux/gui/Cargo.toml b/linux/gui/Cargo.toml new file mode 100644 index 00000000..6144d04a --- /dev/null +++ b/linux/gui/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "lf-gui" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false +description = "LocalFlow's windows on Linux (settings, corrections), drawn with Rune" + +[[bin]] +name = "localflow-gui" +path = "src/main.rs" + +[dependencies] +# The configuration, its settings layer and the picker values. +lf-daemon = { path = "../crates/lf-daemon" } +# Rune is pre-release: a pinned rev of its `dev` branch. Without its default +# features: no accessibility bridge (a picker must not expose dictated text +# to other programs on the session bus), no D-Bus services, no Metal, no +# audio decoding, no SVG images. +rune = { git = "https://github.com/anomalyco/rune", rev = "1ad9cf69a22c86e9803b257220eae8ee6456c9ce", default-features = false, features = ["embedded-fonts"] } +rune-kit = { git = "https://github.com/anomalyco/rune", rev = "1ad9cf69a22c86e9803b257220eae8ee6456c9ce" } +serde_json = "1" + +# Its own workspace: Rune's dependencies stay out of linux/Cargo.lock and the +# localflow package. +[workspace] + +[profile.release] +codegen-units = 16 +lto = "thin" diff --git a/linux/gui/src/main.rs b/linux/gui/src/main.rs new file mode 100644 index 00000000..dfae68c5 --- /dev/null +++ b/linux/gui/src/main.rs @@ -0,0 +1,161 @@ +//! `localflow-gui`: LocalFlow's windows on Linux, drawn with Rune. +//! +//! - `localflow-gui [settings]`: the settings window. +//! - `localflow-gui edit`: correct a dictation (text on stdin; the edit on +//! stdout). See [`prompt`]. +//! - `localflow-gui choose`: pick one of the rows on stdin (its number on +//! stdout). +//! +//! Rune's own flags work on every window (`--snap out.png`, `--tree`), +//! which render it headless. + +mod prompt; +mod settings; + +use std::io::{Read, Write}; +use std::sync::{Mutex, OnceLock}; + +use prompt::Outcome; +use rune::AppSpec; + +/// The app id the compositor sees (window rules match it). +const APP_ID: &str = "com.ajbarryiii.localflow.gui"; +/// The most text `edit` and `choose` read (the daemon's text limit is far +/// below this). +const MAX_INPUT: u64 = 1 << 20; + +/// What stdin held, for the window's `init` (an `fn`, so no captures). +static INPUT: OnceLock = OnceLock::new(); + +fn main() { + rune::set_app_id(APP_ID); + let args = rune::extra_args(); + let code = match args.first().map(String::as_str) { + None | Some("settings") => run_settings(), + Some("edit") => run_edit(), + Some("choose") => run_choose(), + Some(other) => { + eprintln!("localflow-gui: unknown command {other:?} (settings, edit, choose)"); + 2 + } + }; + std::process::exit(code); +} + +fn read_stdin() -> Result { + let mut s = String::new(); + std::io::stdin() + .take(MAX_INPUT) + .read_to_string(&mut s) + .map_err(|e| format!("stdin: {e}"))?; + Ok(s) +} + +/// Prints the outcome: 0 with the answer, 1 when dismissed. +fn finish() -> i32 { + let outcome = prompt::ANSWER.lock().unwrap().take(); + let mut out = std::io::stdout().lock(); + let written = match outcome { + Some(Outcome::Accepted(text)) => writeln!(out, "{text}"), + Some(Outcome::Chose(i)) => writeln!(out, "{i}"), + Some(Outcome::Dismissed) | None => return 1, + }; + match written.and_then(|()| out.flush()) { + Ok(()) => 0, + Err(_) => 1, + } +} + +fn run_edit() -> i32 { + match read_stdin() { + Ok(text) => { + let _ = INPUT.set(text); + } + Err(e) => { + eprintln!("localflow-gui: {e}"); + return 2; + } + } + fn init() -> prompt::Edit { + prompt::Edit::new(INPUT.get().map_or("", String::as_str)) + } + rune::run(AppSpec:: { + min_size: Some((360.0, 120.0)), + on_key: Some(prompt::edit_key), + ..AppSpec::new( + "Correct dictation", + ( + 560.0, + prompt::edit_height(INPUT.get().map_or("", String::as_str)), + ), + init, + prompt::edit_view, + ) + }); + finish() +} + +fn run_choose() -> i32 { + match read_stdin() { + Ok(text) => { + let _ = INPUT.set(text); + } + Err(e) => { + eprintln!("localflow-gui: {e}"); + return 2; + } + } + fn init() -> prompt::Choose { + prompt::Choose { + rows: prompt::parse_rows(INPUT.get().map_or("", String::as_str)), + outcome: None, + } + } + if init().rows.is_empty() { + eprintln!("localflow-gui: no rows on stdin"); + return 2; + } + let rows = INPUT.get().map_or(0, |s| prompt::parse_rows(s).len()); + rune::run(AppSpec:: { + min_size: Some((320.0, 120.0)), + on_key: Some(prompt::choose_key), + ..AppSpec::new( + "LocalFlow", + (420.0, prompt::choose_height(rows)), + init, + prompt::choose_view, + ) + }); + finish() +} + +/// The settings window's state, made before the window opens. +static SETTINGS: Mutex> = Mutex::new(None); + +fn run_settings() -> i32 { + let made = (|| -> Result { + let config = lf_daemon::paths::config_file()?; + let data = lf_daemon::paths::data_dir()?; + let layers = lf_daemon::config::Layers::load(&config, &data)?; + settings::Settings::new(config, data, layers, Some(settings::restart_daemon)) + })(); + match made { + Ok(s) => *SETTINGS.lock().unwrap() = Some(s), + Err(e) => { + eprintln!("localflow-gui: {e}"); + return 1; + } + } + fn init() -> settings::Settings { + SETTINGS + .lock() + .unwrap() + .take() + .expect("settings are made once, before the window") + } + rune::run(AppSpec:: { + min_size: Some((520.0, 400.0)), + ..AppSpec::new("LocalFlow Settings", (600.0, 760.0), init, settings::view) + }); + 0 +} diff --git a/linux/gui/src/prompt.rs b/linux/gui/src/prompt.rs new file mode 100644 index 00000000..290ccf8b --- /dev/null +++ b/linux/gui/src/prompt.rs @@ -0,0 +1,305 @@ +//! The two dialogs `localflowctl` opens through the `gui` picker: +//! +//! - **Edit** (`localflow-gui edit`): the last dictation in a text box. +//! Enter accepts and prints the text; Shift+Enter is refused (a +//! dictation is one line). +//! - **Choose** (`localflow-gui choose`): one button per row; the first is +//! the primary action. Pressing one prints its number. +//! +//! Escape, or closing the window, dismisses: exit status 1, no output. +//! Nothing is written anywhere but stdout. + +use std::sync::Mutex; + +use rune::tok::space; +use rune::{Cx, El, Key, KeyEvent, TextEdit, Tx, col, icon, row}; +use rune_kit::{Button, ButtonVariant, Kit, TextArea}; + +/// The answer for `main` to print once the window has closed. +pub static ANSWER: Mutex> = Mutex::new(None); + +/// Records `outcome` in the state and for `main`, and quits. +fn finish(slot: &mut Option, outcome: Outcome, tx: &mut Tx) { + *ANSWER.lock().unwrap() = Some(outcome.clone()); + *slot = Some(outcome); + tx.quit(); +} + +/// What the user did, once they did it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + Accepted(String), + Chose(usize), + Dismissed, +} + +pub struct Edit { + pub edit: TextEdit, + pub outcome: Option, +} + +impl Edit { + pub fn new(prefill: &str) -> Edit { + let mut edit = TextEdit::new(prefill); + edit.focused = true; + // Selected, so typing replaces it. + edit.set_selection(0, prefill.len()); + Edit { + edit, + outcome: None, + } + } + + fn accept(&mut self, tx: &mut Tx) { + // A paste may carry line breaks; a dictation is one line. + let one_line: String = self + .edit + .text + .split(['\n', '\r']) + .filter(|l| !l.is_empty()) + .collect::>() + .join(" "); + finish(&mut self.outcome, Outcome::Accepted(one_line), tx); + } +} + +pub fn edit_view(s: &Edit, cx: &mut Cx) -> El { + let k = Kit::neutral(cx.system_dark()); + let field = TextArea::new(&s.edit) + .label("Correction") + .width(None) + .auto_grow(2, 8) + .on_edit(|s: &mut Edit| &mut s.edit) + .on_key(|s: &mut Edit, e: &KeyEvent, tx: &mut Tx| match e.key { + Key::Enter if !e.mods.shift => { + s.accept(tx); + true + } + // Shift+Enter would start a second line. + Key::Enter => true, + Key::Escape => { + dismiss(&mut s.outcome, tx); + true + } + _ => false, + }) + .build(&k); + let buttons = row() + .key("buttons") + .gap(space::S2) + .justify_end() + .child( + Button::new("Cancel") + .variant(ButtonVariant::Ghost) + .on_press_tx(|s: &mut Edit, tx| dismiss(&mut s.outcome, tx)) + .build(&k) + .key("cancel"), + ) + .child( + Button::new("Correct") + .variant(ButtonVariant::Contrast) + .shortcut("Enter") + .on_press_tx(|s: &mut Edit, tx| s.accept(tx)) + .build(&k) + .key("correct"), + ); + col() + .key("edit-window") + .w_full() + .h_full() + .padding(16.0) + .gap(space::S3) + .fill(k.c.bg_base) + .child(field) + .child(buttons) +} + +pub struct Choose { + pub rows: Vec, + pub outcome: Option, +} + +pub fn choose_view(s: &Choose, cx: &mut Cx) -> El { + let k = Kit::neutral(cx.system_dark()); + let mut list = col().key("rows").gap(space::S2).w_full(); + for (i, label) in s.rows.iter().enumerate() { + let variant = if i == 0 { + ButtonVariant::Contrast + } else { + ButtonVariant::Neutral + }; + list = list.child( + row().key(format!("row-{i}")).w_full().child( + Button::new(label.clone()) + .variant(variant) + .on_press_tx(move |s: &mut Choose, tx| { + finish(&mut s.outcome, Outcome::Chose(i), tx) + }) + .build(&k) + .key(format!("choice-{i}")) + .grow(1.0), + ), + ); + } + col() + .key("choose-window") + .w_full() + .h_full() + .padding(16.0) + .gap(space::S3) + .justify_center() + .items_center() + .fill(k.c.bg_base) + .child( + icon(&rune_kit::glyphs::MIC, 18.0) + .key("mark") + .color(k.c.text_weak), + ) + .child(list) +} + +/// The edit window's height for `text`: about 64 characters a line at +/// 560 pt, 2 to 8 lines, the buttons under them. +pub fn edit_height(text: &str) -> f32 { + let lines = text.chars().count().div_ceil(64).clamp(2, 8); + 16.0 * 2.0 + 16.0 + lines as f32 * 20.0 + space::S3 + 32.0 +} + +/// The choose window's height for `rows` buttons: padding, the mark, and +/// a 32 pt button with an 8 pt gap per row. +pub fn choose_height(rows: usize) -> f32 { + 16.0 * 2.0 + 18.0 + space::S3 + rows as f32 * 32.0 + rows.saturating_sub(1) as f32 * space::S2 +} + +/// Escape anywhere dismisses the choose dialog (the edit box handles its +/// own keys). +pub fn choose_key(s: &mut Choose, e: &KeyEvent, tx: &mut Tx) -> bool { + if e.key == Key::Escape { + dismiss(&mut s.outcome, tx); + return true; + } + false +} + +pub fn edit_key(s: &mut Edit, e: &KeyEvent, tx: &mut Tx) -> bool { + if e.key == Key::Escape { + dismiss(&mut s.outcome, tx); + return true; + } + false +} + +fn dismiss(outcome: &mut Option, tx: &mut Tx) { + finish(outcome, Outcome::Dismissed, tx); +} + +/// The rows `choose` reads from stdin: one per line, empty lines dropped. +pub fn parse_rows(input: &str) -> Vec { + input + .lines() + .filter(|l| !l.trim().is_empty()) + .map(String::from) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use rune::{Env, Mods, Runtime}; + + fn press(rt: &mut Runtime, key: &str) { + let (x, y) = rt.find(key).unwrap_or_else(|| panic!("no {key:?}")); + rt.pointer_down(x, y); + rt.pointer_up(x, y); + rt.frame(1.0 / 60.0); + } + + fn key(key: Key, shift: bool) -> KeyEvent { + KeyEvent { + key, + mods: Mods { + shift, + ..Mods::default() + }, + } + } + + #[test] + fn enter_accepts_the_edited_text_on_one_line() { + let mut rt = Runtime::new( + Edit::new("synthetic dictation"), + edit_view, + (520.0, 200.0), + Env::new(true, 1.0), + ); + rt.frame(0.0); + // The whole text is selected, so typing replaces it. + rt.key(key(Key::Text("synthetic fix".into()), false)); + rt.frame(1.0 / 60.0); + rt.key(key(Key::Enter, true)); + rt.frame(1.0 / 60.0); + assert_eq!( + rt.state.outcome, None, + "Shift+Enter adds no line and does not accept" + ); + rt.key(key(Key::Enter, false)); + rt.frame(1.0 / 60.0); + assert_eq!( + rt.state.outcome, + Some(Outcome::Accepted("synthetic fix".into())) + ); + } + + #[test] + fn pasted_line_breaks_become_spaces() { + let mut s = Edit::new(""); + s.edit.text = "synthetic\nfirst\r\n\nsecond".into(); + let mut rt = Runtime::new(s, edit_view, (520.0, 200.0), Env::new(true, 1.0)); + rt.frame(0.0); + press(&mut rt, "correct"); + assert_eq!( + rt.state.outcome, + Some(Outcome::Accepted("synthetic first second".into())) + ); + } + + #[test] + fn escape_and_cancel_dismiss() { + let mut rt = Runtime::new( + Edit::new("synthetic"), + edit_view, + (520.0, 200.0), + Env::new(true, 1.0), + ); + rt.frame(0.0); + rt.key(key(Key::Escape, false)); + rt.frame(1.0 / 60.0); + assert_eq!(rt.state.outcome, Some(Outcome::Dismissed)); + + let mut rt = Runtime::new( + Edit::new("synthetic"), + edit_view, + (520.0, 200.0), + Env::new(true, 1.0), + ); + rt.frame(0.0); + press(&mut rt, "cancel"); + assert_eq!(rt.state.outcome, Some(Outcome::Dismissed)); + } + + #[test] + fn choosing_a_row_answers_with_its_number() { + let rows = parse_rows("Add \u{201C}Zorblax\u{201D}\nNot now\n\nDon\u{2019}t suggest\n"); + assert_eq!(rows.len(), 3); + for i in 0..rows.len() { + let s = Choose { + rows: rows.clone(), + outcome: None, + }; + let mut rt = Runtime::new(s, choose_view, (420.0, 220.0), Env::new(true, 1.0)); + rt.frame(0.0); + press(&mut rt, &format!("choice-{i}")); + assert_eq!(rt.state.outcome, Some(Outcome::Chose(i))); + } + } +} diff --git a/linux/gui/src/settings.rs b/linux/gui/src/settings.rs new file mode 100644 index 00000000..ed505e8a --- /dev/null +++ b/linux/gui/src/settings.rs @@ -0,0 +1,821 @@ +//! The settings window (`localflow-gui`, or `localflow-gui settings`). +//! +//! - It shows the configuration `config.json` and `settings.json` make +//! together (`lf_daemon::config::Layers`). +//! - A change is a draft until **Apply**, which writes `settings.json` (only +//! the keys that differ from `config.json`) and restarts the daemon when a +//! key it reads changed. **Discard** drops the draft. +//! - A setting the window changed has a reset icon, which takes it back to +//! `config.json`'s value (or the default). +//! - Values that make an invalid configuration keep Apply disabled and show +//! the daemon's own message, which names the key and never the value. + +use std::path::PathBuf; + +use lf_daemon::config::{Config, Layers, write_settings}; +use rune::tok::space; +use rune::{Cx, El, Icon, ScrollSpec, TextEdit, Tx, col, el, icon, row, text}; +use rune_kit::{ + Button, ButtonVariant, IconButton, IconButtonVariant, Kit, SegmentedControl, Select, Switch, + TextInput, +}; +use serde_json::{Map, Value, json}; + +/// Back to `config.json`'s value. +static RESET: Icon = Icon { + name: "lf-reset", + view: 16.0, + body: r#""#, +}; + +/// Remove a voice macro. +static REMOVE: Icon = Icon { + name: "lf-remove", + view: 16.0, + body: r#""#, +}; + +static PLUS: Icon = Icon { + name: "lf-plus", + view: 16.0, + body: r#""#, +}; + +static CHECK: Icon = Icon { + name: "lf-check", + view: 16.0, + body: r#""#, +}; + +/// Restarts the daemon after Apply; `None` in tests. +pub type Restart = Option Result<(), String>>; + +pub struct Settings { + pub config_path: PathBuf, + pub data_dir: PathBuf, + /// `config.json`'s keys and `settings.json`'s as last read or written. + pub layers: Layers, + /// The settings being edited. + pub draft: Map, + /// What the draft makes; `Err` keeps Apply disabled. + pub config: Result, + /// The last configuration that was valid, which the controls show. + pub shown: Config, + pub prompt_tag: TextEdit, + pub input_device: TextEdit, + pub macros: Vec<(TextEdit, TextEdit)>, + pub open: Option<&'static str>, + pub status: Status, + pub restart: Restart, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Status { + Idle, + Applied, + Failed(String), +} + +/// Keys the daemon ignores (`localflowctl` reads them on each run). +const CLIENT_ONLY: &[&str] = &["picker"]; + +impl Settings { + /// The window's state over `layers`, or the error that stops it (an + /// invalid `config.json` or `settings.json`). + pub fn new( + config_path: PathBuf, + data_dir: PathBuf, + layers: Layers, + restart: Restart, + ) -> Result { + let config = layers.config(&data_dir)?; + let mut s = Settings { + config_path, + data_dir, + draft: layers.settings.clone(), + layers, + shown: config.clone(), + config: Ok(config), + prompt_tag: TextEdit::default(), + input_device: TextEdit::default(), + macros: Vec::new(), + open: None, + status: Status::Idle, + restart, + }; + s.reset_fields(); + Ok(s) + } + + /// The text fields from the shown configuration. + fn reset_fields(&mut self) { + self.prompt_tag = TextEdit::new(self.shown.prompt_tag.clone()); + self.input_device = TextEdit::new(self.shown.input_device.clone().unwrap_or_default()); + self.macros = self + .shown + .voice_macros + .iter() + .map(|m| { + ( + TextEdit::new(m.command.clone()), + TextEdit::new(m.payload.clone()), + ) + }) + .collect(); + } + + /// The configuration `config.json` and `settings` make. + fn with(&self, settings: Map) -> Result { + Layers { + file: self.layers.file.clone(), + settings, + } + .config(&self.data_dir) + } + + /// Sets `key` in the draft; a value that changes nothing over + /// `config.json` drops the key instead. + pub fn set(&mut self, key: &str, value: Value) { + let mut without = self.draft.clone(); + without.remove(key); + let mut with = self.draft.clone(); + with.insert(key.to_owned(), value); + let same = matches!((self.with(without.clone()), self.with(with.clone())), (Ok(a), Ok(b)) if a == b); + self.draft = if same { without } else { with }; + self.recompute(); + } + + /// Takes `key` back to `config.json`'s value. + pub fn reset(&mut self, key: &str) { + self.draft.remove(key); + self.recompute(); + if matches!(key, "prompt_tag" | "input_device" | "voice_macros") { + self.reset_fields(); + } + } + + fn recompute(&mut self) { + self.status = Status::Idle; + self.config = self.with(self.draft.clone()); + if let Ok(c) = &self.config { + self.shown = c.clone(); + } + } + + pub fn dirty(&self) -> bool { + self.draft != self.layers.settings + } + + pub fn discard(&mut self) { + self.draft = self.layers.settings.clone(); + self.recompute(); + self.reset_fields(); + } + + /// Writes the draft and restarts the daemon if a key it reads changed. + pub fn apply(&mut self) { + let changed: Vec<&String> = self + .draft + .keys() + .chain(self.layers.settings.keys()) + .filter(|k| self.draft.get(*k) != self.layers.settings.get(*k)) + .collect(); + let daemon_reads = changed.iter().any(|k| !CLIENT_ONLY.contains(&k.as_str())); + match write_settings(&self.config_path, &self.data_dir, &self.draft) { + Ok(config) => { + self.layers.settings = self.draft.clone(); + self.shown = config.clone(); + self.config = Ok(config); + self.status = Status::Applied; + if daemon_reads + && let Some(restart) = self.restart + && let Err(e) = restart() + { + self.status = Status::Failed(e); + } + } + Err(e) => self.status = Status::Failed(e), + } + } + + fn sync_macros(&mut self) { + let list: Vec = self + .macros + .iter() + .filter(|(c, _)| !c.text.trim().is_empty()) + .map(|(c, p)| json!({"command": c.text, "payload": p.text})) + .collect(); + self.set("voice_macros", Value::Array(list)); + } +} + +/// `systemctl --user try-restart localflowd.service`: restarts the daemon +/// if it runs as the usual user unit. +pub fn restart_daemon() -> Result<(), String> { + let status = std::process::Command::new("systemctl") + .args(["--user", "try-restart", "localflowd.service"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| format!("could not restart the daemon: {e}"))?; + if status.success() { + Ok(()) + } else { + Err("could not restart the daemon (systemctl --user try-restart localflowd)".into()) + } +} + +/// A labelled row: the label, a reset icon when the window changed the +/// key, and the control. +fn setting(k: &Kit, s: &Settings, key: &'static str, label: &str, control: El) -> El { + let changed = s.draft.contains_key(key); + let mut left = row().key("left").gap(space::S2).center_y().grow(1.0).child( + text(label.to_owned()) + .key("label") + .font(k.ty.ui) + .color(k.c.text_base) + .a11y_hidden(), + ); + if changed { + left = left.child( + IconButton::new(&RESET) + .variant(IconButtonVariant::GhostMuted) + .label(format!("Reset {label}")) + .help("Back to config.json") + .on_press(move |s: &mut Settings| s.reset(key)) + .build(k) + .key(format!("reset-{key}")), + ); + } + row() + .key(key) + .min_h(40.0) + .px(space::S3) + .gap(space::S3) + .center_y() + .label(label.to_owned()) + .child(left) + .child(control) +} + +fn toggle(k: &Kit, s: &Settings, key: &'static str, label: &str, on: bool) -> El { + let control = Switch::new(on) + .label(label.to_owned()) + .hide_label() + .on_toggle(move |s: &mut Settings| s.set(key, Value::Bool(!on))) + .build(k) + .key(format!("switch-{key}")); + setting(k, s, key, label, control) +} + +fn card(k: &Kit, key: &str, title: &str, rows: Vec) -> El { + let mut body = col() + .key("card") + .padding(space::S1) + .fill(k.c.layer_01) + .radius(10.0) + .box_shadow(rune::Shadow { + spread: 0.5, + color: k.c.border_base, + ..Default::default() + }); + let n = rows.len(); + for (i, r) in rows.into_iter().enumerate() { + body = body.child(r); + if i + 1 < n { + body = body.child( + el().key(format!("sep{i}")) + .h(1.0) + .mx(space::S3) + .fill(k.c.border_base) + .a11y_hidden(), + ); + } + } + col() + .key(key.to_owned()) + .gap(space::S2) + .child( + text(title.to_owned()) + .key("header") + .font(k.ty.small) + .color(k.c.text_weak) + .ml(space::S3), + ) + .child(body) +} + +/// "Auto" is the model's default (no `precision` key). +const PRECISION: [&str; 5] = ["Auto", "f32", "i8x1", "i8x2", "i8x3"]; + +pub fn view(s: &Settings, cx: &mut Cx) -> El { + let k = Kit::neutral(cx.system_dark()); + let c = &s.shown; + let mut layer = Vec::new(); + + let dictation = card( + &k, + "dictation", + "Dictation", + vec![ + toggle( + &k, + s, + "press_enter", + "Say \u{201C}press enter\u{201D} to send", + c.press_enter, + ), + toggle( + &k, + s, + "spoken_numbers", + "Numbers as digits", + c.spoken_numbers, + ), + toggle( + &k, + s, + "spoken_delimiters", + "Spoken quotes and brackets", + c.spoken_delimiters, + ), + toggle( + &k, + s, + "pause_media", + "Pause media while recording", + c.pause_media, + ), + toggle(&k, s, "history", "Keep recent transcripts", c.history), + ], + ); + + let tag = TextInput::new(&s.prompt_tag) + .label("Prompt tag") + .placeholder("Untagged") + .width(Some(160.0)) + .on_edit(|s: &mut Settings| &mut s.prompt_tag) + .on_change(|s: &mut Settings, _: &mut Tx| { + let v = s.prompt_tag.text.clone(); + s.set("prompt_tag", Value::String(v)); + }) + .build(&k); + static DOUBLE_TAP: [(&str, u64); 4] = [ + ("Off", 0), + ("250 ms", 250), + ("400 ms", 400), + ("600 ms", 600), + ]; + let tap_labels: Vec<&str> = DOUBLE_TAP.iter().map(|(l, _)| *l).collect(); + let tap_current = DOUBLE_TAP.iter().position(|(_, v)| *v == c.double_tap_ms); + let tap = SegmentedControl::new(&tap_labels, tap_current) + .label("Double-tap") + .fit() + .on_pick(|s: &mut Settings, i: usize, _: &mut Tx| { + s.set("double_tap_ms", json!(DOUBLE_TAP[i].1)) + }) + .build(&k) + .key("choice-double_tap_ms"); + let prompt = card( + &k, + "prompt", + "Hold to Prompt", + vec![ + setting(&k, s, "double_tap_ms", "Double-tap and hold", tap), + setting(&k, s, "prompt_tag", "Tag", tag), + ], + ); + + static OUTPUT: [(&str, &str); 4] = [ + ("Auto", "auto"), + ("Wayland", "wayland"), + ("Portal", "portal"), + ("uinput", "uinput"), + ]; + let out_labels: Vec<&str> = OUTPUT.iter().map(|(l, _)| *l).collect(); + let out_current = OUTPUT.iter().position(|(_, v)| *v == output_name(c)); + let output = SegmentedControl::new(&out_labels, out_current) + .label("Output") + .fit() + .on_pick(|s: &mut Settings, i: usize, _: &mut Tx| s.set("output", json!(OUTPUT[i].1))) + .build(&k) + .key("choice-output"); + static DELAY: [(&str, u64); 4] = [("0", 0), ("1 ms", 1), ("5 ms", 5), ("10 ms", 10)]; + let delay_labels: Vec<&str> = DELAY.iter().map(|(l, _)| *l).collect(); + let delay = SegmentedControl::new( + &delay_labels, + DELAY.iter().position(|(_, v)| *v == c.key_delay_ms), + ) + .label("Key delay") + .fit() + .on_pick(|s: &mut Settings, i: usize, _: &mut Tx| s.set("key_delay_ms", json!(DELAY[i].1))) + .build(&k) + .key("choice-key_delay_ms"); + static PICKERS: [(&str, &str); 6] = [ + ("Auto", "auto"), + ("LocalFlow", "gui"), + ("rofi", "rofi"), + ("fuzzel", "fuzzel"), + ("wofi", "wofi"), + ("tofi", "tofi"), + ]; + let picker_labels: Vec<&str> = PICKERS.iter().map(|(l, _)| *l).collect(); + let picker_now = PICKERS + .iter() + .find(|(_, v)| *v == picker_name(c)) + .map(|(l, _)| *l); + let picker = Select::new("picker-select", &picker_labels) + .current(picker_now) + .label("Correction dialogs") + .width(140.0) + .open(s.open == Some("picker")) + .on_toggle(|s: &mut Settings| { + s.open = if s.open.is_some() { + None + } else { + Some("picker") + } + }) + .on_close(|s: &mut Settings| s.open = None) + .on_choose(|s: &mut Settings, v: &str, _: &mut Tx| { + s.open = None; + if let Some((_, name)) = PICKERS.iter().find(|(l, _)| *l == v) { + s.set("picker", json!(name)); + } + }) + .build(&k, &mut layer); + let typing = card( + &k, + "typing", + "Typing", + vec![ + setting(&k, s, "output", "Output", output), + setting(&k, s, "key_delay_ms", "Pause between keys", delay), + setting(&k, s, "picker", "Correction dialogs", picker), + ], + ); + + let precision_now = match c.precision { + None => 0, + Some(p) => PRECISION.iter().position(|n| *n == p.name()).unwrap_or(0), + }; + let precision = SegmentedControl::new(&PRECISION, Some(precision_now)) + .label("Precision") + .fit() + .on_pick(|s: &mut Settings, i: usize, _: &mut Tx| { + if i == 0 { + s.reset("precision"); + } else { + s.set("precision", json!(PRECISION[i])); + } + }) + .build(&k) + .key("choice-precision"); + let device = TextInput::new(&s.input_device) + .label("Microphone") + .placeholder("Default") + .width(Some(200.0)) + .on_edit(|s: &mut Settings| &mut s.input_device) + .on_change(|s: &mut Settings, _: &mut Tx| { + let t = s.input_device.text.trim().to_owned(); + s.set( + "input_device", + if t.is_empty() { + Value::Null + } else { + Value::String(t) + }, + ); + }) + .build(&k); + let recognition = card( + &k, + "recognition", + "Recognition", + vec![ + setting(&k, s, "precision", "Precision", precision), + setting(&k, s, "input_device", "Microphone", device), + ], + ); + + let mut macro_rows = Vec::new(); + for i in 0..s.macros.len() { + let command = TextInput::new(&s.macros[i].0) + .label("Say") + .placeholder("Say") + .width(Some(150.0)) + .on_edit(move |s: &mut Settings| &mut s.macros[i].0) + .on_change(|s: &mut Settings, _: &mut Tx| s.sync_macros()) + .build(&k); + let payload = TextInput::new(&s.macros[i].1) + .label("Type") + .placeholder("Type") + .width(None) + .on_edit(move |s: &mut Settings| &mut s.macros[i].1) + .on_change(|s: &mut Settings, _: &mut Tx| s.sync_macros()) + .build(&k) + .grow(1.0); + let remove = IconButton::new(&REMOVE) + .variant(IconButtonVariant::GhostMuted) + .label("Remove macro") + .on_press(move |s: &mut Settings| { + s.macros.remove(i); + s.sync_macros(); + }) + .build(&k); + macro_rows.push( + row() + .key(format!("macro-{i}")) + .min_h(40.0) + .px(space::S3) + .gap(space::S2) + .center_y() + .child(command) + .child(icon(&rune_kit::glyphs::ARROW_RIGHT, 14.0).color(k.c.text_weak)) + .child(payload) + .child(remove), + ); + } + let mut add_row = row() + .key("macro-add") + .min_h(40.0) + .px(space::S3) + .gap(space::S2) + .center_y() + .child( + IconButton::new(&PLUS) + .variant(IconButtonVariant::Ghost) + .label("Add macro") + .on_press(|s: &mut Settings| { + s.macros.push((TextEdit::default(), TextEdit::default())) + }) + .build(&k) + .key("add-macro"), + ); + add_row = add_row.child(el().grow(1.0)); + if s.draft.contains_key("voice_macros") { + add_row = add_row.child( + IconButton::new(&RESET) + .variant(IconButtonVariant::GhostMuted) + .label("Reset voice macros") + .help("Back to config.json") + .on_press(|s: &mut Settings| s.reset("voice_macros")) + .build(&k) + .key("reset-voice_macros"), + ); + } + macro_rows.push(add_row); + let macros = card(&k, "macros", "Voice macros", macro_rows); + + let content = col() + .key("content") + .w_full() + .px(space::S6) + .pt(16.0) + .pb(space::S6) + .gap(space::S6) + .child(dictation) + .child(prompt) + .child(typing) + .child(recognition) + .child(macros); + + let mut footer = row() + .key("footer") + .h(56.0) + .px(space::S6) + .gap(space::S2) + .center_y() + .fill(k.c.bg_base); + footer = match (&s.config, &s.status) { + (Err(e), _) | (_, Status::Failed(e)) => footer.child( + text(e.clone()) + .key("error") + .font(k.ty.small) + .color(k.c.danger.fg) + .grow(1.0) + .shrink(), + ), + (_, Status::Applied) => footer + .child(icon(&CHECK, 16.0).key("applied").color(k.c.success.icon)) + .child(el().grow(1.0)), + _ => footer.child(el().grow(1.0)), + }; + let can_apply = s.dirty() && s.config.is_ok(); + footer = footer + .child( + Button::new("Discard") + .variant(ButtonVariant::Ghost) + .disabled(!s.dirty()) + .on_press(|s: &mut Settings| s.discard()) + .build(&k) + .key("discard"), + ) + .child( + Button::new("Apply") + .variant(ButtonVariant::Contrast) + .disabled(!can_apply) + .on_press(|s: &mut Settings| s.apply()) + .build(&k) + .key("apply"), + ); + + let body = col() + .key("window") + .w_full() + .h_full() + .fill(k.c.bg_base) + .child( + content + .scroll(ScrollSpec { + stick_bottom: false, + }) + .grow(1.0) + .min_h(0.0), + ) + .child( + el().key("footer-rule") + .h(1.0) + .w_full() + .fill(k.c.border_base), + ) + .child(footer); + rune_kit::root(body, layer) +} + +fn output_name(c: &Config) -> &'static str { + use lf_daemon::config::Output; + match c.output { + Output::Auto => "auto", + Output::Wayland => "wayland", + Output::Portal => "portal", + Output::Uinput => "uinput", + } +} + +fn picker_name(c: &Config) -> &'static str { + use lf_daemon::picker::Choice; + match c.picker { + Choice::Auto => "auto", + Choice::Only(k) => k.name(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rune::{Env, Runtime}; + use std::path::Path; + + /// A temporary directory with a read-only config.json, as Home Manager + /// installs it. + struct Dir(PathBuf); + impl Dir { + fn new(name: &str, config: &str) -> Dir { + use std::os::unix::fs::PermissionsExt; + let d = std::env::temp_dir().join(format!("lf-gui-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let store = d.join("store.json"); + std::fs::write(&store, config).unwrap(); + std::fs::set_permissions(&store, std::fs::Permissions::from_mode(0o444)).unwrap(); + std::os::unix::fs::symlink(&store, d.join("config.json")).unwrap(); + Dir(d) + } + fn config(&self) -> PathBuf { + self.0.join("config.json") + } + fn settings(&self) -> Option { + std::fs::read(self.0.join("settings.json")) + .ok() + .map(|b| serde_json::from_slice(&b).unwrap()) + } + fn window(&self) -> Runtime { + let layers = Layers::load(&self.config(), Path::new("/d")).unwrap(); + let s = Settings::new(self.config(), "/d".into(), layers, None).unwrap(); + // Tall enough that nothing needs scrolling. + let mut rt = Runtime::new(s, view, (560.0, 1400.0), Env::new(true, 1.0)); + rt.frame(0.0); + rt + } + } + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn press(rt: &mut Runtime, key: &str) { + let (x, y) = rt.find(key).unwrap_or_else(|| panic!("no {key:?}")); + rt.pointer_down(x, y); + rt.pointer_up(x, y); + for _ in 0..3 { + rt.frame(1.0 / 60.0); + } + } + + #[test] + fn a_toggle_is_a_draft_until_apply_writes_only_what_differs() { + let d = Dir::new( + "toggle", + r#"{"version": 1, "press_enter": false, "key_delay_ms": 0}"#, + ); + let mut rt = d.window(); + assert!(!rt.state.shown.press_enter); + press(&mut rt, "switch-press_enter"); + assert!(rt.state.shown.press_enter && rt.state.dirty()); + assert_eq!(d.settings(), None, "nothing is written before Apply"); + assert!( + rt.find("reset-press_enter").is_some(), + "a changed setting can be reset" + ); + + press(&mut rt, "apply"); + assert_eq!(d.settings(), Some(json!({"press_enter": true}))); + assert_eq!(rt.state.status, Status::Applied); + assert!(!rt.state.dirty()); + + // Flipping it back to config.json's value drops the key. + press(&mut rt, "switch-press_enter"); + assert!(rt.state.draft.is_empty() && rt.state.dirty()); + press(&mut rt, "apply"); + assert_eq!(d.settings(), None, "no settings left: the file is removed"); + } + + #[test] + fn reset_and_discard_go_back() { + let d = Dir::new("reset", r#"{"version": 1}"#); + let mut rt = d.window(); + press(&mut rt, "switch-history"); + assert!(rt.state.shown.history); + press(&mut rt, "reset-history"); + assert!(!rt.state.shown.history && !rt.state.dirty()); + + press(&mut rt, "switch-pause_media"); + press(&mut rt, "discard"); + assert!(rt.state.shown.pause_media && rt.state.draft.is_empty()); + assert_eq!(d.settings(), None); + } + + #[test] + fn invalid_text_keeps_apply_disabled_and_names_the_key() { + let d = Dir::new("invalid", r#"{"version": 1}"#); + let mut rt = d.window(); + rt.update(|s, _| s.set("prompt_tag", Value::String("x".repeat(65)))); + rt.frame(1.0 / 60.0); + assert!(rt.find("error").is_some(), "the error is shown"); + let e = rt.state.config.clone().unwrap_err(); + assert!(e.contains("prompt_tag") && !e.contains("xxxx"), "{e}"); + press(&mut rt, "apply"); + assert_eq!(d.settings(), None); + // The controls keep the last valid configuration. + assert_eq!(rt.state.shown.prompt_tag, "[dictated]"); + } + + #[test] + fn existing_settings_show_and_macros_round_trip() { + let d = Dir::new( + "macros", + r#"{"version": 1, "voice_macros": [{"command": "sign off", "payload": "Synthetic regards"}]}"#, + ); + let mut rt = d.window(); + assert_eq!(rt.state.macros.len(), 1); + press(&mut rt, "add-macro"); + assert_eq!(rt.state.macros.len(), 2); + rt.update(|s, _| { + s.macros[1].0 = TextEdit::new("synthetic hello"); + s.macros[1].1 = TextEdit::new("Hello there"); + s.sync_macros(); + }); + rt.frame(1.0 / 60.0); + press(&mut rt, "apply"); + assert_eq!( + d.settings(), + Some(json!({"voice_macros": [ + {"command": "sign off", "payload": "Synthetic regards"}, + {"command": "synthetic hello", "payload": "Hello there"} + ]})) + ); + // A new window shows what was applied. + let rt = d.window(); + assert_eq!(rt.state.macros.len(), 2); + assert!(rt.find("reset-voice_macros").is_some()); + } + + #[test] + fn picking_a_segment_sets_its_value() { + let d = Dir::new("segments", r#"{"version": 1}"#); + let mut rt = d.window(); + rt.update(|s, _| { + s.set("key_delay_ms", json!(5)); + s.set("picker", json!("gui")); + }); + rt.frame(1.0 / 60.0); + press(&mut rt, "apply"); + assert_eq!( + d.settings(), + Some(json!({"key_delay_ms": 5, "picker": "gui"})) + ); + } +} From fddd3dbaa7c0514aa6bff566e4f7800ccad38527 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 16:00:47 -0800 Subject: [PATCH 02/18] linux: try localflow-gui last in auto picker order Installing it then changes nothing where a menu tool is installed, and the integration tests' fake rofi keeps priority over a real localflow-gui. --- linux/CHANGELOG.md | 4 +-- linux/README.md | 12 ++++----- .../crates/lf-daemon/src/bin/localflowctl.rs | 6 ++--- linux/crates/lf-daemon/src/picker.rs | 26 ++++++++++++------- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 444255ec..95de9186 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -14,8 +14,8 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. new `settings.json` beside `config.json`, whose keys replace `config.json`'s, so a read-only `config.json` from Home Manager can still be adjusted. `"picker": "gui"` uses its dialogs for `localflowctl correct` - and `dict review`, and `auto` prefers them when `localflow-gui` is - installed. + and `dict review`; `auto` uses them only when no menu tool (rofi, fuzzel, + wofi, tofi) is installed. ### Changed diff --git a/linux/README.md b/linux/README.md index 635787a8..fb5d3a1d 100644 --- a/linux/README.md +++ b/linux/README.md @@ -208,10 +208,10 @@ invalid values stop the daemon with an error. Defaults: newlines; surrounding spaces are trimmed; `""` types prompts untagged. - `double_tap_ms`: 150-1000, or 0 to turn double-tap off. - `picker`: the menu `localflowctl correct` and `dict review` show text in: - `"auto"`, `"gui"`, `"rofi"`, `"fuzzel"`, `"wofi"` or `"tofi"`. `"gui"` is + `"auto"`, `"rofi"`, `"fuzzel"`, `"wofi"`, `"tofi"` or `"gui"`. `"gui"` is LocalFlow's own windows (`localflow-gui`, see "Settings window"). - `auto` takes, for each dialog, the first of localflow-gui, rofi, fuzzel, - wofi and tofi on `PATH` that can show it. fuzzel and tofi cannot prefill an input box without putting the + `auto` takes, for each dialog, the first of rofi, fuzzel, wofi, tofi and + localflow-gui on `PATH` that can show it. fuzzel and tofi cannot prefill an input box without putting the text on their command line, so they only show the suggestion dialog (`dict review`, and the second step of `correct`); with one of them configured, `correct` says so and does nothing. wofi is not used while @@ -422,7 +422,7 @@ localflowctl correct ``` Bind it to a key (see the snippets in `packaging/`). Within two minutes of a -dictation being typed, it opens a picker (localflow-gui, rofi or wofi; see `picker` above) +dictation being typed, it opens a picker (rofi, wofi or localflow-gui; see `picker` above) with that dictation's text; edit it to what you meant and press Return (Escape does nothing). **It never changes the text already typed into the other app**: fix that there as @@ -704,8 +704,8 @@ also works on compositors that have the protocol (Hyprland, below). LocalFlow asks for no particular key. Change them later in the desktop's shortcut settings (GNOME Settings > Keyboard > Keyboard Shortcuts; KDE System Settings > Keyboard > Shortcuts). -3. `localflowctl correct` needs localflow-gui, or rofi or wofi, which need - layer-shell (KDE has it, GNOME does not); bind it as an ordinary custom +3. `localflowctl correct` needs rofi or wofi, which need layer-shell + (or localflow-gui, which does not) (KDE has it, GNOME does not); bind it as an ordinary custom shortcut. Behaviour: diff --git a/linux/crates/lf-daemon/src/bin/localflowctl.rs b/linux/crates/lf-daemon/src/bin/localflowctl.rs index 3b49f8a8..0a5f7581 100644 --- a/linux/crates/lf-daemon/src/bin/localflowctl.rs +++ b/linux/crates/lf-daemon/src/bin/localflowctl.rs @@ -8,7 +8,7 @@ //! killed. //! //! `localflowctl correct` teaches the custom dictionary from a correction of -//! the last dictation, in a picker (localflow-gui, rofi or wofi; see `lf_daemon::picker`); +//! the last dictation, in a picker (rofi, wofi or localflow-gui; see `lf_daemon::picker`); //! it never changes the text already typed. `localflowctl dict …` manages //! the dictionary; `dict suggest` queues a word for review (agents use it) //! and `dict review` offers each pending suggestion in a picker (rofi, @@ -93,8 +93,8 @@ commands: correct edit the last dictation in a picker (within two minutes of typing it) to teach the dictionary; the text already typed is not changed. A word the audio confirms is offered for adding. - The picker is the configuration's \"picker\": gui, rofi, fuzzel, - wofi or tofi (default auto: the first installed) + The picker is the configuration's \"picker\": rofi, fuzzel, + wofi, tofi or gui (default auto: the first installed) dict add (strength 0.25 to 8, default 1), remove, list the custom dictionary; ignored lists words never suggested, unignore allows one again; suggest queues a word for review, review diff --git a/linux/crates/lf-daemon/src/picker.rs b/linux/crates/lf-daemon/src/picker.rs index 4d145ef4..f1fbc9a7 100644 --- a/linux/crates/lf-daemon/src/picker.rs +++ b/linux/crates/lf-daemon/src/picker.rs @@ -31,7 +31,8 @@ //! //! `"picker"` in `config.json` picks one: `"auto"` (the default) takes, for //! each operation, the first tool on `PATH` that supports it, in the order -//! gui (`localflow-gui`), rofi, fuzzel, wofi, tofi. Verdicts, from each +//! rofi, fuzzel, wofi, tofi, then gui (`localflow-gui`; last, so installing +//! it changes nothing for a desktop that has a menu tool). Verdicts, from each //! version's manual and source as packaged in nixpkgs, and run in a private //! headless sway: //! @@ -116,7 +117,7 @@ pub enum Kind { impl Kind { /// In `auto` order. - pub const ALL: [Kind; 5] = [Kind::Gui, Kind::Rofi, Kind::Fuzzel, Kind::Wofi, Kind::Tofi]; + pub const ALL: [Kind; 5] = [Kind::Rofi, Kind::Fuzzel, Kind::Wofi, Kind::Tofi, Kind::Gui]; /// Its `picker` value. pub fn name(self) -> &'static str { @@ -162,7 +163,7 @@ pub enum Choice { impl Choice { /// The accepted values, for error messages. - pub const VALUES: &str = "auto, gui, rofi, fuzzel, wofi, tofi"; + pub const VALUES: &str = "auto, rofi, fuzzel, wofi, tofi, gui"; pub fn parse(s: &str) -> Option { if s == "auto" { @@ -232,15 +233,15 @@ impl std::fmt::Display for NoPicker { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { NoPicker::NoneFound(Op::Edit) => f.write_str( - "no picker that can edit text was found (install localflow-gui, rofi or wofi)", + "no picker that can edit text was found (install rofi, wofi or localflow-gui)", ), NoPicker::NoneFound(Op::Choose) => f.write_str( - "no picker was found (install localflow-gui, rofi, fuzzel, wofi or tofi)", + "no picker was found (install rofi, fuzzel, wofi, tofi or localflow-gui)", ), NoPicker::Unsupported(k, _) => write!( f, "{} cannot edit text without putting it on its command line; \ - set \"picker\" to \"gui\", \"rofi\", \"wofi\" or \"auto\" to correct dictations", + set \"picker\" to \"rofi\", \"wofi\", \"gui\" or \"auto\" to correct dictations", k.name() ), NoPicker::Missing(k) => { @@ -868,15 +869,22 @@ pub(crate) mod tests { } }; let pick = |c, op, names| select(c, op, installed(names)).map(|p| (p.kind, p.program)); - // auto: LocalFlow's own windows first, then rofi, fuzzel, wofi, - // tofi; edit skips those that cannot. + // auto: rofi, fuzzel, wofi, tofi, then LocalFlow's own windows + // last; edit skips those that cannot. let with_gui = &["localflow-gui", "rofi", "fuzzel", "wofi", "tofi"]; for op in [Op::Edit, Op::Choose] { + assert_eq!(pick(Choice::Auto, op, with_gui).unwrap().0, Kind::Rofi); assert_eq!( - pick(Choice::Auto, op, with_gui), + pick(Choice::Auto, op, &["localflow-gui"]), Ok((Kind::Gui, p("/bin/localflow-gui"))) ); } + assert_eq!( + pick(Choice::Auto, Op::Edit, &["localflow-gui", "fuzzel", "tofi"]) + .unwrap() + .0, + Kind::Gui + ); assert_eq!( pick(Choice::Only(Kind::Gui), Op::Edit, &["rofi"]).unwrap_err(), NoPicker::Missing(Kind::Gui) From f4b2f91d26c4e059c69cf6401609203d87139067 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 16:06:24 -0800 Subject: [PATCH 03/18] linux: keep the picker install hint's wording --- linux/crates/lf-daemon/src/picker.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linux/crates/lf-daemon/src/picker.rs b/linux/crates/lf-daemon/src/picker.rs index f1fbc9a7..f4506456 100644 --- a/linux/crates/lf-daemon/src/picker.rs +++ b/linux/crates/lf-daemon/src/picker.rs @@ -233,10 +233,10 @@ impl std::fmt::Display for NoPicker { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { NoPicker::NoneFound(Op::Edit) => f.write_str( - "no picker that can edit text was found (install rofi, wofi or localflow-gui)", + "no picker that can edit text was found (install rofi or wofi, or localflow-gui)", ), NoPicker::NoneFound(Op::Choose) => f.write_str( - "no picker was found (install rofi, fuzzel, wofi, tofi or localflow-gui)", + "no picker was found (install rofi, fuzzel, wofi or tofi, or localflow-gui)", ), NoPicker::Unsupported(k, _) => write!( f, From d0a0c3e0f5b0e6d1e103a582ecb8ac147d510b1d Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 16:11:58 -0800 Subject: [PATCH 04/18] linux: choose dialog keyboard (arrows, Tab, Enter/Space, digits) --- linux/gui/src/main.rs | 5 +- linux/gui/src/prompt.rs | 115 +++++++++++++++++++++++++++++++++++----- 2 files changed, 103 insertions(+), 17 deletions(-) diff --git a/linux/gui/src/main.rs b/linux/gui/src/main.rs index dfae68c5..2eccee44 100644 --- a/linux/gui/src/main.rs +++ b/linux/gui/src/main.rs @@ -106,10 +106,7 @@ fn run_choose() -> i32 { } } fn init() -> prompt::Choose { - prompt::Choose { - rows: prompt::parse_rows(INPUT.get().map_or("", String::as_str)), - outcome: None, - } + prompt::Choose::new(prompt::parse_rows(INPUT.get().map_or("", String::as_str))) } if init().rows.is_empty() { eprintln!("localflow-gui: no rows on stdin"); diff --git a/linux/gui/src/prompt.rs b/linux/gui/src/prompt.rs index 290ccf8b..723c6270 100644 --- a/linux/gui/src/prompt.rs +++ b/linux/gui/src/prompt.rs @@ -3,8 +3,9 @@ //! - **Edit** (`localflow-gui edit`): the last dictation in a text box. //! Enter accepts and prints the text; Shift+Enter is refused (a //! dictation is one line). -//! - **Choose** (`localflow-gui choose`): one button per row; the first is -//! the primary action. Pressing one prints its number. +//! - **Choose** (`localflow-gui choose`): one button per row, the first +//! highlighted. Pressing one prints its number; so do Enter or Space on +//! the highlighted row (Up, Down and Tab move it) and the digits 1-9. //! //! Escape, or closing the window, dismisses: exit status 1, no output. //! Nothing is written anywhere but stdout. @@ -116,14 +117,26 @@ pub fn edit_view(s: &Edit, cx: &mut Cx) -> El { pub struct Choose { pub rows: Vec, + /// The highlighted row, which Enter chooses. + pub selected: usize, pub outcome: Option, } +impl Choose { + pub fn new(rows: Vec) -> Choose { + Choose { + rows, + selected: 0, + outcome: None, + } + } +} + pub fn choose_view(s: &Choose, cx: &mut Cx) -> El { let k = Kit::neutral(cx.system_dark()); let mut list = col().key("rows").gap(space::S2).w_full(); for (i, label) in s.rows.iter().enumerate() { - let variant = if i == 0 { + let variant = if i == s.selected { ButtonVariant::Contrast } else { ButtonVariant::Neutral @@ -171,14 +184,44 @@ pub fn choose_height(rows: usize) -> f32 { 16.0 * 2.0 + 18.0 + space::S3 + rows as f32 * 32.0 + rows.saturating_sub(1) as f32 * space::S2 } -/// Escape anywhere dismisses the choose dialog (the edit box handles its -/// own keys). +/// The choose dialog's keys: Escape dismisses; Up, Down and Tab move the +/// highlight; Enter or Space chooses the focused button, else the +/// highlighted row; 1-9 choose that row. pub fn choose_key(s: &mut Choose, e: &KeyEvent, tx: &mut Tx) -> bool { - if e.key == Key::Escape { - dismiss(&mut s.outcome, tx); - return true; + let n = s.rows.len(); + if n == 0 { + return false; } - false + match &e.key { + Key::Escape => dismiss(&mut s.outcome, tx), + Key::Down => s.selected = (s.selected + 1).min(n - 1), + Key::Up => s.selected = s.selected.saturating_sub(1), + Key::Tab if e.mods.shift => s.selected = (s.selected + n - 1) % n, + Key::Tab => s.selected = (s.selected + 1) % n, + Key::Enter => { + let i = focused_choice(tx).unwrap_or(s.selected); + finish(&mut s.outcome, Outcome::Chose(i), tx); + } + Key::Text(t) if t == " " => { + let i = focused_choice(tx).unwrap_or(s.selected); + finish(&mut s.outcome, Outcome::Chose(i), tx); + } + Key::Text(t) => match t.parse::() { + Ok(d) if (1..=n.min(9)).contains(&d) => { + finish(&mut s.outcome, Outcome::Chose(d - 1), tx) + } + _ => return false, + }, + _ => return false, + } + true +} + +/// The row of the focused button, when one has focus. +fn focused_choice(tx: &Tx) -> Option { + let key = tx.focused()?; + let last = key.rsplit('/').next()?; + last.strip_prefix("choice-")?.parse().ok() } pub fn edit_key(s: &mut Edit, e: &KeyEvent, tx: &mut Tx) -> bool { @@ -207,6 +250,55 @@ mod tests { use super::*; use rune::{Env, Mods, Runtime}; + fn choose_window() -> Runtime { + let rows = parse_rows("Add \u{201C}Zorblax\u{201D}\nNot now\nDon\u{2019}t suggest\n"); + let mut rt = Runtime::new( + Choose::new(rows), + choose_view, + (420.0, 220.0), + Env::new(true, 1.0), + ); + rt.on_key = Some(choose_key); + rt.frame(0.0); + rt + } + + fn type_key(rt: &mut Runtime, k: Key) { + rt.key(KeyEvent { + key: k, + mods: Mods::default(), + }); + rt.frame(1.0 / 60.0); + } + + #[test] + fn the_keyboard_moves_the_highlight_and_chooses() { + let mut rt = choose_window(); + type_key(&mut rt, Key::Down); + type_key(&mut rt, Key::Down); + type_key(&mut rt, Key::Down); + assert_eq!(rt.state.selected, 2, "Down stops at the last row"); + type_key(&mut rt, Key::Up); + type_key(&mut rt, Key::Enter); + assert_eq!(rt.state.outcome, Some(Outcome::Chose(1))); + + let mut rt = choose_window(); + type_key(&mut rt, Key::Tab); + type_key(&mut rt, Key::Text(" ".into())); + assert_eq!(rt.state.outcome, Some(Outcome::Chose(1))); + + let mut rt = choose_window(); + type_key(&mut rt, Key::Text("3".into())); + assert_eq!(rt.state.outcome, Some(Outcome::Chose(2))); + + // A digit past the rows does nothing. + let mut rt = choose_window(); + type_key(&mut rt, Key::Text("4".into())); + assert_eq!(rt.state.outcome, None); + type_key(&mut rt, Key::Escape); + assert_eq!(rt.state.outcome, Some(Outcome::Dismissed)); + } + fn press(rt: &mut Runtime, key: &str) { let (x, y) = rt.find(key).unwrap_or_else(|| panic!("no {key:?}")); rt.pointer_down(x, y); @@ -292,10 +384,7 @@ mod tests { let rows = parse_rows("Add \u{201C}Zorblax\u{201D}\nNot now\n\nDon\u{2019}t suggest\n"); assert_eq!(rows.len(), 3); for i in 0..rows.len() { - let s = Choose { - rows: rows.clone(), - outcome: None, - }; + let s = Choose::new(rows.clone()); let mut rt = Runtime::new(s, choose_view, (420.0, 220.0), Env::new(true, 1.0)); rt.frame(0.0); press(&mut rt, &format!("choice-{i}")); From 77d15b65b7a57a01a8a68520af57bdc3181f73c0 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 16:16:37 -0800 Subject: [PATCH 05/18] linux: address review: opt-in GUI edit box, settings size cap, fresh layers after Apply - auto never edits in localflow-gui: its edit box goes through the desktop's input method, so correcting there needs "picker": "gui". - write_settings refuses a file the daemon would refuse (over 1 MiB). - Apply reloads both files, so a config.json changed meanwhile is the base for later edits. --- linux/CHANGELOG.md | 4 +-- linux/README.md | 6 ++-- linux/crates/lf-daemon/src/config.rs | 18 ++++++++++++ linux/crates/lf-daemon/src/picker.rs | 36 ++++++++++++++++-------- linux/crates/lf-daemon/src/picker/gui.rs | 4 +++ linux/gui/src/settings.rs | 33 +++++++++++++++++++++- 6 files changed, 85 insertions(+), 16 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 95de9186..d093a3a8 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -14,8 +14,8 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. new `settings.json` beside `config.json`, whose keys replace `config.json`'s, so a read-only `config.json` from Home Manager can still be adjusted. `"picker": "gui"` uses its dialogs for `localflowctl correct` - and `dict review`; `auto` uses them only when no menu tool (rofi, fuzzel, - wofi, tofi) is installed. + and `dict review`; `auto` uses them only for the suggestion dialog, and only + when no menu tool (rofi, fuzzel, wofi, tofi) is installed. ### Changed diff --git a/linux/README.md b/linux/README.md index fb5d3a1d..dc141182 100644 --- a/linux/README.md +++ b/linux/README.md @@ -211,7 +211,8 @@ invalid values stop the daemon with an error. Defaults: `"auto"`, `"rofi"`, `"fuzzel"`, `"wofi"`, `"tofi"` or `"gui"`. `"gui"` is LocalFlow's own windows (`localflow-gui`, see "Settings window"). `auto` takes, for each dialog, the first of rofi, fuzzel, wofi, tofi and - localflow-gui on `PATH` that can show it. fuzzel and tofi cannot prefill an input box without putting the + localflow-gui on `PATH` that can show it (localflow-gui only for the + suggestion dialog; see "Settings window"). fuzzel and tofi cannot prefill an input box without putting the text on their command line, so they only show the suggestion dialog (`dict review`, and the second step of `correct`); with one of them configured, `correct` says so and does nothing. wofi is not used while @@ -252,7 +253,8 @@ is a settings window and the correction dialogs: the text comes on stdin and the answer goes to stdout; Escape or closing the window does nothing. They are built without Rune's accessibility bridge. An input method (fcitx5, IBus) sees what is typed in the edit - box, as in any other window. + box, as in any other window, so `auto` uses localflow-gui only for the + suggestion dialog; set `"picker": "gui"` to correct in it too. ## Run diff --git a/linux/crates/lf-daemon/src/config.rs b/linux/crates/lf-daemon/src/config.rs index e4e5b8e5..82b6ec53 100644 --- a/linux/crates/lf-daemon/src/config.rs +++ b/linux/crates/lf-daemon/src/config.rs @@ -273,6 +273,13 @@ pub fn write_settings( let mut bytes = serde_json::to_vec_pretty(&Value::Object(settings.clone())) .map_err(|e| format!("settings: {e}"))?; bytes.push(b'\n'); + // The daemon refuses a larger file. + if bytes.len() as u64 > MAX_CONFIG_BYTES { + return Err(format!( + "settings {} would be larger than 1 MiB", + path.display() + )); + } write_atomic(&path, &bytes, SyncDir::Yes) .map_err(|e| format!("settings {}: {e}", path.display()))?; Ok(config) @@ -1036,6 +1043,17 @@ mod tests { assert!(e.contains("key_delay_ms"), "{e}"); assert!(Config::load(&path, d).unwrap().press_enter); + // Settings too large for the daemon to load are not written, though + // every value is valid. + let big: Vec = (0..300) + .map(|i| serde_json::json!({"command": format!("synthetic {i}"), "payload": "x".repeat(4000)})) + .collect(); + let mut too_big = Map::new(); + too_big.insert("voice_macros".into(), Value::Array(big)); + let e = write_settings(&path, d, &too_big).unwrap_err(); + assert!(e.contains("larger than 1 MiB"), "{e}"); + assert!(Config::load(&path, d).unwrap().press_enter); + // No settings remove the file: back to the config file alone. let c = write_settings(&path, d, &Map::new()).unwrap(); assert!(!c.press_enter); diff --git a/linux/crates/lf-daemon/src/picker.rs b/linux/crates/lf-daemon/src/picker.rs index f4506456..45ca9847 100644 --- a/linux/crates/lf-daemon/src/picker.rs +++ b/linux/crates/lf-daemon/src/picker.rs @@ -32,12 +32,15 @@ //! `"picker"` in `config.json` picks one: `"auto"` (the default) takes, for //! each operation, the first tool on `PATH` that supports it, in the order //! rofi, fuzzel, wofi, tofi, then gui (`localflow-gui`; last, so installing -//! it changes nothing for a desktop that has a menu tool). Verdicts, from each +//! it changes nothing for a desktop that has a menu tool, and for the +//! suggestion dialog only: its edit box needs `"picker": "gui"`, see +//! [`Kind::auto_ok`]). Verdicts, from each //! version's manual and source as packaged in nixpkgs, and run in a private //! headless sway: //! //! - **gui** (`localflow-gui`, LocalFlow's own windows): edit and choose. -//! See [`gui`]. +//! Its edit box goes through the desktop's input method (fcitx5, IBus) +//! when one runs, so `auto` never edits in it. See [`gui`]. //! - **rofi** (2.0.0): edit and choose. See [`rofi`]. //! - **fuzzel** (1.14.1): choose only. It can prefill its box only with //! `--search=TEXT` on its command line (its configuration file has no @@ -138,6 +141,13 @@ impl Kind { } } + /// Whether `auto` may use it for `op`. The gui edit box hands what is + /// typed to the desktop's input method (Rune has no way to turn it + /// off), so it edits only when configured by name. + pub fn auto_ok(self, op: Op) -> bool { + !(self == Kind::Gui && op == Op::Edit) + } + pub fn supports(self, op: Op) -> bool { match op { Op::Choose => true, @@ -267,7 +277,7 @@ pub fn select(choice: Choice, op: Op, probe: impl Fn(Kind) -> Probe) -> Result

Kind::ALL .into_iter() - .filter(|k| k.supports(op)) + .filter(|k| k.supports(op) && k.auto_ok(op)) .find_map(|k| match probe(k) { Probe::Found(program) => Some(Picker::new(k, program)), _ => None, @@ -874,16 +884,20 @@ pub(crate) mod tests { let with_gui = &["localflow-gui", "rofi", "fuzzel", "wofi", "tofi"]; for op in [Op::Edit, Op::Choose] { assert_eq!(pick(Choice::Auto, op, with_gui).unwrap().0, Kind::Rofi); - assert_eq!( - pick(Choice::Auto, op, &["localflow-gui"]), - Ok((Kind::Gui, p("/bin/localflow-gui"))) - ); } + // auto shows the suggestion dialog in it, but never edits there (its + // edit box goes through the input method); configured, it edits. + assert_eq!( + pick(Choice::Auto, Op::Choose, &["localflow-gui"]), + Ok((Kind::Gui, p("/bin/localflow-gui"))) + ); + assert_eq!( + pick(Choice::Auto, Op::Edit, &["localflow-gui", "fuzzel", "tofi"]).unwrap_err(), + NoPicker::NoneFound(Op::Edit) + ); assert_eq!( - pick(Choice::Auto, Op::Edit, &["localflow-gui", "fuzzel", "tofi"]) - .unwrap() - .0, - Kind::Gui + pick(Choice::Only(Kind::Gui), Op::Edit, &["localflow-gui"]), + Ok((Kind::Gui, p("/bin/localflow-gui"))) ); assert_eq!( pick(Choice::Only(Kind::Gui), Op::Edit, &["rofi"]).unwrap_err(), diff --git a/linux/crates/lf-daemon/src/picker/gui.rs b/linux/crates/lf-daemon/src/picker/gui.rs index 56fdce76..5ca5eb7d 100644 --- a/linux/crates/lf-daemon/src/picker/gui.rs +++ b/linux/crates/lf-daemon/src/picker/gui.rs @@ -7,6 +7,10 @@ //! - Escape or closing the window exits 1 with no output. //! - It keeps no history or cache, reads no configuration of its own, and //! is built without the accessibility bridge. +//! - The edit box hands what is typed to the desktop's input method (fcitx5, +//! IBus) when one runs, as any window's text field does; Rune cannot turn +//! that off for one field. So `auto` never edits here: the user opts in +//! with `"picker": "gui"`. use super::{Dismissal, Invocation, Reply}; diff --git a/linux/gui/src/settings.rs b/linux/gui/src/settings.rs index ed505e8a..7bf877c0 100644 --- a/linux/gui/src/settings.rs +++ b/linux/gui/src/settings.rs @@ -183,7 +183,13 @@ impl Settings { let daemon_reads = changed.iter().any(|k| !CLIENT_ONLY.contains(&k.as_str())); match write_settings(&self.config_path, &self.data_dir, &self.draft) { Ok(config) => { - self.layers.settings = self.draft.clone(); + // config.json may have changed since the window opened (a + // Home Manager switch): work from both files as they are. + match Layers::load(&self.config_path, &self.data_dir) { + Ok(layers) => self.layers = layers, + Err(_) => self.layers.settings = self.draft.clone(), + } + self.draft = self.layers.settings.clone(); self.shown = config.clone(); self.config = Ok(config); self.status = Status::Applied; @@ -743,6 +749,31 @@ mod tests { assert_eq!(d.settings(), None, "no settings left: the file is removed"); } + #[test] + fn apply_picks_up_a_changed_config_file() { + let d = Dir::new("rebase", r#"{"version": 1, "press_enter": false}"#); + let mut rt = d.window(); + // A Home Manager switch replaces config.json while the window is open. + std::fs::remove_file(d.config()).unwrap(); + std::fs::write( + d.0.join("new-store.json"), + r#"{"version": 1, "press_enter": true}"#, + ) + .unwrap(); + std::os::unix::fs::symlink(d.0.join("new-store.json"), d.config()).unwrap(); + press(&mut rt, "switch-history"); + press(&mut rt, "apply"); + assert!(rt.state.shown.press_enter, "the new config.json shows"); + // Turning it off now is an override of the new value. + press(&mut rt, "switch-press_enter"); + assert!(!rt.state.shown.press_enter && rt.state.dirty()); + press(&mut rt, "apply"); + assert_eq!( + d.settings(), + Some(json!({"history": true, "press_enter": false})) + ); + } + #[test] fn reset_and_discard_go_back() { let d = Dir::new("reset", r#"{"version": 1}"#); From b268d00363ea6d088ec64551f1c05c5234d4d374 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 16:23:49 -0800 Subject: [PATCH 06/18] linux: choose buttons never take keyboard focus A click cancelled by dragging off a button left it focused, so Enter or Space chose it instead of the highlighted row. --- linux/gui/src/prompt.rs | 49 ++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/linux/gui/src/prompt.rs b/linux/gui/src/prompt.rs index 723c6270..2d533b5b 100644 --- a/linux/gui/src/prompt.rs +++ b/linux/gui/src/prompt.rs @@ -5,7 +5,9 @@ //! dictation is one line). //! - **Choose** (`localflow-gui choose`): one button per row, the first //! highlighted. Pressing one prints its number; so do Enter or Space on -//! the highlighted row (Up, Down and Tab move it) and the digits 1-9. +//! the highlighted row (Up, Down and Tab move it) and the digits 1-9. The +//! buttons never take keyboard focus, so the keys always act on the +//! highlight the window shows. //! //! Escape, or closing the window, dismisses: exit status 1, no output. //! Nothing is written anywhere but stdout. @@ -150,6 +152,9 @@ pub fn choose_view(s: &Choose, cx: &mut Cx) -> El { }) .build(&k) .key(format!("choice-{i}")) + // The keyboard drives the highlight only (a focused + // button would take Enter and Space for itself). + .focusable(false) .grow(1.0), ), ); @@ -185,8 +190,8 @@ pub fn choose_height(rows: usize) -> f32 { } /// The choose dialog's keys: Escape dismisses; Up, Down and Tab move the -/// highlight; Enter or Space chooses the focused button, else the -/// highlighted row; 1-9 choose that row. +/// highlight; Enter or Space choose the highlighted row; 1-9 choose that +/// row. pub fn choose_key(s: &mut Choose, e: &KeyEvent, tx: &mut Tx) -> bool { let n = s.rows.len(); if n == 0 { @@ -198,14 +203,8 @@ pub fn choose_key(s: &mut Choose, e: &KeyEvent, tx: &mut Tx) -> bool { Key::Up => s.selected = s.selected.saturating_sub(1), Key::Tab if e.mods.shift => s.selected = (s.selected + n - 1) % n, Key::Tab => s.selected = (s.selected + 1) % n, - Key::Enter => { - let i = focused_choice(tx).unwrap_or(s.selected); - finish(&mut s.outcome, Outcome::Chose(i), tx); - } - Key::Text(t) if t == " " => { - let i = focused_choice(tx).unwrap_or(s.selected); - finish(&mut s.outcome, Outcome::Chose(i), tx); - } + Key::Enter => finish(&mut s.outcome, Outcome::Chose(s.selected), tx), + Key::Text(t) if t == " " => finish(&mut s.outcome, Outcome::Chose(s.selected), tx), Key::Text(t) => match t.parse::() { Ok(d) if (1..=n.min(9)).contains(&d) => { finish(&mut s.outcome, Outcome::Chose(d - 1), tx) @@ -217,13 +216,6 @@ pub fn choose_key(s: &mut Choose, e: &KeyEvent, tx: &mut Tx) -> bool { true } -/// The row of the focused button, when one has focus. -fn focused_choice(tx: &Tx) -> Option { - let key = tx.focused()?; - let last = key.rsplit('/').next()?; - last.strip_prefix("choice-")?.parse().ok() -} - pub fn edit_key(s: &mut Edit, e: &KeyEvent, tx: &mut Tx) -> bool { if e.key == Key::Escape { dismiss(&mut s.outcome, tx); @@ -291,6 +283,27 @@ mod tests { type_key(&mut rt, Key::Text("3".into())); assert_eq!(rt.state.outcome, Some(Outcome::Chose(2))); + // A click cancelled by dragging off the button leaves nothing + // focused: the keys still act on the highlight. + let mut rt = choose_window(); + let (x, y) = rt.find("choice-2").unwrap(); + rt.pointer_down(x, y); + rt.pointer_move(x, y + 400.0); + rt.pointer_up(x, y + 400.0); + rt.frame(1.0 / 60.0); + assert_eq!(rt.state.outcome, None, "the cancelled click chose nothing"); + type_key(&mut rt, Key::Down); + type_key(&mut rt, Key::Enter); + assert_eq!(rt.state.outcome, Some(Outcome::Chose(1))); + let mut rt = choose_window(); + let (x, y) = rt.find("choice-2").unwrap(); + rt.pointer_down(x, y); + rt.pointer_move(x, y + 400.0); + rt.pointer_up(x, y + 400.0); + rt.frame(1.0 / 60.0); + type_key(&mut rt, Key::Text(" ".into())); + assert_eq!(rt.state.outcome, Some(Outcome::Chose(0))); + // A digit past the rows does nothing. let mut rt = choose_window(); type_key(&mut rt, Key::Text("4".into())); From 138652c4c1d62a8ca7029347917389d3e6c62ce4 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 18:50:25 -0800 Subject: [PATCH 07/18] linux: Escape closes the settings window; one settings window per user Escape closes the settings window, asking first (Keep Editing / Close) when changes are not applied. A second launch finds the open window by an flock in $XDG_RUNTIME_DIR/localflow and hands it its activation token over a 0600 socket there, so the open window comes forward and the new process exits. --- linux/CHANGELOG.md | 3 +- linux/README.md | 4 + linux/gui/src/instance.rs | 196 ++++++++++++++++++++++++++++++++++++++ linux/gui/src/main.rs | 35 ++++++- linux/gui/src/settings.rs | 141 ++++++++++++++++++++++++++- 5 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 linux/gui/src/instance.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index d093a3a8..c0a9b364 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -15,7 +15,8 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. `config.json`'s, so a read-only `config.json` from Home Manager can still be adjusted. `"picker": "gui"` uses its dialogs for `localflowctl correct` and `dict review`; `auto` uses them only for the suggestion dialog, and only - when no menu tool (rofi, fuzzel, wofi, tofi) is installed. + when no menu tool (rofi, fuzzel, wofi, tofi) is installed. Escape closes + the settings window, and launching it again brings the open one forward. ### Changed diff --git a/linux/README.md b/linux/README.md index dc141182..27c7a802 100644 --- a/linux/README.md +++ b/linux/README.md @@ -244,6 +244,10 @@ is a settings window and the correction dialogs: which writes them to `settings.json` beside `config.json` and restarts the daemon (`systemctl --user try-restart localflowd`) when a key it reads changed. A reset icon next to a setting takes it back to `config.json`. + Escape closes it, asking first when there are changes not applied. One + settings window runs per user: launching it again brings that window + forward (passing on `XDG_ACTIVATION_TOKEN` when the launcher sets it; + Hyprland otherwise needs the `focus_on_activate` window rule). - `settings.json` holds only keys whose value differs from `config.json`'s; its keys replace `config.json`'s wherever the configuration is read, so a `config.json` Home Manager links read-only into the Nix store still works. diff --git a/linux/gui/src/instance.rs b/linux/gui/src/instance.rs new file mode 100644 index 00000000..2bce888e --- /dev/null +++ b/linux/gui/src/instance.rs @@ -0,0 +1,196 @@ +//! One settings window per user. A second `localflow-gui settings` brings +//! the open window forward and exits. +//! +//! The first instance holds an flock on `gui-settings.lock` and listens on +//! `gui-settings.sock`, both in `$XDG_RUNTIME_DIR/localflow` (a private +//! directory). A later instance finds the lock taken, connects, and sends +//! its activation token (`XDG_ACTIVATION_TOKEN`, which lets a Wayland +//! compositor give the window focus; it may be empty). Nothing else is +//! sent. + +use std::fs::{File, OpenOptions}; +use std::io::{ErrorKind, Read, Write}; +use std::os::unix::fs::{FileTypeExt, OpenOptionsExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const LOCK_NAME: &str = "gui-settings.lock"; +const SOCKET_NAME: &str = "gui-settings.sock"; +/// Longer tokens are dropped (compositors' tokens are far shorter). +const MAX_TOKEN: usize = 256; + +pub enum Instance { + /// No other window: this process opens one. + First(Listener), + /// Another window was asked to come forward. + Forwarded, +} + +/// The first instance's lock and socket. +pub struct Listener { + listener: UnixListener, + socket_path: PathBuf, + _lock: File, +} + +impl Drop for Listener { + fn drop(&mut self) { + // Before the lock goes, so the next first instance binds afresh. + let _ = std::fs::remove_file(&self.socket_path); + } +} + +/// Takes the lock in `dir` (made private if missing), or forwards `token` +/// to the instance holding it. +pub fn claim(dir: &Path, token: &str) -> Result { + lf_daemon::paths::ensure_private_dir(dir)?; + let lock_path = dir.join(LOCK_NAME); + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(&lock_path) + .map_err(|e| format!("cannot open {}: {e}", lock_path.display()))?; + let socket_path = dir.join(SOCKET_NAME); + match lock.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return forward(&socket_path, token), + Err(std::fs::TryLockError::Error(e)) => { + return Err(format!("cannot lock {}: {e}", lock_path.display())); + } + } + match std::fs::symlink_metadata(&socket_path) { + // Left by an instance that exited: we hold the lock. + Ok(m) if m.file_type().is_socket() => std::fs::remove_file(&socket_path) + .map_err(|e| format!("cannot remove {}: {e}", socket_path.display()))?, + Ok(_) => return Err(format!("{} is not a socket", socket_path.display())), + Err(e) if e.kind() == ErrorKind::NotFound => {} + Err(e) => return Err(format!("{}: {e}", socket_path.display())), + } + let listener = UnixListener::bind(&socket_path) + .map_err(|e| format!("cannot bind {}: {e}", socket_path.display()))?; + std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("cannot chmod {}: {e}", socket_path.display()))?; + Ok(Instance::First(Listener { + listener, + socket_path, + _lock: lock, + })) +} + +/// Sends `token` to the first instance. It may still be binding its +/// socket, so connecting is retried for a moment. +fn forward(socket_path: &Path, token: &str) -> Result { + let mut tries = 0; + let mut conn = loop { + match UnixStream::connect(socket_path) { + Ok(c) => break c, + Err(_) if tries < 20 => { + tries += 1; + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => { + return Err(format!( + "a settings window is open, but {} does not answer: {e}", + socket_path.display() + )); + } + } + }; + let token = if valid(token) { token } else { "" }; + conn.write_all(format!("{token}\n").as_bytes()) + .map_err(|e| format!("cannot reach the open settings window: {e}"))?; + Ok(Instance::Forwarded) +} + +/// What a compositor's token looks like: printable ASCII, not too long. +fn valid(token: &str) -> bool { + token.len() <= MAX_TOKEN && token.bytes().all(|b| b.is_ascii_graphic()) +} + +impl Listener { + /// Calls `show` with the token of each later instance (possibly empty), + /// on a thread that lives as long as the process. + pub fn serve(self, show: impl Fn(String) + Send + 'static) { + std::thread::spawn(move || { + for conn in self.listener.incoming() { + let Ok(mut conn) = conn else { continue }; + let _ = conn.set_read_timeout(Some(Duration::from_secs(1))); + let mut buf = Vec::new(); + let _ = (&mut conn).take(MAX_TOKEN as u64 + 1).read_to_end(&mut buf); + let line = buf.split(|&b| b == b'\n').next().unwrap_or_default(); + let token = std::str::from_utf8(line).unwrap_or_default(); + show(if valid(token) { token } else { "" }.to_owned()); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + struct Dir(PathBuf); + impl Dir { + fn new(name: &str) -> Dir { + let d = std::env::temp_dir().join(format!("lf-gui-inst-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + Dir(d.join("localflow")) + } + } + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(self.0.parent().unwrap()); + } + } + + fn first(dir: &Path) -> Listener { + match claim(dir, "") { + Ok(Instance::First(l)) => l, + Ok(Instance::Forwarded) => panic!("forwarded"), + Err(e) => panic!("{e}"), + } + } + + #[test] + fn a_second_instance_hands_its_token_to_the_first() { + let d = Dir::new("forward"); + let (tx, rx) = mpsc::channel(); + first(&d.0).serve(move |t| tx.send(t).unwrap()); + assert!(matches!( + claim(&d.0, "synthetic-token_1"), + Ok(Instance::Forwarded) + )); + let got = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(got, "synthetic-token_1"); + + // No token, or one that is not a token, arrives empty. + assert!(matches!(claim(&d.0, ""), Ok(Instance::Forwarded))); + assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), ""); + assert!(matches!(claim(&d.0, "two\nlines"), Ok(Instance::Forwarded))); + assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), ""); + + let mode = std::fs::metadata(d.0.join(SOCKET_NAME)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn the_next_window_is_first_once_the_last_one_has_gone() { + let d = Dir::new("stale"); + drop(first(&d.0)); + drop(first(&d.0)); + // A socket left behind (a crash) is replaced. + let _ = UnixListener::bind(d.0.join(SOCKET_NAME)).unwrap(); + assert!(d.0.join(SOCKET_NAME).exists()); + let l = first(&d.0); + assert!(UnixStream::connect(d.0.join(SOCKET_NAME)).is_ok()); + drop(l); + } +} diff --git a/linux/gui/src/main.rs b/linux/gui/src/main.rs index 2eccee44..40683617 100644 --- a/linux/gui/src/main.rs +++ b/linux/gui/src/main.rs @@ -1,6 +1,7 @@ //! `localflow-gui`: LocalFlow's windows on Linux, drawn with Rune. //! -//! - `localflow-gui [settings]`: the settings window. +//! - `localflow-gui [settings]`: the settings window, one per user (a +//! second launch brings it forward; see [`instance`]). //! - `localflow-gui edit`: correct a dictation (text on stdin; the edit on //! stdout). See [`prompt`]. //! - `localflow-gui choose`: pick one of the rows on stdin (its number on @@ -9,6 +10,7 @@ //! Rune's own flags work on every window (`--snap out.png`, `--tree`), //! which render it headless. +mod instance; mod prompt; mod settings; @@ -130,6 +132,9 @@ fn run_choose() -> i32 { static SETTINGS: Mutex> = Mutex::new(None); fn run_settings() -> i32 { + let Some(listener) = claim_settings() else { + return 0; + }; let made = (|| -> Result { let config = lf_daemon::paths::config_file()?; let data = lf_daemon::paths::data_dir()?; @@ -150,9 +155,35 @@ fn run_settings() -> i32 { .take() .expect("settings are made once, before the window") } - rune::run(AppSpec:: { + let app = rune::App::new(AppSpec:: { min_size: Some((520.0, 400.0)), + on_key: Some(settings::key), ..AppSpec::new("LocalFlow Settings", (600.0, 760.0), init, settings::view) }); + if let Some(listener) = listener { + let handle = app.handle(); + listener.serve(move |token| handle.activate(token)); + } + app.run(); 0 } + +/// Whether a settings window is already open: if so it is asked to come +/// forward and this process has nothing to do (`None`). Headless renders +/// skip this. +fn claim_settings() -> Option> { + if rune::is_headless() { + return Some(None); + } + let token = std::env::var("XDG_ACTIVATION_TOKEN").unwrap_or_default(); + let claimed = + lf_daemon::paths::runtime_dir().and_then(|d| instance::claim(&d.join("localflow"), &token)); + match claimed { + Ok(instance::Instance::First(l)) => Some(Some(l)), + Ok(instance::Instance::Forwarded) => None, + Err(e) => { + eprintln!("localflow-gui: {e}"); + Some(None) + } + } +} diff --git a/linux/gui/src/settings.rs b/linux/gui/src/settings.rs index 7bf877c0..09a27a02 100644 --- a/linux/gui/src/settings.rs +++ b/linux/gui/src/settings.rs @@ -9,12 +9,14 @@ //! `config.json`'s value (or the default). //! - Values that make an invalid configuration keep Apply disabled and show //! the daemon's own message, which names the key and never the value. +//! - Escape closes the window. With changes not yet applied it asks first; +//! a second Escape closes without applying them. use std::path::PathBuf; use lf_daemon::config::{Config, Layers, write_settings}; use rune::tok::space; -use rune::{Cx, El, Icon, ScrollSpec, TextEdit, Tx, col, el, icon, row, text}; +use rune::{Cx, El, Icon, Key, KeyEvent, Mods, ScrollSpec, TextEdit, Tx, col, el, icon, row, text}; use rune_kit::{ Button, ButtonVariant, IconButton, IconButtonVariant, Kit, SegmentedControl, Select, Switch, TextInput, @@ -67,6 +69,10 @@ pub struct Settings { pub open: Option<&'static str>, pub status: Status, pub restart: Restart, + /// Escape was pressed with changes not applied: the footer asks. + pub asking_to_close: bool, + /// The window quit. + pub closed: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -102,6 +108,8 @@ impl Settings { open: None, status: Status::Idle, restart, + asking_to_close: false, + closed: false, }; s.reset_fields(); Ok(s) @@ -156,6 +164,7 @@ impl Settings { fn recompute(&mut self) { self.status = Status::Idle; + self.asking_to_close = false; self.config = self.with(self.draft.clone()); if let Ok(c) = &self.config { self.shown = c.clone(); @@ -172,8 +181,20 @@ impl Settings { self.reset_fields(); } + /// Closes the window, unless changes are not applied and the window has + /// not asked yet: then it asks. + pub fn close(&mut self, tx: &mut Tx) { + if self.dirty() && !self.asking_to_close { + self.asking_to_close = true; + return; + } + self.closed = true; + tx.quit(); + } + /// Writes the draft and restarts the daemon if a key it reads changed. pub fn apply(&mut self) { + self.asking_to_close = false; let changed: Vec<&String> = self .draft .keys() @@ -215,6 +236,15 @@ impl Settings { } } +/// The window's keys: Escape closes (see [`Settings::close`]). +pub fn key(s: &mut Settings, e: &KeyEvent, tx: &mut Tx) -> bool { + if e.key == Key::Escape && e.mods == Mods::default() { + s.close(tx); + return true; + } + false +} + /// `systemctl --user try-restart localflowd.service`: restarts the daemon /// if it runs as the usual user unit. pub fn restart_daemon() -> Result<(), String> { @@ -366,7 +396,8 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { let v = s.prompt_tag.text.clone(); s.set("prompt_tag", Value::String(v)); }) - .build(&k); + .build(&k) + .key("prompt-tag-field"); static DOUBLE_TAP: [(&str, u64); 4] = [ ("Off", 0), ("250 ms", 250), @@ -594,6 +625,32 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { .gap(space::S2) .center_y() .fill(k.c.bg_base); + if s.asking_to_close { + footer = footer + .child( + text("Close without applying?") + .key("asking") + .font(k.ty.ui) + .color(k.c.text_base) + .grow(1.0) + .shrink(), + ) + .child( + Button::new("Keep Editing") + .variant(ButtonVariant::Ghost) + .on_press(|s: &mut Settings| s.asking_to_close = false) + .build(&k) + .key("keep-editing"), + ) + .child( + Button::new("Close") + .variant(ButtonVariant::Contrast) + .on_press_tx(|s: &mut Settings, tx: &mut Tx| s.close(tx)) + .build(&k) + .key("close-anyway"), + ); + return page(&k, content, footer, layer); + } footer = match (&s.config, &s.status) { (Err(e), _) | (_, Status::Failed(e)) => footer.child( text(e.clone()) @@ -626,7 +683,11 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { .build(&k) .key("apply"), ); + page(&k, content, footer, layer) +} +/// The scrolling settings over the footer. +fn page(k: &Kit, content: El, footer: El, layer: Vec) -> El { let body = col() .key("window") .w_full() @@ -671,7 +732,7 @@ fn picker_name(c: &Config) -> &'static str { #[cfg(test)] mod tests { use super::*; - use rune::{Env, Runtime}; + use rune::{Env, Key, KeyEvent, Mods, Runtime}; use std::path::Path; /// A temporary directory with a read-only config.json, as Home Manager @@ -702,6 +763,7 @@ mod tests { let s = Settings::new(self.config(), "/d".into(), layers, None).unwrap(); // Tall enough that nothing needs scrolling. let mut rt = Runtime::new(s, view, (560.0, 1400.0), Env::new(true, 1.0)); + rt.on_key = Some(key); rt.frame(0.0); rt } @@ -834,6 +896,79 @@ mod tests { assert!(rt.find("reset-voice_macros").is_some()); } + fn escape(rt: &mut Runtime) { + rt.key(KeyEvent { + key: Key::Escape, + mods: Mods::default(), + }); + for _ in 0..3 { + rt.frame(1.0 / 60.0); + } + } + + #[test] + fn escape_closes_a_window_with_nothing_to_apply() { + let d = Dir::new("escape", r#"{"version": 1}"#); + let mut rt = d.window(); + escape(&mut rt); + assert!(rt.state.closed); + + // Also from a text field. + let mut rt = d.window(); + press(&mut rt, "prompt-tag-field"); + escape(&mut rt); + escape(&mut rt); + assert!(rt.state.closed); + + // An open menu closes first. + let mut rt = d.window(); + press(&mut rt, "picker-select-trigger"); + assert_eq!(rt.state.open, Some("picker")); + escape(&mut rt); + assert_eq!(rt.state.open, None); + assert!(!rt.state.closed, "Escape only closed the menu"); + escape(&mut rt); + assert!(rt.state.closed); + } + + #[test] + fn escape_with_unapplied_changes_asks_before_closing() { + let d = Dir::new("escape-draft", r#"{"version": 1}"#); + let mut rt = d.window(); + press(&mut rt, "switch-history"); + escape(&mut rt); + assert!(!rt.state.closed, "the draft is not dropped yet"); + assert!(rt.find("close-anyway").is_some(), "the window asks"); + + // Keep editing: the draft stays, and the question goes. + press(&mut rt, "keep-editing"); + assert!(rt.state.dirty() && !rt.state.closed); + assert!(rt.find("close-anyway").is_none()); + + // Escape twice closes without writing anything. + escape(&mut rt); + escape(&mut rt); + assert!(rt.state.closed); + assert_eq!(d.settings(), None); + + // So does the close button. + let mut rt = d.window(); + press(&mut rt, "switch-history"); + escape(&mut rt); + press(&mut rt, "close-anyway"); + assert!(rt.state.closed); + assert_eq!(d.settings(), None); + + // A change after the question takes it back. + let mut rt = d.window(); + press(&mut rt, "switch-history"); + escape(&mut rt); + press(&mut rt, "switch-pause_media"); + assert!(rt.find("close-anyway").is_none()); + escape(&mut rt); + assert!(!rt.state.closed, "asks again"); + } + #[test] fn picking_a_segment_sets_its_value() { let d = Dir::new("segments", r#"{"version": 1}"#); From 22be21ed15f93640aaeac24bc0f397afba9ffc60 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 18:54:45 -0800 Subject: [PATCH 08/18] linux: holding Escape doesn't answer the settings window's close question An auto-repeat of a held Escape called close() again and dropped the draft right after the question appeared. Repeats are now consumed without acting. --- linux/gui/src/settings.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/linux/gui/src/settings.rs b/linux/gui/src/settings.rs index 09a27a02..635a9ced 100644 --- a/linux/gui/src/settings.rs +++ b/linux/gui/src/settings.rs @@ -236,10 +236,13 @@ impl Settings { } } -/// The window's keys: Escape closes (see [`Settings::close`]). +/// The window's keys: Escape closes (see [`Settings::close`]). Holding it +/// does nothing more, so the question it may raise needs a second press. pub fn key(s: &mut Settings, e: &KeyEvent, tx: &mut Tx) -> bool { if e.key == Key::Escape && e.mods == Mods::default() { - s.close(tx); + if !tx.key_repeat() { + s.close(tx); + } return true; } false @@ -959,6 +962,20 @@ mod tests { assert!(rt.state.closed); assert_eq!(d.settings(), None); + // Holding Escape (auto-repeat) does not answer the question. + let mut rt = d.window(); + press(&mut rt, "switch-history"); + escape(&mut rt); + for _ in 0..5 { + rt.repeat_key(KeyEvent { + key: Key::Escape, + mods: Mods::default(), + }); + rt.frame(1.0 / 60.0); + } + assert!(!rt.state.closed && rt.state.dirty(), "still asking"); + assert!(rt.find("close-anyway").is_some()); + // A change after the question takes it back. let mut rt = d.window(); press(&mut rt, "switch-history"); From 1041b19c76e353c41a27caa90cbd22cf81da2688 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 20:38:33 -0800 Subject: [PATCH 09/18] linux: give the speaking pill and settings windows the iPhone app's look The Waybar pill keeps one fixed footprint in every state, so the clock never moves when a dictation starts or ends. Recording shows a coral dot wave instead of block bars, and loading, typing, no microphone and offline get glyphs of their own, so the states differ without colour. The README and the deployed CSS share one block in the iPhone palette (coral, glacier, ember), and the tooltips use plain words. The settings window and both correction dialogs share one always-dark kit from the iPhone palette: - Switches that are on are aurora green. - Primary actions are near-black hairline pills. - The dialogs carry the ridge mark. Settings: - Plain names replace f32/i8x1 and uinput/Portal. - A glacier dot marks rows that aren't applied yet. - Invalid values are shown at their field. - Clicking anywhere on a switch's row toggles it. - A failed restart says the settings were saved and offers Retry. - The window opens tall enough to show the voice macros. The edit dialog's button is now "Fix", and the stray space below it is gone. --- linux/AGENT-LINK.md | 5 +- linux/CHANGELOG.md | 13 + linux/README.md | 97 ++-- linux/crates/lf-daemon/src/waybar.rs | 384 ++++++++----- linux/crates/lf-daemon/tests/daemon.rs | 6 +- linux/gui/src/look.rs | 152 ++++++ linux/gui/src/main.rs | 5 +- linux/gui/src/prompt.rs | 120 +++-- linux/gui/src/settings.rs | 712 +++++++++++++++++++------ 9 files changed, 1127 insertions(+), 367 deletions(-) create mode 100644 linux/gui/src/look.rs diff --git a/linux/AGENT-LINK.md b/linux/AGENT-LINK.md index dd93c551..c3f7997a 100644 --- a/linux/AGENT-LINK.md +++ b/linux/AGENT-LINK.md @@ -117,8 +117,9 @@ commands do not carry these fields. - Pending suggestions add `suggestion`. - With extra classes, `class` is an array: the state's class first, then `agent-approval` or `agent-input`, then `suggestion`. - - The text becomes the glyph, a space and approval + input, when that sum - is above zero. + - Approval + input, when that sum is above zero, goes in the text's last + two characters, after the glyph and a space (at most 99), so the text + keeps its width. - **While recording, transcribing or typing:** no extra classes, and the text is unchanged. - **Tooltip, in any state but offline:** the state's tooltip is followed by diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index c0a9b364..8962e9bf 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -17,9 +17,22 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. and `dict review`; `auto` uses them only for the suggestion dialog, and only when no menu tool (rofi, fuzzel, wofi, tofi) is installed. Escape closes the settings window, and launching it again brings the open one forward. + The windows have the iPhone app's dark look. Settings not applied yet + show a dot, an invalid value is explained under its field, and if the + daemon does not restart after **Apply** the window says the settings + were saved and offers **Retry**. ### Changed +- The Waybar indicator (`localflowctl watch --waybar`) looks like the + iPhone app's: a wave of round dots while recording instead of bars, and + every state six characters wide, so the module and the clock beside it + no longer move. Each state has its own glyph: a clock while the model + loads, the hourglass while transcribing, a keyboard while typing, the + microphone with a slash without a microphone, and power-off when + LocalFlow isn’t running. A prompt dictation shows `›` before the wave. + Tooltips use plain words ("LocalFlow: ready"). The README's CSS uses the + iPhone app's colours and adds `min-length` and `align` to the module. - The recommended model is now [Kea-B 0.1](https://huggingface.co/wildernesslabs/kea-b-0.1): `kea-b` in `localflowctl fetch-model` and the flake installs it instead of Kea-B 0.0. diff --git a/linux/README.md b/linux/README.md index 27c7a802..cfb9f49c 100644 --- a/linux/README.md +++ b/linux/README.md @@ -243,7 +243,13 @@ is a settings window and the correction dialogs: - `localflow-gui` opens the settings. Changes are kept until **Apply**, which writes them to `settings.json` beside `config.json` and restarts the daemon (`systemctl --user try-restart localflowd`) when a key it reads - changed. A reset icon next to a setting takes it back to `config.json`. + changed; a cyan dot marks each setting not applied yet. If the restart + fails, the settings are still saved and **Retry** tries it again. A reset + icon next to a setting takes it back to `config.json`. A text field with + an invalid value is outlined, with what to fix under it. **Speed** is + `precision`: Auto (the model's own), Fastest (`i8x1`), Fast (`i8x2`), + Precise (`i8x3`) and Full precision (`f32`). **Type with** is `output`: + Wayland, Desktop portal (`portal`) or Kernel device (`uinput`). Escape closes it, asking first when there are changes not applied. One settings window runs per user: launching it again brings that window forward (passing on `XDG_ACTIVATION_TOKEN` when the launcher sets it; @@ -347,33 +353,64 @@ being the same package: ### Waybar indicator `localflowctl watch --waybar` prints one JSON object per change for a -Waybar custom module. Classes (also the `alt`): `idle`, `loading` (model -loading), `recording` (text is a scrolling level graph), `transcribing`, -`typing`, `nomic` (idle with no microphone), `offline` (daemon not -running). A prompt dictation (double-tap and hold; `tag=prompt` in -`watch` lines) adds the class `prompt` while it records, transcribes and -types (`"class": ["recording", "prompt"]`, tooltip "LocalFlow: recording -(prompt)"). It keeps retrying every 2 s while the daemon is down, so Waybar -never needs restarting. The default text uses Nerd Font glyphs; override -them with `format-icons` keyed by `alt` if you like. +Waybar custom module. Every text is six characters wide, so in a monospace +Nerd Font (JetBrainsMono Nerd Font, say) the module and the clock beside it +never move. Classes (also the `alt`) and what each shows: + +| Class | Text | Tooltip | +|---|---|---| +| `idle` | microphone | LocalFlow: ready | +| `loading` | clock (the model is loading) | LocalFlow: loading the model | +| `recording` | a wave of dots, newest on the right; small equal dots in silence | LocalFlow: listening | +| `transcribing` | hourglass | LocalFlow: transcribing | +| `typing` | keyboard | LocalFlow: typing | +| `nomic` | microphone with a slash (idle with no microphone) | LocalFlow: no microphone | +| `offline` | power-off (the daemon is not running) | LocalFlow isn’t running | + +A prompt dictation (double-tap and hold; `tag=prompt` in `watch` lines) +puts `›` in the first character and adds the class `prompt` while it +records, transcribes and types (`"class": ["recording", "prompt"]`, tooltip +"LocalFlow: listening (prompt dictation)"). It keeps retrying every 2 s +while the daemon is down, so Waybar never needs restarting. Override the +glyphs with `format-icons` keyed by `alt` if you like. `min-length` and +`align` keep the module's width with a proportional font too. ```jsonc "custom/localflow": { "exec": "localflowctl watch --waybar", "return-type": "json", - "restart-interval": 5 + "restart-interval": 5, + "min-length": 6, + "align": 0.5 } ``` +The LocalFlow colours (coral while recording, glacier while working or +waiting on you, ember for a problem), including the agent classes (see +[Coding agents](#coding-agents)): + ```css -#custom-localflow.recording { color: #f38ba8; } +#custom-localflow { + padding-left: 6px; + padding-right: 6px; +} +#custom-localflow.idle, +#custom-localflow.offline { + color: rgba(255, 255, 255, 0.4); +} +#custom-localflow.recording { + color: #ff7d5e; +} +#custom-localflow.loading, #custom-localflow.transcribing, -#custom-localflow.typing { color: #f9e2af; } -#custom-localflow.nomic { color: #e64553; } -#custom-localflow.offline, -#custom-localflow.loading { opacity: 0.5; } -/* A prompt dictation (double-tap and hold), after the rules above. */ -#custom-localflow.prompt { color: #89b4fa; } +#custom-localflow.typing, +#custom-localflow.agent-input { + color: #67e8f9; +} +#custom-localflow.nomic, +#custom-localflow.agent-approval { + color: #f5c66b; +} ``` ### Tray icon @@ -516,13 +553,9 @@ localflowctl agents t3 # T3 Code bridge (runs unti - **Waybar:** - Bind the module's `on-click` to `localflowctl dict review` to approve suggestions with a click. - - Style the new classes: - - ```css - #custom-localflow.agent-approval { color: #fab387; } - #custom-localflow.agent-input { color: #a6e3a1; } - #custom-localflow.suggestion { font-weight: bold; } - ``` + - The CSS under [Waybar indicator](#waybar-indicator) styles the agent + classes: `agent-approval` in ember, `agent-input` in glacier. The number + of agents needing you shows beside the microphone. ## Text output and desktops @@ -855,12 +888,14 @@ your dictation key. from HFP to A2DP. `playerctl -l` (if installed) lists the players the daemon sees. 17. Waybar indicator (`localflowctl watch --waybar` in a terminal, or the - custom module): idle shows the microphone; holding the key shows a moving - level graph that follows your voice; release shows the hourglass, then - idle. Disconnect the microphone (or turn the headset off): `nomic` - within a second or two, and back to idle when reconnected. With - `input_device` set, only that device counts. Stop the daemon: `offline`; - start it again: idle within about 2 s, without restarting Waybar. + custom module): idle shows the dim microphone; holding the key shows a + coral wave of dots that follows your voice; release shows the hourglass, + the keyboard, then idle. The clock beside it never moves. Disconnect the + microphone (or turn the headset off): `nomic` (the microphone with a + slash) within a second or two, and back to idle when reconnected. With + `input_device` set, only that device counts. Stop the daemon: `offline` + (power-off); start it again: idle within about 2 s, without restarting + Waybar. `journalctl --user -u localflowd` shows `microphone: absent/present` but no device names. 18. Hold to Prompt: in a terminal or chat box, tap the key once quickly, then diff --git a/linux/crates/lf-daemon/src/waybar.rs b/linux/crates/lf-daemon/src/waybar.rs index 884914f6..d2d5edcb 100644 --- a/linux/crates/lf-daemon/src/waybar.rs +++ b/linux/crates/lf-daemon/src/waybar.rs @@ -2,24 +2,33 @@ //! JSON, one object per line: //! //! ```text -//! {"text": "", "alt": "", "class": "", "tooltip": "LocalFlow: ..."} +//! {"text": "", "alt": "", "class": "", "tooltip": "LocalFlow: ..."} //! ``` //! //! Classes (and alts): idle, loading, recording, transcribing, typing, -//! nomic, offline. Recording shows the last [`HISTORY`] input levels as a -//! scrolling bar graph. Glyphs are Nerd Font icons; a bar config can replace -//! them with `format-icons` keyed by `alt`. A prompt dictation (double-tap -//! and hold) while recording, transcribing or typing has two classes, e.g. -//! `"class": ["recording", "prompt"]`, the state's alt, and a tooltip such -//! as "LocalFlow: recording (prompt)". +//! nomic, offline. The text is always [`WIDTH`] characters, each one cell +//! of a monospace font, so the module and the clock beside it never move +//! between states: +//! +//! - Recording shows the last input levels as a wave of round dots, newest +//! on the right; silence is a row of equal small dots. +//! - Every other state shows its glyph in the third cell ([`GLYPH_CELL`]). +//! Glyphs are Font Awesome icons as Nerd Fonts carry them, drawn wider +//! than one cell, so the glyph sits about in the middle; a bar config can +//! replace them with `format-icons` keyed by `alt`. +//! - A prompt dictation (double-tap and hold) while recording, transcribing +//! or typing puts [`PROMPT_MARKER`] in the first cell, and has two +//! classes, e.g. `"class": ["recording", "prompt"]`, the state's alt, and +//! a tooltip such as "LocalFlow: listening (prompt dictation)". //! //! The agent link's counts on watch lines (see `crate::protocol`): //! //! - While idle, loading or without a microphone, an agent needing approval //! adds the class `agent-approval`, else one waiting for input //! `agent-input`, and pending suggestions `suggestion` (in that order, -//! after the state's class: `class` is then an array). The text gets a -//! space and the number of agents needing approval or input, when any. +//! after the state's class: `class` is then an array). The number of +//! agents needing approval or input, when any, goes in the last two cells +//! (at most 99). //! - While recording, transcribing or typing, classes and text are as //! without them. //! - In every state but offline, the tooltip gets a line each for agents, @@ -27,19 +36,32 @@ use std::collections::VecDeque; -/// nf-fa-microphone. +/// nf-fa-microphone: ready. pub const MIC_GLYPH: &str = "\u{f130}"; -/// nf-fa-hourglass_half. -pub const BUSY_GLYPH: &str = "\u{f252}"; -/// nf-fa-times (styled red by the bar's CSS). -pub const NO_MIC_GLYPH: &str = "\u{f00d}"; -/// nf-fa-microphone_slash. -pub const OFFLINE_GLYPH: &str = "\u{f131}"; +/// nf-fa-clock_o: the model is loading. +pub const LOADING_GLYPH: &str = "\u{f017}"; +/// nf-fa-hourglass_half: transcribing. +pub const TRANSCRIBING_GLYPH: &str = "\u{f252}"; +/// nf-fa-keyboard_o: typing. +pub const TYPING_GLYPH: &str = "\u{f11c}"; +/// nf-fa-microphone_slash: no microphone. +pub const NO_MIC_GLYPH: &str = "\u{f131}"; +/// nf-fa-power_off: the daemon is not running. +pub const OFFLINE_GLYPH: &str = "\u{f011}"; +/// The first cell of a prompt dictation's text. +pub const PROMPT_MARKER: char = '\u{203a}'; -/// Levels shown while recording. -pub const HISTORY: usize = 8; +/// Characters in every text: one cell each in a monospace (Nerd) font. +pub const WIDTH: usize = 6; +/// The cell of a state's glyph; the agent count takes the last two. +pub const GLYPH_CELL: usize = 2; +/// Levels kept while recording: a full row of dots. +pub const HISTORY: usize = WIDTH; -const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +/// The wave's dots, smallest (silence) to largest, each centred in its cell +/// and narrower than it, so neighbours never touch: nf-md-circle_small, +/// U+2022 bullet, nf-md-circle_medium, nf-cod-circle_filled. +pub const DOTS: [char; 4] = ['\u{f09df}', '\u{2022}', '\u{f09de}', '\u{ea71}']; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Class { @@ -104,31 +126,49 @@ impl Class { fn tooltip(self) -> &'static str { match self { - Class::Idle => "LocalFlow: idle", - Class::Loading => "LocalFlow: loading model", - Class::Recording => "LocalFlow: recording", + Class::Idle => "LocalFlow: ready", + Class::Loading => "LocalFlow: loading the model", + Class::Recording => "LocalFlow: listening", Class::Transcribing => "LocalFlow: transcribing", Class::Typing => "LocalFlow: typing", Class::NoMic => "LocalFlow: no microphone", - Class::Offline => "LocalFlow: offline", + Class::Offline => "LocalFlow isn\u{2019}t running", } } + /// The glyph of every state but recording, which shows the wave. fn glyph(self) -> &'static str { match self { - Class::Idle | Class::Loading | Class::Recording => MIC_GLYPH, - Class::Transcribing | Class::Typing => BUSY_GLYPH, + Class::Idle | Class::Recording => MIC_GLYPH, + Class::Loading => LOADING_GLYPH, + Class::Transcribing => TRANSCRIBING_GLYPH, + Class::Typing => TYPING_GLYPH, Class::NoMic => NO_MIC_GLYPH, Class::Offline => OFFLINE_GLYPH, } } } -/// One bar for a level of 0..=100, on a square-root curve so quiet speech -/// still shows. -pub fn bar(level: u8) -> char { - let v = (f32::from(level.min(100)) / 100.0).sqrt(); - BARS[(v * (BARS.len() - 1) as f32).round() as usize] +/// Levels up to this (in 0..=100) are room noise: silence's dot. +const NOISE: f32 = 4.0; + +/// One dot for a level of 0..=100: silence up to [`NOISE`], then a +/// square-root curve so quiet speech still shows. +pub fn dot(level: u8) -> char { + let v = ((f32::from(level.min(100)) - NOISE).max(0.0) / (100.0 - NOISE)).sqrt(); + DOTS[(v * (DOTS.len() - 1) as f32).round() as usize] +} + +/// A state's [`WIDTH`] cells: the prompt marker or a space, the glyph in +/// [`GLYPH_CELL`], and `count` (at most 99) in the last two. +fn field(prompt: bool, glyph: &str, count: u32) -> String { + let marker = if prompt { PROMPT_MARKER } else { ' ' }; + let count = if count > 0 { + count.min(99).to_string() + } else { + String::new() + }; + format!("{marker} {glyph} {count:<2}") } /// A JSON string literal. @@ -178,18 +218,6 @@ pub fn object(class: Class, text: &str) -> String { render(class, &[class.name()], text, class.tooltip()) } -/// The object for a prompt dictation (double-tap and hold) in `class` -/// (recording, transcribing or typing): classes `[, "prompt"]`, so a -/// bar style can tell it apart, and the alt of `class`. -pub fn prompt_object(class: Class, text: &str) -> String { - render( - class, - &[class.name(), PROMPT_CLASS], - text, - &format!("{} (prompt)", class.tooltip()), - ) -} - /// The extra class of prompt dictations. pub const PROMPT_CLASS: &str = "prompt"; /// Extra classes while idle, loading or without a microphone: an agent @@ -286,20 +314,18 @@ impl Indicator { } } self.recording = class == Class::Recording; - let mut text = match class { - Class::Recording => self.graph(), - _ => class.glyph().to_owned(), - }; let busy = matches!( class, Class::Recording | Class::Transcribing | Class::Typing ); + let prompt = prompt && busy; let mut classes = vec![class.name()]; let mut tooltip = class.tooltip().to_owned(); - if prompt && busy { + if prompt { classes.push(PROMPT_CLASS); - tooltip += " (prompt)"; + tooltip += " (prompt dictation)"; } + let mut waiting = 0; if !busy { if n.approval > 0 { classes.push(AGENT_APPROVAL_CLASS); @@ -309,11 +335,13 @@ impl Indicator { if n.suggestions > 0 { classes.push(SUGGESTION_CLASS); } - let waiting = n.approval.saturating_add(n.input); - if waiting > 0 { - text = format!("{text} {waiting}"); - } + waiting = n.approval.saturating_add(n.input); } + let text = match class { + Class::Recording if prompt => format!("{PROMPT_MARKER}{}", self.wave(WIDTH - 1)), + Class::Recording => self.wave(WIDTH), + _ => field(prompt, class.glyph(), waiting), + }; for line in n.tooltip_lines() { tooltip.push('\n'); tooltip += &line; @@ -324,14 +352,20 @@ impl Indicator { /// The object shown while the daemon is unreachable. pub fn offline(&mut self) -> String { self.recording = false; - object(Class::Offline, Class::Offline.glyph()) + object(Class::Offline, &field(false, Class::Offline.glyph(), 0)) } - /// The last [`HISTORY`] levels, newest on the right, padded with silence. - fn graph(&self) -> String { - let pad = HISTORY - self.levels.len(); - std::iter::repeat_n(bar(0), pad) - .chain(self.levels.iter().map(|&l| bar(l))) + /// The last `cells` levels as dots, newest on the right, padded with + /// silence. + fn wave(&self, cells: usize) -> String { + let shown = self.levels.len().min(cells); + std::iter::repeat_n(dot(0), cells - shown) + .chain( + self.levels + .iter() + .skip(self.levels.len() - shown) + .map(|&l| dot(l)), + ) .collect() } } @@ -340,33 +374,48 @@ impl Indicator { mod tests { use super::*; + fn json(o: &str) -> serde_json::Value { + serde_json::from_str(o).unwrap() + } + + fn text(o: &str) -> String { + json(o)["text"].as_str().unwrap().to_owned() + } + + /// Dots for levels, oldest first. + fn dots(levels: &[u8]) -> String { + levels.iter().map(|&l| dot(l)).collect() + } + + const SILENCE: char = DOTS[0]; + #[test] fn every_class_formats_as_waybar_json() { let mut ind = Indicator::default(); let cases = [ ( "state=idle model=ready mic=present", - "{\"text\": \"\u{f130}\", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: idle\"}", + "{\"text\": \" \u{f130} \", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: ready\"}", ), ( "state=idle model=loading mic=unknown", - "{\"text\": \"\u{f130}\", \"alt\": \"loading\", \"class\": \"loading\", \"tooltip\": \"LocalFlow: loading model\"}", + "{\"text\": \" \u{f017} \", \"alt\": \"loading\", \"class\": \"loading\", \"tooltip\": \"LocalFlow: loading the model\"}", ), ( "state=recording mode=hold model=ready mic=present level=100", - "{\"text\": \"▁▁▁▁▁▁▁█\", \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: recording\"}", + "{\"text\": \"\u{f09df}\u{f09df}\u{f09df}\u{f09df}\u{f09df}\u{ea71}\", \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\"}", ), ( "state=transcribing model=ready mic=present", - "{\"text\": \"\u{f252}\", \"alt\": \"transcribing\", \"class\": \"transcribing\", \"tooltip\": \"LocalFlow: transcribing\"}", + "{\"text\": \" \u{f252} \", \"alt\": \"transcribing\", \"class\": \"transcribing\", \"tooltip\": \"LocalFlow: transcribing\"}", ), ( "state=typing model=ready mic=present", - "{\"text\": \"\u{f252}\", \"alt\": \"typing\", \"class\": \"typing\", \"tooltip\": \"LocalFlow: typing\"}", + "{\"text\": \" \u{f11c} \", \"alt\": \"typing\", \"class\": \"typing\", \"tooltip\": \"LocalFlow: typing\"}", ), ( "state=idle model=loading mic=absent", - "{\"text\": \"\u{f00d}\", \"alt\": \"nomic\", \"class\": \"nomic\", \"tooltip\": \"LocalFlow: no microphone\"}", + "{\"text\": \" \u{f131} \", \"alt\": \"nomic\", \"class\": \"nomic\", \"tooltip\": \"LocalFlow: no microphone\"}", ), ]; let mut seen = Vec::new(); @@ -377,7 +426,7 @@ mod tests { let offline = ind.offline(); assert_eq!( offline, - "{\"text\": \"\u{f131}\", \"alt\": \"offline\", \"class\": \"offline\", \"tooltip\": \"LocalFlow: offline\"}" + "{\"text\": \" \u{f011} \", \"alt\": \"offline\", \"class\": \"offline\", \"tooltip\": \"LocalFlow isn\u{2019}t running\"}" ); seen.push(&offline); // Every class appears, and every object is valid JSON with the four @@ -387,103 +436,172 @@ mod tests { assert!(seen.iter().any(|o| o.contains(&tag)), "{tag}"); } for o in seen { - let v: serde_json::Value = serde_json::from_str(o).unwrap(); + let v = json(o); assert_eq!(v["alt"], v["class"]); - assert!(v["text"].as_str().is_some_and(|t| !t.is_empty())); - assert!(v["tooltip"].as_str().unwrap().starts_with("LocalFlow: ")); + assert!(v["text"].as_str().is_some_and(|t| !t.trim().is_empty())); + assert!(v["tooltip"].as_str().unwrap().starts_with("LocalFlow")); } assert_eq!(ind.update("state=exploding model=ready"), None); assert_eq!(ind.update("model=ready"), None); } #[test] - fn prompt_dictations_get_a_second_class() { + fn every_state_has_its_own_glyph() { + // Stock Waybar has no colours: the glyph alone tells the states apart. + let glyphs = [ + MIC_GLYPH, + LOADING_GLYPH, + TRANSCRIBING_GLYPH, + TYPING_GLYPH, + NO_MIC_GLYPH, + OFFLINE_GLYPH, + ]; + for (i, a) in glyphs.iter().enumerate() { + assert_eq!(a.chars().count(), 1); + for b in &glyphs[i + 1..] { + assert_ne!(a, b); + } + assert!(!DOTS.contains(&a.chars().next().unwrap())); + assert_ne!(a.chars().next(), Some(PROMPT_MARKER)); + } + } + + #[test] + fn every_text_is_the_same_width_with_the_glyph_in_the_same_cell() { + let mut ind = Indicator::default(); + let lines = [ + "state=idle model=ready mic=present", + "state=idle model=loading mic=present", + "state=recording mode=hold model=ready mic=present", + "state=recording mode=hold model=ready mic=present level=0", + "state=recording mode=hold model=ready mic=present level=60", + "state=recording mode=hold tag=prompt model=ready mic=present level=60", + "state=transcribing tag=prompt model=ready mic=present", + "state=transcribing model=ready mic=present", + "state=typing tag=prompt model=ready mic=present", + "state=typing model=ready mic=present", + "state=idle model=ready mic=absent", + "state=idle model=ready mic=present agent-approval=1", + "state=idle model=ready mic=present agent-input=12 suggestions=3", + "state=idle model=loading mic=absent agent-approval=999 agent-input=999", + ]; + let mut texts: Vec = lines + .iter() + .map(|l| text(&ind.update(l).unwrap())) + .collect(); + texts.push(text(&ind.offline())); + for t in &texts { + assert_eq!(t.chars().count(), WIDTH, "{t:?}"); + let glyph = t.chars().nth(GLYPH_CELL).unwrap(); + assert_ne!(glyph, ' ', "{t:?}"); + } + // The count stays in the last two cells, after a space. + assert_eq!(texts[11], format!(" {MIC_GLYPH} 1 ")); + assert_eq!(texts[12], format!(" {MIC_GLYPH} 12")); + assert_eq!(texts[13], format!(" {NO_MIC_GLYPH} 99")); + } + + #[test] + fn prompt_dictations_get_a_marker_and_a_second_class() { let mut ind = Indicator::default(); - let json = |o: String| -> serde_json::Value { serde_json::from_str(&o).unwrap() }; let v = json( - ind.update("state=recording mode=hold tag=prompt model=ready mic=present level=100") + &ind.update("state=recording mode=hold tag=prompt model=ready mic=present level=100") .unwrap(), ); assert_eq!(v["class"], serde_json::json!(["recording", "prompt"])); assert_eq!(v["alt"], "recording"); - assert_eq!(v["tooltip"], "LocalFlow: recording (prompt)"); - assert_eq!(v["text"], "▁▁▁▁▁▁▁█"); - for (state, glyph) in [("transcribing", BUSY_GLYPH), ("typing", BUSY_GLYPH)] { + assert_eq!(v["tooltip"], "LocalFlow: listening (prompt dictation)"); + assert_eq!( + v["text"], + format!("{PROMPT_MARKER}{}", dots(&[0, 0, 0, 0, 100])) + ); + for (state, glyph, tooltip) in [ + ("transcribing", TRANSCRIBING_GLYPH, "transcribing"), + ("typing", TYPING_GLYPH, "typing"), + ] { let v = json( - ind.update(&format!("state={state} tag=prompt model=ready mic=present")) + &ind.update(&format!("state={state} tag=prompt model=ready mic=present")) .unwrap(), ); assert_eq!(v["class"], serde_json::json!([state, "prompt"])); assert_eq!(v["alt"], state); - assert_eq!(v["tooltip"], format!("LocalFlow: {state} (prompt)")); - assert_eq!(v["text"], glyph); + assert_eq!( + v["tooltip"], + format!("LocalFlow: {tooltip} (prompt dictation)") + ); + assert_eq!(v["text"], format!("{PROMPT_MARKER} {glyph} ")); } // Without the tag, or when idle, objects are exactly as before. assert_eq!( ind.update("state=typing model=ready mic=present").unwrap(), - "{\"text\": \"\u{f252}\", \"alt\": \"typing\", \"class\": \"typing\", \"tooltip\": \"LocalFlow: typing\"}" + "{\"text\": \" \u{f11c} \", \"alt\": \"typing\", \"class\": \"typing\", \"tooltip\": \"LocalFlow: typing\"}" ); assert_eq!( ind.update("state=idle tag=prompt model=ready mic=present") .unwrap(), - "{\"text\": \"\u{f130}\", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: idle\"}" + "{\"text\": \" \u{f130} \", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: ready\"}" ); } #[test] fn agent_link_counts_add_classes_a_count_and_tooltip_lines() { let mut ind = Indicator::default(); + let loud = format!("{SILENCE}{SILENCE}{SILENCE}{SILENCE}{SILENCE}\u{ea71}"); let cases = [ ( - "state=idle model=ready mic=present suggestions=3 words=12 agent-approval=2 agent-input=1 agent-working=1 agent-done=4", - "{\"text\": \"\u{f130} 3\", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\", \"suggestion\"], \"tooltip\": \"LocalFlow: idle\\nAgents: 2 need approval, 1 waiting for input, 1 working, 4 done\\n3 dictionary suggestions from agents (localflowctl dict review)\\n12 project words boosted\"}", + "state=idle model=ready mic=present suggestions=3 words=12 agent-approval=2 agent-input=1 agent-working=1 agent-done=4".to_owned(), + "{\"text\": \" \u{f130} 3 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\", \"suggestion\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 2 need approval, 1 waiting for input, 1 working, 4 done\\n3 dictionary suggestions from agents (localflowctl dict review)\\n12 project words boosted\"}".to_owned(), ), ( - "state=idle model=loading mic=unknown agent-input=1", - "{\"text\": \"\u{f130} 1\", \"alt\": \"loading\", \"class\": [\"loading\", \"agent-input\"], \"tooltip\": \"LocalFlow: loading model\\nAgents: 1 waiting for input\"}", + "state=idle model=loading mic=unknown agent-input=1".into(), + "{\"text\": \" \u{f017} 1 \", \"alt\": \"loading\", \"class\": [\"loading\", \"agent-input\"], \"tooltip\": \"LocalFlow: loading the model\\nAgents: 1 waiting for input\"}".into(), ), ( - "state=idle model=ready mic=absent suggestions=1", - "{\"text\": \"\u{f00d}\", \"alt\": \"nomic\", \"class\": [\"nomic\", \"suggestion\"], \"tooltip\": \"LocalFlow: no microphone\\n1 dictionary suggestion from an agent (localflowctl dict review)\"}", + "state=idle model=ready mic=absent suggestions=1".into(), + "{\"text\": \" \u{f131} \", \"alt\": \"nomic\", \"class\": [\"nomic\", \"suggestion\"], \"tooltip\": \"LocalFlow: no microphone\\n1 dictionary suggestion from an agent (localflowctl dict review)\"}".into(), ), ( - "state=idle model=ready mic=present agent-approval=1", - "{\"text\": \"\u{f130} 1\", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\"], \"tooltip\": \"LocalFlow: idle\\nAgents: 1 needs approval\"}", + "state=idle model=ready mic=present agent-approval=1".into(), + "{\"text\": \" \u{f130} 1 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 1 needs approval\"}".into(), ), // Working and done alone add no class and no count. ( - "state=idle model=ready mic=present agent-working=1 agent-done=1 words=1", - "{\"text\": \"\u{f130}\", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: idle\\nAgents: 1 working, 1 done\\n1 project word boosted\"}", + "state=idle model=ready mic=present agent-working=1 agent-done=1 words=1".into(), + "{\"text\": \" \u{f130} \", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: ready\\nAgents: 1 working, 1 done\\n1 project word boosted\"}".into(), ), // Busy states keep their classes and text; only the tooltip grows. ( - "state=recording mode=hold model=ready mic=present level=100 agent-approval=1 suggestions=2", - "{\"text\": \"▁▁▁▁▁▁▁█\", \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: recording\\nAgents: 1 needs approval\\n2 dictionary suggestions from agents (localflowctl dict review)\"}", + "state=recording mode=hold model=ready mic=present level=100 agent-approval=1 suggestions=2".into(), + format!("{{\"text\": \"{loud}\", \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\\nAgents: 1 needs approval\\n2 dictionary suggestions from agents (localflowctl dict review)\"}}"), ), ( - "state=transcribing model=ready mic=present agent-input=3 words=2", - "{\"text\": \"\u{f252}\", \"alt\": \"transcribing\", \"class\": \"transcribing\", \"tooltip\": \"LocalFlow: transcribing\\nAgents: 3 waiting for input\\n2 project words boosted\"}", + "state=transcribing model=ready mic=present agent-input=3 words=2".into(), + "{\"text\": \" \u{f252} \", \"alt\": \"transcribing\", \"class\": \"transcribing\", \"tooltip\": \"LocalFlow: transcribing\\nAgents: 3 waiting for input\\n2 project words boosted\"}".into(), ), ( - "state=typing tag=prompt model=ready mic=present agent-input=2 suggestions=1", - "{\"text\": \"\u{f252}\", \"alt\": \"typing\", \"class\": [\"typing\", \"prompt\"], \"tooltip\": \"LocalFlow: typing (prompt)\\nAgents: 2 waiting for input\\n1 dictionary suggestion from an agent (localflowctl dict review)\"}", + "state=typing tag=prompt model=ready mic=present agent-input=2 suggestions=1".into(), + "{\"text\": \"\u{203a} \u{f11c} \", \"alt\": \"typing\", \"class\": [\"typing\", \"prompt\"], \"tooltip\": \"LocalFlow: typing (prompt dictation)\\nAgents: 2 waiting for input\\n1 dictionary suggestion from an agent (localflowctl dict review)\"}".into(), ), ]; for (fields, want) in cases { - let got = ind.update(fields).unwrap(); + let got = ind.update(&fields).unwrap(); assert_eq!(got, want, "{fields}"); - let v: serde_json::Value = serde_json::from_str(&got).unwrap(); - assert!(v["tooltip"].as_str().unwrap().starts_with("LocalFlow: ")); + assert!( + json(&got)["tooltip"] + .as_str() + .unwrap() + .starts_with("LocalFlow: ") + ); } // Back to no counts: exactly the plain object again. assert_eq!( ind.update("state=idle model=ready mic=present").unwrap(), - "{\"text\": \"\u{f130}\", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: idle\"}" + "{\"text\": \" \u{f130} \", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: ready\"}" ); // Offline shows none of it. assert_eq!( ind.offline(), - "{\"text\": \"\u{f131}\", \"alt\": \"offline\", \"class\": \"offline\", \"tooltip\": \"LocalFlow: offline\"}" + "{\"text\": \" \u{f011} \", \"alt\": \"offline\", \"class\": \"offline\", \"tooltip\": \"LocalFlow isn\u{2019}t running\"}" ); } @@ -503,35 +621,63 @@ mod tests { } #[test] - fn the_level_graph_scrolls_and_restarts_per_recording() { - assert_eq!(bar(0), '▁'); - assert_eq!(bar(100), '█'); - assert_eq!(bar(255), '█'); - // The square-root curve lifts quiet levels: 4% is already a bar up. - assert_eq!(bar(4), '▂'); - assert_eq!(bar(25), '▅'); - let text = |o: String| -> String { - let v: serde_json::Value = serde_json::from_str(&o).unwrap(); - v["text"].as_str().unwrap().to_owned() - }; + fn the_dot_wave_scrolls_and_restarts_per_recording() { + assert_eq!(dot(0), SILENCE); + assert_eq!(dot(100), DOTS[3]); + assert_eq!(dot(255), DOTS[3]); + // Room noise stays a row of silence; the square-root curve lifts + // quiet speech: 10% is already a dot up, 30% two. + assert_eq!(dot(4), SILENCE); + assert_eq!(dot(10), DOTS[1]); + assert_eq!(dot(30), DOTS[2]); + // Louder is never smaller. + assert!((0..100).all(|l| DOTS.iter().position(|&d| d == dot(l)) + <= DOTS.iter().position(|&d| d == dot(l + 1)))); let mut ind = Indicator::default(); let rec = |l: u8| format!("state=recording mode=toggle model=ready mic=present level={l}"); - assert_eq!(text(ind.update(&rec(100)).unwrap()), "▁▁▁▁▁▁▁█"); - assert_eq!(text(ind.update(&rec(25)).unwrap()), "▁▁▁▁▁▁█▅"); + // A recording starts as silence: a row of equal small dots. + assert_eq!( + text( + &ind.update("state=recording mode=toggle model=ready mic=present") + .unwrap() + ), + SILENCE.to_string().repeat(WIDTH) + ); + assert_eq!( + text(&ind.update(&rec(100)).unwrap()), + dots(&[0, 0, 0, 0, 0, 100]) + ); + assert_eq!( + text(&ind.update(&rec(25)).unwrap()), + dots(&[0, 0, 0, 0, 100, 25]) + ); for _ in 0..HISTORY { ind.update(&rec(0)); } - assert_eq!(text(ind.update(&rec(100)).unwrap()), "▁▁▁▁▁▁▁█"); - // A recording line without a level keeps the graph. assert_eq!( - text(ind.update("state=recording mode=hold model=ready").unwrap()), - "▁▁▁▁▁▁▁█" + text(&ind.update(&rec(100)).unwrap()), + dots(&[0, 0, 0, 0, 0, 100]) + ); + // A recording line without a level keeps the wave. + assert_eq!( + text(&ind.update("state=recording mode=hold model=ready").unwrap()), + dots(&[0, 0, 0, 0, 0, 100]) + ); + // A prompt dictation shows the newest levels after its marker. + let prompt = + |l: u8| format!("state=recording tag=prompt model=ready mic=present level={l}"); + assert_eq!( + text(&ind.update(&prompt(25)).unwrap()), + format!("{PROMPT_MARKER}{}", dots(&[0, 0, 0, 100, 25])) ); // The next recording starts from silence. ind.update("state=transcribing model=ready mic=present"); - assert_eq!(text(ind.update(&rec(25)).unwrap()), "▁▁▁▁▁▁▁▅"); + assert_eq!( + text(&ind.update(&rec(25)).unwrap()), + dots(&[0, 0, 0, 0, 0, 25]) + ); ind.offline(); - assert_eq!(text(ind.update(&rec(0)).unwrap()), "▁▁▁▁▁▁▁▁"); + assert_eq!(text(&ind.update(&rec(0)).unwrap()), dots(&[0; WIDTH])); } #[test] diff --git a/linux/crates/lf-daemon/tests/daemon.rs b/linux/crates/lf-daemon/tests/daemon.rs index 0acf71dc..6248390e 100644 --- a/linux/crates/lf-daemon/tests/daemon.rs +++ b/linux/crates/lf-daemon/tests/daemon.rs @@ -1398,9 +1398,9 @@ fn localflowctl_waybar_shows_states_and_survives_daemon_restarts() { assert!(ctl(&rd, &["toggle"]).status.success()); let v = object(&lines); assert_eq!(v["class"], "recording"); - let graph = v["text"].as_str().unwrap(); - assert_eq!(graph.chars().count(), 8); - assert!(graph.ends_with('▆'), "{graph}"); + let wave = v["text"].as_str().unwrap(); + assert_eq!(wave.chars().count(), lf_daemon::waybar::WIDTH); + assert!(wave.ends_with(lf_daemon::waybar::dot(50)), "{wave}"); assert!(ctl(&rd, &["cancel"]).status.success()); // Skip level lines still queued. while class(&lines) != "idle" {} diff --git a/linux/gui/src/look.rs b/linux/gui/src/look.rs new file mode 100644 index 00000000..399296fe --- /dev/null +++ b/linux/gui/src/look.rs @@ -0,0 +1,152 @@ +//! LocalFlow's look, shared by the settings window and both dialogs: the +//! iPhone app's (`iOS/App/Components.swift`, `Theme`). Always dark, as the +//! app and the site are: near-black surfaces, white ink, hairline edges, and +//! brand colour only on state (a switch that is on, a dot, a check, an +//! error). + +use rune::color::hex; +use rune::{Color, El, Icon, el, icon}; +use rune_kit::{Button, ButtonVariant, ControlSize, Kit, PaletteFocus, PaletteSeed}; + +fn rgb(s: &str) -> Color { + hex(s).expect("a #rrggbb colour") +} + +/// The window behind everything. +pub fn screen() -> Color { + rgb("#05080A") +} + +/// A card of settings, and a text box. +pub fn card() -> Color { + rgb("#0F1A1C") +} + +/// The main action's near-black pill and its grey hairline. +fn pill() -> Color { + rgb("#09090A") +} + +fn edge() -> Color { + rgb("#6E6E70") +} + +/// Waiting: a change not applied yet. +pub fn glacier() -> Color { + rgb("#67E8F9") +} + +/// Done. +fn aurora() -> Color { + rgb("#6DFFA8") +} + +/// A warning or something to fix. +pub fn ember() -> Color { + rgb("#F5C66B") +} + +/// The kit every LocalFlow window draws with. +pub fn kit() -> Kit { + let mut k = Kit::from_palette_focus( + PaletteSeed { + bg: screen(), + fg: Color::WHITE, + // A switch that is on: aurora, a shade deeper so the knob stays crisp. + accent: rgb("#3FD17A"), + }, + // Focus is a soft white ring, never blue. + PaletteFocus::Custom(Color::WHITE.alpha(0.75)), + ); + let (secondary, faint) = (k.light(0.6), k.light(0.4)); + let c = &mut k.c; + c.layer_01 = card(); + c.text_muted = secondary; + c.text_weak = secondary; + c.icon_muted = secondary; + c.text_faint = faint; + c.icon_faint = faint; + c.knob = Color::WHITE; + c.knob_on = Color::WHITE; + // The main action: the near-black pill with white ink (see `primary`). + c.bg_contrast = pill(); + c.text_contrast = Color::WHITE; + c.contrast_hover = Color::WHITE.alpha(0.08); + c.contrast_pressed = Color::WHITE.alpha(0.14); + // Selected text: a soft white wash, not the switches' green. + c.text_accent = Color::WHITE; + c.success.icon = aurora(); + c.danger.fg = ember(); + c.danger.icon = ember(); + c.danger.border = ember(); + k +} + +/// `k` for segmented controls: a track set into the card, and the chosen +/// segment clearly lifted from it. +pub fn segments(k: &Kit) -> Kit { + let mut s = k.clone(); + s.c.layer_01 = screen(); + s.c.bg_base = rgb("#2C3739"); + s.c.border_strong = Color::WHITE.alpha(0.16); + s +} + +/// A pill's height, as a large push button. +const PILL_HEIGHT: f32 = 28.0; + +/// The main action: the iPhone's near-black pill with a hairline edge and +/// white ink. +pub fn primary(k: &Kit, b: Button) -> El { + b.variant(ButtonVariant::Contrast) + .control_size(ControlSize::Large) + .build(k) + .radius(PILL_HEIGHT / 2.0) + .px(16.0) + .gradient(Color::TRANSPARENT, Color::TRANSPARENT) + .border(1.0, edge()) +} + +/// A quieter action beside the main one: secondary ink, no surface. +pub fn secondary(k: &Kit, b: Button) -> El { + b.variant(ButtonVariant::GhostMuted) + .control_size(ControlSize::Large) + .build(k) + .radius(PILL_HEIGHT / 2.0) + .px(14.0) +} + +/// A state's small dot (`StateDot` on the iPhone). +pub fn dot(color: Color) -> El { + el().size(6.0, 6.0).radius(3.0).fill(color).a11y_hidden() +} + +/// LocalFlow's ridge mark (`RidgeMark` in `iOS/KeyboardCore`): the W line, +/// in the mark's 32 units, drawn heavier (1.3) for small sizes as the +/// keyboard draws it. +static RIDGE_LINE: Icon = Icon { + name: "lf-ridge-line", + view: 32.0, + body: r#""#, +}; + +/// The mark's dot. +static RIDGE_DOT: Icon = Icon { + name: "lf-ridge-dot", + view: 32.0, + body: r#""#, +}; + +/// The ridge mark, `side` points square: the line in white, its dot in +/// aurora. Decorative. +pub fn mark(side: f32) -> El { + el().key("mark") + .size(side, side) + .a11y_hidden() + .child( + icon(&RIDGE_LINE, side) + .color(Color::WHITE) + .absolute(0.0, 0.0), + ) + .child(icon(&RIDGE_DOT, side).color(aurora()).absolute(0.0, 0.0)) +} diff --git a/linux/gui/src/main.rs b/linux/gui/src/main.rs index 40683617..1f361fd5 100644 --- a/linux/gui/src/main.rs +++ b/linux/gui/src/main.rs @@ -11,6 +11,7 @@ //! which render it headless. mod instance; +mod look; mod prompt; mod settings; @@ -85,7 +86,7 @@ fn run_edit() -> i32 { min_size: Some((360.0, 120.0)), on_key: Some(prompt::edit_key), ..AppSpec::new( - "Correct dictation", + "Fix dictation", ( 560.0, prompt::edit_height(INPUT.get().map_or("", String::as_str)), @@ -158,7 +159,7 @@ fn run_settings() -> i32 { let app = rune::App::new(AppSpec:: { min_size: Some((520.0, 400.0)), on_key: Some(settings::key), - ..AppSpec::new("LocalFlow Settings", (600.0, 760.0), init, settings::view) + ..AppSpec::new("LocalFlow Settings", (600.0, 880.0), init, settings::view) }); if let Some(listener) = listener { let handle = app.handle(); diff --git a/linux/gui/src/prompt.rs b/linux/gui/src/prompt.rs index 2d533b5b..9c639d98 100644 --- a/linux/gui/src/prompt.rs +++ b/linux/gui/src/prompt.rs @@ -15,8 +15,10 @@ use std::sync::Mutex; use rune::tok::space; -use rune::{Cx, El, Key, KeyEvent, TextEdit, Tx, col, icon, row}; -use rune_kit::{Button, ButtonVariant, Kit, TextArea}; +use rune::{Cx, El, Key, KeyEvent, TextEdit, Tx, col, el, row}; +use rune_kit::{Button, TextArea}; + +use crate::look; /// The answer for `main` to print once the window has closed. pub static ANSWER: Mutex> = Mutex::new(None); @@ -66,8 +68,18 @@ impl Edit { } } -pub fn edit_view(s: &Edit, cx: &mut Cx) -> El { - let k = Kit::neutral(cx.system_dark()); +/// The dialogs' margin, and the gap between the text and the buttons. +const PAD: f32 = 16.0; +/// A pill button's height (`look::primary`). +const BUTTON: f32 = 28.0; +/// The ridge mark's side. +const MARK: f32 = 20.0; + +pub fn edit_view(s: &Edit, _cx: &mut Cx) -> El { + let k = look::kit(); + // The box is a card on the window, as the settings' fields are. + let mut boxed = k.clone(); + boxed.c.bg_base = look::card(); let field = TextArea::new(&s.edit) .label("Correction") .width(None) @@ -86,33 +98,40 @@ pub fn edit_view(s: &Edit, cx: &mut Cx) -> El { } _ => false, }) - .build(&k); + .build(&boxed) + // A longer text than the window was sized for still fits: the box + // takes the room, and the buttons stay at the bottom. + .grow(1.0) + .min_h(0.0); let buttons = row() .key("buttons") .gap(space::S2) - .justify_end() + .center_y() + .child(look::mark(MARK)) + .child(el().grow(1.0)) .child( - Button::new("Cancel") - .variant(ButtonVariant::Ghost) - .on_press_tx(|s: &mut Edit, tx| dismiss(&mut s.outcome, tx)) - .build(&k) - .key("cancel"), + look::secondary( + &k, + Button::new("Cancel").on_press_tx(|s: &mut Edit, tx| dismiss(&mut s.outcome, tx)), + ) + .key("cancel"), ) .child( - Button::new("Correct") - .variant(ButtonVariant::Contrast) - .shortcut("Enter") - .on_press_tx(|s: &mut Edit, tx| s.accept(tx)) - .build(&k) - .key("correct"), + look::primary( + &k, + Button::new("Fix") + .shortcut("Enter") + .on_press_tx(|s: &mut Edit, tx| s.accept(tx)), + ) + .key("fix"), ); col() .key("edit-window") .w_full() .h_full() - .padding(16.0) + .padding(PAD) .gap(space::S3) - .fill(k.c.bg_base) + .fill(look::screen()) .child(field) .child(buttons) } @@ -134,24 +153,21 @@ impl Choose { } } -pub fn choose_view(s: &Choose, cx: &mut Cx) -> El { - let k = Kit::neutral(cx.system_dark()); +pub fn choose_view(s: &Choose, _cx: &mut Cx) -> El { + let k = look::kit(); let mut list = col().key("rows").gap(space::S2).w_full(); for (i, label) in s.rows.iter().enumerate() { - let variant = if i == s.selected { - ButtonVariant::Contrast + let b = Button::new(label.clone()) + .on_press_tx(move |s: &mut Choose, tx| finish(&mut s.outcome, Outcome::Chose(i), tx)); + // The highlighted row, which Enter chooses, is the pill. + let b = if i == s.selected { + look::primary(&k, b) } else { - ButtonVariant::Neutral + look::secondary(&k, b) }; list = list.child( row().key(format!("row-{i}")).w_full().child( - Button::new(label.clone()) - .variant(variant) - .on_press_tx(move |s: &mut Choose, tx| { - finish(&mut s.outcome, Outcome::Chose(i), tx) - }) - .build(&k) - .key(format!("choice-{i}")) + b.key(format!("choice-{i}")) // The keyboard drives the highlight only (a focused // button would take Enter and Space for itself). .focusable(false) @@ -163,30 +179,27 @@ pub fn choose_view(s: &Choose, cx: &mut Cx) -> El { .key("choose-window") .w_full() .h_full() - .padding(16.0) + .padding(PAD) .gap(space::S3) .justify_center() .items_center() - .fill(k.c.bg_base) - .child( - icon(&rune_kit::glyphs::MIC, 18.0) - .key("mark") - .color(k.c.text_weak), - ) + .fill(look::screen()) + .child(look::mark(MARK)) .child(list) } /// The edit window's height for `text`: about 64 characters a line at -/// 560 pt, 2 to 8 lines, the buttons under them. +/// 560 pt, 2 to 8 lines of 17.5 pt in the box (8 pt of padding), and the +/// buttons under it. pub fn edit_height(text: &str) -> f32 { let lines = text.chars().count().div_ceil(64).clamp(2, 8); - 16.0 * 2.0 + 16.0 + lines as f32 * 20.0 + space::S3 + 32.0 + PAD * 2.0 + 16.0 + lines as f32 * 17.5 + space::S3 + BUTTON } -/// The choose window's height for `rows` buttons: padding, the mark, and -/// a 32 pt button with an 8 pt gap per row. +/// The choose window's height for `rows` buttons: the mark over a pill per +/// row, 8 pt apart. pub fn choose_height(rows: usize) -> f32 { - 16.0 * 2.0 + 18.0 + space::S3 + rows as f32 * 32.0 + rows.saturating_sub(1) as f32 * space::S2 + PAD * 2.0 + MARK + space::S3 + rows as f32 * BUTTON + rows.saturating_sub(1) as f32 * space::S2 } /// The choose dialog's keys: Escape dismisses; Up, Down and Tab move the @@ -361,13 +374,32 @@ mod tests { s.edit.text = "synthetic\nfirst\r\n\nsecond".into(); let mut rt = Runtime::new(s, edit_view, (520.0, 200.0), Env::new(true, 1.0)); rt.frame(0.0); - press(&mut rt, "correct"); + press(&mut rt, "fix"); assert_eq!( rt.state.outcome, Some(Outcome::Accepted("synthetic first second".into())) ); } + #[test] + fn the_edit_buttons_sit_one_margin_above_the_bottom() { + for text in [ + "synthetic", + "Synthetic dictation about a cluster, its pods and the plan for tomorrow morning, \ + long enough to wrap onto a second and a third line of the box.", + ] { + let h = edit_height(text); + let mut rt = Runtime::new(Edit::new(text), edit_view, (560.0, h), Env::new(true, 1.0)); + rt.frame(0.0); + let ([_, y, _, bh], _) = rt.presented("buttons").unwrap(); + let under = h - (y + bh); + assert!( + (under - PAD).abs() < 0.5, + "{under} pt under the buttons in a {h} pt window" + ); + } + } + #[test] fn escape_and_cancel_dismiss() { let mut rt = Runtime::new( diff --git a/linux/gui/src/settings.rs b/linux/gui/src/settings.rs index 635a9ced..ccbe71bd 100644 --- a/linux/gui/src/settings.rs +++ b/linux/gui/src/settings.rs @@ -5,10 +5,14 @@ //! - A change is a draft until **Apply**, which writes `settings.json` (only //! the keys that differ from `config.json`) and restarts the daemon when a //! key it reads changed. **Discard** drops the draft. -//! - A setting the window changed has a reset icon, which takes it back to -//! `config.json`'s value (or the default). -//! - Values that make an invalid configuration keep Apply disabled and show -//! the daemon's own message, which names the key and never the value. +//! - A setting whose draft differs from what is saved has a glacier dot +//! beside its label. A setting the window changed has a reset icon, which +//! takes it back to `config.json`'s value (or the default). +//! - A text field whose value makes an invalid configuration is marked, with +//! a few plain words under it (never the value), and Apply stays disabled. +//! - If Apply saved the settings but could not restart the daemon, the +//! footer says so and offers to try the restart again. +//! - Clicking anywhere on a switch's row flips the switch. //! - Escape closes the window. With changes not yet applied it asks first; //! a second Escape closes without applying them. @@ -18,11 +22,13 @@ use lf_daemon::config::{Config, Layers, write_settings}; use rune::tok::space; use rune::{Cx, El, Icon, Key, KeyEvent, Mods, ScrollSpec, TextEdit, Tx, col, el, icon, row, text}; use rune_kit::{ - Button, ButtonVariant, IconButton, IconButtonVariant, Kit, SegmentedControl, Select, Switch, + Button, IconButton, IconButtonVariant, Kit, SegmentedControl, Select, SelectAppearance, Switch, TextInput, }; use serde_json::{Map, Value, json}; +use crate::look; + /// Back to `config.json`'s value. static RESET: Icon = Icon { name: "lf-reset", @@ -79,9 +85,25 @@ pub struct Settings { pub enum Status { Idle, Applied, + /// The settings were saved, but the daemon did not restart (why, for + /// the footer's tooltip). + RestartFailed(String), + /// Nothing was saved. Failed(String), } +/// A text field whose value is invalid. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Spot { + PromptTag, + Microphone, + /// A voice macro's row; `payload` for its "Type" field, else "Say". + Macro { + row: usize, + payload: bool, + }, +} + /// Keys the daemon ignores (`localflowctl` reads them on each run). const CLIENT_ONLY: &[&str] = &["picker"]; @@ -214,17 +236,76 @@ impl Settings { self.shown = config.clone(); self.config = Ok(config); self.status = Status::Applied; - if daemon_reads - && let Some(restart) = self.restart - && let Err(e) = restart() - { - self.status = Status::Failed(e); + if daemon_reads { + self.restart_daemon(); } } Err(e) => self.status = Status::Failed(e), } } + /// Restarts the daemon over saved settings: applied, or saved without + /// the restart. Also the restart-failed footer's Retry. + pub fn restart_daemon(&mut self) { + self.status = match self.restart.map_or(Ok(()), |restart| restart()) { + Ok(()) => Status::Applied, + Err(e) => Status::RestartFailed(e), + }; + } + + /// Whether `key`'s draft differs from what is saved. + pub fn unapplied(&self, key: &str) -> bool { + self.draft.get(key) != self.layers.settings.get(key) + } + + /// The text field that makes the draft invalid, and what to do about it + /// in a few plain words (never its value). + pub fn problem(&self) -> Option<(Spot, &'static str)> { + let e = self.config.as_ref().err()?; + let breaks = |t: &str| t.chars().any(char::is_control); + if e.starts_with("prompt_tag") { + let fix = if breaks(&self.prompt_tag.text) { + "No tabs or line breaks" + } else { + "Use 64 characters or fewer" + }; + return Some((Spot::PromptTag, fix)); + } + if e.starts_with("input_device") { + let fix = if breaks(&self.input_device.text) { + "No tabs or line breaks" + } else { + "Use a shorter name" + }; + return Some((Spot::Microphone, fix)); + } + // `voice_macros[]. ...`, counting only the rows that have + // something to say (see `sync_macros`). + let rest = e.strip_prefix("voice_macros[")?; + let (i, rest) = rest.split_once(']')?; + let row = self + .macros + .iter() + .enumerate() + .filter(|(_, (c, _))| !c.text.trim().is_empty()) + .nth(i.parse().ok()?)? + .0; + let payload = rest.starts_with(".payload"); + let fix = if rest.contains("never match") { + "Use words, not just punctuation" + } else { + "Use shorter text" + }; + Some((Spot::Macro { row, payload }, fix)) + } + + /// The plain words for `spot`, when it is the invalid field. + fn problem_at(&self, spot: Spot) -> Option<&'static str> { + self.problem() + .filter(|(s, _)| *s == spot) + .map(|(_, fix)| fix) + } + fn sync_macros(&mut self) { let list: Vec = self .macros @@ -249,7 +330,9 @@ pub fn key(s: &mut Settings, e: &KeyEvent, tx: &mut Tx) -> bool { } /// `systemctl --user try-restart localflowd.service`: restarts the daemon -/// if it runs as the usual user unit. +/// if it runs as the usual user unit. It returns in about 20 ms (the daemon +/// stops at once; systemd does not wait for the model to load), so Apply +/// needs no "applying" state. pub fn restart_daemon() -> Result<(), String> { let status = std::process::Command::new("systemctl") .args(["--user", "try-restart", "localflowd.service"]) @@ -265,10 +348,9 @@ pub fn restart_daemon() -> Result<(), String> { } } -/// A labelled row: the label, a reset icon when the window changed the -/// key, and the control. +/// A labelled row: the label, a glacier dot while its draft is not applied, +/// a reset icon when the window changed the key, and the control. fn setting(k: &Kit, s: &Settings, key: &'static str, label: &str, control: El) -> El { - let changed = s.draft.contains_key(key); let mut left = row().key("left").gap(space::S2).center_y().grow(1.0).child( text(label.to_owned()) .key("label") @@ -276,16 +358,11 @@ fn setting(k: &Kit, s: &Settings, key: &'static str, label: &str, control: El) - .color(k.c.text_base) .a11y_hidden(), ); - if changed { - left = left.child( - IconButton::new(&RESET) - .variant(IconButtonVariant::GhostMuted) - .label(format!("Reset {label}")) - .help("Back to config.json") - .on_press(move |s: &mut Settings| s.reset(key)) - .build(k) - .key(format!("reset-{key}")), - ); + if s.unapplied(key) { + left = left.child(look::dot(look::glacier()).key(format!("unapplied-{key}"))); + } + if s.draft.contains_key(key) { + left = left.child(reset(k, key, label)); } row() .key(key) @@ -298,6 +375,19 @@ fn setting(k: &Kit, s: &Settings, key: &'static str, label: &str, control: El) - .child(control) } +/// Takes `key` back to `config.json`'s value. +fn reset(k: &Kit, key: &'static str, label: &str) -> El { + IconButton::new(&RESET) + .variant(IconButtonVariant::GhostMuted) + .label(format!("Reset {label}")) + .help("Back to default") + .on_press(move |s: &mut Settings| s.reset(key)) + .build(k) + .key(format!("reset-{key}")) +} + +/// A switch's row: a click anywhere on it flips the switch, which stays the +/// row's only focus stop. fn toggle(k: &Kit, s: &Settings, key: &'static str, label: &str, on: bool) -> El { let control = Switch::new(on) .label(label.to_owned()) @@ -306,14 +396,26 @@ fn toggle(k: &Kit, s: &Settings, key: &'static str, label: &str, on: bool) -> El .build(k) .key(format!("switch-{key}")); setting(k, s, key, label, control) + .on_press(move |s: &mut Settings| s.set(key, Value::Bool(!on))) + .role("group") +} + +/// `field` with `fix` under it, when its value is the invalid one. +fn with_fix(k: &Kit, field: El, key: String, fix: Option<&'static str>) -> El { + let Some(fix) = fix else { return field }; + col() + .items_end() + .gap(space::S1) + .child(field) + .child(text(fix).key(key).font(k.ty.small).color(k.c.danger.fg)) } -fn card(k: &Kit, key: &str, title: &str, rows: Vec) -> El { +fn card(k: &Kit, key: &str, title: &str, unapplied: bool, rows: Vec) -> El { let mut body = col() .key("card") .padding(space::S1) .fill(k.c.layer_01) - .radius(10.0) + .radius(12.0) .box_shadow(rune::Shadow { spread: 0.5, color: k.c.border_base, @@ -327,29 +429,97 @@ fn card(k: &Kit, key: &str, title: &str, rows: Vec) -> El { el().key(format!("sep{i}")) .h(1.0) .mx(space::S3) - .fill(k.c.border_base) + .fill(k.c.border_muted) .a11y_hidden(), ); } } - col() - .key(key.to_owned()) + let mut header = row() + .key("header") .gap(space::S2) + .center_y() + .ml(space::S3) .child( text(title.to_owned()) - .key("header") - .font(k.ty.small) - .color(k.c.text_weak) - .ml(space::S3), - ) + .font(k.ty.small_medium) + .color(k.c.text_muted), + ); + if unapplied { + header = header.child(look::dot(look::glacier()).key(format!("unapplied-{key}"))); + } + col() + .key(key.to_owned()) + .gap(space::S2) + .child(header) .child(body) } -/// "Auto" is the model's default (no `precision` key). -const PRECISION: [&str; 5] = ["Auto", "f32", "i8x1", "i8x2", "i8x3"]; +/// A menu for `key`: `options` are labels and values, a `None` value +/// takes the key back to `config.json`'s. `Settings::open` names the open +/// menu. +fn menu( + k: &Kit, + s: &Settings, + key: &'static str, + label: &str, + options: &'static [(&'static str, Option<&'static str>)], + current: Option<&str>, + layer: &mut Vec, +) -> El { + let labels: Vec<&str> = options.iter().map(|(l, _)| *l).collect(); + let now = options.iter().find(|(_, v)| *v == current).map(|(l, _)| *l); + Select::new(format!("{key}-select"), &labels) + .appearance(SelectAppearance::Field) + .current(now) + .label(label.to_owned()) + .width(FIELD_WIDTH) + .open(s.open == Some(key)) + .on_toggle(move |s: &mut Settings| s.open = if s.open.is_some() { None } else { Some(key) }) + .on_close(|s: &mut Settings| s.open = None) + .on_choose(move |s: &mut Settings, v: &str, _: &mut Tx| { + s.open = None; + match options.iter().find(|(l, _)| *l == v) { + Some((_, Some(value))) => s.set(key, json!(value)), + Some((_, None)) => s.reset(key), + None => {} + } + }) + .build(k, layer) +} + +/// Text fields and menus share one width, so their edges line up. +const FIELD_WIDTH: f32 = 200.0; + +/// `precision`'s values, from the fastest. "Auto" is the model's own (no +/// `precision` key): i8x1 for 1-bit models, i8x2 for ternary ones. +const SPEED: [(&str, Option<&str>); 5] = [ + ("Auto", None), + ("Fastest", Some("i8x1")), + ("Fast", Some("i8x2")), + ("Precise", Some("i8x3")), + ("Full precision", Some("f32")), +]; + +/// `output`'s values. +const OUTPUT: [(&str, Option<&str>); 4] = [ + ("Auto", Some("auto")), + ("Wayland", Some("wayland")), + ("Desktop portal", Some("portal")), + ("Kernel device", Some("uinput")), +]; -pub fn view(s: &Settings, cx: &mut Cx) -> El { - let k = Kit::neutral(cx.system_dark()); +const PICKERS: [(&str, Option<&str>); 6] = [ + ("Auto", Some("auto")), + ("LocalFlow", Some("gui")), + ("rofi", Some("rofi")), + ("fuzzel", Some("fuzzel")), + ("wofi", Some("wofi")), + ("tofi", Some("tofi")), +]; + +pub fn view(s: &Settings, _cx: &mut Cx) -> El { + let k = look::kit(); + let seg = look::segments(&k); let c = &s.shown; let mut layer = Vec::new(); @@ -357,6 +527,7 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { &k, "dictation", "Dictation", + false, vec![ toggle( &k, @@ -390,10 +561,12 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { ], ); + let tag_fix = s.problem_at(Spot::PromptTag); let tag = TextInput::new(&s.prompt_tag) .label("Prompt tag") .placeholder("Untagged") - .width(Some(160.0)) + .width(Some(FIELD_WIDTH)) + .invalid(tag_fix.is_some()) .on_edit(|s: &mut Settings| &mut s.prompt_tag) .on_change(|s: &mut Settings, _: &mut Tx| { let v = s.prompt_tag.text.clone(); @@ -401,6 +574,7 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { }) .build(&k) .key("prompt-tag-field"); + let tag = with_fix(&k, tag, "error-prompt_tag".into(), tag_fix); static DOUBLE_TAP: [(&str, u64); 4] = [ ("Off", 0), ("250 ms", 250), @@ -415,107 +589,76 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { .on_pick(|s: &mut Settings, i: usize, _: &mut Tx| { s.set("double_tap_ms", json!(DOUBLE_TAP[i].1)) }) - .build(&k) + .build(&seg) .key("choice-double_tap_ms"); let prompt = card( &k, "prompt", - "Hold to Prompt", + "Hold to prompt", + false, vec![ setting(&k, s, "double_tap_ms", "Double-tap and hold", tap), - setting(&k, s, "prompt_tag", "Tag", tag), + setting(&k, s, "prompt_tag", "Prompt tag", tag), ], ); - static OUTPUT: [(&str, &str); 4] = [ - ("Auto", "auto"), - ("Wayland", "wayland"), - ("Portal", "portal"), - ("uinput", "uinput"), - ]; - let out_labels: Vec<&str> = OUTPUT.iter().map(|(l, _)| *l).collect(); - let out_current = OUTPUT.iter().position(|(_, v)| *v == output_name(c)); - let output = SegmentedControl::new(&out_labels, out_current) - .label("Output") - .fit() - .on_pick(|s: &mut Settings, i: usize, _: &mut Tx| s.set("output", json!(OUTPUT[i].1))) - .build(&k) - .key("choice-output"); - static DELAY: [(&str, u64); 4] = [("0", 0), ("1 ms", 1), ("5 ms", 5), ("10 ms", 10)]; + let output = menu( + &k, + s, + "output", + "Type with", + &OUTPUT, + Some(output_name(c)), + &mut layer, + ); + static DELAY: [(&str, u64); 4] = [("0 ms", 0), ("1 ms", 1), ("5 ms", 5), ("10 ms", 10)]; let delay_labels: Vec<&str> = DELAY.iter().map(|(l, _)| *l).collect(); let delay = SegmentedControl::new( &delay_labels, DELAY.iter().position(|(_, v)| *v == c.key_delay_ms), ) - .label("Key delay") + .label("Pause between keys") .fit() .on_pick(|s: &mut Settings, i: usize, _: &mut Tx| s.set("key_delay_ms", json!(DELAY[i].1))) - .build(&k) + .build(&seg) .key("choice-key_delay_ms"); - static PICKERS: [(&str, &str); 6] = [ - ("Auto", "auto"), - ("LocalFlow", "gui"), - ("rofi", "rofi"), - ("fuzzel", "fuzzel"), - ("wofi", "wofi"), - ("tofi", "tofi"), - ]; - let picker_labels: Vec<&str> = PICKERS.iter().map(|(l, _)| *l).collect(); - let picker_now = PICKERS - .iter() - .find(|(_, v)| *v == picker_name(c)) - .map(|(l, _)| *l); - let picker = Select::new("picker-select", &picker_labels) - .current(picker_now) - .label("Correction dialogs") - .width(140.0) - .open(s.open == Some("picker")) - .on_toggle(|s: &mut Settings| { - s.open = if s.open.is_some() { - None - } else { - Some("picker") - } - }) - .on_close(|s: &mut Settings| s.open = None) - .on_choose(|s: &mut Settings, v: &str, _: &mut Tx| { - s.open = None; - if let Some((_, name)) = PICKERS.iter().find(|(l, _)| *l == v) { - s.set("picker", json!(name)); - } - }) - .build(&k, &mut layer); + let picker = menu( + &k, + s, + "picker", + "Correction dialogs", + &PICKERS, + Some(picker_name(c)), + &mut layer, + ); let typing = card( &k, "typing", "Typing", + false, vec![ - setting(&k, s, "output", "Output", output), + setting(&k, s, "output", "Type with", output), setting(&k, s, "key_delay_ms", "Pause between keys", delay), setting(&k, s, "picker", "Correction dialogs", picker), ], ); - let precision_now = match c.precision { - None => 0, - Some(p) => PRECISION.iter().position(|n| *n == p.name()).unwrap_or(0), - }; - let precision = SegmentedControl::new(&PRECISION, Some(precision_now)) - .label("Precision") - .fit() - .on_pick(|s: &mut Settings, i: usize, _: &mut Tx| { - if i == 0 { - s.reset("precision"); - } else { - s.set("precision", json!(PRECISION[i])); - } - }) - .build(&k) - .key("choice-precision"); + let precision = c.precision.map(|p| p.name()); + let speed = menu( + &k, + s, + "precision", + "Speed", + &SPEED, + precision.as_deref(), + &mut layer, + ); + let mic_fix = s.problem_at(Spot::Microphone); let device = TextInput::new(&s.input_device) .label("Microphone") .placeholder("Default") - .width(Some(200.0)) + .width(Some(FIELD_WIDTH)) + .invalid(mic_fix.is_some()) .on_edit(|s: &mut Settings| &mut s.input_device) .on_change(|s: &mut Settings, _: &mut Tx| { let t = s.input_device.text.trim().to_owned(); @@ -528,23 +671,29 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { }, ); }) - .build(&k); + .build(&k) + .key("microphone-field"); + let device = with_fix(&k, device, "error-input_device".into(), mic_fix); let recognition = card( &k, "recognition", "Recognition", + false, vec![ - setting(&k, s, "precision", "Precision", precision), + setting(&k, s, "precision", "Speed", speed), setting(&k, s, "input_device", "Microphone", device), ], ); let mut macro_rows = Vec::new(); for i in 0..s.macros.len() { + let fix = |payload| s.problem_at(Spot::Macro { row: i, payload }); + let (say_fix, type_fix) = (fix(false), fix(true)); let command = TextInput::new(&s.macros[i].0) .label("Say") .placeholder("Say") .width(Some(150.0)) + .invalid(say_fix.is_some()) .on_edit(move |s: &mut Settings| &mut s.macros[i].0) .on_change(|s: &mut Settings, _: &mut Tx| s.sync_macros()) .build(&k); @@ -552,6 +701,7 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { .label("Type") .placeholder("Type") .width(None) + .invalid(type_fix.is_some()) .on_edit(move |s: &mut Settings| &mut s.macros[i].1) .on_change(|s: &mut Settings, _: &mut Tx| s.sync_macros()) .build(&k) @@ -564,18 +714,35 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { s.sync_macros(); }) .build(&k); - macro_rows.push( - row() - .key(format!("macro-{i}")) - .min_h(40.0) - .px(space::S3) - .gap(space::S2) - .center_y() - .child(command) - .child(icon(&rune_kit::glyphs::ARROW_RIGHT, 14.0).color(k.c.text_weak)) - .child(payload) - .child(remove), - ); + let fields = row() + .key("fields") + .gap(space::S2) + .center_y() + .child(command) + .child(icon(&rune_kit::glyphs::ARROW_RIGHT, 14.0).color(k.c.text_muted)) + .child(payload) + .child(remove); + let mut item = col() + .key(format!("macro-{i}")) + .px(space::S3) + .py(6.0) + .gap(space::S1) + .child(fields); + if let Some(fix) = say_fix.or(type_fix) { + item = item.child( + text(fix) + .key(format!("error-macro-{i}")) + .font(k.ty.small) + .color(k.c.danger.fg) + // Under the field it is about. + .ml(if say_fix.is_some() { + 0.0 + } else { + 150.0 + 2.0 * space::S2 + 14.0 + }), + ); + } + macro_rows.push(item); } let mut add_row = row() .key("macro-add") @@ -595,18 +762,16 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { ); add_row = add_row.child(el().grow(1.0)); if s.draft.contains_key("voice_macros") { - add_row = add_row.child( - IconButton::new(&RESET) - .variant(IconButtonVariant::GhostMuted) - .label("Reset voice macros") - .help("Back to config.json") - .on_press(|s: &mut Settings| s.reset("voice_macros")) - .build(&k) - .key("reset-voice_macros"), - ); + add_row = add_row.child(reset(&k, "voice_macros", "voice macros")); } macro_rows.push(add_row); - let macros = card(&k, "macros", "Voice macros", macro_rows); + let macros = card( + &k, + "voice_macros", + "Voice macros", + s.unapplied("voice_macros"), + macro_rows, + ); let content = col() .key("content") @@ -614,7 +779,7 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { .px(space::S6) .pt(16.0) .pb(space::S6) - .gap(space::S6) + .gap(20.0) .child(dictation) .child(prompt) .child(typing) @@ -639,30 +804,66 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { .shrink(), ) .child( - Button::new("Keep Editing") - .variant(ButtonVariant::Ghost) - .on_press(|s: &mut Settings| s.asking_to_close = false) - .build(&k) - .key("keep-editing"), + look::secondary( + &k, + Button::new("Keep editing") + .on_press(|s: &mut Settings| s.asking_to_close = false), + ) + .key("keep-editing"), + ) + .child( + look::primary( + &k, + Button::new("Close").on_press_tx(|s: &mut Settings, tx: &mut Tx| s.close(tx)), + ) + .key("close-anyway"), + ); + return page(&k, content, footer, layer); + } + let note = |key: &'static str, words: String, color: rune::Color| { + text(words) + .key(key) + .font(k.ty.ui) + .color(color) + .grow(1.0) + .shrink() + .truncate() + }; + // Saved, so nothing is left to apply or discard (any change clears + // this): the restart is the one thing to do. + if let Status::RestartFailed(why) = &s.status { + footer = footer + .child(look::dot(look::ember()).key("restart-failed")) + .child( + note( + "restart-note", + "Saved. LocalFlow didn\u{2019}t restart.".into(), + k.c.text_base, + ) + .help(why.clone()), ) .child( - Button::new("Close") - .variant(ButtonVariant::Contrast) - .on_press_tx(|s: &mut Settings, tx: &mut Tx| s.close(tx)) - .build(&k) - .key("close-anyway"), + look::primary( + &k, + Button::new("Retry").on_press(|s: &mut Settings| s.restart_daemon()), + ) + .key("retry"), ); return page(&k, content, footer, layer); } footer = match (&s.config, &s.status) { - (Err(e), _) | (_, Status::Failed(e)) => footer.child( - text(e.clone()) - .key("error") - .font(k.ty.small) - .color(k.c.danger.fg) - .grow(1.0) - .shrink(), - ), + (Err(e), _) => { + let words = match s.problem() { + Some((Spot::PromptTag, _)) => "Fix the prompt tag to apply".to_owned(), + Some((Spot::Microphone, _)) => "Fix the microphone to apply".to_owned(), + Some((Spot::Macro { row, .. }, _)) => { + format!("Fix voice macro {} to apply", row + 1) + } + None => e.clone(), + }; + footer.child(note("error", words, k.c.danger.fg)) + } + (_, Status::Failed(e)) => footer.child(note("error", e.clone(), k.c.danger.fg)), (_, Status::Applied) => footer .child(icon(&CHECK, 16.0).key("applied").color(k.c.success.icon)) .child(el().grow(1.0)), @@ -671,20 +872,22 @@ pub fn view(s: &Settings, cx: &mut Cx) -> El { let can_apply = s.dirty() && s.config.is_ok(); footer = footer .child( - Button::new("Discard") - .variant(ButtonVariant::Ghost) - .disabled(!s.dirty()) - .on_press(|s: &mut Settings| s.discard()) - .build(&k) - .key("discard"), + look::secondary( + &k, + Button::new("Discard") + .disabled(!s.dirty()) + .on_press(|s: &mut Settings| s.discard()), + ) + .key("discard"), ) .child( - Button::new("Apply") - .variant(ButtonVariant::Contrast) - .disabled(!can_apply) - .on_press(|s: &mut Settings| s.apply()) - .build(&k) - .key("apply"), + look::primary( + &k, + Button::new("Apply") + .disabled(!can_apply) + .on_press(|s: &mut Settings| s.apply()), + ) + .key("apply"), ); page(&k, content, footer, layer) } @@ -762,8 +965,11 @@ mod tests { .map(|b| serde_json::from_slice(&b).unwrap()) } fn window(&self) -> Runtime { + self.window_with(None) + } + fn window_with(&self, restart: Restart) -> Runtime { let layers = Layers::load(&self.config(), Path::new("/d")).unwrap(); - let s = Settings::new(self.config(), "/d".into(), layers, None).unwrap(); + let s = Settings::new(self.config(), "/d".into(), layers, restart).unwrap(); // Tall enough that nothing needs scrolling. let mut rt = Runtime::new(s, view, (560.0, 1400.0), Env::new(true, 1.0)); rt.on_key = Some(key); @@ -869,6 +1075,180 @@ mod tests { assert_eq!(rt.state.shown.prompt_tag, "[dictated]"); } + #[test] + fn a_switch_row_flips_from_anywhere_on_it_once() { + let d = Dir::new("row", r#"{"version": 1}"#); + let mut rt = d.window(); + let off = rt.state.shown.history; + // The row's middle, between its label and its switch. + press(&mut rt, "history"); + assert_eq!(rt.state.shown.history, !off); + // The switch itself flips it once, not once for it and once for + // the row. + press(&mut rt, "switch-history"); + assert_eq!(rt.state.shown.history, off); + press(&mut rt, "switch-history"); + assert_eq!(rt.state.shown.history, !off); + // The reset icon in the row only resets. + press(&mut rt, "reset-history"); + assert_eq!(rt.state.shown.history, off); + assert!(!rt.state.dirty()); + + // The keyboard has one stop per row: the switch. + let mut rt = d.window(); + let (enter, numbers) = (rt.state.shown.press_enter, rt.state.shown.spoken_numbers); + for _ in 0..2 { + rt.key(KeyEvent { + key: Key::Tab, + mods: Mods::default(), + }); + rt.key(KeyEvent { + key: Key::Text(" ".into()), + mods: Mods::default(), + }); + rt.frame(1.0 / 60.0); + } + assert_eq!(rt.state.shown.press_enter, !enter); + assert_eq!(rt.state.shown.spoken_numbers, !numbers); + } + + #[test] + fn a_setting_not_applied_has_a_dot_until_apply() { + let d = Dir::new("unapplied", r#"{"version": 1}"#); + let mut rt = d.window(); + assert!(rt.find("unapplied-history").is_none()); + press(&mut rt, "switch-history"); + assert!(rt.find("unapplied-history").is_some()); + assert!(rt.find("unapplied-pause_media").is_none()); + press(&mut rt, "apply"); + // Saved: no dot, but it still differs from config.json. + assert!(rt.find("unapplied-history").is_none()); + assert!(rt.find("reset-history").is_some()); + // Taking it back is a change not applied yet, too. + press(&mut rt, "reset-history"); + assert!(rt.find("unapplied-history").is_some()); + press(&mut rt, "discard"); + assert!(rt.find("unapplied-history").is_none()); + + // Voice macros, on their card's title. + press(&mut rt, "add-macro"); + assert!( + rt.find("unapplied-voice_macros").is_none(), + "an empty row is nothing yet" + ); + rt.update(|s, _| { + s.macros[0].0 = TextEdit::new("synthetic sign off"); + s.sync_macros(); + }); + rt.frame(1.0 / 60.0); + assert!(rt.find("unapplied-voice_macros").is_some()); + } + + #[test] + fn an_invalid_field_says_what_to_fix_under_it() { + let d = Dir::new("fix", r#"{"version": 1}"#); + let mut rt = d.window(); + let secret = "synthetic".repeat(8); + rt.update(|s, _| { + s.prompt_tag = TextEdit::new(secret.clone()); + s.set("prompt_tag", Value::String(secret.clone())); + }); + rt.frame(1.0 / 60.0); + assert_eq!( + rt.state.problem(), + Some((Spot::PromptTag, "Use 64 characters or fewer")) + ); + assert!(rt.find("error-prompt_tag").is_some()); + assert!(rt.find("error-input_device").is_none()); + press(&mut rt, "apply"); + assert_eq!(d.settings(), None, "Apply stays disabled"); + // Fixed, the words go. + rt.update(|s, _| { + s.prompt_tag = TextEdit::new("[synthetic]"); + s.set("prompt_tag", json!("[synthetic]")); + }); + rt.frame(1.0 / 60.0); + assert_eq!(rt.state.problem(), None); + assert!(rt.find("error-prompt_tag").is_none()); + + let mut rt = d.window(); + rt.update(|s, _| { + s.input_device = TextEdit::new("synthetic\tmic"); + s.set("input_device", json!("synthetic\tmic")); + }); + rt.frame(1.0 / 60.0); + assert_eq!( + rt.state.problem(), + Some((Spot::Microphone, "No tabs or line breaks")) + ); + assert!(rt.find("error-input_device").is_some()); + + // The second macro row, counting the empty first one, which is not + // saved. + let mut rt = d.window(); + rt.update(|s, _| { + s.macros = vec![ + (TextEdit::default(), TextEdit::default()), + (TextEdit::new("?!"), TextEdit::new("synthetic")), + ]; + s.sync_macros(); + }); + rt.frame(1.0 / 60.0); + assert_eq!( + rt.state.problem(), + Some(( + Spot::Macro { + row: 1, + payload: false + }, + "Use words, not just punctuation" + )) + ); + assert!(rt.find("error-macro-1").is_some()); + assert!(rt.find("error-macro-0").is_none()); + let (_, fix) = rt.state.problem().unwrap(); + assert!(!fix.contains("synthetic") && !fix.contains("?!")); + } + + fn restart_fails() -> Result<(), String> { + Err("synthetic restart failure".into()) + } + + fn restart_works() -> Result<(), String> { + Ok(()) + } + + #[test] + fn a_failed_restart_keeps_the_save_and_offers_retry() { + let d = Dir::new("restart", r#"{"version": 1}"#); + let mut rt = d.window_with(Some(restart_fails)); + press(&mut rt, "switch-history"); + press(&mut rt, "apply"); + assert_eq!(d.settings(), Some(json!({"history": true})), "saved"); + assert!(matches!(rt.state.status, Status::RestartFailed(_))); + assert!(!rt.state.dirty()); + assert!(rt.find("restart-failed").is_some()); + assert!(rt.find("error").is_none(), "not shown as a failed save"); + assert!(rt.find("applied").is_none()); + + // Retry runs the restart again: still failing, then working. + press(&mut rt, "retry"); + assert!(matches!(rt.state.status, Status::RestartFailed(_))); + rt.state.restart = Some(restart_works); + press(&mut rt, "retry"); + assert_eq!(rt.state.status, Status::Applied); + assert!(rt.find("retry").is_none()); + assert!(rt.find("applied").is_some()); + assert_eq!(d.settings(), Some(json!({"history": true}))); + + // A key the daemon does not read needs no restart. + let mut rt = d.window_with(Some(restart_fails)); + rt.update(|s, _| s.set("picker", json!("gui"))); + rt.frame(1.0 / 60.0); + press(&mut rt, "apply"); + assert_eq!(rt.state.status, Status::Applied); + } + #[test] fn existing_settings_show_and_macros_round_trip() { let d = Dir::new( From 837d0f7da253320e78640ca67dfe2b971a747713 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 21:27:17 -0800 Subject: [PATCH 10/18] linux: a visible dot wave without a Nerd Font, and restart warnings that stay The pill's wave now uses two standard dots with Pango alpha, so it renders on a stock Waybar. At rest the dots are half the size and half the brightness of a full dot, as on the iPhone, so the start of a dictation reads as listening. An agent needing approval shows a raised hand instead of the state glyph, so approval and input differ without colour. Settings: - A failed restart stays visible with Retry until a restart succeeds; edits, Discard and later saves no longer hide it. - When localflowd isn't a running user unit but answers on its socket, Apply says to restart it by hand instead of reporting success. - The speed choices use a single scale, Fastest to Slowest. - Placeholders use the secondary ink. - Primary pills are semibold. - The label of an invalid field stays on the field's line. The correction dialogs' minimum sizes come from their content, so nothing clips. --- linux/AGENT-LINK.md | 6 +- linux/CHANGELOG.md | 21 +- linux/README.md | 38 +- linux/crates/lf-daemon/src/waybar.rs | 304 ++++++++++++--- linux/crates/lf-daemon/tests/daemon.rs | 7 +- linux/gui/src/look.rs | 14 +- linux/gui/src/main.rs | 6 +- linux/gui/src/prompt.rs | 65 +++ linux/gui/src/settings.rs | 521 ++++++++++++++++++++----- 9 files changed, 807 insertions(+), 175 deletions(-) diff --git a/linux/AGENT-LINK.md b/linux/AGENT-LINK.md index c3f7997a..df799382 100644 --- a/linux/AGENT-LINK.md +++ b/linux/AGENT-LINK.md @@ -112,8 +112,10 @@ commands do not carry these fields. `watch --waybar` adds to its usual classes, glyphs and texts: - **While idle, loading or without a microphone:** - - A live report with `agent-approval` adds the class `agent-approval`; - with `agent-input` (and no approval), `agent-input`. + - A live report with `agent-approval` adds the class `agent-approval` + and shows a raised hand (Font Awesome U+F256) in place of the state's + glyph; with `agent-input` (and no approval), `agent-input`, and the + glyph stays. - Pending suggestions add `suggestion`. - With extra classes, `class` is an array: the state's class first, then `agent-approval` or `agent-input`, then `suggestion`. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 8962e9bf..0c0c10b6 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,19 +18,26 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. when no menu tool (rofi, fuzzel, wofi, tofi) is installed. Escape closes the settings window, and launching it again brings the open one forward. The windows have the iPhone app's dark look. Settings not applied yet - show a dot, an invalid value is explained under its field, and if the - daemon does not restart after **Apply** the window says the settings - were saved and offers **Retry**. + show a dot, and an invalid value is explained under its field. If the + daemon does not restart after **Apply**, the window says the settings + were saved and keeps saying so, with **Retry**, until a restart works; a + daemon started outside its systemd user unit is not silently skipped, + the window asks you to restart it. **Speed** names `precision` from + Fastest to Slowest. The dialogs' smallest size fits every choice. ### Changed - The Waybar indicator (`localflowctl watch --waybar`) looks like the iPhone app's: a wave of round dots while recording instead of bars, and every state six characters wide, so the module and the clock beside it - no longer move. Each state has its own glyph: a clock while the model - loads, the hourglass while transcribing, a keyboard while typing, the - microphone with a slash without a microphone, and power-off when - LocalFlow isn’t running. A prompt dictation shows `›` before the wave. + no longer move. The wave's dots grow and brighten with your voice and + rest as a row of small dim dots in silence; they are standard characters + with Pango alpha markup, so the wave needs no Nerd Font (keep the + module's `escape` off). Each state has its own glyph: a clock while the + model loads, the hourglass while transcribing, a keyboard while typing, + the microphone with a slash without a microphone, power-off when + LocalFlow isn’t running, and a raised hand while an agent needs your + approval. A prompt dictation shows `›` before the wave. Tooltips use plain words ("LocalFlow: ready"). The README's CSS uses the iPhone app's colours and adds `min-length` and `align` to the module. - The recommended model is now diff --git a/linux/README.md b/linux/README.md index cfb9f49c..e5105234 100644 --- a/linux/README.md +++ b/linux/README.md @@ -160,7 +160,10 @@ invalid values stop the daemon with an error. Defaults: less time per utterance (LibriSpeech 15.7 vs 23.7 ms on average). On kea-t, i8x2 matched f32's WER over 32,560 dev/test utterances (six near-tie transcript changes) and is 25-28% faster than i8x3, which tracks - f32 to rounding. To + f32 to rounding. `f32` is the slowest: `lf-gemm-bench` on synthetic + binary weights (8 threads of a 9950X3D, 3 s of audio) timed the encoder + projections at 5.5 ms for `i8x1`, 10.0 for `i8x2`, 14.6 for `i8x3` and + 21.0 for `f32`. To re-check a new export, save hypotheses at each precision with `lf-transcribe --save-hyps` and compare them with `lf-hypcompare` (both in `lf-bench`). @@ -244,11 +247,15 @@ is a settings window and the correction dialogs: which writes them to `settings.json` beside `config.json` and restarts the daemon (`systemctl --user try-restart localflowd`) when a key it reads changed; a cyan dot marks each setting not applied yet. If the restart - fails, the settings are still saved and **Retry** tries it again. A reset + fails, the settings are still saved, and the window says so, with + **Retry**, until a restart works (a later **Apply** restarts too). A + daemon that answers but doesn't run as the `localflowd` user unit (one + started by `exec-once`, say) is left running, and the window asks you to + restart it; with no daemon running, saving is enough. A reset icon next to a setting takes it back to `config.json`. A text field with an invalid value is outlined, with what to fix under it. **Speed** is - `precision`: Auto (the model's own), Fastest (`i8x1`), Fast (`i8x2`), - Precise (`i8x3`) and Full precision (`f32`). **Type with** is `output`: + `precision`, from the fastest: Auto (the model's own), Fastest (`i8x1`), + Fast (`i8x2`), Slow (`i8x3`) and Slowest (`f32`). **Type with** is `output`: Wayland, Desktop portal (`portal`) or Kernel device (`uinput`). Escape closes it, asking first when there are changes not applied. One settings window runs per user: launching it again brings that window @@ -361,7 +368,7 @@ never move. Classes (also the `alt`) and what each shows: |---|---|---| | `idle` | microphone | LocalFlow: ready | | `loading` | clock (the model is loading) | LocalFlow: loading the model | -| `recording` | a wave of dots, newest on the right; small equal dots in silence | LocalFlow: listening | +| `recording` | a wave of dots, newest on the right, growing and brightening with your voice; a row of small dim dots in silence | LocalFlow: listening | | `transcribing` | hourglass | LocalFlow: transcribing | | `typing` | keyboard | LocalFlow: typing | | `nomic` | microphone with a slash (idle with no microphone) | LocalFlow: no microphone | @@ -370,10 +377,20 @@ never move. Classes (also the `alt`) and what each shows: A prompt dictation (double-tap and hold; `tag=prompt` in `watch` lines) puts `›` in the first character and adds the class `prompt` while it records, transcribes and types (`"class": ["recording", "prompt"]`, tooltip -"LocalFlow: listening (prompt dictation)"). It keeps retrying every 2 s -while the daemon is down, so Waybar never needs restarting. Override the -glyphs with `format-icons` keyed by `alt` if you like. `min-length` and -`align` keep the module's width with a proportional font too. +"LocalFlow: listening (prompt dictation)"). While an agent needs your +approval (see [Coding agents](#coding-agents)), a raised hand takes the +glyph's place. It keeps retrying every 2 s while the daemon is down, so +Waybar never needs restarting. Override the glyphs with `format-icons` +keyed by `alt` if you like. `min-length` and `align` keep the module's +width with a proportional font too. + +The wave's dots are `•` and `●` (U+2022, U+25CF), which common fonts such +as DejaVu have, and the glyphs are Font Awesome's, which stock Waybar's +style asks for, so with Font Awesome installed no Nerd Font is needed (a +proportional font does let the module's width vary). Dimmer dots are +Pango markup (`•`), which Waybar reads in custom +modules by default; leave the module's `escape` off, or the tags show as +text. ```jsonc "custom/localflow": { @@ -555,7 +572,8 @@ localflowctl agents t3 # T3 Code bridge (runs unti suggestions with a click. - The CSS under [Waybar indicator](#waybar-indicator) styles the agent classes: `agent-approval` in ember, `agent-input` in glacier. The number - of agents needing you shows beside the microphone. + of agents needing you shows beside the microphone, or beside a raised + hand while one needs approval. ## Text output and desktops diff --git a/linux/crates/lf-daemon/src/waybar.rs b/linux/crates/lf-daemon/src/waybar.rs index d2d5edcb..7ebb6dc3 100644 --- a/linux/crates/lf-daemon/src/waybar.rs +++ b/linux/crates/lf-daemon/src/waybar.rs @@ -6,12 +6,15 @@ //! ``` //! //! Classes (and alts): idle, loading, recording, transcribing, typing, -//! nomic, offline. The text is always [`WIDTH`] characters, each one cell -//! of a monospace font, so the module and the clock beside it never move -//! between states: +//! nomic, offline. The text always shows [`WIDTH`] characters, each one +//! cell of a monospace font, so the module and the clock beside it never +//! move between states: //! //! - Recording shows the last input levels as a wave of round dots, newest -//! on the right; silence is a row of equal small dots. +//! on the right; silence is a row of small dim dots. The wave is Pango +//! markup (Waybar's default for custom modules): a dot dimmer than full +//! is `•`. Its dots are standard characters +//! (U+2022, U+25CF), so it needs no Nerd Font. //! - Every other state shows its glyph in the third cell ([`GLYPH_CELL`]). //! Glyphs are Font Awesome icons as Nerd Fonts carry them, drawn wider //! than one cell, so the glyph sits about in the middle; a bar config can @@ -21,11 +24,15 @@ //! classes, e.g. `"class": ["recording", "prompt"]`, the state's alt, and //! a tooltip such as "LocalFlow: listening (prompt dictation)". //! +//! Texts and tooltips hold nothing but fixed words, glyphs, digits and +//! those spans: nothing in them ever needs escaping. +//! //! The agent link's counts on watch lines (see `crate::protocol`): //! //! - While idle, loading or without a microphone, an agent needing approval -//! adds the class `agent-approval`, else one waiting for input -//! `agent-input`, and pending suggestions `suggestion` (in that order, +//! adds the class `agent-approval` and shows [`APPROVAL_GLYPH`] in place +//! of the state's glyph; else one waiting for input adds `agent-input` +//! (the glyph stays); pending suggestions add `suggestion` (in that order, //! after the state's class: `class` is then an array). The number of //! agents needing approval or input, when any, goes in the last two cells //! (at most 99). @@ -48,6 +55,8 @@ pub const TYPING_GLYPH: &str = "\u{f11c}"; pub const NO_MIC_GLYPH: &str = "\u{f131}"; /// nf-fa-power_off: the daemon is not running. pub const OFFLINE_GLYPH: &str = "\u{f011}"; +/// nf-fa-hand_paper_o (Font Awesome's raised hand): an agent needs you. +pub const APPROVAL_GLYPH: &str = "\u{f256}"; /// The first cell of a prompt dictation's text. pub const PROMPT_MARKER: char = '\u{203a}'; @@ -58,10 +67,26 @@ pub const GLYPH_CELL: usize = 2; /// Levels kept while recording: a full row of dots. pub const HISTORY: usize = WIDTH; -/// The wave's dots, smallest (silence) to largest, each centred in its cell -/// and narrower than it, so neighbours never touch: nf-md-circle_small, -/// U+2022 bullet, nf-md-circle_medium, nf-cod-circle_filled. -pub const DOTS: [char; 4] = ['\u{f09df}', '\u{2022}', '\u{f09de}', '\u{ea71}']; +/// The wave's small dot, U+2022 bullet: in JetBrainsMono about 0.43 of a +/// cell across, as a resting dot on the iPhone is about half a full one. +pub const SMALL_DOT: char = '\u{2022}'; +/// The wave's full dot, U+25CF black circle: about a cell across, so full +/// neighbours just touch, as at the top of the iPhone's swell. +pub const FULL_DOT: char = '\u{25cf}'; + +/// The wave's steps, quietest first: a dot and its alpha in percent. As the +/// iPhone's DotWave, a dot at rest is half a full dot's size and brightness, +/// and louder dots grow and brighten together. Monospace fonts such as +/// JetBrainsMono and DejaVu Sans Mono draw both dots one cell wide, with no +/// round dot between their sizes (`·` is smaller than a resting one), so +/// alpha carries the steps between. +pub const STEPS: [(char, u8); 5] = [ + (SMALL_DOT, 50), + (SMALL_DOT, 75), + (SMALL_DOT, 100), + (FULL_DOT, 80), + (FULL_DOT, 100), +]; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Class { @@ -149,14 +174,35 @@ impl Class { } } -/// Levels up to this (in 0..=100) are room noise: silence's dot. +/// Levels up to this (in 0..=100) are room noise: a dot at rest. const NOISE: f32 = 4.0; -/// One dot for a level of 0..=100: silence up to [`NOISE`], then a -/// square-root curve so quiet speech still shows. -pub fn dot(level: u8) -> char { +/// The [`STEPS`] index for a level of 0..=100: at rest up to [`NOISE`], +/// then a square-root curve so quiet speech still shows. +pub fn step(level: u8) -> usize { let v = ((f32::from(level.min(100)) - NOISE).max(0.0) / (100.0 - NOISE)).sqrt(); - DOTS[(v * (DOTS.len() - 1) as f32).round() as usize] + (v * (STEPS.len() - 1) as f32).round() as usize +} + +/// One cell of the wave for a level of 0..=100, as Pango markup: the +/// step's dot, in a span with its alpha unless that is 100%. +pub fn dot(level: u8) -> String { + match STEPS[step(level)] { + (glyph, 100) => glyph.to_string(), + (glyph, alpha) => format!("{glyph}"), + } +} + +/// A text as Waybar shows it: without the wave's markup. +pub fn plain(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find('<') { + out.push_str(&rest[..i]); + rest = rest[i..].split_once('>').map_or("", |(_, after)| after); + } + out.push_str(rest); + out } /// A state's [`WIDTH`] cells: the prompt marker or a space, the glyph in @@ -326,9 +372,11 @@ impl Indicator { tooltip += " (prompt dictation)"; } let mut waiting = 0; + let mut glyph = class.glyph(); if !busy { if n.approval > 0 { classes.push(AGENT_APPROVAL_CLASS); + glyph = APPROVAL_GLYPH; } else if n.input > 0 { classes.push(AGENT_INPUT_CLASS); } @@ -340,7 +388,7 @@ impl Indicator { let text = match class { Class::Recording if prompt => format!("{PROMPT_MARKER}{}", self.wave(WIDTH - 1)), Class::Recording => self.wave(WIDTH), - _ => field(prompt, class.glyph(), waiting), + _ => field(prompt, glyph, waiting), }; for line in n.tooltip_lines() { tooltip.push('\n'); @@ -356,16 +404,12 @@ impl Indicator { } /// The last `cells` levels as dots, newest on the right, padded with - /// silence. + /// dots at rest. fn wave(&self, cells: usize) -> String { let shown = self.levels.len().min(cells); - std::iter::repeat_n(dot(0), cells - shown) - .chain( - self.levels - .iter() - .skip(self.levels.len() - shown) - .map(|&l| dot(l)), - ) + std::iter::repeat_n(0, cells - shown) + .chain(self.levels.iter().skip(self.levels.len() - shown).copied()) + .map(dot) .collect() } } @@ -387,8 +431,6 @@ mod tests { levels.iter().map(|&l| dot(l)).collect() } - const SILENCE: char = DOTS[0]; - #[test] fn every_class_formats_as_waybar_json() { let mut ind = Indicator::default(); @@ -401,10 +443,6 @@ mod tests { "state=idle model=loading mic=unknown", "{\"text\": \" \u{f017} \", \"alt\": \"loading\", \"class\": \"loading\", \"tooltip\": \"LocalFlow: loading the model\"}", ), - ( - "state=recording mode=hold model=ready mic=present level=100", - "{\"text\": \"\u{f09df}\u{f09df}\u{f09df}\u{f09df}\u{f09df}\u{ea71}\", \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\"}", - ), ( "state=transcribing model=ready mic=present", "{\"text\": \" \u{f252} \", \"alt\": \"transcribing\", \"class\": \"transcribing\", \"tooltip\": \"LocalFlow: transcribing\"}", @@ -418,24 +456,35 @@ mod tests { "{\"text\": \" \u{f131} \", \"alt\": \"nomic\", \"class\": \"nomic\", \"tooltip\": \"LocalFlow: no microphone\"}", ), ]; - let mut seen = Vec::new(); + let mut seen: Vec = Vec::new(); for (fields, want) in cases { assert_eq!(ind.update(fields).as_deref(), Some(want), "{fields}"); - seen.push(want); + seen.push(want.into()); } + let recording = ind + .update("state=recording mode=hold model=ready mic=present level=100") + .unwrap(); + assert_eq!( + recording, + format!( + "{{\"text\": {}, \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\"}}", + json_string(&dots(&[0, 0, 0, 0, 0, 100])) + ) + ); + seen.push(recording); let offline = ind.offline(); assert_eq!( offline, "{\"text\": \" \u{f011} \", \"alt\": \"offline\", \"class\": \"offline\", \"tooltip\": \"LocalFlow isn\u{2019}t running\"}" ); - seen.push(&offline); + seen.push(offline); // Every class appears, and every object is valid JSON with the four // fields. for class in Class::ALL { let tag = format!("\"class\": \"{}\"", class.name()); assert!(seen.iter().any(|o| o.contains(&tag)), "{tag}"); } - for o in seen { + for o in &seen { let v = json(o); assert_eq!(v["alt"], v["class"]); assert!(v["text"].as_str().is_some_and(|t| !t.trim().is_empty())); @@ -455,13 +504,15 @@ mod tests { TYPING_GLYPH, NO_MIC_GLYPH, OFFLINE_GLYPH, + APPROVAL_GLYPH, ]; for (i, a) in glyphs.iter().enumerate() { assert_eq!(a.chars().count(), 1); for b in &glyphs[i + 1..] { assert_ne!(a, b); } - assert!(!DOTS.contains(&a.chars().next().unwrap())); + let c = a.chars().next().unwrap(); + assert!(STEPS.iter().all(|&(d, _)| d != c)); assert_ne!(a.chars().next(), Some(PROMPT_MARKER)); } } @@ -491,14 +542,15 @@ mod tests { .collect(); texts.push(text(&ind.offline())); for t in &texts { - assert_eq!(t.chars().count(), WIDTH, "{t:?}"); - let glyph = t.chars().nth(GLYPH_CELL).unwrap(); + let shown = plain(t); + assert_eq!(shown.chars().count(), WIDTH, "{t:?}"); + let glyph = shown.chars().nth(GLYPH_CELL).unwrap(); assert_ne!(glyph, ' ', "{t:?}"); } // The count stays in the last two cells, after a space. - assert_eq!(texts[11], format!(" {MIC_GLYPH} 1 ")); + assert_eq!(texts[11], format!(" {APPROVAL_GLYPH} 1 ")); assert_eq!(texts[12], format!(" {MIC_GLYPH} 12")); - assert_eq!(texts[13], format!(" {NO_MIC_GLYPH} 99")); + assert_eq!(texts[13], format!(" {APPROVAL_GLYPH} 99")); } #[test] @@ -546,11 +598,11 @@ mod tests { #[test] fn agent_link_counts_add_classes_a_count_and_tooltip_lines() { let mut ind = Indicator::default(); - let loud = format!("{SILENCE}{SILENCE}{SILENCE}{SILENCE}{SILENCE}\u{ea71}"); + let loud = json_string(&dots(&[0, 0, 0, 0, 0, 100])); let cases = [ ( "state=idle model=ready mic=present suggestions=3 words=12 agent-approval=2 agent-input=1 agent-working=1 agent-done=4".to_owned(), - "{\"text\": \" \u{f130} 3 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\", \"suggestion\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 2 need approval, 1 waiting for input, 1 working, 4 done\\n3 dictionary suggestions from agents (localflowctl dict review)\\n12 project words boosted\"}".to_owned(), + "{\"text\": \" \u{f256} 3 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\", \"suggestion\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 2 need approval, 1 waiting for input, 1 working, 4 done\\n3 dictionary suggestions from agents (localflowctl dict review)\\n12 project words boosted\"}".to_owned(), ), ( "state=idle model=loading mic=unknown agent-input=1".into(), @@ -562,7 +614,7 @@ mod tests { ), ( "state=idle model=ready mic=present agent-approval=1".into(), - "{\"text\": \" \u{f130} 1 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 1 needs approval\"}".into(), + "{\"text\": \" \u{f256} 1 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 1 needs approval\"}".into(), ), // Working and done alone add no class and no count. ( @@ -572,7 +624,7 @@ mod tests { // Busy states keep their classes and text; only the tooltip grows. ( "state=recording mode=hold model=ready mic=present level=100 agent-approval=1 suggestions=2".into(), - format!("{{\"text\": \"{loud}\", \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\\nAgents: 1 needs approval\\n2 dictionary suggestions from agents (localflowctl dict review)\"}}"), + format!("{{\"text\": {loud}, \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\\nAgents: 1 needs approval\\n2 dictionary suggestions from agents (localflowctl dict review)\"}}"), ), ( "state=transcribing model=ready mic=present agent-input=3 words=2".into(), @@ -622,17 +674,19 @@ mod tests { #[test] fn the_dot_wave_scrolls_and_restarts_per_recording() { - assert_eq!(dot(0), SILENCE); - assert_eq!(dot(100), DOTS[3]); - assert_eq!(dot(255), DOTS[3]); - // Room noise stays a row of silence; the square-root curve lifts - // quiet speech: 10% is already a dot up, 30% two. - assert_eq!(dot(4), SILENCE); - assert_eq!(dot(10), DOTS[1]); - assert_eq!(dot(30), DOTS[2]); - // Louder is never smaller. - assert!((0..100).all(|l| DOTS.iter().position(|&d| d == dot(l)) - <= DOTS.iter().position(|&d| d == dot(l + 1)))); + let last = STEPS.len() - 1; + assert_eq!(step(0), 0); + assert_eq!(step(100), last); + assert_eq!(step(255), last); + // Room noise stays at rest; the square-root curve lifts quiet + // speech: 10% is already a step up, 30% two, and half-loud speech + // reaches the full dot. + assert_eq!(step(4), 0); + assert_eq!(step(10), 1); + assert_eq!(step(30), 2); + assert_eq!(STEPS[step(50)].0, FULL_DOT); + // Louder is never smaller or dimmer. + assert!((0..100).all(|l| step(l) <= step(l + 1))); let mut ind = Indicator::default(); let rec = |l: u8| format!("state=recording mode=toggle model=ready mic=present level={l}"); // A recording starts as silence: a row of equal small dots. @@ -641,7 +695,7 @@ mod tests { &ind.update("state=recording mode=toggle model=ready mic=present") .unwrap() ), - SILENCE.to_string().repeat(WIDTH) + dot(0).repeat(WIDTH) ); assert_eq!( text(&ind.update(&rec(100)).unwrap()), @@ -680,6 +734,146 @@ mod tests { assert_eq!(text(&ind.update(&rec(0)).unwrap()), dots(&[0; WIDTH])); } + #[test] + fn the_wave_rests_and_swells_as_the_iphones_does() { + // At rest: the small dot at half brightness; at the top of the + // swell: the full dot at full brightness. + assert_eq!(STEPS[0], (SMALL_DOT, 50)); + assert_eq!(STEPS[STEPS.len() - 1], (FULL_DOT, 100)); + // Dots grow and brighten together, step by step. + let size = |c: char| usize::from(c == FULL_DOT); + for w in STEPS.windows(2) { + let ((a, x), (b, y)) = (w[0], w[1]); + assert!(size(a) <= size(b), "{w:?}"); + assert!(size(a) < size(b) || x < y, "{w:?}"); + assert!(x <= 100 && y <= 100); + } + // Standard characters only: no Nerd Font needed. + for (c, _) in STEPS { + let c = c as u32; + assert!(!(0xe000..=0xf8ff).contains(&c) && c < 0xf0000); + } + assert_eq!(dot(0), "\u{2022}"); + assert_eq!(dot(100), "\u{25cf}"); + + // Silence is a calm row of equal resting dots; speech undulates, + // in size as well as brightness. + let mut ind = Indicator::default(); + let rec = |l: u8| format!("state=recording mode=hold model=ready mic=present level={l}"); + for l in [0, 3, 2, 4, 1, 0] { + ind.update(&rec(l)); + } + assert_eq!(text(&ind.update(&rec(0)).unwrap()), dot(0).repeat(WIDTH)); + let speech = [65, 40, 85, 50, 20, 55]; + let mut wave = String::new(); + for l in speech { + wave = text(&ind.update(&rec(l)).unwrap()); + } + assert_eq!(wave, dots(&speech)); + let shown = plain(&wave); + assert!( + shown.contains(SMALL_DOT) && shown.contains(FULL_DOT), + "{wave}" + ); + let mut steps = speech.map(step).to_vec(); + steps.sort(); + steps.dedup(); + assert!(steps.len() >= 3, "{steps:?}"); + } + + #[test] + fn nothing_in_a_text_or_tooltip_needs_escaping() { + // Every text and tooltip, across states, counts and levels. + let mut ind = Indicator::default(); + let mut objects = vec![ind.offline()]; + for state in ["idle", "recording", "transcribing", "typing", "exploding"] { + for extra in [ + "", + " tag=prompt", + " mic=absent", + " model=loading", + " agent-approval=3 agent-input=200 agent-working=1 agent-done=7 suggestions=1 words=40", + " agent-input=1 suggestions=12 words=1", + ] { + for level in [0, 1, 4, 5, 9, 10, 29, 30, 49, 50, 77, 99, 100, 101, 255] { + let line = format!( + "state={state} mode=hold model=ready mic=present level={level}{extra}" + ); + objects.extend(ind.update(&line)); + } + } + } + assert!(objects.len() > 300); + for o in &objects { + let v = json(o); + let (t, tip) = (v["text"].as_str().unwrap(), v["tooltip"].as_str().unwrap()); + // The only markup: alpha spans, each around one dot. + let mut rest = t; + while let Some(i) = rest.find('<') { + let (head, tail) = rest[i..].split_once("%\">").expect(t); + let alpha = head.strip_prefix("().is_ok_and(|a| a < 100), "{t:?}"); + let mut chars = tail.chars(); + let dot = chars.next(); + assert!(STEPS.iter().any(|&(d, _)| Some(d) == dot), "{t:?}"); + rest = chars.as_str().strip_prefix("").expect(t); + } + let shown = plain(t); + assert_eq!(shown.chars().count(), WIDTH, "{t:?}"); + for s in [shown.as_str(), tip] { + assert!(!s.contains(['<', '>', '&']), "{s:?}"); + } + } + } + + #[test] + fn an_agent_needing_approval_shows_the_hand_instead_of_the_state_glyph() { + let mut ind = Indicator::default(); + let cases = [ + ( + "idle model=ready mic=present agent-approval=1", + APPROVAL_GLYPH, + " 1 ", + ), + ( + "idle model=ready mic=present agent-approval=2 agent-input=3", + APPROVAL_GLYPH, + " 5 ", + ), + ( + "idle model=loading mic=present agent-approval=1", + APPROVAL_GLYPH, + " 1 ", + ), + ( + "idle model=ready mic=absent agent-approval=1", + APPROVAL_GLYPH, + " 1 ", + ), + // Waiting for input only: the state's glyph and the count. + ( + "idle model=ready mic=present agent-input=2", + MIC_GLYPH, + " 2 ", + ), + ( + "idle model=loading mic=present agent-input=1", + LOADING_GLYPH, + " 1 ", + ), + // Busy states keep their own glyph. + ( + "transcribing model=ready mic=present agent-approval=1", + TRANSCRIBING_GLYPH, + " ", + ), + ]; + for (fields, glyph, count) in cases { + let t = text(&ind.update(&format!("state={fields}")).unwrap()); + assert_eq!(t, format!(" {glyph}{count}"), "{fields}"); + } + } + #[test] fn json_strings_are_escaped() { assert_eq!(json_string("a\"b\\c"), "\"a\\\"b\\\\c\""); diff --git a/linux/crates/lf-daemon/tests/daemon.rs b/linux/crates/lf-daemon/tests/daemon.rs index 6248390e..31398b2e 100644 --- a/linux/crates/lf-daemon/tests/daemon.rs +++ b/linux/crates/lf-daemon/tests/daemon.rs @@ -1399,8 +1399,11 @@ fn localflowctl_waybar_shows_states_and_survives_daemon_restarts() { let v = object(&lines); assert_eq!(v["class"], "recording"); let wave = v["text"].as_str().unwrap(); - assert_eq!(wave.chars().count(), lf_daemon::waybar::WIDTH); - assert!(wave.ends_with(lf_daemon::waybar::dot(50)), "{wave}"); + assert_eq!( + lf_daemon::waybar::plain(wave).chars().count(), + lf_daemon::waybar::WIDTH + ); + assert!(wave.ends_with(&lf_daemon::waybar::dot(50)), "{wave}"); assert!(ctl(&rd, &["cancel"]).status.success()); // Skip level lines still queued. while class(&lines) != "idle" {} diff --git a/linux/gui/src/look.rs b/linux/gui/src/look.rs index 399296fe..50aca300 100644 --- a/linux/gui/src/look.rs +++ b/linux/gui/src/look.rs @@ -92,15 +92,25 @@ pub fn segments(k: &Kit) -> Kit { s } +/// `k` for text fields: placeholders in secondary ink, not faint, since +/// they carry meaning (the microphone's "Default"). +pub fn fields(k: &Kit) -> Kit { + let mut f = k.clone(); + f.c.text_faint = k.c.text_muted; + f +} + /// A pill's height, as a large push button. const PILL_HEIGHT: f32 = 28.0; /// The main action: the iPhone's near-black pill with a hairline edge and -/// white ink. +/// white semibold ink (`PillLabel`). pub fn primary(k: &Kit, b: Button) -> El { + let mut k = k.clone(); + k.ty.ui_medium.weight = 600.0; b.variant(ButtonVariant::Contrast) .control_size(ControlSize::Large) - .build(k) + .build(&k) .radius(PILL_HEIGHT / 2.0) .px(16.0) .gradient(Color::TRANSPARENT, Color::TRANSPARENT) diff --git a/linux/gui/src/main.rs b/linux/gui/src/main.rs index 1f361fd5..099fa540 100644 --- a/linux/gui/src/main.rs +++ b/linux/gui/src/main.rs @@ -83,7 +83,7 @@ fn run_edit() -> i32 { prompt::Edit::new(INPUT.get().map_or("", String::as_str)) } rune::run(AppSpec:: { - min_size: Some((360.0, 120.0)), + min_size: Some(prompt::edit_min_size()), on_key: Some(prompt::edit_key), ..AppSpec::new( "Fix dictation", @@ -117,7 +117,7 @@ fn run_choose() -> i32 { } let rows = INPUT.get().map_or(0, |s| prompt::parse_rows(s).len()); rune::run(AppSpec:: { - min_size: Some((320.0, 120.0)), + min_size: Some(prompt::choose_min_size(rows)), on_key: Some(prompt::choose_key), ..AppSpec::new( "LocalFlow", @@ -140,7 +140,7 @@ fn run_settings() -> i32 { let config = lf_daemon::paths::config_file()?; let data = lf_daemon::paths::data_dir()?; let layers = lf_daemon::config::Layers::load(&config, &data)?; - settings::Settings::new(config, data, layers, Some(settings::restart_daemon)) + settings::Settings::new(config, data, layers, Some(settings::Daemon::SYSTEM)) })(); match made { Ok(s) => *SETTINGS.lock().unwrap() = Some(s), diff --git a/linux/gui/src/prompt.rs b/linux/gui/src/prompt.rs index 9c639d98..b92ae769 100644 --- a/linux/gui/src/prompt.rs +++ b/linux/gui/src/prompt.rs @@ -99,6 +99,7 @@ pub fn edit_view(s: &Edit, _cx: &mut Cx) -> El { _ => false, }) .build(&boxed) + .key("correction") // A longer text than the window was sized for still fits: the box // takes the room, and the buttons stay at the bottom. .grow(1.0) @@ -202,6 +203,17 @@ pub fn choose_height(rows: usize) -> f32 { PAD * 2.0 + MARK + space::S3 + rows as f32 * BUTTON + rows.saturating_sub(1) as f32 * space::S2 } +/// The edit window's smallest size: two lines of text and the buttons. +pub fn edit_min_size() -> (f32, f32) { + (360.0, edit_height("")) +} + +/// The choose window's smallest size for `rows` buttons: the mark and every +/// row, unsquashed. +pub fn choose_min_size(rows: usize) -> (f32, f32) { + (320.0, choose_height(rows)) +} + /// The choose dialog's keys: Escape dismisses; Up, Down and Tab move the /// highlight; Enter or Space choose the highlighted row; 1-9 choose that /// row. @@ -424,6 +436,59 @@ mod tests { assert_eq!(rt.state.outcome, Some(Outcome::Dismissed)); } + /// `key`'s drawn rect lies inside the window's margins. + fn inside_margins(rt: &Runtime, key: &str, (w, h): (f32, f32)) -> [f32; 4] { + let ([x, y, bw, bh], _) = rt.presented(key).unwrap_or_else(|| panic!("no {key:?}")); + assert!( + x >= PAD - 0.5 && y >= PAD - 0.5 && x + bw <= w - PAD + 0.5 && y + bh <= h - PAD + 0.5, + "{key} at ({x}, {y}) {bw}x{bh} in a {w}x{h} window" + ); + [x, y, bw, bh] + } + + #[test] + fn the_choose_dialog_fits_the_mark_and_every_row_at_its_minimum_size() { + let labels = [ + "Add \u{201C}Zorblax\u{201D} to your dictionary", + "Not now", + "Don\u{2019}t suggest \u{201C}Zorblax\u{201D}", + "Synthetic fourth row", + "Synthetic fifth row", + ]; + for n in 1..=labels.len() { + let rows: Vec = labels[..n].iter().map(|l| l.to_string()).collect(); + let size = choose_min_size(n); + let mut rt = Runtime::new(Choose::new(rows), choose_view, size, Env::new(true, 1.0)); + rt.frame(0.0); + inside_margins(&rt, "mark", size); + for i in 0..n { + let [_, _, _, h] = inside_margins(&rt, &format!("choice-{i}"), size); + assert!( + (h - BUTTON).abs() < 0.5, + "row {i} of {n} squashed to {h} pt" + ); + } + } + } + + #[test] + fn the_edit_dialog_fits_two_lines_and_its_buttons_at_its_minimum_size() { + let size = edit_min_size(); + for text in [ + "synthetic", + "Synthetic dictation about a cluster, its pods and the plan for tomorrow morning, \ + long enough to wrap onto a second and a third line of the box.", + ] { + let mut rt = Runtime::new(Edit::new(text), edit_view, size, Env::new(true, 1.0)); + rt.frame(0.0); + for key in ["mark", "cancel", "fix"] { + inside_margins(&rt, key, size); + } + let [_, _, _, h] = inside_margins(&rt, "correction", size); + assert!(h >= 16.0 + 2.0 * 17.5 - 0.5, "the box is {h} pt tall"); + } + } + #[test] fn choosing_a_row_answers_with_its_number() { let rows = parse_rows("Add \u{201C}Zorblax\u{201D}\nNot now\n\nDon\u{2019}t suggest\n"); diff --git a/linux/gui/src/settings.rs b/linux/gui/src/settings.rs index ccbe71bd..d56c9450 100644 --- a/linux/gui/src/settings.rs +++ b/linux/gui/src/settings.rs @@ -10,8 +10,11 @@ //! takes it back to `config.json`'s value (or the default). //! - A text field whose value makes an invalid configuration is marked, with //! a few plain words under it (never the value), and Apply stays disabled. -//! - If Apply saved the settings but could not restart the daemon, the -//! footer says so and offers to try the restart again. +//! - If Apply saved the settings but the daemon did not restart, the footer +//! says so until a restart works: with Retry when the restart failed, or +//! asking for a restart by hand when the daemon runs outside its user +//! unit (which `try-restart` would silently skip). Later changes keep the +//! warning, and their Apply restarts the daemon whatever the keys. //! - Clicking anywhere on a switch's row flips the switch. //! - Escape closes the window. With changes not yet applied it asks first; //! a second Escape closes without applying them. @@ -55,8 +58,60 @@ static CHECK: Icon = Icon { body: r#""#, }; -/// Restarts the daemon after Apply; `None` in tests. -pub type Restart = Option Result<(), String>>; +/// How Apply reaches the daemon: [`Daemon::SYSTEM`] in the app, stand-ins in +/// tests. +#[derive(Clone, Copy)] +pub struct Daemon { + /// Whether it runs as the active user unit. + pub unit_active: fn() -> bool, + /// Restarts the user unit. + pub restart_unit: fn() -> Result<(), String>, + /// Whether a daemon answers on the control socket. + pub answers: fn() -> bool, +} + +/// What restarting the daemon over saved settings did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Restarted { + /// It restarted, or none runs (it reads the settings when it starts). + Done, + /// It runs outside its user unit, so only its user can restart it. + Manual, + /// The restart failed (why). + Failed(String), +} + +impl Daemon { + pub const SYSTEM: Daemon = Daemon { + unit_active, + restart_unit, + answers, + }; + + /// Restarts the daemon if it runs as the user unit. `try-restart` alone + /// would succeed and do nothing for one started some other way. + pub fn restart(&self) -> Restarted { + if (self.unit_active)() { + match (self.restart_unit)() { + Ok(()) => Restarted::Done, + Err(e) => Restarted::Failed(e), + } + } else if (self.answers)() { + Restarted::Manual + } else { + Restarted::Done + } + } +} + +/// Saved settings the daemon does not run yet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Pending { + /// The restart failed (why, for the footer's tooltip). + Failed(String), + /// The daemon runs outside its user unit. + Manual, +} pub struct Settings { pub config_path: PathBuf, @@ -73,8 +128,12 @@ pub struct Settings { pub input_device: TextEdit, pub macros: Vec<(TextEdit, TextEdit)>, pub open: Option<&'static str>, + /// What the last Apply did. pub status: Status, - pub restart: Restart, + /// A restart the saved settings still need, kept until one works. + pub pending: Option, + /// `None`: no daemon to restart. + pub daemon: Option, /// Escape was pressed with changes not applied: the footer asks. pub asking_to_close: bool, /// The window quit. @@ -84,10 +143,8 @@ pub struct Settings { #[derive(Clone, Debug, PartialEq, Eq)] pub enum Status { Idle, + /// Saved (and restarted, unless [`Settings::pending`] says otherwise). Applied, - /// The settings were saved, but the daemon did not restart (why, for - /// the footer's tooltip). - RestartFailed(String), /// Nothing was saved. Failed(String), } @@ -114,7 +171,7 @@ impl Settings { config_path: PathBuf, data_dir: PathBuf, layers: Layers, - restart: Restart, + daemon: Option, ) -> Result { let config = layers.config(&data_dir)?; let mut s = Settings { @@ -129,7 +186,8 @@ impl Settings { macros: Vec::new(), open: None, status: Status::Idle, - restart, + pending: None, + daemon, asking_to_close: false, closed: false, }; @@ -214,7 +272,8 @@ impl Settings { tx.quit(); } - /// Writes the draft and restarts the daemon if a key it reads changed. + /// Writes the draft and restarts the daemon if a key it reads changed, + /// or if earlier saved settings still wait for a restart. pub fn apply(&mut self) { self.asking_to_close = false; let changed: Vec<&String> = self @@ -236,7 +295,7 @@ impl Settings { self.shown = config.clone(); self.config = Ok(config); self.status = Status::Applied; - if daemon_reads { + if daemon_reads || self.pending.is_some() { self.restart_daemon(); } } @@ -244,12 +303,14 @@ impl Settings { } } - /// Restarts the daemon over saved settings: applied, or saved without - /// the restart. Also the restart-failed footer's Retry. + /// Restarts the daemon over saved settings; until that works, the + /// restart stays pending. Also the restart-failed footer's Retry. pub fn restart_daemon(&mut self) { - self.status = match self.restart.map_or(Ok(()), |restart| restart()) { - Ok(()) => Status::Applied, - Err(e) => Status::RestartFailed(e), + self.status = Status::Applied; + self.pending = match self.daemon.map_or(Restarted::Done, |d| d.restart()) { + Restarted::Done => None, + Restarted::Manual => Some(Pending::Manual), + Restarted::Failed(e) => Some(Pending::Failed(e)), }; } @@ -329,25 +390,68 @@ pub fn key(s: &mut Settings, e: &KeyEvent, tx: &mut Tx) -> bool { false } -/// `systemctl --user try-restart localflowd.service`: restarts the daemon -/// if it runs as the usual user unit. It returns in about 20 ms (the daemon -/// stops at once; systemd does not wait for the model to load), so Apply -/// needs no "applying" state. -pub fn restart_daemon() -> Result<(), String> { - let status = std::process::Command::new("systemctl") - .args(["--user", "try-restart", "localflowd.service"]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) +/// `systemctl --user` with `args`, quietly: whether it succeeded. +fn systemctl(args: &[&str]) -> Result { + use std::process::{Command, Stdio}; + Command::new("systemctl") + .arg("--user") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .status() - .map_err(|e| format!("could not restart the daemon: {e}"))?; - if status.success() { + .map(|s| s.success()) + .map_err(|e| format!("could not run systemctl: {e}")) +} + +/// Whether `localflowd.service` is the active user unit (no systemctl: +/// it is not). +fn unit_active() -> bool { + systemctl(&["--quiet", "is-active", "localflowd.service"]).unwrap_or(false) +} + +/// `systemctl --user try-restart localflowd.service`. It returns in about +/// 20 ms (the daemon stops at once; systemd does not wait for the model to +/// load), so Apply needs no "applying" state. +fn restart_unit() -> Result<(), String> { + if systemctl(&["try-restart", "localflowd.service"])? { Ok(()) } else { Err("could not restart the daemon (systemctl --user try-restart localflowd)".into()) } } +/// Whether this user's daemon answers a `status` request on the control +/// socket within half a second. +fn answers() -> bool { + use lf_daemon::protocol::{ClientReply, Command, MAX_LINE, Request, parse_reply, request_line}; + use std::io::{BufRead, BufReader, Read, Write}; + use std::os::unix::net::UnixStream; + + let Ok(path) = lf_daemon::paths::socket_path_for_client() else { + return false; + }; + let Ok(mut stream) = UnixStream::connect(path) else { + return false; + }; + let wait = Some(std::time::Duration::from_millis(500)); + let mine = lf_daemon::server::peer_uid(&stream).ok() == Some(lf_daemon::paths::euid()); + if !mine + || stream.set_read_timeout(wait).is_err() + || stream.set_write_timeout(wait).is_err() + || stream + .write_all(request_line(&Request::new(Command::Status)).as_bytes()) + .is_err() + { + return false; + } + let mut line = Vec::new(); + let read = BufReader::new(stream.take(MAX_LINE as u64)).read_until(b'\n', &mut line); + read.is_ok() + && line.pop() == Some(b'\n') + && matches!(parse_reply(&line), Some(ClientReply::Ok(_))) +} + /// A labelled row: the label, a glacier dot while its draft is not applied, /// a reset icon when the window changed the key, and the control. fn setting(k: &Kit, s: &Settings, key: &'static str, label: &str, control: El) -> El { @@ -400,14 +504,19 @@ fn toggle(k: &Kit, s: &Settings, key: &'static str, label: &str, on: bool) -> El .role("group") } -/// `field` with `fix` under it, when its value is the invalid one. -fn with_fix(k: &Kit, field: El, key: String, fix: Option<&'static str>) -> El { - let Some(fix) = fix else { return field }; - col() - .items_end() - .gap(space::S1) - .child(field) - .child(text(fix).key(key).font(k.ty.small).color(k.c.danger.fg)) +/// A text field's `row` with `fix` under its field, when its value is the +/// invalid one. The label stays on the field's line. +fn with_fix(k: &Kit, row: El, key: &str, fix: Option<&'static str>) -> El { + let Some(fix) = fix else { return row }; + col().child(row).child( + text(fix) + .key(format!("error-{key}")) + .font(k.ty.small) + .color(k.c.danger.fg) + .self_end() + .px(space::S3) + .pb(space::S2), + ) } fn card(k: &Kit, key: &str, title: &str, unapplied: bool, rows: Vec) -> El { @@ -490,14 +599,15 @@ fn menu( /// Text fields and menus share one width, so their edges line up. const FIELD_WIDTH: f32 = 200.0; -/// `precision`'s values, from the fastest. "Auto" is the model's own (no -/// `precision` key): i8x1 for 1-bit models, i8x2 for ternary ones. +/// `precision`'s values, from the fastest (README, `precision`), named for +/// speed alone. "Auto" is the model's own (no `precision` key): i8x1 for +/// 1-bit models, i8x2 for ternary ones. const SPEED: [(&str, Option<&str>); 5] = [ ("Auto", None), ("Fastest", Some("i8x1")), ("Fast", Some("i8x2")), - ("Precise", Some("i8x3")), - ("Full precision", Some("f32")), + ("Slow", Some("i8x3")), + ("Slowest", Some("f32")), ]; /// `output`'s values. @@ -520,6 +630,7 @@ const PICKERS: [(&str, Option<&str>); 6] = [ pub fn view(s: &Settings, _cx: &mut Cx) -> El { let k = look::kit(); let seg = look::segments(&k); + let fields = look::fields(&k); let c = &s.shown; let mut layer = Vec::new(); @@ -572,9 +683,8 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { let v = s.prompt_tag.text.clone(); s.set("prompt_tag", Value::String(v)); }) - .build(&k) + .build(&fields) .key("prompt-tag-field"); - let tag = with_fix(&k, tag, "error-prompt_tag".into(), tag_fix); static DOUBLE_TAP: [(&str, u64); 4] = [ ("Off", 0), ("250 ms", 250), @@ -598,7 +708,12 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { false, vec![ setting(&k, s, "double_tap_ms", "Double-tap and hold", tap), - setting(&k, s, "prompt_tag", "Prompt tag", tag), + with_fix( + &k, + setting(&k, s, "prompt_tag", "Prompt tag", tag), + "prompt_tag", + tag_fix, + ), ], ); @@ -671,9 +786,8 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { }, ); }) - .build(&k) + .build(&fields) .key("microphone-field"); - let device = with_fix(&k, device, "error-input_device".into(), mic_fix); let recognition = card( &k, "recognition", @@ -681,7 +795,12 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { false, vec![ setting(&k, s, "precision", "Speed", speed), - setting(&k, s, "input_device", "Microphone", device), + with_fix( + &k, + setting(&k, s, "input_device", "Microphone", device), + "input_device", + mic_fix, + ), ], ); @@ -696,7 +815,7 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { .invalid(say_fix.is_some()) .on_edit(move |s: &mut Settings| &mut s.macros[i].0) .on_change(|s: &mut Settings, _: &mut Tx| s.sync_macros()) - .build(&k); + .build(&fields); let payload = TextInput::new(&s.macros[i].1) .label("Type") .placeholder("Type") @@ -704,7 +823,7 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { .invalid(type_fix.is_some()) .on_edit(move |s: &mut Settings| &mut s.macros[i].1) .on_change(|s: &mut Settings, _: &mut Tx| s.sync_macros()) - .build(&k) + .build(&fields) .grow(1.0); let remove = IconButton::new(&REMOVE) .variant(IconButtonVariant::GhostMuted) @@ -829,46 +948,52 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { .shrink() .truncate() }; - // Saved, so nothing is left to apply or discard (any change clears - // this): the restart is the one thing to do. - if let Status::RestartFailed(why) = &s.status { + // A restart the saved settings still need stays in the footer until + // one works, whatever else changes (only a failed save goes first). + let pending = match &s.pending { + _ if matches!(s.status, Status::Failed(_)) => None, + Some(Pending::Failed(why)) => Some(( + "restart-failed", + if s.dirty() { + "LocalFlow didn\u{2019}t restart." + } else { + "Saved. LocalFlow didn\u{2019}t restart." + }, + why.clone(), + )), + Some(Pending::Manual) => Some(( + "restart-manual", + if s.dirty() { + "LocalFlow needs a restart." + } else { + "Saved. Restart LocalFlow to use it." + }, + "LocalFlow doesn\u{2019}t run as its systemd service, so this window can\u{2019}t restart it." + .to_owned(), + )), + None => None, + }; + if let Some((key, words, why)) = pending { footer = footer - .child(look::dot(look::ember()).key("restart-failed")) - .child( - note( - "restart-note", - "Saved. LocalFlow didn\u{2019}t restart.".into(), - k.c.text_base, - ) - .help(why.clone()), - ) - .child( - look::primary( - &k, - Button::new("Retry").on_press(|s: &mut Settings| s.restart_daemon()), - ) - .key("retry"), - ); - return page(&k, content, footer, layer); - } - footer = match (&s.config, &s.status) { - (Err(e), _) => { - let words = match s.problem() { - Some((Spot::PromptTag, _)) => "Fix the prompt tag to apply".to_owned(), - Some((Spot::Microphone, _)) => "Fix the microphone to apply".to_owned(), - Some((Spot::Macro { row, .. }, _)) => { - format!("Fix voice macro {} to apply", row + 1) - } - None => e.clone(), - }; - footer.child(note("error", words, k.c.danger.fg)) + .child(look::dot(look::ember()).key(key)) + .child(note("restart-note", words.into(), k.c.text_base).help(why)); + // Saved, with nothing left to apply: the restart is the one thing + // to do (Apply restarts too, once there are changes). + if !s.dirty() { + if matches!(s.pending, Some(Pending::Failed(_))) { + footer = footer.child( + look::primary( + &k, + Button::new("Retry").on_press(|s: &mut Settings| s.restart_daemon()), + ) + .key("retry"), + ); + } + return page(&k, content, footer, layer); } - (_, Status::Failed(e)) => footer.child(note("error", e.clone(), k.c.danger.fg)), - (_, Status::Applied) => footer - .child(icon(&CHECK, 16.0).key("applied").color(k.c.success.icon)) - .child(el().grow(1.0)), - _ => footer.child(el().grow(1.0)), - }; + } else { + footer = feedback(&k, s, footer, note); + } let can_apply = s.dirty() && s.config.is_ok(); footer = footer .child( @@ -892,6 +1017,34 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { page(&k, content, footer, layer) } +/// The footer's left side without a pending restart: what to fix, a failed +/// save, or the check after Apply. +fn feedback( + k: &Kit, + s: &Settings, + footer: El, + note: impl Fn(&'static str, String, rune::Color) -> El, +) -> El { + match (&s.config, &s.status) { + (Err(e), _) => { + let words = match s.problem() { + Some((Spot::PromptTag, _)) => "Fix the prompt tag to apply".to_owned(), + Some((Spot::Microphone, _)) => "Fix the microphone to apply".to_owned(), + Some((Spot::Macro { row, .. }, _)) => { + format!("Fix voice macro {} to apply", row + 1) + } + None => e.clone(), + }; + footer.child(note("error", words, k.c.danger.fg)) + } + (_, Status::Failed(e)) => footer.child(note("error", e.clone(), k.c.danger.fg)), + (_, Status::Applied) => footer + .child(icon(&CHECK, 16.0).key("applied").color(k.c.success.icon)) + .child(el().grow(1.0)), + _ => footer.child(el().grow(1.0)), + } +} + /// The scrolling settings over the footer. fn page(k: &Kit, content: El, footer: El, layer: Vec) -> El { let body = col() @@ -967,9 +1120,9 @@ mod tests { fn window(&self) -> Runtime { self.window_with(None) } - fn window_with(&self, restart: Restart) -> Runtime { + fn window_with(&self, daemon: Option) -> Runtime { let layers = Layers::load(&self.config(), Path::new("/d")).unwrap(); - let s = Settings::new(self.config(), "/d".into(), layers, restart).unwrap(); + let s = Settings::new(self.config(), "/d".into(), layers, daemon).unwrap(); // Tall enough that nothing needs scrolling. let mut rt = Runtime::new(s, view, (560.0, 1400.0), Env::new(true, 1.0)); rt.on_key = Some(key); @@ -1210,22 +1363,117 @@ mod tests { assert!(!fix.contains("synthetic") && !fix.contains("?!")); } + thread_local! { + /// Restarts of the user unit this test thread asked for. + static RESTARTS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + fn restarts() -> usize { + RESTARTS.with(|n| n.get()) + } + + #[test] + fn an_invalid_fields_label_stays_on_the_fields_line() { + let d = Dir::new("invalid-line", r#"{"version": 1}"#); + let mut rt = d.window(); + let centre = |rt: &Runtime, key: &str| rt.find(key).unwrap().1; + let before = centre(&rt, "prompt_tag/left/label"); + rt.update(|s, _| { + s.prompt_tag = TextEdit::new("x".repeat(65)); + s.set("prompt_tag", Value::String("x".repeat(65))); + }); + rt.frame(1.0 / 60.0); + assert!(rt.find("error-prompt_tag").is_some()); + let (label, field) = ( + centre(&rt, "prompt_tag/left/label"), + centre(&rt, "prompt-tag-field"), + ); + assert!((label - field).abs() < 0.5, "label {label}, field {field}"); + assert!( + (label - before).abs() < 0.5, + "the label moved: {before} to {label}" + ); + } + + #[test] + fn the_speed_menu_goes_from_fastest_to_slowest() { + let values: Vec<_> = SPEED.iter().map(|(_, v)| *v).collect(); + assert_eq!( + values, + [None, Some("i8x1"), Some("i8x2"), Some("i8x3"), Some("f32")] + ); + assert_eq!(SPEED[0].0, "Auto"); + // Speed words only: nothing about accuracy. + for (label, _) in SPEED { + let l = label.to_lowercase(); + assert!(!l.contains("precis") && !l.contains("accura"), "{label}"); + } + } + fn restart_fails() -> Result<(), String> { + RESTARTS.with(|n| n.set(n.get() + 1)); Err("synthetic restart failure".into()) } fn restart_works() -> Result<(), String> { + RESTARTS.with(|n| n.set(n.get() + 1)); Ok(()) } + fn yes() -> bool { + true + } + + fn no() -> bool { + false + } + + /// The user unit runs, and restarting it fails. + const FAILING: Daemon = Daemon { + unit_active: yes, + restart_unit: restart_fails, + answers: yes, + }; + const WORKING: Daemon = Daemon { + unit_active: yes, + restart_unit: restart_works, + answers: yes, + }; + /// Started some other way (`exec-once`): the unit is not active, but the + /// daemon answers on its socket. + const OUTSIDE_SYSTEMD: Daemon = Daemon { + unit_active: no, + restart_unit: restart_works, + answers: yes, + }; + const NOT_RUNNING: Daemon = Daemon { + unit_active: no, + restart_unit: restart_works, + answers: no, + }; + + #[test] + fn a_restart_is_done_failed_or_left_to_the_user() { + let before = restarts(); + assert_eq!(WORKING.restart(), Restarted::Done); + assert!(matches!(FAILING.restart(), Restarted::Failed(_))); + assert_eq!(restarts(), before + 2); + // Not the user unit: try-restart would do nothing and succeed, so + // it is not run. + assert_eq!(OUTSIDE_SYSTEMD.restart(), Restarted::Manual); + // Nothing running reads the settings when it starts. + assert_eq!(NOT_RUNNING.restart(), Restarted::Done); + assert_eq!(restarts(), before + 2); + } + #[test] fn a_failed_restart_keeps_the_save_and_offers_retry() { let d = Dir::new("restart", r#"{"version": 1}"#); - let mut rt = d.window_with(Some(restart_fails)); + let mut rt = d.window_with(Some(FAILING)); press(&mut rt, "switch-history"); press(&mut rt, "apply"); assert_eq!(d.settings(), Some(json!({"history": true})), "saved"); - assert!(matches!(rt.state.status, Status::RestartFailed(_))); + assert!(matches!(rt.state.pending, Some(Pending::Failed(_)))); assert!(!rt.state.dirty()); assert!(rt.find("restart-failed").is_some()); assert!(rt.find("error").is_none(), "not shown as a failed save"); @@ -1233,20 +1481,105 @@ mod tests { // Retry runs the restart again: still failing, then working. press(&mut rt, "retry"); - assert!(matches!(rt.state.status, Status::RestartFailed(_))); - rt.state.restart = Some(restart_works); + assert!(matches!(rt.state.pending, Some(Pending::Failed(_)))); + rt.state.daemon = Some(WORKING); press(&mut rt, "retry"); + assert_eq!(rt.state.pending, None); assert_eq!(rt.state.status, Status::Applied); assert!(rt.find("retry").is_none()); + assert!(rt.find("restart-failed").is_none()); assert!(rt.find("applied").is_some()); assert_eq!(d.settings(), Some(json!({"history": true}))); // A key the daemon does not read needs no restart. - let mut rt = d.window_with(Some(restart_fails)); + let mut rt = d.window_with(Some(FAILING)); + let before = restarts(); + rt.update(|s, _| s.set("picker", json!("gui"))); + rt.frame(1.0 / 60.0); + press(&mut rt, "apply"); + assert_eq!(rt.state.status, Status::Applied); + assert_eq!(rt.state.pending, None); + assert_eq!(restarts(), before); + } + + #[test] + fn a_failed_restart_stays_until_a_restart_works() { + let d = Dir::new("pending", r#"{"version": 1}"#); + let mut rt = d.window_with(Some(FAILING)); + press(&mut rt, "switch-history"); + press(&mut rt, "apply"); + assert!(rt.find("restart-failed").is_some()); + + // Another change keeps the warning; Apply, which restarts too, + // takes Retry's place. + press(&mut rt, "switch-pause_media"); + assert!(rt.find("restart-failed").is_some()); + assert!(rt.find("retry").is_none()); + assert!(rt.find("apply").is_some()); + // So does Discard, which brings Retry back. + press(&mut rt, "discard"); + assert!(rt.find("restart-failed").is_some()); + assert!(rt.find("retry").is_some()); + // And an invalid value. + rt.update(|s, _| s.set("prompt_tag", Value::String("x".repeat(65)))); + rt.frame(1.0 / 60.0); + assert!(rt.find("restart-failed").is_some()); + press(&mut rt, "discard"); + + // Applying a key the daemon does not read still retries the restart + // the saved settings need. + let before = restarts(); rt.update(|s, _| s.set("picker", json!("gui"))); rt.frame(1.0 / 60.0); press(&mut rt, "apply"); + assert_eq!(restarts(), before + 1); + assert_eq!( + d.settings(), + Some(json!({"history": true, "picker": "gui"})) + ); + assert!(matches!(rt.state.pending, Some(Pending::Failed(_)))); + assert!(rt.find("restart-failed").is_some()); + assert!(rt.find("applied").is_none()); + + // Once a restart works, the warning goes. + rt.state.daemon = Some(WORKING); + press(&mut rt, "switch-pause_media"); + press(&mut rt, "apply"); + assert_eq!(rt.state.pending, None); + assert!(rt.find("restart-failed").is_none()); + assert!(rt.find("applied").is_some()); + } + + #[test] + fn a_daemon_started_outside_systemd_needs_a_restart_by_hand() { + let d = Dir::new("manual", r#"{"version": 1}"#); + let mut rt = d.window_with(Some(OUTSIDE_SYSTEMD)); + let before = restarts(); + press(&mut rt, "switch-history"); + press(&mut rt, "apply"); + assert_eq!(d.settings(), Some(json!({"history": true})), "saved"); + assert_eq!(restarts(), before, "try-restart would do nothing"); + assert_eq!(rt.state.pending, Some(Pending::Manual)); + assert!(rt.find("restart-manual").is_some()); + assert!(rt.find("applied").is_none(), "no claim it took effect"); + assert!(rt.find("error").is_none(), "not shown as a failed save"); + // The window cannot restart it: no Retry. + assert!(rt.find("retry").is_none()); + // Still there after another change. + press(&mut rt, "switch-pause_media"); + assert!(rt.find("restart-manual").is_some()); + } + + #[test] + fn with_no_daemon_running_saving_is_enough() { + let d = Dir::new("nodaemon", r#"{"version": 1}"#); + let mut rt = d.window_with(Some(NOT_RUNNING)); + press(&mut rt, "switch-history"); + press(&mut rt, "apply"); + assert_eq!(d.settings(), Some(json!({"history": true}))); + assert_eq!(rt.state.pending, None); assert_eq!(rt.state.status, Status::Applied); + assert!(rt.find("applied").is_some()); } #[test] From f4cec713bb4c6f8c608109da1bdc844d21d9077a Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 21:58:53 -0800 Subject: [PATCH 11/18] linux: anchor the pill's dot wave and calm its motion While recording, the pill's last cell is always a full, bright dot, as the iPhone island ends in its mark dot. Pressing F13 now brightens the pill even in silence, and resting dots are brighter (60%) so they stay visible. The wave moves one cell per two level lines, keeping the louder of the two. A follower rises at once and fades over two cells, so syllables cross in about 0.66 s, close to the iPhone's 0.75 s, instead of flickering at 15 Hz. Two full dots never sit side by side, so peaks don't fuse into one blob. The recording text asks Pango for a monospace font, so on a stock Waybar with a proportional font speech no longer moves the clock. AJ's bar resolves monospace to the same font, so its geometry is unchanged. Without a microphone the pill keeps the slashed microphone even when an agent needs approval. The tooltip says "N dictionary suggestions waiting" instead of naming a command. Invalid-field notes in the settings footer lead with the same ember dot as the restart notes. --- linux/AGENT-LINK.md | 9 +- linux/CHANGELOG.md | 26 +- linux/README.md | 31 +- linux/crates/lf-daemon/src/waybar.rs | 484 +++++++++++++++++++------ linux/crates/lf-daemon/tests/daemon.rs | 6 +- linux/gui/src/settings.rs | 12 +- 6 files changed, 430 insertions(+), 138 deletions(-) diff --git a/linux/AGENT-LINK.md b/linux/AGENT-LINK.md index df799382..fbf716f1 100644 --- a/linux/AGENT-LINK.md +++ b/linux/AGENT-LINK.md @@ -114,8 +114,9 @@ commands do not carry these fields. - **While idle, loading or without a microphone:** - A live report with `agent-approval` adds the class `agent-approval` and shows a raised hand (Font Awesome U+F256) in place of the state's - glyph; with `agent-input` (and no approval), `agent-input`, and the - glyph stays. + glyph, except without a microphone, whose slashed microphone stays; + with `agent-input` (and no approval), `agent-input`, and the glyph + stays. - Pending suggestions add `suggestion`. - With extra classes, `class` is an array: the state's class first, then `agent-approval` or `agent-input`, then `suggestion`. @@ -128,8 +129,8 @@ commands do not carry these fields. these lines, each only when it applies: - `Agents: need approval, waiting for input, working, done`. Only the non-zero parts are listed, and the singular is used for 1. - - ` dictionary suggestions from agents (localflowctl dict review)`, or - `1 dictionary suggestion from an agent (…)`. + - ` dictionary suggestions waiting`, or `1 dictionary suggestion + waiting`. - ` project words boosted`. ## CLI diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 0c0c10b6..1733e21d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -30,16 +30,22 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. - The Waybar indicator (`localflowctl watch --waybar`) looks like the iPhone app's: a wave of round dots while recording instead of bars, and every state six characters wide, so the module and the clock beside it - no longer move. The wave's dots grow and brighten with your voice and - rest as a row of small dim dots in silence; they are standard characters - with Pango alpha markup, so the wave needs no Nerd Font (keep the - module's `escape` off). Each state has its own glyph: a clock while the - model loads, the hourglass while transcribing, a keyboard while typing, - the microphone with a slash without a microphone, power-off when - LocalFlow isn’t running, and a raised hand while an agent needs your - approval. A prompt dictation shows `›` before the wave. - Tooltips use plain words ("LocalFlow: ready"). The README's CSS uses the - iPhone app's colours and adds `min-length` and `align` to the module. + no longer move. The wave ends in a full, bright dot, so it reads as + listening from the first moment, even in silence. The dots before it + grow and brighten with your voice at the iPhone's pace, a syllable + crossing in about two thirds of a second, and fade over a couple of + cells after a word; in silence they rest as a row of small dim dots. + They are standard characters with Pango markup, set in the `monospace` + font so the clock holds still while you speak even when the bar's font + is proportional, and need no Nerd Font (keep the module's `escape` off). + Each state has its own glyph: a clock while the model loads, the + hourglass while transcribing, a keyboard while typing, the microphone + with a slash without a microphone, power-off when LocalFlow isn’t + running, and a raised hand while an agent needs your approval (unless + there is no microphone). A prompt dictation shows `›` before the wave. + Tooltips use plain words ("LocalFlow: ready", "2 dictionary suggestions + waiting"). The README's CSS uses the iPhone app's colours and adds + `min-length` and `align` to the module. - The recommended model is now [Kea-B 0.1](https://huggingface.co/wildernesslabs/kea-b-0.1): `kea-b` in `localflowctl fetch-model` and the flake installs it instead of Kea-B 0.0. diff --git a/linux/README.md b/linux/README.md index e5105234..49b81881 100644 --- a/linux/README.md +++ b/linux/README.md @@ -368,7 +368,7 @@ never move. Classes (also the `alt`) and what each shows: |---|---|---| | `idle` | microphone | LocalFlow: ready | | `loading` | clock (the model is loading) | LocalFlow: loading the model | -| `recording` | a wave of dots, newest on the right, growing and brightening with your voice; a row of small dim dots in silence | LocalFlow: listening | +| `recording` | a row of dots ending in a full, bright dot; the dots before it grow and brighten with your voice, newest on the right, and rest small and dim in silence | LocalFlow: listening | | `transcribing` | hourglass | LocalFlow: transcribing | | `typing` | keyboard | LocalFlow: typing | | `nomic` | microphone with a slash (idle with no microphone) | LocalFlow: no microphone | @@ -379,18 +379,28 @@ puts `›` in the first character and adds the class `prompt` while it records, transcribes and types (`"class": ["recording", "prompt"]`, tooltip "LocalFlow: listening (prompt dictation)"). While an agent needs your approval (see [Coding agents](#coding-agents)), a raised hand takes the -glyph's place. It keeps retrying every 2 s while the daemon is down, so +glyph's place, except without a microphone, which keeps its slashed +microphone. It keeps retrying every 2 s while the daemon is down, so Waybar never needs restarting. Override the glyphs with `format-icons` -keyed by `alt` if you like. `min-length` and `align` keep the module's -width with a proportional font too. +keyed by `alt` if you like. The wave's dots are `•` and `●` (U+2022, U+25CF), which common fonts such as DejaVu have, and the glyphs are Font Awesome's, which stock Waybar's -style asks for, so with Font Awesome installed no Nerd Font is needed (a -proportional font does let the module's width vary). Dimmer dots are -Pango markup (`•`), which Waybar reads in custom -modules by default; leave the module's `escape` off, or the tags show as -text. +style asks for, so with Font Awesome installed no Nerd Font is needed. The +wave is Pango markup, which Waybar reads in custom modules by default: it +is set in the `monospace` font (``), so its +six cells are equally wide even in a proportional bar font, and dimmer +dots are `•`. Leave the module's `escape` off, or +the tags show as text. + +`min-length: 6` makes the module at least six average characters of the +bar's font wide, and `align: 0.5` centres the text in that width. A text +that fits leaves the width alone; a wider one widens the module and moves +the clock. In a monospace bar font that is also fontconfig's `monospace` +(`fc-match monospace`), every text is exactly six cells, so nothing moves. +In a proportional font, such as stock Waybar's DejaVu +Sans, the wave and most glyphs fit; the keyboard and the slashed +microphone are a little wider and move the clock by about a pixel. ```jsonc "custom/localflow": { @@ -907,7 +917,8 @@ your dictation key. daemon sees. 17. Waybar indicator (`localflowctl watch --waybar` in a terminal, or the custom module): idle shows the dim microphone; holding the key shows a - coral wave of dots that follows your voice; release shows the hourglass, + coral wave of dots ending in a full dot at once, and the dots before it + follow your voice, calmly; release shows the hourglass, the keyboard, then idle. The clock beside it never moves. Disconnect the microphone (or turn the headset off): `nomic` (the microphone with a slash) within a second or two, and back to idle when reconnected. With diff --git a/linux/crates/lf-daemon/src/waybar.rs b/linux/crates/lf-daemon/src/waybar.rs index 7ebb6dc3..38178219 100644 --- a/linux/crates/lf-daemon/src/waybar.rs +++ b/linux/crates/lf-daemon/src/waybar.rs @@ -10,11 +10,20 @@ //! cell of a monospace font, so the module and the clock beside it never //! move between states: //! -//! - Recording shows the last input levels as a wave of round dots, newest -//! on the right; silence is a row of small dim dots. The wave is Pango -//! markup (Waybar's default for custom modules): a dot dimmer than full -//! is `•`. Its dots are standard characters -//! (U+2022, U+25CF), so it needs no Nerd Font. +//! - Recording shows a wave of round dots that comes to rest in a full, +//! bright anchor dot in the last cell, as the iPhone's wave ends in the +//! mark's dot, so silence still reads as listening. The cells before it +//! show the voice, newest on the right: one cell per two level lines +//! ([`LINES_PER_CELL`]), the louder of the pair, through a follower that +//! rises at once and falls by [`FALL`] a cell; silence is a row of small +//! dim dots. The wave is Pango markup (Waybar's default for custom +//! modules): one `` around its cells, so a +//! proportional bar font still gives them equal widths, and a dot dimmer +//! than full is `•`. Its dots are standard +//! characters (U+2022, U+25CF), so it needs no Nerd Font. A full dot is a +//! little wider than its cell, so two never stand side by side: the +//! second of two loud cells, and a loud cell beside the anchor, show as +//! the brightest small dot ([`CAPPED`]). //! - Every other state shows its glyph in the third cell ([`GLYPH_CELL`]). //! Glyphs are Font Awesome icons as Nerd Fonts carry them, drawn wider //! than one cell, so the glyph sits about in the middle; a bar config can @@ -30,8 +39,10 @@ //! The agent link's counts on watch lines (see `crate::protocol`): //! //! - While idle, loading or without a microphone, an agent needing approval -//! adds the class `agent-approval` and shows [`APPROVAL_GLYPH`] in place -//! of the state's glyph; else one waiting for input adds `agent-input` +//! adds the class `agent-approval` and, unless the microphone is missing, +//! shows [`APPROVAL_GLYPH`] in place of the state's glyph (no microphone +//! comes first: nothing can be dictated until one is back); else one +//! waiting for input adds `agent-input` //! (the glyph stays); pending suggestions add `suggestion` (in that order, //! after the state's class: `class` is then an array). The number of //! agents needing approval or input, when any, goes in the last two cells @@ -64,29 +75,50 @@ pub const PROMPT_MARKER: char = '\u{203a}'; pub const WIDTH: usize = 6; /// The cell of a state's glyph; the agent count takes the last two. pub const GLYPH_CELL: usize = 2; -/// Levels kept while recording: a full row of dots. -pub const HISTORY: usize = WIDTH; +/// Cells of the voice kept while recording: every cell but the anchor's. +pub const HISTORY: usize = WIDTH - 1; /// The wave's small dot, U+2022 bullet: in JetBrainsMono about 0.43 of a /// cell across, as a resting dot on the iPhone is about half a full one. pub const SMALL_DOT: char = '\u{2022}'; -/// The wave's full dot, U+25CF black circle: about a cell across, so full -/// neighbours just touch, as at the top of the iPhone's swell. +/// The wave's full dot, U+25CF black circle: 620 units of ink in a 600-unit +/// cell in JetBrainsMono (1221 in 1233 in DejaVu Sans Mono), so two side by +/// side would fuse; see [`CAPPED`]. Also the anchor in the last cell. pub const FULL_DOT: char = '\u{25cf}'; /// The wave's steps, quietest first: a dot and its alpha in percent. As the -/// iPhone's DotWave, a dot at rest is half a full dot's size and brightness, -/// and louder dots grow and brighten together. Monospace fonts such as -/// JetBrainsMono and DejaVu Sans Mono draw both dots one cell wide, with no -/// round dot between their sizes (`·` is smaller than a resting one), so -/// alpha carries the steps between. +/// iPhone's DotWave, a dot at rest is half a full dot's size and dimmer, +/// and louder dots grow and brighten together. At rest it is 60%: at 50% +/// the 3 px dot was 2.6:1 on the black pill at 1x, darker than the idle +/// microphone; 60% is 3.4:1. Monospace fonts such as JetBrainsMono and +/// DejaVu Sans Mono draw both dots one cell wide, with no round dot between +/// their sizes (`·` is smaller than a resting one), so alpha carries the +/// steps between. pub const STEPS: [(char, u8); 5] = [ - (SMALL_DOT, 50), - (SMALL_DOT, 75), + (SMALL_DOT, 60), + (SMALL_DOT, 80), (SMALL_DOT, 100), (FULL_DOT, 80), (FULL_DOT, 100), ]; +/// The step a full dot shows instead beside another full dot (the anchor +/// included): the brightest small dot, so the two stay apart and the cell +/// still reads as loud. +pub const CAPPED: usize = 2; + +/// Level lines (every 66 ms while recording) per cell of the wave: a +/// syllable crosses its five cells in about 0.66 s, near the iPhone's +/// 0.75 s, instead of rushing through in 0.33 s. +pub const LINES_PER_CELL: usize = 2; +/// How far the followed level can fall in one cell (132 ms): to 35% of +/// itself, about the iPhone's 90 ms fall plus its softening. It rises at +/// once, so onsets show in the next cell, but a dip between syllables no +/// longer drops to rest and the end of a word fades over two cells. +pub const FALL: f32 = 0.35; + +/// The font of the wave's cells: equal advances even in a proportional bar +/// font. On a monospace bar it is usually the same font. +pub const WAVE_FONT: &str = "monospace"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Class { @@ -184,15 +216,20 @@ pub fn step(level: u8) -> usize { (v * (STEPS.len() - 1) as f32).round() as usize } -/// One cell of the wave for a level of 0..=100, as Pango markup: the -/// step's dot, in a span with its alpha unless that is 100%. -pub fn dot(level: u8) -> String { - match STEPS[step(level)] { +/// One cell of the wave for a [`STEPS`] index, as Pango markup: the step's +/// dot, in a span with its alpha unless that is 100%. +pub fn dot(step: usize) -> String { + match STEPS[step.min(STEPS.len() - 1)] { (glyph, 100) => glyph.to_string(), (glyph, alpha) => format!("{glyph}"), } } +/// Whether a step shows the full dot. +fn is_full(step: usize) -> bool { + STEPS[step].0 == FULL_DOT +} + /// A text as Waybar shows it: without the wave's markup. pub fn plain(text: &str) -> String { let mut out = String::with_capacity(text.len()); @@ -303,11 +340,8 @@ impl Counts { } match self.suggestions { 0 => {} - 1 => lines - .push("1 dictionary suggestion from an agent (localflowctl dict review)".into()), - n => lines.push(format!( - "{n} dictionary suggestions from agents (localflowctl dict review)" - )), + 1 => lines.push("1 dictionary suggestion waiting".into()), + n => lines.push(format!("{n} dictionary suggestions waiting")), } match self.words { 0 => {} @@ -321,8 +355,15 @@ impl Counts { /// Turns a stream of watch lines into Waybar objects. #[derive(Debug, Default)] pub struct Indicator { - /// Recent levels of the current recording, oldest first. - levels: VecDeque, + /// The current recording's recent cells, oldest first: [`STEPS`] + /// indices, a full dot after another already capped. + cells: VecDeque, + /// Levels of the cell still being heard (fewer than + /// [`LINES_PER_CELL`]), and the loudest of them. + heard: usize, + loudest: u8, + /// The followed level, 0..=100. + follow: f32, recording: bool, } @@ -350,13 +391,10 @@ impl Indicator { } if class == Class::Recording { if !self.recording { - self.levels.clear(); + *self = Indicator::default(); } if let Some(l) = level { - if self.levels.len() == HISTORY { - self.levels.pop_front(); - } - self.levels.push_back(l); + self.hear(l); } } self.recording = class == Class::Recording; @@ -376,7 +414,9 @@ impl Indicator { if !busy { if n.approval > 0 { classes.push(AGENT_APPROVAL_CLASS); - glyph = APPROVAL_GLYPH; + if class != Class::NoMic { + glyph = APPROVAL_GLYPH; + } } else if n.input > 0 { classes.push(AGENT_INPUT_CLASS); } @@ -386,8 +426,8 @@ impl Indicator { waiting = n.approval.saturating_add(n.input); } let text = match class { - Class::Recording if prompt => format!("{PROMPT_MARKER}{}", self.wave(WIDTH - 1)), - Class::Recording => self.wave(WIDTH), + Class::Recording if prompt => self.wave(PROMPT_MARKER.to_string(), WIDTH - 2), + Class::Recording => self.wave(String::new(), WIDTH - 1), _ => field(prompt, glyph, waiting), }; for line in n.tooltip_lines() { @@ -403,14 +443,39 @@ impl Indicator { object(Class::Offline, &field(false, Class::Offline.glyph(), 0)) } - /// The last `cells` levels as dots, newest on the right, padded with - /// dots at rest. - fn wave(&self, cells: usize) -> String { - let shown = self.levels.len().min(cells); - std::iter::repeat_n(0, cells - shown) - .chain(self.levels.iter().skip(self.levels.len() - shown).copied()) - .map(dot) - .collect() + /// One level line while recording: every [`LINES_PER_CELL`] lines, the + /// loudest of them goes through the follower into a new cell. + fn hear(&mut self, level: u8) { + self.loudest = self.loudest.max(level.min(100)); + self.heard += 1; + if self.heard < LINES_PER_CELL { + return; + } + self.follow = f32::from(self.loudest).max(self.follow * FALL); + (self.heard, self.loudest) = (0, 0); + let mut s = step(self.follow.round() as u8); + if is_full(s) && self.cells.back().is_some_and(|&b| is_full(b)) { + s = CAPPED; + } + if self.cells.len() == HISTORY { + self.cells.pop_front(); + } + self.cells.push_back(s); + } + + /// `prefix`, the last `cells` cells, newest on the right and padded with + /// dots at rest, then the anchor, in the monospace span. The newest + /// cell sits beside the full anchor, so it is capped too. + fn wave(&self, prefix: String, cells: usize) -> String { + let shown = self.cells.len().min(cells); + let mut steps: Vec = std::iter::repeat_n(0, cells - shown) + .chain(self.cells.iter().skip(self.cells.len() - shown).copied()) + .collect(); + if let Some(newest) = steps.last_mut().filter(|s| is_full(**s)) { + *newest = CAPPED; + } + let dots: String = steps.into_iter().map(dot).collect(); + format!("{prefix}{dots}{FULL_DOT}") } } @@ -426,9 +491,55 @@ mod tests { json(o)["text"].as_str().unwrap().to_owned() } - /// Dots for levels, oldest first. - fn dots(levels: &[u8]) -> String { - levels.iter().map(|&l| dot(l)).collect() + /// A recording's text: `prefix` (the prompt marker or nothing), the + /// wave's cells for `steps` (oldest first), then the anchor, in the + /// monospace span. + fn wave_text(prefix: &str, steps: &[usize]) -> String { + let cells: String = steps.iter().map(|&s| dot(s)).collect(); + format!("{prefix}{cells}{FULL_DOT}") + } + + /// A plain recording's text for `steps`, one per wave cell. + fn rec_text(steps: [usize; WIDTH - 1]) -> String { + wave_text("", &steps) + } + + /// The watch line of a plain recording at `level`. + fn rec(level: u8) -> String { + format!("state=recording mode=hold model=ready mic=present level={level}") + } + + /// Feeds `levels` as recording lines; the text after the last. + fn feed(ind: &mut Indicator, levels: &[u8]) -> String { + let mut t = String::new(); + for &l in levels { + t = text(&ind.update(&rec(l)).unwrap()); + } + t + } + + /// The step of each cell a text shows (the anchor and any prompt marker + /// left out), read back from its markup. + fn shown_steps(t: &str) -> Vec { + let inner = t + .strip_prefix("") + .and_then(|t| t.strip_suffix(&format!("{FULL_DOT}"))) + .expect(t); + let inner = inner.strip_prefix(PROMPT_MARKER).unwrap_or(inner); + let mut out = Vec::new(); + let mut rest = inner; + while !rest.is_empty() { + let s = (0..STEPS.len()) + .find(|&s| rest.starts_with(&dot(s))) + .expect(t); + out.push(s); + rest = &rest[dot(s).len()..]; + } + out + } + + fn full(s: usize) -> bool { + STEPS[s].0 == FULL_DOT } #[test] @@ -461,6 +572,7 @@ mod tests { assert_eq!(ind.update(fields).as_deref(), Some(want), "{fields}"); seen.push(want.into()); } + // A recording's first line: listening, the wave still at rest. let recording = ind .update("state=recording mode=hold model=ready mic=present level=100") .unwrap(); @@ -468,7 +580,7 @@ mod tests { recording, format!( "{{\"text\": {}, \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\"}}", - json_string(&dots(&[0, 0, 0, 0, 0, 100])) + json_string(&rec_text([0; WIDTH - 1])) ) ); seen.push(recording); @@ -550,7 +662,7 @@ mod tests { // The count stays in the last two cells, after a space. assert_eq!(texts[11], format!(" {APPROVAL_GLYPH} 1 ")); assert_eq!(texts[12], format!(" {MIC_GLYPH} 12")); - assert_eq!(texts[13], format!(" {APPROVAL_GLYPH} 99")); + assert_eq!(texts[13], format!(" {NO_MIC_GLYPH} 99")); } #[test] @@ -565,7 +677,7 @@ mod tests { assert_eq!(v["tooltip"], "LocalFlow: listening (prompt dictation)"); assert_eq!( v["text"], - format!("{PROMPT_MARKER}{}", dots(&[0, 0, 0, 0, 100])) + wave_text(&PROMPT_MARKER.to_string(), &[0; WIDTH - 2]) ); for (state, glyph, tooltip) in [ ("transcribing", TRANSCRIBING_GLYPH, "transcribing"), @@ -598,11 +710,12 @@ mod tests { #[test] fn agent_link_counts_add_classes_a_count_and_tooltip_lines() { let mut ind = Indicator::default(); - let loud = json_string(&dots(&[0, 0, 0, 0, 0, 100])); + // A fresh recording's first line: the wave at rest. + let loud = json_string(&rec_text([0; WIDTH - 1])); let cases = [ ( "state=idle model=ready mic=present suggestions=3 words=12 agent-approval=2 agent-input=1 agent-working=1 agent-done=4".to_owned(), - "{\"text\": \" \u{f256} 3 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\", \"suggestion\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 2 need approval, 1 waiting for input, 1 working, 4 done\\n3 dictionary suggestions from agents (localflowctl dict review)\\n12 project words boosted\"}".to_owned(), + "{\"text\": \" \u{f256} 3 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\", \"suggestion\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 2 need approval, 1 waiting for input, 1 working, 4 done\\n3 dictionary suggestions waiting\\n12 project words boosted\"}".to_owned(), ), ( "state=idle model=loading mic=unknown agent-input=1".into(), @@ -610,7 +723,7 @@ mod tests { ), ( "state=idle model=ready mic=absent suggestions=1".into(), - "{\"text\": \" \u{f131} \", \"alt\": \"nomic\", \"class\": [\"nomic\", \"suggestion\"], \"tooltip\": \"LocalFlow: no microphone\\n1 dictionary suggestion from an agent (localflowctl dict review)\"}".into(), + "{\"text\": \" \u{f131} \", \"alt\": \"nomic\", \"class\": [\"nomic\", \"suggestion\"], \"tooltip\": \"LocalFlow: no microphone\\n1 dictionary suggestion waiting\"}".into(), ), ( "state=idle model=ready mic=present agent-approval=1".into(), @@ -624,7 +737,7 @@ mod tests { // Busy states keep their classes and text; only the tooltip grows. ( "state=recording mode=hold model=ready mic=present level=100 agent-approval=1 suggestions=2".into(), - format!("{{\"text\": {loud}, \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\\nAgents: 1 needs approval\\n2 dictionary suggestions from agents (localflowctl dict review)\"}}"), + format!("{{\"text\": {loud}, \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\\nAgents: 1 needs approval\\n2 dictionary suggestions waiting\"}}"), ), ( "state=transcribing model=ready mic=present agent-input=3 words=2".into(), @@ -632,7 +745,7 @@ mod tests { ), ( "state=typing tag=prompt model=ready mic=present agent-input=2 suggestions=1".into(), - "{\"text\": \"\u{203a} \u{f11c} \", \"alt\": \"typing\", \"class\": [\"typing\", \"prompt\"], \"tooltip\": \"LocalFlow: typing (prompt dictation)\\nAgents: 2 waiting for input\\n1 dictionary suggestion from an agent (localflowctl dict review)\"}".into(), + "{\"text\": \"\u{203a} \u{f11c} \", \"alt\": \"typing\", \"class\": [\"typing\", \"prompt\"], \"tooltip\": \"LocalFlow: typing (prompt dictation)\\nAgents: 2 waiting for input\\n1 dictionary suggestion waiting\"}".into(), ), ]; for (fields, want) in cases { @@ -688,57 +801,49 @@ mod tests { // Louder is never smaller or dimmer. assert!((0..100).all(|l| step(l) <= step(l + 1))); let mut ind = Indicator::default(); - let rec = |l: u8| format!("state=recording mode=toggle model=ready mic=present level={l}"); // A recording starts as silence: a row of equal small dots. assert_eq!( text( &ind.update("state=recording mode=toggle model=ready mic=present") .unwrap() ), - dot(0).repeat(WIDTH) - ); - assert_eq!( - text(&ind.update(&rec(100)).unwrap()), - dots(&[0, 0, 0, 0, 0, 100]) - ); - assert_eq!( - text(&ind.update(&rec(25)).unwrap()), - dots(&[0, 0, 0, 0, 100, 25]) - ); - for _ in 0..HISTORY { - ind.update(&rec(0)); - } - assert_eq!( - text(&ind.update(&rec(100)).unwrap()), - dots(&[0, 0, 0, 0, 0, 100]) + rec_text([0; WIDTH - 1]) ); + // Each new cell comes in on the right, beside the anchor. + assert_eq!(feed(&mut ind, &[0, 30]), rec_text([0, 0, 0, 0, 2])); + assert_eq!(feed(&mut ind, &[10, 10]), rec_text([0, 0, 0, 2, 1])); + // Silence scrolls it out. + feed(&mut ind, &[0; 4 * HISTORY]); + assert_eq!(feed(&mut ind, &[30, 0]), rec_text([0, 0, 0, 0, 2])); // A recording line without a level keeps the wave. assert_eq!( text(&ind.update("state=recording mode=hold model=ready").unwrap()), - dots(&[0, 0, 0, 0, 0, 100]) + rec_text([0, 0, 0, 0, 2]) ); - // A prompt dictation shows the newest levels after its marker. + // A prompt dictation shows the newest cells after its marker. let prompt = |l: u8| format!("state=recording tag=prompt model=ready mic=present level={l}"); + ind.update(&prompt(10)); assert_eq!( - text(&ind.update(&prompt(25)).unwrap()), - format!("{PROMPT_MARKER}{}", dots(&[0, 0, 0, 100, 25])) + text(&ind.update(&prompt(10)).unwrap()), + wave_text(&PROMPT_MARKER.to_string(), &[0, 0, 2, 1]) ); - // The next recording starts from silence. + // The next recording starts from silence: neither the last + // recording's cells nor half a pair of its levels carry over. + ind.update(&rec(100)); ind.update("state=transcribing model=ready mic=present"); - assert_eq!( - text(&ind.update(&rec(25)).unwrap()), - dots(&[0, 0, 0, 0, 0, 25]) - ); + assert_eq!(feed(&mut ind, &[0, 0]), rec_text([0; WIDTH - 1])); ind.offline(); - assert_eq!(text(&ind.update(&rec(0)).unwrap()), dots(&[0; WIDTH])); + assert_eq!(feed(&mut ind, &[0, 0]), rec_text([0; WIDTH - 1])); } #[test] fn the_wave_rests_and_swells_as_the_iphones_does() { - // At rest: the small dot at half brightness; at the top of the - // swell: the full dot at full brightness. - assert_eq!(STEPS[0], (SMALL_DOT, 50)); + // At rest: the small dot, dimmed (about half as bright as a full + // dot on the iPhone; 60% is the least that keeps a 3:1 contrast + // on the black pill at 1x); at the top of the swell: the full dot + // at full brightness. + assert_eq!(STEPS[0], (SMALL_DOT, 60)); assert_eq!(STEPS[STEPS.len() - 1], (FULL_DOT, 100)); // Dots grow and brighten together, step by step. let size = |c: char| usize::from(c == FULL_DOT); @@ -753,32 +858,181 @@ mod tests { let c = c as u32; assert!(!(0xe000..=0xf8ff).contains(&c) && c < 0xf0000); } - assert_eq!(dot(0), "\u{2022}"); - assert_eq!(dot(100), "\u{25cf}"); + assert_eq!(dot(0), "\u{2022}"); + assert_eq!(dot(STEPS.len() - 1), "\u{25cf}"); - // Silence is a calm row of equal resting dots; speech undulates, - // in size as well as brightness. + // Silence is a calm row of equal resting dots before the anchor. let mut ind = Indicator::default(); - let rec = |l: u8| format!("state=recording mode=hold model=ready mic=present level={l}"); - for l in [0, 3, 2, 4, 1, 0] { - ind.update(&rec(l)); - } - assert_eq!(text(&ind.update(&rec(0)).unwrap()), dot(0).repeat(WIDTH)); - let speech = [65, 40, 85, 50, 20, 55]; - let mut wave = String::new(); - for l in speech { - wave = text(&ind.update(&rec(l)).unwrap()); - } - assert_eq!(wave, dots(&speech)); - let shown = plain(&wave); + let quiet = feed(&mut ind, &[0, 3, 2, 4, 1, 0, 2, 3, 0, 1, 4, 2]); + assert_eq!(quiet, rec_text([0; WIDTH - 1])); + assert_eq!( + plain(&quiet), + "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{25cf}" + ); + // Speech undulates, in size as well as brightness. + let speech = feed(&mut ind, &[40, 85, 6, 9, 30, 20, 2, 5, 60, 55, 30, 9]); + let steps = shown_steps(&speech); assert!( - shown.contains(SMALL_DOT) && shown.contains(FULL_DOT), - "{wave}" + steps.iter().any(|&s| full(s)) && steps.iter().any(|&s| !full(s)), + "{speech}" ); - let mut steps = speech.map(step).to_vec(); - steps.sort(); - steps.dedup(); - assert!(steps.len() >= 3, "{steps:?}"); + let mut distinct = steps.clone(); + distinct.sort(); + distinct.dedup(); + assert!(distinct.len() >= 3, "{steps:?}"); + } + + #[test] + fn a_recording_always_ends_in_the_full_anchor_dot() { + // Silence reads as listening: whatever the voice, the last cell is + // the full dot at full brightness, as the iPhone's wave comes to + // rest in the mark's dot; the wave takes the cells before it. + let mut ind = Indicator::default(); + let first = text( + &ind.update("state=recording mode=hold model=ready mic=present") + .unwrap(), + ); + assert_eq!( + plain(&first), + "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{25cf}" + ); + let prompt = text( + &ind.update("state=recording tag=prompt model=ready mic=present level=0") + .unwrap(), + ); + assert_eq!( + plain(&prompt), + "\u{203a}\u{2022}\u{2022}\u{2022}\u{2022}\u{25cf}" + ); + let mut ind = Indicator::default(); + let mut seed = 7u32; + for _ in 0..400 { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + let t = text(&ind.update(&rec((seed >> 16) as u8 % 101)).unwrap()); + assert!(t.ends_with(&format!("{FULL_DOT}")), "{t}"); + assert_eq!(shown_steps(&t).len(), WIDTH - 1, "{t}"); + } + } + + #[test] + fn the_wave_is_set_in_one_monospace_span() { + // A proportional bar font draws `•` and `●` at different widths; + // the span gives every cell of the wave the same advance, so the + // clock beside the module holds still while you speak. + let mut ind = Indicator::default(); + for line in [ + "state=recording mode=hold model=ready mic=present", + "state=recording mode=hold model=ready mic=present level=90", + "state=recording mode=hold tag=prompt model=ready mic=present level=40", + ] { + let t = text(&ind.update(line).unwrap()); + assert!(t.starts_with(""), "{t}"); + assert!(t.ends_with(""), "{t}"); + assert_eq!(t.matches("font_family").count(), 1, "{t}"); + } + // Other states keep the bar's font, for Font Awesome's glyphs. + for line in [ + "state=idle model=ready mic=present", + "state=transcribing model=ready mic=present", + ] { + assert!(!text(&ind.update(line).unwrap()).contains('<')); + } + } + + #[test] + fn one_cell_per_two_level_lines_with_the_louder_of_the_pair() { + // Level lines come every 66 ms; a cell every second one makes a + // syllable cross the wave in about 0.66 s, near the iPhone's 0.75 s. + let silent = rec_text([0; WIDTH - 1]); + for pair in [[30, 0], [0, 30], [30, 10]] { + let mut ind = Indicator::default(); + assert_eq!(feed(&mut ind, &[0, 0, 0, 0]), silent); + // The first line of a pair changes nothing: the same object, + // which `watch --waybar` does not write again. + let before = ind.update(&rec(0)).unwrap(); + assert_eq!(ind.update(&rec(0)).unwrap(), before); + let held = ind.update(&rec(pair[0])).unwrap(); + assert_eq!(text(&held), silent, "{pair:?}"); + // The second adds one cell for the louder of the two. + assert_eq!( + feed(&mut ind, &[pair[1]]), + rec_text([0, 0, 0, 0, 2]), + "{pair:?}" + ); + } + } + + #[test] + fn onsets_show_within_one_cell_and_silence_after_speech_decays() { + // Speech arriving on either line of a pair shows by the line after + // it at the latest: within one cell. + for lines in [[60, 0], [0, 60]] { + let mut ind = Indicator::default(); + feed(&mut ind, &[0, 0, 0, 0]); + let t = feed(&mut ind, &lines); + assert!(shown_steps(&t)[WIDTH - 2] > 0, "{lines:?} {t}"); + } + // A loud cell beside the anchor is the brightest small dot, and + // grows to the full dot one cell later (full dots never touch). + let mut ind = Indicator::default(); + let loud = feed(&mut ind, &[0, 0, 100, 100]); + assert_eq!(STEPS[shown_steps(&loud)[WIDTH - 2]], (SMALL_DOT, 100)); + let next = feed(&mut ind, &[0, 0]); + assert_eq!(shown_steps(&next)[WIDTH - 3], STEPS.len() - 1); + + // After speech the wave falls a step or two a cell: the first cell + // of silence is still lit, and it is at rest within three cells. + let mut ind = Indicator::default(); + feed(&mut ind, &[100, 100]); + let mut tail = Vec::new(); + for _ in 0..4 { + tail.push(*shown_steps(&feed(&mut ind, &[0, 0])).last().unwrap()); + } + assert!(tail[0] > 0, "{tail:?}"); + assert!(tail.windows(2).all(|w| w[1] <= w[0]), "{tail:?}"); + assert_eq!(tail[2..], [0, 0], "{tail:?}"); + // Speech rises at once even while the wave is falling. + let up = feed(&mut ind, &[0, 0, 100, 0]); + assert!(shown_steps(&up)[WIDTH - 2] >= 2, "{up}"); + } + + #[test] + fn full_dots_never_touch() { + // `●` is wider than its cell, so two side by side fuse into one + // blob at 1x: the second of two loud cells, and a loud cell beside + // the anchor, show as the brightest small dot instead. + let capped = (0..STEPS.len()).rfind(|&s| !full(s)).unwrap(); + assert_eq!(STEPS[capped], (SMALL_DOT, 100)); + let mut ind = Indicator::default(); + let mut seed = 99u32; + let mut fulls = 0; + for i in 0..2000 { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + let level = match i % 300 { + // Stretches of sustained loud speech, and of silence. + 0..40 => 100, + 40..60 => 0, + _ => (seed >> 16) as u8 % 101, + }; + let line = if i % 500 < 250 { + rec(level) + } else { + format!("state=recording tag=prompt model=ready mic=present level={level}") + }; + let t = text(&ind.update(&line).unwrap()); + let shown: Vec = plain(&t).chars().collect(); + assert!(shown.windows(2).all(|w| w != [FULL_DOT, FULL_DOT]), "{t}"); + fulls += shown.iter().filter(|&&c| c == FULL_DOT).count() - 1; + } + // Loud speech still shows full dots. + assert!(fulls > 500, "{fulls}"); + // A lone loud syllable keeps its full dot all the way across. + let mut ind = Indicator::default(); + feed(&mut ind, &[0, 0, 100, 100]); + for cell in (0..WIDTH - 2).rev() { + let t = feed(&mut ind, &[0, 0]); + assert_eq!(shown_steps(&t)[cell], STEPS.len() - 1, "{t}"); + } } #[test] @@ -807,8 +1061,12 @@ mod tests { for o in &objects { let v = json(o); let (t, tip) = (v["text"].as_str().unwrap(), v["tooltip"].as_str().unwrap()); - // The only markup: alpha spans, each around one dot. - let mut rest = t; + // The only markup: the wave's one monospace span, and inside + // it alpha spans, each around one dot. + let mut rest = match t.strip_prefix("") { + Some(inner) => inner.strip_suffix("").expect(t), + None => t, + }; while let Some(i) = rest.find('<') { let (head, tail) = rest[i..].split_once("%\">").expect(t); let alpha = head.strip_prefix(" e.clone(), }; - footer.child(note("error", words, k.c.danger.fg)) + // The same cue as a pending restart: an ember dot, plain ink. + footer + .child(look::dot(look::ember()).key("fix-dot")) + .child(note("error", words, k.c.text_base)) } (_, Status::Failed(e)) => footer.child(note("error", e.clone(), k.c.danger.fg)), (_, Status::Applied) => footer @@ -1220,6 +1223,13 @@ mod tests { rt.update(|s, _| s.set("prompt_tag", Value::String("x".repeat(65)))); rt.frame(1.0 / 60.0); assert!(rt.find("error").is_some(), "the error is shown"); + // Led by the ember dot, as the restart notes are. + let dot = rt.find("fix-dot").expect("the footer's dot"); + let note = rt.find("error").unwrap(); + assert!( + dot.0 < note.0 && (dot.1 - note.1).abs() < 0.5, + "dot {dot:?}, note {note:?}" + ); let e = rt.state.config.clone().unwrap_err(); assert!(e.contains("prompt_tag") && !e.contains("xxxx"), "{e}"); press(&mut rt, "apply"); From def01a3eb50fd14895d2711d91efd5cca163255e Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 22:48:06 -0800 Subject: [PATCH 12/18] linux: retry the single-window test's claim while a forked child still holds the lock Rune runs fc-list for each test window. Until that child execs it holds a copy of every open file, including the lock of a window that just closed, so a claim straight after the close could fail in the test process. --- linux/gui/src/instance.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/linux/gui/src/instance.rs b/linux/gui/src/instance.rs index 2bce888e..25519072 100644 --- a/linux/gui/src/instance.rs +++ b/linux/gui/src/instance.rs @@ -149,11 +149,21 @@ mod tests { } fn first(dir: &Path) -> Listener { - match claim(dir, "") { - Ok(Instance::First(l)) => l, - Ok(Instance::Forwarded) => panic!("forwarded"), - Err(e) => panic!("{e}"), + // Other tests in this process start child processes (Rune runs + // `fc-list` for its first window). Until it execs, such a child + // holds a copy of every open file, the lock of a window just closed + // included, so a claim right after the close can find it held by + // nobody that answers. Only the test process forks like this: + // try again. + let mut error = String::new(); + for _ in 0..3 { + match claim(dir, "") { + Ok(Instance::First(l)) => return l, + Ok(Instance::Forwarded) => panic!("forwarded"), + Err(e) => error = e, + } } + panic!("{error}") } #[test] From e173f5bc95210b00977f29e2d76a05dfb2d5b6ed Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 22:48:06 -0800 Subject: [PATCH 13/18] linux: keep loud speech steady in the pill and align the settings controls Full dots are now capped when the wave is drawn, by each slot's distance from the anchor, instead of when a cell is stored. Sustained loud speech holds a steady pattern instead of swapping every dot's size about 7.5 times a second. A lone peak keeps its size as it scrolls, and full dots still never touch. The settings window's fields, menus and both segmented controls share one right-hand column, wide enough for "250 ms" without truncation. The minimum width grows to 560 pt, so a row with the unapplied dot and reset button still fits its card. --- linux/crates/lf-daemon/src/waybar.rs | 143 ++++++++++++++++++++++----- linux/gui/src/main.rs | 2 +- linux/gui/src/settings.rs | 99 ++++++++++++++++++- 3 files changed, 214 insertions(+), 30 deletions(-) diff --git a/linux/crates/lf-daemon/src/waybar.rs b/linux/crates/lf-daemon/src/waybar.rs index 38178219..246507fd 100644 --- a/linux/crates/lf-daemon/src/waybar.rs +++ b/linux/crates/lf-daemon/src/waybar.rs @@ -21,9 +21,10 @@ //! proportional bar font still gives them equal widths, and a dot dimmer //! than full is `•`. Its dots are standard //! characters (U+2022, U+25CF), so it needs no Nerd Font. A full dot is a -//! little wider than its cell, so two never stand side by side: the -//! second of two loud cells, and a loud cell beside the anchor, show as -//! the brightest small dot ([`CAPPED`]). +//! little wider than its cell, so two never stand side by side: a loud +//! cell beside another full dot shows as the brightest small dot +//! ([`CAPPED`]) in every other slot, counted from the anchor, so loud +//! speech holds a steady `•●•●•●`. //! - Every other state shows its glyph in the third cell ([`GLYPH_CELL`]). //! Glyphs are Font Awesome icons as Nerd Fonts carry them, drawn wider //! than one cell, so the glyph sits about in the middle; a bar config can @@ -102,8 +103,10 @@ pub const STEPS: [(char, u8); 5] = [ (FULL_DOT, 100), ]; /// The step a full dot shows instead beside another full dot (the anchor -/// included): the brightest small dot, so the two stay apart and the cell -/// still reads as loud. +/// included), in the slots an odd distance from the anchor: the brightest +/// small dot, so the two stay apart and the cell still reads as loud. +/// Applied as the wave is drawn, not to the cells kept, so a loud run's +/// pattern holds still on screen while its cells scroll through it. pub const CAPPED: usize = 2; /// Level lines (every 66 ms while recording) per cell of the wave: a @@ -356,7 +359,7 @@ impl Counts { #[derive(Debug, Default)] pub struct Indicator { /// The current recording's recent cells, oldest first: [`STEPS`] - /// indices, a full dot after another already capped. + /// indices, as heard (capping is the drawing's, see `wave`). cells: VecDeque, /// Levels of the cell still being heard (fewer than /// [`LINES_PER_CELL`]), and the loudest of them. @@ -453,28 +456,38 @@ impl Indicator { } self.follow = f32::from(self.loudest).max(self.follow * FALL); (self.heard, self.loudest) = (0, 0); - let mut s = step(self.follow.round() as u8); - if is_full(s) && self.cells.back().is_some_and(|&b| is_full(b)) { - s = CAPPED; - } if self.cells.len() == HISTORY { self.cells.pop_front(); } - self.cells.push_back(s); + self.cells.push_back(step(self.follow.round() as u8)); } /// `prefix`, the last `cells` cells, newest on the right and padded with - /// dots at rest, then the anchor, in the monospace span. The newest - /// cell sits beside the full anchor, so it is capped too. + /// dots at rest, then the anchor, in the monospace span. Full dots are + /// capped here, by the slot's distance from the anchor (1 beside it): + /// at an odd distance a full cell beside a full one (the anchor counts) + /// shows as [`CAPPED`]; at an even distance it always shows full. Even + /// slots never touch, so neither do full dots; a lone loud cell stays + /// full across the wave (bar the slot beside the anchor); and a run of + /// loud cells holds one steady wave instead of swapping sizes as it + /// scrolls. fn wave(&self, prefix: String, cells: usize) -> String { let shown = self.cells.len().min(cells); - let mut steps: Vec = std::iter::repeat_n(0, cells - shown) + let steps: Vec = std::iter::repeat_n(0, cells - shown) .chain(self.cells.iter().skip(self.cells.len() - shown).copied()) .collect(); - if let Some(newest) = steps.last_mut().filter(|s| is_full(**s)) { - *newest = CAPPED; - } - let dots: String = steps.into_iter().map(dot).collect(); + let full_at = |i: usize| i == cells || steps.get(i).is_some_and(|&s| is_full(s)); + let dots: String = (0..cells) + .map(|i| { + let odd = (cells - i) % 2 == 1; + let beside_full = full_at(i + 1) || (i > 0 && full_at(i - 1)); + if is_full(steps[i]) && odd && beside_full { + dot(CAPPED) + } else { + dot(steps[i]) + } + }) + .collect(); format!("{prefix}{dots}{FULL_DOT}") } } @@ -999,8 +1012,9 @@ mod tests { #[test] fn full_dots_never_touch() { // `●` is wider than its cell, so two side by side fuse into one - // blob at 1x: the second of two loud cells, and a loud cell beside - // the anchor, show as the brightest small dot instead. + // blob at 1x: a loud cell beside another full dot (the anchor + // included) shows as the brightest small dot instead, in every + // other slot from the anchor. let capped = (0..STEPS.len()).rfind(|&s| !full(s)).unwrap(); assert_eq!(STEPS[capped], (SMALL_DOT, 100)); let mut ind = Indicator::default(); @@ -1026,12 +1040,89 @@ mod tests { } // Loud speech still shows full dots. assert!(fulls > 500, "{fulls}"); - // A lone loud syllable keeps its full dot all the way across. - let mut ind = Indicator::default(); - feed(&mut ind, &[0, 0, 100, 100]); - for cell in (0..WIDTH - 2).rev() { - let t = feed(&mut ind, &[0, 0]); - assert_eq!(shown_steps(&t)[cell], STEPS.len() - 1, "{t}"); + } + + /// The watch line of a recording at `level`, a prompt dictation or not. + fn rec_line(prompt: bool, level: u8) -> String { + let tag = if prompt { " tag=prompt" } else { "" }; + format!("state=recording mode=hold{tag} model=ready mic=present level={level}") + } + + #[test] + fn sustained_loud_speech_holds_a_steady_wave() { + // Seconds of loud speech: every cell is loud. Full and small dots + // take turns, and every cell shows the same wave, so no dot swaps + // size in place while the wave scrolls. + let last = STEPS.len() - 1; + for prompt in [false, true] { + let mut ind = Indicator::default(); + let mut waves = Vec::new(); + for i in 0..60 { + let t = text(&ind.update(&rec_line(prompt, 100)).unwrap()); + // Once the run fills the wave. + if i >= LINES_PER_CELL * HISTORY { + waves.push(t); + } + } + assert!(waves.iter().all(|t| *t == waves[0]), "{waves:#?}"); + let want = if prompt { + vec![last, CAPPED, last, CAPPED] + } else { + vec![CAPPED, last, CAPPED, last, CAPPED] + }; + assert_eq!(shown_steps(&waves[0]), want, "{}", waves[0]); + let (marker, wave) = if prompt { + ("\u{203a}", "\u{25cf}\u{2022}\u{25cf}\u{2022}\u{25cf}") + } else { + ("", "\u{2022}\u{25cf}\u{2022}\u{25cf}\u{2022}\u{25cf}") + }; + assert_eq!(plain(&waves[0]), format!("{marker}{wave}")); + + // Loud speech that varies within the full dot's steps: the dots' + // sizes still hold still, cell after cell. + let mut ind = Indicator::default(); + let mut sizes = Vec::new(); + for (i, level) in [60, 100, 75, 90, 100, 65, 85] + .iter() + .cycle() + .take(70) + .enumerate() + { + let t = text(&ind.update(&rec_line(prompt, *level)).unwrap()); + if i >= LINES_PER_CELL * HISTORY { + sizes.push(plain(&t)); + } + } + assert!(sizes.iter().all(|s| *s == sizes[0]), "{sizes:#?}"); + } + } + + #[test] + fn a_lone_peak_keeps_its_size_as_it_scrolls() { + // One loud syllable between quiet ones: beside the anchor it is the + // brightest small dot (full dots never touch), then a full dot in + // every cell it crosses, until it leaves the wave. + let last = STEPS.len() - 1; + for prompt in [false, true] { + let cells = if prompt { WIDTH - 2 } else { WIDTH - 1 }; + let mut ind = Indicator::default(); + for _ in 0..2 * LINES_PER_CELL { + ind.update(&rec_line(prompt, 0)); + } + ind.update(&rec_line(prompt, 100)); + let t = text(&ind.update(&rec_line(prompt, 100)).unwrap()); + assert_eq!(shown_steps(&t)[cells - 1], CAPPED, "{t}"); + for cell in (0..cells - 1).rev() { + ind.update(&rec_line(prompt, 0)); + let t = text(&ind.update(&rec_line(prompt, 0)).unwrap()); + let steps = shown_steps(&t); + assert_eq!(steps[cell], last, "{prompt} {t}"); + // It is the only full dot. + assert_eq!(steps.iter().filter(|&&s| full(s)).count(), 1, "{t}"); + } + ind.update(&rec_line(prompt, 0)); + let t = text(&ind.update(&rec_line(prompt, 0)).unwrap()); + assert!(shown_steps(&t).iter().all(|&s| !full(s)), "{t}"); } } diff --git a/linux/gui/src/main.rs b/linux/gui/src/main.rs index 099fa540..18a97648 100644 --- a/linux/gui/src/main.rs +++ b/linux/gui/src/main.rs @@ -157,7 +157,7 @@ fn run_settings() -> i32 { .expect("settings are made once, before the window") } let app = rune::App::new(AppSpec:: { - min_size: Some((520.0, 400.0)), + min_size: Some(settings::MIN_SIZE), on_key: Some(settings::key), ..AppSpec::new("LocalFlow Settings", (600.0, 880.0), init, settings::view) }); diff --git a/linux/gui/src/settings.rs b/linux/gui/src/settings.rs index f913bd6b..66d3ef35 100644 --- a/linux/gui/src/settings.rs +++ b/linux/gui/src/settings.rs @@ -596,8 +596,14 @@ fn menu( .build(k, layer) } -/// Text fields and menus share one width, so their edges line up. -const FIELD_WIDTH: f32 = 200.0; +/// Text fields, menus and segmented controls share one width, so the +/// right side of every card is one column. Four segments need it: each is +/// as wide as "250 ms" and its padding. +const FIELD_WIDTH: f32 = 288.0; + +/// The window's smallest size: wide enough for the column of controls +/// beside the longest label with its dot and reset button. +pub const MIN_SIZE: (f32, f32) = (560.0, 400.0); /// `precision`'s values, from the fastest (README, `precision`), named for /// speed alone. "Auto" is the model's own (no `precision` key): i8x1 for @@ -700,6 +706,7 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { s.set("double_tap_ms", json!(DOUBLE_TAP[i].1)) }) .build(&seg) + .min_w(FIELD_WIDTH) .key("choice-double_tap_ms"); let prompt = card( &k, @@ -736,6 +743,7 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { .fit() .on_pick(|s: &mut Settings, i: usize, _: &mut Tx| s.set("key_delay_ms", json!(DELAY[i].1))) .build(&seg) + .min_w(FIELD_WIDTH) .key("choice-key_delay_ms"); let picker = menu( &k, @@ -1124,10 +1132,13 @@ mod tests { self.window_with(None) } fn window_with(&self, daemon: Option) -> Runtime { + self.window_at(daemon, 560.0, 1.0) + } + fn window_at(&self, daemon: Option, width: f32, scale: f32) -> Runtime { let layers = Layers::load(&self.config(), Path::new("/d")).unwrap(); let s = Settings::new(self.config(), "/d".into(), layers, daemon).unwrap(); // Tall enough that nothing needs scrolling. - let mut rt = Runtime::new(s, view, (560.0, 1400.0), Env::new(true, 1.0)); + let mut rt = Runtime::new(s, view, (width, 1400.0), Env::new(true, scale)); rt.on_key = Some(key); rt.frame(0.0); rt @@ -1709,6 +1720,88 @@ mod tests { assert!(!rt.state.closed, "asks again"); } + #[test] + fn the_controls_share_one_column() { + // Segmented controls, menus and text fields start and end at the + // same x, so the right side of every card reads as one column. It + // holds at the smallest window too, with every row's label at its + // widest (changed, not applied: a dot and a reset button), and no + // control reaches past its row. + let d = Dir::new("column", r#"{"version": 1}"#); + let controls = [ + ("double_tap_ms", "choice-double_tap_ms", json!(600)), + ("prompt_tag", "prompt-tag-field", json!("[synthetic]")), + ("output", "output-select-trigger", json!("portal")), + ("key_delay_ms", "choice-key_delay_ms", json!(10)), + ("picker", "picker-select-trigger", json!("gui")), + ("precision", "precision-select-trigger", json!("f32")), + ("input_device", "microphone-field", json!("Synthetic mic")), + ]; + let widest = |scale: f32| { + let mut rt = d.window_at(None, MIN_SIZE.0, scale); + rt.update(|s, _| { + for (key, _, value) in &controls { + s.set(key, value.clone()); + } + }); + rt.frame(1.0 / 60.0); + for (key, _, _) in &controls { + assert!(rt.find(&format!("reset-{key}")).is_some(), "{key}"); + assert!(rt.find(&format!("unapplied-{key}")).is_some(), "{key}"); + } + rt + }; + // At 2x text is hinted, and a little wider. + for rt in [d.window(), widest(1.0), widest(2.0)] { + let rects: Vec<[f32; 4]> = controls + .iter() + .map(|(_, k, _)| rt.presented(k).unwrap_or_else(|| panic!("no {k}")).0) + .collect(); + for ((key, k, _), r) in controls.iter().zip(&rects) { + assert!((r[0] - rects[0][0]).abs() < 0.5, "{k} starts at {r:?}"); + let (right, first) = (r[0] + r[2], rects[0][0] + rects[0][2]); + assert!((right - first).abs() < 0.5, "{k} ends at {r:?}"); + let row = rt.presented(key).unwrap().0; + assert!( + right <= row[0] + row[2] - space::S3 + 0.5, + "{k} {r:?} in {row:?}" + ); + } + } + // Nothing is squeezed to get there: each segment is at least as + // wide as its label needs. + for scale in [1.0, 2.0] { + let rt = d.window_at(None, 560.0, scale); + for (key, labels) in [ + ( + "choice-double_tap_ms", + ["Off", "250 ms", "400 ms", "600 ms"], + ), + ("choice-key_delay_ms", ["0 ms", "1 ms", "5 ms", "10 ms"]), + ] { + // The same control, as wide as its labels make it. + let mut fit = Runtime::new( + labels, + |labels: &[&str; 4], _: &mut Cx<[&str; 4]>| { + let seg = look::segments(&look::kit()); + row().child( + SegmentedControl::new(labels, Some(0)) + .fit() + .build(&seg) + .key("fit"), + ) + }, + (560.0, 100.0), + Env::new(true, scale), + ); + fit.frame(0.0); + let need = fit.presented("fit").unwrap().0[2]; + let have = rt.presented(key).unwrap().0[2]; + assert!(have + 0.5 >= need, "{key} at {scale}x: {have} < {need}"); + } + } + } + #[test] fn picking_a_segment_sets_its_value() { let d = Dir::new("segments", r#"{"version": 1}"#); From b71b6782d19cd944a48256708f115e2cfeef1d94 Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 6 Nov 2026 23:08:40 -0800 Subject: [PATCH 14/18] linux: Apply keeps the restart note when the daemon check is unclear, with a bounded connect Apply checks whether a daemon that isn't a running user unit still answers. That check now tells definite absence (no socket, or nobody listening) from an unclear result: a timeout, a busy reply, or a bad answer. Only absence counts as nothing to restart. A busy or slow daemon keeps the restart-by-hand note instead of showing success while it runs the old settings. The connect is nonblocking with one 500 ms deadline, so a full listen backlog can't freeze the window. localflowctl's connect moved into lf_daemon::server, so both use the same code. Adding a voice macro puts the cursor in its Say field. --- linux/CHANGELOG.md | 6 +- .../crates/lf-daemon/src/bin/localflowctl.rs | 81 +----- linux/crates/lf-daemon/src/server.rs | 149 ++++++++++ linux/gui/src/settings.rs | 265 ++++++++++++++++-- 4 files changed, 398 insertions(+), 103 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 1733e21d..a86f65d5 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -22,8 +22,10 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. daemon does not restart after **Apply**, the window says the settings were saved and keeps saying so, with **Retry**, until a restart works; a daemon started outside its systemd user unit is not silently skipped, - the window asks you to restart it. **Speed** names `precision` from - Fastest to Slowest. The dialogs' smallest size fits every choice. + the window asks you to restart it (also when it is busy or does not + answer within half a second). **Speed** names `precision` from + Fastest to Slowest. Adding a voice macro puts the cursor in its Say + field. The dialogs' smallest size fits every choice. ### Changed diff --git a/linux/crates/lf-daemon/src/bin/localflowctl.rs b/linux/crates/lf-daemon/src/bin/localflowctl.rs index 0a5f7581..9e4e2e90 100644 --- a/linux/crates/lf-daemon/src/bin/localflowctl.rs +++ b/linux/crates/lf-daemon/src/bin/localflowctl.rs @@ -43,8 +43,6 @@ //! - 128 + N: interrupted by signal N (`fetch-model`, after cleaning up). use std::io::{ErrorKind, Read, Write}; -use std::os::fd::{FromRawFd, OwnedFd}; -use std::os::unix::ffi::OsStrExt; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -58,7 +56,7 @@ use lf_daemon::protocol::{ monotonic_ns, parse_dec, parse_reply, printable_fields, reply_field, request_line, valid_set_name, }; -use lf_daemon::server::peer_uid; +use lf_daemon::server::{self, ConnectError, peer_uid}; use lf_daemon::tray::{Tray, TrayError}; use lf_daemon::waybar::{Class, Indicator}; use lf_daemon::{dictionary, paths, t3}; @@ -1211,74 +1209,17 @@ fn io_failure(e: std::io::Error) -> Failure { } } -/// Connects without blocking past `deadline` (a Unix stream connect blocks -/// while the listen backlog is full). +/// Connects without blocking past `deadline` (see [`server::connect`]). fn connect(path: &Path, deadline: Instant) -> Result { - let bytes = path.as_os_str().as_bytes(); - // SAFETY: zeroed sockaddr_un is valid; the path fits (checked below). - let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; - if bytes.len() >= addr.sun_path.len() { - return Err(Failure::Unreachable(format!( - "socket path {} is too long", - path.display() - ))); - } - addr.sun_family = libc::AF_UNIX as libc::sa_family_t; - for (dst, &src) in addr.sun_path.iter_mut().zip(bytes) { - *dst = src as libc::c_char; - } - // SAFETY: plain socket creation; the descriptor is owned immediately. - let fd = unsafe { - libc::socket( - libc::AF_UNIX, - libc::SOCK_STREAM | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK, - 0, - ) - }; - if fd < 0 { - return Err(Failure::Protocol( - std::io::Error::last_os_error().to_string(), - )); - } - // SAFETY: `fd` is a fresh descriptor owned by nothing else. - let fd = unsafe { OwnedFd::from_raw_fd(fd) }; - let len = std::mem::size_of::() as libc::socklen_t; - loop { - use std::os::fd::AsRawFd; - // SAFETY: `addr` is a valid sockaddr_un of `len` bytes. - let rc = unsafe { - libc::connect( - fd.as_raw_fd(), - (&addr as *const libc::sockaddr_un).cast(), - len, - ) - }; - if rc == 0 { - break; + server::connect(path, deadline).map_err(|e| match e { + ConnectError::Absent => Failure::Unreachable("localflowd is not running".into()), + ConnectError::Timeout => Failure::Timeout, + ConnectError::TooLong => { + Failure::Unreachable(format!("socket path {} is too long", path.display())) } - let err = std::io::Error::last_os_error(); - match err.raw_os_error() { - Some(libc::EINTR) => continue, - Some(libc::EAGAIN) => { - if Instant::now() >= deadline { - return Err(Failure::Timeout); - } - std::thread::sleep(Duration::from_millis(10)); - } - Some(libc::ENOENT | libc::ECONNREFUSED) => { - return Err(Failure::Unreachable("localflowd is not running".into())); - } - _ => { - return Err(Failure::Unreachable(format!( - "cannot connect to {}: {err}", - path.display() - ))); - } + ConnectError::Socket(e) => Failure::Protocol(e.to_string()), + ConnectError::Connect(e) => { + Failure::Unreachable(format!("cannot connect to {}: {e}", path.display())) } - } - let stream = UnixStream::from(fd); - stream - .set_nonblocking(false) - .map_err(|e| Failure::Protocol(e.to_string()))?; - Ok(stream) + }) } diff --git a/linux/crates/lf-daemon/src/server.rs b/linux/crates/lf-daemon/src/server.rs index 20ecfa82..1f8a809c 100644 --- a/linux/crates/lf-daemon/src/server.rs +++ b/linux/crates/lf-daemon/src/server.rs @@ -221,6 +221,87 @@ impl Server { } } +/// Why [`connect`] failed. +#[derive(Debug)] +pub enum ConnectError { + /// No daemon runs: there is no socket at the path, or nothing listens + /// on it. + Absent, + /// The deadline passed while the daemon's listen backlog was full. + Timeout, + /// The path does not fit a Unix socket address. + TooLong, + /// The client's socket could not be created. + Socket(std::io::Error), + /// Any other failure to connect. + Connect(std::io::Error), +} + +/// Connects a client to the control socket at `path` without blocking past +/// `deadline`: a blocking Unix stream connect waits as long as the listen +/// backlog stays full. The stream it returns blocks again. +pub fn connect(path: &Path, deadline: Instant) -> Result { + use std::os::fd::{FromRawFd, OwnedFd}; + use std::os::unix::ffi::OsStrExt; + + let bytes = path.as_os_str().as_bytes(); + // SAFETY: zeroed sockaddr_un is valid; the path fits (checked below). + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + if bytes.len() >= addr.sun_path.len() { + return Err(ConnectError::TooLong); + } + addr.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (dst, &src) in addr.sun_path.iter_mut().zip(bytes) { + *dst = src as libc::c_char; + } + // SAFETY: plain socket creation; the descriptor is owned immediately. + let fd = unsafe { + libc::socket( + libc::AF_UNIX, + libc::SOCK_STREAM | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK, + 0, + ) + }; + if fd < 0 { + return Err(ConnectError::Socket(std::io::Error::last_os_error())); + } + // SAFETY: `fd` is a fresh descriptor owned by nothing else. + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + let len = std::mem::size_of::() as libc::socklen_t; + loop { + // SAFETY: `addr` is a valid sockaddr_un of `len` bytes. + let rc = unsafe { + libc::connect( + fd.as_raw_fd(), + (&addr as *const libc::sockaddr_un).cast(), + len, + ) + }; + if rc == 0 { + break; + } + let err = std::io::Error::last_os_error(); + match err.raw_os_error() { + Some(libc::EINTR) => continue, + // The backlog is full. + Some(libc::EAGAIN) => { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(ConnectError::Timeout); + } + std::thread::sleep(left.min(Duration::from_millis(10))); + } + Some(libc::ENOENT | libc::ECONNREFUSED) => return Err(ConnectError::Absent), + _ => return Err(ConnectError::Connect(err)), + } + } + let stream = UnixStream::from(fd); + stream + .set_nonblocking(false) + .map_err(ConnectError::Socket)?; + Ok(stream) +} + /// The uid of the process on the other end of `stream`. pub fn peer_uid(stream: &UnixStream) -> std::io::Result { let mut cred = libc::ucred { @@ -382,3 +463,71 @@ fn is_text_frame(frame: &[u8]) -> bool { .and_then(crate::protocol::Command::parse) .is_some_and(crate::protocol::Command::is_text) } + +#[cfg(test)] +mod tests { + use super::*; + use std::os::fd::{FromRawFd, OwnedFd}; + use std::os::unix::ffi::OsStrExt; + + /// A socket at `path` listening with a backlog of 0, which never + /// accepts. + fn never_accepting(path: &Path) -> OwnedFd { + // SAFETY: plain socket(2); the descriptor is owned at once. + let fd = unsafe { + OwnedFd::from_raw_fd(libc::socket( + libc::AF_UNIX, + libc::SOCK_STREAM | libc::SOCK_CLOEXEC, + 0, + )) + }; + // SAFETY: an all-zero sockaddr_un is valid. + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + addr.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (dst, &src) in addr.sun_path.iter_mut().zip(path.as_os_str().as_bytes()) { + *dst = src as libc::c_char; + } + let len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `addr` is a valid sockaddr_un of `len` bytes; `fd` is open. + unsafe { + assert_eq!( + libc::bind( + fd.as_raw_fd(), + (&addr as *const libc::sockaddr_un).cast(), + len + ), + 0 + ); + assert_eq!(libc::listen(fd.as_raw_fd(), 0), 0); + } + fd + } + + #[test] + fn a_client_connect_ends_at_its_deadline_or_says_no_daemon_runs() { + let dir = crate::testing::TempDir::new("connect-deadline"); + let soon = || Instant::now() + Duration::from_millis(300); + + assert!(matches!( + connect(&dir.path().join("missing"), soon()), + Err(ConnectError::Absent) + )); + let stale = dir.path().join("stale"); + drop(UnixListener::bind(&stale).unwrap()); + assert!(matches!(connect(&stale, soon()), Err(ConnectError::Absent))); + + // A full backlog: a blocking connect would wait for an accept that + // never comes. + let full = dir.path().join("full"); + let _listener = never_accepting(&full); + // A backlog of 0 admits one pending connection. + let _filler = connect(&full, soon()).unwrap(); + let t0 = Instant::now(); + assert!(matches!(connect(&full, soon()), Err(ConnectError::Timeout))); + let took = t0.elapsed(); + assert!( + took >= Duration::from_millis(250) && took < Duration::from_secs(2), + "took {took:?}" + ); + } +} diff --git a/linux/gui/src/settings.rs b/linux/gui/src/settings.rs index 66d3ef35..418d0f81 100644 --- a/linux/gui/src/settings.rs +++ b/linux/gui/src/settings.rs @@ -66,8 +66,20 @@ pub struct Daemon { pub unit_active: fn() -> bool, /// Restarts the user unit. pub restart_unit: fn() -> Result<(), String>, - /// Whether a daemon answers on the control socket. - pub answers: fn() -> bool, + /// Asks for the daemon's status on the control socket. + pub probe: fn() -> Probe, +} + +/// What asking for the daemon's status on the control socket found. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Probe { + /// A daemon answered. + Answered, + /// None runs: there is no socket, or nothing listens on it. + Absent, + /// Something may be there, but no proper answer came in time: a + /// timeout, `error busy` or another error, a malformed reply. + Unclear, } /// What restarting the daemon over saved settings did. @@ -85,21 +97,23 @@ impl Daemon { pub const SYSTEM: Daemon = Daemon { unit_active, restart_unit, - answers, + probe, }; /// Restarts the daemon if it runs as the user unit. `try-restart` alone - /// would succeed and do nothing for one started some other way. + /// would succeed and do nothing for one started some other way. Only a + /// daemon known to be absent needs nothing: one that did not answer + /// properly may still run the old settings. pub fn restart(&self) -> Restarted { if (self.unit_active)() { match (self.restart_unit)() { Ok(()) => Restarted::Done, Err(e) => Restarted::Failed(e), } - } else if (self.answers)() { - Restarted::Manual - } else { + } else if (self.probe)() == Probe::Absent { Restarted::Done + } else { + Restarted::Manual } } } @@ -421,24 +435,52 @@ fn restart_unit() -> Result<(), String> { } } -/// Whether this user's daemon answers a `status` request on the control -/// socket within half a second. -fn answers() -> bool { +/// How long Apply waits for the daemon's status, connecting included. It +/// runs on the window's thread, on Apply only. +const PROBE_WAIT: std::time::Duration = std::time::Duration::from_millis(500); + +/// Asks this user's daemon for its status on the control socket. Without a +/// socket path to ask, it cannot tell that none runs. +fn probe() -> Probe { + match lf_daemon::paths::socket_path_for_client() { + Ok(path) => probe_at(&path, std::time::Instant::now() + PROBE_WAIT), + Err(_) => Probe::Unclear, + } +} + +/// Asks for the status on the socket at `path`, until `deadline` at most. +/// Only a missing socket, or one nothing listens on, is [`Probe::Absent`]: +/// a timeout, `error busy`, any other error, a malformed reply or another +/// user's socket may still be a daemon. +fn probe_at(path: &std::path::Path, deadline: std::time::Instant) -> Probe { + use lf_daemon::server::{ConnectError, connect}; + let mut stream = match connect(path, deadline) { + Ok(stream) => stream, + Err(ConnectError::Absent) => return Probe::Absent, + Err(_) => return Probe::Unclear, + }; + if status_ok(&mut stream, deadline) { + Probe::Answered + } else { + Probe::Unclear + } +} + +/// Whether `stream` is this user's daemon and replies `ok` to `status` +/// before `deadline`. +fn status_ok(stream: &mut std::os::unix::net::UnixStream, deadline: std::time::Instant) -> bool { use lf_daemon::protocol::{ClientReply, Command, MAX_LINE, Request, parse_reply, request_line}; - use std::io::{BufRead, BufReader, Read, Write}; - use std::os::unix::net::UnixStream; + use std::io::{ErrorKind, Read, Write}; - let Ok(path) = lf_daemon::paths::socket_path_for_client() else { - return false; + // `None` once the deadline has passed (a `None` timeout would block). + let left = || { + Some(deadline.saturating_duration_since(std::time::Instant::now())).filter(|d| !d.is_zero()) }; - let Ok(mut stream) = UnixStream::connect(path) else { + if lf_daemon::server::peer_uid(stream).ok() != Some(lf_daemon::paths::euid()) { return false; - }; - let wait = Some(std::time::Duration::from_millis(500)); - let mine = lf_daemon::server::peer_uid(&stream).ok() == Some(lf_daemon::paths::euid()); - if !mine - || stream.set_read_timeout(wait).is_err() - || stream.set_write_timeout(wait).is_err() + } + let Some(wait) = left() else { return false }; + if stream.set_write_timeout(Some(wait)).is_err() || stream .write_all(request_line(&Request::new(Command::Status)).as_bytes()) .is_err() @@ -446,10 +488,26 @@ fn answers() -> bool { return false; } let mut line = Vec::new(); - let read = BufReader::new(stream.take(MAX_LINE as u64)).read_until(b'\n', &mut line); - read.is_ok() - && line.pop() == Some(b'\n') - && matches!(parse_reply(&line), Some(ClientReply::Ok(_))) + let mut chunk = [0u8; 512]; + let end = loop { + if let Some(end) = line.iter().position(|&b| b == b'\n') { + break end; + } + if line.len() >= MAX_LINE { + return false; + } + let Some(wait) = left() else { return false }; + if stream.set_read_timeout(Some(wait)).is_err() { + return false; + } + match stream.read(&mut chunk) { + Ok(0) => return false, + Ok(n) => line.extend_from_slice(&chunk[..n]), + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(_) => return false, + } + }; + end < MAX_LINE && matches!(parse_reply(&line[..end]), Some(ClientReply::Ok(_))) } /// A labelled row: the label, a glacier dot while its draft is not applied, @@ -882,7 +940,10 @@ pub fn view(s: &Settings, _cx: &mut Cx) -> El { .variant(IconButtonVariant::Ghost) .label("Add macro") .on_press(|s: &mut Settings| { - s.macros.push((TextEdit::default(), TextEdit::default())) + // Typing goes straight into the new row's "Say". + let mut say = TextEdit::default(); + say.focused = true; + s.macros.push((say, TextEdit::default())) }) .build(&k) .key("add-macro"), @@ -1449,28 +1510,47 @@ mod tests { false } + fn answered() -> Probe { + Probe::Answered + } + + fn absent() -> Probe { + Probe::Absent + } + + fn unclear() -> Probe { + Probe::Unclear + } + /// The user unit runs, and restarting it fails. const FAILING: Daemon = Daemon { unit_active: yes, restart_unit: restart_fails, - answers: yes, + probe: answered, }; const WORKING: Daemon = Daemon { unit_active: yes, restart_unit: restart_works, - answers: yes, + probe: answered, }; /// Started some other way (`exec-once`): the unit is not active, but the /// daemon answers on its socket. const OUTSIDE_SYSTEMD: Daemon = Daemon { unit_active: no, restart_unit: restart_works, - answers: yes, + probe: answered, + }; + /// Started some other way, and its socket is there, but it did not + /// answer properly (it timed out, was busy, or replied with an error). + const OUTSIDE_SYSTEMD_SILENT: Daemon = Daemon { + unit_active: no, + restart_unit: restart_works, + probe: unclear, }; const NOT_RUNNING: Daemon = Daemon { unit_active: no, restart_unit: restart_works, - answers: no, + probe: absent, }; #[test] @@ -1482,11 +1562,107 @@ mod tests { // Not the user unit: try-restart would do nothing and succeed, so // it is not run. assert_eq!(OUTSIDE_SYSTEMD.restart(), Restarted::Manual); - // Nothing running reads the settings when it starts. + // Only a daemon known to be absent reads the settings when it + // starts; one that may be there but did not answer is still left + // to the user. assert_eq!(NOT_RUNNING.restart(), Restarted::Done); + assert_eq!(OUTSIDE_SYSTEMD_SILENT.restart(), Restarted::Manual); assert_eq!(restarts(), before + 2); } + #[test] + fn a_daemon_that_does_not_answer_properly_still_needs_a_restart() { + let d = Dir::new("unclear", r#"{"version": 1}"#); + let mut rt = d.window_with(Some(OUTSIDE_SYSTEMD_SILENT)); + press(&mut rt, "switch-history"); + press(&mut rt, "apply"); + assert_eq!(d.settings(), Some(json!({"history": true})), "saved"); + assert_eq!(rt.state.pending, Some(Pending::Manual)); + assert!(rt.find("restart-manual").is_some()); + assert!(rt.find("applied").is_none(), "no claim it took effect"); + } + + /// A private directory for synthetic control sockets. + struct Sockets(PathBuf); + impl Sockets { + fn new(name: &str) -> Sockets { + use std::os::unix::fs::DirBuilderExt; + let d = std::env::temp_dir().join(format!("lf-gui-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::DirBuilder::new().mode(0o700).create(&d).unwrap(); + Sockets(d) + } + /// A socket that answers one connection's request with `reply` + /// (`None`: closes without a word), on its own thread. + fn replying(&self, name: &str, reply: Option<&'static str>) -> PathBuf { + use std::io::{BufRead, BufReader, Write}; + let path = self.0.join(name); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut line = Vec::new(); + let _ = BufReader::new(&stream).read_until(b'\n', &mut line); + if let Some(reply) = reply { + let _ = stream.write_all(reply.as_bytes()); + } + }); + path + } + } + impl Drop for Sockets { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn the_probe_tells_an_absent_daemon_from_one_that_did_not_answer() { + use std::time::{Duration, Instant}; + let dir = Sockets::new("probe"); + let soon = || Instant::now() + Duration::from_millis(300); + + // Absent: no socket file, or nothing listening on it. + assert_eq!(probe_at(&dir.0.join("missing"), soon()), Probe::Absent); + let stale = dir.0.join("stale"); + drop(std::os::unix::net::UnixListener::bind(&stale).unwrap()); + assert!(stale.exists()); + assert_eq!(probe_at(&stale, soon()), Probe::Absent); + + // A proper status reply. + let ok = dir.replying("ok", Some("localflow/1 ok state=idle model=ready\n")); + assert_eq!(probe_at(&ok, soon()), Probe::Answered); + + // Anything else may be a daemon: busy, another error, a malformed + // reply, or none. + for (name, reply) in [ + ("busy", Some("localflow/1 error busy\n")), + ("bad-request", Some("localflow/1 error bad-request\n")), + ("garbage", Some("synthetic nonsense\n")), + ( + "unterminated", + Some("localflow/1 ok state=idle model=ready"), + ), + ("closed", None), + ] { + let path = dir.replying(name, reply); + assert_eq!(probe_at(&path, soon()), Probe::Unclear, "{name}"); + } + + // Accepted (the backlog takes it) but never answered: it times out + // at the deadline, not before and not long after. + let silent = dir.0.join("silent"); + let _listener = std::os::unix::net::UnixListener::bind(&silent).unwrap(); + let t0 = Instant::now(); + assert_eq!(probe_at(&silent, soon()), Probe::Unclear); + let took = t0.elapsed(); + assert!( + took >= Duration::from_millis(250) && took < Duration::from_secs(2), + "took {took:?}" + ); + } + #[test] fn a_failed_restart_keeps_the_save_and_offers_retry() { let d = Dir::new("restart", r#"{"version": 1}"#); @@ -1633,6 +1809,33 @@ mod tests { assert!(rt.find("reset-voice_macros").is_some()); } + #[test] + fn typing_after_adding_a_macro_fills_its_say_field() { + let d = Dir::new( + "macro-focus", + r#"{"version": 1, "voice_macros": [{"command": "sign off", "payload": "Synthetic regards"}]}"#, + ); + let type_text = |rt: &mut Runtime, text: &str| { + rt.ime(rune::Ime::Commit(text.into())); + rt.frame(1.0 / 60.0); + }; + let mut rt = d.window(); + press(&mut rt, "add-macro"); + type_text(&mut rt, "synthetic hello"); + assert_eq!(rt.state.macros.len(), 2); + assert_eq!(rt.state.macros[1].0.text, "synthetic hello"); + assert_eq!(rt.state.macros[1].1.text, ""); + assert_eq!(rt.state.macros[0].0.text, "sign off"); + + // From another text field too: the new row takes the keyboard. + let mut rt = d.window(); + press(&mut rt, "prompt-tag-field"); + press(&mut rt, "add-macro"); + type_text(&mut rt, "synthetic bye"); + assert_eq!(rt.state.macros[1].0.text, "synthetic bye"); + assert_eq!(rt.state.prompt_tag.text, "[dictated]"); + } + fn escape(rt: &mut Runtime) { rt.key(KeyEvent { key: Key::Escape, From 15c5bf6702dc58d76d0aec6d61c7d19f485d3d5f Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 7 Nov 2026 09:44:55 -0800 Subject: [PATCH 15/18] linux: make the speaking pill the iPhone keyboard's island, not its Live Activity Recording is now a row of eight small white dots that settles into an aurora anchor dot, as the keyboard's dictation island does, instead of the Live Activity's coral wave. The dots are half-size glyphs with a narrow gap, so even two full dots stay apart without the old cap rule. One level line moves the row one dot, through the keyboard's 25 ms rise and 90 ms fall, and the row rises out of the line at its left end. Transcribing shows the keyboard's glacier-white shimmer, a spot sweeping a resting row every 1.1 s, instead of the hourglass. localflowctl draws its frames ten times a second, only while transcribing. The CSS makes recording white and transcribing glacier white. It adds a min-width, so the clock holds still at other bar font sizes too. --- linux/CHANGELOG.md | 39 +- linux/README.md | 81 +- .../crates/lf-daemon/src/bin/localflowctl.rs | 55 +- linux/crates/lf-daemon/src/waybar.rs | 1352 +++++++++++------ linux/crates/lf-daemon/tests/daemon.rs | 85 +- 5 files changed, 1105 insertions(+), 507 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index a86f65d5..d618937c 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -30,23 +30,28 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. ### Changed - The Waybar indicator (`localflowctl watch --waybar`) looks like the - iPhone app's: a wave of round dots while recording instead of bars, and - every state six characters wide, so the module and the clock beside it - no longer move. The wave ends in a full, bright dot, so it reads as - listening from the first moment, even in silence. The dots before it - grow and brighten with your voice at the iPhone's pace, a syllable - crossing in about two thirds of a second, and fade over a couple of - cells after a word; in silence they rest as a row of small dim dots. - They are standard characters with Pango markup, set in the `monospace` - font so the clock holds still while you speak even when the bar's font - is proportional, and need no Nerd Font (keep the module's `escape` off). - Each state has its own glyph: a clock while the model loads, the - hourglass while transcribing, a keyboard while typing, the microphone - with a slash without a microphone, power-off when LocalFlow isn’t - running, and a raised hand while an agent needs your approval (unless - there is no microphone). A prompt dictation shows `›` before the wave. - Tooltips use plain words ("LocalFlow: ready", "2 dictionary suggestions - waiting"). The README's CSS uses the iPhone app's colours and adds + iPhone keyboard's dictation island, and every state is six cells wide, so + the module and the clock beside it no longer move. While recording it + shows a row of small white dots instead of bars, ending in a larger + aurora dot, so it reads as listening from the first moment, even in + silence. Your voice crosses the row in about half a second: a syllable + rises at once beside the aurora dot and fades within a few dots, so + syllables read as separate pulses, and the row rises out of the line at + its left end; in silence the dots rest small and dim. While transcribing, + the row rests without the aurora dot and a glacier-white spot sweeps + across it every 1.1 s, drawn by `localflowctl` itself ten times a second + and only in that state. The dots are standard characters with Pango + markup, at half the bar font's size and set in the `monospace` font, so + the clock holds still while you speak even when the bar's font is + proportional, and need no Nerd Font (keep the module's `escape` off); the + aurora dot's colour is in the markup, so it shows without the README's + CSS. Each other state has its own glyph: a clock while the model loads, a + keyboard while typing, the microphone with a slash without a microphone, + power-off when LocalFlow isn’t running, and a raised hand while an agent + needs your approval (unless there is no microphone). A prompt dictation + shows `›` before the dots. Tooltips use plain words ("LocalFlow: ready", + "2 dictionary suggestions waiting"). The README's CSS uses the iPhone + keyboard's colours, gives the module a fixed `min-width`, and adds `min-length` and `align` to the module. - The recommended model is now [Kea-B 0.1](https://huggingface.co/wildernesslabs/kea-b-0.1): `kea-b` in diff --git a/linux/README.md b/linux/README.md index 49b81881..ab779e45 100644 --- a/linux/README.md +++ b/linux/README.md @@ -360,23 +360,25 @@ being the same package: ### Waybar indicator `localflowctl watch --waybar` prints one JSON object per change for a -Waybar custom module. Every text is six characters wide, so in a monospace -Nerd Font (JetBrainsMono Nerd Font, say) the module and the clock beside it -never move. Classes (also the `alt`) and what each shows: +Waybar custom module. Every text is six cells of a monospace font wide, so +the module and the clock beside it never move. It looks like the iPhone +keyboard's dictation island: a white wave settling into an aurora dot while +you speak, and a glacier-white shimmer while it transcribes. Classes (also +the `alt`) and what each shows: | Class | Text | Tooltip | |---|---|---| | `idle` | microphone | LocalFlow: ready | | `loading` | clock (the model is loading) | LocalFlow: loading the model | -| `recording` | a row of dots ending in a full, bright dot; the dots before it grow and brighten with your voice, newest on the right, and rest small and dim in silence | LocalFlow: listening | -| `transcribing` | hourglass | LocalFlow: transcribing | +| `recording` | a row of small white dots ending in a larger aurora dot; your voice crosses the row as pulses, newest beside the aurora dot, the dots growing and brightening with it, and the row rests small and dim in silence | LocalFlow: listening | +| `transcribing` | the row at rest, without the aurora dot, with a brighter spot sweeping across it | LocalFlow: transcribing | | `typing` | keyboard | LocalFlow: typing | | `nomic` | microphone with a slash (idle with no microphone) | LocalFlow: no microphone | | `offline` | power-off (the daemon is not running) | LocalFlow isn’t running | A prompt dictation (double-tap and hold; `tag=prompt` in `watch` lines) -puts `›` in the first character and adds the class `prompt` while it -records, transcribes and types (`"class": ["recording", "prompt"]`, tooltip +puts `›` in the first cell and adds the class `prompt` while it records, +transcribes and types (`"class": ["recording", "prompt"]`, tooltip "LocalFlow: listening (prompt dictation)"). While an agent needs your approval (see [Coding agents](#coding-agents)), a raised hand takes the glyph's place, except without a microphone, which keeps its slashed @@ -384,23 +386,39 @@ microphone. It keeps retrying every 2 s while the daemon is down, so Waybar never needs restarting. Override the glyphs with `format-icons` keyed by `alt` if you like. -The wave's dots are `•` and `●` (U+2022, U+25CF), which common fonts such -as DejaVu have, and the glyphs are Font Awesome's, which stock Waybar's -style asks for, so with Font Awesome installed no Nerd Font is needed. The -wave is Pango markup, which Waybar reads in custom modules by default: it -is set in the `monospace` font (``), so its -six cells are equally wide even in a proportional bar font, and dimmer -dots are `•`. Leave the module's `escape` off, or -the tags show as text. +The wave shows about the last half second of your voice: each level line +from the daemon (every 66 ms) moves it one dot, and a syllable rises at once +and fades within a few dots, so it crosses the row as a pulse. It rises out +of the line at its left end, as on the keyboard. While transcribing, +`localflowctl` itself draws the shimmer ten frames a second, a sweep every +1.1 s; in every other state it writes only when the state changes. + +The dots are `•` and `●` (U+2022, U+25CF), which common fonts such as DejaVu +have, and the glyphs are Font Awesome's, which stock Waybar's style asks +for, so with Font Awesome installed no Nerd Font is needed. The rows are +Pango markup, which Waybar reads in custom modules by default. They are set +in the `monospace` font (``), so their cells +are equally wide even in a proportional bar font. Each dot is at half the +bar font's size with a sliver of space after it, so eight fit where five +characters would and two full dots never touch; dimmer dots add `alpha`, +and `rise` lifts the smaller dots onto the bar's middle line (tuned for a +bar font of about 13 px). The aurora dot's colour (#6DFFA8) is fixed in the +markup, so it shows on any bar; everything else takes the module's colour. +Leave the module's `escape` off, or the tags show as text. `min-length: 6` makes the module at least six average characters of the bar's font wide, and `align: 0.5` centres the text in that width. A text that fits leaves the width alone; a wider one widens the module and moves the clock. In a monospace bar font that is also fontconfig's `monospace` -(`fc-match monospace`), every text is exactly six cells, so nothing moves. -In a proportional font, such as stock Waybar's DejaVu -Sans, the wave and most glyphs fit; the keyboard and the slashed -microphone are a little wider and move the clock by about a pixel. +(`fc-match monospace`), every text is six cells. The rows mix sizes, and +each size's characters are rounded to whole pixels, so at some font sizes a +row is a pixel or two wider than a glyph's text (JetBrainsMono at 12 or +15 px, for one), which would nudge the clock as a dictation starts. The CSS +below sets a `min-width` a little wider than six cells, which holds the +module's width whatever the text: nothing moves with a JetBrainsMono bar +at 12, 13 or 15 px, nor with stock Waybar's proportional DejaVu Sans at +those sizes, where the keyboard and the slashed microphone are a little +wider than six characters. ```jsonc "custom/localflow": { @@ -412,24 +430,28 @@ microphone are a little wider and move the clock by about a pixel. } ``` -The LocalFlow colours (coral while recording, glacier while working or -waiting on you, ember for a problem), including the agent classes (see +The LocalFlow colours: white while recording, glacier white (the +keyboard's shimmer) while transcribing, glacier while loading, typing or +waiting on you, and ember for a problem, including the agent classes (see [Coding agents](#coding-agents)): ```css #custom-localflow { - padding-left: 6px; - padding-right: 6px; + min-width: 4.05em; + padding-left: 4px; + padding-right: 4px; } #custom-localflow.idle, #custom-localflow.offline { color: rgba(255, 255, 255, 0.4); } #custom-localflow.recording { - color: #ff7d5e; + color: #ffffff; +} +#custom-localflow.transcribing { + color: #abf2fc; } #custom-localflow.loading, -#custom-localflow.transcribing, #custom-localflow.typing, #custom-localflow.agent-input { color: #67e8f9; @@ -917,9 +939,12 @@ your dictation key. daemon sees. 17. Waybar indicator (`localflowctl watch --waybar` in a terminal, or the custom module): idle shows the dim microphone; holding the key shows a - coral wave of dots ending in a full dot at once, and the dots before it - follow your voice, calmly; release shows the hourglass, - the keyboard, then idle. The clock beside it never moves. Disconnect the + row of white dots ending in an aurora dot at once, and your syllables + cross the row as pulses; release shows the glacier-white shimmer + sweeping the row until the text is ready (it stops at once when typing + starts), then the keyboard, then idle. The clock beside it never moves, + and with `watch --waybar` in a terminal no line is printed while idle. + Disconnect the microphone (or turn the headset off): `nomic` (the microphone with a slash) within a second or two, and back to idle when reconnected. With `input_device` set, only that device counts. Stop the daemon: `offline` diff --git a/linux/crates/lf-daemon/src/bin/localflowctl.rs b/linux/crates/lf-daemon/src/bin/localflowctl.rs index 9e4e2e90..828453de 100644 --- a/linux/crates/lf-daemon/src/bin/localflowctl.rs +++ b/linux/crates/lf-daemon/src/bin/localflowctl.rs @@ -957,6 +957,17 @@ impl Watch { /// The next line's `ok` fields; a daemon error, a lost connection or a /// bad line is a failure, and a closed stdout is [`Failure::Closed`]. fn next(&mut self) -> Result { + loop { + // Without `until`, only a line or a failure ends the wait. + if let Some(fields) = self.next_until(None)? { + return Ok(fields); + } + } + } + + /// As [`Watch::next`], but `Ok(None)` once `until` passes without a + /// whole line (a part of one stays buffered for the next call). + fn next_until(&mut self, until: Option) -> Result, Failure> { let mut chunk = [0u8; MAX_LINE]; let end = loop { if let Some(end) = self.buf.iter().position(|&b| b == b'\n') { @@ -965,8 +976,16 @@ impl Watch { if self.buf.len() >= MAX_LINE { return Err(Failure::Protocol("line too long".into())); } - let timeout = self.deadline.map(remaining).transpose()?; + let first = self.deadline.map(remaining).transpose()?; + let until_left = until.map(|u| u.saturating_duration_since(Instant::now())); + let timeout = match (first, until_left) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; if !wait_readable(&self.stream, timeout, self.follow_stdout)? { + if until.is_some_and(|u| Instant::now() >= u) { + return Ok(None); + } return Err(Failure::Timeout); } let n = match self.stream.read(&mut chunk) { @@ -991,7 +1010,7 @@ impl Watch { self.buf.drain(..=end); self.deadline = None; match reply { - Some(ClientReply::Ok(fields)) => Ok(fields), + Some(ClientReply::Ok(fields)) => Ok(Some(fields)), Some(ClientReply::Error(code)) => Err(Failure::Daemon(code)), None => Err(Failure::Protocol("malformed reply".into())), } @@ -1110,7 +1129,10 @@ fn watch(args: &Args) -> Result<(), Failure> { /// `watch --waybar`: runs until stdout is closed. A lost connection is /// retried at once; while the daemon is unreachable the offline object is -/// shown and the connection retried every [`WAYBAR_RETRY`]. +/// shown and the connection retried every [`WAYBAR_RETRY`]. While the +/// daemon is transcribing, the shimmer's next frame is drawn when it is +/// due ([`Indicator::next_frame`]) even without a new line; in every other +/// state nothing is written until a line changes the object. fn watch_waybar(args: &Args) { let mut out = std::io::stdout().lock(); let mut indicator = Indicator::default(); @@ -1123,16 +1145,35 @@ fn watch_waybar(args: &Args) { last = object; ok }; + // When the wait for a transcript began, while it lasts: the shimmer's + // clock. It survives a reconnect, as the indicator's frame does. + let mut waiting: Option = None; let mut retried = false; loop { let began = Instant::now(); let mut connected = false; if let Ok(mut w) = Watch::open(args, true) { loop { - match w.next() { - Ok(fields) => { + let due = waiting + .zip(indicator.next_frame()) + .map(|(since, next)| since + next); + match w.next_until(due) { + Ok(Some(fields)) => { connected = true; - if let Some(object) = indicator.update(&fields) + let object = indicator.update(&fields); + waiting = indicator + .next_frame() + .map(|_| waiting.unwrap_or_else(Instant::now)); + if let Some(object) = object + && !show(&mut out, object) + { + return; + } + } + // The shimmer's next frame is due. + Ok(None) => { + if let Some(object) = + waiting.and_then(|since| indicator.tick(since.elapsed())) && !show(&mut out, object) { return; @@ -1151,6 +1192,8 @@ fn watch_waybar(args: &Args) { continue; } retried = false; + // Offline ends a wait: the next one starts its sweep again. + waiting = None; // An unchanged offline object is not written again, so a closed // stdout is noticed by the wait instead. if !show(&mut out, indicator.offline()) || sleep_unless_stdout_gone(WAYBAR_RETRY) { diff --git a/linux/crates/lf-daemon/src/waybar.rs b/linux/crates/lf-daemon/src/waybar.rs index 246507fd..a012642c 100644 --- a/linux/crates/lf-daemon/src/waybar.rs +++ b/linux/crates/lf-daemon/src/waybar.rs @@ -2,29 +2,32 @@ //! JSON, one object per line: //! //! ```text -//! {"text": "", "alt": "", "class": "", "tooltip": "LocalFlow: ..."} +//! {"text": "", "alt": "", "class": "", "tooltip": "LocalFlow: ..."} //! ``` //! //! Classes (and alts): idle, loading, recording, transcribing, typing, -//! nomic, offline. The text always shows [`WIDTH`] characters, each one -//! cell of a monospace font, so the module and the clock beside it never -//! move between states: +//! nomic, offline. Every text is [`WIDTH`] cells of a monospace font wide, +//! so the module and the clock beside it never move between states. The +//! pill follows the iPhone keyboard's dictation island: //! -//! - Recording shows a wave of round dots that comes to rest in a full, -//! bright anchor dot in the last cell, as the iPhone's wave ends in the -//! mark's dot, so silence still reads as listening. The cells before it -//! show the voice, newest on the right: one cell per two level lines -//! ([`LINES_PER_CELL`]), the louder of the pair, through a follower that -//! rises at once and falls by [`FALL`] a cell; silence is a row of small -//! dim dots. The wave is Pango markup (Waybar's default for custom -//! modules): one `` around its cells, so a -//! proportional bar font still gives them equal widths, and a dot dimmer -//! than full is `•`. Its dots are standard -//! characters (U+2022, U+25CF), so it needs no Nerd Font. A full dot is a -//! little wider than its cell, so two never stand side by side: a loud -//! cell beside another full dot shows as the brightest small dot -//! ([`CAPPED`]) in every other slot, counted from the anchor, so loud -//! speech holds a steady `•●•●•●`. +//! - Recording shows a white wave that comes to rest in the mark's aurora +//! dot (#6DFFA8, [`AURORA`]): a row of [`WAVE_DOTS`] half-size dots along +//! the middle line, the voice's last half second, newest on the right +//! beside the anchor. Each level line (every 66 ms) moves the row one dot +//! to the left, through a follower that rises at once and falls quickly, +//! so a syllable is a pulse that crosses the row and leaves it. Louder +//! dots grow and brighten; in silence the row is calm, equal resting +//! dots. As on the keyboard, the row rises out of the line at its left +//! end ([`ENVELOPE`]). The anchor is always lit, so silence still reads +//! as listening; its colour is set in the markup, so it is aurora on any +//! bar, with or without the README's CSS. The wave's dots take the +//! module's colour (white in the README's CSS). +//! - Transcribing shows the keyboard's wait: the row at rest, without the +//! anchor, with a brighter, larger spot sweeping it left to right every +//! 1.1 s ([`shimmer`]). `localflowctl` draws a frame every +//! [`SHIMMER_FRAME`] while it lasts ([`Indicator::next_frame`], +//! [`Indicator::tick`]), and never in any other state. Its colour is the +//! module's (glacier white, #ABF2FC, in the README's CSS). //! - Every other state shows its glyph in the third cell ([`GLYPH_CELL`]). //! Glyphs are Font Awesome icons as Nerd Fonts carry them, drawn wider //! than one cell, so the glyph sits about in the middle; a bar config can @@ -34,6 +37,21 @@ //! classes, e.g. `"class": ["recording", "prompt"]`, the state's alt, and //! a tooltip such as "LocalFlow: listening (prompt dictation)". //! +//! The rows are Pango markup (Waybar's default for custom modules), in one +//! ``, so a proportional bar font still gives +//! their cells equal widths. A dot is a standard character (U+2022 or +//! U+25CF, so no Nerd Font is needed) in a span at half the bar font's size +//! ([`DOT_SIZE`]), followed by a space an eighth of a cell wide +//! ([`GAP_SIZE`]): eight dots and their gaps take five cells, and even two +//! full dots side by side stay apart. Dimmer dots add `alpha`, and each +//! size has its own `rise` so every dot sits on the bar's middle line. A +//! zero-width space ([`STRUT`]) at the bar font's size leads each row, so +//! its line is as tall as every other state's. The sizes are relative, so +//! a row is six cells as the markup measures it ([`cells`]); each size's +//! advance is rounded to whole pixels, so at some bar font sizes a row +//! comes out a pixel or two wider, which the README's CSS (`min-width`) +//! absorbs. `rise` is absolute, tuned for a bar font of about 13 px. +//! //! Texts and tooltips hold nothing but fixed words, glyphs, digits and //! those spans: nothing in them ever needs escaping. //! @@ -54,13 +72,14 @@ //! suggestions and project words, when there are any. use std::collections::VecDeque; +use std::time::Duration; + +use crate::watch::LEVEL_INTERVAL; /// nf-fa-microphone: ready. pub const MIC_GLYPH: &str = "\u{f130}"; /// nf-fa-clock_o: the model is loading. pub const LOADING_GLYPH: &str = "\u{f017}"; -/// nf-fa-hourglass_half: transcribing. -pub const TRANSCRIBING_GLYPH: &str = "\u{f252}"; /// nf-fa-keyboard_o: typing. pub const TYPING_GLYPH: &str = "\u{f11c}"; /// nf-fa-microphone_slash: no microphone. @@ -72,29 +91,30 @@ pub const APPROVAL_GLYPH: &str = "\u{f256}"; /// The first cell of a prompt dictation's text. pub const PROMPT_MARKER: char = '\u{203a}'; -/// Characters in every text: one cell each in a monospace (Nerd) font. +/// Cells in every text: characters of a monospace (Nerd) font. pub const WIDTH: usize = 6; /// The cell of a state's glyph; the agent count takes the last two. pub const GLYPH_CELL: usize = 2; -/// Cells of the voice kept while recording: every cell but the anchor's. -pub const HISTORY: usize = WIDTH - 1; -/// The wave's small dot, U+2022 bullet: in JetBrainsMono about 0.43 of a -/// cell across, as a resting dot on the iPhone is about half a full one. +/// The row's small dot, U+2022 bullet: in JetBrainsMono about 0.43 of a +/// cell across, as a resting dot on the keyboard is about half a full one. pub const SMALL_DOT: char = '\u{2022}'; -/// The wave's full dot, U+25CF black circle: 620 units of ink in a 600-unit -/// cell in JetBrainsMono (1221 in 1233 in DejaVu Sans Mono), so two side by -/// side would fuse; see [`CAPPED`]. Also the anchor in the last cell. +/// The row's full dot, U+25CF black circle (620 units of ink in a 600-unit +/// cell in JetBrainsMono, 1221 in 1233 in DejaVu Sans Mono). Also the +/// anchor. pub const FULL_DOT: char = '\u{25cf}'; +/// Leads every row, at the bar font's size: a zero-width space, so the row's +/// line is as tall as a glyph state's and its dots sit on the same middle +/// line (a row of smaller text alone is a shorter line, centred +/// differently). +pub const STRUT: char = '\u{200b}'; -/// The wave's steps, quietest first: a dot and its alpha in percent. As the -/// iPhone's DotWave, a dot at rest is half a full dot's size and dimmer, -/// and louder dots grow and brighten together. At rest it is 60%: at 50% -/// the 3 px dot was 2.6:1 on the black pill at 1x, darker than the idle -/// microphone; 60% is 3.4:1. Monospace fonts such as JetBrainsMono and -/// DejaVu Sans Mono draw both dots one cell wide, with no round dot between -/// their sizes (`·` is smaller than a resting one), so alpha carries the -/// steps between. +/// The steps of the row's dots, quietest first: a dot and its alpha in +/// percent. As on the keyboard, a resting dot is half a full one's size and +/// dimmer, and louder dots grow and brighten together. At rest it is 60%: +/// the 2 px dot is 4.8:1 on the black pill at 1x. Monospace fonts draw both +/// dots one cell wide, with no round dot between their sizes (`·` is +/// smaller than a resting one), so alpha carries the steps between. pub const STEPS: [(char, u8); 5] = [ (SMALL_DOT, 60), (SMALL_DOT, 80), @@ -102,26 +122,63 @@ pub const STEPS: [(char, u8); 5] = [ (FULL_DOT, 80), (FULL_DOT, 100), ]; -/// The step a full dot shows instead beside another full dot (the anchor -/// included), in the slots an odd distance from the anchor: the brightest -/// small dot, so the two stay apart and the cell still reads as loud. -/// Applied as the wave is drawn, not to the cells kept, so a loud run's -/// pattern holds still on screen while its cells scroll through it. -pub const CAPPED: usize = 2; - -/// Level lines (every 66 ms while recording) per cell of the wave: a -/// syllable crosses its five cells in about 0.66 s, near the iPhone's -/// 0.75 s, instead of rushing through in 0.33 s. -pub const LINES_PER_CELL: usize = 2; -/// How far the followed level can fall in one cell (132 ms): to 35% of -/// itself, about the iPhone's 90 ms fall plus its softening. It rises at -/// once, so onsets show in the next cell, but a dip between syllables no -/// longer drops to rest and the end of a word fades over two cells. -pub const FALL: f32 = 0.35; - -/// The font of the wave's cells: equal advances even in a proportional bar -/// font. On a monospace bar it is usually the same font. -pub const WAVE_FONT: &str = "monospace"; + +/// The mark's dot the wave settles into, as the keyboard's (`BrandPalette`'s +/// aurora). Fixed in the markup, whatever the bar's CSS. +pub const AURORA: &str = "#6DFFA8"; + +/// Dots in the recording's row, and after a prompt dictation's marker. +pub const WAVE_DOTS: usize = 8; +pub const PROMPT_WAVE_DOTS: usize = 6; +/// Dots in the shimmer's row (it has no anchor), and after the marker. +pub const SHIMMER_DOTS: usize = 9; +pub const PROMPT_SHIMMER_DOTS: usize = 8; + +/// A dot's size, relative to the bar font: two dots' cells in one cell. +pub const DOT_SIZE: &str = "50%"; +/// The space after each dot: an eighth of a cell (1 px in a 13 px font), so +/// the row's pitch is five eighths of a cell and full dots never touch. +pub const GAP_SIZE: &str = "12.5%"; +/// The anchor's size: larger than any dot of the wave, as the mark's dot. +pub const ANCHOR_SIZE: &str = "75%"; +/// The space before the anchor, which with it fills the row's sixth cell +/// (after a prompt marker, [`PROMPT_ANCHOR_PAD`]). +pub const ANCHOR_PAD: &str = "25%"; +pub const PROMPT_ANCHOR_PAD: &str = "50%"; +/// The shimmer's room either side of its nine dots, centring them. +pub const SHIMMER_LEAD: &str = "25%"; +pub const SHIMMER_TRAIL: &str = "12.5%"; + +/// Each glyph's `rise` in Pango units (1/1024 px; measured in JetBrainsMono +/// Bold at 12.9 px and DejaVu Sans Mono at 13 px): lifts the smaller dots +/// back onto the line a full-size glyph's middle sits on. Hinting snaps the +/// two dots differently, so each has its own. +const SMALL_RISE: u32 = 1792; +const FULL_RISE: u32 = 2560; +const ANCHOR_RISE: u32 = 1664; + +/// The keyboard's follower (`DotWave.LevelFollower`): the time constants of +/// the rise and the fall, in seconds. Per level line it closes 93% of the +/// gap upwards and 52% downwards. +const RISE_TIME: f32 = 0.025; +const FALL_TIME: f32 = 0.09; + +/// How far each dot of the row can lift ([`STEPS`] indices), oldest first: +/// the keyboard's envelope (`DotWave.envelope`) at each dot, as a ceiling. +/// None at the left end, so the voice rises out of the line there, all of +/// it from a little past the middle, a little less beside the anchor. +pub const ENVELOPE: [usize; WAVE_DOTS] = [0, 1, 2, 3, 4, 4, 4, 3]; +/// The same for a prompt dictation's shorter row. +pub const PROMPT_ENVELOPE: [usize; PROMPT_WAVE_DOTS] = [0, 1, 3, 4, 4, 3]; + +/// One frame of the shimmer: ten a second. +pub const SHIMMER_FRAME: Duration = Duration::from_millis(100); +/// Frames in one sweep (1.1 s, as the keyboard's `shimmerPeriod`): the spot +/// moves one dot a frame, from one dot before the row to one past it. +pub const SWEEP_FRAMES: usize = 11; +/// The spot's steps at its centre and one and two dots away: the keyboard's +/// glow (a Gaussian a fifth of the row wide) at whole dots. +const SPOT: [usize; 3] = [4, 2, 1]; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Class { @@ -196,12 +253,11 @@ impl Class { } } - /// The glyph of every state but recording, which shows the wave. + /// The glyph of the states without a row of dots. fn glyph(self) -> &'static str { match self { - Class::Idle | Class::Recording => MIC_GLYPH, + Class::Idle | Class::Recording | Class::Transcribing => MIC_GLYPH, Class::Loading => LOADING_GLYPH, - Class::Transcribing => TRANSCRIBING_GLYPH, Class::Typing => TYPING_GLYPH, Class::NoMic => NO_MIC_GLYPH, Class::Offline => OFFLINE_GLYPH, @@ -219,21 +275,66 @@ pub fn step(level: u8) -> usize { (v * (STEPS.len() - 1) as f32).round() as usize } -/// One cell of the wave for a [`STEPS`] index, as Pango markup: the step's -/// dot, in a span with its alpha unless that is 100%. +/// One dot of a row for a [`STEPS`] index, as Pango markup: the dot at +/// [`DOT_SIZE`] with its rise (and its alpha unless that is 100%), then its +/// gap. pub fn dot(step: usize) -> String { - match STEPS[step.min(STEPS.len() - 1)] { - (glyph, 100) => glyph.to_string(), - (glyph, alpha) => format!("{glyph}"), - } + let (glyph, alpha) = STEPS[step.min(STEPS.len() - 1)]; + let rise = if glyph == FULL_DOT { + FULL_RISE + } else { + SMALL_RISE + }; + let alpha = if alpha == 100 { + String::new() + } else { + format!(" alpha=\"{alpha}%\"") + }; + format!( + "{glyph}{}", + space(GAP_SIZE) + ) +} + +/// A space at `size`. +fn space(size: &str) -> String { + format!(" ") } -/// Whether a step shows the full dot. -fn is_full(step: usize) -> bool { - STEPS[step].0 == FULL_DOT +/// `inner` as a row: in the monospace span, after the [`STRUT`]. +fn row(inner: &str) -> String { + format!("{STRUT}{inner}") } -/// A text as Waybar shows it: without the wave's markup. +/// The shimmer's text `elapsed` after transcribing began (a prompt +/// dictation's after its marker): its row of resting dots with the spot +/// one dot further right every [`SHIMMER_FRAME`], from one dot before the +/// row to one past it, a sweep every [`SWEEP_FRAMES`] frames. The first +/// frame has the spot on the first dot, so it reads as working before any +/// timer runs. No anchor. +pub fn shimmer(elapsed: Duration, prompt: bool) -> String { + let frame = (elapsed.as_millis() / SHIMMER_FRAME.as_millis()) as usize; + let spot = ((frame + 1) % SWEEP_FRAMES) as isize - 1; + let n = if prompt { + PROMPT_SHIMMER_DOTS + } else { + SHIMMER_DOTS + }; + let dots: String = (0..n as isize) + .map(|i| dot(SPOT.get(i.abs_diff(spot)).copied().unwrap_or(0))) + .collect(); + if prompt { + row(&format!("{PROMPT_MARKER}{dots}")) + } else { + row(&format!( + "{}{dots}{}", + space(SHIMMER_LEAD), + space(SHIMMER_TRAIL) + )) + } +} + +/// A text as Waybar shows it: without the markup. pub fn plain(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; @@ -245,6 +346,38 @@ pub fn plain(text: &str) -> String { out } +/// A text's width in cells of the bar's font, as its markup asks: each +/// character counts the `size` percentage in force (1 outside any), and the +/// [`STRUT`] none. +pub fn cells(text: &str) -> f64 { + let mut sizes = vec![1.0]; + let mut width = 0.0; + let mut rest = text; + while let Some(c) = rest.chars().next() { + if c == '<' { + let (tag, after) = rest[1..].split_once('>').unwrap_or((rest, "")); + rest = after; + if tag.starts_with('/') { + sizes.pop(); + } else { + let top = *sizes.last().unwrap_or(&1.0); + let size = tag + .split_once("size=\"") + .and_then(|(_, v)| v.split_once("%\"")) + .and_then(|(v, _)| v.parse::().ok()) + .map_or(top, |p| p / 100.0); + sizes.push(size); + } + continue; + } + if c != STRUT { + width += sizes.last().copied().unwrap_or(1.0); + } + rest = &rest[c.len_utf8()..]; + } + width +} + /// A state's [`WIDTH`] cells: the prompt marker or a space, the glyph in /// [`GLYPH_CELL`], and `count` (at most 99) in the last two. fn field(prompt: bool, glyph: &str, count: u32) -> String { @@ -358,16 +491,17 @@ impl Counts { /// Turns a stream of watch lines into Waybar objects. #[derive(Debug, Default)] pub struct Indicator { - /// The current recording's recent cells, oldest first: [`STEPS`] - /// indices, as heard (capping is the drawing's, see `wave`). + /// The current recording's recent dots, oldest first: [`STEPS`] indices + /// of the followed level, one per level line. cells: VecDeque, - /// Levels of the cell still being heard (fewer than - /// [`LINES_PER_CELL`]), and the loudest of them. - heard: usize, - loudest: u8, /// The followed level, 0..=100. follow: f32, recording: bool, + /// While transcribing: the shimmer's frame shown, counted from the + /// start of the wait. + frame: Option, + /// The last watch line's fields, which a shimmer frame shows again. + fields: String, } impl Indicator { @@ -375,13 +509,63 @@ impl Indicator { /// `None` for an unknown state. pub fn update(&mut self, fields: &str) -> Option { let class = Class::from_fields(fields)?; - let mut level = None; + if class == Class::Recording { + if !self.recording { + *self = Indicator::default(); + } + let level = fields + .split(' ') + .find_map(|f| f.strip_prefix("level=")) + .and_then(|v| v.parse::().ok()); + if let Some(l) = level { + self.hear(l); + } + } + self.recording = class == Class::Recording; + // A wait starts its sweep at the first frame; another line while it + // lasts (new counts) keeps the frame. + self.frame = match class { + Class::Transcribing => Some(self.frame.unwrap_or(0)), + _ => None, + }; + fields.clone_into(&mut self.fields); + Some(self.show(class, fields)) + } + + /// While transcribing, how long after the wait began the shimmer's next + /// frame is due; `None` in every other state, so no timer runs. + pub fn next_frame(&self) -> Option { + self.frame.map(|f| SHIMMER_FRAME * (f + 1)) + } + + /// The object for the shimmer `elapsed` after the wait began, or `None` + /// unless transcribing. A frame that is still showing comes back + /// unchanged (`watch --waybar` does not write it again). + pub fn tick(&mut self, elapsed: Duration) -> Option { + let shown = self.frame?; + let due = (elapsed.as_millis() / SHIMMER_FRAME.as_millis()).min(u128::from(u32::MAX)); + self.frame = Some(shown.max(due as u32)); + let fields = std::mem::take(&mut self.fields); + let object = self.show(Class::Transcribing, &fields); + self.fields = fields; + Some(object) + } + + /// The object shown while the daemon is unreachable. + pub fn offline(&mut self) -> String { + self.recording = false; + self.frame = None; + object(Class::Offline, &field(false, Class::Offline.glyph(), 0)) + } + + /// The object for `fields` of `class`, with the wave and the shimmer as + /// they stand. + fn show(&self, class: Class, fields: &str) -> String { let mut prompt = false; let mut n = Counts::default(); for (k, v) in fields.split(' ').filter_map(|f| f.split_once('=')) { let count = || v.parse::().unwrap_or(0); match k { - "level" => level = v.parse::().ok(), "tag" => prompt = v == "prompt", "suggestions" => n.suggestions = count(), "words" => n.words = count(), @@ -392,15 +576,6 @@ impl Indicator { _ => {} } } - if class == Class::Recording { - if !self.recording { - *self = Indicator::default(); - } - if let Some(l) = level { - self.hear(l); - } - } - self.recording = class == Class::Recording; let busy = matches!( class, Class::Recording | Class::Transcribing | Class::Typing @@ -429,66 +604,56 @@ impl Indicator { waiting = n.approval.saturating_add(n.input); } let text = match class { - Class::Recording if prompt => self.wave(PROMPT_MARKER.to_string(), WIDTH - 2), - Class::Recording => self.wave(String::new(), WIDTH - 1), + Class::Recording => self.wave(prompt), + Class::Transcribing => shimmer(SHIMMER_FRAME * self.frame.unwrap_or(0), prompt), _ => field(prompt, glyph, waiting), }; for line in n.tooltip_lines() { tooltip.push('\n'); tooltip += &line; } - Some(render(class, &classes, &text, &tooltip)) + render(class, &classes, &text, &tooltip) } - /// The object shown while the daemon is unreachable. - pub fn offline(&mut self) -> String { - self.recording = false; - object(Class::Offline, &field(false, Class::Offline.glyph(), 0)) - } - - /// One level line while recording: every [`LINES_PER_CELL`] lines, the - /// loudest of them goes through the follower into a new cell. + /// One level line while recording: the follower moves towards it (fast + /// up, quickly down, over one [`LEVEL_INTERVAL`]), and its step is the + /// row's newest dot. fn hear(&mut self, level: u8) { - self.loudest = self.loudest.max(level.min(100)); - self.heard += 1; - if self.heard < LINES_PER_CELL { - return; - } - self.follow = f32::from(self.loudest).max(self.follow * FALL); - (self.heard, self.loudest) = (0, 0); - if self.cells.len() == HISTORY { + let target = f32::from(level.min(100)); + let constant = if target > self.follow { + RISE_TIME + } else { + FALL_TIME + }; + let share = 1.0 - (-LEVEL_INTERVAL.as_secs_f32() / constant).exp(); + self.follow += (target - self.follow) * share; + if self.cells.len() == WAVE_DOTS { self.cells.pop_front(); } self.cells.push_back(step(self.follow.round() as u8)); } - /// `prefix`, the last `cells` cells, newest on the right and padded with - /// dots at rest, then the anchor, in the monospace span. Full dots are - /// capped here, by the slot's distance from the anchor (1 beside it): - /// at an odd distance a full cell beside a full one (the anchor counts) - /// shows as [`CAPPED`]; at an even distance it always shows full. Even - /// slots never touch, so neither do full dots; a lone loud cell stays - /// full across the wave (bar the slot beside the anchor); and a run of - /// loud cells holds one steady wave instead of swapping sizes as it - /// scrolls. - fn wave(&self, prefix: String, cells: usize) -> String { - let shown = self.cells.len().min(cells); - let steps: Vec = std::iter::repeat_n(0, cells - shown) + /// The recording's text: the prompt marker if any, the newest dots + /// (padded at the left with dots at rest), each no higher than the + /// envelope lets it lift there, then the anchor. + fn wave(&self, prompt: bool) -> String { + let envelope: &[usize] = if prompt { &PROMPT_ENVELOPE } else { &ENVELOPE }; + let n = envelope.len(); + let shown = self.cells.len().min(n); + let dots: String = std::iter::repeat_n(0, n - shown) .chain(self.cells.iter().skip(self.cells.len() - shown).copied()) + .zip(envelope) + .map(|(s, &most)| dot(s.min(most))) .collect(); - let full_at = |i: usize| i == cells || steps.get(i).is_some_and(|&s| is_full(s)); - let dots: String = (0..cells) - .map(|i| { - let odd = (cells - i) % 2 == 1; - let beside_full = full_at(i + 1) || (i > 0 && full_at(i - 1)); - if is_full(steps[i]) && odd && beside_full { - dot(CAPPED) - } else { - dot(steps[i]) - } - }) - .collect(); - format!("{prefix}{dots}{FULL_DOT}") + let (marker, pad) = if prompt { + (PROMPT_MARKER.to_string(), PROMPT_ANCHOR_PAD) + } else { + (String::new(), ANCHOR_PAD) + }; + row(&format!( + "{marker}{dots}{}{FULL_DOT}", + space(pad) + )) } } @@ -504,57 +669,138 @@ mod tests { json(o)["text"].as_str().unwrap().to_owned() } - /// A recording's text: `prefix` (the prompt marker or nothing), the - /// wave's cells for `steps` (oldest first), then the anchor, in the - /// monospace span. - fn wave_text(prefix: &str, steps: &[usize]) -> String { - let cells: String = steps.iter().map(|&s| dot(s)).collect(); - format!("{prefix}{cells}{FULL_DOT}") - } - - /// A plain recording's text for `steps`, one per wave cell. - fn rec_text(steps: [usize; WIDTH - 1]) -> String { - wave_text("", &steps) + /// The watch line of a recording at `level`, a prompt dictation or not. + fn rec_line(prompt: bool, level: u8) -> String { + let tag = if prompt { " tag=prompt" } else { "" }; + format!("state=recording mode=hold{tag} model=ready mic=present level={level}") } /// The watch line of a plain recording at `level`. fn rec(level: u8) -> String { - format!("state=recording mode=hold model=ready mic=present level={level}") + rec_line(false, level) } /// Feeds `levels` as recording lines; the text after the last. fn feed(ind: &mut Indicator, levels: &[u8]) -> String { + feed_line(ind, false, levels) + } + + fn feed_line(ind: &mut Indicator, prompt: bool, levels: &[u8]) -> String { let mut t = String::new(); for &l in levels { - t = text(&ind.update(&rec(l)).unwrap()); + t = text(&ind.update(&rec_line(prompt, l)).unwrap()); } t } - /// The step of each cell a text shows (the anchor and any prompt marker - /// left out), read back from its markup. - fn shown_steps(t: &str) -> Vec { - let inner = t - .strip_prefix("") - .and_then(|t| t.strip_suffix(&format!("{FULL_DOT}"))) - .expect(t); - let inner = inner.strip_prefix(PROMPT_MARKER).unwrap_or(inner); + /// One character a text shows, with the Pango span attributes in force + /// where it stands. + #[derive(Clone, Debug, Default, PartialEq)] + struct Glyph { + c: char, + font_family: Option, + size: Option, + rise: Option, + alpha: Option, + foreground: Option, + } + + impl Glyph { + /// Its size as a share of the bar font's (1 outside a size span). + fn scale(&self) -> f64 { + self.size.as_deref().map_or(1.0, |s| { + s.strip_suffix('%').unwrap().parse::().unwrap() / 100.0 + }) + } + + /// Its alpha in percent (100 outside an alpha span). + fn alpha(&self) -> u8 { + self.alpha + .as_deref() + .map_or(100, |a| a.strip_suffix('%').unwrap().parse().unwrap()) + } + + /// Its [`STEPS`] index, if it is one of the row's dots. + fn step(&self) -> Option { + STEPS + .iter() + .position(|&(c, a)| c == self.c && a == self.alpha()) + } + } + + /// What a text shows, read back from its markup. Only `` with + /// the attributes the indicator uses is accepted. + fn glyphs(t: &str) -> Vec { + let mut stack = vec![Glyph::default()]; let mut out = Vec::new(); - let mut rest = inner; - while !rest.is_empty() { - let s = (0..STEPS.len()) - .find(|&s| rest.starts_with(&dot(s))) - .expect(t); - out.push(s); - rest = &rest[dot(s).len()..]; + let mut rest = t; + while let Some(c) = rest.chars().next() { + if c != '<' { + out.push(Glyph { + c, + ..stack.last().unwrap().clone() + }); + rest = &rest[c.len_utf8()..]; + continue; + } + let (tag, after) = rest[1..].split_once('>').expect(t); + rest = after; + if tag == "/span" { + stack.pop(); + assert!(!stack.is_empty(), "{t}"); + continue; + } + let mut g = stack.last().unwrap().clone(); + let mut attrs = tag.strip_prefix("span ").expect(t).trim(); + while !attrs.is_empty() { + let (key, after) = attrs.split_once("=\"").expect(t); + let (value, after) = after.split_once('"').expect(t); + let value = Some(value.to_owned()); + match key { + "font_family" => g.font_family = value, + "size" => g.size = value, + "rise" => g.rise = value, + "alpha" => g.alpha = value, + "foreground" => g.foreground = value, + _ => panic!("unexpected attribute {key} in {t}"), + } + attrs = after.trim_start(); + } + stack.push(g); } + assert_eq!(stack.len(), 1, "unclosed span: {t}"); out } + /// The row's dots (half-size round dots), oldest first, as [`STEPS`] + /// indices. + fn dots(t: &str) -> Vec { + glyphs(t) + .iter() + .filter(|g| g.size.as_deref() == Some(DOT_SIZE) && g.c != ' ') + .map(|g| g.step().expect(t)) + .collect() + } + + /// The anchor: the recording's aurora mark dot, if the text has one. + fn anchor(t: &str) -> Option { + glyphs(t) + .into_iter() + .find(|g| g.size.as_deref() == Some(ANCHOR_SIZE)) + } + fn full(s: usize) -> bool { STEPS[s].0 == FULL_DOT } + /// The index of the row's brightest, largest dot (the leftmost of a + /// tie: a pulse's tail, newer, is on its right). + fn peak(t: &str) -> usize { + let d = dots(t); + let top = *d.iter().max().unwrap(); + d.iter().position(|&s| s == top).unwrap() + } + #[test] fn every_class_formats_as_waybar_json() { let mut ind = Indicator::default(); @@ -567,10 +813,6 @@ mod tests { "state=idle model=loading mic=unknown", "{\"text\": \" \u{f017} \", \"alt\": \"loading\", \"class\": \"loading\", \"tooltip\": \"LocalFlow: loading the model\"}", ), - ( - "state=transcribing model=ready mic=present", - "{\"text\": \" \u{f252} \", \"alt\": \"transcribing\", \"class\": \"transcribing\", \"tooltip\": \"LocalFlow: transcribing\"}", - ), ( "state=typing model=ready mic=present", "{\"text\": \" \u{f11c} \", \"alt\": \"typing\", \"class\": \"typing\", \"tooltip\": \"LocalFlow: typing\"}", @@ -585,17 +827,26 @@ mod tests { assert_eq!(ind.update(fields).as_deref(), Some(want), "{fields}"); seen.push(want.into()); } - // A recording's first line: listening, the wave still at rest. - let recording = ind - .update("state=recording mode=hold model=ready mic=present level=100") + // Transcribing: the shimmer's first frame. + let transcribing = ind + .update("state=transcribing model=ready mic=present") .unwrap(); assert_eq!( - recording, + transcribing, format!( - "{{\"text\": {}, \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\"}}", - json_string(&rec_text([0; WIDTH - 1])) + "{{\"text\": {}, \"alt\": \"transcribing\", \"class\": \"transcribing\", \"tooltip\": \"LocalFlow: transcribing\"}}", + json_string(&shimmer(Duration::ZERO, false)) ) ); + seen.push(transcribing); + // A recording's first line: listening, the wave still at rest. + let recording = ind + .update("state=recording mode=hold model=ready mic=present level=0") + .unwrap(); + let v = json(&recording); + assert_eq!(v["alt"], "recording"); + assert_eq!(v["tooltip"], "LocalFlow: listening"); + assert_eq!(dots(v["text"].as_str().unwrap()), [0; WAVE_DOTS]); seen.push(recording); let offline = ind.offline(); assert_eq!( @@ -621,39 +872,47 @@ mod tests { #[test] fn every_state_has_its_own_glyph() { - // Stock Waybar has no colours: the glyph alone tells the states apart. - let glyphs = [ + // Stock Waybar has no colours: the glyph alone tells the states + // apart. Recording and transcribing show dots instead: the wave with + // its anchor, and the shimmer without one. + let glyphs_ = [ MIC_GLYPH, LOADING_GLYPH, - TRANSCRIBING_GLYPH, TYPING_GLYPH, NO_MIC_GLYPH, OFFLINE_GLYPH, APPROVAL_GLYPH, ]; - for (i, a) in glyphs.iter().enumerate() { + for (i, a) in glyphs_.iter().enumerate() { assert_eq!(a.chars().count(), 1); - for b in &glyphs[i + 1..] { + for b in &glyphs_[i + 1..] { assert_ne!(a, b); } let c = a.chars().next().unwrap(); assert!(STEPS.iter().all(|&(d, _)| d != c)); assert_ne!(a.chars().next(), Some(PROMPT_MARKER)); } + let mut ind = Indicator::default(); + for line in [ + "state=transcribing model=ready mic=present", + "state=recording mode=hold model=ready mic=present level=0", + ] { + let t = text(&ind.update(line).unwrap()); + for g in glyphs(&t) { + assert!( + g.c == ' ' || g.c == STRUT || g.step().is_some() || g.c == FULL_DOT, + "{t}" + ); + } + } } #[test] - fn every_text_is_the_same_width_with_the_glyph_in_the_same_cell() { + fn every_text_is_six_cells_wide_with_the_glyph_in_the_same_cell() { let mut ind = Indicator::default(); let lines = [ "state=idle model=ready mic=present", "state=idle model=loading mic=present", - "state=recording mode=hold model=ready mic=present", - "state=recording mode=hold model=ready mic=present level=0", - "state=recording mode=hold model=ready mic=present level=60", - "state=recording mode=hold tag=prompt model=ready mic=present level=60", - "state=transcribing tag=prompt model=ready mic=present", - "state=transcribing model=ready mic=present", "state=typing tag=prompt model=ready mic=present", "state=typing model=ready mic=present", "state=idle model=ready mic=absent", @@ -669,45 +928,80 @@ mod tests { for t in &texts { let shown = plain(t); assert_eq!(shown.chars().count(), WIDTH, "{t:?}"); + assert_eq!(cells(t), WIDTH as f64, "{t:?}"); let glyph = shown.chars().nth(GLYPH_CELL).unwrap(); assert_ne!(glyph, ' ', "{t:?}"); } // The count stays in the last two cells, after a space. - assert_eq!(texts[11], format!(" {APPROVAL_GLYPH} 1 ")); - assert_eq!(texts[12], format!(" {MIC_GLYPH} 12")); - assert_eq!(texts[13], format!(" {NO_MIC_GLYPH} 99")); + assert_eq!(texts[5], format!(" {APPROVAL_GLYPH} 1 ")); + assert_eq!(texts[6], format!(" {MIC_GLYPH} 12")); + assert_eq!(texts[7], format!(" {NO_MIC_GLYPH} 99")); + + // The wave and the shimmer mix sizes; they take exactly six cells of + // the bar's font too, in every frame, so the clock never moves. + let mut rows = Vec::new(); + for prompt in [false, true] { + let mut ind = Indicator::default(); + for level in [0, 0, 60, 100, 30, 100, 100, 5, 0] { + rows.push(text(&ind.update(&rec_line(prompt, level)).unwrap())); + } + let tag = if prompt { " tag=prompt" } else { "" }; + rows.push(text( + &ind.update(&format!("state=transcribing{tag} model=ready mic=present")) + .unwrap(), + )); + for ms in (0..2400).step_by(50) { + rows.push(shimmer(Duration::from_millis(ms), prompt)); + } + } + for t in &rows { + assert_eq!(cells(t), WIDTH as f64, "{t}"); + let width: f64 = glyphs(t) + .iter() + .filter(|g| g.c != STRUT) + .map(Glyph::scale) + .sum(); + assert_eq!(width, WIDTH as f64, "{t}"); + } } #[test] fn prompt_dictations_get_a_marker_and_a_second_class() { let mut ind = Indicator::default(); - let v = json( - &ind.update("state=recording mode=hold tag=prompt model=ready mic=present level=100") - .unwrap(), - ); + let v = json(&ind.update(&rec_line(true, 0)).unwrap()); assert_eq!(v["class"], serde_json::json!(["recording", "prompt"])); assert_eq!(v["alt"], "recording"); assert_eq!(v["tooltip"], "LocalFlow: listening (prompt dictation)"); + let t = v["text"].as_str().unwrap(); + // The marker leads, at full size, then a shorter row and the anchor. + let shown: Vec = glyphs(t).into_iter().filter(|g| g.c != STRUT).collect(); + assert_eq!(shown[0].c, PROMPT_MARKER, "{t}"); + assert_eq!(shown[0].scale(), 1.0, "{t}"); + assert_eq!(dots(t), [0; PROMPT_WAVE_DOTS]); + assert!(anchor(t).is_some(), "{t}"); + + let v = json( + &ind.update("state=transcribing tag=prompt model=ready mic=present") + .unwrap(), + ); + assert_eq!(v["class"], serde_json::json!(["transcribing", "prompt"])); + assert_eq!(v["alt"], "transcribing"); + assert_eq!(v["tooltip"], "LocalFlow: transcribing (prompt dictation)"); + assert_eq!(v["text"], shimmer(Duration::ZERO, true)); + let t = v["text"].as_str().unwrap(); assert_eq!( - v["text"], - wave_text(&PROMPT_MARKER.to_string(), &[0; WIDTH - 2]) + plain(t).trim_start_matches(STRUT).chars().next(), + Some(PROMPT_MARKER) ); - for (state, glyph, tooltip) in [ - ("transcribing", TRANSCRIBING_GLYPH, "transcribing"), - ("typing", TYPING_GLYPH, "typing"), - ] { - let v = json( - &ind.update(&format!("state={state} tag=prompt model=ready mic=present")) - .unwrap(), - ); - assert_eq!(v["class"], serde_json::json!([state, "prompt"])); - assert_eq!(v["alt"], state); - assert_eq!( - v["tooltip"], - format!("LocalFlow: {tooltip} (prompt dictation)") - ); - assert_eq!(v["text"], format!("{PROMPT_MARKER} {glyph} ")); - } + assert_eq!(dots(t).len(), PROMPT_SHIMMER_DOTS); + + let v = json( + &ind.update("state=typing tag=prompt model=ready mic=present") + .unwrap(), + ); + assert_eq!(v["class"], serde_json::json!(["typing", "prompt"])); + assert_eq!(v["tooltip"], "LocalFlow: typing (prompt dictation)"); + assert_eq!(v["text"], format!("{PROMPT_MARKER} {TYPING_GLYPH} ")); // Without the tag, or when idle, objects are exactly as before. assert_eq!( ind.update("state=typing model=ready mic=present").unwrap(), @@ -723,54 +1017,61 @@ mod tests { #[test] fn agent_link_counts_add_classes_a_count_and_tooltip_lines() { let mut ind = Indicator::default(); - // A fresh recording's first line: the wave at rest. - let loud = json_string(&rec_text([0; WIDTH - 1])); let cases = [ ( - "state=idle model=ready mic=present suggestions=3 words=12 agent-approval=2 agent-input=1 agent-working=1 agent-done=4".to_owned(), - "{\"text\": \" \u{f256} 3 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\", \"suggestion\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 2 need approval, 1 waiting for input, 1 working, 4 done\\n3 dictionary suggestions waiting\\n12 project words boosted\"}".to_owned(), + "state=idle model=ready mic=present suggestions=3 words=12 agent-approval=2 agent-input=1 agent-working=1 agent-done=4", + "{\"text\": \" \u{f256} 3 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\", \"suggestion\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 2 need approval, 1 waiting for input, 1 working, 4 done\\n3 dictionary suggestions waiting\\n12 project words boosted\"}", ), ( - "state=idle model=loading mic=unknown agent-input=1".into(), - "{\"text\": \" \u{f017} 1 \", \"alt\": \"loading\", \"class\": [\"loading\", \"agent-input\"], \"tooltip\": \"LocalFlow: loading the model\\nAgents: 1 waiting for input\"}".into(), + "state=idle model=loading mic=unknown agent-input=1", + "{\"text\": \" \u{f017} 1 \", \"alt\": \"loading\", \"class\": [\"loading\", \"agent-input\"], \"tooltip\": \"LocalFlow: loading the model\\nAgents: 1 waiting for input\"}", ), ( - "state=idle model=ready mic=absent suggestions=1".into(), - "{\"text\": \" \u{f131} \", \"alt\": \"nomic\", \"class\": [\"nomic\", \"suggestion\"], \"tooltip\": \"LocalFlow: no microphone\\n1 dictionary suggestion waiting\"}".into(), + "state=idle model=ready mic=absent suggestions=1", + "{\"text\": \" \u{f131} \", \"alt\": \"nomic\", \"class\": [\"nomic\", \"suggestion\"], \"tooltip\": \"LocalFlow: no microphone\\n1 dictionary suggestion waiting\"}", ), ( - "state=idle model=ready mic=present agent-approval=1".into(), - "{\"text\": \" \u{f256} 1 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 1 needs approval\"}".into(), + "state=idle model=ready mic=present agent-approval=1", + "{\"text\": \" \u{f256} 1 \", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\"], \"tooltip\": \"LocalFlow: ready\\nAgents: 1 needs approval\"}", ), // Working and done alone add no class and no count. ( - "state=idle model=ready mic=present agent-working=1 agent-done=1 words=1".into(), - "{\"text\": \" \u{f130} \", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: ready\\nAgents: 1 working, 1 done\\n1 project word boosted\"}".into(), - ), - // Busy states keep their classes and text; only the tooltip grows. - ( - "state=recording mode=hold model=ready mic=present level=100 agent-approval=1 suggestions=2".into(), - format!("{{\"text\": {loud}, \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: listening\\nAgents: 1 needs approval\\n2 dictionary suggestions waiting\"}}"), - ), - ( - "state=transcribing model=ready mic=present agent-input=3 words=2".into(), - "{\"text\": \" \u{f252} \", \"alt\": \"transcribing\", \"class\": \"transcribing\", \"tooltip\": \"LocalFlow: transcribing\\nAgents: 3 waiting for input\\n2 project words boosted\"}".into(), + "state=idle model=ready mic=present agent-working=1 agent-done=1 words=1", + "{\"text\": \" \u{f130} \", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: ready\\nAgents: 1 working, 1 done\\n1 project word boosted\"}", ), ( - "state=typing tag=prompt model=ready mic=present agent-input=2 suggestions=1".into(), - "{\"text\": \"\u{203a} \u{f11c} \", \"alt\": \"typing\", \"class\": [\"typing\", \"prompt\"], \"tooltip\": \"LocalFlow: typing (prompt dictation)\\nAgents: 2 waiting for input\\n1 dictionary suggestion waiting\"}".into(), + "state=typing tag=prompt model=ready mic=present agent-input=2 suggestions=1", + "{\"text\": \"\u{203a} \u{f11c} \", \"alt\": \"typing\", \"class\": [\"typing\", \"prompt\"], \"tooltip\": \"LocalFlow: typing (prompt dictation)\\nAgents: 2 waiting for input\\n1 dictionary suggestion waiting\"}", ), ]; for (fields, want) in cases { - let got = ind.update(&fields).unwrap(); + let got = ind.update(fields).unwrap(); assert_eq!(got, want, "{fields}"); - assert!( - json(&got)["tooltip"] - .as_str() - .unwrap() - .starts_with("LocalFlow: ") - ); } + // Busy states keep their classes and text; only the tooltip grows. + let mut fresh = Indicator::default(); + let quiet = fresh.update(&rec(100)).unwrap(); + let mut ind = Indicator::default(); + let v = json( + &ind.update("state=recording mode=hold model=ready mic=present level=100 agent-approval=1 suggestions=2") + .unwrap(), + ); + assert_eq!(v["text"], json(&quiet)["text"]); + assert_eq!(v["class"], "recording"); + assert_eq!( + v["tooltip"], + "LocalFlow: listening\nAgents: 1 needs approval\n2 dictionary suggestions waiting" + ); + let v = json( + &ind.update("state=transcribing model=ready mic=present agent-input=3 words=2") + .unwrap(), + ); + assert_eq!(v["text"], shimmer(Duration::ZERO, false)); + assert_eq!(v["class"], "transcribing"); + assert_eq!( + v["tooltip"], + "LocalFlow: transcribing\nAgents: 3 waiting for input\n2 project words boosted" + ); // Back to no counts: exactly the plain object again. assert_eq!( ind.update("state=idle model=ready mic=present").unwrap(), @@ -799,7 +1100,7 @@ mod tests { } #[test] - fn the_dot_wave_scrolls_and_restarts_per_recording() { + fn the_wave_is_white_dots_that_rest_and_swell_as_the_keyboards_do() { let last = STEPS.len() - 1; assert_eq!(step(0), 0); assert_eq!(step(100), last); @@ -811,54 +1112,11 @@ mod tests { assert_eq!(step(10), 1); assert_eq!(step(30), 2); assert_eq!(STEPS[step(50)].0, FULL_DOT); - // Louder is never smaller or dimmer. assert!((0..100).all(|l| step(l) <= step(l + 1))); - let mut ind = Indicator::default(); - // A recording starts as silence: a row of equal small dots. - assert_eq!( - text( - &ind.update("state=recording mode=toggle model=ready mic=present") - .unwrap() - ), - rec_text([0; WIDTH - 1]) - ); - // Each new cell comes in on the right, beside the anchor. - assert_eq!(feed(&mut ind, &[0, 30]), rec_text([0, 0, 0, 0, 2])); - assert_eq!(feed(&mut ind, &[10, 10]), rec_text([0, 0, 0, 2, 1])); - // Silence scrolls it out. - feed(&mut ind, &[0; 4 * HISTORY]); - assert_eq!(feed(&mut ind, &[30, 0]), rec_text([0, 0, 0, 0, 2])); - // A recording line without a level keeps the wave. - assert_eq!( - text(&ind.update("state=recording mode=hold model=ready").unwrap()), - rec_text([0, 0, 0, 0, 2]) - ); - // A prompt dictation shows the newest cells after its marker. - let prompt = - |l: u8| format!("state=recording tag=prompt model=ready mic=present level={l}"); - ind.update(&prompt(10)); - assert_eq!( - text(&ind.update(&prompt(10)).unwrap()), - wave_text(&PROMPT_MARKER.to_string(), &[0, 0, 2, 1]) - ); - // The next recording starts from silence: neither the last - // recording's cells nor half a pair of its levels carry over. - ind.update(&rec(100)); - ind.update("state=transcribing model=ready mic=present"); - assert_eq!(feed(&mut ind, &[0, 0]), rec_text([0; WIDTH - 1])); - ind.offline(); - assert_eq!(feed(&mut ind, &[0, 0]), rec_text([0; WIDTH - 1])); - } - - #[test] - fn the_wave_rests_and_swells_as_the_iphones_does() { - // At rest: the small dot, dimmed (about half as bright as a full - // dot on the iPhone; 60% is the least that keeps a 3:1 contrast - // on the black pill at 1x); at the top of the swell: the full dot - // at full brightness. + // At rest: the small dot, dimmed (60% keeps 3:1 on the black pill + // at 1x); at the top of the swell: the full dot at full brightness. assert_eq!(STEPS[0], (SMALL_DOT, 60)); - assert_eq!(STEPS[STEPS.len() - 1], (FULL_DOT, 100)); - // Dots grow and brighten together, step by step. + assert_eq!(STEPS[last], (FULL_DOT, 100)); let size = |c: char| usize::from(c == FULL_DOT); for w in STEPS.windows(2) { let ((a, x), (b, y)) = (w[0], w[1]); @@ -867,156 +1125,245 @@ mod tests { assert!(x <= 100 && y <= 100); } // Standard characters only: no Nerd Font needed. - for (c, _) in STEPS { + for c in STEPS.iter().map(|&(c, _)| c).chain([PROMPT_MARKER, STRUT]) { let c = c as u32; assert!(!(0xe000..=0xf8ff).contains(&c) && c < 0xf0000); } - assert_eq!(dot(0), "\u{2022}"); - assert_eq!(dot(STEPS.len() - 1), "\u{25cf}"); // Silence is a calm row of equal resting dots before the anchor. let mut ind = Indicator::default(); let quiet = feed(&mut ind, &[0, 3, 2, 4, 1, 0, 2, 3, 0, 1, 4, 2]); - assert_eq!(quiet, rec_text([0; WIDTH - 1])); - assert_eq!( - plain(&quiet), - "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{25cf}" - ); - // Speech undulates, in size as well as brightness. + assert_eq!(dots(&quiet), [0; WAVE_DOTS]); + // The wave's dots take the bar's ink (white in the README's CSS): + // only the anchor carries a colour of its own. let speech = feed(&mut ind, &[40, 85, 6, 9, 30, 20, 2, 5, 60, 55, 30, 9]); - let steps = shown_steps(&speech); + for g in glyphs(&speech) { + if g.step().is_some() && g.size.as_deref() == Some(DOT_SIZE) { + assert_eq!(g.foreground, None, "{speech}"); + } + } + // Speech undulates, in size as well as brightness. + let d = dots(&speech); assert!( - steps.iter().any(|&s| full(s)) && steps.iter().any(|&s| !full(s)), + d.iter().any(|&s| full(s)) && d.iter().any(|&s| !full(s)), "{speech}" ); - let mut distinct = steps.clone(); + let mut distinct = d.clone(); distinct.sort(); distinct.dedup(); - assert!(distinct.len() >= 3, "{steps:?}"); + assert!(distinct.len() >= 3, "{d:?}"); } #[test] - fn a_recording_always_ends_in_the_full_anchor_dot() { - // Silence reads as listening: whatever the voice, the last cell is - // the full dot at full brightness, as the iPhone's wave comes to - // rest in the mark's dot; the wave takes the cells before it. - let mut ind = Indicator::default(); - let first = text( - &ind.update("state=recording mode=hold model=ready mic=present") - .unwrap(), - ); - assert_eq!( - plain(&first), - "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{25cf}" - ); - let prompt = text( - &ind.update("state=recording tag=prompt model=ready mic=present level=0") - .unwrap(), - ); - assert_eq!( - plain(&prompt), - "\u{203a}\u{2022}\u{2022}\u{2022}\u{2022}\u{25cf}" - ); - let mut ind = Indicator::default(); - let mut seed = 7u32; - for _ in 0..400 { - seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); - let t = text(&ind.update(&rec((seed >> 16) as u8 % 101)).unwrap()); - assert!(t.ends_with(&format!("{FULL_DOT}")), "{t}"); - assert_eq!(shown_steps(&t).len(), WIDTH - 1, "{t}"); + fn a_recording_always_ends_in_the_aurora_anchor() { + // Silence reads as listening: whatever the voice, the row ends in the + // mark's dot, aurora in the markup itself (so even a bar without the + // README's CSS shows it), larger than any dot of the wave and at full + // brightness, as the keyboard's wave comes to rest in it. + for prompt in [false, true] { + let mut ind = Indicator::default(); + let mut seed = 7u32; + for i in 0..400 { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + let level = if i == 0 { 0 } else { (seed >> 16) as u8 % 101 }; + let t = text(&ind.update(&rec_line(prompt, level)).unwrap()); + let a = anchor(&t).expect(&t); + assert_eq!(a.c, FULL_DOT, "{t}"); + assert_eq!(a.foreground.as_deref(), Some(AURORA), "{t}"); + assert_eq!(a.alpha(), 100, "{t}"); + assert!(a.scale() > 0.5, "{t}"); + let g = glyphs(&t); + assert_eq!(g.last(), Some(&a), "{t}"); + // Only the prompt marker and the zero-width strut keep the + // bar font's size. + assert!( + g.iter() + .all(|x| x.scale() <= a.scale() || matches!(x.c, PROMPT_MARKER | STRUT)), + "{t}" + ); + assert_eq!( + g.iter().filter(|x| x.foreground.is_some()).count(), + 1, + "{t}" + ); + let want = if prompt { PROMPT_WAVE_DOTS } else { WAVE_DOTS }; + assert_eq!(dots(&t).len(), want, "{t}"); + } } + assert_eq!(AURORA, "#6DFFA8"); } #[test] - fn the_wave_is_set_in_one_monospace_span() { + fn the_rows_are_set_in_one_monospace_span() { // A proportional bar font draws `•` and `●` at different widths; - // the span gives every cell of the wave the same advance, so the - // clock beside the module holds still while you speak. + // the span gives every cell the same advance, so the clock beside + // the module holds still while you speak and while it transcribes. let mut ind = Indicator::default(); for line in [ "state=recording mode=hold model=ready mic=present", "state=recording mode=hold model=ready mic=present level=90", "state=recording mode=hold tag=prompt model=ready mic=present level=40", + "state=transcribing model=ready mic=present", + "state=transcribing tag=prompt model=ready mic=present", ] { let t = text(&ind.update(line).unwrap()); assert!(t.starts_with(""), "{t}"); assert!(t.ends_with(""), "{t}"); assert_eq!(t.matches("font_family").count(), 1, "{t}"); + // It starts with a zero-width strut in the bar's own size, so + // the line is as tall as every other state's and the dots sit + // on the same middle line. + let g = glyphs(&t); + assert_eq!(g[0].c, STRUT, "{t}"); + assert_eq!(g[0].scale(), 1.0, "{t}"); } // Other states keep the bar's font, for Font Awesome's glyphs. for line in [ "state=idle model=ready mic=present", - "state=transcribing model=ready mic=present", + "state=typing model=ready mic=present", ] { assert!(!text(&ind.update(line).unwrap()).contains('<')); } } #[test] - fn one_cell_per_two_level_lines_with_the_louder_of_the_pair() { - // Level lines come every 66 ms; a cell every second one makes a - // syllable cross the wave in about 0.66 s, near the iPhone's 0.75 s. - let silent = rec_text([0; WIDTH - 1]); - for pair in [[30, 0], [0, 30], [30, 10]] { + fn the_voice_crosses_the_row_one_dot_per_level_line() { + // A level line every 66 ms moves the row one dot: a syllable crosses + // its eight dots in about half a second, the newest beside the + // anchor. + assert_eq!( + crate::watch::LEVEL_INTERVAL.as_millis() * WAVE_DOTS as u128, + 528 + ); + for prompt in [false, true] { + let n = if prompt { PROMPT_WAVE_DOTS } else { WAVE_DOTS }; let mut ind = Indicator::default(); - assert_eq!(feed(&mut ind, &[0, 0, 0, 0]), silent); - // The first line of a pair changes nothing: the same object, - // which `watch --waybar` does not write again. - let before = ind.update(&rec(0)).unwrap(); - assert_eq!(ind.update(&rec(0)).unwrap(), before); - let held = ind.update(&rec(pair[0])).unwrap(); - assert_eq!(text(&held), silent, "{pair:?}"); - // The second adds one cell for the louder of the two. - assert_eq!( - feed(&mut ind, &[pair[1]]), - rec_text([0, 0, 0, 0, 2]), - "{pair:?}" - ); + let rest = feed_line(&mut ind, prompt, &[0; 10]); + assert_eq!(dots(&rest), vec![0; n]); + // Every line changes the row (none is held back for a pair). + let t = feed_line(&mut ind, prompt, &[100]); + assert_ne!(t, rest); + assert_eq!(peak(&t), n - 1, "{t}"); + // Then it moves left one dot per line, through the row (until + // it sinks into the line at the left end). + for k in 1..n - 3 { + let t = feed_line(&mut ind, prompt, &[0]); + assert_eq!(peak(&t), n - 1 - k, "{prompt} {k} {t}"); + } } } #[test] - fn onsets_show_within_one_cell_and_silence_after_speech_decays() { - // Speech arriving on either line of a pair shows by the line after - // it at the latest: within one cell. - for lines in [[60, 0], [0, 60]] { - let mut ind = Indicator::default(); - feed(&mut ind, &[0, 0, 0, 0]); - let t = feed(&mut ind, &lines); - assert!(shown_steps(&t)[WIDTH - 2] > 0, "{lines:?} {t}"); - } - // A loud cell beside the anchor is the brightest small dot, and - // grows to the full dot one cell later (full dots never touch). + fn syllables_rise_at_once_and_fall_quickly() { + // The keyboard's follower: 25 ms up, 90 ms down. A syllable shows + // in the very next dot, and its tail is gone within four lines. let mut ind = Indicator::default(); - let loud = feed(&mut ind, &[0, 0, 100, 100]); - assert_eq!(STEPS[shown_steps(&loud)[WIDTH - 2]], (SMALL_DOT, 100)); - let next = feed(&mut ind, &[0, 0]); - assert_eq!(shown_steps(&next)[WIDTH - 3], STEPS.len() - 1); - - // After speech the wave falls a step or two a cell: the first cell - // of silence is still lit, and it is at rest within three cells. + feed(&mut ind, &[0; 10]); + let onset = feed(&mut ind, &[60]); + assert!(dots(&onset)[WAVE_DOTS - 1] > 0, "{onset}"); let mut ind = Indicator::default(); - feed(&mut ind, &[100, 100]); + feed(&mut ind, &[0; 10]); + feed(&mut ind, &[100]); let mut tail = Vec::new(); - for _ in 0..4 { - tail.push(*shown_steps(&feed(&mut ind, &[0, 0])).last().unwrap()); + for _ in 0..5 { + tail.push(dots(&feed(&mut ind, &[0]))[WAVE_DOTS - 1]); } assert!(tail[0] > 0, "{tail:?}"); assert!(tail.windows(2).all(|w| w[1] <= w[0]), "{tail:?}"); - assert_eq!(tail[2..], [0, 0], "{tail:?}"); - // Speech rises at once even while the wave is falling. - let up = feed(&mut ind, &[0, 0, 100, 0]); - assert!(shown_steps(&up)[WIDTH - 2] >= 2, "{up}"); + assert_eq!(tail[3..], [0, 0], "{tail:?}"); + // Two syllables 130 ms apart read as two pulses: a dip between them. + let mut ind = Indicator::default(); + feed(&mut ind, &[0; 10]); + let t = feed(&mut ind, &[100, 0, 0, 100, 0]); + let d = dots(&t); + let (a, b) = (WAVE_DOTS - 5, WAVE_DOTS - 2); + assert!(full(d[a]) && full(d[b]), "{d:?}"); + assert!((a + 1..b).any(|i| d[i] < d[a].min(d[b])), "{d:?}"); + // Speech rises at once even while the row is falling. + let up = feed(&mut ind, &[100]); + assert!(full(dots(&up)[WAVE_DOTS - 1]), "{up}"); + } + + #[test] + fn the_row_rises_out_of_the_line_at_its_left_end() { + // As the keyboard's envelope: the oldest dot always rests, the next + // ones lift less, and the dot beside the anchor a little less than + // the middle, as the row settles into the mark's dot. + for prompt in [false, true] { + let mut ind = Indicator::default(); + let mut seed = 3u32; + for _ in 0..500 { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + let t = text( + &ind.update(&rec_line(prompt, (seed >> 16) as u8 % 101)) + .unwrap(), + ); + let d = dots(&t); + assert_eq!(d[0], 0, "{t}"); + assert!(d[1] <= 1, "{t}"); + } + let loud = feed_line(&mut ind, prompt, &[100; 12]); + let want: &[usize] = if prompt { + &[0, 1, 3, 4, 4, 3] + } else { + &[0, 1, 2, 3, 4, 4, 4, 3] + }; + assert_eq!(dots(&loud), want, "{loud}"); + } + } + + #[test] + fn the_envelope_is_the_keyboards() { + // `DotWave.envelope` at each dot, as the most a full voice lifts it. + fn smooth(a: f64, b: f64, x: f64) -> f64 { + let t = ((x - a) / (b - a)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) + } + let envelope = |u: f64| smooth(0.0, 0.6, u) * (1.0 - 0.2 * smooth(0.85, 1.0, u)); + let top = (STEPS.len() - 1) as f64; + for table in [&ENVELOPE[..], &PROMPT_ENVELOPE[..]] { + let n = table.len(); + let want: Vec = (0..n) + .map(|i| (top * envelope(i as f64 / (n - 1) as f64)).round() as usize) + .collect(); + assert_eq!(table, want); + } + } + + #[test] + fn a_new_recording_starts_from_silence() { + let mut ind = Indicator::default(); + feed(&mut ind, &[100, 100, 100]); + // A recording line without a level keeps the row. + let before = feed(&mut ind, &[100]); + assert_eq!( + text(&ind.update("state=recording mode=hold model=ready").unwrap()), + before + ); + ind.update("state=transcribing model=ready mic=present"); + assert_eq!(dots(&feed(&mut ind, &[0])), [0; WAVE_DOTS]); + feed(&mut ind, &[100, 100]); + ind.offline(); + assert_eq!(dots(&feed(&mut ind, &[0])), [0; WAVE_DOTS]); } #[test] fn full_dots_never_touch() { - // `●` is wider than its cell, so two side by side fuse into one - // blob at 1x: a loud cell beside another full dot (the anchor - // included) shows as the brightest small dot instead, in every - // other slot from the anchor. - let capped = (0..STEPS.len()).rfind(|&s| !full(s)).unwrap(); - assert_eq!(STEPS[capped], (SMALL_DOT, 100)); + // The row's dots are half-size, each followed by a gap an eighth of + // a cell wide, so even the full dot (a little wider than its own + // cell in both fonts measured: 620 units in 600 in JetBrainsMono, + // 1221 in 1233 in DejaVu Sans Mono) clears the next one, and the + // anchor clears the last. + let (dot, gap) = (scale(DOT_SIZE), scale(GAP_SIZE)); + let pad = scale(ANCHOR_PAD); + for ink in [620.0 / 600.0, 1221.0 / 1233.0] { + let full = ink * dot; + assert!(full < dot + gap, "{full}"); + // From the last dot's centre to the anchor's: half the dot's + // cell, its gap, the pad and half the anchor's cell. + let apart = dot / 2.0 + gap + pad + scale(ANCHOR_SIZE) / 2.0; + assert!(full / 2.0 + ink * scale(ANCHOR_SIZE) / 2.0 < apart); + } let mut ind = Indicator::default(); let mut seed = 99u32; let mut fulls = 0; @@ -1028,58 +1375,42 @@ mod tests { 40..60 => 0, _ => (seed >> 16) as u8 % 101, }; - let line = if i % 500 < 250 { - rec(level) - } else { - format!("state=recording tag=prompt model=ready mic=present level={level}") - }; - let t = text(&ind.update(&line).unwrap()); - let shown: Vec = plain(&t).chars().collect(); - assert!(shown.windows(2).all(|w| w != [FULL_DOT, FULL_DOT]), "{t}"); - fulls += shown.iter().filter(|&&c| c == FULL_DOT).count() - 1; + let t = text(&ind.update(&rec_line(i % 500 >= 250, level)).unwrap()); + let g = glyphs(&t); + // Every dot of the row is followed by its gap. + for (i, x) in g.iter().enumerate() { + if x.size.as_deref() == Some(DOT_SIZE) && x.c != ' ' { + let next = &g[i + 1]; + assert_eq!((next.c, next.size.as_deref()), (' ', Some(GAP_SIZE)), "{t}"); + } + } + fulls += dots(&t).iter().filter(|&&s| full(s)).count(); } - // Loud speech still shows full dots. - assert!(fulls > 500, "{fulls}"); + // Loud speech still shows full dots, side by side. + assert!(fulls > 3000, "{fulls}"); } - /// The watch line of a recording at `level`, a prompt dictation or not. - fn rec_line(prompt: bool, level: u8) -> String { - let tag = if prompt { " tag=prompt" } else { "" }; - format!("state=recording mode=hold{tag} model=ready mic=present level={level}") + /// A size attribute's share of the bar font's size. + fn scale(size: &str) -> f64 { + size.strip_suffix('%').unwrap().parse::().unwrap() / 100.0 } #[test] fn sustained_loud_speech_holds_a_steady_wave() { - // Seconds of loud speech: every cell is loud. Full and small dots - // take turns, and every cell shows the same wave, so no dot swaps - // size in place while the wave scrolls. - let last = STEPS.len() - 1; + // Seconds of loud speech: no dot changes size or brightness while + // the row scrolls. for prompt in [false, true] { let mut ind = Indicator::default(); let mut waves = Vec::new(); for i in 0..60 { let t = text(&ind.update(&rec_line(prompt, 100)).unwrap()); - // Once the run fills the wave. - if i >= LINES_PER_CELL * HISTORY { + if i >= WAVE_DOTS { waves.push(t); } } assert!(waves.iter().all(|t| *t == waves[0]), "{waves:#?}"); - let want = if prompt { - vec![last, CAPPED, last, CAPPED] - } else { - vec![CAPPED, last, CAPPED, last, CAPPED] - }; - assert_eq!(shown_steps(&waves[0]), want, "{}", waves[0]); - let (marker, wave) = if prompt { - ("\u{203a}", "\u{25cf}\u{2022}\u{25cf}\u{2022}\u{25cf}") - } else { - ("", "\u{2022}\u{25cf}\u{2022}\u{25cf}\u{2022}\u{25cf}") - }; - assert_eq!(plain(&waves[0]), format!("{marker}{wave}")); - // Loud speech that varies within the full dot's steps: the dots' - // sizes still hold still, cell after cell. + // sizes hold still too. let mut ind = Indicator::default(); let mut sizes = Vec::new(); for (i, level) in [60, 100, 75, 90, 100, 65, 85] @@ -1089,7 +1420,7 @@ mod tests { .enumerate() { let t = text(&ind.update(&rec_line(prompt, *level)).unwrap()); - if i >= LINES_PER_CELL * HISTORY { + if i >= WAVE_DOTS { sizes.push(plain(&t)); } } @@ -1099,33 +1430,147 @@ mod tests { #[test] fn a_lone_peak_keeps_its_size_as_it_scrolls() { - // One loud syllable between quiet ones: beside the anchor it is the - // brightest small dot (full dots never touch), then a full dot in - // every cell it crosses, until it leaves the wave. - let last = STEPS.len() - 1; + // One loud syllable between quiet ones keeps its full dot across the + // row (a little dimmer beside the anchor and where the row starts to + // rise), until it sinks into the line at the left end. for prompt in [false, true] { - let cells = if prompt { WIDTH - 2 } else { WIDTH - 1 }; + let n = if prompt { PROMPT_WAVE_DOTS } else { WAVE_DOTS }; let mut ind = Indicator::default(); - for _ in 0..2 * LINES_PER_CELL { - ind.update(&rec_line(prompt, 0)); + feed_line(&mut ind, prompt, &[0; 10]); + let mut at = vec![dots(&feed_line(&mut ind, prompt, &[100]))[n - 1]]; + for k in 1..n { + at.push(dots(&feed_line(&mut ind, prompt, &[0]))[n - 1 - k]); } - ind.update(&rec_line(prompt, 100)); - let t = text(&ind.update(&rec_line(prompt, 100)).unwrap()); - assert_eq!(shown_steps(&t)[cells - 1], CAPPED, "{t}"); - for cell in (0..cells - 1).rev() { - ind.update(&rec_line(prompt, 0)); - let t = text(&ind.update(&rec_line(prompt, 0)).unwrap()); - let steps = shown_steps(&t); - assert_eq!(steps[cell], last, "{prompt} {t}"); - // It is the only full dot. - assert_eq!(steps.iter().filter(|&&s| full(s)).count(), 1, "{t}"); + // at[k]: the peak's dot k lines later, from the anchor leftwards. + assert!(at[..n - 3].iter().all(|&s| full(s)), "{prompt} {at:?}"); + assert!(at[1..n - 4].iter().all(|&s| s == STEPS.len() - 1), "{at:?}"); + assert_eq!(at[n - 1], 0, "{at:?}"); + } + } + + #[test] + fn the_shimmer_sweeps_a_resting_row_without_an_anchor() { + let at = |ms: u64, prompt| shimmer(Duration::from_millis(ms), prompt); + // Ten frames a second, a sweep every 1.1 s as on the keyboard. + assert_eq!( + SHIMMER_FRAME * SWEEP_FRAMES as u32, + Duration::from_millis(1100) + ); + assert!((83..=125).contains(&SHIMMER_FRAME.as_millis())); + for prompt in [false, true] { + let n = if prompt { + PROMPT_SHIMMER_DOTS + } else { + SHIMMER_DOTS + }; + // The first frame, before any timer: a row of resting dots with + // the spot entering on the first. + let first = dots(&at(0, prompt)); + assert_eq!(first.len(), n); + assert_eq!(first[..4], [STEPS.len() - 1, 2, 1, 0]); + assert!(first[3..].iter().all(|&s| s == 0), "{first:?}"); + // Then the spot (the one full dot, glowing either side) moves + // one dot per frame to the right. + for k in 1..n { + let t = at(k as u64 * 100 + 30, prompt); + let d = dots(&t); + assert_eq!(peak(&t), k, "{t}"); + assert_eq!(d.iter().filter(|&&s| full(s)).count(), 1, "{t}"); + assert_eq!(d[k], STEPS.len() - 1, "{t}"); + // Away from it, the row rests. + for (i, &s) in d.iter().enumerate() { + if i.abs_diff(k) > 2 { + assert_eq!(s, 0, "{t}"); + } + } + } + // It leaves one dot past the right end, comes back in one dot + // before the left, and starts again. + let frame = |k: usize| dots(&at(k as u64 * 100, prompt)); + let leaving = frame(SWEEP_FRAMES - 2); + assert!(leaving.iter().all(|&s| !full(s)), "{leaving:?}"); + assert!(leaving[n - 1] > 0, "{leaving:?}"); + let entering = frame(SWEEP_FRAMES - 1); + assert_eq!(entering[..3], [2, 1, 0]); + assert!(entering[2..].iter().all(|&s| s == 0), "{entering:?}"); + assert_eq!(at(1100, prompt), at(0, prompt)); + assert_eq!(at(1100 * 7 + 450, prompt), at(450, prompt)); + // A frame holds until the next one is due. + assert_eq!(at(99, prompt), at(0, prompt)); + assert_ne!(at(100, prompt), at(0, prompt)); + // No anchor and no colour of its own: the transcribing class + // colours it (glacier white in the README's CSS). + for ms in (0..1100).step_by(100) { + let t = at(ms, prompt); + assert!(anchor(&t).is_none(), "{t}"); + assert!(glyphs(&t).iter().all(|g| g.foreground.is_none()), "{t}"); } - ind.update(&rec_line(prompt, 0)); - let t = text(&ind.update(&rec_line(prompt, 0)).unwrap()); - assert!(shown_steps(&t).iter().all(|&s| !full(s)), "{t}"); } } + #[test] + fn the_shimmer_only_runs_while_transcribing() { + let mut ind = Indicator::default(); + // No frames, and so no timer, in any other state. + for line in [ + "state=idle model=ready mic=present", + "state=idle model=loading mic=present", + "state=recording mode=hold model=ready mic=present level=50", + "state=typing model=ready mic=present", + "state=idle model=ready mic=absent", + ] { + ind.update(line); + assert_eq!(ind.next_frame(), None, "{line}"); + assert_eq!(ind.tick(Duration::from_millis(250)), None, "{line}"); + } + ind.offline(); + assert_eq!(ind.next_frame(), None); + assert_eq!(ind.tick(Duration::from_millis(250)), None); + + // Transcribing: the first frame at once, the next due one frame on. + let first = ind + .update("state=transcribing tag=prompt model=ready mic=present agent-input=1") + .unwrap(); + assert_eq!(json(&first)["text"], shimmer(Duration::ZERO, true)); + assert_eq!(ind.next_frame(), Some(SHIMMER_FRAME)); + let second = ind.tick(SHIMMER_FRAME).unwrap(); + let v = json(&second); + assert_eq!(v["text"], shimmer(SHIMMER_FRAME, true)); + // Everything else stays as the line said. + assert_eq!(v["class"], json(&first)["class"]); + assert_eq!(v["tooltip"], json(&first)["tooltip"]); + assert_eq!(ind.next_frame(), Some(2 * SHIMMER_FRAME)); + // A late timer catches up with the clock. + let late = ind + .tick(SHIMMER_FRAME * 5 + Duration::from_millis(40)) + .unwrap(); + assert_eq!(json(&late)["text"], shimmer(SHIMMER_FRAME * 5, true)); + assert_eq!(ind.next_frame(), Some(6 * SHIMMER_FRAME)); + // Another transcribing line (new counts) does not restart it. + let same = ind + .update("state=transcribing tag=prompt model=ready mic=present agent-input=2") + .unwrap(); + assert_eq!(json(&same)["text"], shimmer(SHIMMER_FRAME * 5, true)); + assert_eq!(ind.next_frame(), Some(6 * SHIMMER_FRAME)); + + // It stops the moment the state changes. + ind.update("state=typing model=ready mic=present"); + assert_eq!(ind.next_frame(), None); + assert_eq!(ind.tick(SHIMMER_FRAME * 6), None); + // The next dictation's wait starts its sweep again. + let again = ind + .update("state=transcribing model=ready mic=present") + .unwrap(); + assert_eq!(json(&again)["text"], shimmer(Duration::ZERO, false)); + assert_eq!(ind.next_frame(), Some(SHIMMER_FRAME)); + assert!(ind.tick(SHIMMER_FRAME).is_some()); + ind.offline(); + assert_eq!(ind.next_frame(), None); + ind.update("state=transcribing model=ready mic=present"); + ind.update(&rec(30)); + assert_eq!(ind.next_frame(), None); + } + #[test] fn nothing_in_a_text_or_tooltip_needs_escaping() { // Every text and tooltip, across states, counts and levels. @@ -1140,11 +1585,12 @@ mod tests { " agent-approval=3 agent-input=200 agent-working=1 agent-done=7 suggestions=1 words=40", " agent-input=1 suggestions=12 words=1", ] { - for level in [0, 1, 4, 5, 9, 10, 29, 30, 49, 50, 77, 99, 100, 101, 255] { + for level in [0u64, 1, 4, 5, 9, 10, 29, 30, 49, 50, 77, 99, 100, 101, 255] { let line = format!( "state={state} mode=hold model=ready mic=present level={level}{extra}" ); objects.extend(ind.update(&line)); + objects.extend(ind.tick(Duration::from_millis(level * 37))); } } } @@ -1152,26 +1598,19 @@ mod tests { for o in &objects { let v = json(o); let (t, tip) = (v["text"].as_str().unwrap(), v["tooltip"].as_str().unwrap()); - // The only markup: the wave's one monospace span, and inside - // it alpha spans, each around one dot. - let mut rest = match t.strip_prefix("") { - Some(inner) => inner.strip_suffix("").expect(t), - None => t, - }; - while let Some(i) = rest.find('<') { - let (head, tail) = rest[i..].split_once("%\">").expect(t); - let alpha = head.strip_prefix("().is_ok_and(|a| a < 100), "{t:?}"); - let mut chars = tail.chars(); - let dot = chars.next(); - assert!(STEPS.iter().any(|&(d, _)| Some(d) == dot), "{t:?}"); - rest = chars.as_str().strip_prefix("").expect(t); + // The only markup: spans with the attributes `glyphs` reads, + // around fixed characters. + for g in glyphs(t) { + assert!( + matches!(g.c, ' ' | STRUT | PROMPT_MARKER | '0'..='9' | '\u{e000}'..='\u{f8ff}') + || g.step().is_some(), + "{t:?}" + ); } - let shown = plain(t); - assert_eq!(shown.chars().count(), WIDTH, "{t:?}"); - for s in [shown.as_str(), tip] { + for s in [plain(t).as_str(), tip] { assert!(!s.contains(['<', '>', '&']), "{s:?}"); } + assert_eq!(cells(t), WIDTH as f64, "{t:?}"); } } @@ -1212,10 +1651,10 @@ mod tests { LOADING_GLYPH, " 1 ", ), - // Busy states keep their own glyph. + // Busy states keep their own text. ( - "transcribing model=ready mic=present agent-approval=1", - TRANSCRIBING_GLYPH, + "typing model=ready mic=present agent-approval=1", + TYPING_GLYPH, " ", ), ]; @@ -1223,6 +1662,11 @@ mod tests { let t = text(&ind.update(&format!("state={fields}")).unwrap()); assert_eq!(t, format!(" {glyph}{count}"), "{fields}"); } + let t = text( + &ind.update("state=transcribing model=ready mic=present agent-approval=1") + .unwrap(), + ); + assert_eq!(t, shimmer(Duration::ZERO, false)); } #[test] diff --git a/linux/crates/lf-daemon/tests/daemon.rs b/linux/crates/lf-daemon/tests/daemon.rs index b23feef4..f9271119 100644 --- a/linux/crates/lf-daemon/tests/daemon.rs +++ b/linux/crates/lf-daemon/tests/daemon.rs @@ -1339,6 +1339,87 @@ fn hold_to_prompt_by_double_tap_and_hold() { let _ = bar.wait(); } +/// While transcribing, `watch --waybar` draws the shimmer's frames on its +/// own, with no new watch line; in every other state it writes only what a +/// line changes. +#[test] +fn localflowctl_waybar_shimmers_only_while_transcribing() { + use lf_daemon::waybar::{SHIMMER_FRAME, shimmer}; + // A stand-in daemon that sends the watch lines the test hands it. + let dir = TempDir::new("waybar-shimmer"); + let rd = runtime_dir(&dir); + private_dir(&rd.join("localflow")); + let listener = std::os::unix::net::UnixListener::bind(rd.join("localflow/ctl.sock")).unwrap(); + let (send, to_send) = std::sync::mpsc::channel::<&'static str>(); + let server = std::thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + let mut request = [0u8; 256]; + let _ = conn.read(&mut request); + for fields in to_send { + if conn + .write_all(format!("localflow/1 ok {fields}\n").as_bytes()) + .is_err() + { + break; + } + } + }); + let (mut child, lines) = spawn_ctl(&rd, &["watch", "--waybar"]); + let object = |wait: Duration| -> Option { + let line = lines.recv_timeout(wait).ok()?; + Some(serde_json::from_str(&line).unwrap()) + }; + let soon = Duration::from_secs(10); + // Several frames' worth of quiet. + let quiet = SHIMMER_FRAME * 6; + + send.send("state=idle model=ready mic=present").unwrap(); + assert_eq!(object(soon).unwrap()["class"], "idle"); + assert!(object(quiet).is_none(), "idle: more objects"); + + // The wait: its first frame at once, then frames from a timer. + send.send("state=transcribing model=ready mic=present") + .unwrap(); + let first = object(soon).unwrap(); + assert_eq!(first["class"], "transcribing"); + assert_eq!(first["text"], shimmer(Duration::ZERO, false)); + let began = Instant::now(); + let mut texts = vec![first["text"].as_str().unwrap().to_owned()]; + let span = Duration::from_secs(1); + while let Some(v) = object(span.saturating_sub(began.elapsed())) { + assert_eq!(v["class"], "transcribing", "{v}"); + texts.push(v["text"].as_str().unwrap().to_owned()); + } + // Ten a second: allow for a loaded machine, but frames must come. + assert!(texts.len() >= 5, "{} frames in 1 s", texts.len()); + assert!(texts.windows(2).all(|w| w[0] != w[1]), "a frame repeated"); + + // Each other state: its object, then nothing more. + for (fields, class) in [ + ("state=typing model=ready mic=present", "typing"), + ( + "state=recording mode=hold model=ready mic=present level=0", + "recording", + ), + ("state=idle model=ready mic=present", "idle"), + ] { + send.send(fields).unwrap(); + // A frame may already be on its way. + let v = loop { + let v = object(soon).unwrap(); + if v["class"] != "transcribing" { + break v; + } + }; + assert_eq!(v["class"], class); + assert!(object(quiet).is_none(), "{class}: frames after the wait"); + } + let _ = child.kill(); + let _ = child.wait(); + drop(send); + server.join().unwrap(); +} + #[test] fn localflowctl_waybar_shows_states_and_survives_daemon_restarts() { let dir = TempDir::new("waybar"); @@ -1400,8 +1481,8 @@ fn localflowctl_waybar_shows_states_and_survives_daemon_restarts() { assert_eq!(v["class"], "recording"); let wave = v["text"].as_str().unwrap(); assert_eq!( - lf_daemon::waybar::plain(wave).chars().count(), - lf_daemon::waybar::WIDTH + lf_daemon::waybar::cells(wave), + lf_daemon::waybar::WIDTH as f64 ); // Listening shows at once: the wave ends in its full anchor dot. assert!( From e5b2179efa45a4fd0ddf1cf4ee1ae98fdfa50512 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 7 Nov 2026 10:16:58 -0800 Subject: [PATCH 16/18] linux: scale the pill's wave to the speaker's own voice, as the iPhone keyboard does The daemon reports the input level as linear RMS in percent. Normal speech sits around levels 2 to 18, so the old square-root curve, which needed a level of about 42 for a full dot, left a real voice almost still. The pill now ports the keyboard's adaptive meter, client side. Each level becomes dBFS, the top of the window is the recording's recent loudest moment (held 1.2 s, then sinking 6 dB/s, never below -30 dBFS), and the bottom is 27 dB under it, never below a -45 dBFS gate. The keyboard's voice curve then maps the result to steps. Soft, normal and loud voices now all reach full dots on stressed syllables and dip between words, while a quiet room rests. The tests use realistic levels taken from synthetic speech at -32, -22 and -12 dBFS. --- linux/CHANGELOG.md | 5 +- linux/README.md | 13 +- linux/crates/lf-daemon/src/waybar.rs | 428 +++++++++++++++++++++++---- 3 files changed, 384 insertions(+), 62 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index d618937c..a3903e4f 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -37,7 +37,10 @@ releases are tagged `linux-vX.Y.Z`, separately from the macOS app's tags. silence. Your voice crosses the row in about half a second: a syllable rises at once beside the aurora dot and fades within a few dots, so syllables read as separate pulses, and the row rises out of the line at - its left end; in silence the dots rest small and dim. While transcribing, + its left end; in silence the dots rest small and dim. As on the keyboard, + the dots follow your own voice, measured against the recording's recent + loudest moment, so a soft, a normal and a loud voice all swell to full + dots on their syllables and a quiet room stays at rest. While transcribing, the row rests without the aurora dot and a glacier-white spot sweeps across it every 1.1 s, drawn by `localflowctl` itself ten times a second and only in that state. The dots are standard characters with Pango diff --git a/linux/README.md b/linux/README.md index ab779e45..713d7eb9 100644 --- a/linux/README.md +++ b/linux/README.md @@ -389,9 +389,16 @@ keyed by `alt` if you like. The wave shows about the last half second of your voice: each level line from the daemon (every 66 ms) moves it one dot, and a syllable rises at once and fades within a few dots, so it crosses the row as a pulse. It rises out -of the line at its left end, as on the keyboard. While transcribing, -`localflowctl` itself draws the shimmer ten frames a second, a sweep every -1.1 s; in every other state it writes only when the state changes. +of the line at its left end, as on the keyboard. As on the keyboard too, the +dots follow your own voice rather than a fixed scale: they are measured +against the recording's recent loudest moment (held for 1.2 s, then sinking +6 dB a second, never below -30 dBFS), down to 27 dB under it and never below +-45 dBFS. So a soft, a normal and a loud voice all swell to full dots on +their syllables and dip between them, and a quiet room stays at rest. + +While transcribing, `localflowctl` itself draws the shimmer ten frames a +second, a sweep every 1.1 s; in every other state it writes only when the +state changes. The dots are `•` and `●` (U+2022, U+25CF), which common fonts such as DejaVu have, and the glyphs are Font Awesome's, which stock Waybar's style asks diff --git a/linux/crates/lf-daemon/src/waybar.rs b/linux/crates/lf-daemon/src/waybar.rs index a012642c..0c7a0f70 100644 --- a/linux/crates/lf-daemon/src/waybar.rs +++ b/linux/crates/lf-daemon/src/waybar.rs @@ -15,13 +15,19 @@ //! the middle line, the voice's last half second, newest on the right //! beside the anchor. Each level line (every 66 ms) moves the row one dot //! to the left, through a follower that rises at once and falls quickly, -//! so a syllable is a pulse that crosses the row and leaves it. Louder -//! dots grow and brighten; in silence the row is calm, equal resting -//! dots. As on the keyboard, the row rises out of the line at its left -//! end ([`ENVELOPE`]). The anchor is always lit, so silence still reads -//! as listening; its colour is set in the markup, so it is aurora on any -//! bar, with or without the README's CSS. The wave's dots take the -//! module's colour (white in the README's CSS). +//! so a syllable is a pulse that crosses the row and leaves it. (The +//! keyboard's row spans 0.75 s; one dot a line makes 0.53 s, still +//! quicker than the 1.1 s shimmer as on the keyboard, where two lines a +//! dot would be as slow as it.) Each level is placed in the keyboard's +//! meter window ([`Window`]), which runs up to the recording's recent +//! loudest moment, so a soft, a normal and a loud voice alike swell to +//! full dots on their syllables and dip between them, and a quiet room +//! rests. Louder dots grow and brighten ([`voice`]); in silence the row +//! is calm, equal resting dots. As on the keyboard, the row rises out of +//! the line at its left end ([`ENVELOPE`]). The anchor is always lit, so +//! silence still reads as listening; its colour is set in the markup, so +//! it is aurora on any bar, with or without the README's CSS. The wave's +//! dots take the module's colour (white in the README's CSS). //! - Transcribing shows the keyboard's wait: the row at rest, without the //! anchor, with a brighter, larger spot sweeping it left to right every //! 1.1 s ([`shimmer`]). `localflowctl` draws a frame every @@ -265,14 +271,99 @@ impl Class { } } -/// Levels up to this (in 0..=100) are room noise: a dot at rest. -const NOISE: f32 = 4.0; +/// The keyboard's meter window (`LevelMeter`), in dBFS: below the gate is +/// the room, not a voice. +pub const GATE_DB: f32 = -45.0; +/// The window's top never sinks below this, so a murmur just over the gate +/// is never stretched to a full dot. +pub const PEAK_FLOOR_DB: f32 = -30.0; +/// How long the top holds after the loudest moment, and how fast it then +/// sinks. +pub const PEAK_HOLD: Duration = Duration::from_millis(1200); +pub const PEAK_DECAY_DB_PER_SECOND: f32 = 6.0; +/// The window's depth under its top at most, so a loud speaker's gaps +/// between syllables still read as gaps. +pub const RANGE_DB: f32 = 27.0; + +/// A level line's level (the latest capture block's RMS x 100) in dBFS: 1 +/// is -40, 100 full scale; 0 (silence) is far under [`GATE_DB`]. +pub fn dbfs(level: u8) -> f32 { + match level { + 0 => -100.0, + l => 20.0 * (f32::from(l.min(100)) / 100.0).log10(), + } +} + +/// The keyboard's meter window (`LevelMeter`), run on the level lines with +/// their spacing ([`LEVEL_INTERVAL`]) as its clock: it maps each level onto +/// the speaker's own range, so a soft, a normal and a loud voice (-32, -22, +/// -12 dBFS) all reach the top on their syllables and dip between them, and +/// a quiet room stays under the bottom. The top is the recording's recent +/// loudest moment, never below [`PEAK_FLOOR_DB`]; the bottom is +/// [`RANGE_DB`] under it, never below [`GATE_DB`]. One per recording. +#[derive(Clone, Copy, Debug)] +pub struct Window { + /// The top, in dBFS. + peak: f32, + /// Seconds since the top was set. + since: f32, +} + +impl Default for Window { + fn default() -> Self { + Window { + peak: PEAK_FLOOR_DB, + since: 0.0, + } + } +} + +impl Window { + /// Hears one level line, [`LEVEL_INTERVAL`] after the last: where it + /// falls in the window, 0 at or under the bottom, 1 at the top. + pub fn hear(&mut self, level: u8) -> f32 { + let db = dbfs(level); + let line = LEVEL_INTERVAL.as_secs_f32(); + self.since += line; + let hold = PEAK_HOLD.as_secs_f32(); + if self.since > hold { + self.peak -= PEAK_DECAY_DB_PER_SECOND * (self.since - hold).min(line); + } + if db >= self.peak { + self.peak = db; + self.since = 0.0; + } + self.peak = self.peak.max(PEAK_FLOOR_DB); + let low = self.bottom(); + ((db - low) / (self.peak - low)).clamp(0.0, 1.0) + } + + /// The window's top, in dBFS. + pub fn top(&self) -> f32 { + self.peak + } -/// The [`STEPS`] index for a level of 0..=100: at rest up to [`NOISE`], -/// then a square-root curve so quiet speech still shows. -pub fn step(level: u8) -> usize { - let v = ((f32::from(level.min(100)) - NOISE).max(0.0) / (100.0 - NOISE)).sqrt(); - (v * (STEPS.len() - 1) as f32).round() as usize + /// The window's bottom, in dBFS. + pub fn bottom(&self) -> f32 { + GATE_DB.max(self.peak - RANGE_DB) + } +} + +/// How much of the swell a place in the window (0..=1) takes, as the +/// keyboard's `DotWave.voice`: nothing below 0.15 (a background murmur), +/// then rising faster than the level (a 1.5 power), so a loud word looks +/// clearly larger than a quiet one. +pub fn voice(x: f32) -> f32 { + if x.is_nan() { + return 0.0; + } + ((x.clamp(0.0, 1.0) - 0.15).max(0.0) / 0.85).powf(1.5) +} + +/// The [`STEPS`] index for a place in the window (0..=1): its [`voice`], +/// to the nearest step. +pub fn step(x: f32) -> usize { + (voice(x) * (STEPS.len() - 1) as f32).round() as usize } /// One dot of a row for a [`STEPS`] index, as Pango markup: the dot at @@ -494,7 +585,9 @@ pub struct Indicator { /// The current recording's recent dots, oldest first: [`STEPS`] indices /// of the followed level, one per level line. cells: VecDeque, - /// The followed level, 0..=100. + /// The current recording's meter window. + window: Window, + /// The followed place in the window, 0..=1. follow: f32, recording: bool, /// While transcribing: the shimmer's frame shown, counted from the @@ -615,11 +708,15 @@ impl Indicator { render(class, &classes, &text, &tooltip) } - /// One level line while recording: the follower moves towards it (fast - /// up, quickly down, over one [`LEVEL_INTERVAL`]), and its step is the - /// row's newest dot. + /// One level line while recording: the follower moves towards its place + /// in the window (fast up, quickly down, over one [`LEVEL_INTERVAL`]), + /// and its step is the row's newest dot. The keyboard's meter also + /// blends each level in (0.8 up, 0.55 down per 100 ms buffer) ahead of + /// its follower; here the follower alone: at one dot per line, the two + /// in a row shrink a syllable one line long to a small dot and fill the + /// dips between syllables. fn hear(&mut self, level: u8) { - let target = f32::from(level.min(100)); + let target = self.window.hear(level); let constant = if target > self.follow { RISE_TIME } else { @@ -630,7 +727,7 @@ impl Indicator { if self.cells.len() == WAVE_DOTS { self.cells.pop_front(); } - self.cells.push_back(step(self.follow.round() as u8)); + self.cells.push_back(step(self.follow)); } /// The recording's text: the prompt marker if any, the newest dots @@ -680,6 +777,46 @@ mod tests { rec_line(false, level) } + /// A level line's level for `db` dBFS of RMS, rounded as the daemon + /// does (RMS x 100). + fn level_of(db: f32) -> u8 { + (100.0 * 10f32.powf(db / 20.0)).round().min(100.0) as u8 + } + + /// A quiet room at a desk (about -45 dBFS): levels 0 and 1, as the + /// daemon sends them. + fn room(n: usize) -> Vec { + (0..n).map(|i| u8::from(i % 3 != 1)).collect() + } + + /// A soft, a normal and a loud voice: their RMS over a phrase, in dBFS, + /// as on the keyboard (`LevelMeter`). + const VOICES: [f32; 3] = [-32.0, -22.0, -12.0]; + + /// An invented phrase's syllables, one level line (66 ms) each, in dB + /// around the voice's level: as measured on a synthesised sentence, + /// each rises, peaks 2 to 7 dB over it and falls 12 to 18 dB under it + /// between syllables. With the index of each syllable's peak. + const SYLLABLES: [(&[f32], usize); 6] = [ + (&[-9.0, 7.0, 1.0, -4.0, -16.0], 1), + (&[-8.0, 3.0, -2.0, -14.0], 1), + (&[-6.0, 5.0, 0.0, -15.0], 1), + (&[-10.0, 2.0, -3.0, -17.0], 1), + (&[-7.0, 6.0, 2.0, -5.0, -18.0], 1), + (&[-5.0, 4.0, -12.0], 1), + ]; + + /// The phrase's level lines for a voice of `voice` dBFS, and the line of + /// each syllable's peak. + fn phrase(voice: f32) -> (Vec, Vec) { + let (mut levels, mut peaks) = (Vec::new(), Vec::new()); + for (syllable, peak) in SYLLABLES { + peaks.push(levels.len() + peak); + levels.extend(syllable.iter().map(|db| level_of(voice + db))); + } + (levels, peaks) + } + /// Feeds `levels` as recording lines; the text after the last. fn feed(ind: &mut Indicator, levels: &[u8]) -> String { feed_line(ind, false, levels) @@ -1102,17 +1239,23 @@ mod tests { #[test] fn the_wave_is_white_dots_that_rest_and_swell_as_the_keyboards_do() { let last = STEPS.len() - 1; - assert_eq!(step(0), 0); - assert_eq!(step(100), last); - assert_eq!(step(255), last); - // Room noise stays at rest; the square-root curve lifts quiet - // speech: 10% is already a step up, 30% two, and half-loud speech - // reaches the full dot. - assert_eq!(step(4), 0); - assert_eq!(step(10), 1); - assert_eq!(step(30), 2); - assert_eq!(STEPS[step(50)].0, FULL_DOT); - assert!((0..100).all(|l| step(l) <= step(l + 1))); + // The keyboard's swell (`DotWave.voice`) of a level in its window: + // nothing below 0.15 (a murmur), then a 1.5 power, so a loud word + // looks clearly larger than a quiet one. Each dot is its nearest + // step. + for i in 0..=100 { + let x = i as f32 / 100.0; + let want = (((x - 0.15).max(0.0)) / 0.85).powf(1.5); + assert!((voice(x) - want).abs() < 1e-6, "{x}"); + assert_eq!(step(x), (want * last as f32).round() as usize, "{x}"); + } + assert_eq!(voice(-1.0), 0.0); + assert_eq!(voice(2.0), 1.0); + assert_eq!(voice(f32::NAN), 0.0); + assert_eq!(step(0.0), 0); + assert_eq!(step(0.15), 0); + assert_eq!(step(1.0), last); + assert!((0..100).all(|i| step(i as f32 / 100.0) <= step((i + 1) as f32 / 100.0))); // At rest: the small dot, dimmed (60% keeps 3:1 on the black pill // at 1x); at the top of the swell: the full dot at full brightness. assert_eq!(STEPS[0], (SMALL_DOT, 60)); @@ -1132,11 +1275,11 @@ mod tests { // Silence is a calm row of equal resting dots before the anchor. let mut ind = Indicator::default(); - let quiet = feed(&mut ind, &[0, 3, 2, 4, 1, 0, 2, 3, 0, 1, 4, 2]); + let quiet = feed(&mut ind, &room(12)); assert_eq!(dots(&quiet), [0; WAVE_DOTS]); // The wave's dots take the bar's ink (white in the README's CSS): // only the anchor carries a colour of its own. - let speech = feed(&mut ind, &[40, 85, 6, 9, 30, 20, 2, 5, 60, 55, 30, 9]); + let speech = feed(&mut ind, &[5, 18, 8, 1, 7, 6, 11, 3, 2, 11, 9, 4]); for g in glyphs(&speech) { if g.step().is_some() && g.size.as_deref() == Some(DOT_SIZE) { assert_eq!(g.foreground, None, "{speech}"); @@ -1238,16 +1381,16 @@ mod tests { for prompt in [false, true] { let n = if prompt { PROMPT_WAVE_DOTS } else { WAVE_DOTS }; let mut ind = Indicator::default(); - let rest = feed_line(&mut ind, prompt, &[0; 10]); + let rest = feed_line(&mut ind, prompt, &room(10)); assert_eq!(dots(&rest), vec![0; n]); // Every line changes the row (none is held back for a pair). - let t = feed_line(&mut ind, prompt, &[100]); + let t = feed_line(&mut ind, prompt, &[12]); assert_ne!(t, rest); assert_eq!(peak(&t), n - 1, "{t}"); // Then it moves left one dot per line, through the row (until // it sinks into the line at the left end). for k in 1..n - 3 { - let t = feed_line(&mut ind, prompt, &[0]); + let t = feed_line(&mut ind, prompt, &[1]); assert_eq!(peak(&t), n - 1 - k, "{prompt} {k} {t}"); } } @@ -1257,30 +1400,34 @@ mod tests { fn syllables_rise_at_once_and_fall_quickly() { // The keyboard's follower: 25 ms up, 90 ms down. A syllable shows // in the very next dot, and its tail is gone within four lines. + // At every loudness, a syllable shows in full in the very next dot. + for voice in VOICES { + let mut ind = Indicator::default(); + feed(&mut ind, &room(10)); + let onset = feed(&mut ind, &[level_of(voice + 3.0)]); + assert!(full(dots(&onset)[WAVE_DOTS - 1]), "{voice} {onset}"); + } let mut ind = Indicator::default(); - feed(&mut ind, &[0; 10]); - let onset = feed(&mut ind, &[60]); - assert!(dots(&onset)[WAVE_DOTS - 1] > 0, "{onset}"); - let mut ind = Indicator::default(); - feed(&mut ind, &[0; 10]); - feed(&mut ind, &[100]); + feed(&mut ind, &room(10)); + feed(&mut ind, &[12]); let mut tail = Vec::new(); for _ in 0..5 { - tail.push(dots(&feed(&mut ind, &[0]))[WAVE_DOTS - 1]); + tail.push(dots(&feed(&mut ind, &[1]))[WAVE_DOTS - 1]); } assert!(tail[0] > 0, "{tail:?}"); assert!(tail.windows(2).all(|w| w[1] <= w[0]), "{tail:?}"); assert_eq!(tail[3..], [0, 0], "{tail:?}"); - // Two syllables 130 ms apart read as two pulses: a dip between them. + // Two syllables 200 ms apart read as two pulses: a dip between them, + // though the voice falls only 15 dB there. let mut ind = Indicator::default(); - feed(&mut ind, &[0; 10]); - let t = feed(&mut ind, &[100, 0, 0, 100, 0]); + feed(&mut ind, &room(10)); + let t = feed(&mut ind, &[12, 2, 2, 12, 2]); let d = dots(&t); let (a, b) = (WAVE_DOTS - 5, WAVE_DOTS - 2); assert!(full(d[a]) && full(d[b]), "{d:?}"); assert!((a + 1..b).any(|i| d[i] < d[a].min(d[b])), "{d:?}"); // Speech rises at once even while the row is falling. - let up = feed(&mut ind, &[100]); + let up = feed(&mut ind, &[12]); assert!(full(dots(&up)[WAVE_DOTS - 1]), "{up}"); } @@ -1302,7 +1449,10 @@ mod tests { assert_eq!(d[0], 0, "{t}"); assert!(d[1] <= 1, "{t}"); } - let loud = feed_line(&mut ind, prompt, &[100; 12]); + // A held vowel, loud enough for every dot to be full: the row + // is the envelope itself. + let mut ind = Indicator::default(); + let loud = feed_line(&mut ind, prompt, &[30; 12]); let want: &[usize] = if prompt { &[0, 1, 3, 4, 4, 3] } else { @@ -1330,6 +1480,141 @@ mod tests { } } + #[test] + fn the_window_is_the_keyboards_meter() { + // `LevelMeter`: an absolute noise gate, a top that is the + // recording's recent loudest moment (held 1.2 s, then sinking 6 dB + // a second, never below a floor), and a bottom 27 dB under it. + assert_eq!(GATE_DB, -45.0); + assert_eq!(PEAK_FLOOR_DB, -30.0); + assert_eq!(PEAK_HOLD, Duration::from_millis(1200)); + assert_eq!(PEAK_DECAY_DB_PER_SECOND, 6.0); + assert_eq!(RANGE_DB, 27.0); + // A level line's level is the RMS x 100, so in dBFS: 100 is full + // scale, 1 is -40 dBFS, and 0 (silence) is under the gate. + let close = |a: f32, b: f32| (a - b).abs() < 0.01; + assert_eq!(dbfs(100), 0.0); + assert_eq!(dbfs(255), 0.0); + assert!(close(dbfs(10), -20.0) && close(dbfs(1), -40.0)); + assert!(close(dbfs(3), -30.46)); + assert!(dbfs(0) < GATE_DB); + + // A fresh window runs from the gate to the floor. + let mut w = Window::default(); + assert_eq!((w.bottom(), w.top()), (GATE_DB, PEAK_FLOOR_DB)); + assert_eq!(w.hear(0), 0.0); + assert!(close(w.hear(1), 5.0 / 15.0)); + // A louder moment is the top at once, the bottom 27 dB under it. + let top = dbfs(50); + assert_eq!(w.hear(50), 1.0); + assert!(close(w.top(), top) && close(w.bottom(), top - RANGE_DB)); + // In between, a level falls where its dBFS does. + assert!(close(w.hear(10), (-20.0 - (top - RANGE_DB)) / RANGE_DB)); + assert_eq!(w.hear(2), 0.0); + // The top holds for 1.2 s of level lines after the moment, then + // sinks 6 dB a second, down to the floor, where the gate is the + // bottom again. + let line = LEVEL_INTERVAL.as_secs_f32(); + for k in 3..=200 { + let x = w.hear(1); + let since = k as f32 * line; + let want = (top - 6.0 * (since - 1.2).max(0.0)).max(PEAK_FLOOR_DB); + assert!(close(w.top(), want), "{k}: {} {want}", w.top()); + assert!(close(w.bottom(), (want - RANGE_DB).max(GATE_DB)), "{k}"); + let at = ((-40.0 - w.bottom()) / (w.top() - w.bottom())).clamp(0.0, 1.0); + assert!(close(x, at), "{k}: {x} {at}"); + } + assert_eq!((w.bottom(), w.top()), (GATE_DB, PEAK_FLOOR_DB)); + // A new loudest moment holds again from its own line. + assert_eq!(w.hear(20), 1.0); + for _ in 0..18 { + w.hear(5); + } + assert!(close(w.top(), dbfs(20))); + w.hear(5); + assert!(w.top() < dbfs(20) - 0.1); + } + + #[test] + fn soft_normal_and_loud_voices_all_reach_full_dots_and_dip_between_syllables() { + // As the keyboard's meter: the window follows the speaker, so a soft + // voice (levels 0 to 6), a normal one (1 to 18) and a loud one (3 + // to 56) all swell to full dots on their stressed syllables and + // fall between them, from the first syllable on. A syllable 5 dB or + // more under the phrase's loudest swells less, as on the keyboard + // (a loud word looks larger than a quiet one), but still lifts. + let loudest = SYLLABLES + .iter() + .map(|(s, p)| s[*p]) + .fold(f32::MIN, f32::max); + for voice in VOICES { + for prompt in [false, true] { + let n = if prompt { PROMPT_WAVE_DOTS } else { WAVE_DOTS }; + let mut ind = Indicator::default(); + feed_line(&mut ind, prompt, &room(8)); + let (levels, peaks) = phrase(voice); + let mut last: Option = None; + let mut stressed = 0; + for (i, &level) in levels.iter().enumerate() { + let t = feed_line(&mut ind, prompt, &[level]); + let Some(k) = peaks.iter().position(|&p| p == i) else { + continue; + }; + let d = dots(&t); + let (syllable, peak) = SYLLABLES[k]; + if syllable[peak] >= loudest - 4.0 { + // A stressed syllable: a full dot beside the anchor. + assert!(full(d[n - 1]), "{voice} {levels:?} {i}: {d:?}"); + stressed += 1; + } else { + assert!(d[n - 1] >= 2, "{voice} {levels:?} {i}: {d:?}"); + } + // Since the last syllable's peak: a dip, a small dot. + if let Some(p) = last { + let back = i - p; + assert!(back < n); + assert!( + d[n - back..n - 1].iter().any(|&s| !full(s)), + "{voice} {levels:?} {i}: {d:?}" + ); + } + last = Some(i); + } + assert_eq!(stressed, SYLLABLES.len() - 1); + } + } + } + + #[test] + fn a_quiet_room_stays_at_rest() { + // A room at a desk, levels 0 and 1 (up to about -37 dBFS, over the + // gate): the row rests from the start of a recording, and again + // once the last word has crossed it, while the window sinks back to + // its floor. + for prompt in [false, true] { + let n = if prompt { PROMPT_WAVE_DOTS } else { WAVE_DOTS }; + let mut ind = Indicator::default(); + let mut seed = 5u32; + let mut noise = || { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + ((seed >> 16) % 2) as u8 + }; + for _ in 0..400 { + let t = feed_line(&mut ind, prompt, &[noise()]); + assert_eq!(dots(&t), vec![0; n], "{t}"); + } + for voice in VOICES { + feed_line(&mut ind, prompt, &phrase(voice).0); + for i in 0..400 { + let t = feed_line(&mut ind, prompt, &[noise()]); + if i >= n + 3 { + assert_eq!(dots(&t), vec![0; n], "{voice} {i}: {t}"); + } + } + } + } + } + #[test] fn a_new_recording_starts_from_silence() { let mut ind = Indicator::default(); @@ -1345,6 +1630,23 @@ mod tests { feed(&mut ind, &[100, 100]); ind.offline(); assert_eq!(dots(&feed(&mut ind, &[0])), [0; WAVE_DOTS]); + + // Its window starts afresh too: after a shout, the next dictation's + // soft voice reaches full dots on its first syllable, as it would + // have without the shout. Within one recording, the shout's window + // holds a moment longer. + let soft = [2, 4]; + let mut ind = Indicator::default(); + feed(&mut ind, &[50, 40, 1, 1]); + let after_shout = feed(&mut ind, &soft); + assert!(!full(dots(&after_shout)[WAVE_DOTS - 1]), "{after_shout}"); + ind.update("state=transcribing model=ready mic=present"); + ind.update("state=idle model=ready mic=present"); + feed(&mut ind, &[1]); + let fresh = feed(&mut ind, &soft); + assert!(full(dots(&fresh)[WAVE_DOTS - 1]), "{fresh}"); + let mut alone = Indicator::default(); + assert_eq!(feed(&mut alone, &[1, 2, 4]), fresh); } #[test] @@ -1397,23 +1699,33 @@ mod tests { #[test] fn sustained_loud_speech_holds_a_steady_wave() { - // Seconds of loud speech: no dot changes size or brightness while - // the row scrolls. - for prompt in [false, true] { + // Seconds of loud speech (a held vowel, or a shout): no dot changes + // size or brightness while the row scrolls. The same for a softer + // voice held as steadily: the window makes it as large. + for (prompt, level) in [ + (false, 40), + (true, 40), + (false, 12), + (false, 4), + (true, 100), + ] { let mut ind = Indicator::default(); let mut waves = Vec::new(); for i in 0..60 { - let t = text(&ind.update(&rec_line(prompt, 100)).unwrap()); + let t = text(&ind.update(&rec_line(prompt, level)).unwrap()); if i >= WAVE_DOTS { waves.push(t); } } assert!(waves.iter().all(|t| *t == waves[0]), "{waves:#?}"); - // Loud speech that varies within the full dot's steps: the dots' - // sizes hold still too. + assert!(dots(&waves[0]).iter().any(|&s| full(s)), "{level}"); + } + for prompt in [false, true] { + // Loud speech that varies by 2 dB or so (levels 40 to 50, -8 to + // -6 dBFS): the dots' sizes hold still too. let mut ind = Indicator::default(); let mut sizes = Vec::new(); - for (i, level) in [60, 100, 75, 90, 100, 65, 85] + for (i, level) in [40, 50, 44, 47, 50, 41, 46] .iter() .cycle() .take(70) @@ -1436,10 +1748,10 @@ mod tests { for prompt in [false, true] { let n = if prompt { PROMPT_WAVE_DOTS } else { WAVE_DOTS }; let mut ind = Indicator::default(); - feed_line(&mut ind, prompt, &[0; 10]); - let mut at = vec![dots(&feed_line(&mut ind, prompt, &[100]))[n - 1]]; + feed_line(&mut ind, prompt, &room(10)); + let mut at = vec![dots(&feed_line(&mut ind, prompt, &[12]))[n - 1]]; for k in 1..n { - at.push(dots(&feed_line(&mut ind, prompt, &[0]))[n - 1 - k]); + at.push(dots(&feed_line(&mut ind, prompt, &[1]))[n - 1 - k]); } // at[k]: the peak's dot k lines later, from the anchor leftwards. assert!(at[..n - 3].iter().all(|&s| full(s)), "{prompt} {at:?}"); From b3baf5526785b2a25153c0ec2f48103b971d58e2 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 7 Nov 2026 10:20:42 -0800 Subject: [PATCH 17/18] linux: use real minus signs for the README's dBFS figures --- linux/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linux/README.md b/linux/README.md index 713d7eb9..25fc269d 100644 --- a/linux/README.md +++ b/linux/README.md @@ -392,8 +392,8 @@ and fades within a few dots, so it crosses the row as a pulse. It rises out of the line at its left end, as on the keyboard. As on the keyboard too, the dots follow your own voice rather than a fixed scale: they are measured against the recording's recent loudest moment (held for 1.2 s, then sinking -6 dB a second, never below -30 dBFS), down to 27 dB under it and never below --45 dBFS. So a soft, a normal and a loud voice all swell to full dots on +6 dB a second, never below −30 dBFS), down to 27 dB under it and never below +−45 dBFS. So a soft, a normal and a loud voice all swell to full dots on their syllables and dip between them, and a quiet room stays at rest. While transcribing, `localflowctl` itself draws the shimmer ten frames a From 71864cb8acea18b054f4845cab4a31544b04e4ac Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 7 Nov 2026 10:23:49 -0800 Subject: [PATCH 18/18] linux: don't time the shimmer's integration test against a one-second window Under load, catching up skips missed frames, so five frames in one second could fail a correct build. The test now waits for five distinct frames with a generous deadline. Frame timing stays with the Indicator::tick unit tests. --- linux/crates/lf-daemon/tests/daemon.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/linux/crates/lf-daemon/tests/daemon.rs b/linux/crates/lf-daemon/tests/daemon.rs index f9271119..280cfed3 100644 --- a/linux/crates/lf-daemon/tests/daemon.rs +++ b/linux/crates/lf-daemon/tests/daemon.rs @@ -1383,15 +1383,15 @@ fn localflowctl_waybar_shimmers_only_while_transcribing() { let first = object(soon).unwrap(); assert_eq!(first["class"], "transcribing"); assert_eq!(first["text"], shimmer(Duration::ZERO, false)); - let began = Instant::now(); + // Frames keep coming with no new line. Their timing is `Indicator::tick`'s + // (unit tests); here a generous deadline, since a loaded machine may skip + // frames by catching up. let mut texts = vec![first["text"].as_str().unwrap().to_owned()]; - let span = Duration::from_secs(1); - while let Some(v) = object(span.saturating_sub(began.elapsed())) { + while texts.len() < 5 { + let v = object(soon).expect("the shimmer stopped drawing frames"); assert_eq!(v["class"], "transcribing", "{v}"); texts.push(v["text"].as_str().unwrap().to_owned()); } - // Ten a second: allow for a loaded machine, but frames must come. - assert!(texts.len() >= 5, "{} frames in 1 s", texts.len()); assert!(texts.windows(2).all(|w| w[0] != w[1]), "a frame repeated"); // Each other state: its object, then nothing more.