diff --git a/linux/AGENT-LINK.md b/linux/AGENT-LINK.md index dd93c551..fbf716f1 100644 --- a/linux/AGENT-LINK.md +++ b/linux/AGENT-LINK.md @@ -112,21 +112,25 @@ 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, 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`. - - 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 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 5fb243a8..a3903e4f 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -7,8 +7,55 @@ 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`; `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, 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 (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 +- The Waybar indicator (`localflowctl watch --waybar`) looks like the + 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. 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 + 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 `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 24433675..25fc269d 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`). @@ -208,9 +211,11 @@ 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"`, `"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 (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 @@ -232,6 +237,42 @@ 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 cyan dot marks each setting not applied yet. If the restart + 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`, 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 + 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. + 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, so `auto` uses localflow-gui only for the + suggestion dialog; set `"picker": "gui"` to correct in it too. + ## Run ```bash @@ -319,33 +360,113 @@ 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 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 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 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 +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 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. 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 +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 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": { "exec": "localflowctl watch --waybar", "return-type": "json", - "restart-interval": 5 + "restart-interval": 5, + "min-length": 6, + "align": 0.5 } ``` +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.recording { color: #f38ba8; } -#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 { + 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: #ffffff; +} +#custom-localflow.transcribing { + color: #abf2fc; +} +#custom-localflow.loading, +#custom-localflow.typing, +#custom-localflow.agent-input { + color: #67e8f9; +} +#custom-localflow.nomic, +#custom-localflow.agent-approval { + color: #f5c66b; +} ``` ### Tray icon @@ -400,7 +521,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 (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 @@ -488,13 +609,10 @@ 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, or beside a raised + hand while one needs approval. ## Text output and desktops @@ -682,8 +800,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 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: @@ -750,7 +869,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 @@ -826,12 +945,18 @@ 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 + 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` + (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/bin/localflowctl.rs b/linux/crates/lf-daemon/src/bin/localflowctl.rs index 35bc6750..828453de 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 (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, @@ -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}; @@ -94,7 +92,7 @@ commands: 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, - wofi or tofi (default auto: the first installed) + 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 @@ -959,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') { @@ -967,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) { @@ -993,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())), } @@ -1112,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(); @@ -1125,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; @@ -1153,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) { @@ -1211,74 +1252,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/config.rs b/linux/crates/lf-daemon/src/config.rs index 353e79c0..82b6ec53 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,132 @@ 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'); + // 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) +} + impl Config { pub fn defaults(data_dir: &Path) -> Config { Config { @@ -182,23 +314,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 +327,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 +986,128 @@ 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); + + // 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); + 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..45ca9847 100644 --- a/linux/crates/lf-daemon/src/picker.rs +++ b/linux/crates/lf-daemon/src/picker.rs @@ -31,9 +31,16 @@ //! //! `"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: +//! rofi, fuzzel, wofi, tofi, then gui (`localflow-gui`; last, so installing +//! 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. +//! 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 @@ -70,6 +77,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 +111,7 @@ const ROWS: [(&str, Option<&str>); 3] = [ #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { + Gui, Rofi, Fuzzel, Wofi, @@ -111,11 +120,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::Rofi, Kind::Fuzzel, Kind::Wofi, Kind::Tofi, Kind::Gui]; - /// 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 +133,25 @@ impl Kind { } } + /// The program looked up on `PATH`. + pub fn program(self) -> &'static str { + match self { + Kind::Gui => "localflow-gui", + k => k.name(), + } + } + + /// 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, - Op::Edit => matches!(self, Kind::Rofi | Kind::Wofi), + Op::Edit => matches!(self, Kind::Gui | Kind::Rofi | Kind::Wofi), } } } @@ -148,7 +173,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, rofi, fuzzel, wofi, tofi, gui"; pub fn parse(s: &str) -> Option { if s == "auto" { @@ -174,7 +199,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 +242,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 rofi or wofi, or localflow-gui)", + ), + NoPicker::NoneFound(Op::Choose) => f.write_str( + "no picker was found (install rofi, fuzzel, wofi or tofi, or localflow-gui)", + ), 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 \"rofi\", \"wofi\", \"gui\" 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()), } } @@ -250,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, @@ -402,6 +429,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 +439,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 +701,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 +732,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 +765,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 +834,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 +871,42 @@ 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: 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); + } + // 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::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(), + 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..5ca5eb7d --- /dev/null +++ b/linux/crates/lf-daemon/src/picker/gui.rs @@ -0,0 +1,75 @@ +//! 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. +//! - 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}; + +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/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/crates/lf-daemon/src/waybar.rs b/linux/crates/lf-daemon/src/waybar.rs index 884914f6..0c7a0f70 100644 --- a/linux/crates/lf-daemon/src/waybar.rs +++ b/linux/crates/lf-daemon/src/waybar.rs @@ -2,44 +2,189 @@ //! 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. 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 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. (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 +//! [`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 +//! 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 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. //! //! 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. +//! 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 +//! (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, //! suggestions and project words, when there are any. use std::collections::VecDeque; +use std::time::Duration; -/// nf-fa-microphone. +use crate::watch::LEVEL_INTERVAL; + +/// 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-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}"; +/// 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}'; + +/// 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; + +/// 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 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 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), + (SMALL_DOT, 100), + (FULL_DOT, 80), + (FULL_DOT, 100), +]; + +/// 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; -/// Levels shown while recording. -pub const HISTORY: usize = 8; +/// 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]; -const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +/// 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 { @@ -104,31 +249,236 @@ 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 the states without a row of dots. 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 | Class::Transcribing => MIC_GLYPH, + Class::Loading => LOADING_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] +/// 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 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 +/// [`DOT_SIZE`] with its rise (and its alpha unless that is 100%), then its +/// gap. +pub fn dot(step: usize) -> String { + 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!(" ") +} + +/// `inner` as a row: in the monospace span, after the [`STRUT`]. +fn row(inner: &str) -> String { + format!("{STRUT}{inner}") +} + +/// 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; + 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 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 { + 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 +528,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 @@ -229,11 +567,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 => {} @@ -247,9 +582,19 @@ 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 dots, oldest first: [`STEPS`] indices + /// of the followed level, one per level line. + cells: VecDeque, + /// 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 + /// start of the wait. + frame: Option, + /// The last watch line's fields, which a shimmer frame shows again. + fields: String, } impl Indicator { @@ -257,13 +602,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(), @@ -274,65 +669,88 @@ impl Indicator { _ => {} } } - if class == Class::Recording { - if !self.recording { - self.levels.clear(); - } - if let Some(l) = level { - if self.levels.len() == HISTORY { - self.levels.pop_front(); - } - self.levels.push_back(l); - } - } - 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; + let mut glyph = class.glyph(); if !busy { if n.approval > 0 { classes.push(AGENT_APPROVAL_CLASS); + if class != Class::NoMic { + glyph = APPROVAL_GLYPH; + } } else if n.input > 0 { classes.push(AGENT_INPUT_CLASS); } 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 => 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, Class::Offline.glyph()) + /// 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 = self.window.hear(level); + 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)); } - /// 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))) - .collect() + /// 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 (marker, pad) = if prompt { + (PROMPT_MARKER.to_string(), PROMPT_ANCHOR_PAD) + } else { + (String::new(), ANCHOR_PAD) + }; + row(&format!( + "{marker}{dots}{}{FULL_DOT}", + space(pad) + )) } } @@ -340,93 +758,396 @@ 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() + } + + /// 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 { + 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) + } + + 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_line(prompt, l)).unwrap()); + } + t + } + + /// 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 = 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(); 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\"}", - ), - ( - "state=recording mode=hold model=ready mic=present level=100", - "{\"text\": \"▁▁▁▁▁▁▁█\", \"alt\": \"recording\", \"class\": \"recording\", \"tooltip\": \"LocalFlow: recording\"}", - ), - ( - "state=transcribing model=ready mic=present", - "{\"text\": \"\u{f252}\", \"alt\": \"transcribing\", \"class\": \"transcribing\", \"tooltip\": \"LocalFlow: transcribing\"}", + "{\"text\": \" \u{f017} \", \"alt\": \"loading\", \"class\": \"loading\", \"tooltip\": \"LocalFlow: loading the model\"}", ), ( "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(); + 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()); } + // Transcribing: the shimmer's first frame. + let transcribing = ind + .update("state=transcribing model=ready mic=present") + .unwrap(); + assert_eq!( + transcribing, + format!( + "{{\"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!( 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); + 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 { - let v: serde_json::Value = serde_json::from_str(o).unwrap(); + for o in &seen { + 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. Recording and transcribing show dots instead: the wave with + // its anchor, and the shimmer without one. + let glyphs_ = [ + MIC_GLYPH, + LOADING_GLYPH, + 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); + } + 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_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=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 { + 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[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 json = |o: String| -> serde_json::Value { serde_json::from_str(&o).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=recording mode=hold tag=prompt model=ready mic=present level=100") + &ind.update("state=transcribing tag=prompt model=ready mic=present") .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)] { - 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: {state} (prompt)")); - assert_eq!(v["text"], glyph); - } + 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!( + plain(t).trim_start_matches(STRUT).chars().next(), + Some(PROMPT_MARKER) + ); + 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(), - "{\"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\"}" ); } @@ -436,54 +1157,67 @@ mod tests { 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\"}", + "{\"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", - "{\"text\": \"\u{f130} 1\", \"alt\": \"loading\", \"class\": [\"loading\", \"agent-input\"], \"tooltip\": \"LocalFlow: loading model\\nAgents: 1 waiting for input\"}", + "{\"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", - "{\"text\": \"\u{f00d}\", \"alt\": \"nomic\", \"class\": [\"nomic\", \"suggestion\"], \"tooltip\": \"LocalFlow: no microphone\\n1 dictionary suggestion from an agent (localflowctl dict review)\"}", + "{\"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", - "{\"text\": \"\u{f130} 1\", \"alt\": \"idle\", \"class\": [\"idle\", \"agent-approval\"], \"tooltip\": \"LocalFlow: idle\\nAgents: 1 needs approval\"}", + "{\"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", - "{\"text\": \"\u{f130}\", \"alt\": \"idle\", \"class\": \"idle\", \"tooltip\": \"LocalFlow: idle\\nAgents: 1 working, 1 done\\n1 project word boosted\"}", - ), - // 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=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\"}", + "{\"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", - "{\"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)\"}", + "{\"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(); 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: ")); } + // 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(), - "{\"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 +1237,748 @@ 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_wave_is_white_dots_that_rest_and_swell_as_the_keyboards_do() { + let last = STEPS.len() - 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)); + 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]); + 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.iter().map(|&(c, _)| c).chain([PROMPT_MARKER, STRUT]) { + let c = c as u32; + assert!(!(0xe000..=0xf8ff).contains(&c) && c < 0xf0000); + } + + // Silence is a calm row of equal resting dots before the anchor. + let mut ind = Indicator::default(); + 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, &[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}"); + } + } + // Speech undulates, in size as well as brightness. + let d = dots(&speech); + assert!( + d.iter().any(|&s| full(s)) && d.iter().any(|&s| !full(s)), + "{speech}" + ); + let mut distinct = d.clone(); + distinct.sort(); + distinct.dedup(); + assert!(distinct.len() >= 3, "{d:?}"); + } + + #[test] + 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_rows_are_set_in_one_monospace_span() { + // A proportional bar font draws `•` and `●` at different widths; + // 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(); - 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()), "▁▁▁▁▁▁█▅"); - 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. + 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=typing model=ready mic=present", + ] { + assert!(!text(&ind.update(line).unwrap()).contains('<')); + } + } + + #[test] + 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!( - text(ind.update("state=recording mode=hold model=ready").unwrap()), - "▁▁▁▁▁▁▁█" + 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(); + 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, &[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, &[1]); + assert_eq!(peak(&t), n - 1 - k, "{prompt} {k} {t}"); + } + } + } + + #[test] + 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, &room(10)); + feed(&mut ind, &[12]); + let mut tail = Vec::new(); + for _ in 0..5 { + 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 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, &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, &[12]); + 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}"); + } + // 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 { + &[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 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(); + 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 ); - // The next recording starts from silence. ind.update("state=transcribing model=ready mic=present"); - assert_eq!(text(ind.update(&rec(25)).unwrap()), "▁▁▁▁▁▁▁▅"); + assert_eq!(dots(&feed(&mut ind, &[0])), [0; WAVE_DOTS]); + feed(&mut ind, &[100, 100]); ind.offline(); - assert_eq!(text(ind.update(&rec(0)).unwrap()), "▁▁▁▁▁▁▁▁"); + 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] + fn full_dots_never_touch() { + // 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; + 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 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, side by side. + assert!(fulls > 3000, "{fulls}"); + } + + /// 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 (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, level)).unwrap()); + if i >= WAVE_DOTS { + waves.push(t); + } + } + assert!(waves.iter().all(|t| *t == waves[0]), "{waves:#?}"); + 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 [40, 50, 44, 47, 50, 41, 46] + .iter() + .cycle() + .take(70) + .enumerate() + { + let t = text(&ind.update(&rec_line(prompt, *level)).unwrap()); + if i >= WAVE_DOTS { + 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 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 n = if prompt { PROMPT_WAVE_DOTS } else { WAVE_DOTS }; + let mut ind = Indicator::default(); + 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, &[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:?}"); + 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}"); + } + } + } + + #[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. + 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 [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))); + } + } + } + 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: 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:?}" + ); + } + for s in [plain(t).as_str(), tip] { + assert!(!s.contains(['<', '>', '&']), "{s:?}"); + } + assert_eq!(cells(t), WIDTH as f64, "{t:?}"); + } + } + + #[test] + fn an_agent_needing_approval_shows_the_hand_unless_the_microphone_is_missing() { + 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 ", + ), + // Without a microphone, its glyph stays: nothing can be + // dictated until one is back. The count still shows. + ( + "idle model=ready mic=absent agent-approval=1", + NO_MIC_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 text. + ( + "typing model=ready mic=present agent-approval=1", + TYPING_GLYPH, + " ", + ), + ]; + for (fields, glyph, count) in cases { + 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 0acf71dc..280cfed3 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)); + // 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()]; + 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()); + } + 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"); @@ -1398,9 +1479,16 @@ 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!( + lf_daemon::waybar::cells(wave), + lf_daemon::waybar::WIDTH as f64 + ); + // Listening shows at once: the wave ends in its full anchor dot. + assert!( + lf_daemon::waybar::plain(wave).ends_with(lf_daemon::waybar::FULL_DOT), + "{wave}" + ); assert!(ctl(&rd, &["cancel"]).status.success()); // Skip level lines still queued. while class(&lines) != "idle" {} 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/instance.rs b/linux/gui/src/instance.rs new file mode 100644 index 00000000..25519072 --- /dev/null +++ b/linux/gui/src/instance.rs @@ -0,0 +1,206 @@ +//! 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 { + // 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] + 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/look.rs b/linux/gui/src/look.rs new file mode 100644 index 00000000..50aca300 --- /dev/null +++ b/linux/gui/src/look.rs @@ -0,0 +1,162 @@ +//! 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 +} + +/// `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 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) + .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 new file mode 100644 index 00000000..18a97648 --- /dev/null +++ b/linux/gui/src/main.rs @@ -0,0 +1,190 @@ +//! `localflow-gui`: LocalFlow's windows on Linux, drawn with Rune. +//! +//! - `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 +//! stdout). +//! +//! Rune's own flags work on every window (`--snap out.png`, `--tree`), +//! which render it headless. + +mod instance; +mod look; +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(prompt::edit_min_size()), + on_key: Some(prompt::edit_key), + ..AppSpec::new( + "Fix 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::new(prompt::parse_rows(INPUT.get().map_or("", String::as_str))) + } + 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(prompt::choose_min_size(rows)), + 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 Some(listener) = claim_settings() else { + return 0; + }; + 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::Daemon::SYSTEM)) + })(); + 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") + } + let app = rune::App::new(AppSpec:: { + min_size: Some(settings::MIN_SIZE), + on_key: Some(settings::key), + ..AppSpec::new("LocalFlow Settings", (600.0, 880.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/prompt.rs b/linux/gui/src/prompt.rs new file mode 100644 index 00000000..b92ae769 --- /dev/null +++ b/linux/gui/src/prompt.rs @@ -0,0 +1,504 @@ +//! 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 +//! 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 +//! 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. + +use std::sync::Mutex; + +use rune::tok::space; +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); + +/// 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); + } +} + +/// 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) + .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(&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) + .min_h(0.0); + let buttons = row() + .key("buttons") + .gap(space::S2) + .center_y() + .child(look::mark(MARK)) + .child(el().grow(1.0)) + .child( + look::secondary( + &k, + Button::new("Cancel").on_press_tx(|s: &mut Edit, tx| dismiss(&mut s.outcome, tx)), + ) + .key("cancel"), + ) + .child( + 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(PAD) + .gap(space::S3) + .fill(look::screen()) + .child(field) + .child(buttons) +} + +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 = look::kit(); + let mut list = col().key("rows").gap(space::S2).w_full(); + for (i, label) in s.rows.iter().enumerate() { + 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 { + look::secondary(&k, b) + }; + list = list.child( + row().key(format!("row-{i}")).w_full().child( + b.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), + ), + ); + } + col() + .key("choose-window") + .w_full() + .h_full() + .padding(PAD) + .gap(space::S3) + .justify_center() + .items_center() + .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 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); + PAD * 2.0 + 16.0 + lines as f32 * 17.5 + space::S3 + BUTTON +} + +/// 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 { + 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. +pub fn choose_key(s: &mut Choose, e: &KeyEvent, tx: &mut Tx) -> bool { + let n = s.rows.len(); + if n == 0 { + return 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 => 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) + } + _ => return false, + }, + _ => return false, + } + true +} + +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 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 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())); + 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); + 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, "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( + 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)); + } + + /// `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"); + assert_eq!(rows.len(), 3); + for i in 0..rows.len() { + 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}")); + 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..418d0f81 --- /dev/null +++ b/linux/gui/src/settings.rs @@ -0,0 +1,2023 @@ +//! 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 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 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. + +use std::path::PathBuf; + +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, 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", + 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#""#, +}; + +/// 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>, + /// 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. +#[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, + 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. 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.probe)() == Probe::Absent { + Restarted::Done + } else { + Restarted::Manual + } + } +} + +/// 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, + 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>, + /// What the last Apply did. + pub status: Status, + /// 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. + pub closed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Status { + Idle, + /// Saved (and restarted, unless [`Settings::pending`] says otherwise). + Applied, + /// 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"]; + +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, + daemon: Option, + ) -> 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, + pending: None, + daemon, + asking_to_close: false, + closed: false, + }; + 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.asking_to_close = false; + 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(); + } + + /// 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, + /// 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 + .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) => { + // 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; + if daemon_reads || self.pending.is_some() { + self.restart_daemon(); + } + } + Err(e) => self.status = Status::Failed(e), + } + } + + /// 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 = 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)), + }; + } + + /// 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 + .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)); + } +} + +/// 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() { + if !tx.key_repeat() { + s.close(tx); + } + return true; + } + false +} + +/// `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(|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()) + } +} + +/// 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::{ErrorKind, Read, Write}; + + // `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()) + }; + if lf_daemon::server::peer_uid(stream).ok() != Some(lf_daemon::paths::euid()) { + return false; + } + 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() + { + return false; + } + let mut line = Vec::new(); + 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, +/// 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 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 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) + .min_h(40.0) + .px(space::S3) + .gap(space::S3) + .center_y() + .label(label.to_owned()) + .child(left) + .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()) + .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) + .on_press(move |s: &mut Settings| s.set(key, Value::Bool(!on))) + .role("group") +} + +/// 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 { + let mut body = col() + .key("card") + .padding(space::S1) + .fill(k.c.layer_01) + .radius(12.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_muted) + .a11y_hidden(), + ); + } + } + let mut header = row() + .key("header") + .gap(space::S2) + .center_y() + .ml(space::S3) + .child( + text(title.to_owned()) + .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) +} + +/// 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, 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 +/// 1-bit models, i8x2 for ternary ones. +const SPEED: [(&str, Option<&str>); 5] = [ + ("Auto", None), + ("Fastest", Some("i8x1")), + ("Fast", Some("i8x2")), + ("Slow", Some("i8x3")), + ("Slowest", 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")), +]; + +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 fields = look::fields(&k); + let c = &s.shown; + let mut layer = Vec::new(); + + let dictation = card( + &k, + "dictation", + "Dictation", + false, + 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_fix = s.problem_at(Spot::PromptTag); + let tag = TextInput::new(&s.prompt_tag) + .label("Prompt tag") + .placeholder("Untagged") + .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(); + s.set("prompt_tag", Value::String(v)); + }) + .build(&fields) + .key("prompt-tag-field"); + 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(&seg) + .min_w(FIELD_WIDTH) + .key("choice-double_tap_ms"); + let prompt = card( + &k, + "prompt", + "Hold to prompt", + false, + vec![ + setting(&k, s, "double_tap_ms", "Double-tap and hold", tap), + with_fix( + &k, + setting(&k, s, "prompt_tag", "Prompt tag", tag), + "prompt_tag", + tag_fix, + ), + ], + ); + + 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("Pause between keys") + .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, + s, + "picker", + "Correction dialogs", + &PICKERS, + Some(picker_name(c)), + &mut layer, + ); + let typing = card( + &k, + "typing", + "Typing", + false, + vec![ + 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 = 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(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(); + s.set( + "input_device", + if t.is_empty() { + Value::Null + } else { + Value::String(t) + }, + ); + }) + .build(&fields) + .key("microphone-field"); + let recognition = card( + &k, + "recognition", + "Recognition", + false, + vec![ + setting(&k, s, "precision", "Speed", speed), + with_fix( + &k, + setting(&k, s, "input_device", "Microphone", device), + "input_device", + mic_fix, + ), + ], + ); + + 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(&fields); + let payload = TextInput::new(&s.macros[i].1) + .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(&fields) + .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); + 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") + .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| { + // 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"), + ); + add_row = add_row.child(el().grow(1.0)); + if s.draft.contains_key("voice_macros") { + add_row = add_row.child(reset(&k, "voice_macros", "voice macros")); + } + macro_rows.push(add_row); + let macros = card( + &k, + "voice_macros", + "Voice macros", + s.unapplied("voice_macros"), + macro_rows, + ); + + let content = col() + .key("content") + .w_full() + .px(space::S6) + .pt(16.0) + .pb(space::S6) + .gap(20.0) + .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); + 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( + 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() + }; + // 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(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); + } + } else { + footer = feedback(&k, s, footer, note); + } + let can_apply = s.dirty() && s.config.is_ok(); + footer = footer + .child( + look::secondary( + &k, + Button::new("Discard") + .disabled(!s.dirty()) + .on_press(|s: &mut Settings| s.discard()), + ) + .key("discard"), + ) + .child( + look::primary( + &k, + Button::new("Apply") + .disabled(!can_apply) + .on_press(|s: &mut Settings| s.apply()), + ) + .key("apply"), + ); + 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(), + }; + // 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 + .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() + .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, Key, KeyEvent, Mods, 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 { + 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, (width, 1400.0), Env::new(true, scale)); + rt.on_key = Some(key); + 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 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}"#); + 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"); + // 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"); + assert_eq!(d.settings(), None); + // The controls keep the last valid configuration. + 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("?!")); + } + + 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 + } + + 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, + probe: answered, + }; + const WORKING: Daemon = Daemon { + unit_active: yes, + restart_unit: restart_works, + 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, + 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, + probe: absent, + }; + + #[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); + // 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}"#); + 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.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"); + assert!(rt.find("applied").is_none()); + + // Retry runs the restart again: still failing, then working. + press(&mut rt, "retry"); + 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(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] + 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 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, + 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); + + // 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"); + 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 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}"#); + 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"})) + ); + } +}