From 5bf5030a057288f1094ae5a98086226a967984a9 Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 12 Sep 2026 12:54:32 +0200 Subject: [PATCH 1/2] fix(linux): make the Linux build actually work; add CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux port was never verified against a Linux compiler or a Linux desktop, and four things were broken at runtime. * keys: VK codes were handed to enigo's `Key::Other`, which is a Windows virtual-key code only on Windows — on Linux it is an X11 keysym. Letters and digits coincided; everything else did not. F1 typed "p", the arrow keys typed punctuation, and every media and modifier key was a silent no-op. Adds a VK->keysym table in the new `keys_vk` module, with a test asserting every entry of `keys_library::KEYS` maps, so a future key cannot be added without one. The repeat thread also built a fresh `Enigo` every 33ms, which on Linux meant an X11 connection and a keymap upload per tick; it now keeps one. * enigo: switch from the default `xdo` backend to `x11rb`. `xdo` is `#[link(name = "xdo")]`, so libxdo.so.3 was a load-time dependency and the shipped binary would not start without xdotool installed. DT_NEEDED is now libudev, libfontconfig, libgcc_s, libm, libc and the loader — nothing else. * main/ui: `--hidden` is ignored where there is no tray, and the two tray settings are hidden. `src/tray.rs` is Windows-only, so autostart plus "start minimized" left a process with no window, no tray and no close event: `kill` was the only way out. * pulse: force `LC_ALL=C`. Every parser matched English pactl output, so on a translated desktop device and process lists came back empty and `is_muted` always returned false. Mute and volume are now served from the existing list cache instead of spawning a `pactl` per query — the LED engine polls 10x/s per LED, which was up to 60 fork/exec per second at idle. Monitor sources are filtered out of the microphone picker. * serial: `friendly()` only matched Windows error wording, so both of its Linux branches were unreachable. Adds the libc spellings and names the actual fix (the `dialout` group), plus a udev rule in packaging/. * autostart: quote `Exec=` so a path containing spaces works at all, and rewrite a stale entry when the executable has moved. * Drop libpulse-binding and udev: declared, never used, and each one a native build and runtime dependency for nothing. Add .github/workflows/ci.yml. release.yml only fires on tags, so nothing compiled the Linux target between releases — and none of the code above is visible to a Windows developer's compiler. The job builds, tests and prints the binary's DT_NEEDED so a newly linked native library shows up in the log rather than as "it won't start" on a user's machine. It caught a real error in this very changeset on its first run. The pactl parsers and the keysym table live in `audio/pulse_parse` and `keys_vk`, deliberately not cfg-gated, so the logic most likely to regress is unit-testable on any host. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 65 + .github/workflows/release.yml | 4 +- Cargo.lock | 44 +- Cargo.toml | 14 +- README.md | 33 +- deploy.sh | 3 +- packaging/99-slidr.rules | 28 + src/audio/mod.rs | 3 + src/audio/pulse.rs | 407 ++--- src/audio/pulse_parse.rs | 262 +++ src/autostart.rs | 97 +- src/glue.rs | 3211 +++++++++++++++++---------------- src/keys.rs | 98 +- src/keys_vk.rs | 130 ++ src/main.rs | 19 + src/serial.rs | 61 +- ui/app.slint | 11 +- 17 files changed, 2600 insertions(+), 1890 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 packaging/99-slidr.rules create mode 100644 src/audio/pulse_parse.rs create mode 100644 src/keys_vk.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0deceda --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +# Build and test on every push / PR. `release.yml` only ever fires on a tag, so +# without this nothing compiles the Linux target until release time — and the +# Linux-gated code (audio/pulse.rs, the non-Windows half of keys.rs, +# autostart.rs) is invisible to a Windows developer's compiler entirely. +on: + push: + branches-ignore: + - "gh-pages" + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + linux: + name: Linux (build + test) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config libudev-dev libxkbcommon-dev libxkbcommon-x11-dev \ + libwayland-dev libxcb1-dev libfontconfig1-dev + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + key: ci-linux + cache-all-crates: true + + # Type-checks the Linux-only modules that never see a compiler on a + # Windows dev machine. + - name: cargo check + run: cargo check --all-targets + + - name: cargo test + run: cargo test + + - name: cargo build --release + run: cargo build --release + + # The shipped Linux binary is a bare dynamically-linked ELF, so anything + # in DT_NEEDED must exist on a plain desktop. Print it so a newly + # introduced native dependency (libxdo, libpulse, ...) is visible in the + # log instead of surfacing as "it won't start" on a user's machine. + - name: Report runtime library dependencies + run: | + ldd target/release/slidr || true + echo "--- DT_NEEDED ---" + readelf -d target/release/slidr | grep NEEDED || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fee8e8c..87881aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,8 +59,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - pkg-config libpulse-dev libudev-dev libxkbcommon-dev libxkbcommon-x11-dev \ - libwayland-dev libxcb1-dev libfontconfig1-dev libxdo-dev + pkg-config libudev-dev libxkbcommon-dev libxkbcommon-x11-dev \ + libwayland-dev libxcb1-dev libfontconfig1-dev - name: Install dependencies (Windows build) if: matrix.target == 'windows' diff --git a/Cargo.lock b/Cargo.lock index de7c2ee..fb0ffa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1253,6 +1253,7 @@ dependencies = [ "log", "objc2 0.5.2", "windows 0.56.0", + "x11rb", "xkbcommon 0.7.0", "xkeysym", ] @@ -2629,7 +2630,7 @@ dependencies = [ "input-sys", "libc", "log", - "udev 0.9.3", + "udev", ] [[package]] @@ -2939,33 +2940,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libpulse-binding" -version = "2.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" -dependencies = [ - "bitflags 2.11.1", - "libc", - "libpulse-sys", - "num-derive", - "num-traits", - "winapi", -] - -[[package]] -name = "libpulse-sys" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" -dependencies = [ - "libc", - "num-derive", - "num-traits", - "pkg-config", - "winapi", -] - [[package]] name = "libredox" version = "0.1.17" @@ -4944,7 +4918,6 @@ dependencies = [ "enigo", "env_logger", "image", - "libpulse-binding", "log", "once_cell", "open", @@ -4957,7 +4930,6 @@ dependencies = [ "slint-build", "thiserror 1.0.69", "tray-icon", - "udev 0.8.0", "ureq", "windows 0.58.0", "windows-core 0.58.0", @@ -5672,18 +5644,6 @@ dependencies = [ "serde", ] -[[package]] -name = "udev" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50051c6e22be28ee6f217d50014f3bc29e81c20dc66ff7ca0d5c5226e1dcc5a1" -dependencies = [ - "io-lifetimes", - "libc", - "libudev-sys", - "pkg-config", -] - [[package]] name = "udev" version = "0.9.3" diff --git a/Cargo.toml b/Cargo.toml index d985de6..18b220e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,8 +49,14 @@ slint = { version = "1.16", default-features = false, features = ["std", "compat # Build timestamp injected at compile time chrono = { version = "0.4", default-features = false, features = ["clock"] } -# Key simulation (cross-platform) -enigo = "0.2" +# Key simulation (cross-platform). +# Linux: the `x11rb` backend (pure Rust XTEST) instead of the default `xdo` +# one. `xdo` is `#[link(name = "xdo")]`, which makes libxdo.so.3 a hard +# load-time dependency of the shipped binary - it would refuse to start on any +# machine without xdotool installed. `x11rb` has the same reach (X11 + +# XWayland) with no native library. Native Wayland clients are unreachable +# either way; `keys.rs` warns about that at startup. +enigo = { version = "0.2", default-features = false, features = ["x11rb"] } # Cross-platform "open file/url" open = "5" @@ -58,10 +64,6 @@ open = "5" # Native file dialog for "Browse…" buttons rfd = "0.15" -[target.'cfg(target_os = "linux")'.dependencies] -libpulse-binding = "2.28" -udev = "0.8" - [target.'cfg(target_os = "windows")'.dependencies] # Direct dep so the `#[interface]` macro's `::windows_core` paths resolve; pinned # to the same version `windows` re-exports as `windows::core` (shared types). diff --git a/README.md b/README.md index dc5734f..f58b0ab 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,42 @@ installer/slidr.nsi Interactive NSIS installer ```sh # Linux dev deps (Ubuntu 24.04 names): -sudo apt install build-essential pkg-config libpulse-dev libudev-dev \ +sudo apt install build-essential pkg-config libudev-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libxcb1-dev \ - libxdo-dev libfontconfig1-dev + libfontconfig1-dev cargo run --release ``` +### Linux runtime requirements + +* **`pactl`** (Ubuntu/Debian/Fedora: `pulseaudio-utils`). The audio backend + shells out to it; it also ships with `pipewire-pulse`. Without it Slidr + starts but every volume/mute action is a no-op. +* **Serial port access.** Opening `/dev/ttyUSB*` / `/dev/ttyACM*` needs + permission, or the connection banner shows "Access denied". Either install + the bundled udev rule (preferred - applies on replug, no group, no re-login): + + ```sh + sudo install -m644 packaging/99-slidr.rules /etc/udev/rules.d/99-slidr.rules + sudo udevadm control --reload-rules && sudo udevadm trigger + ``` + + or add yourself to the port's group and log out and back in: + + ```sh + sudo usermod -aG dialout "$USER" + ``` +* **Key actions need an X11 session.** Simulated keypresses go out over XTEST, + which reaches X11 and XWayland windows but *not* native Wayland windows - a + Wayland compositor does not let an ordinary application inject input. On a + Wayland session Slidr logs a warning at startup and key actions will appear + to do nothing in Wayland-native apps; volume, mute, LED and API actions are + unaffected. Pick the "Xorg"/"X11" session at login if you need key actions. +* **No system tray.** The tray (`src/tray.rs`) is Windows-only, so the + tray-related settings are hidden on Linux and `--hidden` is ignored - it + would otherwise leave a window-less process that cannot be reopened. + Override the config location with `SLIDR_CONFIG_DIR=/path` (default `$XDG_CONFIG_HOME/slidr`, i.e. `~/.config/slidr`; `%APPDATA%/slidr` on Windows). diff --git a/deploy.sh b/deploy.sh index eab7628..38c1759 100755 --- a/deploy.sh +++ b/deploy.sh @@ -40,7 +40,8 @@ fi # ── Publish ────────────────────────────────────────────────────────────── log "Publishing to $WEBROOT" mkdir -p "$WEBROOT" -install -m644 target/release/slidr "$WEBROOT/Slidr-linux-x86_64" +# 0755: this is a binary users download and run, not a document. +install -m755 target/release/slidr "$WEBROOT/Slidr-linux-x86_64" if [[ "$SKIP_WINDOWS" -eq 0 ]]; then install -m644 installer/Slidr-windows-setup.exe "$WEBROOT/Slidr-windows-setup.exe" install -m644 "target/$WIN_TARGET/release/slidr.exe" "$WEBROOT/Slidr-windows-portable.exe" diff --git a/packaging/99-slidr.rules b/packaging/99-slidr.rules new file mode 100644 index 0000000..a46c3a6 --- /dev/null +++ b/packaging/99-slidr.rules @@ -0,0 +1,28 @@ +# udev rules for Slidr — grant the logged-in desktop user access to the board's +# serial port without adding them to a group or running the app as root. +# +# Install: +# sudo install -m644 packaging/99-slidr.rules /etc/udev/rules.d/99-slidr.rules +# sudo udevadm control --reload-rules && sudo udevadm trigger +# +# Without this (or membership in the port's owning group, usually `dialout`), +# opening /dev/ttyUSB* / /dev/ttyACM* fails with "Permission denied (os error 13)" +# and Slidr reports "Access denied" on the connection banner. +# +# The VID list mirrors KNOWN_USB_VIDS in src/serial.rs — keep the two in sync. + +# Arduino LLC (Uno, Mega, Nano 33, ...) +SUBSYSTEM=="tty", ATTRS{idVendor}=="2341", TAG+="uaccess" +# Atmel / Arduino Leonardo, Micro, Pro Micro +SUBSYSTEM=="tty", ATTRS{idVendor}=="0d28", TAG+="uaccess" +# WCH CH340 / CH341 (most Nano clones) +SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", TAG+="uaccess" +# FTDI FT232 +SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", TAG+="uaccess" +# Silicon Labs CP210x +SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", TAG+="uaccess" + +# `uaccess` hands the device to whoever is logged in at the seat, which is what +# you want on a single-user desktop. On systems without systemd-logind, replace +# the TAG with a group instead, e.g.: +# SUBSYSTEM=="tty", ATTRS{idVendor}=="2341", MODE="0660", GROUP="dialout" diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 3570acd..f7efd16 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -6,6 +6,9 @@ #[cfg(target_os = "linux")] pub mod pulse; +// Pure `pactl` output parsers. Compiled everywhere (not gated) so they stay +// unit-testable on any host; only `pulse` consumes them. +pub mod pulse_parse; #[cfg(target_os = "windows")] pub mod wasapi; pub mod null; diff --git a/src/audio/pulse.rs b/src/audio/pulse.rs index 49943fc..a5dac70 100644 --- a/src/audio/pulse.rs +++ b/src/audio/pulse.rs @@ -4,11 +4,23 @@ //! transparently supported by PipeWire via `pipewire-pulse`. Shelling out //! keeps the implementation tiny vs. binding to libpulse's async C API, //! and the action rate (≤60Hz, throttled) is well within budget. +//! +//! Two things matter for correctness here: +//! +//! * **Locale.** `pactl`'s human-readable output is translated, so every parse +//! below would break on a non-English desktop ("Sink-Eingang #", "Stumm: ja"). +//! Every invocation therefore forces `LC_ALL=C`. +//! * **Query cost.** A `pactl` call is a fork+exec. The LED engine polls mute +//! and volume 10×/s per LED, so answering those from a live process each time +//! meant dozens of processes per second at idle. `Volume:`/`Mute:` are already +//! part of the `pactl list` output we refresh anyway, so reads are served from +//! the same short-TTL cache and cost nothing extra. use std::process::{Command, Stdio}; use std::sync::Mutex; use std::time::{Duration, Instant}; +use super::pulse_parse::{parse_defaults, parse_records, strip_exe, Record}; use super::{AudioBackend, MuteTarget, VolumeTarget}; pub struct PulseBackend { @@ -17,31 +29,22 @@ pub struct PulseBackend { #[derive(Default)] struct Cache { - sink_inputs: Vec, - sources: Vec, - sinks: Vec, + sink_inputs: Vec, + sources: Vec, + sinks: Vec, + /// Names reported by `pactl info`, cached alongside the lists so resolving + /// "System (default)" costs no extra process per query. + default_sink: String, + default_source: String, stamp: Option, } -#[derive(Clone)] -struct SinkInput { - id: String, - process_binary: String, - app_name: String, -} - -#[derive(Clone)] -struct NamedId { - name: String, - description: String, -} - const CACHE_TTL: Duration = Duration::from_millis(500); impl PulseBackend { pub fn new() -> anyhow::Result { // Probe pactl presence + a working server. - let out = Command::new("pactl") + let out = pactl() .arg("info") .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -55,45 +58,112 @@ impl PulseBackend { } fn refresh(&self) { - let mut c = self.cache.lock().unwrap(); - if let Some(t) = c.stamp { - if t.elapsed() < CACHE_TTL { - return; + // Collect outside the lock so concurrent readers (UI thread listing + // devices while the actuator ticks) never queue behind three subprocesses. + { + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(t) = c.stamp { + if t.elapsed() < CACHE_TTL { + return; + } } } - c.sink_inputs = list_sink_inputs(); - c.sources = list_endpoints("sources"); - c.sinks = list_endpoints("sinks"); + let sink_inputs = list("sink-inputs", "Sink Input #"); + let sources = list("sources", "Source #"); + let sinks = list("sinks", "Sink #"); + let (default_sink, default_source) = defaults(); + let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + c.sink_inputs = sink_inputs; + c.sources = sources; + c.sinks = sinks; + c.default_sink = default_sink; + c.default_source = default_source; c.stamp = Some(Instant::now()); } - fn sink_inputs_for(&self, process: &str) -> Vec { + /// Drop the cache so the next read reflects a change we just made. Used + /// after mute/default-sink changes so LED feedback is immediate; *not* used + /// after volume writes, which arrive at up to 60Hz during a slider drag and + /// whose ≤500ms staleness is invisible. + fn invalidate(&self) { + self.cache.lock().unwrap_or_else(|e| e.into_inner()).stamp = None; + } + + fn sink_inputs_for(&self, process: &str) -> Vec { self.refresh(); - let c = self.cache.lock().unwrap(); - let needle = process.to_lowercase(); - let needle = strip_exe(&needle); + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let needle = strip_exe(&process.to_lowercase()); + if needle.is_empty() { + return Vec::new(); + } c.sink_inputs .iter() .filter(|si| { si.process_binary.to_lowercase().contains(&needle) || si.app_name.to_lowercase().contains(&needle) }) - .map(|si| si.id.clone()) + .cloned() .collect() } - fn match_endpoint(&self, kind: EndpointKind, name: Option<&str>) -> Option { + fn match_endpoint(&self, kind: EndpointKind, name: Option<&str>) -> Option { self.refresh(); - let c = self.cache.lock().unwrap(); + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); let list = match kind { EndpointKind::Source => &c.sources, EndpointKind::Sink => &c.sinks, }; let name = name?; let n = name.to_lowercase(); + if n.is_empty() { + return None; + } list.iter() .find(|e| e.name.to_lowercase().contains(&n) || e.description.to_lowercase().contains(&n)) - .map(|e| e.name.clone()) + .cloned() + } + + /// Resolve an endpoint to its pactl name, falling back to the server's + /// default when the configured device isn't present. + fn endpoint_name(&self, kind: EndpointKind, name: Option<&str>) -> String { + self.match_endpoint(kind, name).map(|e| e.name).unwrap_or_else(|| { + match kind { + EndpointKind::Source => "@DEFAULT_SOURCE@".into(), + EndpointKind::Sink => "@DEFAULT_SINK@".into(), + } + }) + } + + /// Cached record for an endpoint, resolving `None`/unknown to the server + /// default by looking up the name `pactl get-default-{sink,source}` reports. + fn endpoint_record(&self, kind: EndpointKind, name: Option<&str>) -> Option { + if let Some(r) = self.match_endpoint(kind, name) { + return Some(r); + } + // Unconfigured or unresolvable: fall back to the server default, read + // from the same cache (no subprocess). + self.refresh(); + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let (list, default) = match kind { + EndpointKind::Source => (&c.sources, &c.default_source), + EndpointKind::Sink => (&c.sinks, &c.default_sink), + }; + list.iter().find(|e| &e.name == default).cloned() + } + + fn run(&self, subcmd: &str, args: &[&str]) { + let status = pactl() + .arg(subcmd) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + match status { + Ok(s) if s.success() => {} + Ok(s) => log::debug!("pactl {subcmd} {args:?} exited with {s}"), + Err(e) => log::warn!("pactl {subcmd} failed to run: {e}"), + } } } @@ -103,138 +173,69 @@ enum EndpointKind { Sink, } -fn strip_exe(s: &str) -> String { - let s = s.trim_end_matches(".exe"); - // Use the basename if a path was provided. - s.rsplit(['/', '\\']).next().unwrap_or(s).to_string() -} impl AudioBackend for PulseBackend { fn set_volume(&self, target: VolumeTarget<'_>, value: f32) { let pct = format!("{}%", (value.clamp(0.0, 1.0) * 100.0).round() as i32); match target { VolumeTarget::Process(p) => { - for id in self.sink_inputs_for(p) { - run("set-sink-input-volume", &[&id, &pct]); + for si in self.sink_inputs_for(p) { + self.run("set-sink-input-volume", &[&si.id, &pct]); } } VolumeTarget::Mic(m) => { - let name = self - .match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()); - run("set-source-volume", &[&name, &pct]); + let name = self.endpoint_name(EndpointKind::Source, Some(m)); + self.run("set-source-volume", &[&name, &pct]); } VolumeTarget::System(d) => { - let name = self - .match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()); - run("set-sink-volume", &[&name, &pct]); + let name = self.endpoint_name(EndpointKind::Sink, d); + self.run("set-sink-volume", &[&name, &pct]); } } } fn set_mute(&self, target: MuteTarget<'_>, muted: bool) { let flag = if muted { "1" } else { "0" }; - match target { - MuteTarget::Process(p) => { - for id in self.sink_inputs_for(p) { - run("set-sink-input-mute", &[&id, flag]); - } - } - MuteTarget::Mic(m) => { - let name = self - .match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()); - run("set-source-mute", &[&name, flag]); - } - MuteTarget::System(d) => { - let name = self - .match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()); - run("set-sink-mute", &[&name, flag]); - } - } + self.apply_mute(target, flag); } fn toggle_mute(&self, target: MuteTarget<'_>) { - match target { - MuteTarget::Process(p) => { - for id in self.sink_inputs_for(p) { - run("set-sink-input-mute", &[&id, "toggle"]); - } - } - MuteTarget::Mic(m) => { - let name = self - .match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()); - run("set-source-mute", &[&name, "toggle"]); - } - MuteTarget::System(d) => { - let name = self - .match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()); - run("set-sink-mute", &[&name, "toggle"]); - } - } + self.apply_mute(target, "toggle"); } fn is_muted(&self, target: MuteTarget<'_>) -> bool { - let (subcmd, name) = match target { + // Served entirely from the refresh cache — no subprocess. + match target { MuteTarget::Process(p) => { - let ids = self.sink_inputs_for(p); - let Some(id) = ids.first() else { return false }; - ("get-sink-input-mute", id.clone()) + self.sink_inputs_for(p).first().map(|si| si.muted).unwrap_or(false) } - MuteTarget::Mic(m) => ( - "get-source-mute", - self.match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()), - ), - MuteTarget::System(d) => ( - "get-sink-mute", - self.match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()), - ), - }; - capture(subcmd, &[&name]) - .map(|o| o.contains("yes")) - .unwrap_or(false) + MuteTarget::Mic(m) => self + .endpoint_record(EndpointKind::Source, Some(m)) + .map(|e| e.muted) + .unwrap_or(false), + MuteTarget::System(d) => self + .endpoint_record(EndpointKind::Sink, d) + .map(|e| e.muted) + .unwrap_or(false), + } } fn get_volume(&self, target: VolumeTarget<'_>) -> Option { - let (subcmd, name) = match target { - VolumeTarget::Process(p) => { - let ids = self.sink_inputs_for(p); - ("get-sink-input-volume", ids.first()?.clone()) - } - VolumeTarget::Mic(m) => ( - "get-source-volume", - self.match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()), - ), - VolumeTarget::System(d) => ( - "get-sink-volume", - self.match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()), - ), - }; - let out = capture(subcmd, &[&name])?; - parse_first_percent(&out).map(|p| (p as f32 / 100.0).clamp(0.0, 1.0)) + match target { + VolumeTarget::Process(p) => self.sink_inputs_for(p).first()?.volume, + VolumeTarget::Mic(m) => self.endpoint_record(EndpointKind::Source, Some(m))?.volume, + VolumeTarget::System(d) => self.endpoint_record(EndpointKind::Sink, d)?.volume, + } } fn list_processes(&self) -> Vec { self.refresh(); - let c = self.cache.lock().unwrap(); + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); let mut names: Vec = c .sink_inputs .iter() - .map(|si| { - if !si.process_binary.is_empty() { - si.process_binary.clone() - } else { - si.app_name.clone() - } - }) + .map(Record::process_label) + .filter(|n| !n.is_empty()) .collect(); names.sort(); names.dedup(); @@ -243,17 +244,29 @@ impl AudioBackend for PulseBackend { fn list_mics(&self) -> Vec { self.refresh(); - self.cache.lock().unwrap().sources.iter().map(|e| e.description.clone()).collect() + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + // Skip `.monitor` sources: those are loopbacks of output devices, not + // capture hardware, and only clutter the microphone picker. + let mut names: Vec = c + .sources + .iter() + .filter(|s| !s.monitor) + .map(Record::label) + .filter(|n| !n.is_empty()) + .collect(); + names.dedup(); + names } fn list_outputs(&self) -> Vec { self.refresh(); - self.cache.lock().unwrap().sinks.iter().map(|e| e.description.clone()).collect() + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + c.sinks.iter().map(Record::label).filter(|n| !n.is_empty()).collect() } fn cycle_output(&self, devices: &[String]) { self.refresh(); - let sinks = self.cache.lock().unwrap().sinks.clone(); + let sinks = self.cache.lock().unwrap_or_else(|e| e.into_inner()).sinks.clone(); // Cycle order as pactl sink names. Empty list = every sink; otherwise map // each configured description (or name) to its sink, skipping any missing. let order: Vec = if devices.is_empty() { @@ -279,103 +292,69 @@ impl AudioBackend for PulseBackend { let cur = capture("get-default-sink", &[]).map(|s| s.trim().to_string()); let cur_idx = cur.as_ref().and_then(|c| order.iter().position(|n| n == c)); let next = cur_idx.map_or(0, |i| (i + 1) % order.len()); - run("set-default-sink", &[&order[next]]); + self.run("set-default-sink", &[&order[next]]); + self.invalidate(); } } -fn run(subcmd: &str, args: &[&str]) { - let _ = Command::new("pactl") - .arg(subcmd) - .args(args) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); +impl PulseBackend { + fn apply_mute(&self, target: MuteTarget<'_>, flag: &str) { + match target { + MuteTarget::Process(p) => { + for si in self.sink_inputs_for(p) { + self.run("set-sink-input-mute", &[&si.id, flag]); + } + } + MuteTarget::Mic(m) => { + let name = self.endpoint_name(EndpointKind::Source, Some(m)); + self.run("set-source-mute", &[&name, flag]); + } + MuteTarget::System(d) => { + let name = self.endpoint_name(EndpointKind::Sink, d); + self.run("set-sink-mute", &[&name, flag]); + } + } + // Mute changes are user-initiated and rare; refresh now so the LED + // engine reflects the new state on its next tick rather than up to + // CACHE_TTL later. + self.invalidate(); + } +} + +/// A `pactl` command with the locale pinned. Without this every `starts_with` +/// below fails on a translated desktop. +fn pactl() -> Command { + let mut c = Command::new("pactl"); + c.env("LC_ALL", "C").env("LANG", "C").env_remove("LANGUAGE"); + c } fn capture(subcmd: &str, args: &[&str]) -> Option { - let out = Command::new("pactl") + let out = pactl() .arg(subcmd) .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) .output() .ok()?; - Some(String::from_utf8_lossy(&out.stdout).into_owned()) -} - -fn list_sink_inputs() -> Vec { - let Some(text) = capture("list", &["sink-inputs"]) else { return Vec::new() }; - let mut out = Vec::new(); - let mut cur: Option = None; - for raw in text.lines() { - let line = raw.trim(); - if let Some(rest) = line.strip_prefix("Sink Input #") { - if let Some(c) = cur.take() { - out.push(c); - } - cur = Some(SinkInput { - id: rest.trim().to_string(), - process_binary: String::new(), - app_name: String::new(), - }); - } else if let Some(c) = cur.as_mut() { - if let Some(v) = property(line, "application.process.binary") { - c.process_binary = v; - } else if let Some(v) = property(line, "application.name") { - if c.app_name.is_empty() { - c.app_name = v; - } - } - } - } - if let Some(c) = cur { - out.push(c); + if !out.status.success() { + return None; } - out + Some(String::from_utf8_lossy(&out.stdout).into_owned()) } -fn list_endpoints(kind: &str) -> Vec { +/// Parse `pactl list `. All three entity kinds share one layout: a header +/// line `` followed by indented `Key: value` fields and a +/// `Properties:` block of `key = "value"` lines. +fn list(kind: &str, header_prefix: &str) -> Vec { let Some(text) = capture("list", &[kind]) else { return Vec::new() }; - let mut out = Vec::new(); - let mut name = String::new(); - let mut desc = String::new(); - for raw in text.lines() { - let line = raw.trim_start(); - if line.starts_with("Name: ") { - if !name.is_empty() { - out.push(NamedId { name: std::mem::take(&mut name), description: std::mem::take(&mut desc) }); - } - name = line[6..].trim().to_string(); - } else if line.starts_with("Description: ") { - desc = line[13..].trim().to_string(); - } - } - if !name.is_empty() { - out.push(NamedId { name, description: desc }); - } - out + parse_records(&text, header_prefix) } -/// Parse the first `%` occurrence (pactl volume output) into a percent. -fn parse_first_percent(s: &str) -> Option { - let bytes = s.as_bytes(); - for i in 0..bytes.len() { - if bytes[i] == b'%' { - let mut j = i; - while j > 0 && bytes[j - 1].is_ascii_digit() { - j -= 1; - } - if j < i { - return s[j..i].parse().ok(); - } - } - } - None +/// `(default_sink, default_source)` from a single `pactl info` call. +fn defaults() -> (String, String) { + let Some(text) = capture("info", &[]) else { return (String::new(), String::new()) }; + parse_defaults(&text) } -fn property(line: &str, key: &str) -> Option { - // pactl prints `key = "value"` (with leading whitespace). - let prefix = format!("{key} = \""); - let idx = line.find(&prefix)?; - let rest = &line[idx + prefix.len()..]; - let end = rest.rfind('"')?; - Some(rest[..end].to_string()) -} + diff --git a/src/audio/pulse_parse.rs b/src/audio/pulse_parse.rs new file mode 100644 index 0000000..53d4d33 --- /dev/null +++ b/src/audio/pulse_parse.rs @@ -0,0 +1,262 @@ +//! Parsers for `pactl` textual output. +//! +//! Split out of `pulse.rs` and compiled on every platform so the parsing -- +//! the part most likely to regress, and the part that silently broke on +//! non-English desktops -- is unit-testable anywhere, including Windows CI. +//! Everything here is pure: no process spawning, no I/O. + +#![allow(dead_code)] // only the Linux backend consumes these + +#[derive(Clone, Default)] +pub struct Record { + pub id: String, + pub name: String, + pub description: String, + pub process_binary: String, + pub app_name: String, + pub volume: Option, + pub muted: bool, + /// Sources only: true for the `.monitor` loopback of an output device. + pub monitor: bool, +} + +impl Record { + /// Best human-readable label for a device. + pub fn label(&self) -> String { + if self.description.is_empty() { + self.name.clone() + } else { + self.description.clone() + } + } + + /// Best human-readable label for a playback stream. + pub fn process_label(&self) -> String { + if self.process_binary.is_empty() { + self.app_name.clone() + } else { + self.process_binary.clone() + } + } +} + +pub fn strip_exe(s: &str) -> String { + let s = s.strip_suffix(".exe").unwrap_or(s); + // Use the basename if a path was provided. + s.rsplit(['/', '\\']).next().unwrap_or(s).to_string() +} + +pub fn parse_defaults(text: &str) -> (String, String) { + let mut sink = String::new(); + let mut source = String::new(); + for raw in text.lines() { + let line = raw.trim(); + if let Some(v) = line.strip_prefix("Default Sink: ") { + sink = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("Default Source: ") { + source = v.trim().to_string(); + } + } + (sink, source) +} + +pub fn parse_records(text: &str, header_prefix: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut cur: Option = None; + for raw in text.lines() { + let line = raw.trim(); + if let Some(rest) = line.strip_prefix(header_prefix) { + if let Some(c) = cur.take() { + out.push(c); + } + cur = Some(Record { id: rest.trim().to_string(), ..Default::default() }); + continue; + } + let Some(c) = cur.as_mut() else { continue }; + + if let Some(v) = line.strip_prefix("Name: ") { + c.name = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("Description: ") { + c.description = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("Mute: ") { + c.muted = v.trim().eq_ignore_ascii_case("yes"); + } else if let Some(v) = line.strip_prefix("Volume: ") { + // Only the plain `Volume:` line — `Base Volume:` also carries a + // percentage and would otherwise win for sinks/sources. + c.volume = parse_first_percent(v).map(|p| (p as f32 / 100.0).clamp(0.0, 1.0)); + } else if let Some(v) = line.strip_prefix("Monitor of Sink: ") { + c.monitor = v.trim() != "n/a"; + } else if let Some(v) = property(line, "application.process.binary") { + c.process_binary = v; + } else if let Some(v) = property(line, "application.name") { + if c.app_name.is_empty() { + c.app_name = v; + } + } + } + if let Some(c) = cur { + out.push(c); + } + out +} + +fn parse_first_percent(s: &str) -> Option { + let bytes = s.as_bytes(); + for i in 0..bytes.len() { + if bytes[i] == b'%' { + let mut j = i; + while j > 0 && bytes[j - 1].is_ascii_digit() { + j -= 1; + } + if j < i { + return s[j..i].parse().ok(); + } + } + } + None +} + +fn property(line: &str, key: &str) -> Option { + // pactl prints `key = "value"` (with leading whitespace). + let prefix = format!("{key} = \""); + let idx = line.find(&prefix)?; + let rest = &line[idx + prefix.len()..]; + let end = rest.rfind('"')?; + Some(rest[..end].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SINKS: &str = "\ +Sink #0 +\tState: RUNNING +\tName: alsa_output.pci-0000_00_1f.3.analog-stereo +\tDescription: Built-in Audio Analog Stereo +\tMute: no +\tVolume: front-left: 42926 / 65% / -11.20 dB, front-right: 42926 / 65% / -11.20 dB +\t balance 0.00 +\tBase Volume: 65536 / 100% / 0.00 dB +\tMonitor Source: alsa_output.pci-0000_00_1f.3.analog-stereo.monitor +\tProperties: +\t\tdevice.description = \"Built-in Audio Analog Stereo\" +Sink #1 +\tState: SUSPENDED +\tName: alsa_output.usb-headset +\tDescription: USB Headset +\tMute: yes +\tVolume: front-left: 65536 / 100% / 0.00 dB +\tBase Volume: 65536 / 100% / 0.00 dB +"; + + const SOURCES: &str = "\ +Source #0 +\tName: alsa_output.pci-0000_00_1f.3.analog-stereo.monitor +\tDescription: Monitor of Built-in Audio +\tMute: no +\tVolume: front-left: 65536 / 100% / 0.00 dB +\tMonitor of Sink: alsa_output.pci-0000_00_1f.3.analog-stereo +Source #1 +\tName: alsa_input.pci-0000_00_1f.3.analog-stereo +\tDescription: Built-in Microphone +\tMute: yes +\tVolume: front-left: 32768 / 50% / -18.06 dB +\tMonitor of Sink: n/a +"; + + const SINK_INPUTS: &str = "\ +Sink Input #412 +\tDriver: protocol-native.c +\tSink: 0 +\tMute: no +\tVolume: front-left: 45875 / 70% / -9.30 dB +\t balance 0.00 +\tProperties: +\t\tapplication.name = \"Spotify\" +\t\tapplication.process.binary = \"spotify\" +Sink Input #413 +\tSink: 0 +\tMute: yes +\tVolume: front-left: 65536 / 100% / 0.00 dB +\tProperties: +\t\tapplication.name = \"Firefox\" +"; + + #[test] + fn parses_sinks_with_volume_and_mute() { + let r = parse_records(SINKS, "Sink #"); + assert_eq!(r.len(), 2); + assert_eq!(r[0].id, "0"); + assert_eq!(r[0].description, "Built-in Audio Analog Stereo"); + assert!(!r[0].muted); + assert_eq!(r[0].volume, Some(0.65)); + assert!(r[1].muted); + assert_eq!(r[1].volume, Some(1.0)); + } + + /// `Base Volume:` must not override the live `Volume:` line. + #[test] + fn base_volume_is_ignored() { + let r = parse_records(SINKS, "Sink #"); + assert_eq!(r[0].volume, Some(0.65)); + } + + #[test] + fn detects_monitor_sources() { + let r = parse_records(SOURCES, "Source #"); + assert_eq!(r.len(), 2); + assert!(r[0].monitor, "monitor source not flagged"); + assert!(!r[1].monitor); + assert!(r[1].muted); + assert_eq!(r[1].volume, Some(0.5)); + } + + #[test] + fn parses_sink_inputs_with_properties() { + let r = parse_records(SINK_INPUTS, "Sink Input #"); + assert_eq!(r.len(), 2); + assert_eq!(r[0].id, "412"); + assert_eq!(r[0].process_binary, "spotify"); + assert_eq!(r[0].app_name, "Spotify"); + assert_eq!(r[0].volume, Some(0.7)); + assert!(!r[0].muted); + // No process.binary property -> falls back to the application name. + assert_eq!(r[1].process_label(), "Firefox"); + assert!(r[1].muted); + } + + #[test] + fn sink_header_does_not_swallow_sink_inputs() { + assert!(parse_records(SINK_INPUTS, "Sink #").is_empty()); + } + + #[test] + fn strip_exe_handles_paths_and_repeats() { + assert_eq!(strip_exe("spotify.exe"), "spotify"); + assert_eq!(strip_exe("c:\\apps\\discord.exe"), "discord"); + assert_eq!(strip_exe("/usr/bin/firefox"), "firefox"); + // Only one suffix is stripped (trim_end_matches used to eat them all). + assert_eq!(strip_exe("weird.exe.exe"), "weird.exe"); + } + + #[test] + fn parses_pactl_info_defaults() { + let info = "\ +Server String: /run/user/1000/pulse/native +Server Name: PulseAudio (on PipeWire 1.0.5) +Default Sink: alsa_output.usb-headset +Default Source: alsa_input.pci-0000_00_1f.3.analog-stereo +Cookie: 1234:5678 +"; + let (sink, source) = parse_defaults(info); + assert_eq!(sink, "alsa_output.usb-headset"); + assert_eq!(source, "alsa_input.pci-0000_00_1f.3.analog-stereo"); + } + + #[test] + fn percent_parsing() { + assert_eq!(parse_first_percent("front-left: 42926 / 65% / -11.20 dB"), Some(65)); + assert_eq!(parse_first_percent("no percentage here"), None); + } +} diff --git a/src/autostart.rs b/src/autostart.rs index 1e1e4b4..3439198 100644 --- a/src/autostart.rs +++ b/src/autostart.rs @@ -3,7 +3,10 @@ //! * Linux: XDG `~/.config/autostart/slidr.desktop` //! * Windows: `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\Slidr` -use std::path::PathBuf; +/// Whether this build has a system tray to minimise into. Only Windows does +/// (`src/tray.rs`), so elsewhere `--hidden` is never written into the autostart +/// entry: it would start a process with no window and no way to open one. +const HAS_TRAY: bool = cfg!(target_os = "windows"); #[cfg(target_os = "linux")] pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { @@ -16,17 +19,18 @@ pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { return Ok(()); } let exe = std::env::current_exe()?; - let exe = exe.display(); - let exec_line = if start_minimized { - format!("{exe} --hidden") - } else { - format!("{exe}") - }; + let mut exec_line = quote_exec_arg(&exe.to_string_lossy()); + if start_minimized && HAS_TRAY { + exec_line.push_str(" --hidden"); + } let desktop = format!( "[Desktop Entry]\n\ Type=Application\n\ Name=Slidr\n\ + Comment=Arduino-based audio mixer and macro pad\n\ + Icon=slidr\n\ Exec={exec_line}\n\ + Categories=AudioVideo;Audio;Utility;\n\ X-GNOME-Autostart-enabled=true\n\ Terminal=false\n" ); @@ -37,15 +41,64 @@ pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { Ok(()) } +/// Quote a path for a desktop-entry `Exec=` value. +/// +/// Unquoted `Exec` splits on whitespace, so an install under e.g. +/// `~/My Apps/slidr` would never launch. The desktop-entry spec wants the +/// argument in double quotes with `"`, `` ` ``, `$` and `\` backslash-escaped. +#[cfg(target_os = "linux")] +fn quote_exec_arg(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + if matches!(c, '"' | '`' | '$' | '\\') { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + #[cfg(target_os = "linux")] pub fn is_enabled() -> bool { autostart_file().exists() } +/// Rewrite the autostart entry if it exists but points at a different binary. +/// +/// The entry stores an absolute path, so moving, renaming or reinstalling the +/// executable leaves a file that silently fails at login. Called once at +/// startup; a no-op when autostart is off or already current. +#[cfg(target_os = "linux")] +pub fn resync(start_minimized: bool) { + let path = autostart_file(); + if !path.exists() { + return; + } + let Ok(exe) = std::env::current_exe() else { return }; + let current = quote_exec_arg(&exe.to_string_lossy()); + let stale = match std::fs::read_to_string(&path) { + Ok(text) => !text + .lines() + .any(|l| l.strip_prefix("Exec=").is_some_and(|v| v.starts_with(¤t))), + Err(e) => { + log::warn!("autostart: cannot read {}: {e}", path.display()); + return; + } + }; + if stale { + log::info!("autostart entry points elsewhere; rewriting for {}", exe.display()); + if let Err(e) = set_enabled(true, start_minimized) { + log::warn!("autostart: rewrite failed: {e}"); + } + } +} + #[cfg(target_os = "linux")] -fn autostart_file() -> PathBuf { +fn autostart_file() -> std::path::PathBuf { dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) + .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("autostart") .join("slidr.desktop") } @@ -57,7 +110,6 @@ pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { RegCloseKey, RegCreateKeyExW, RegDeleteValueW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_SET_VALUE, REG_OPTION_NON_VOLATILE, REG_SZ, }; - use windows::Win32::Foundation::HANDLE as _HANDLE; let subkey = HSTRING::from("Software\\Microsoft\\Windows\\CurrentVersion\\Run"); let name = HSTRING::from("Slidr"); @@ -78,7 +130,7 @@ pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { if enable { let exe = std::env::current_exe()?; let mut cmd = format!("\"{}\"", exe.display()); - if start_minimized { + if start_minimized && HAS_TRAY { cmd.push_str(" --hidden"); } let wide: Vec = cmd.encode_utf16().chain(std::iter::once(0)).collect(); @@ -101,7 +153,24 @@ pub fn is_enabled() -> bool { false } -#[allow(dead_code)] -fn _path_unused() -> Option { - None +/// The registry value is rewritten on every settings save, so there is nothing +/// to repair on Windows. +#[cfg(target_os = "windows")] +pub fn resync(_start_minimized: bool) {} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::quote_exec_arg; + + #[test] + fn quotes_paths_with_spaces() { + assert_eq!(quote_exec_arg("/home/u/My Apps/slidr"), "\"/home/u/My Apps/slidr\""); + } + + #[test] + fn escapes_reserved_characters() { + assert_eq!(quote_exec_arg("/opt/a$b"), "\"/opt/a\\$b\""); + assert_eq!(quote_exec_arg("/opt/a\"b"), "\"/opt/a\\\"b\""); + assert_eq!(quote_exec_arg("/opt/a\\b"), "\"/opt/a\\\\b\""); + } } diff --git a/src/glue.rs b/src/glue.rs index 62fa319..1b10075 100644 --- a/src/glue.rs +++ b/src/glue.rs @@ -1,1591 +1,1620 @@ -//! Bridge between the Slint UI and the Rust core. -//! -//! `wire()` attaches every callback and starts a polling timer that drains -//! [`SerialEvent`]s from the worker thread into Slint model properties. - -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use crossbeam_channel::Receiver; -use parking_lot::Mutex; -use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel}; - -use crate::audio::AudioBackend; -use crate::events::EventEngine; -use crate::model::{ - led_for_button, next_id, ActionKind, ApiCall, AudioKind, AudioStream, BrightnessMode, - ButtonAction, ButtonCategory, CombineLogic, Comparison, HttpMethod, LedCondition, - LedConditionKind, LedConfig, LedControl, LedMode, LedModeKind, Preset, Settings, SliderCategory, - ThemeMode, -}; -use crate::protocol::{Frame, NUM_BUTTONS, NUM_SLIDERS}; -use crate::serial::{RetryKicker, SerialEvent}; -use crate::{ - AppWindow, AssignPick, ButtonCell, CategorySummary, LedConditionUI, LineItem, PickerEntry, - SliderCell, WizardResult, -}; - -pub struct Shared { - pub settings: Settings, - pub preset: Preset, - pub engine: EventEngine, - pub audio: Arc, - pub cmd_tx: crossbeam_channel::Sender, - pub kicker: RetryKicker, - /// Live LED state published by the actuator (for the configure-LED popup). - pub led_state: crate::led::LedStateHandle, - pub pending_assign: Option<(i32, i32)>, - pub pending_wizard: Option<(i32, u32)>, - pub pending_retry_deadline: Option, - /// When editing an existing category entry, the index being replaced. - pub editing_idx: Option, - /// Working copy for the configure-LED popup: (led index, config being edited). - pub editing_led: Option<(usize, LedConfig)>, -} - -impl Shared { - /// Push the full LED configuration set + brightness + experimental flag to - /// the actuator's LED engine. Call after any change to LED config/settings. - pub fn push_leds(&self) { - let _ = self.cmd_tx.send(crate::events::Cmd::SetLedExperimental(self.settings.led_experimental)); - let _ = self - .cmd_tx - .send(crate::events::Cmd::SetLeds(Box::new(self.preset.leds.clone()))); - } -} - -pub fn wire( - ui: &AppWindow, - shared: Arc>, - rx: Receiver, - build_stamp: &'static str, -) { - ui.set_build_stamp(SharedString::from(build_stamp)); - - // Push initial state from settings/preset → UI. - { - let s = shared.lock(); - push_settings_to_ui(ui, &s.settings, &s.preset.settings); - push_preset_to_ui(ui, &s.preset); - apply_appearance(ui, &s.preset.settings); - s.push_leds(); - } - - // Empty initial slider/button cells. - push_cells(ui, &[0; NUM_SLIDERS], &[0; NUM_BUTTONS], &shared.lock().preset); - ui.set_connection_msg("Searching for Slidr device…".into()); - ui.set_retry_eta_secs(-1.0); - ui.set_connected(false); - - // ─── callbacks ───────────────────────────────────────────────────────── - { - let shared = shared.clone(); - ui.on_retry_now(move || shared.lock().kicker.kick()); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_open_assign(move |kind, idx| { - let s = shared.lock(); - let options: Vec = if kind == 0 { - s.preset.slider_categories.iter().map(|c| AssignPick { - id: c.id as i32, name: c.name.clone().into(), - }).collect() - } else { - s.preset.button_categories.iter().map(|c| AssignPick { - id: c.id as i32, name: c.name.clone().into(), - }).collect() - }; - drop(s); - if let Some(ui) = weak.upgrade() { - ui.set_assign_options(ModelRc::new(VecModel::from(options))); - ui.set_assign_target_kind(kind); - ui.set_assign_target_index(idx); - ui.set_assign_popup_open(true); - } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_apply_assign(move |chosen_id| { - let Some(ui) = weak.upgrade() else { return }; - let target_kind = ui.get_assign_target_kind(); - let idx = ui.get_assign_target_index() as usize; - let chosen = if chosen_id < 0 { None } else { Some(chosen_id as u32) }; - let mut s = shared.lock(); - if target_kind == 0 && idx < NUM_SLIDERS { - s.preset.assignments.sliders[idx] = chosen; - } else if target_kind == 1 && idx < NUM_BUTTONS { - s.preset.assignments.buttons[idx] = chosen; - } - let _ = crate::storage::save_preset(&s.preset); - push_preset_to_ui(&ui, &s.preset); - let live = s.engine.state().clone(); - push_cells(&ui, &live.sliders, &live.buttons, &s.preset); - }); - } - - // ─── category CRUD ───────────────────────────────────────────────────── - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_add_category(move |kind, name| { - if name.is_empty() { return } - let mut s = shared.lock(); - if kind == 0 { - let id = next_id(&s.preset.slider_categories, |c| c.id); - s.preset.slider_categories.push(SliderCategory { id, name: name.to_string(), streams: vec![], collapsed: false }); - } else { - let id = next_id(&s.preset.button_categories, |c| c.id); - s.preset.button_categories.push(ButtonCategory { id, name: name.to_string(), actions: vec![], collapsed: false }); - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_create_preset(move |name| { - if name.is_empty() { return } - let new_preset = Preset { - id: 1, - name: name.to_string(), - ..Default::default() - }; - if crate::storage::save_preset(&new_preset).is_ok() { - let mut s = shared.lock(); - s.preset = new_preset; - s.settings.active_preset = name.to_string(); - let _ = crate::storage::save_settings(&s.settings); - if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &s.preset); - push_settings_to_ui(&ui, &s.settings, &s.preset.settings); - apply_appearance(&ui, &s.preset.settings); - s.push_leds(); - refresh_home(&ui, &s); - toast(&ui, &format!("Created profile: {name}")); - } - } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_rename_category(move |kind, id, name| { - let mut s = shared.lock(); - if kind == 0 { - if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id as i32 == id) { - c.name = name.to_string(); - } - } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id as i32 == id) { - c.name = name.to_string(); - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - refresh_home(&ui, &s); // update Home preview names without a board input - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_delete_category(move |kind, id| { - let mut s = shared.lock(); - let id = id as u32; - if kind == 0 { - s.preset.slider_categories.retain(|c| c.id != id); - for slot in s.preset.assignments.sliders.iter_mut() { - if *slot == Some(id) { *slot = None } - } - } else { - s.preset.button_categories.retain(|c| c.id != id); - for slot in s.preset.assignments.buttons.iter_mut() { - if *slot == Some(id) { *slot = None } - } - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - refresh_home(&ui, &s); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_delete_line(move |kind, cat_id, idx| { - let mut s = shared.lock(); - let cat_id = cat_id as u32; - let idx = idx as usize; - if kind == 0 { - if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == cat_id) { - if idx < c.streams.len() { c.streams.remove(idx); } - } - } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == cat_id) { - if idx < c.actions.len() { c.actions.remove(idx); } - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - }); - } - - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_toggle_category_collapse(move |kind, id| { - let mut s = shared.lock(); - let id = id as u32; - if kind == 0 { - if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == id) { - c.collapsed = !c.collapsed; - } - } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == id) { - c.collapsed = !c.collapsed; - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - // Reorder category cards (cosmetic, but persisted via Vec order). - ui.on_reorder_category(move |kind, from_id, to_index| { - let mut s = shared.lock(); - let from_id = from_id as u32; - let to = to_index.max(0) as usize; - if kind == 0 { - reorder_by_id(&mut s.preset.slider_categories, from_id, to, |c| c.id); - } else { - reorder_by_id(&mut s.preset.button_categories, from_id, to, |c| c.id); - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - refresh_home(&ui, &s); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - // Move an action/stream within or between categories (same target-kind). - ui.on_move_line(move |kind, from_cat, from_idx, to_cat, to_idx| { - let mut s = shared.lock(); - let (from_cat, to_cat) = (from_cat as u32, to_cat as u32); - let (from_idx, to_idx) = (from_idx.max(0) as usize, to_idx.max(0) as usize); - if kind == 0 { - move_line(&mut s.preset.slider_categories, from_cat, from_idx, to_cat, to_idx, - |c| c.id, |c| &mut c.streams); - } else { - move_line(&mut s.preset.button_categories, from_cat, from_idx, to_cat, to_idx, - |c| c.id, |c| &mut c.actions); - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - }); - } - - // ─── wizard ──────────────────────────────────────────────────────────── - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_open_wizard(move |kind, cat_id| { - let Some(ui) = weak.upgrade() else { return }; - { - let mut s = shared.lock(); - s.pending_wizard = Some((kind, cat_id as u32)); - s.editing_idx = None; - } - ui.set_wizard_target_kind(kind); - ui.set_wizard_category_id(cat_id); - ui.set_wizard_step(0); - ui.set_wizard_kind(0); - ui.set_wizard_property("".into()); - ui.set_wizard_display("".into()); - reset_wizard_api(&ui); - populate_live_lists(&ui, shared.clone()); - ui.set_wizard_filter("".into()); - push_wizard_picker(&ui, shared.clone()); - ui.set_wizard_open(true); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_edit_line(move |target_kind, cat_id, idx| { - let Some(ui) = weak.upgrade() else { return }; - let idx = idx as usize; - let cat_id = cat_id as u32; - // Resolve existing entry → wizard kind/property/display (+ optional API). - let (wkind, prop, disp, api) = { - let s = shared.lock(); - if target_kind == 0 { - let Some(c) = s.preset.slider_categories.iter().find(|c| c.id == cat_id) else { return }; - let Some(stream) = c.streams.get(idx) else { return }; - if let Some(api) = &stream.api { (3, String::new(), String::new(), Some(api.clone())) } - else if let Some(p) = &stream.process { (0, p.clone(), p.clone(), None) } - else if let Some(m) = &stream.mic_name { (1, m.clone(), m.clone(), None) } - else { (2, stream.device_name.clone().unwrap_or_default(), - stream.device_name.clone().unwrap_or_default(), None) } - } else { - let Some(c) = s.preset.button_categories.iter().find(|c| c.id == cat_id) else { return }; - let Some(a) = c.actions.get(idx) else { return }; - let k = action_kind_index(a.kind); - let prop = a.property.clone().unwrap_or_default(); - // For KeyPress, show the friendly name in the picker. - let shown = if a.kind == ActionKind::KeyPress { - prop.parse::().ok() - .and_then(crate::keys_library::label_for_vk) - .map(|s| s.to_string()) - .unwrap_or_else(|| a.display.clone().unwrap_or(prop.clone())) - } else { - prop.clone() - }; - (k, shown, a.display.clone().unwrap_or_default(), a.api.clone()) - } - }; - shared.lock().editing_idx = Some(idx); - ui.set_wizard_target_kind(target_kind); - ui.set_wizard_category_id(cat_id as i32); - ui.set_wizard_kind(wkind); - ui.set_wizard_step(1); // jump straight to the target step - ui.set_wizard_property(prop.into()); - ui.set_wizard_display(disp.into()); - push_wizard_api(&ui, api.as_ref()); - populate_live_lists(&ui, shared.clone()); - ui.set_wizard_filter("".into()); - push_wizard_picker(&ui, shared.clone()); - ui.set_wizard_open(true); - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_refresh_live_lists(move || { - let Some(ui) = weak.upgrade() else { return }; - populate_live_lists(&ui, shared.clone()); - push_wizard_picker(&ui, shared.clone()); - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_wizard_state_changed(move || { - let Some(ui) = weak.upgrade() else { return }; - push_wizard_picker(&ui, shared.clone()); - }); - } - { - let weak = ui.as_weak(); - ui.on_browse_file(move || { - let path = rfd::FileDialog::new().pick_file(); - if let Some(p) = path { - if let Some(ui) = weak.upgrade() { - let s = p.display().to_string(); - ui.set_wizard_property(s.clone().into()); - let name = p.file_stem().map(|s| s.to_string_lossy().to_string()) - .unwrap_or(s.clone()); - ui.set_wizard_display(name.into()); - } - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - // Multi-select picker (Cycle output): toggle a device in/out of the - // newline-separated set held in the wizard property. - ui.on_wizard_toggle_pick(move |name| { - let Some(ui) = weak.upgrade() else { return }; - let name = name.to_string(); - let mut list: Vec = ui - .get_wizard_property() - .to_string() - .lines() - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); - if let Some(pos) = list.iter().position(|n| n == &name) { - list.remove(pos); - } else { - list.push(name); - } - ui.set_wizard_property(list.join("\n").into()); - push_wizard_picker(&ui, shared.clone()); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_wizard_finish(move |result| { - let mut s = shared.lock(); - let editing = s.editing_idx.take(); - let cat_id = result.category_id as u32; - if result.target_kind == 0 { - if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == cat_id) { - let stream = match result.kind { - 0 => AudioStream { process: Some(result.property.into()), ..Default::default() }, - 1 => AudioStream { mic_name: Some(result.property.into()), ..Default::default() }, - 2 => AudioStream { device_name: Some(result.property.into()), ..Default::default() }, - _ => AudioStream { api: Some(api_from_result(&result)), ..Default::default() }, - }; - match editing { - Some(i) if i < c.streams.len() => c.streams[i] = stream, - _ => c.streams.push(stream), - } - } - } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == cat_id) { - let kind = button_kind_from_index(result.kind); - if kind == ActionKind::ApiCall { - let action = ButtonAction { - kind, property: None, display: None, - api: Some(api_from_result(&result)), - }; - match editing { - Some(i) if i < c.actions.len() => c.actions[i] = action, - _ => c.actions.push(action), - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); - return; - } - // For KeyPress, picker stores the friendly name; resolve to VK code. - let (prop, disp) = if kind == ActionKind::KeyPress { - let name = result.property.to_string(); - let vk = crate::keys_library::KEYS.iter() - .find(|k| k.name.eq_ignore_ascii_case(&name)) - .map(|k| k.vk) - .or_else(|| name.parse::().ok()) - .unwrap_or(0); - (Some(vk.to_string()), Some(name)) - } else if kind == ActionKind::CycleOutput { - // Property is the newline-separated device list (empty = all). - // The human-readable label is derived at render time - // (`action_secondary`), so no display is stored here. - let list = result.property.to_string(); - let has_any = list.lines().any(|s| !s.is_empty()); - (has_any.then_some(list), None) - } else { - let prop = if result.property.is_empty() { None } else { Some(result.property.to_string()) }; - let disp = if result.display.is_empty() { None } else { Some(result.display.to_string()) }; - (prop, disp) - }; - let action = ButtonAction { kind, property: prop, display: disp, api: None }; - match editing { - Some(i) if i < c.actions.len() => c.actions[i] = action, - _ => c.actions.push(action), - } - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); - }); - } - - // ─── settings ────────────────────────────────────────────────────────── - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_theme_changed(move |idx| { - let mode = match idx { - 1 => ThemeMode::Light, 2 => ThemeMode::Dark, _ => ThemeMode::System, - }; - { - let mut s = shared.lock(); - s.preset.settings.theme = mode; - let _ = crate::storage::save_preset(&s.preset); - } - if let Some(ui) = weak.upgrade() { - apply_color_scheme(&ui, mode); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_curve_preset_changed(move |idx| { - let mut s = shared.lock(); - let preset = index_to_curve_preset(idx); - s.preset.settings.curve_preset = preset; - // Custom starts from a gentle default each time it's freshly selected. - if matches!(preset, crate::curve::CurvePreset::Custom) { - s.preset.settings.custom_curve = crate::curve::BezierPoints::CUSTOM_DEFAULT; - } - let _ = crate::storage::save_preset(&s.preset); - if let Some(ui) = weak.upgrade() { - push_curve_to_ui(&ui, &s.preset.settings); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_curve_changed(move || { - let Some(ui) = weak.upgrade() else { return }; - let mut s = shared.lock(); - s.preset.settings.custom_curve = crate::curve::BezierPoints { - x1: ui.get_curve_x1(), y1: ui.get_curve_y1(), - x2: ui.get_curve_x2(), y2: ui.get_curve_y2(), - }; - s.preset.settings.curve_preset = crate::curve::CurvePreset::Custom; - let _ = crate::storage::save_preset(&s.preset); - }); - } - { - ui.on_exit_app(|| slint::quit_event_loop().ok().unwrap_or(())); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_rename_preset(move |old, new| { - if old.is_empty() || new.is_empty() || old == new { return } - // Read the old preset, write under new name, delete old. - if let Ok(mut p) = crate::storage::load_preset(&old) { - p.name = new.to_string(); - if crate::storage::save_preset(&p).is_ok() { - let _ = crate::storage::delete_preset(&old); - let mut s = shared.lock(); - if s.settings.active_preset == old.as_str() { - s.settings.active_preset = new.to_string(); - let _ = crate::storage::save_settings(&s.settings); - } - if s.preset.name == old.as_str() { - s.preset.name = new.to_string(); - } - if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &s.preset); - toast(&ui, &format!("Renamed to {new}")); - } - } - } - }); - } - { - let weak = ui.as_weak(); - ui.on_export_preset(move |name| { - let src = crate::storage::presets_dir().join(name.to_string()).join("preset.json"); - let dest = rfd::FileDialog::new() - .set_file_name(format!("slidr-preset-{name}.json")) - .add_filter("Slidr preset", &["json"]) - .save_file(); - let Some(dest) = dest else { return }; - match std::fs::copy(&src, &dest) { - Ok(_) => if let Some(ui) = weak.upgrade() { - toast(&ui, &format!("Exported to {}", dest.display())); - }, - Err(e) => if let Some(ui) = weak.upgrade() { - toast(&ui, &format!("Export failed: {e}")); - }, - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_import_preset(move || { - let Some(path) = rfd::FileDialog::new() - .add_filter("Slidr preset", &["json"]) - .pick_file() else { return }; - let Ok(data) = std::fs::read_to_string(&path) else { return }; - let Ok(mut preset) = serde_json::from_str::(&data) else { - if let Some(ui) = weak.upgrade() { toast(&ui, "Import failed: invalid preset file"); } - return; - }; - // Ensure a unique, non-empty name (avoid clobbering an existing one). - if preset.name.trim().is_empty() { - preset.name = path.file_stem().map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "Imported".into()); - } - let existing = crate::storage::list_presets(); - let base = preset.name.clone(); - let mut n = 1; - while existing.iter().any(|e| e == &preset.name) { - n += 1; - preset.name = format!("{base} ({n})"); - } - match crate::storage::save_preset(&preset) { - Ok(()) => if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &shared.lock().preset); - toast(&ui, &format!("Imported profile: {}", preset.name)); - }, - Err(e) => if let Some(ui) = weak.upgrade() { - toast(&ui, &format!("Import failed: {e}")); - }, - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_save_settings(move || { - let Some(ui) = weak.upgrade() else { return }; - let mut s = shared.lock(); - // System → global settings; appearance/sliders → active profile. - pull_global_from_ui(&ui, &mut s.settings); - pull_profile_from_ui(&ui, &mut s.preset.settings); - let _ = crate::storage::save_settings(&s.settings); - let _ = crate::storage::save_preset(&s.preset); - let _ = crate::autostart::set_enabled(s.settings.start_with_os, s.settings.start_minimized); - // Live-apply appearance (accent / theme) immediately. - apply_appearance(&ui, &s.preset.settings); - // LED brightness / experimental flag may have changed. - s.push_leds(); - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_save_preset(move || { - let s = shared.lock(); - let result = crate::storage::save_preset(&s.preset); - if let Some(ui) = weak.upgrade() { - match result { - Ok(()) => toast(&ui, "Preset saved."), - Err(e) => toast(&ui, &format!("Save failed: {e}")), - } - push_preset_to_ui(&ui, &s.preset); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_load_preset(move |name| { - let Ok(p) = crate::storage::load_preset(&name) else { return }; - let mut s = shared.lock(); - s.preset = p; - s.settings.active_preset = s.preset.name.clone(); - let _ = crate::storage::save_settings(&s.settings); - if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &s.preset); - push_settings_to_ui(&ui, &s.settings, &s.preset.settings); - apply_appearance(&ui, &s.preset.settings); - s.push_leds(); - // Refresh Home immediately with the new assignments using the - // last live readings — no board input required. - refresh_home(&ui, &s); - toast(&ui, &format!("Switched to profile: {name}")); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_delete_preset(move |name| { - let _ = crate::storage::delete_preset(&name); - if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &shared.lock().preset); - toast(&ui, &format!("Deleted profile: {name}")); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_set_active_preset(move |name| { - let mut s = shared.lock(); - s.settings.active_preset = name.to_string(); - let _ = crate::storage::save_settings(&s.settings); - if let Some(ui) = weak.upgrade() { - push_settings_to_ui(&ui, &s.settings, &s.preset.settings); - } - }); - } - - // ─── LED state persistence (board EEPROM) ────────────────────────────── - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_save_state(move || { - shared.lock().cmd_tx.send(crate::events::Cmd::LedSaveState).ok(); - if let Some(ui) = weak.upgrade() { - toast(&ui, if ui.get_connected() { "Saved LED state to the board." } - else { "Not connected — connect the board first." }); - } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_clear_state(move || { - shared.lock().cmd_tx.send(crate::events::Cmd::LedClearSaved).ok(); - if let Some(ui) = weak.upgrade() { - toast(&ui, if ui.get_connected() { "Cleared saved LED state (all off)." } - else { "Not connected — connect the board first." }); - } - }); - } - - // ─── configure LED ───────────────────────────────────────────────────── - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_open_led_config(move |btn| { - let Some(led) = led_for_button(btn as usize) else { return }; - let cfg = { - let mut s = shared.lock(); - let cfg = s.preset.leds[led].clone().unwrap_or_default(); - s.editing_led = Some((led, cfg.clone())); - cfg - }; - let Some(ui) = weak.upgrade() else { return }; - ui.set_led_index(led as i32); - ui.set_led_control(if cfg.control == LedControl::Manual { 1 } else { 0 }); - ui.set_led_combine(if cfg.combine == CombineLogic::All { 1 } else { 0 }); - // Populate the device lists first so target indices resolve. - populate_live_lists(&ui, shared.clone()); - push_mode_to_ui(&ui, &cfg.active, true); - push_mode_to_ui(&ui, &cfg.inactive, false); - push_led_conditions(&ui, &cfg.conditions); - ui.set_led_live_active(false); - ui.set_led_popup_open(true); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_cancel(move || { - shared.lock().editing_led = None; - if let Some(ui) = weak.upgrade() { ui.set_led_popup_open(false); } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_save(move || { - let Some(ui) = weak.upgrade() else { return }; - let mut s = shared.lock(); - let Some((led, mut cfg)) = s.editing_led.take() else { return }; - cfg.control = if ui.get_led_control() == 1 { LedControl::Manual } else { LedControl::Conditional }; - cfg.combine = if ui.get_led_combine() == 1 { CombineLogic::All } else { CombineLogic::Any }; - cfg.active = led_mode_from_ui( - ui.get_led_active_kind(), - ui.get_led_active_on().as_str(), - ui.get_led_active_off().as_str(), - ui.get_led_active_cycle().as_str(), - ui.get_led_active_bmode(), - ui.get_led_active_bright(), - ui.get_led_active_baudio(), - ui.get_led_active_btarget().as_str(), - ); - cfg.inactive = led_mode_from_ui( - ui.get_led_inactive_kind(), - ui.get_led_inactive_on().as_str(), - ui.get_led_inactive_off().as_str(), - ui.get_led_inactive_cycle().as_str(), - ui.get_led_inactive_bmode(), - ui.get_led_inactive_bright(), - ui.get_led_inactive_baudio(), - ui.get_led_inactive_btarget().as_str(), - ); - s.preset.leds[led] = Some(cfg); - let _ = crate::storage::save_preset(&s.preset); - s.push_leds(); - drop(s); - ui.set_led_popup_open(false); - toast(&ui, "LED saved."); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_add_condition(move || { - let conds = { - let mut s = shared.lock(); - let Some((_, cfg)) = s.editing_led.as_mut() else { return }; - cfg.conditions.push(LedCondition::default()); - cfg.conditions.clone() - }; - if let Some(ui) = weak.upgrade() { push_led_conditions(&ui, &conds); } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_remove_condition(move |idx| { - let conds = { - let mut s = shared.lock(); - let Some((_, cfg)) = s.editing_led.as_mut() else { return }; - let i = idx as usize; - if i < cfg.conditions.len() { cfg.conditions.remove(i); } - cfg.conditions.clone() - }; - if let Some(ui) = weak.upgrade() { push_led_conditions(&ui, &conds); } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_led_refresh(move || { - let Some(ui) = weak.upgrade() else { return }; - populate_live_lists(&ui, shared.clone()); - // Re-resolve dropdown indices against the freshly scanned lists. - let conds = shared.lock().editing_led.as_ref().map(|(_, c)| c.conditions.clone()); - if let Some(conds) = conds { - push_led_conditions(&ui, &conds); - } - ui.set_led_active_btidx(list_index_of(&ui, ui.get_led_active_baudio(), ui.get_led_active_btarget().as_str())); - ui.set_led_inactive_btidx(list_index_of(&ui, ui.get_led_inactive_baudio(), ui.get_led_inactive_btarget().as_str())); - }); - } - { - let shared = shared.clone(); - ui.on_led_manual_toggle(move || { - let mut s = shared.lock(); - let Some((led, cfg)) = s.editing_led.as_mut() else { return }; - cfg.manual_active = !cfg.manual_active; - let led = *led; - let _ = s.cmd_tx.send(crate::events::Cmd::LedManualToggle(led as u8)); - if let Some(saved) = s.preset.leds.get_mut(led).and_then(|c| c.as_mut()) { - if saved.control == LedControl::Manual { - saved.manual_active = !saved.manual_active; - let _ = crate::storage::save_preset(&s.preset); - } - } - }); - } - // Per-field condition mutators. Structural edits (kind/audio) re-push the - // model so dependent fields show/hide; scalar/text edits update in place. - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_cond_kind(move |idx, v| { - let conds = mutate_cond_struct(&shared, idx, |c| c.kind = cond_kind_from(v)); - if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_cond_audio(move |idx, v| { - // Changing the endpoint family invalidates the previously picked target. - let conds = mutate_cond_struct(&shared, idx, |c| { - c.audio_kind = audio_kind_from(v); - c.target = None; - }); - if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } - }); - } - { - let shared = shared.clone(); - ui.on_led_cond_target(move |idx, v| { - mutate_cond(&shared, idx, |c| c.target = opt(v.as_str())); - }); - } - { - let shared = shared.clone(); - ui.on_led_cond_cmp(move |idx, v| { mutate_cond(&shared, idx, |c| c.cmp = cmp_from(v)); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_value(move |idx, v| { mutate_cond(&shared, idx, |c| c.value = v.clamp(0, 100) as u8); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_method(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.method = HttpMethod::from_index(v)); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_url(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.url = v.to_string()); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_payload(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.payload = opt(v.as_str())); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_bearer(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.bearer = opt(v.as_str())); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_interval(move |idx, v| { - if let Ok(n) = v.as_str().trim().parse::() { - mutate_cond(&shared, idx, |c| c.api.interval_ms = n); - } - }); - } - { - let shared = shared.clone(); - ui.on_led_cond_expect(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.expect_body = opt(v.as_str())); }); - } - - // ─── serial event pump ──────────────────────────────────────────────── - let weak = ui.as_weak(); - let timer = slint::Timer::default(); - timer.start(slint::TimerMode::Repeated, Duration::from_millis(16), move || { - let Some(ui) = weak.upgrade() else { return }; - let mut s = shared.lock(); - let mut changed = false; - while let Ok(ev) = rx.try_recv() { - match ev { - SerialEvent::Connected(port) => { - s.engine.set_connected(true); - ui.set_connected(true); - ui.set_connection_msg(format!("Connected on {port}").into()); - ui.set_retry_eta_secs(-1.0); - // Board reset on connect; re-push all desired LED state. - s.push_leds(); - changed = true; - } - SerialEvent::Disconnected { reason, retrying_in } => { - s.engine.set_connected(false); - ui.set_connected(false); - ui.set_connection_msg(reason.into()); - ui.set_retry_eta_secs(retrying_in.as_secs_f32()); - s.pending_retry_deadline = Some(Instant::now() + retrying_in); - changed = true; - } - SerialEvent::Frame(frame) => { - // Cheap diff on the UI thread; side effects go to the actuator. - let preset = s.preset.clone(); - let (dirty, cmds) = s.engine.ingest(&frame, &preset); - let mut led_toggled = false; - for cmd in cmds { - // Persist a manual-LED toggle into the preset so it - // survives a restart (the actuator owns the live state). - if let crate::events::Cmd::LedManualToggle(led) = &cmd { - if let Some(cfg) = s.preset.leds.get_mut(*led as usize).and_then(|c| c.as_mut()) { - cfg.manual_active = !cfg.manual_active; - led_toggled = true; - } - } - let _ = s.cmd_tx.send(cmd); - } - if led_toggled { - let _ = crate::storage::save_preset(&s.preset); - } - if dirty { changed = true; } - } - } - } - // Mirror live LED state into the configure popup (manual-mode readout). - if ui.get_led_popup_open() { - let led = ui.get_led_index() as usize; - let active = s.led_state.lock().get(led).map(|l| l.active).unwrap_or(false); - ui.set_led_live_active(active); - } - - // Animate retry countdown even between events. - if let Some(deadline) = s.pending_retry_deadline { - let remaining = deadline.saturating_duration_since(Instant::now()).as_secs_f32(); - if remaining > 0.0 { - ui.set_retry_eta_secs(remaining); - } - } - if changed { - let live = s.engine.state().clone(); - push_cells(&ui, &live.sliders, &live.buttons, &s.preset); - ui.set_badge_text(match live.badge { - crate::protocol::Badge::Supporter => "Supporter".into(), - crate::protocol::Badge::Premium => "Premium".into(), - _ => "".into(), - }); - } - }); - // Leak the timer so it survives until window close. - std::mem::forget(timer); -} - -// Helpers ---------------------------------------------------------------- - -/// Refresh the Home live cells from the last known board state — used after -/// preset/category edits so names update without waiting for a board input. -fn refresh_home(ui: &AppWindow, s: &Shared) { - let live = s.engine.state().clone(); - push_cells(ui, &live.sliders, &live.buttons, &s.preset); -} - -fn push_cells(ui: &AppWindow, sliders: &[i32; NUM_SLIDERS], buttons: &[u8; NUM_BUTTONS], preset: &Preset) { - let pts = crate::curve::BezierPoints::for_preset( - preset_curve(preset).0, preset_curve(preset).1); - let _ = pts; // curve preview unused at the cell level - let slider_cells: Vec = sliders - .iter().enumerate().map(|(i, &v)| { - let pct = crate::curve::normalize_raw(v); - let cat = preset.slider_category(i); - SliderCell { - value: v, - percent: pct, - category: cat.map(|c| c.name.clone()).unwrap_or_default().into(), - assigned: cat.is_some(), - } - }).collect(); - let button_cells: Vec = buttons - .iter().enumerate().map(|(i, &b)| { - let cat = preset.button_category(i); - ButtonCell { - pressed: b != 0, - category: cat.map(|c| c.name.clone()).unwrap_or_default().into(), - assigned: cat.is_some(), - } - }).collect(); - ui.set_sliders(ModelRc::new(VecModel::from(slider_cells))); - let (top, right): (Vec<_>, Vec<_>) = button_cells.into_iter().enumerate() - .partition(|(i, _)| *i < 6); - let top: Vec<_> = top.into_iter().map(|(_, b)| b).collect(); - let right: Vec<_> = right.into_iter().map(|(_, b)| b).collect(); - ui.set_top_buttons(ModelRc::new(VecModel::from(top))); - ui.set_right_buttons(ModelRc::new(VecModel::from(right))); -} - -fn preset_curve(_preset: &Preset) -> (crate::curve::CurvePreset, crate::curve::BezierPoints) { - (crate::curve::CurvePreset::Linear, crate::curve::BezierPoints::LINEAR) -} - -/// Move the category with `from_id` to insertion index `to_index` (0..=len). -fn reorder_by_id(cats: &mut Vec, from_id: u32, to_index: usize, id: impl Fn(&C) -> u32) { - let Some(from) = cats.iter().position(|c| id(c) == from_id) else { return }; - let item = cats.remove(from); - // `to_index` was computed against the original list; removing the item - // shifts everything after it down by one. - let to = if from < to_index { to_index - 1 } else { to_index }; - cats.insert(to.min(cats.len()), item); -} - -/// Move element `from_idx` of category `from_cat` to insertion index `to_idx` of -/// category `to_cat` (may be the same category). `to_idx` is in the destination's -/// original ordering. -fn move_line( - cats: &mut [C], - from_cat: u32, - from_idx: usize, - to_cat: u32, - to_idx: usize, - id: impl Fn(&C) -> u32, - lines: impl Fn(&mut C) -> &mut Vec, -) { - let Some(from_pos) = cats.iter().position(|c| id(c) == from_cat) else { return }; - let Some(to_pos) = cats.iter().position(|c| id(c) == to_cat) else { return }; - let item = { - let v = lines(&mut cats[from_pos]); - if from_idx >= v.len() { - return; - } - v.remove(from_idx) - }; - // Within the same category, a removal before the target shifts it down. - let to = if from_cat == to_cat && from_idx < to_idx { to_idx - 1 } else { to_idx }; - let v = lines(&mut cats[to_pos]); - v.insert(to.min(v.len()), item); -} - -fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { - let sliders: Vec = preset - .slider_categories - .iter() - .map(|c| CategorySummary { - id: c.id as i32, - name: c.name.clone().into(), - count: c.streams.len() as i32, - collapsed: c.collapsed, - lines: ModelRc::new(VecModel::from( - c.streams.iter().enumerate().map(|(i, s)| LineItem { - id: i as i32, - primary: s.label().into(), - secondary: stream_secondary(s).into(), - icon_kind: stream_icon_kind(s), - }).collect::>(), - )), - }) - .collect(); - let buttons: Vec = preset - .button_categories - .iter() - .map(|c| CategorySummary { - id: c.id as i32, - name: c.name.clone().into(), - count: c.actions.len() as i32, - collapsed: c.collapsed, - lines: ModelRc::new(VecModel::from( - c.actions.iter().enumerate().map(|(i, a)| LineItem { - id: i as i32, - primary: SharedString::from(a.kind.label()), - secondary: action_secondary(a).into(), - icon_kind: action_icon_kind(a.kind), - }).collect::>(), - )), - }) - .collect(); - ui.set_slider_categories(ModelRc::new(VecModel::from(sliders))); - ui.set_button_categories(ModelRc::new(VecModel::from(buttons))); - ui.set_active_preset_name(preset.name.clone().into()); - ui.set_preset_names(ModelRc::new(VecModel::from( - crate::storage::list_presets().into_iter().map(SharedString::from).collect::>(), - ))); -} - -fn stream_icon_kind(s: &AudioStream) -> i32 { - if s.api.is_some() { 3 } - else if s.process.is_some() { 0 } - else if s.mic_name.is_some() { 1 } - else { 2 } -} - -/// Secondary line for a button action. Cycle-output derives its label from the -/// device list (so it stays correct without a stored display); others fall back -/// to the display, then the raw property. -fn action_secondary(a: &crate::model::ButtonAction) -> String { - if a.kind == ActionKind::CycleOutput { - let names: Vec<&str> = - a.property.as_deref().unwrap_or("").lines().filter(|s| !s.is_empty()).collect(); - return if names.is_empty() { - "All output devices".into() - } else { - names.join(" → ") - }; - } - if a.kind == ActionKind::ApiCall { - return a.api.as_ref().map(|api| api.label()).unwrap_or_else(|| "API call".into()); - } - a.display.clone().or_else(|| a.property.clone()).unwrap_or_default() -} - -fn action_icon_kind(k: ActionKind) -> i32 { - use ActionKind::*; - match k { - MuteProcess => 0, MuteMic => 1, MuteMainAudio => 5, - OpenProcess => 0, OpenWebsite => 3, KeyPress => 4, CycleOutput => 2, ApiCall => 3, - } -} - -fn stream_secondary(s: &AudioStream) -> String { - if s.api.is_some() { - "API call".into() - } else if s.process.is_some() { - "process".into() - } else if s.mic_name.is_some() { - "microphone".into() - } else if s.device_name.is_some() { - "output device".into() - } else { - "default output".into() - } -} - -/// Push system (global) + appearance/slider (per-profile) settings into the UI. -fn push_settings_to_ui(ui: &AppWindow, global: &Settings, profile: &crate::model::ProfileSettings) { - // System (global) - ui.set_start_with_os(global.start_with_os); - ui.set_start_minimized(global.start_minimized); - ui.set_minimize_to_tray(global.minimize_to_tray); - ui.set_led_experimental(global.led_experimental); - ui.set_active_preset_name(global.active_preset.clone().into()); - // Appearance + sliders (profile) - ui.set_theme_index(match profile.theme { - ThemeMode::System => 0, ThemeMode::Light => 1, ThemeMode::Dark => 2, - }); - ui.set_accent_preset(profile.accent_preset); - ui.set_dead_zone(profile.slider_dead_zone); - ui.set_unmute_on_change(profile.unmute_on_change); - ui.set_curve_preset_index(curve_preset_to_index(profile.curve_preset)); - push_curve_to_ui(ui, profile); -} - -/// Push the active curve's control points + editability into the UI. -fn push_curve_to_ui(ui: &AppWindow, profile: &crate::model::ProfileSettings) { - let pts = crate::curve::BezierPoints::for_preset(profile.curve_preset, profile.custom_curve); - ui.set_curve_x1(pts.x1); - ui.set_curve_y1(pts.y1); - ui.set_curve_x2(pts.x2); - ui.set_curve_y2(pts.y2); - ui.set_curve_editable(matches!(profile.curve_preset, crate::curve::CurvePreset::Custom)); -} - -fn pull_global_from_ui(ui: &AppWindow, g: &mut Settings) { - g.start_with_os = ui.get_start_with_os(); - g.start_minimized = ui.get_start_minimized(); - g.minimize_to_tray = ui.get_minimize_to_tray(); - g.led_experimental = ui.get_led_experimental(); -} - -fn pull_profile_from_ui(ui: &AppWindow, p: &mut crate::model::ProfileSettings) { - p.theme = match ui.get_theme_index() { 1 => ThemeMode::Light, 2 => ThemeMode::Dark, _ => ThemeMode::System }; - p.accent_preset = ui.get_accent_preset(); - p.slider_dead_zone = ui.get_dead_zone(); - p.unmute_on_change = ui.get_unmute_on_change(); - p.curve_preset = index_to_curve_preset(ui.get_curve_preset_index()); -} - -/// Apply a profile's appearance (theme + accent) to the live UI. -fn apply_appearance(ui: &AppWindow, profile: &crate::model::ProfileSettings) { - apply_color_scheme(ui, profile.theme); - ui.set_accent_preset(profile.accent_preset); - ui.invoke_apply_accent(profile.accent_preset); -} - -fn curve_preset_to_index(p: crate::curve::CurvePreset) -> i32 { - use crate::curve::CurvePreset::*; - match p { Linear => 0, Ease => 1, EaseIn => 2, EaseOut => 3, EaseInOut => 4, Custom => 5 } -} -fn index_to_curve_preset(i: i32) -> crate::curve::CurvePreset { - use crate::curve::CurvePreset::*; - match i { 1 => Ease, 2 => EaseIn, 3 => EaseOut, 4 => EaseInOut, 5 => Custom, _ => Linear } -} - -/// Build an [`ApiCall`] from the wizard result's API fields. -fn api_from_result(r: &WizardResult) -> ApiCall { - let opt = |s: String| if s.is_empty() { None } else { Some(s) }; - ApiCall { - method: HttpMethod::from_index(r.method), - url: r.url.trim().to_string(), - payload: opt(r.payload.to_string()), - bearer: opt(r.bearer.trim().to_string()), - throttle_ms: r.throttle.clamp( - crate::model::API_THROTTLE_MIN_MS, - crate::model::API_THROTTLE_MAX_MS, - ), - } -} - -/// Push an existing [`ApiCall`] (or defaults) into the wizard API fields. -fn push_wizard_api(ui: &AppWindow, api: Option<&ApiCall>) { - match api { - Some(api) => { - ui.set_wizard_method(api.method.to_index()); - ui.set_wizard_url(api.url.clone().into()); - ui.set_wizard_payload(api.payload.clone().unwrap_or_default().into()); - ui.set_wizard_bearer(api.bearer.clone().unwrap_or_default().into()); - ui.set_wizard_throttle(api.throttle_ms); - } - None => reset_wizard_api(ui), - } -} - -fn reset_wizard_api(ui: &AppWindow) { - ui.set_wizard_method(HttpMethod::default().to_index()); - ui.set_wizard_url("".into()); - ui.set_wizard_payload("".into()); - ui.set_wizard_bearer("".into()); - ui.set_wizard_throttle(crate::model::default_api_throttle_ms()); -} - -fn action_kind_index(k: ActionKind) -> i32 { - use ActionKind::*; - match k { - MuteProcess => 0, MuteMainAudio => 1, MuteMic => 2, - OpenProcess => 3, OpenWebsite => 4, KeyPress => 5, CycleOutput => 6, ApiCall => 7, - } -} - -fn button_kind_from_index(i: i32) -> ActionKind { - use ActionKind::*; - match i { - 0 => MuteProcess, 1 => MuteMainAudio, 2 => MuteMic, - 3 => OpenProcess, 4 => OpenWebsite, 6 => CycleOutput, 7 => ApiCall, _ => KeyPress, - } -} - -fn apply_color_scheme(ui: &AppWindow, mode: ThemeMode) { - // Trigger the slint-side `apply-theme(idx)` which sets `Palette.color-scheme`. - let idx = match mode { - ThemeMode::System => 0, - ThemeMode::Light => 1, - ThemeMode::Dark => 2, - }; - ui.invoke_apply_theme(idx); -} - -fn push_wizard_picker(ui: &AppWindow, shared: Arc>) { - let target_kind = ui.get_wizard_target_kind(); - let kind = ui.get_wizard_kind(); - let filter = ui.get_wizard_filter().to_string().to_lowercase(); - - let s = shared.lock(); - let source: Vec = match (target_kind, kind) { - // Slider streams - (0, 0) => merged_processes(s.audio.as_ref()), - (0, 1) => s.audio.list_mics(), - (0, 2) => s.audio.list_outputs(), - // Button actions - (1, 0) => merged_processes(s.audio.as_ref()), - (1, 1) => s.audio.list_outputs(), - (1, 2) => s.audio.list_mics(), - // Simulate key — full library - (1, 5) => crate::keys_library::KEYS.iter().map(|k| k.name.to_string()).collect(), - // Cycle output device — multi-select list of outputs - (1, 6) => s.audio.list_outputs(), - _ => Vec::new(), - }; - drop(s); - - // Multi-select kinds (currently Cycle output) keep their chosen set in the - // wizard property as a newline-separated list; flag matching rows as selected. - let multi_select = target_kind == 1 && kind == 6; - let chosen: Vec = if multi_select { - ui.get_wizard_property().to_string().lines().map(str::to_string).collect() - } else { - Vec::new() - }; - - let filter_norm = filter.trim(); - let mut filtered: Vec = if filter_norm.is_empty() { - source - } else { - source.into_iter().filter(|n| n.to_lowercase().contains(filter_norm)).collect() - }; - // For non-key lists, dedup + sort. Keep key library order. - if !(target_kind == 1 && kind == 5) { - filtered.sort(); - filtered.dedup(); - } - let icon_kind: i32 = match (target_kind, kind) { - (_, 1) if target_kind == 0 => 1, // slider mic - (0, 2) => 2, // slider output - (1, 2) => 1, // button mic - (1, 1) => 2, // button output (main audio) - (1, 5) => 4, // key - (1, 6) => 2, // cycle output - _ => 0, // process - }; - ui.set_wizard_picker_source(ModelRc::new(VecModel::from( - filtered.into_iter() - .map(|n| crate::PickerEntry { - selected: chosen.iter().any(|c| c == &n), - name: n.into(), - icon_kind, - }) - .collect::>(), - ))); -} - -/// Merge sessions-with-audio with all running processes, so the picker isn't -/// empty when no audio is playing yet. -fn merged_processes(audio: &dyn AudioBackend) -> Vec { - let mut out = audio.list_processes(); - out.extend(list_running_processes()); - out -} - -fn populate_live_lists(ui: &AppWindow, shared: Arc>) { - let s = shared.lock(); - let mut procs = s.audio.list_processes(); - let mut mics = s.audio.list_mics(); - let mut outs = s.audio.list_outputs(); - // Also surface system processes (running apps) on top of audio-active ones, dedup. - procs.extend(list_running_processes()); - procs.sort(); - procs.dedup(); - mics.sort(); mics.dedup(); - outs.sort(); outs.dedup(); - ui.set_live_processes(ModelRc::new(VecModel::from( - procs.into_iter().map(SharedString::from).collect::>(), - ))); - ui.set_live_mics(ModelRc::new(VecModel::from( - mics.into_iter().map(SharedString::from).collect::>(), - ))); - ui.set_live_outputs(ModelRc::new(VecModel::from( - outs.into_iter().map(SharedString::from).collect::>(), - ))); -} - -#[cfg(target_os = "linux")] -fn list_running_processes() -> Vec { - let mut out = Vec::new(); - if let Ok(rd) = std::fs::read_dir("/proc") { - for e in rd.flatten() { - let n = e.file_name(); - let n = n.to_string_lossy(); - if !n.chars().all(|c| c.is_ascii_digit()) { continue } - if let Ok(comm) = std::fs::read_to_string(format!("/proc/{n}/comm")) { - let comm = comm.trim(); - if !comm.is_empty() { out.push(comm.to_string()); } - } - } - } - out -} - -#[cfg(target_os = "windows")] -fn list_running_processes() -> Vec { - use windows::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, - }; - use windows::Win32::Foundation::CloseHandle; - let mut out = Vec::new(); - unsafe { - let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else { return out }; - let mut entry = PROCESSENTRY32W { dwSize: std::mem::size_of::() as u32, ..Default::default() }; - if Process32FirstW(snap, &mut entry).is_ok() { - loop { - let len = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len()); - let name = String::from_utf16_lossy(&entry.szExeFile[..len]); - if !name.is_empty() { out.push(name); } - if Process32NextW(snap, &mut entry).is_err() { break } - } - } - let _ = CloseHandle(snap); - } - out -} - -// ── LED config <-> UI conversions ────────────────────────────────────────── - -fn opt(s: &str) -> Option { - if s.is_empty() { None } else { Some(s.to_string()) } -} - -fn led_mode_kind_to(k: LedModeKind) -> i32 { - match k { LedModeKind::Off => 0, LedModeKind::On => 1, LedModeKind::Blink => 2, LedModeKind::Breathe => 3 } -} -fn audio_kind_to(k: AudioKind) -> i32 { - match k { AudioKind::Process => 0, AudioKind::Mic => 1, AudioKind::Output => 2 } -} - -/// Snapshot a UI string-model into a Vec. -fn ui_list(model: &ModelRc) -> Vec { - (0..model.row_count()).filter_map(|i| model.row_data(i)).map(|s| s.to_string()).collect() -} - -/// Index of `target` within the live list for audio kind `audio` (-1 if absent). -fn list_index_of(ui: &AppWindow, audio: i32, target: &str) -> i32 { - if target.is_empty() { - return -1; - } - let list = match audio { - 1 => ui_list(&ui.get_live_mics()), - 2 => ui_list(&ui.get_live_outputs()), - _ => ui_list(&ui.get_live_processes()), - }; - list.iter().position(|x| x == target).map(|p| p as i32).unwrap_or(-1) -} - -fn led_mode_from_ui( - kind: i32, on: &str, off: &str, cycle: &str, - bmode: i32, bright: i32, baudio: i32, btarget: &str, -) -> LedMode { - let kind = match kind { - 1 => LedModeKind::On, - 2 => LedModeKind::Blink, - 3 => LedModeKind::Breathe, - _ => LedModeKind::Off, - }; - LedMode { - kind, - on_ms: on.trim().parse().unwrap_or(500).max(1), - off_ms: off.trim().parse().unwrap_or(500).max(1), - cycle_ms: cycle.trim().parse().unwrap_or(2000).max(1), - brightness_mode: if bmode == 1 { BrightnessMode::Volume } else { BrightnessMode::Fixed }, - brightness: bright.clamp(0, 100) as u8, - brightness_audio: audio_kind_from(baudio), - brightness_target: opt(btarget), - } -} - -fn cond_kind_from(v: i32) -> LedConditionKind { - match v { 1 => LedConditionKind::Volume, 2 => LedConditionKind::Api, _ => LedConditionKind::Muted } -} -fn audio_kind_from(v: i32) -> AudioKind { - match v { 1 => AudioKind::Mic, 2 => AudioKind::Output, _ => AudioKind::Process } -} -fn cmp_from(v: i32) -> Comparison { - match v { 1 => Comparison::Above, 2 => Comparison::Exact, _ => Comparison::Below } -} - -fn cond_to_ui(c: &LedCondition, target_index: i32) -> LedConditionUI { - LedConditionUI { - kind: match c.kind { LedConditionKind::Muted => 0, LedConditionKind::Volume => 1, LedConditionKind::Api => 2 }, - audio_kind: audio_kind_to(c.audio_kind), - target: c.target.clone().unwrap_or_default().into(), - target_index, - cmp: match c.cmp { Comparison::Below => 0, Comparison::Above => 1, Comparison::Exact => 2 }, - value: c.value as i32, - method: c.api.method.to_index(), - url: c.api.url.clone().into(), - payload: c.api.payload.clone().unwrap_or_default().into(), - bearer: c.api.bearer.clone().unwrap_or_default().into(), - interval: c.api.interval_ms.to_string().into(), - expect_body: c.api.expect_body.clone().unwrap_or_default().into(), - } -} - -/// Rebuild the conditions model, resolving each target's index within the live -/// lists already on the UI (so its dropdown shows the right selection). -fn push_led_conditions(ui: &AppWindow, conds: &[LedCondition]) { - let rows: Vec = conds - .iter() - .map(|c| { - let tidx = list_index_of(ui, audio_kind_to(c.audio_kind), c.target.as_deref().unwrap_or("")); - cond_to_ui(c, tidx) - }) - .collect(); - ui.set_led_conditions(ModelRc::new(VecModel::from(rows))); -} - -/// Push one LED appearance into the UI scalar props (active or inactive). -fn push_mode_to_ui(ui: &AppWindow, m: &LedMode, active: bool) { - let kind = led_mode_kind_to(m.kind); - let on: SharedString = m.on_ms.to_string().into(); - let off: SharedString = m.off_ms.to_string().into(); - let cycle: SharedString = m.cycle_ms.to_string().into(); - let bmode = if m.brightness_mode == BrightnessMode::Volume { 1 } else { 0 }; - let bright = m.brightness as i32; - let baudio = audio_kind_to(m.brightness_audio); - let btarget: SharedString = m.brightness_target.clone().unwrap_or_default().into(); - let btidx = list_index_of(ui, baudio, m.brightness_target.as_deref().unwrap_or("")); - if active { - ui.set_led_active_kind(kind); - ui.set_led_active_on(on); - ui.set_led_active_off(off); - ui.set_led_active_cycle(cycle); - ui.set_led_active_bmode(bmode); - ui.set_led_active_bright(bright); - ui.set_led_active_baudio(baudio); - ui.set_led_active_btarget(btarget); - ui.set_led_active_btidx(btidx); - } else { - ui.set_led_inactive_kind(kind); - ui.set_led_inactive_on(on); - ui.set_led_inactive_off(off); - ui.set_led_inactive_cycle(cycle); - ui.set_led_inactive_bmode(bmode); - ui.set_led_inactive_bright(bright); - ui.set_led_inactive_baudio(baudio); - ui.set_led_inactive_btarget(btarget); - ui.set_led_inactive_btidx(btidx); - } -} - -/// Apply `f` to condition `idx` of the working LED config (no UI re-push). -fn mutate_cond(shared: &Arc>, idx: i32, f: F) { - let mut s = shared.lock(); - if let Some((_, cfg)) = s.editing_led.as_mut() { - if let Some(c) = cfg.conditions.get_mut(idx as usize) { - f(c); - } - } -} - -/// Like [`mutate_cond`] but returns the updated condition list so the caller can -/// re-push the model (for structural changes that show/hide dependent fields). -fn mutate_cond_struct( - shared: &Arc>, - idx: i32, - f: F, -) -> Option> { - let mut s = shared.lock(); - let (_, cfg) = s.editing_led.as_mut()?; - if let Some(c) = cfg.conditions.get_mut(idx as usize) { - f(c); - } - Some(cfg.conditions.clone()) -} - -fn toast(ui: &AppWindow, msg: &str) { - ui.set_toast_text(msg.into()); - let weak = ui.as_weak(); - let timer = slint::Timer::default(); - timer.start(slint::TimerMode::SingleShot, Duration::from_millis(1800), move || { - if let Some(ui) = weak.upgrade() { - ui.set_toast_text("".into()); - } - }); - std::mem::forget(timer); -} +//! Bridge between the Slint UI and the Rust core. +//! +//! `wire()` attaches every callback and starts a polling timer that drains +//! [`SerialEvent`]s from the worker thread into Slint model properties. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crossbeam_channel::Receiver; +use parking_lot::Mutex; +use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel}; + +use crate::audio::AudioBackend; +use crate::events::EventEngine; +use crate::model::{ + led_for_button, next_id, ActionKind, ApiCall, AudioKind, AudioStream, BrightnessMode, + ButtonAction, ButtonCategory, CombineLogic, Comparison, HttpMethod, LedCondition, + LedConditionKind, LedConfig, LedControl, LedMode, LedModeKind, Preset, Settings, SliderCategory, + ThemeMode, +}; +use crate::protocol::{Frame, NUM_BUTTONS, NUM_SLIDERS}; +use crate::serial::{RetryKicker, SerialEvent}; +use crate::{ + AppWindow, AssignPick, ButtonCell, CategorySummary, LedConditionUI, LineItem, PickerEntry, + SliderCell, WizardResult, +}; + +pub struct Shared { + pub settings: Settings, + pub preset: Preset, + pub engine: EventEngine, + pub audio: Arc, + pub cmd_tx: crossbeam_channel::Sender, + pub kicker: RetryKicker, + /// Live LED state published by the actuator (for the configure-LED popup). + pub led_state: crate::led::LedStateHandle, + pub pending_assign: Option<(i32, i32)>, + pub pending_wizard: Option<(i32, u32)>, + pub pending_retry_deadline: Option, + /// When editing an existing category entry, the index being replaced. + pub editing_idx: Option, + /// Working copy for the configure-LED popup: (led index, config being edited). + pub editing_led: Option<(usize, LedConfig)>, +} + +impl Shared { + /// Push the full LED configuration set + brightness + experimental flag to + /// the actuator's LED engine. Call after any change to LED config/settings. + pub fn push_leds(&self) { + let _ = self.cmd_tx.send(crate::events::Cmd::SetLedExperimental(self.settings.led_experimental)); + let _ = self + .cmd_tx + .send(crate::events::Cmd::SetLeds(Box::new(self.preset.leds.clone()))); + } +} + +pub fn wire( + ui: &AppWindow, + shared: Arc>, + rx: Receiver, + build_stamp: &'static str, +) { + ui.set_build_stamp(SharedString::from(build_stamp)); + + // Push initial state from settings/preset → UI. + { + let s = shared.lock(); + push_settings_to_ui(ui, &s.settings, &s.preset.settings); + push_preset_to_ui(ui, &s.preset); + apply_appearance(ui, &s.preset.settings); + s.push_leds(); + } + + // Empty initial slider/button cells. + push_cells(ui, &[0; NUM_SLIDERS], &[0; NUM_BUTTONS], &shared.lock().preset); + ui.set_connection_msg("Searching for Slidr device…".into()); + ui.set_retry_eta_secs(-1.0); + ui.set_connected(false); + + // ─── callbacks ───────────────────────────────────────────────────────── + { + let shared = shared.clone(); + ui.on_retry_now(move || shared.lock().kicker.kick()); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_open_assign(move |kind, idx| { + let s = shared.lock(); + let options: Vec = if kind == 0 { + s.preset.slider_categories.iter().map(|c| AssignPick { + id: c.id as i32, name: c.name.clone().into(), + }).collect() + } else { + s.preset.button_categories.iter().map(|c| AssignPick { + id: c.id as i32, name: c.name.clone().into(), + }).collect() + }; + drop(s); + if let Some(ui) = weak.upgrade() { + ui.set_assign_options(ModelRc::new(VecModel::from(options))); + ui.set_assign_target_kind(kind); + ui.set_assign_target_index(idx); + ui.set_assign_popup_open(true); + } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_apply_assign(move |chosen_id| { + let Some(ui) = weak.upgrade() else { return }; + let target_kind = ui.get_assign_target_kind(); + let idx = ui.get_assign_target_index() as usize; + let chosen = if chosen_id < 0 { None } else { Some(chosen_id as u32) }; + let mut s = shared.lock(); + if target_kind == 0 && idx < NUM_SLIDERS { + s.preset.assignments.sliders[idx] = chosen; + } else if target_kind == 1 && idx < NUM_BUTTONS { + s.preset.assignments.buttons[idx] = chosen; + } + let _ = crate::storage::save_preset(&s.preset); + push_preset_to_ui(&ui, &s.preset); + let live = s.engine.state().clone(); + push_cells(&ui, &live.sliders, &live.buttons, &s.preset); + }); + } + + // ─── category CRUD ───────────────────────────────────────────────────── + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_add_category(move |kind, name| { + if name.is_empty() { return } + let mut s = shared.lock(); + if kind == 0 { + let id = next_id(&s.preset.slider_categories, |c| c.id); + s.preset.slider_categories.push(SliderCategory { id, name: name.to_string(), streams: vec![], collapsed: false }); + } else { + let id = next_id(&s.preset.button_categories, |c| c.id); + s.preset.button_categories.push(ButtonCategory { id, name: name.to_string(), actions: vec![], collapsed: false }); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_create_preset(move |name| { + if name.is_empty() { return } + let new_preset = Preset { + id: 1, + name: name.to_string(), + ..Default::default() + }; + if crate::storage::save_preset(&new_preset).is_ok() { + let mut s = shared.lock(); + s.preset = new_preset; + s.settings.active_preset = name.to_string(); + let _ = crate::storage::save_settings(&s.settings); + if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &s.preset); + push_settings_to_ui(&ui, &s.settings, &s.preset.settings); + apply_appearance(&ui, &s.preset.settings); + s.push_leds(); + refresh_home(&ui, &s); + toast(&ui, &format!("Created profile: {name}")); + } + } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_rename_category(move |kind, id, name| { + let mut s = shared.lock(); + if kind == 0 { + if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id as i32 == id) { + c.name = name.to_string(); + } + } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id as i32 == id) { + c.name = name.to_string(); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + refresh_home(&ui, &s); // update Home preview names without a board input + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_delete_category(move |kind, id| { + let mut s = shared.lock(); + let id = id as u32; + if kind == 0 { + s.preset.slider_categories.retain(|c| c.id != id); + for slot in s.preset.assignments.sliders.iter_mut() { + if *slot == Some(id) { *slot = None } + } + } else { + s.preset.button_categories.retain(|c| c.id != id); + for slot in s.preset.assignments.buttons.iter_mut() { + if *slot == Some(id) { *slot = None } + } + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + refresh_home(&ui, &s); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_delete_line(move |kind, cat_id, idx| { + let mut s = shared.lock(); + let cat_id = cat_id as u32; + let idx = idx as usize; + if kind == 0 { + if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == cat_id) { + if idx < c.streams.len() { c.streams.remove(idx); } + } + } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == cat_id) { + if idx < c.actions.len() { c.actions.remove(idx); } + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_toggle_category_collapse(move |kind, id| { + let mut s = shared.lock(); + let id = id as u32; + if kind == 0 { + if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == id) { + c.collapsed = !c.collapsed; + } + } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == id) { + c.collapsed = !c.collapsed; + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + // Reorder category cards (cosmetic, but persisted via Vec order). + ui.on_reorder_category(move |kind, from_id, to_index| { + let mut s = shared.lock(); + let from_id = from_id as u32; + let to = to_index.max(0) as usize; + if kind == 0 { + reorder_by_id(&mut s.preset.slider_categories, from_id, to, |c| c.id); + } else { + reorder_by_id(&mut s.preset.button_categories, from_id, to, |c| c.id); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + refresh_home(&ui, &s); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + // Move an action/stream within or between categories (same target-kind). + ui.on_move_line(move |kind, from_cat, from_idx, to_cat, to_idx| { + let mut s = shared.lock(); + let (from_cat, to_cat) = (from_cat as u32, to_cat as u32); + let (from_idx, to_idx) = (from_idx.max(0) as usize, to_idx.max(0) as usize); + if kind == 0 { + move_line(&mut s.preset.slider_categories, from_cat, from_idx, to_cat, to_idx, + |c| c.id, |c| &mut c.streams); + } else { + move_line(&mut s.preset.button_categories, from_cat, from_idx, to_cat, to_idx, + |c| c.id, |c| &mut c.actions); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + + // ─── wizard ──────────────────────────────────────────────────────────── + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_open_wizard(move |kind, cat_id| { + let Some(ui) = weak.upgrade() else { return }; + { + let mut s = shared.lock(); + s.pending_wizard = Some((kind, cat_id as u32)); + s.editing_idx = None; + } + ui.set_wizard_target_kind(kind); + ui.set_wizard_category_id(cat_id); + ui.set_wizard_step(0); + ui.set_wizard_kind(0); + ui.set_wizard_property("".into()); + ui.set_wizard_display("".into()); + reset_wizard_api(&ui); + populate_live_lists(&ui, shared.clone()); + ui.set_wizard_filter("".into()); + push_wizard_picker(&ui, shared.clone()); + ui.set_wizard_open(true); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_edit_line(move |target_kind, cat_id, idx| { + let Some(ui) = weak.upgrade() else { return }; + let idx = idx as usize; + let cat_id = cat_id as u32; + // Resolve existing entry → wizard kind/property/display (+ optional API). + let (wkind, prop, disp, api) = { + let s = shared.lock(); + if target_kind == 0 { + let Some(c) = s.preset.slider_categories.iter().find(|c| c.id == cat_id) else { return }; + let Some(stream) = c.streams.get(idx) else { return }; + if let Some(api) = &stream.api { (3, String::new(), String::new(), Some(api.clone())) } + else if let Some(p) = &stream.process { (0, p.clone(), p.clone(), None) } + else if let Some(m) = &stream.mic_name { (1, m.clone(), m.clone(), None) } + else { (2, stream.device_name.clone().unwrap_or_default(), + stream.device_name.clone().unwrap_or_default(), None) } + } else { + let Some(c) = s.preset.button_categories.iter().find(|c| c.id == cat_id) else { return }; + let Some(a) = c.actions.get(idx) else { return }; + let k = action_kind_index(a.kind); + let prop = a.property.clone().unwrap_or_default(); + // For KeyPress, show the friendly name in the picker. + let shown = if a.kind == ActionKind::KeyPress { + prop.parse::().ok() + .and_then(crate::keys_library::label_for_vk) + .map(|s| s.to_string()) + .unwrap_or_else(|| a.display.clone().unwrap_or(prop.clone())) + } else { + prop.clone() + }; + (k, shown, a.display.clone().unwrap_or_default(), a.api.clone()) + } + }; + shared.lock().editing_idx = Some(idx); + ui.set_wizard_target_kind(target_kind); + ui.set_wizard_category_id(cat_id as i32); + ui.set_wizard_kind(wkind); + ui.set_wizard_step(1); // jump straight to the target step + ui.set_wizard_property(prop.into()); + ui.set_wizard_display(disp.into()); + push_wizard_api(&ui, api.as_ref()); + populate_live_lists(&ui, shared.clone()); + ui.set_wizard_filter("".into()); + push_wizard_picker(&ui, shared.clone()); + ui.set_wizard_open(true); + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_refresh_live_lists(move || { + let Some(ui) = weak.upgrade() else { return }; + populate_live_lists(&ui, shared.clone()); + push_wizard_picker(&ui, shared.clone()); + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_wizard_state_changed(move || { + let Some(ui) = weak.upgrade() else { return }; + push_wizard_picker(&ui, shared.clone()); + }); + } + { + let weak = ui.as_weak(); + ui.on_browse_file(move || { + let path = rfd::FileDialog::new().pick_file(); + if let Some(p) = path { + if let Some(ui) = weak.upgrade() { + let s = p.display().to_string(); + ui.set_wizard_property(s.clone().into()); + let name = p.file_stem().map(|s| s.to_string_lossy().to_string()) + .unwrap_or(s.clone()); + ui.set_wizard_display(name.into()); + } + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + // Multi-select picker (Cycle output): toggle a device in/out of the + // newline-separated set held in the wizard property. + ui.on_wizard_toggle_pick(move |name| { + let Some(ui) = weak.upgrade() else { return }; + let name = name.to_string(); + let mut list: Vec = ui + .get_wizard_property() + .to_string() + .lines() + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if let Some(pos) = list.iter().position(|n| n == &name) { + list.remove(pos); + } else { + list.push(name); + } + ui.set_wizard_property(list.join("\n").into()); + push_wizard_picker(&ui, shared.clone()); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_wizard_finish(move |result| { + let mut s = shared.lock(); + let editing = s.editing_idx.take(); + let cat_id = result.category_id as u32; + if result.target_kind == 0 { + if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == cat_id) { + let stream = match result.kind { + 0 => AudioStream { process: Some(result.property.into()), ..Default::default() }, + 1 => AudioStream { mic_name: Some(result.property.into()), ..Default::default() }, + 2 => AudioStream { device_name: Some(result.property.into()), ..Default::default() }, + _ => AudioStream { api: Some(api_from_result(&result)), ..Default::default() }, + }; + match editing { + Some(i) if i < c.streams.len() => c.streams[i] = stream, + _ => c.streams.push(stream), + } + } + } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == cat_id) { + let kind = button_kind_from_index(result.kind); + if kind == ActionKind::ApiCall { + let action = ButtonAction { + kind, property: None, display: None, + api: Some(api_from_result(&result)), + }; + match editing { + Some(i) if i < c.actions.len() => c.actions[i] = action, + _ => c.actions.push(action), + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); + return; + } + // For KeyPress, picker stores the friendly name; resolve to VK code. + let (prop, disp) = if kind == ActionKind::KeyPress { + let name = result.property.to_string(); + let vk = crate::keys_library::KEYS.iter() + .find(|k| k.name.eq_ignore_ascii_case(&name)) + .map(|k| k.vk) + .or_else(|| name.parse::().ok()) + .unwrap_or(0); + (Some(vk.to_string()), Some(name)) + } else if kind == ActionKind::CycleOutput { + // Property is the newline-separated device list (empty = all). + // The human-readable label is derived at render time + // (`action_secondary`), so no display is stored here. + let list = result.property.to_string(); + let has_any = list.lines().any(|s| !s.is_empty()); + (has_any.then_some(list), None) + } else { + let prop = if result.property.is_empty() { None } else { Some(result.property.to_string()) }; + let disp = if result.display.is_empty() { None } else { Some(result.display.to_string()) }; + (prop, disp) + }; + let action = ButtonAction { kind, property: prop, display: disp, api: None }; + match editing { + Some(i) if i < c.actions.len() => c.actions[i] = action, + _ => c.actions.push(action), + } + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); + }); + } + + // ─── settings ────────────────────────────────────────────────────────── + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_theme_changed(move |idx| { + let mode = match idx { + 1 => ThemeMode::Light, 2 => ThemeMode::Dark, _ => ThemeMode::System, + }; + { + let mut s = shared.lock(); + s.preset.settings.theme = mode; + let _ = crate::storage::save_preset(&s.preset); + } + if let Some(ui) = weak.upgrade() { + apply_color_scheme(&ui, mode); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_curve_preset_changed(move |idx| { + let mut s = shared.lock(); + let preset = index_to_curve_preset(idx); + s.preset.settings.curve_preset = preset; + // Custom starts from a gentle default each time it's freshly selected. + if matches!(preset, crate::curve::CurvePreset::Custom) { + s.preset.settings.custom_curve = crate::curve::BezierPoints::CUSTOM_DEFAULT; + } + let _ = crate::storage::save_preset(&s.preset); + if let Some(ui) = weak.upgrade() { + push_curve_to_ui(&ui, &s.preset.settings); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_curve_changed(move || { + let Some(ui) = weak.upgrade() else { return }; + let mut s = shared.lock(); + s.preset.settings.custom_curve = crate::curve::BezierPoints { + x1: ui.get_curve_x1(), y1: ui.get_curve_y1(), + x2: ui.get_curve_x2(), y2: ui.get_curve_y2(), + }; + s.preset.settings.curve_preset = crate::curve::CurvePreset::Custom; + let _ = crate::storage::save_preset(&s.preset); + }); + } + { + ui.on_exit_app(|| slint::quit_event_loop().ok().unwrap_or(())); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_rename_preset(move |old, new| { + if old.is_empty() || new.is_empty() || old == new { return } + // Read the old preset, write under new name, delete old. + if let Ok(mut p) = crate::storage::load_preset(&old) { + p.name = new.to_string(); + if crate::storage::save_preset(&p).is_ok() { + let _ = crate::storage::delete_preset(&old); + let mut s = shared.lock(); + if s.settings.active_preset == old.as_str() { + s.settings.active_preset = new.to_string(); + let _ = crate::storage::save_settings(&s.settings); + } + if s.preset.name == old.as_str() { + s.preset.name = new.to_string(); + } + if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &s.preset); + toast(&ui, &format!("Renamed to {new}")); + } + } + } + }); + } + { + let weak = ui.as_weak(); + ui.on_export_preset(move |name| { + let src = crate::storage::presets_dir().join(name.to_string()).join("preset.json"); + let dest = rfd::FileDialog::new() + .set_file_name(format!("slidr-preset-{name}.json")) + .add_filter("Slidr preset", &["json"]) + .save_file(); + let Some(dest) = dest else { return }; + match std::fs::copy(&src, &dest) { + Ok(_) => if let Some(ui) = weak.upgrade() { + toast(&ui, &format!("Exported to {}", dest.display())); + }, + Err(e) => if let Some(ui) = weak.upgrade() { + toast(&ui, &format!("Export failed: {e}")); + }, + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_import_preset(move || { + let Some(path) = rfd::FileDialog::new() + .add_filter("Slidr preset", &["json"]) + .pick_file() else { return }; + let Ok(data) = std::fs::read_to_string(&path) else { return }; + let Ok(mut preset) = serde_json::from_str::(&data) else { + if let Some(ui) = weak.upgrade() { toast(&ui, "Import failed: invalid preset file"); } + return; + }; + // Ensure a unique, non-empty name (avoid clobbering an existing one). + if preset.name.trim().is_empty() { + preset.name = path.file_stem().map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Imported".into()); + } + let existing = crate::storage::list_presets(); + let base = preset.name.clone(); + let mut n = 1; + while existing.iter().any(|e| e == &preset.name) { + n += 1; + preset.name = format!("{base} ({n})"); + } + match crate::storage::save_preset(&preset) { + Ok(()) => if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &shared.lock().preset); + toast(&ui, &format!("Imported profile: {}", preset.name)); + }, + Err(e) => if let Some(ui) = weak.upgrade() { + toast(&ui, &format!("Import failed: {e}")); + }, + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_save_settings(move || { + let Some(ui) = weak.upgrade() else { return }; + let mut s = shared.lock(); + // System → global settings; appearance/sliders → active profile. + pull_global_from_ui(&ui, &mut s.settings); + pull_profile_from_ui(&ui, &mut s.preset.settings); + let _ = crate::storage::save_settings(&s.settings); + let _ = crate::storage::save_preset(&s.preset); + let _ = crate::autostart::set_enabled(s.settings.start_with_os, s.settings.start_minimized); + // Live-apply appearance (accent / theme) immediately. + apply_appearance(&ui, &s.preset.settings); + // LED brightness / experimental flag may have changed. + s.push_leds(); + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_save_preset(move || { + let s = shared.lock(); + let result = crate::storage::save_preset(&s.preset); + if let Some(ui) = weak.upgrade() { + match result { + Ok(()) => toast(&ui, "Preset saved."), + Err(e) => toast(&ui, &format!("Save failed: {e}")), + } + push_preset_to_ui(&ui, &s.preset); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_load_preset(move |name| { + let Ok(p) = crate::storage::load_preset(&name) else { return }; + let mut s = shared.lock(); + s.preset = p; + s.settings.active_preset = s.preset.name.clone(); + let _ = crate::storage::save_settings(&s.settings); + if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &s.preset); + push_settings_to_ui(&ui, &s.settings, &s.preset.settings); + apply_appearance(&ui, &s.preset.settings); + s.push_leds(); + // Refresh Home immediately with the new assignments using the + // last live readings — no board input required. + refresh_home(&ui, &s); + toast(&ui, &format!("Switched to profile: {name}")); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_delete_preset(move |name| { + let _ = crate::storage::delete_preset(&name); + if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &shared.lock().preset); + toast(&ui, &format!("Deleted profile: {name}")); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_set_active_preset(move |name| { + let mut s = shared.lock(); + s.settings.active_preset = name.to_string(); + let _ = crate::storage::save_settings(&s.settings); + if let Some(ui) = weak.upgrade() { + push_settings_to_ui(&ui, &s.settings, &s.preset.settings); + } + }); + } + + // ─── LED state persistence (board EEPROM) ────────────────────────────── + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_save_state(move || { + shared.lock().cmd_tx.send(crate::events::Cmd::LedSaveState).ok(); + if let Some(ui) = weak.upgrade() { + toast(&ui, if ui.get_connected() { "Saved LED state to the board." } + else { "Not connected — connect the board first." }); + } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_clear_state(move || { + shared.lock().cmd_tx.send(crate::events::Cmd::LedClearSaved).ok(); + if let Some(ui) = weak.upgrade() { + toast(&ui, if ui.get_connected() { "Cleared saved LED state (all off)." } + else { "Not connected — connect the board first." }); + } + }); + } + + // ─── configure LED ───────────────────────────────────────────────────── + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_open_led_config(move |btn| { + let Some(led) = led_for_button(btn as usize) else { return }; + let cfg = { + let mut s = shared.lock(); + let cfg = s.preset.leds[led].clone().unwrap_or_default(); + s.editing_led = Some((led, cfg.clone())); + cfg + }; + let Some(ui) = weak.upgrade() else { return }; + ui.set_led_index(led as i32); + ui.set_led_control(if cfg.control == LedControl::Manual { 1 } else { 0 }); + ui.set_led_combine(if cfg.combine == CombineLogic::All { 1 } else { 0 }); + // Populate the device lists first so target indices resolve. + populate_live_lists(&ui, shared.clone()); + push_mode_to_ui(&ui, &cfg.active, true); + push_mode_to_ui(&ui, &cfg.inactive, false); + push_led_conditions(&ui, &cfg.conditions); + ui.set_led_live_active(false); + ui.set_led_popup_open(true); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_cancel(move || { + shared.lock().editing_led = None; + if let Some(ui) = weak.upgrade() { ui.set_led_popup_open(false); } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_save(move || { + let Some(ui) = weak.upgrade() else { return }; + let mut s = shared.lock(); + let Some((led, mut cfg)) = s.editing_led.take() else { return }; + cfg.control = if ui.get_led_control() == 1 { LedControl::Manual } else { LedControl::Conditional }; + cfg.combine = if ui.get_led_combine() == 1 { CombineLogic::All } else { CombineLogic::Any }; + cfg.active = led_mode_from_ui( + ui.get_led_active_kind(), + ui.get_led_active_on().as_str(), + ui.get_led_active_off().as_str(), + ui.get_led_active_cycle().as_str(), + ui.get_led_active_bmode(), + ui.get_led_active_bright(), + ui.get_led_active_baudio(), + ui.get_led_active_btarget().as_str(), + ); + cfg.inactive = led_mode_from_ui( + ui.get_led_inactive_kind(), + ui.get_led_inactive_on().as_str(), + ui.get_led_inactive_off().as_str(), + ui.get_led_inactive_cycle().as_str(), + ui.get_led_inactive_bmode(), + ui.get_led_inactive_bright(), + ui.get_led_inactive_baudio(), + ui.get_led_inactive_btarget().as_str(), + ); + s.preset.leds[led] = Some(cfg); + let _ = crate::storage::save_preset(&s.preset); + s.push_leds(); + drop(s); + ui.set_led_popup_open(false); + toast(&ui, "LED saved."); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_add_condition(move || { + let conds = { + let mut s = shared.lock(); + let Some((_, cfg)) = s.editing_led.as_mut() else { return }; + cfg.conditions.push(LedCondition::default()); + cfg.conditions.clone() + }; + if let Some(ui) = weak.upgrade() { push_led_conditions(&ui, &conds); } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_remove_condition(move |idx| { + let conds = { + let mut s = shared.lock(); + let Some((_, cfg)) = s.editing_led.as_mut() else { return }; + let i = idx as usize; + if i < cfg.conditions.len() { cfg.conditions.remove(i); } + cfg.conditions.clone() + }; + if let Some(ui) = weak.upgrade() { push_led_conditions(&ui, &conds); } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_led_refresh(move || { + let Some(ui) = weak.upgrade() else { return }; + populate_live_lists(&ui, shared.clone()); + // Re-resolve dropdown indices against the freshly scanned lists. + let conds = shared.lock().editing_led.as_ref().map(|(_, c)| c.conditions.clone()); + if let Some(conds) = conds { + push_led_conditions(&ui, &conds); + } + ui.set_led_active_btidx(list_index_of(&ui, ui.get_led_active_baudio(), ui.get_led_active_btarget().as_str())); + ui.set_led_inactive_btidx(list_index_of(&ui, ui.get_led_inactive_baudio(), ui.get_led_inactive_btarget().as_str())); + }); + } + { + let shared = shared.clone(); + ui.on_led_manual_toggle(move || { + let mut s = shared.lock(); + let Some((led, cfg)) = s.editing_led.as_mut() else { return }; + cfg.manual_active = !cfg.manual_active; + let led = *led; + let _ = s.cmd_tx.send(crate::events::Cmd::LedManualToggle(led as u8)); + if let Some(saved) = s.preset.leds.get_mut(led).and_then(|c| c.as_mut()) { + if saved.control == LedControl::Manual { + saved.manual_active = !saved.manual_active; + let _ = crate::storage::save_preset(&s.preset); + } + } + }); + } + // Per-field condition mutators. Structural edits (kind/audio) re-push the + // model so dependent fields show/hide; scalar/text edits update in place. + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_cond_kind(move |idx, v| { + let conds = mutate_cond_struct(&shared, idx, |c| c.kind = cond_kind_from(v)); + if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_cond_audio(move |idx, v| { + // Changing the endpoint family invalidates the previously picked target. + let conds = mutate_cond_struct(&shared, idx, |c| { + c.audio_kind = audio_kind_from(v); + c.target = None; + }); + if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } + }); + } + { + let shared = shared.clone(); + ui.on_led_cond_target(move |idx, v| { + mutate_cond(&shared, idx, |c| c.target = opt(v.as_str())); + }); + } + { + let shared = shared.clone(); + ui.on_led_cond_cmp(move |idx, v| { mutate_cond(&shared, idx, |c| c.cmp = cmp_from(v)); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_value(move |idx, v| { mutate_cond(&shared, idx, |c| c.value = v.clamp(0, 100) as u8); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_method(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.method = HttpMethod::from_index(v)); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_url(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.url = v.to_string()); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_payload(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.payload = opt(v.as_str())); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_bearer(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.bearer = opt(v.as_str())); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_interval(move |idx, v| { + if let Ok(n) = v.as_str().trim().parse::() { + mutate_cond(&shared, idx, |c| c.api.interval_ms = n); + } + }); + } + { + let shared = shared.clone(); + ui.on_led_cond_expect(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.expect_body = opt(v.as_str())); }); + } + + // ─── serial event pump ──────────────────────────────────────────────── + let weak = ui.as_weak(); + let timer = slint::Timer::default(); + timer.start(slint::TimerMode::Repeated, Duration::from_millis(16), move || { + let Some(ui) = weak.upgrade() else { return }; + let mut s = shared.lock(); + let mut changed = false; + while let Ok(ev) = rx.try_recv() { + match ev { + SerialEvent::Connected(port) => { + s.engine.set_connected(true); + ui.set_connected(true); + ui.set_connection_msg(format!("Connected on {port}").into()); + ui.set_retry_eta_secs(-1.0); + // Board reset on connect; re-push all desired LED state. + s.push_leds(); + changed = true; + } + SerialEvent::Disconnected { reason, retrying_in } => { + s.engine.set_connected(false); + ui.set_connected(false); + ui.set_connection_msg(reason.into()); + ui.set_retry_eta_secs(retrying_in.as_secs_f32()); + s.pending_retry_deadline = Some(Instant::now() + retrying_in); + changed = true; + } + SerialEvent::Frame(frame) => { + // Cheap diff on the UI thread; side effects go to the actuator. + let preset = s.preset.clone(); + let (dirty, cmds) = s.engine.ingest(&frame, &preset); + let mut led_toggled = false; + for cmd in cmds { + // Persist a manual-LED toggle into the preset so it + // survives a restart (the actuator owns the live state). + if let crate::events::Cmd::LedManualToggle(led) = &cmd { + if let Some(cfg) = s.preset.leds.get_mut(*led as usize).and_then(|c| c.as_mut()) { + cfg.manual_active = !cfg.manual_active; + led_toggled = true; + } + } + let _ = s.cmd_tx.send(cmd); + } + if led_toggled { + let _ = crate::storage::save_preset(&s.preset); + } + if dirty { changed = true; } + } + } + } + // Mirror live LED state into the configure popup (manual-mode readout). + if ui.get_led_popup_open() { + let led = ui.get_led_index() as usize; + let active = s.led_state.lock().get(led).map(|l| l.active).unwrap_or(false); + ui.set_led_live_active(active); + } + + // Animate retry countdown even between events. + if let Some(deadline) = s.pending_retry_deadline { + let remaining = deadline.saturating_duration_since(Instant::now()).as_secs_f32(); + if remaining > 0.0 { + ui.set_retry_eta_secs(remaining); + } + } + if changed { + let live = s.engine.state().clone(); + push_cells(&ui, &live.sliders, &live.buttons, &s.preset); + ui.set_badge_text(match live.badge { + crate::protocol::Badge::Supporter => "Supporter".into(), + crate::protocol::Badge::Premium => "Premium".into(), + _ => "".into(), + }); + } + }); + // Leak the timer so it survives until window close. + std::mem::forget(timer); +} + +// Helpers ---------------------------------------------------------------- + +/// Refresh the Home live cells from the last known board state — used after +/// preset/category edits so names update without waiting for a board input. +fn refresh_home(ui: &AppWindow, s: &Shared) { + let live = s.engine.state().clone(); + push_cells(ui, &live.sliders, &live.buttons, &s.preset); +} + +fn push_cells(ui: &AppWindow, sliders: &[i32; NUM_SLIDERS], buttons: &[u8; NUM_BUTTONS], preset: &Preset) { + let pts = crate::curve::BezierPoints::for_preset( + preset_curve(preset).0, preset_curve(preset).1); + let _ = pts; // curve preview unused at the cell level + let slider_cells: Vec = sliders + .iter().enumerate().map(|(i, &v)| { + let pct = crate::curve::normalize_raw(v); + let cat = preset.slider_category(i); + SliderCell { + value: v, + percent: pct, + category: cat.map(|c| c.name.clone()).unwrap_or_default().into(), + assigned: cat.is_some(), + } + }).collect(); + let button_cells: Vec = buttons + .iter().enumerate().map(|(i, &b)| { + let cat = preset.button_category(i); + ButtonCell { + pressed: b != 0, + category: cat.map(|c| c.name.clone()).unwrap_or_default().into(), + assigned: cat.is_some(), + } + }).collect(); + ui.set_sliders(ModelRc::new(VecModel::from(slider_cells))); + let (top, right): (Vec<_>, Vec<_>) = button_cells.into_iter().enumerate() + .partition(|(i, _)| *i < 6); + let top: Vec<_> = top.into_iter().map(|(_, b)| b).collect(); + let right: Vec<_> = right.into_iter().map(|(_, b)| b).collect(); + ui.set_top_buttons(ModelRc::new(VecModel::from(top))); + ui.set_right_buttons(ModelRc::new(VecModel::from(right))); +} + +fn preset_curve(_preset: &Preset) -> (crate::curve::CurvePreset, crate::curve::BezierPoints) { + (crate::curve::CurvePreset::Linear, crate::curve::BezierPoints::LINEAR) +} + +/// Move the category with `from_id` to insertion index `to_index` (0..=len). +fn reorder_by_id(cats: &mut Vec, from_id: u32, to_index: usize, id: impl Fn(&C) -> u32) { + let Some(from) = cats.iter().position(|c| id(c) == from_id) else { return }; + let item = cats.remove(from); + // `to_index` was computed against the original list; removing the item + // shifts everything after it down by one. + let to = if from < to_index { to_index - 1 } else { to_index }; + cats.insert(to.min(cats.len()), item); +} + +/// Move element `from_idx` of category `from_cat` to insertion index `to_idx` of +/// category `to_cat` (may be the same category). `to_idx` is in the destination's +/// original ordering. +fn move_line( + cats: &mut [C], + from_cat: u32, + from_idx: usize, + to_cat: u32, + to_idx: usize, + id: impl Fn(&C) -> u32, + lines: impl Fn(&mut C) -> &mut Vec, +) { + let Some(from_pos) = cats.iter().position(|c| id(c) == from_cat) else { return }; + let Some(to_pos) = cats.iter().position(|c| id(c) == to_cat) else { return }; + let item = { + let v = lines(&mut cats[from_pos]); + if from_idx >= v.len() { + return; + } + v.remove(from_idx) + }; + // Within the same category, a removal before the target shifts it down. + let to = if from_cat == to_cat && from_idx < to_idx { to_idx - 1 } else { to_idx }; + let v = lines(&mut cats[to_pos]); + v.insert(to.min(v.len()), item); +} + +fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { + let sliders: Vec = preset + .slider_categories + .iter() + .map(|c| CategorySummary { + id: c.id as i32, + name: c.name.clone().into(), + count: c.streams.len() as i32, + collapsed: c.collapsed, + lines: ModelRc::new(VecModel::from( + c.streams.iter().enumerate().map(|(i, s)| LineItem { + id: i as i32, + primary: s.label().into(), + secondary: stream_secondary(s).into(), + icon_kind: stream_icon_kind(s), + }).collect::>(), + )), + }) + .collect(); + let buttons: Vec = preset + .button_categories + .iter() + .map(|c| CategorySummary { + id: c.id as i32, + name: c.name.clone().into(), + count: c.actions.len() as i32, + collapsed: c.collapsed, + lines: ModelRc::new(VecModel::from( + c.actions.iter().enumerate().map(|(i, a)| LineItem { + id: i as i32, + primary: SharedString::from(a.kind.label()), + secondary: action_secondary(a).into(), + icon_kind: action_icon_kind(a.kind), + }).collect::>(), + )), + }) + .collect(); + ui.set_slider_categories(ModelRc::new(VecModel::from(sliders))); + ui.set_button_categories(ModelRc::new(VecModel::from(buttons))); + ui.set_active_preset_name(preset.name.clone().into()); + ui.set_preset_names(ModelRc::new(VecModel::from( + crate::storage::list_presets().into_iter().map(SharedString::from).collect::>(), + ))); +} + +fn stream_icon_kind(s: &AudioStream) -> i32 { + if s.api.is_some() { 3 } + else if s.process.is_some() { 0 } + else if s.mic_name.is_some() { 1 } + else { 2 } +} + +/// Secondary line for a button action. Cycle-output derives its label from the +/// device list (so it stays correct without a stored display); others fall back +/// to the display, then the raw property. +fn action_secondary(a: &crate::model::ButtonAction) -> String { + if a.kind == ActionKind::CycleOutput { + let names: Vec<&str> = + a.property.as_deref().unwrap_or("").lines().filter(|s| !s.is_empty()).collect(); + return if names.is_empty() { + "All output devices".into() + } else { + names.join(" → ") + }; + } + if a.kind == ActionKind::ApiCall { + return a.api.as_ref().map(|api| api.label()).unwrap_or_else(|| "API call".into()); + } + a.display.clone().or_else(|| a.property.clone()).unwrap_or_default() +} + +fn action_icon_kind(k: ActionKind) -> i32 { + use ActionKind::*; + match k { + MuteProcess => 0, MuteMic => 1, MuteMainAudio => 5, + OpenProcess => 0, OpenWebsite => 3, KeyPress => 4, CycleOutput => 2, ApiCall => 3, + } +} + +fn stream_secondary(s: &AudioStream) -> String { + if s.api.is_some() { + "API call".into() + } else if s.process.is_some() { + "process".into() + } else if s.mic_name.is_some() { + "microphone".into() + } else if s.device_name.is_some() { + "output device".into() + } else { + "default output".into() + } +} + +/// Push system (global) + appearance/slider (per-profile) settings into the UI. +fn push_settings_to_ui(ui: &AppWindow, global: &Settings, profile: &crate::model::ProfileSettings) { + // System (global) + // Gates the two tray rows: only Windows ships a tray (`src/tray.rs`), and + // without one "start minimized" would leave the app running with no window + // and no way to reopen it. + ui.set_has_tray(cfg!(target_os = "windows")); + ui.set_start_with_os(global.start_with_os); + ui.set_start_minimized(global.start_minimized); + ui.set_minimize_to_tray(global.minimize_to_tray); + ui.set_led_experimental(global.led_experimental); + ui.set_active_preset_name(global.active_preset.clone().into()); + // Appearance + sliders (profile) + ui.set_theme_index(match profile.theme { + ThemeMode::System => 0, ThemeMode::Light => 1, ThemeMode::Dark => 2, + }); + ui.set_accent_preset(profile.accent_preset); + ui.set_dead_zone(profile.slider_dead_zone); + ui.set_unmute_on_change(profile.unmute_on_change); + ui.set_curve_preset_index(curve_preset_to_index(profile.curve_preset)); + push_curve_to_ui(ui, profile); +} + +/// Push the active curve's control points + editability into the UI. +fn push_curve_to_ui(ui: &AppWindow, profile: &crate::model::ProfileSettings) { + let pts = crate::curve::BezierPoints::for_preset(profile.curve_preset, profile.custom_curve); + ui.set_curve_x1(pts.x1); + ui.set_curve_y1(pts.y1); + ui.set_curve_x2(pts.x2); + ui.set_curve_y2(pts.y2); + ui.set_curve_editable(matches!(profile.curve_preset, crate::curve::CurvePreset::Custom)); +} + +fn pull_global_from_ui(ui: &AppWindow, g: &mut Settings) { + g.start_with_os = ui.get_start_with_os(); + g.start_minimized = ui.get_start_minimized(); + g.minimize_to_tray = ui.get_minimize_to_tray(); + g.led_experimental = ui.get_led_experimental(); +} + +fn pull_profile_from_ui(ui: &AppWindow, p: &mut crate::model::ProfileSettings) { + p.theme = match ui.get_theme_index() { 1 => ThemeMode::Light, 2 => ThemeMode::Dark, _ => ThemeMode::System }; + p.accent_preset = ui.get_accent_preset(); + p.slider_dead_zone = ui.get_dead_zone(); + p.unmute_on_change = ui.get_unmute_on_change(); + p.curve_preset = index_to_curve_preset(ui.get_curve_preset_index()); +} + +/// Apply a profile's appearance (theme + accent) to the live UI. +fn apply_appearance(ui: &AppWindow, profile: &crate::model::ProfileSettings) { + apply_color_scheme(ui, profile.theme); + ui.set_accent_preset(profile.accent_preset); + ui.invoke_apply_accent(profile.accent_preset); +} + +fn curve_preset_to_index(p: crate::curve::CurvePreset) -> i32 { + use crate::curve::CurvePreset::*; + match p { Linear => 0, Ease => 1, EaseIn => 2, EaseOut => 3, EaseInOut => 4, Custom => 5 } +} +fn index_to_curve_preset(i: i32) -> crate::curve::CurvePreset { + use crate::curve::CurvePreset::*; + match i { 1 => Ease, 2 => EaseIn, 3 => EaseOut, 4 => EaseInOut, 5 => Custom, _ => Linear } +} + +/// Build an [`ApiCall`] from the wizard result's API fields. +fn api_from_result(r: &WizardResult) -> ApiCall { + let opt = |s: String| if s.is_empty() { None } else { Some(s) }; + ApiCall { + method: HttpMethod::from_index(r.method), + url: r.url.trim().to_string(), + payload: opt(r.payload.to_string()), + bearer: opt(r.bearer.trim().to_string()), + throttle_ms: r.throttle.clamp( + crate::model::API_THROTTLE_MIN_MS, + crate::model::API_THROTTLE_MAX_MS, + ), + } +} + +/// Push an existing [`ApiCall`] (or defaults) into the wizard API fields. +fn push_wizard_api(ui: &AppWindow, api: Option<&ApiCall>) { + match api { + Some(api) => { + ui.set_wizard_method(api.method.to_index()); + ui.set_wizard_url(api.url.clone().into()); + ui.set_wizard_payload(api.payload.clone().unwrap_or_default().into()); + ui.set_wizard_bearer(api.bearer.clone().unwrap_or_default().into()); + ui.set_wizard_throttle(api.throttle_ms); + } + None => reset_wizard_api(ui), + } +} + +fn reset_wizard_api(ui: &AppWindow) { + ui.set_wizard_method(HttpMethod::default().to_index()); + ui.set_wizard_url("".into()); + ui.set_wizard_payload("".into()); + ui.set_wizard_bearer("".into()); + ui.set_wizard_throttle(crate::model::default_api_throttle_ms()); +} + +fn action_kind_index(k: ActionKind) -> i32 { + use ActionKind::*; + match k { + MuteProcess => 0, MuteMainAudio => 1, MuteMic => 2, + OpenProcess => 3, OpenWebsite => 4, KeyPress => 5, CycleOutput => 6, ApiCall => 7, + } +} + +fn button_kind_from_index(i: i32) -> ActionKind { + use ActionKind::*; + match i { + 0 => MuteProcess, 1 => MuteMainAudio, 2 => MuteMic, + 3 => OpenProcess, 4 => OpenWebsite, 6 => CycleOutput, 7 => ApiCall, _ => KeyPress, + } +} + +fn apply_color_scheme(ui: &AppWindow, mode: ThemeMode) { + // Trigger the slint-side `apply-theme(idx)` which sets `Palette.color-scheme`. + let idx = match mode { + ThemeMode::System => 0, + ThemeMode::Light => 1, + ThemeMode::Dark => 2, + }; + ui.invoke_apply_theme(idx); +} + +fn push_wizard_picker(ui: &AppWindow, shared: Arc>) { + let target_kind = ui.get_wizard_target_kind(); + let kind = ui.get_wizard_kind(); + let filter = ui.get_wizard_filter().to_string().to_lowercase(); + + let s = shared.lock(); + let source: Vec = match (target_kind, kind) { + // Slider streams + (0, 0) => merged_processes(s.audio.as_ref()), + (0, 1) => s.audio.list_mics(), + (0, 2) => s.audio.list_outputs(), + // Button actions + (1, 0) => merged_processes(s.audio.as_ref()), + (1, 1) => s.audio.list_outputs(), + (1, 2) => s.audio.list_mics(), + // Simulate key — full library + (1, 5) => crate::keys_library::KEYS.iter().map(|k| k.name.to_string()).collect(), + // Cycle output device — multi-select list of outputs + (1, 6) => s.audio.list_outputs(), + _ => Vec::new(), + }; + drop(s); + + // Multi-select kinds (currently Cycle output) keep their chosen set in the + // wizard property as a newline-separated list; flag matching rows as selected. + let multi_select = target_kind == 1 && kind == 6; + let chosen: Vec = if multi_select { + ui.get_wizard_property().to_string().lines().map(str::to_string).collect() + } else { + Vec::new() + }; + + let filter_norm = filter.trim(); + let mut filtered: Vec = if filter_norm.is_empty() { + source + } else { + source.into_iter().filter(|n| n.to_lowercase().contains(filter_norm)).collect() + }; + // For non-key lists, dedup + sort. Keep key library order. + if !(target_kind == 1 && kind == 5) { + filtered.sort(); + filtered.dedup(); + } + let icon_kind: i32 = match (target_kind, kind) { + (_, 1) if target_kind == 0 => 1, // slider mic + (0, 2) => 2, // slider output + (1, 2) => 1, // button mic + (1, 1) => 2, // button output (main audio) + (1, 5) => 4, // key + (1, 6) => 2, // cycle output + _ => 0, // process + }; + ui.set_wizard_picker_source(ModelRc::new(VecModel::from( + filtered.into_iter() + .map(|n| crate::PickerEntry { + selected: chosen.iter().any(|c| c == &n), + name: n.into(), + icon_kind, + }) + .collect::>(), + ))); +} + +/// Merge sessions-with-audio with all running processes, so the picker isn't +/// empty when no audio is playing yet. +fn merged_processes(audio: &dyn AudioBackend) -> Vec { + let mut out = audio.list_processes(); + out.extend(list_running_processes()); + out +} + +fn populate_live_lists(ui: &AppWindow, shared: Arc>) { + let s = shared.lock(); + let mut procs = s.audio.list_processes(); + let mut mics = s.audio.list_mics(); + let mut outs = s.audio.list_outputs(); + // Also surface system processes (running apps) on top of audio-active ones, dedup. + procs.extend(list_running_processes()); + procs.sort(); + procs.dedup(); + mics.sort(); mics.dedup(); + outs.sort(); outs.dedup(); + ui.set_live_processes(ModelRc::new(VecModel::from( + procs.into_iter().map(SharedString::from).collect::>(), + ))); + ui.set_live_mics(ModelRc::new(VecModel::from( + mics.into_iter().map(SharedString::from).collect::>(), + ))); + ui.set_live_outputs(ModelRc::new(VecModel::from( + outs.into_iter().map(SharedString::from).collect::>(), + ))); +} + +#[cfg(target_os = "linux")] +fn list_running_processes() -> Vec { + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir("/proc") { + for e in rd.flatten() { + let n = e.file_name(); + let n = n.to_string_lossy(); + if n.is_empty() || !n.chars().all(|c| c.is_ascii_digit()) { continue } + if let Ok(comm) = std::fs::read_to_string(format!("/proc/{n}/comm")) { + let comm = comm.trim(); + if comm.is_empty() { continue } + out.push(untruncate_comm(&n, comm)); + } + } + } + out +} + +/// The kernel caps `/proc//comm` at 15 characters (`TASK_COMM_LEN - 1`), so +/// long binaries show up clipped ("gnome-terminal-", "systemd-journal") and then +/// fail to match what PulseAudio reports as `application.process.binary`. When a +/// name is exactly at the cap, recover the full one from `cmdline`'s argv[0]. +#[cfg(target_os = "linux")] +fn untruncate_comm(pid: &str, comm: &str) -> String { + const TASK_COMM_MAX: usize = 15; + if comm.len() < TASK_COMM_MAX { + return comm.to_string(); + } + let Ok(cmdline) = std::fs::read_to_string(format!("/proc/{pid}/cmdline")) else { + return comm.to_string(); + }; + let argv0 = cmdline.split('').next().unwrap_or(""); + let base = argv0.rsplit('/').next().unwrap_or(""); + // Only trust it when it really is the same program, just not clipped - + // argv[0] can be an interpreter path or a rewritten title. + if base.len() > comm.len() && base.starts_with(comm) { + base.to_string() + } else { + comm.to_string() + } +} + +#[cfg(target_os = "windows")] +fn list_running_processes() -> Vec { + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, + }; + use windows::Win32::Foundation::CloseHandle; + let mut out = Vec::new(); + unsafe { + let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else { return out }; + let mut entry = PROCESSENTRY32W { dwSize: std::mem::size_of::() as u32, ..Default::default() }; + if Process32FirstW(snap, &mut entry).is_ok() { + loop { + let len = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..len]); + if !name.is_empty() { out.push(name); } + if Process32NextW(snap, &mut entry).is_err() { break } + } + } + let _ = CloseHandle(snap); + } + out +} + +// ── LED config <-> UI conversions ────────────────────────────────────────── + +fn opt(s: &str) -> Option { + if s.is_empty() { None } else { Some(s.to_string()) } +} + +fn led_mode_kind_to(k: LedModeKind) -> i32 { + match k { LedModeKind::Off => 0, LedModeKind::On => 1, LedModeKind::Blink => 2, LedModeKind::Breathe => 3 } +} +fn audio_kind_to(k: AudioKind) -> i32 { + match k { AudioKind::Process => 0, AudioKind::Mic => 1, AudioKind::Output => 2 } +} + +/// Snapshot a UI string-model into a Vec. +fn ui_list(model: &ModelRc) -> Vec { + (0..model.row_count()).filter_map(|i| model.row_data(i)).map(|s| s.to_string()).collect() +} + +/// Index of `target` within the live list for audio kind `audio` (-1 if absent). +fn list_index_of(ui: &AppWindow, audio: i32, target: &str) -> i32 { + if target.is_empty() { + return -1; + } + let list = match audio { + 1 => ui_list(&ui.get_live_mics()), + 2 => ui_list(&ui.get_live_outputs()), + _ => ui_list(&ui.get_live_processes()), + }; + list.iter().position(|x| x == target).map(|p| p as i32).unwrap_or(-1) +} + +fn led_mode_from_ui( + kind: i32, on: &str, off: &str, cycle: &str, + bmode: i32, bright: i32, baudio: i32, btarget: &str, +) -> LedMode { + let kind = match kind { + 1 => LedModeKind::On, + 2 => LedModeKind::Blink, + 3 => LedModeKind::Breathe, + _ => LedModeKind::Off, + }; + LedMode { + kind, + on_ms: on.trim().parse().unwrap_or(500).max(1), + off_ms: off.trim().parse().unwrap_or(500).max(1), + cycle_ms: cycle.trim().parse().unwrap_or(2000).max(1), + brightness_mode: if bmode == 1 { BrightnessMode::Volume } else { BrightnessMode::Fixed }, + brightness: bright.clamp(0, 100) as u8, + brightness_audio: audio_kind_from(baudio), + brightness_target: opt(btarget), + } +} + +fn cond_kind_from(v: i32) -> LedConditionKind { + match v { 1 => LedConditionKind::Volume, 2 => LedConditionKind::Api, _ => LedConditionKind::Muted } +} +fn audio_kind_from(v: i32) -> AudioKind { + match v { 1 => AudioKind::Mic, 2 => AudioKind::Output, _ => AudioKind::Process } +} +fn cmp_from(v: i32) -> Comparison { + match v { 1 => Comparison::Above, 2 => Comparison::Exact, _ => Comparison::Below } +} + +fn cond_to_ui(c: &LedCondition, target_index: i32) -> LedConditionUI { + LedConditionUI { + kind: match c.kind { LedConditionKind::Muted => 0, LedConditionKind::Volume => 1, LedConditionKind::Api => 2 }, + audio_kind: audio_kind_to(c.audio_kind), + target: c.target.clone().unwrap_or_default().into(), + target_index, + cmp: match c.cmp { Comparison::Below => 0, Comparison::Above => 1, Comparison::Exact => 2 }, + value: c.value as i32, + method: c.api.method.to_index(), + url: c.api.url.clone().into(), + payload: c.api.payload.clone().unwrap_or_default().into(), + bearer: c.api.bearer.clone().unwrap_or_default().into(), + interval: c.api.interval_ms.to_string().into(), + expect_body: c.api.expect_body.clone().unwrap_or_default().into(), + } +} + +/// Rebuild the conditions model, resolving each target's index within the live +/// lists already on the UI (so its dropdown shows the right selection). +fn push_led_conditions(ui: &AppWindow, conds: &[LedCondition]) { + let rows: Vec = conds + .iter() + .map(|c| { + let tidx = list_index_of(ui, audio_kind_to(c.audio_kind), c.target.as_deref().unwrap_or("")); + cond_to_ui(c, tidx) + }) + .collect(); + ui.set_led_conditions(ModelRc::new(VecModel::from(rows))); +} + +/// Push one LED appearance into the UI scalar props (active or inactive). +fn push_mode_to_ui(ui: &AppWindow, m: &LedMode, active: bool) { + let kind = led_mode_kind_to(m.kind); + let on: SharedString = m.on_ms.to_string().into(); + let off: SharedString = m.off_ms.to_string().into(); + let cycle: SharedString = m.cycle_ms.to_string().into(); + let bmode = if m.brightness_mode == BrightnessMode::Volume { 1 } else { 0 }; + let bright = m.brightness as i32; + let baudio = audio_kind_to(m.brightness_audio); + let btarget: SharedString = m.brightness_target.clone().unwrap_or_default().into(); + let btidx = list_index_of(ui, baudio, m.brightness_target.as_deref().unwrap_or("")); + if active { + ui.set_led_active_kind(kind); + ui.set_led_active_on(on); + ui.set_led_active_off(off); + ui.set_led_active_cycle(cycle); + ui.set_led_active_bmode(bmode); + ui.set_led_active_bright(bright); + ui.set_led_active_baudio(baudio); + ui.set_led_active_btarget(btarget); + ui.set_led_active_btidx(btidx); + } else { + ui.set_led_inactive_kind(kind); + ui.set_led_inactive_on(on); + ui.set_led_inactive_off(off); + ui.set_led_inactive_cycle(cycle); + ui.set_led_inactive_bmode(bmode); + ui.set_led_inactive_bright(bright); + ui.set_led_inactive_baudio(baudio); + ui.set_led_inactive_btarget(btarget); + ui.set_led_inactive_btidx(btidx); + } +} + +/// Apply `f` to condition `idx` of the working LED config (no UI re-push). +fn mutate_cond(shared: &Arc>, idx: i32, f: F) { + let mut s = shared.lock(); + if let Some((_, cfg)) = s.editing_led.as_mut() { + if let Some(c) = cfg.conditions.get_mut(idx as usize) { + f(c); + } + } +} + +/// Like [`mutate_cond`] but returns the updated condition list so the caller can +/// re-push the model (for structural changes that show/hide dependent fields). +fn mutate_cond_struct( + shared: &Arc>, + idx: i32, + f: F, +) -> Option> { + let mut s = shared.lock(); + let (_, cfg) = s.editing_led.as_mut()?; + if let Some(c) = cfg.conditions.get_mut(idx as usize) { + f(c); + } + Some(cfg.conditions.clone()) +} + +fn toast(ui: &AppWindow, msg: &str) { + ui.set_toast_text(msg.into()); + let weak = ui.as_weak(); + let timer = slint::Timer::default(); + timer.start(slint::TimerMode::SingleShot, Duration::from_millis(1800), move || { + if let Some(ui) = weak.upgrade() { + ui.set_toast_text("".into()); + } + }); + std::mem::forget(timer); +} diff --git a/src/keys.rs b/src/keys.rs index d32e204..b36b0f5 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -3,6 +3,12 @@ //! `hold_start(vk)` emits the initial key-down and, if the key is "tap-only" //! (media keys etc.), schedules repeats at the system repeat rate. Regular //! keys are held physically and rely on the OS auto-repeat. +//! +//! The wire format everywhere in Slidr (presets, `keys_library`, the board +//! protocol) is a **Windows Virtual-Key code**. That is only directly usable by +//! `enigo` on Windows — on Linux `Key::Other` is interpreted as an *X11 keysym*, +//! which shares numbering with VK codes for ASCII letters/digits and for nothing +//! else. So VK codes are translated at the enigo boundary; see `platform_key`. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -12,6 +18,29 @@ use std::time::Duration; use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings}; +/// Translate a Windows VK code into the key representation the platform's +/// `enigo` backend expects. `None` = no sensible mapping (caller skips the key). +#[cfg(target_os = "windows")] +fn platform_key(vk: u32) -> Option { + Some(Key::Other(vk)) +} + +/// On Linux `Key::Other(v)` is fed to `Keysym::from(v)`, so we must hand enigo +/// an X11 keysym, not a VK code. Without this, F-keys type letters, arrows type +/// punctuation and every media/modifier key is a silent no-op. +#[cfg(not(target_os = "windows"))] +fn platform_key(vk: u32) -> Option { + vk_to_keysym(vk).map(Key::Other) +} + +#[cfg(not(target_os = "windows"))] +use crate::keys_vk::vk_to_keysym; + + +/// Initial delay before a tap-only key starts repeating, and the repeat period. +const REPEAT_DELAY: Duration = Duration::from_millis(500); +const REPEAT_PERIOD: Duration = Duration::from_millis(33); + pub struct KeyController { enigo: Enigo, holds: HashMap>, @@ -19,6 +48,7 @@ pub struct KeyController { impl KeyController { pub fn new() -> anyhow::Result { + warn_if_no_x11(); let enigo = Enigo::new(&EnigoSettings::default()) .map_err(|e| anyhow::anyhow!("Enigo init failed: {e:?}"))?; Ok(Self { enigo, holds: HashMap::new() }) @@ -28,7 +58,10 @@ impl KeyController { if self.holds.contains_key(&vk) { return; } - let key = Key::Other(vk); + let Some(key) = platform_key(vk) else { + log::warn!("key {vk} (0x{vk:02X}) has no mapping on this platform; ignored"); + return; + }; if let Err(e) = self.enigo.key(key, Direction::Press) { log::warn!("key press {vk} failed: {e:?}"); return; @@ -41,17 +74,7 @@ impl KeyController { let _ = self.enigo.key(key, Direction::Release); let cancel = Arc::new(AtomicBool::new(false)); self.holds.insert(vk, cancel.clone()); - // Spawn a repeater. Conservative defaults: 500ms initial, 33ms repeat. - thread::spawn(move || { - thread::sleep(Duration::from_millis(500)); - while !cancel.load(Ordering::Relaxed) { - // We can't reuse `Enigo` here (not Send); spawn a fresh one. - if let Ok(mut e) = Enigo::new(&EnigoSettings::default()) { - let _ = e.key(Key::Other(vk), Direction::Click); - } - thread::sleep(Duration::from_millis(33)); - } - }); + spawn_repeater(vk, cancel); } else { self.holds.insert(vk, Arc::new(AtomicBool::new(false))); } @@ -61,9 +84,58 @@ impl KeyController { if let Some(cancel) = self.holds.remove(&vk) { cancel.store(true, Ordering::Relaxed); } - let key = Key::Other(vk); + let Some(key) = platform_key(vk) else { return }; if let Err(e) = self.enigo.key(key, Direction::Release) { log::debug!("key release {vk}: {e:?}"); } } } + +/// Repeat `vk` until cancelled. `Enigo` is not `Send`, so the thread builds its +/// own and **keeps** it for the whole hold — the previous version constructed a +/// fresh one per tick, which on Linux meant opening an X11 connection and +/// re-uploading a keymap 30x/s. +fn spawn_repeater(vk: u32, cancel: Arc) { + let _ = thread::Builder::new() + .name("slidr-key-repeat".into()) + .spawn(move || { + thread::sleep(REPEAT_DELAY); + if cancel.load(Ordering::Relaxed) { + return; + } + let Some(key) = platform_key(vk) else { return }; + let Ok(mut enigo) = Enigo::new(&EnigoSettings::default()) else { + log::warn!("key repeat {vk}: cannot open input connection"); + return; + }; + while !cancel.load(Ordering::Relaxed) { + if let Err(e) = enigo.key(key, Direction::Click) { + log::debug!("key repeat {vk} stopped: {e:?}"); + return; + } + thread::sleep(REPEAT_PERIOD); + } + }); +} + +/// enigo is built with the `x11rb` backend only, which reaches native X11 +/// clients and XWayland clients but *not* native Wayland clients. Say so once, +/// loudly, instead of letting key actions silently do nothing. +#[cfg(not(target_os = "windows"))] +fn warn_if_no_x11() { + let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false); + if wayland { + log::warn!( + "Wayland session detected: simulated keys are delivered over XTEST and will only \ + reach XWayland (X11) windows, not native Wayland windows. Log in with an X11/Xorg \ + session if you need key actions to work everywhere." + ); + } + if std::env::var_os("DISPLAY").is_none() { + log::warn!("$DISPLAY is unset; key actions will be unavailable"); + } +} + +#[cfg(target_os = "windows")] +fn warn_if_no_x11() {} diff --git a/src/keys_vk.rs b/src/keys_vk.rs new file mode 100644 index 0000000..8e397a2 --- /dev/null +++ b/src/keys_vk.rs @@ -0,0 +1,130 @@ +//! Windows Virtual-Key -> X11 keysym lookup. +//! +//! Split out of `keys.rs` and compiled on every platform (not just Linux) so +//! the table is unit-testable anywhere, including on the Windows CI runner. +//! `keys.rs` only consults it on non-Windows targets. + +#![allow(dead_code)] // unused on Windows, where VK codes are passed through + +/// Windows Virtual-Key → X11 keysym (`keysymdef.h` / `XF86keysym.h`). +pub fn vk_to_keysym(vk: u32) -> Option { + // Letters map to the *lowercase* keysym: VK 0x41 ('A') on Windows produces + // an unshifted keypress, and the X11 uppercase keysym would imply Shift. + if (0x41..=0x5A).contains(&vk) { + return Some(vk + 0x20); + } + // Digits and space share numbering with ASCII/keysyms. + if (0x30..=0x39).contains(&vk) || vk == 0x20 { + return Some(vk); + } + // F1..F24 -> XK_F1..XK_F24 (contiguous in both numberings). + if (0x70..=0x87).contains(&vk) { + return Some(0xFFBE + (vk - 0x70)); + } + // Numpad 0..9 -> XK_KP_0..XK_KP_9. + if (0x60..=0x69).contains(&vk) { + return Some(0xFFB0 + (vk - 0x60)); + } + Some(match vk { + 0x08 => 0xFF08, // Backspace + 0x09 => 0xFF09, // Tab + 0x0D => 0xFF0D, // Return + 0x10 => 0xFFE1, // Shift_L + 0x11 => 0xFFE3, // Control_L + 0x12 => 0xFFE9, // Alt_L + 0x13 => 0xFF13, // Pause + 0x14 => 0xFFE5, // Caps_Lock + 0x1B => 0xFF1B, // Escape + 0x21 => 0xFF55, // Prior (Page Up) + 0x22 => 0xFF56, // Next (Page Down) + 0x23 => 0xFF57, // End + 0x24 => 0xFF50, // Home + 0x25 => 0xFF51, // Left + 0x26 => 0xFF52, // Up + 0x27 => 0xFF53, // Right + 0x28 => 0xFF54, // Down + 0x2C => 0xFF61, // Print + 0x2D => 0xFF63, // Insert + 0x2E => 0xFFFF, // Delete + 0x5B => 0xFFEB, // Super_L + 0x5C => 0xFFEC, // Super_R + 0x5D => 0xFF67, // Menu + 0x6A => 0xFFAA, // KP_Multiply + 0x6B => 0xFFAB, // KP_Add + 0x6C => 0xFFAC, // KP_Separator + 0x6D => 0xFFAD, // KP_Subtract + 0x6E => 0xFFAE, // KP_Decimal + 0x6F => 0xFFAF, // KP_Divide + 0x90 => 0xFF7F, // Num_Lock + 0x91 => 0xFF14, // Scroll_Lock + 0xA0 => 0xFFE1, // Shift_L + 0xA1 => 0xFFE2, // Shift_R + 0xA2 => 0xFFE3, // Control_L + 0xA3 => 0xFFE4, // Control_R + 0xA4 => 0xFFE9, // Alt_L + 0xA5 => 0xFFEA, // Alt_R + // Browser keys (XF86) + 0xA6 => 0x1008FF26, // Back + 0xA7 => 0x1008FF27, // Forward + 0xA8 => 0x1008FF73, // Reload + 0xA9 => 0x1008FF28, // Stop + 0xAA => 0x1008FF1B, // Search + 0xAB => 0x1008FF30, // Favorites + 0xAC => 0x1008FF18, // HomePage + // Media keys (XF86) + 0xAD => 0x1008FF12, // AudioMute + 0xAE => 0x1008FF11, // AudioLowerVolume + 0xAF => 0x1008FF13, // AudioRaiseVolume + 0xB0 => 0x1008FF17, // AudioNext + 0xB1 => 0x1008FF16, // AudioPrev + 0xB2 => 0x1008FF15, // AudioStop + 0xB3 => 0x1008FF14, // AudioPlay + 0xB4 => 0x1008FF19, // Mail + 0xB5 => 0x1008FF32, // AudioMedia + 0xB6 => 0x1008FF40, // Launch0 + 0xB7 => 0x1008FF41, // Launch1 + // OEM punctuation (US layout, matching the labels in `keys_library`). + 0xBA => 0x003B, // ; + 0xBB => 0x003D, // = + 0xBC => 0x002C, // , + 0xBD => 0x002D, // - + 0xBE => 0x002E, // . + 0xBF => 0x002F, // slash + 0xC0 => 0x0060, // grave + 0xDB => 0x005B, // [ + 0xDC => 0x005C, // backslash + 0xDD => 0x005D, // ] + 0xDE => 0x0027, // apostrophe + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::vk_to_keysym; + use crate::keys_library::KEYS; + + #[test] + fn every_library_key_maps_to_a_keysym() { + for k in KEYS { + assert!( + vk_to_keysym(k.vk).is_some(), + "no keysym for {} (VK 0x{:02X})", + k.name, + k.vk + ); + } + } + + #[test] + fn known_mappings() { + assert_eq!(vk_to_keysym(0x41), Some(0x0061)); // A -> 'a' + assert_eq!(vk_to_keysym(0x70), Some(0xFFBE)); // F1 + assert_eq!(vk_to_keysym(0x87), Some(0xFFD5)); // F24 + assert_eq!(vk_to_keysym(0x0D), Some(0xFF0D)); // Return + assert_eq!(vk_to_keysym(0x25), Some(0xFF51)); // Left + assert_eq!(vk_to_keysym(0xAE), Some(0x1008FF11)); // Volume Down + assert_eq!(vk_to_keysym(0x69), Some(0xFFB9)); // Numpad 9 + assert_eq!(vk_to_keysym(0x07), None); // unassigned + } +} diff --git a/src/main.rs b/src/main.rs index 2a1e413..cb508fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod events; mod glue; mod keys; mod keys_library; +mod keys_vk; mod led; mod model; mod protocol; @@ -60,6 +61,19 @@ fn main() -> anyhow::Result<()> { let dark = args.iter().any(|a| a == "--dark"); // Autostart-minimized passes --hidden so we start in the tray. let start_hidden = args.iter().any(|a| a == "--hidden"); + // ...but only where there *is* a tray. Without one, starting hidden leaves a + // process with no window and no way to open one - `on_close_requested` never + // fires either, so the only exit is `kill`. Honour the flag on Windows only. + #[cfg(not(target_os = "windows"))] + let start_hidden = { + if start_hidden { + log::warn!( + "--hidden ignored: this build has no system tray, so a hidden window could \ + not be reopened. Showing the window instead." + ); + } + false + }; // Lock the main thread to an STA apartment before *any* COM-using crate // (WASAPI, winit's OleInitialize) gets a chance. @@ -101,6 +115,11 @@ fn main() -> anyhow::Result<()> { }); settings.active_preset = preset.name.clone(); + // Repair a stale autostart entry (the executable moved since it was set up). + if settings.start_with_os { + autostart::resync(settings.start_minimized); + } + let shared = Arc::new(Mutex::new(glue::Shared { settings, preset, diff --git a/src/serial.rs b/src/serial.rs index 8b41f1f..d8a40f0 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -171,16 +171,71 @@ pub fn spawn(tx: Sender) -> (RetryKicker, SerialLink) { fn friendly(err: &str) -> String { // Translate the common platform error strings to something a human can act on. + // + // serialport's `Display` is the underlying `io::Error`'s message, so on Linux + // these are the libc strings ("Permission denied (os error 13)"), not the + // Windows wording — both spellings have to be matched here. let l = err.to_lowercase(); - if l.contains("zugriff verweigert") || l.contains("access is denied") || l.contains("permissiondenied") { - "Access denied — the port is held by another program (or you lack permission)".into() - } else if l.contains("nicht gefunden") || l.contains("not found") || l.contains("nosuchdevice") { + if l.contains("zugriff verweigert") + || l.contains("access is denied") + || l.contains("permission denied") + || l.contains("permissiondenied") + || l.contains("os error 13") + { + if cfg!(target_os = "linux") { + // By far the most common Linux first-run failure: the user is not in + // the group that owns /dev/ttyUSB*, so name the fix. + "Access denied — add yourself to the 'dialout' group \ + (sudo usermod -aG dialout $USER, then log out and back in), \ + or the port is held by another program" + .into() + } else { + "Access denied — the port is held by another program (or you lack permission)".into() + } + } else if l.contains("nicht gefunden") + || l.contains("not found") + || l.contains("no such file or directory") + || l.contains("nosuchdevice") + || l.contains("os error 2") + || l.contains("os error 19") + { "Device disappeared".into() } else { err.to_string() } } +#[cfg(test)] +mod tests { + use super::friendly; + + #[test] + fn linux_permission_denied_is_translated() { + let msg = friendly("Permission denied (os error 13)"); + assert!(msg.starts_with("Access denied"), "got: {msg}"); + if cfg!(target_os = "linux") { + assert!(msg.contains("dialout"), "Linux hint missing: {msg}"); + } + } + + #[test] + fn linux_missing_device_is_translated() { + assert_eq!(friendly("No such file or directory (os error 2)"), "Device disappeared"); + assert_eq!(friendly("No such device (os error 19)"), "Device disappeared"); + } + + #[test] + fn windows_wording_still_matches() { + assert!(friendly("Access is denied.").starts_with("Access denied")); + assert!(friendly("Zugriff verweigert").starts_with("Access denied")); + } + + #[test] + fn unknown_errors_pass_through() { + assert_eq!(friendly("Broken pipe (os error 32)"), "Broken pipe (os error 32)"); + } +} + fn run_session(port_name: &str, tx: &Sender, link: &SerialLink) -> anyhow::Result<()> { let mut sp = serialport::new(port_name, BAUD) .timeout(Duration::from_millis(100)) diff --git a/ui/app.slint b/ui/app.slint index 1249073..036ef1c 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1048,6 +1048,10 @@ component CategoriesPage inherits VerticalBox { } component SettingsPage inherits VerticalBox { + // False on platforms without a system tray (currently everything but + // Windows): the tray-related rows are hidden rather than offering a toggle + // that does nothing - or, for "start minimized", strands the app headless. + in property has-tray; in-out property theme-index; in-out property accent-preset; in-out property dead-zone; @@ -1210,7 +1214,7 @@ component SettingsPage inherits VerticalBox { VerticalLayout { alignment: center; Toggle { checked <=> root.start-with-os; toggled => { root.dirty(); } } } } } - SettingRow { label: "Start minimized to tray"; + if root.has-tray: SettingRow { label: "Start minimized to tray"; description: root.start-with-os ? "" : "Enable 'Start with OS' to enable this"; HorizontalLayout { width: root.control-w; alignment: end; @@ -1218,7 +1222,7 @@ component SettingsPage inherits VerticalBox { Toggle { checked <=> root.start-minimized; enabled: root.start-with-os; toggled => { root.dirty(); } } } } } - SettingRow { label: "Minimize to system tray"; + if root.has-tray: SettingRow { label: "Minimize to system tray"; HorizontalLayout { width: root.control-w; alignment: end; VerticalLayout { alignment: center; Toggle { checked <=> root.minimize-to-tray; @@ -2107,6 +2111,8 @@ export component AppWindow inherits Window { in-out property start-with-os; in-out property start-minimized; in-out property minimize-to-tray; + // Set from Rust: true only where a tray actually exists (Windows). + in property has-tray; in-out property curve-preset-index; in property curve-editable; in-out property curve-x1; @@ -2443,6 +2449,7 @@ export component AppWindow inherits Window { start-with-os <=> root.start-with-os; start-minimized <=> root.start-minimized; minimize-to-tray <=> root.minimize-to-tray; + has-tray: root.has-tray; curve-preset-index <=> root.curve-preset-index; curve-editable: root.curve-editable; curve-x1 <=> root.curve-x1; From 66165fa50e005be7c4c4422bcc926fe190be30b7 Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 12 Sep 2026 13:13:40 +0200 Subject: [PATCH 2/2] fix: write the NUL char literal as an escape, not a raw NUL byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `untruncate_comm` splits /proc//cmdline on NUL. The literal was written into the source as an actual 0x00 byte rather than the two-character `\0` escape. Rust accepts both, so it compiled and the tests passed — but a NUL byte makes git and grep classify glue.rs as binary, which turns any merge touching it into a whole-file conflict instead of a three-way merge. Co-Authored-By: Claude Opus 5 (1M context) --- src/glue.rs | 3240 +++++++++++++++++++++++++-------------------------- 1 file changed, 1620 insertions(+), 1620 deletions(-) diff --git a/src/glue.rs b/src/glue.rs index 1b10075..8acf397 100644 --- a/src/glue.rs +++ b/src/glue.rs @@ -1,1620 +1,1620 @@ -//! Bridge between the Slint UI and the Rust core. -//! -//! `wire()` attaches every callback and starts a polling timer that drains -//! [`SerialEvent`]s from the worker thread into Slint model properties. - -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use crossbeam_channel::Receiver; -use parking_lot::Mutex; -use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel}; - -use crate::audio::AudioBackend; -use crate::events::EventEngine; -use crate::model::{ - led_for_button, next_id, ActionKind, ApiCall, AudioKind, AudioStream, BrightnessMode, - ButtonAction, ButtonCategory, CombineLogic, Comparison, HttpMethod, LedCondition, - LedConditionKind, LedConfig, LedControl, LedMode, LedModeKind, Preset, Settings, SliderCategory, - ThemeMode, -}; -use crate::protocol::{Frame, NUM_BUTTONS, NUM_SLIDERS}; -use crate::serial::{RetryKicker, SerialEvent}; -use crate::{ - AppWindow, AssignPick, ButtonCell, CategorySummary, LedConditionUI, LineItem, PickerEntry, - SliderCell, WizardResult, -}; - -pub struct Shared { - pub settings: Settings, - pub preset: Preset, - pub engine: EventEngine, - pub audio: Arc, - pub cmd_tx: crossbeam_channel::Sender, - pub kicker: RetryKicker, - /// Live LED state published by the actuator (for the configure-LED popup). - pub led_state: crate::led::LedStateHandle, - pub pending_assign: Option<(i32, i32)>, - pub pending_wizard: Option<(i32, u32)>, - pub pending_retry_deadline: Option, - /// When editing an existing category entry, the index being replaced. - pub editing_idx: Option, - /// Working copy for the configure-LED popup: (led index, config being edited). - pub editing_led: Option<(usize, LedConfig)>, -} - -impl Shared { - /// Push the full LED configuration set + brightness + experimental flag to - /// the actuator's LED engine. Call after any change to LED config/settings. - pub fn push_leds(&self) { - let _ = self.cmd_tx.send(crate::events::Cmd::SetLedExperimental(self.settings.led_experimental)); - let _ = self - .cmd_tx - .send(crate::events::Cmd::SetLeds(Box::new(self.preset.leds.clone()))); - } -} - -pub fn wire( - ui: &AppWindow, - shared: Arc>, - rx: Receiver, - build_stamp: &'static str, -) { - ui.set_build_stamp(SharedString::from(build_stamp)); - - // Push initial state from settings/preset → UI. - { - let s = shared.lock(); - push_settings_to_ui(ui, &s.settings, &s.preset.settings); - push_preset_to_ui(ui, &s.preset); - apply_appearance(ui, &s.preset.settings); - s.push_leds(); - } - - // Empty initial slider/button cells. - push_cells(ui, &[0; NUM_SLIDERS], &[0; NUM_BUTTONS], &shared.lock().preset); - ui.set_connection_msg("Searching for Slidr device…".into()); - ui.set_retry_eta_secs(-1.0); - ui.set_connected(false); - - // ─── callbacks ───────────────────────────────────────────────────────── - { - let shared = shared.clone(); - ui.on_retry_now(move || shared.lock().kicker.kick()); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_open_assign(move |kind, idx| { - let s = shared.lock(); - let options: Vec = if kind == 0 { - s.preset.slider_categories.iter().map(|c| AssignPick { - id: c.id as i32, name: c.name.clone().into(), - }).collect() - } else { - s.preset.button_categories.iter().map(|c| AssignPick { - id: c.id as i32, name: c.name.clone().into(), - }).collect() - }; - drop(s); - if let Some(ui) = weak.upgrade() { - ui.set_assign_options(ModelRc::new(VecModel::from(options))); - ui.set_assign_target_kind(kind); - ui.set_assign_target_index(idx); - ui.set_assign_popup_open(true); - } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_apply_assign(move |chosen_id| { - let Some(ui) = weak.upgrade() else { return }; - let target_kind = ui.get_assign_target_kind(); - let idx = ui.get_assign_target_index() as usize; - let chosen = if chosen_id < 0 { None } else { Some(chosen_id as u32) }; - let mut s = shared.lock(); - if target_kind == 0 && idx < NUM_SLIDERS { - s.preset.assignments.sliders[idx] = chosen; - } else if target_kind == 1 && idx < NUM_BUTTONS { - s.preset.assignments.buttons[idx] = chosen; - } - let _ = crate::storage::save_preset(&s.preset); - push_preset_to_ui(&ui, &s.preset); - let live = s.engine.state().clone(); - push_cells(&ui, &live.sliders, &live.buttons, &s.preset); - }); - } - - // ─── category CRUD ───────────────────────────────────────────────────── - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_add_category(move |kind, name| { - if name.is_empty() { return } - let mut s = shared.lock(); - if kind == 0 { - let id = next_id(&s.preset.slider_categories, |c| c.id); - s.preset.slider_categories.push(SliderCategory { id, name: name.to_string(), streams: vec![], collapsed: false }); - } else { - let id = next_id(&s.preset.button_categories, |c| c.id); - s.preset.button_categories.push(ButtonCategory { id, name: name.to_string(), actions: vec![], collapsed: false }); - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_create_preset(move |name| { - if name.is_empty() { return } - let new_preset = Preset { - id: 1, - name: name.to_string(), - ..Default::default() - }; - if crate::storage::save_preset(&new_preset).is_ok() { - let mut s = shared.lock(); - s.preset = new_preset; - s.settings.active_preset = name.to_string(); - let _ = crate::storage::save_settings(&s.settings); - if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &s.preset); - push_settings_to_ui(&ui, &s.settings, &s.preset.settings); - apply_appearance(&ui, &s.preset.settings); - s.push_leds(); - refresh_home(&ui, &s); - toast(&ui, &format!("Created profile: {name}")); - } - } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_rename_category(move |kind, id, name| { - let mut s = shared.lock(); - if kind == 0 { - if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id as i32 == id) { - c.name = name.to_string(); - } - } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id as i32 == id) { - c.name = name.to_string(); - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - refresh_home(&ui, &s); // update Home preview names without a board input - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_delete_category(move |kind, id| { - let mut s = shared.lock(); - let id = id as u32; - if kind == 0 { - s.preset.slider_categories.retain(|c| c.id != id); - for slot in s.preset.assignments.sliders.iter_mut() { - if *slot == Some(id) { *slot = None } - } - } else { - s.preset.button_categories.retain(|c| c.id != id); - for slot in s.preset.assignments.buttons.iter_mut() { - if *slot == Some(id) { *slot = None } - } - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - refresh_home(&ui, &s); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_delete_line(move |kind, cat_id, idx| { - let mut s = shared.lock(); - let cat_id = cat_id as u32; - let idx = idx as usize; - if kind == 0 { - if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == cat_id) { - if idx < c.streams.len() { c.streams.remove(idx); } - } - } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == cat_id) { - if idx < c.actions.len() { c.actions.remove(idx); } - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - }); - } - - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_toggle_category_collapse(move |kind, id| { - let mut s = shared.lock(); - let id = id as u32; - if kind == 0 { - if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == id) { - c.collapsed = !c.collapsed; - } - } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == id) { - c.collapsed = !c.collapsed; - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - // Reorder category cards (cosmetic, but persisted via Vec order). - ui.on_reorder_category(move |kind, from_id, to_index| { - let mut s = shared.lock(); - let from_id = from_id as u32; - let to = to_index.max(0) as usize; - if kind == 0 { - reorder_by_id(&mut s.preset.slider_categories, from_id, to, |c| c.id); - } else { - reorder_by_id(&mut s.preset.button_categories, from_id, to, |c| c.id); - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - refresh_home(&ui, &s); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - // Move an action/stream within or between categories (same target-kind). - ui.on_move_line(move |kind, from_cat, from_idx, to_cat, to_idx| { - let mut s = shared.lock(); - let (from_cat, to_cat) = (from_cat as u32, to_cat as u32); - let (from_idx, to_idx) = (from_idx.max(0) as usize, to_idx.max(0) as usize); - if kind == 0 { - move_line(&mut s.preset.slider_categories, from_cat, from_idx, to_cat, to_idx, - |c| c.id, |c| &mut c.streams); - } else { - move_line(&mut s.preset.button_categories, from_cat, from_idx, to_cat, to_idx, - |c| c.id, |c| &mut c.actions); - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - }); - } - - // ─── wizard ──────────────────────────────────────────────────────────── - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_open_wizard(move |kind, cat_id| { - let Some(ui) = weak.upgrade() else { return }; - { - let mut s = shared.lock(); - s.pending_wizard = Some((kind, cat_id as u32)); - s.editing_idx = None; - } - ui.set_wizard_target_kind(kind); - ui.set_wizard_category_id(cat_id); - ui.set_wizard_step(0); - ui.set_wizard_kind(0); - ui.set_wizard_property("".into()); - ui.set_wizard_display("".into()); - reset_wizard_api(&ui); - populate_live_lists(&ui, shared.clone()); - ui.set_wizard_filter("".into()); - push_wizard_picker(&ui, shared.clone()); - ui.set_wizard_open(true); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_edit_line(move |target_kind, cat_id, idx| { - let Some(ui) = weak.upgrade() else { return }; - let idx = idx as usize; - let cat_id = cat_id as u32; - // Resolve existing entry → wizard kind/property/display (+ optional API). - let (wkind, prop, disp, api) = { - let s = shared.lock(); - if target_kind == 0 { - let Some(c) = s.preset.slider_categories.iter().find(|c| c.id == cat_id) else { return }; - let Some(stream) = c.streams.get(idx) else { return }; - if let Some(api) = &stream.api { (3, String::new(), String::new(), Some(api.clone())) } - else if let Some(p) = &stream.process { (0, p.clone(), p.clone(), None) } - else if let Some(m) = &stream.mic_name { (1, m.clone(), m.clone(), None) } - else { (2, stream.device_name.clone().unwrap_or_default(), - stream.device_name.clone().unwrap_or_default(), None) } - } else { - let Some(c) = s.preset.button_categories.iter().find(|c| c.id == cat_id) else { return }; - let Some(a) = c.actions.get(idx) else { return }; - let k = action_kind_index(a.kind); - let prop = a.property.clone().unwrap_or_default(); - // For KeyPress, show the friendly name in the picker. - let shown = if a.kind == ActionKind::KeyPress { - prop.parse::().ok() - .and_then(crate::keys_library::label_for_vk) - .map(|s| s.to_string()) - .unwrap_or_else(|| a.display.clone().unwrap_or(prop.clone())) - } else { - prop.clone() - }; - (k, shown, a.display.clone().unwrap_or_default(), a.api.clone()) - } - }; - shared.lock().editing_idx = Some(idx); - ui.set_wizard_target_kind(target_kind); - ui.set_wizard_category_id(cat_id as i32); - ui.set_wizard_kind(wkind); - ui.set_wizard_step(1); // jump straight to the target step - ui.set_wizard_property(prop.into()); - ui.set_wizard_display(disp.into()); - push_wizard_api(&ui, api.as_ref()); - populate_live_lists(&ui, shared.clone()); - ui.set_wizard_filter("".into()); - push_wizard_picker(&ui, shared.clone()); - ui.set_wizard_open(true); - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_refresh_live_lists(move || { - let Some(ui) = weak.upgrade() else { return }; - populate_live_lists(&ui, shared.clone()); - push_wizard_picker(&ui, shared.clone()); - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_wizard_state_changed(move || { - let Some(ui) = weak.upgrade() else { return }; - push_wizard_picker(&ui, shared.clone()); - }); - } - { - let weak = ui.as_weak(); - ui.on_browse_file(move || { - let path = rfd::FileDialog::new().pick_file(); - if let Some(p) = path { - if let Some(ui) = weak.upgrade() { - let s = p.display().to_string(); - ui.set_wizard_property(s.clone().into()); - let name = p.file_stem().map(|s| s.to_string_lossy().to_string()) - .unwrap_or(s.clone()); - ui.set_wizard_display(name.into()); - } - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - // Multi-select picker (Cycle output): toggle a device in/out of the - // newline-separated set held in the wizard property. - ui.on_wizard_toggle_pick(move |name| { - let Some(ui) = weak.upgrade() else { return }; - let name = name.to_string(); - let mut list: Vec = ui - .get_wizard_property() - .to_string() - .lines() - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); - if let Some(pos) = list.iter().position(|n| n == &name) { - list.remove(pos); - } else { - list.push(name); - } - ui.set_wizard_property(list.join("\n").into()); - push_wizard_picker(&ui, shared.clone()); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_wizard_finish(move |result| { - let mut s = shared.lock(); - let editing = s.editing_idx.take(); - let cat_id = result.category_id as u32; - if result.target_kind == 0 { - if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == cat_id) { - let stream = match result.kind { - 0 => AudioStream { process: Some(result.property.into()), ..Default::default() }, - 1 => AudioStream { mic_name: Some(result.property.into()), ..Default::default() }, - 2 => AudioStream { device_name: Some(result.property.into()), ..Default::default() }, - _ => AudioStream { api: Some(api_from_result(&result)), ..Default::default() }, - }; - match editing { - Some(i) if i < c.streams.len() => c.streams[i] = stream, - _ => c.streams.push(stream), - } - } - } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == cat_id) { - let kind = button_kind_from_index(result.kind); - if kind == ActionKind::ApiCall { - let action = ButtonAction { - kind, property: None, display: None, - api: Some(api_from_result(&result)), - }; - match editing { - Some(i) if i < c.actions.len() => c.actions[i] = action, - _ => c.actions.push(action), - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); - return; - } - // For KeyPress, picker stores the friendly name; resolve to VK code. - let (prop, disp) = if kind == ActionKind::KeyPress { - let name = result.property.to_string(); - let vk = crate::keys_library::KEYS.iter() - .find(|k| k.name.eq_ignore_ascii_case(&name)) - .map(|k| k.vk) - .or_else(|| name.parse::().ok()) - .unwrap_or(0); - (Some(vk.to_string()), Some(name)) - } else if kind == ActionKind::CycleOutput { - // Property is the newline-separated device list (empty = all). - // The human-readable label is derived at render time - // (`action_secondary`), so no display is stored here. - let list = result.property.to_string(); - let has_any = list.lines().any(|s| !s.is_empty()); - (has_any.then_some(list), None) - } else { - let prop = if result.property.is_empty() { None } else { Some(result.property.to_string()) }; - let disp = if result.display.is_empty() { None } else { Some(result.display.to_string()) }; - (prop, disp) - }; - let action = ButtonAction { kind, property: prop, display: disp, api: None }; - match editing { - Some(i) if i < c.actions.len() => c.actions[i] = action, - _ => c.actions.push(action), - } - } - let _ = crate::storage::save_preset(&s.preset); - let Some(ui) = weak.upgrade() else { return }; - push_preset_to_ui(&ui, &s.preset); - toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); - }); - } - - // ─── settings ────────────────────────────────────────────────────────── - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_theme_changed(move |idx| { - let mode = match idx { - 1 => ThemeMode::Light, 2 => ThemeMode::Dark, _ => ThemeMode::System, - }; - { - let mut s = shared.lock(); - s.preset.settings.theme = mode; - let _ = crate::storage::save_preset(&s.preset); - } - if let Some(ui) = weak.upgrade() { - apply_color_scheme(&ui, mode); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_curve_preset_changed(move |idx| { - let mut s = shared.lock(); - let preset = index_to_curve_preset(idx); - s.preset.settings.curve_preset = preset; - // Custom starts from a gentle default each time it's freshly selected. - if matches!(preset, crate::curve::CurvePreset::Custom) { - s.preset.settings.custom_curve = crate::curve::BezierPoints::CUSTOM_DEFAULT; - } - let _ = crate::storage::save_preset(&s.preset); - if let Some(ui) = weak.upgrade() { - push_curve_to_ui(&ui, &s.preset.settings); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_curve_changed(move || { - let Some(ui) = weak.upgrade() else { return }; - let mut s = shared.lock(); - s.preset.settings.custom_curve = crate::curve::BezierPoints { - x1: ui.get_curve_x1(), y1: ui.get_curve_y1(), - x2: ui.get_curve_x2(), y2: ui.get_curve_y2(), - }; - s.preset.settings.curve_preset = crate::curve::CurvePreset::Custom; - let _ = crate::storage::save_preset(&s.preset); - }); - } - { - ui.on_exit_app(|| slint::quit_event_loop().ok().unwrap_or(())); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_rename_preset(move |old, new| { - if old.is_empty() || new.is_empty() || old == new { return } - // Read the old preset, write under new name, delete old. - if let Ok(mut p) = crate::storage::load_preset(&old) { - p.name = new.to_string(); - if crate::storage::save_preset(&p).is_ok() { - let _ = crate::storage::delete_preset(&old); - let mut s = shared.lock(); - if s.settings.active_preset == old.as_str() { - s.settings.active_preset = new.to_string(); - let _ = crate::storage::save_settings(&s.settings); - } - if s.preset.name == old.as_str() { - s.preset.name = new.to_string(); - } - if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &s.preset); - toast(&ui, &format!("Renamed to {new}")); - } - } - } - }); - } - { - let weak = ui.as_weak(); - ui.on_export_preset(move |name| { - let src = crate::storage::presets_dir().join(name.to_string()).join("preset.json"); - let dest = rfd::FileDialog::new() - .set_file_name(format!("slidr-preset-{name}.json")) - .add_filter("Slidr preset", &["json"]) - .save_file(); - let Some(dest) = dest else { return }; - match std::fs::copy(&src, &dest) { - Ok(_) => if let Some(ui) = weak.upgrade() { - toast(&ui, &format!("Exported to {}", dest.display())); - }, - Err(e) => if let Some(ui) = weak.upgrade() { - toast(&ui, &format!("Export failed: {e}")); - }, - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_import_preset(move || { - let Some(path) = rfd::FileDialog::new() - .add_filter("Slidr preset", &["json"]) - .pick_file() else { return }; - let Ok(data) = std::fs::read_to_string(&path) else { return }; - let Ok(mut preset) = serde_json::from_str::(&data) else { - if let Some(ui) = weak.upgrade() { toast(&ui, "Import failed: invalid preset file"); } - return; - }; - // Ensure a unique, non-empty name (avoid clobbering an existing one). - if preset.name.trim().is_empty() { - preset.name = path.file_stem().map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "Imported".into()); - } - let existing = crate::storage::list_presets(); - let base = preset.name.clone(); - let mut n = 1; - while existing.iter().any(|e| e == &preset.name) { - n += 1; - preset.name = format!("{base} ({n})"); - } - match crate::storage::save_preset(&preset) { - Ok(()) => if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &shared.lock().preset); - toast(&ui, &format!("Imported profile: {}", preset.name)); - }, - Err(e) => if let Some(ui) = weak.upgrade() { - toast(&ui, &format!("Import failed: {e}")); - }, - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_save_settings(move || { - let Some(ui) = weak.upgrade() else { return }; - let mut s = shared.lock(); - // System → global settings; appearance/sliders → active profile. - pull_global_from_ui(&ui, &mut s.settings); - pull_profile_from_ui(&ui, &mut s.preset.settings); - let _ = crate::storage::save_settings(&s.settings); - let _ = crate::storage::save_preset(&s.preset); - let _ = crate::autostart::set_enabled(s.settings.start_with_os, s.settings.start_minimized); - // Live-apply appearance (accent / theme) immediately. - apply_appearance(&ui, &s.preset.settings); - // LED brightness / experimental flag may have changed. - s.push_leds(); - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_save_preset(move || { - let s = shared.lock(); - let result = crate::storage::save_preset(&s.preset); - if let Some(ui) = weak.upgrade() { - match result { - Ok(()) => toast(&ui, "Preset saved."), - Err(e) => toast(&ui, &format!("Save failed: {e}")), - } - push_preset_to_ui(&ui, &s.preset); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_load_preset(move |name| { - let Ok(p) = crate::storage::load_preset(&name) else { return }; - let mut s = shared.lock(); - s.preset = p; - s.settings.active_preset = s.preset.name.clone(); - let _ = crate::storage::save_settings(&s.settings); - if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &s.preset); - push_settings_to_ui(&ui, &s.settings, &s.preset.settings); - apply_appearance(&ui, &s.preset.settings); - s.push_leds(); - // Refresh Home immediately with the new assignments using the - // last live readings — no board input required. - refresh_home(&ui, &s); - toast(&ui, &format!("Switched to profile: {name}")); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_delete_preset(move |name| { - let _ = crate::storage::delete_preset(&name); - if let Some(ui) = weak.upgrade() { - push_preset_to_ui(&ui, &shared.lock().preset); - toast(&ui, &format!("Deleted profile: {name}")); - } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_set_active_preset(move |name| { - let mut s = shared.lock(); - s.settings.active_preset = name.to_string(); - let _ = crate::storage::save_settings(&s.settings); - if let Some(ui) = weak.upgrade() { - push_settings_to_ui(&ui, &s.settings, &s.preset.settings); - } - }); - } - - // ─── LED state persistence (board EEPROM) ────────────────────────────── - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_save_state(move || { - shared.lock().cmd_tx.send(crate::events::Cmd::LedSaveState).ok(); - if let Some(ui) = weak.upgrade() { - toast(&ui, if ui.get_connected() { "Saved LED state to the board." } - else { "Not connected — connect the board first." }); - } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_clear_state(move || { - shared.lock().cmd_tx.send(crate::events::Cmd::LedClearSaved).ok(); - if let Some(ui) = weak.upgrade() { - toast(&ui, if ui.get_connected() { "Cleared saved LED state (all off)." } - else { "Not connected — connect the board first." }); - } - }); - } - - // ─── configure LED ───────────────────────────────────────────────────── - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_open_led_config(move |btn| { - let Some(led) = led_for_button(btn as usize) else { return }; - let cfg = { - let mut s = shared.lock(); - let cfg = s.preset.leds[led].clone().unwrap_or_default(); - s.editing_led = Some((led, cfg.clone())); - cfg - }; - let Some(ui) = weak.upgrade() else { return }; - ui.set_led_index(led as i32); - ui.set_led_control(if cfg.control == LedControl::Manual { 1 } else { 0 }); - ui.set_led_combine(if cfg.combine == CombineLogic::All { 1 } else { 0 }); - // Populate the device lists first so target indices resolve. - populate_live_lists(&ui, shared.clone()); - push_mode_to_ui(&ui, &cfg.active, true); - push_mode_to_ui(&ui, &cfg.inactive, false); - push_led_conditions(&ui, &cfg.conditions); - ui.set_led_live_active(false); - ui.set_led_popup_open(true); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_cancel(move || { - shared.lock().editing_led = None; - if let Some(ui) = weak.upgrade() { ui.set_led_popup_open(false); } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_save(move || { - let Some(ui) = weak.upgrade() else { return }; - let mut s = shared.lock(); - let Some((led, mut cfg)) = s.editing_led.take() else { return }; - cfg.control = if ui.get_led_control() == 1 { LedControl::Manual } else { LedControl::Conditional }; - cfg.combine = if ui.get_led_combine() == 1 { CombineLogic::All } else { CombineLogic::Any }; - cfg.active = led_mode_from_ui( - ui.get_led_active_kind(), - ui.get_led_active_on().as_str(), - ui.get_led_active_off().as_str(), - ui.get_led_active_cycle().as_str(), - ui.get_led_active_bmode(), - ui.get_led_active_bright(), - ui.get_led_active_baudio(), - ui.get_led_active_btarget().as_str(), - ); - cfg.inactive = led_mode_from_ui( - ui.get_led_inactive_kind(), - ui.get_led_inactive_on().as_str(), - ui.get_led_inactive_off().as_str(), - ui.get_led_inactive_cycle().as_str(), - ui.get_led_inactive_bmode(), - ui.get_led_inactive_bright(), - ui.get_led_inactive_baudio(), - ui.get_led_inactive_btarget().as_str(), - ); - s.preset.leds[led] = Some(cfg); - let _ = crate::storage::save_preset(&s.preset); - s.push_leds(); - drop(s); - ui.set_led_popup_open(false); - toast(&ui, "LED saved."); - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_add_condition(move || { - let conds = { - let mut s = shared.lock(); - let Some((_, cfg)) = s.editing_led.as_mut() else { return }; - cfg.conditions.push(LedCondition::default()); - cfg.conditions.clone() - }; - if let Some(ui) = weak.upgrade() { push_led_conditions(&ui, &conds); } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_remove_condition(move |idx| { - let conds = { - let mut s = shared.lock(); - let Some((_, cfg)) = s.editing_led.as_mut() else { return }; - let i = idx as usize; - if i < cfg.conditions.len() { cfg.conditions.remove(i); } - cfg.conditions.clone() - }; - if let Some(ui) = weak.upgrade() { push_led_conditions(&ui, &conds); } - }); - } - { - let shared = shared.clone(); - let weak = ui.as_weak(); - ui.on_led_refresh(move || { - let Some(ui) = weak.upgrade() else { return }; - populate_live_lists(&ui, shared.clone()); - // Re-resolve dropdown indices against the freshly scanned lists. - let conds = shared.lock().editing_led.as_ref().map(|(_, c)| c.conditions.clone()); - if let Some(conds) = conds { - push_led_conditions(&ui, &conds); - } - ui.set_led_active_btidx(list_index_of(&ui, ui.get_led_active_baudio(), ui.get_led_active_btarget().as_str())); - ui.set_led_inactive_btidx(list_index_of(&ui, ui.get_led_inactive_baudio(), ui.get_led_inactive_btarget().as_str())); - }); - } - { - let shared = shared.clone(); - ui.on_led_manual_toggle(move || { - let mut s = shared.lock(); - let Some((led, cfg)) = s.editing_led.as_mut() else { return }; - cfg.manual_active = !cfg.manual_active; - let led = *led; - let _ = s.cmd_tx.send(crate::events::Cmd::LedManualToggle(led as u8)); - if let Some(saved) = s.preset.leds.get_mut(led).and_then(|c| c.as_mut()) { - if saved.control == LedControl::Manual { - saved.manual_active = !saved.manual_active; - let _ = crate::storage::save_preset(&s.preset); - } - } - }); - } - // Per-field condition mutators. Structural edits (kind/audio) re-push the - // model so dependent fields show/hide; scalar/text edits update in place. - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_cond_kind(move |idx, v| { - let conds = mutate_cond_struct(&shared, idx, |c| c.kind = cond_kind_from(v)); - if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } - }); - } - { - let weak = ui.as_weak(); - let shared = shared.clone(); - ui.on_led_cond_audio(move |idx, v| { - // Changing the endpoint family invalidates the previously picked target. - let conds = mutate_cond_struct(&shared, idx, |c| { - c.audio_kind = audio_kind_from(v); - c.target = None; - }); - if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } - }); - } - { - let shared = shared.clone(); - ui.on_led_cond_target(move |idx, v| { - mutate_cond(&shared, idx, |c| c.target = opt(v.as_str())); - }); - } - { - let shared = shared.clone(); - ui.on_led_cond_cmp(move |idx, v| { mutate_cond(&shared, idx, |c| c.cmp = cmp_from(v)); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_value(move |idx, v| { mutate_cond(&shared, idx, |c| c.value = v.clamp(0, 100) as u8); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_method(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.method = HttpMethod::from_index(v)); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_url(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.url = v.to_string()); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_payload(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.payload = opt(v.as_str())); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_bearer(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.bearer = opt(v.as_str())); }); - } - { - let shared = shared.clone(); - ui.on_led_cond_interval(move |idx, v| { - if let Ok(n) = v.as_str().trim().parse::() { - mutate_cond(&shared, idx, |c| c.api.interval_ms = n); - } - }); - } - { - let shared = shared.clone(); - ui.on_led_cond_expect(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.expect_body = opt(v.as_str())); }); - } - - // ─── serial event pump ──────────────────────────────────────────────── - let weak = ui.as_weak(); - let timer = slint::Timer::default(); - timer.start(slint::TimerMode::Repeated, Duration::from_millis(16), move || { - let Some(ui) = weak.upgrade() else { return }; - let mut s = shared.lock(); - let mut changed = false; - while let Ok(ev) = rx.try_recv() { - match ev { - SerialEvent::Connected(port) => { - s.engine.set_connected(true); - ui.set_connected(true); - ui.set_connection_msg(format!("Connected on {port}").into()); - ui.set_retry_eta_secs(-1.0); - // Board reset on connect; re-push all desired LED state. - s.push_leds(); - changed = true; - } - SerialEvent::Disconnected { reason, retrying_in } => { - s.engine.set_connected(false); - ui.set_connected(false); - ui.set_connection_msg(reason.into()); - ui.set_retry_eta_secs(retrying_in.as_secs_f32()); - s.pending_retry_deadline = Some(Instant::now() + retrying_in); - changed = true; - } - SerialEvent::Frame(frame) => { - // Cheap diff on the UI thread; side effects go to the actuator. - let preset = s.preset.clone(); - let (dirty, cmds) = s.engine.ingest(&frame, &preset); - let mut led_toggled = false; - for cmd in cmds { - // Persist a manual-LED toggle into the preset so it - // survives a restart (the actuator owns the live state). - if let crate::events::Cmd::LedManualToggle(led) = &cmd { - if let Some(cfg) = s.preset.leds.get_mut(*led as usize).and_then(|c| c.as_mut()) { - cfg.manual_active = !cfg.manual_active; - led_toggled = true; - } - } - let _ = s.cmd_tx.send(cmd); - } - if led_toggled { - let _ = crate::storage::save_preset(&s.preset); - } - if dirty { changed = true; } - } - } - } - // Mirror live LED state into the configure popup (manual-mode readout). - if ui.get_led_popup_open() { - let led = ui.get_led_index() as usize; - let active = s.led_state.lock().get(led).map(|l| l.active).unwrap_or(false); - ui.set_led_live_active(active); - } - - // Animate retry countdown even between events. - if let Some(deadline) = s.pending_retry_deadline { - let remaining = deadline.saturating_duration_since(Instant::now()).as_secs_f32(); - if remaining > 0.0 { - ui.set_retry_eta_secs(remaining); - } - } - if changed { - let live = s.engine.state().clone(); - push_cells(&ui, &live.sliders, &live.buttons, &s.preset); - ui.set_badge_text(match live.badge { - crate::protocol::Badge::Supporter => "Supporter".into(), - crate::protocol::Badge::Premium => "Premium".into(), - _ => "".into(), - }); - } - }); - // Leak the timer so it survives until window close. - std::mem::forget(timer); -} - -// Helpers ---------------------------------------------------------------- - -/// Refresh the Home live cells from the last known board state — used after -/// preset/category edits so names update without waiting for a board input. -fn refresh_home(ui: &AppWindow, s: &Shared) { - let live = s.engine.state().clone(); - push_cells(ui, &live.sliders, &live.buttons, &s.preset); -} - -fn push_cells(ui: &AppWindow, sliders: &[i32; NUM_SLIDERS], buttons: &[u8; NUM_BUTTONS], preset: &Preset) { - let pts = crate::curve::BezierPoints::for_preset( - preset_curve(preset).0, preset_curve(preset).1); - let _ = pts; // curve preview unused at the cell level - let slider_cells: Vec = sliders - .iter().enumerate().map(|(i, &v)| { - let pct = crate::curve::normalize_raw(v); - let cat = preset.slider_category(i); - SliderCell { - value: v, - percent: pct, - category: cat.map(|c| c.name.clone()).unwrap_or_default().into(), - assigned: cat.is_some(), - } - }).collect(); - let button_cells: Vec = buttons - .iter().enumerate().map(|(i, &b)| { - let cat = preset.button_category(i); - ButtonCell { - pressed: b != 0, - category: cat.map(|c| c.name.clone()).unwrap_or_default().into(), - assigned: cat.is_some(), - } - }).collect(); - ui.set_sliders(ModelRc::new(VecModel::from(slider_cells))); - let (top, right): (Vec<_>, Vec<_>) = button_cells.into_iter().enumerate() - .partition(|(i, _)| *i < 6); - let top: Vec<_> = top.into_iter().map(|(_, b)| b).collect(); - let right: Vec<_> = right.into_iter().map(|(_, b)| b).collect(); - ui.set_top_buttons(ModelRc::new(VecModel::from(top))); - ui.set_right_buttons(ModelRc::new(VecModel::from(right))); -} - -fn preset_curve(_preset: &Preset) -> (crate::curve::CurvePreset, crate::curve::BezierPoints) { - (crate::curve::CurvePreset::Linear, crate::curve::BezierPoints::LINEAR) -} - -/// Move the category with `from_id` to insertion index `to_index` (0..=len). -fn reorder_by_id(cats: &mut Vec, from_id: u32, to_index: usize, id: impl Fn(&C) -> u32) { - let Some(from) = cats.iter().position(|c| id(c) == from_id) else { return }; - let item = cats.remove(from); - // `to_index` was computed against the original list; removing the item - // shifts everything after it down by one. - let to = if from < to_index { to_index - 1 } else { to_index }; - cats.insert(to.min(cats.len()), item); -} - -/// Move element `from_idx` of category `from_cat` to insertion index `to_idx` of -/// category `to_cat` (may be the same category). `to_idx` is in the destination's -/// original ordering. -fn move_line( - cats: &mut [C], - from_cat: u32, - from_idx: usize, - to_cat: u32, - to_idx: usize, - id: impl Fn(&C) -> u32, - lines: impl Fn(&mut C) -> &mut Vec, -) { - let Some(from_pos) = cats.iter().position(|c| id(c) == from_cat) else { return }; - let Some(to_pos) = cats.iter().position(|c| id(c) == to_cat) else { return }; - let item = { - let v = lines(&mut cats[from_pos]); - if from_idx >= v.len() { - return; - } - v.remove(from_idx) - }; - // Within the same category, a removal before the target shifts it down. - let to = if from_cat == to_cat && from_idx < to_idx { to_idx - 1 } else { to_idx }; - let v = lines(&mut cats[to_pos]); - v.insert(to.min(v.len()), item); -} - -fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { - let sliders: Vec = preset - .slider_categories - .iter() - .map(|c| CategorySummary { - id: c.id as i32, - name: c.name.clone().into(), - count: c.streams.len() as i32, - collapsed: c.collapsed, - lines: ModelRc::new(VecModel::from( - c.streams.iter().enumerate().map(|(i, s)| LineItem { - id: i as i32, - primary: s.label().into(), - secondary: stream_secondary(s).into(), - icon_kind: stream_icon_kind(s), - }).collect::>(), - )), - }) - .collect(); - let buttons: Vec = preset - .button_categories - .iter() - .map(|c| CategorySummary { - id: c.id as i32, - name: c.name.clone().into(), - count: c.actions.len() as i32, - collapsed: c.collapsed, - lines: ModelRc::new(VecModel::from( - c.actions.iter().enumerate().map(|(i, a)| LineItem { - id: i as i32, - primary: SharedString::from(a.kind.label()), - secondary: action_secondary(a).into(), - icon_kind: action_icon_kind(a.kind), - }).collect::>(), - )), - }) - .collect(); - ui.set_slider_categories(ModelRc::new(VecModel::from(sliders))); - ui.set_button_categories(ModelRc::new(VecModel::from(buttons))); - ui.set_active_preset_name(preset.name.clone().into()); - ui.set_preset_names(ModelRc::new(VecModel::from( - crate::storage::list_presets().into_iter().map(SharedString::from).collect::>(), - ))); -} - -fn stream_icon_kind(s: &AudioStream) -> i32 { - if s.api.is_some() { 3 } - else if s.process.is_some() { 0 } - else if s.mic_name.is_some() { 1 } - else { 2 } -} - -/// Secondary line for a button action. Cycle-output derives its label from the -/// device list (so it stays correct without a stored display); others fall back -/// to the display, then the raw property. -fn action_secondary(a: &crate::model::ButtonAction) -> String { - if a.kind == ActionKind::CycleOutput { - let names: Vec<&str> = - a.property.as_deref().unwrap_or("").lines().filter(|s| !s.is_empty()).collect(); - return if names.is_empty() { - "All output devices".into() - } else { - names.join(" → ") - }; - } - if a.kind == ActionKind::ApiCall { - return a.api.as_ref().map(|api| api.label()).unwrap_or_else(|| "API call".into()); - } - a.display.clone().or_else(|| a.property.clone()).unwrap_or_default() -} - -fn action_icon_kind(k: ActionKind) -> i32 { - use ActionKind::*; - match k { - MuteProcess => 0, MuteMic => 1, MuteMainAudio => 5, - OpenProcess => 0, OpenWebsite => 3, KeyPress => 4, CycleOutput => 2, ApiCall => 3, - } -} - -fn stream_secondary(s: &AudioStream) -> String { - if s.api.is_some() { - "API call".into() - } else if s.process.is_some() { - "process".into() - } else if s.mic_name.is_some() { - "microphone".into() - } else if s.device_name.is_some() { - "output device".into() - } else { - "default output".into() - } -} - -/// Push system (global) + appearance/slider (per-profile) settings into the UI. -fn push_settings_to_ui(ui: &AppWindow, global: &Settings, profile: &crate::model::ProfileSettings) { - // System (global) - // Gates the two tray rows: only Windows ships a tray (`src/tray.rs`), and - // without one "start minimized" would leave the app running with no window - // and no way to reopen it. - ui.set_has_tray(cfg!(target_os = "windows")); - ui.set_start_with_os(global.start_with_os); - ui.set_start_minimized(global.start_minimized); - ui.set_minimize_to_tray(global.minimize_to_tray); - ui.set_led_experimental(global.led_experimental); - ui.set_active_preset_name(global.active_preset.clone().into()); - // Appearance + sliders (profile) - ui.set_theme_index(match profile.theme { - ThemeMode::System => 0, ThemeMode::Light => 1, ThemeMode::Dark => 2, - }); - ui.set_accent_preset(profile.accent_preset); - ui.set_dead_zone(profile.slider_dead_zone); - ui.set_unmute_on_change(profile.unmute_on_change); - ui.set_curve_preset_index(curve_preset_to_index(profile.curve_preset)); - push_curve_to_ui(ui, profile); -} - -/// Push the active curve's control points + editability into the UI. -fn push_curve_to_ui(ui: &AppWindow, profile: &crate::model::ProfileSettings) { - let pts = crate::curve::BezierPoints::for_preset(profile.curve_preset, profile.custom_curve); - ui.set_curve_x1(pts.x1); - ui.set_curve_y1(pts.y1); - ui.set_curve_x2(pts.x2); - ui.set_curve_y2(pts.y2); - ui.set_curve_editable(matches!(profile.curve_preset, crate::curve::CurvePreset::Custom)); -} - -fn pull_global_from_ui(ui: &AppWindow, g: &mut Settings) { - g.start_with_os = ui.get_start_with_os(); - g.start_minimized = ui.get_start_minimized(); - g.minimize_to_tray = ui.get_minimize_to_tray(); - g.led_experimental = ui.get_led_experimental(); -} - -fn pull_profile_from_ui(ui: &AppWindow, p: &mut crate::model::ProfileSettings) { - p.theme = match ui.get_theme_index() { 1 => ThemeMode::Light, 2 => ThemeMode::Dark, _ => ThemeMode::System }; - p.accent_preset = ui.get_accent_preset(); - p.slider_dead_zone = ui.get_dead_zone(); - p.unmute_on_change = ui.get_unmute_on_change(); - p.curve_preset = index_to_curve_preset(ui.get_curve_preset_index()); -} - -/// Apply a profile's appearance (theme + accent) to the live UI. -fn apply_appearance(ui: &AppWindow, profile: &crate::model::ProfileSettings) { - apply_color_scheme(ui, profile.theme); - ui.set_accent_preset(profile.accent_preset); - ui.invoke_apply_accent(profile.accent_preset); -} - -fn curve_preset_to_index(p: crate::curve::CurvePreset) -> i32 { - use crate::curve::CurvePreset::*; - match p { Linear => 0, Ease => 1, EaseIn => 2, EaseOut => 3, EaseInOut => 4, Custom => 5 } -} -fn index_to_curve_preset(i: i32) -> crate::curve::CurvePreset { - use crate::curve::CurvePreset::*; - match i { 1 => Ease, 2 => EaseIn, 3 => EaseOut, 4 => EaseInOut, 5 => Custom, _ => Linear } -} - -/// Build an [`ApiCall`] from the wizard result's API fields. -fn api_from_result(r: &WizardResult) -> ApiCall { - let opt = |s: String| if s.is_empty() { None } else { Some(s) }; - ApiCall { - method: HttpMethod::from_index(r.method), - url: r.url.trim().to_string(), - payload: opt(r.payload.to_string()), - bearer: opt(r.bearer.trim().to_string()), - throttle_ms: r.throttle.clamp( - crate::model::API_THROTTLE_MIN_MS, - crate::model::API_THROTTLE_MAX_MS, - ), - } -} - -/// Push an existing [`ApiCall`] (or defaults) into the wizard API fields. -fn push_wizard_api(ui: &AppWindow, api: Option<&ApiCall>) { - match api { - Some(api) => { - ui.set_wizard_method(api.method.to_index()); - ui.set_wizard_url(api.url.clone().into()); - ui.set_wizard_payload(api.payload.clone().unwrap_or_default().into()); - ui.set_wizard_bearer(api.bearer.clone().unwrap_or_default().into()); - ui.set_wizard_throttle(api.throttle_ms); - } - None => reset_wizard_api(ui), - } -} - -fn reset_wizard_api(ui: &AppWindow) { - ui.set_wizard_method(HttpMethod::default().to_index()); - ui.set_wizard_url("".into()); - ui.set_wizard_payload("".into()); - ui.set_wizard_bearer("".into()); - ui.set_wizard_throttle(crate::model::default_api_throttle_ms()); -} - -fn action_kind_index(k: ActionKind) -> i32 { - use ActionKind::*; - match k { - MuteProcess => 0, MuteMainAudio => 1, MuteMic => 2, - OpenProcess => 3, OpenWebsite => 4, KeyPress => 5, CycleOutput => 6, ApiCall => 7, - } -} - -fn button_kind_from_index(i: i32) -> ActionKind { - use ActionKind::*; - match i { - 0 => MuteProcess, 1 => MuteMainAudio, 2 => MuteMic, - 3 => OpenProcess, 4 => OpenWebsite, 6 => CycleOutput, 7 => ApiCall, _ => KeyPress, - } -} - -fn apply_color_scheme(ui: &AppWindow, mode: ThemeMode) { - // Trigger the slint-side `apply-theme(idx)` which sets `Palette.color-scheme`. - let idx = match mode { - ThemeMode::System => 0, - ThemeMode::Light => 1, - ThemeMode::Dark => 2, - }; - ui.invoke_apply_theme(idx); -} - -fn push_wizard_picker(ui: &AppWindow, shared: Arc>) { - let target_kind = ui.get_wizard_target_kind(); - let kind = ui.get_wizard_kind(); - let filter = ui.get_wizard_filter().to_string().to_lowercase(); - - let s = shared.lock(); - let source: Vec = match (target_kind, kind) { - // Slider streams - (0, 0) => merged_processes(s.audio.as_ref()), - (0, 1) => s.audio.list_mics(), - (0, 2) => s.audio.list_outputs(), - // Button actions - (1, 0) => merged_processes(s.audio.as_ref()), - (1, 1) => s.audio.list_outputs(), - (1, 2) => s.audio.list_mics(), - // Simulate key — full library - (1, 5) => crate::keys_library::KEYS.iter().map(|k| k.name.to_string()).collect(), - // Cycle output device — multi-select list of outputs - (1, 6) => s.audio.list_outputs(), - _ => Vec::new(), - }; - drop(s); - - // Multi-select kinds (currently Cycle output) keep their chosen set in the - // wizard property as a newline-separated list; flag matching rows as selected. - let multi_select = target_kind == 1 && kind == 6; - let chosen: Vec = if multi_select { - ui.get_wizard_property().to_string().lines().map(str::to_string).collect() - } else { - Vec::new() - }; - - let filter_norm = filter.trim(); - let mut filtered: Vec = if filter_norm.is_empty() { - source - } else { - source.into_iter().filter(|n| n.to_lowercase().contains(filter_norm)).collect() - }; - // For non-key lists, dedup + sort. Keep key library order. - if !(target_kind == 1 && kind == 5) { - filtered.sort(); - filtered.dedup(); - } - let icon_kind: i32 = match (target_kind, kind) { - (_, 1) if target_kind == 0 => 1, // slider mic - (0, 2) => 2, // slider output - (1, 2) => 1, // button mic - (1, 1) => 2, // button output (main audio) - (1, 5) => 4, // key - (1, 6) => 2, // cycle output - _ => 0, // process - }; - ui.set_wizard_picker_source(ModelRc::new(VecModel::from( - filtered.into_iter() - .map(|n| crate::PickerEntry { - selected: chosen.iter().any(|c| c == &n), - name: n.into(), - icon_kind, - }) - .collect::>(), - ))); -} - -/// Merge sessions-with-audio with all running processes, so the picker isn't -/// empty when no audio is playing yet. -fn merged_processes(audio: &dyn AudioBackend) -> Vec { - let mut out = audio.list_processes(); - out.extend(list_running_processes()); - out -} - -fn populate_live_lists(ui: &AppWindow, shared: Arc>) { - let s = shared.lock(); - let mut procs = s.audio.list_processes(); - let mut mics = s.audio.list_mics(); - let mut outs = s.audio.list_outputs(); - // Also surface system processes (running apps) on top of audio-active ones, dedup. - procs.extend(list_running_processes()); - procs.sort(); - procs.dedup(); - mics.sort(); mics.dedup(); - outs.sort(); outs.dedup(); - ui.set_live_processes(ModelRc::new(VecModel::from( - procs.into_iter().map(SharedString::from).collect::>(), - ))); - ui.set_live_mics(ModelRc::new(VecModel::from( - mics.into_iter().map(SharedString::from).collect::>(), - ))); - ui.set_live_outputs(ModelRc::new(VecModel::from( - outs.into_iter().map(SharedString::from).collect::>(), - ))); -} - -#[cfg(target_os = "linux")] -fn list_running_processes() -> Vec { - let mut out = Vec::new(); - if let Ok(rd) = std::fs::read_dir("/proc") { - for e in rd.flatten() { - let n = e.file_name(); - let n = n.to_string_lossy(); - if n.is_empty() || !n.chars().all(|c| c.is_ascii_digit()) { continue } - if let Ok(comm) = std::fs::read_to_string(format!("/proc/{n}/comm")) { - let comm = comm.trim(); - if comm.is_empty() { continue } - out.push(untruncate_comm(&n, comm)); - } - } - } - out -} - -/// The kernel caps `/proc//comm` at 15 characters (`TASK_COMM_LEN - 1`), so -/// long binaries show up clipped ("gnome-terminal-", "systemd-journal") and then -/// fail to match what PulseAudio reports as `application.process.binary`. When a -/// name is exactly at the cap, recover the full one from `cmdline`'s argv[0]. -#[cfg(target_os = "linux")] -fn untruncate_comm(pid: &str, comm: &str) -> String { - const TASK_COMM_MAX: usize = 15; - if comm.len() < TASK_COMM_MAX { - return comm.to_string(); - } - let Ok(cmdline) = std::fs::read_to_string(format!("/proc/{pid}/cmdline")) else { - return comm.to_string(); - }; - let argv0 = cmdline.split('').next().unwrap_or(""); - let base = argv0.rsplit('/').next().unwrap_or(""); - // Only trust it when it really is the same program, just not clipped - - // argv[0] can be an interpreter path or a rewritten title. - if base.len() > comm.len() && base.starts_with(comm) { - base.to_string() - } else { - comm.to_string() - } -} - -#[cfg(target_os = "windows")] -fn list_running_processes() -> Vec { - use windows::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, - }; - use windows::Win32::Foundation::CloseHandle; - let mut out = Vec::new(); - unsafe { - let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else { return out }; - let mut entry = PROCESSENTRY32W { dwSize: std::mem::size_of::() as u32, ..Default::default() }; - if Process32FirstW(snap, &mut entry).is_ok() { - loop { - let len = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len()); - let name = String::from_utf16_lossy(&entry.szExeFile[..len]); - if !name.is_empty() { out.push(name); } - if Process32NextW(snap, &mut entry).is_err() { break } - } - } - let _ = CloseHandle(snap); - } - out -} - -// ── LED config <-> UI conversions ────────────────────────────────────────── - -fn opt(s: &str) -> Option { - if s.is_empty() { None } else { Some(s.to_string()) } -} - -fn led_mode_kind_to(k: LedModeKind) -> i32 { - match k { LedModeKind::Off => 0, LedModeKind::On => 1, LedModeKind::Blink => 2, LedModeKind::Breathe => 3 } -} -fn audio_kind_to(k: AudioKind) -> i32 { - match k { AudioKind::Process => 0, AudioKind::Mic => 1, AudioKind::Output => 2 } -} - -/// Snapshot a UI string-model into a Vec. -fn ui_list(model: &ModelRc) -> Vec { - (0..model.row_count()).filter_map(|i| model.row_data(i)).map(|s| s.to_string()).collect() -} - -/// Index of `target` within the live list for audio kind `audio` (-1 if absent). -fn list_index_of(ui: &AppWindow, audio: i32, target: &str) -> i32 { - if target.is_empty() { - return -1; - } - let list = match audio { - 1 => ui_list(&ui.get_live_mics()), - 2 => ui_list(&ui.get_live_outputs()), - _ => ui_list(&ui.get_live_processes()), - }; - list.iter().position(|x| x == target).map(|p| p as i32).unwrap_or(-1) -} - -fn led_mode_from_ui( - kind: i32, on: &str, off: &str, cycle: &str, - bmode: i32, bright: i32, baudio: i32, btarget: &str, -) -> LedMode { - let kind = match kind { - 1 => LedModeKind::On, - 2 => LedModeKind::Blink, - 3 => LedModeKind::Breathe, - _ => LedModeKind::Off, - }; - LedMode { - kind, - on_ms: on.trim().parse().unwrap_or(500).max(1), - off_ms: off.trim().parse().unwrap_or(500).max(1), - cycle_ms: cycle.trim().parse().unwrap_or(2000).max(1), - brightness_mode: if bmode == 1 { BrightnessMode::Volume } else { BrightnessMode::Fixed }, - brightness: bright.clamp(0, 100) as u8, - brightness_audio: audio_kind_from(baudio), - brightness_target: opt(btarget), - } -} - -fn cond_kind_from(v: i32) -> LedConditionKind { - match v { 1 => LedConditionKind::Volume, 2 => LedConditionKind::Api, _ => LedConditionKind::Muted } -} -fn audio_kind_from(v: i32) -> AudioKind { - match v { 1 => AudioKind::Mic, 2 => AudioKind::Output, _ => AudioKind::Process } -} -fn cmp_from(v: i32) -> Comparison { - match v { 1 => Comparison::Above, 2 => Comparison::Exact, _ => Comparison::Below } -} - -fn cond_to_ui(c: &LedCondition, target_index: i32) -> LedConditionUI { - LedConditionUI { - kind: match c.kind { LedConditionKind::Muted => 0, LedConditionKind::Volume => 1, LedConditionKind::Api => 2 }, - audio_kind: audio_kind_to(c.audio_kind), - target: c.target.clone().unwrap_or_default().into(), - target_index, - cmp: match c.cmp { Comparison::Below => 0, Comparison::Above => 1, Comparison::Exact => 2 }, - value: c.value as i32, - method: c.api.method.to_index(), - url: c.api.url.clone().into(), - payload: c.api.payload.clone().unwrap_or_default().into(), - bearer: c.api.bearer.clone().unwrap_or_default().into(), - interval: c.api.interval_ms.to_string().into(), - expect_body: c.api.expect_body.clone().unwrap_or_default().into(), - } -} - -/// Rebuild the conditions model, resolving each target's index within the live -/// lists already on the UI (so its dropdown shows the right selection). -fn push_led_conditions(ui: &AppWindow, conds: &[LedCondition]) { - let rows: Vec = conds - .iter() - .map(|c| { - let tidx = list_index_of(ui, audio_kind_to(c.audio_kind), c.target.as_deref().unwrap_or("")); - cond_to_ui(c, tidx) - }) - .collect(); - ui.set_led_conditions(ModelRc::new(VecModel::from(rows))); -} - -/// Push one LED appearance into the UI scalar props (active or inactive). -fn push_mode_to_ui(ui: &AppWindow, m: &LedMode, active: bool) { - let kind = led_mode_kind_to(m.kind); - let on: SharedString = m.on_ms.to_string().into(); - let off: SharedString = m.off_ms.to_string().into(); - let cycle: SharedString = m.cycle_ms.to_string().into(); - let bmode = if m.brightness_mode == BrightnessMode::Volume { 1 } else { 0 }; - let bright = m.brightness as i32; - let baudio = audio_kind_to(m.brightness_audio); - let btarget: SharedString = m.brightness_target.clone().unwrap_or_default().into(); - let btidx = list_index_of(ui, baudio, m.brightness_target.as_deref().unwrap_or("")); - if active { - ui.set_led_active_kind(kind); - ui.set_led_active_on(on); - ui.set_led_active_off(off); - ui.set_led_active_cycle(cycle); - ui.set_led_active_bmode(bmode); - ui.set_led_active_bright(bright); - ui.set_led_active_baudio(baudio); - ui.set_led_active_btarget(btarget); - ui.set_led_active_btidx(btidx); - } else { - ui.set_led_inactive_kind(kind); - ui.set_led_inactive_on(on); - ui.set_led_inactive_off(off); - ui.set_led_inactive_cycle(cycle); - ui.set_led_inactive_bmode(bmode); - ui.set_led_inactive_bright(bright); - ui.set_led_inactive_baudio(baudio); - ui.set_led_inactive_btarget(btarget); - ui.set_led_inactive_btidx(btidx); - } -} - -/// Apply `f` to condition `idx` of the working LED config (no UI re-push). -fn mutate_cond(shared: &Arc>, idx: i32, f: F) { - let mut s = shared.lock(); - if let Some((_, cfg)) = s.editing_led.as_mut() { - if let Some(c) = cfg.conditions.get_mut(idx as usize) { - f(c); - } - } -} - -/// Like [`mutate_cond`] but returns the updated condition list so the caller can -/// re-push the model (for structural changes that show/hide dependent fields). -fn mutate_cond_struct( - shared: &Arc>, - idx: i32, - f: F, -) -> Option> { - let mut s = shared.lock(); - let (_, cfg) = s.editing_led.as_mut()?; - if let Some(c) = cfg.conditions.get_mut(idx as usize) { - f(c); - } - Some(cfg.conditions.clone()) -} - -fn toast(ui: &AppWindow, msg: &str) { - ui.set_toast_text(msg.into()); - let weak = ui.as_weak(); - let timer = slint::Timer::default(); - timer.start(slint::TimerMode::SingleShot, Duration::from_millis(1800), move || { - if let Some(ui) = weak.upgrade() { - ui.set_toast_text("".into()); - } - }); - std::mem::forget(timer); -} +//! Bridge between the Slint UI and the Rust core. +//! +//! `wire()` attaches every callback and starts a polling timer that drains +//! [`SerialEvent`]s from the worker thread into Slint model properties. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crossbeam_channel::Receiver; +use parking_lot::Mutex; +use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel}; + +use crate::audio::AudioBackend; +use crate::events::EventEngine; +use crate::model::{ + led_for_button, next_id, ActionKind, ApiCall, AudioKind, AudioStream, BrightnessMode, + ButtonAction, ButtonCategory, CombineLogic, Comparison, HttpMethod, LedCondition, + LedConditionKind, LedConfig, LedControl, LedMode, LedModeKind, Preset, Settings, SliderCategory, + ThemeMode, +}; +use crate::protocol::{Frame, NUM_BUTTONS, NUM_SLIDERS}; +use crate::serial::{RetryKicker, SerialEvent}; +use crate::{ + AppWindow, AssignPick, ButtonCell, CategorySummary, LedConditionUI, LineItem, PickerEntry, + SliderCell, WizardResult, +}; + +pub struct Shared { + pub settings: Settings, + pub preset: Preset, + pub engine: EventEngine, + pub audio: Arc, + pub cmd_tx: crossbeam_channel::Sender, + pub kicker: RetryKicker, + /// Live LED state published by the actuator (for the configure-LED popup). + pub led_state: crate::led::LedStateHandle, + pub pending_assign: Option<(i32, i32)>, + pub pending_wizard: Option<(i32, u32)>, + pub pending_retry_deadline: Option, + /// When editing an existing category entry, the index being replaced. + pub editing_idx: Option, + /// Working copy for the configure-LED popup: (led index, config being edited). + pub editing_led: Option<(usize, LedConfig)>, +} + +impl Shared { + /// Push the full LED configuration set + brightness + experimental flag to + /// the actuator's LED engine. Call after any change to LED config/settings. + pub fn push_leds(&self) { + let _ = self.cmd_tx.send(crate::events::Cmd::SetLedExperimental(self.settings.led_experimental)); + let _ = self + .cmd_tx + .send(crate::events::Cmd::SetLeds(Box::new(self.preset.leds.clone()))); + } +} + +pub fn wire( + ui: &AppWindow, + shared: Arc>, + rx: Receiver, + build_stamp: &'static str, +) { + ui.set_build_stamp(SharedString::from(build_stamp)); + + // Push initial state from settings/preset → UI. + { + let s = shared.lock(); + push_settings_to_ui(ui, &s.settings, &s.preset.settings); + push_preset_to_ui(ui, &s.preset); + apply_appearance(ui, &s.preset.settings); + s.push_leds(); + } + + // Empty initial slider/button cells. + push_cells(ui, &[0; NUM_SLIDERS], &[0; NUM_BUTTONS], &shared.lock().preset); + ui.set_connection_msg("Searching for Slidr device…".into()); + ui.set_retry_eta_secs(-1.0); + ui.set_connected(false); + + // ─── callbacks ───────────────────────────────────────────────────────── + { + let shared = shared.clone(); + ui.on_retry_now(move || shared.lock().kicker.kick()); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_open_assign(move |kind, idx| { + let s = shared.lock(); + let options: Vec = if kind == 0 { + s.preset.slider_categories.iter().map(|c| AssignPick { + id: c.id as i32, name: c.name.clone().into(), + }).collect() + } else { + s.preset.button_categories.iter().map(|c| AssignPick { + id: c.id as i32, name: c.name.clone().into(), + }).collect() + }; + drop(s); + if let Some(ui) = weak.upgrade() { + ui.set_assign_options(ModelRc::new(VecModel::from(options))); + ui.set_assign_target_kind(kind); + ui.set_assign_target_index(idx); + ui.set_assign_popup_open(true); + } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_apply_assign(move |chosen_id| { + let Some(ui) = weak.upgrade() else { return }; + let target_kind = ui.get_assign_target_kind(); + let idx = ui.get_assign_target_index() as usize; + let chosen = if chosen_id < 0 { None } else { Some(chosen_id as u32) }; + let mut s = shared.lock(); + if target_kind == 0 && idx < NUM_SLIDERS { + s.preset.assignments.sliders[idx] = chosen; + } else if target_kind == 1 && idx < NUM_BUTTONS { + s.preset.assignments.buttons[idx] = chosen; + } + let _ = crate::storage::save_preset(&s.preset); + push_preset_to_ui(&ui, &s.preset); + let live = s.engine.state().clone(); + push_cells(&ui, &live.sliders, &live.buttons, &s.preset); + }); + } + + // ─── category CRUD ───────────────────────────────────────────────────── + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_add_category(move |kind, name| { + if name.is_empty() { return } + let mut s = shared.lock(); + if kind == 0 { + let id = next_id(&s.preset.slider_categories, |c| c.id); + s.preset.slider_categories.push(SliderCategory { id, name: name.to_string(), streams: vec![], collapsed: false }); + } else { + let id = next_id(&s.preset.button_categories, |c| c.id); + s.preset.button_categories.push(ButtonCategory { id, name: name.to_string(), actions: vec![], collapsed: false }); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_create_preset(move |name| { + if name.is_empty() { return } + let new_preset = Preset { + id: 1, + name: name.to_string(), + ..Default::default() + }; + if crate::storage::save_preset(&new_preset).is_ok() { + let mut s = shared.lock(); + s.preset = new_preset; + s.settings.active_preset = name.to_string(); + let _ = crate::storage::save_settings(&s.settings); + if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &s.preset); + push_settings_to_ui(&ui, &s.settings, &s.preset.settings); + apply_appearance(&ui, &s.preset.settings); + s.push_leds(); + refresh_home(&ui, &s); + toast(&ui, &format!("Created profile: {name}")); + } + } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_rename_category(move |kind, id, name| { + let mut s = shared.lock(); + if kind == 0 { + if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id as i32 == id) { + c.name = name.to_string(); + } + } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id as i32 == id) { + c.name = name.to_string(); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + refresh_home(&ui, &s); // update Home preview names without a board input + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_delete_category(move |kind, id| { + let mut s = shared.lock(); + let id = id as u32; + if kind == 0 { + s.preset.slider_categories.retain(|c| c.id != id); + for slot in s.preset.assignments.sliders.iter_mut() { + if *slot == Some(id) { *slot = None } + } + } else { + s.preset.button_categories.retain(|c| c.id != id); + for slot in s.preset.assignments.buttons.iter_mut() { + if *slot == Some(id) { *slot = None } + } + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + refresh_home(&ui, &s); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_delete_line(move |kind, cat_id, idx| { + let mut s = shared.lock(); + let cat_id = cat_id as u32; + let idx = idx as usize; + if kind == 0 { + if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == cat_id) { + if idx < c.streams.len() { c.streams.remove(idx); } + } + } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == cat_id) { + if idx < c.actions.len() { c.actions.remove(idx); } + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_toggle_category_collapse(move |kind, id| { + let mut s = shared.lock(); + let id = id as u32; + if kind == 0 { + if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == id) { + c.collapsed = !c.collapsed; + } + } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == id) { + c.collapsed = !c.collapsed; + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + // Reorder category cards (cosmetic, but persisted via Vec order). + ui.on_reorder_category(move |kind, from_id, to_index| { + let mut s = shared.lock(); + let from_id = from_id as u32; + let to = to_index.max(0) as usize; + if kind == 0 { + reorder_by_id(&mut s.preset.slider_categories, from_id, to, |c| c.id); + } else { + reorder_by_id(&mut s.preset.button_categories, from_id, to, |c| c.id); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + refresh_home(&ui, &s); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + // Move an action/stream within or between categories (same target-kind). + ui.on_move_line(move |kind, from_cat, from_idx, to_cat, to_idx| { + let mut s = shared.lock(); + let (from_cat, to_cat) = (from_cat as u32, to_cat as u32); + let (from_idx, to_idx) = (from_idx.max(0) as usize, to_idx.max(0) as usize); + if kind == 0 { + move_line(&mut s.preset.slider_categories, from_cat, from_idx, to_cat, to_idx, + |c| c.id, |c| &mut c.streams); + } else { + move_line(&mut s.preset.button_categories, from_cat, from_idx, to_cat, to_idx, + |c| c.id, |c| &mut c.actions); + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + }); + } + + // ─── wizard ──────────────────────────────────────────────────────────── + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_open_wizard(move |kind, cat_id| { + let Some(ui) = weak.upgrade() else { return }; + { + let mut s = shared.lock(); + s.pending_wizard = Some((kind, cat_id as u32)); + s.editing_idx = None; + } + ui.set_wizard_target_kind(kind); + ui.set_wizard_category_id(cat_id); + ui.set_wizard_step(0); + ui.set_wizard_kind(0); + ui.set_wizard_property("".into()); + ui.set_wizard_display("".into()); + reset_wizard_api(&ui); + populate_live_lists(&ui, shared.clone()); + ui.set_wizard_filter("".into()); + push_wizard_picker(&ui, shared.clone()); + ui.set_wizard_open(true); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_edit_line(move |target_kind, cat_id, idx| { + let Some(ui) = weak.upgrade() else { return }; + let idx = idx as usize; + let cat_id = cat_id as u32; + // Resolve existing entry → wizard kind/property/display (+ optional API). + let (wkind, prop, disp, api) = { + let s = shared.lock(); + if target_kind == 0 { + let Some(c) = s.preset.slider_categories.iter().find(|c| c.id == cat_id) else { return }; + let Some(stream) = c.streams.get(idx) else { return }; + if let Some(api) = &stream.api { (3, String::new(), String::new(), Some(api.clone())) } + else if let Some(p) = &stream.process { (0, p.clone(), p.clone(), None) } + else if let Some(m) = &stream.mic_name { (1, m.clone(), m.clone(), None) } + else { (2, stream.device_name.clone().unwrap_or_default(), + stream.device_name.clone().unwrap_or_default(), None) } + } else { + let Some(c) = s.preset.button_categories.iter().find(|c| c.id == cat_id) else { return }; + let Some(a) = c.actions.get(idx) else { return }; + let k = action_kind_index(a.kind); + let prop = a.property.clone().unwrap_or_default(); + // For KeyPress, show the friendly name in the picker. + let shown = if a.kind == ActionKind::KeyPress { + prop.parse::().ok() + .and_then(crate::keys_library::label_for_vk) + .map(|s| s.to_string()) + .unwrap_or_else(|| a.display.clone().unwrap_or(prop.clone())) + } else { + prop.clone() + }; + (k, shown, a.display.clone().unwrap_or_default(), a.api.clone()) + } + }; + shared.lock().editing_idx = Some(idx); + ui.set_wizard_target_kind(target_kind); + ui.set_wizard_category_id(cat_id as i32); + ui.set_wizard_kind(wkind); + ui.set_wizard_step(1); // jump straight to the target step + ui.set_wizard_property(prop.into()); + ui.set_wizard_display(disp.into()); + push_wizard_api(&ui, api.as_ref()); + populate_live_lists(&ui, shared.clone()); + ui.set_wizard_filter("".into()); + push_wizard_picker(&ui, shared.clone()); + ui.set_wizard_open(true); + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_refresh_live_lists(move || { + let Some(ui) = weak.upgrade() else { return }; + populate_live_lists(&ui, shared.clone()); + push_wizard_picker(&ui, shared.clone()); + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_wizard_state_changed(move || { + let Some(ui) = weak.upgrade() else { return }; + push_wizard_picker(&ui, shared.clone()); + }); + } + { + let weak = ui.as_weak(); + ui.on_browse_file(move || { + let path = rfd::FileDialog::new().pick_file(); + if let Some(p) = path { + if let Some(ui) = weak.upgrade() { + let s = p.display().to_string(); + ui.set_wizard_property(s.clone().into()); + let name = p.file_stem().map(|s| s.to_string_lossy().to_string()) + .unwrap_or(s.clone()); + ui.set_wizard_display(name.into()); + } + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + // Multi-select picker (Cycle output): toggle a device in/out of the + // newline-separated set held in the wizard property. + ui.on_wizard_toggle_pick(move |name| { + let Some(ui) = weak.upgrade() else { return }; + let name = name.to_string(); + let mut list: Vec = ui + .get_wizard_property() + .to_string() + .lines() + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if let Some(pos) = list.iter().position(|n| n == &name) { + list.remove(pos); + } else { + list.push(name); + } + ui.set_wizard_property(list.join("\n").into()); + push_wizard_picker(&ui, shared.clone()); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_wizard_finish(move |result| { + let mut s = shared.lock(); + let editing = s.editing_idx.take(); + let cat_id = result.category_id as u32; + if result.target_kind == 0 { + if let Some(c) = s.preset.slider_categories.iter_mut().find(|c| c.id == cat_id) { + let stream = match result.kind { + 0 => AudioStream { process: Some(result.property.into()), ..Default::default() }, + 1 => AudioStream { mic_name: Some(result.property.into()), ..Default::default() }, + 2 => AudioStream { device_name: Some(result.property.into()), ..Default::default() }, + _ => AudioStream { api: Some(api_from_result(&result)), ..Default::default() }, + }; + match editing { + Some(i) if i < c.streams.len() => c.streams[i] = stream, + _ => c.streams.push(stream), + } + } + } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == cat_id) { + let kind = button_kind_from_index(result.kind); + if kind == ActionKind::ApiCall { + let action = ButtonAction { + kind, property: None, display: None, + api: Some(api_from_result(&result)), + }; + match editing { + Some(i) if i < c.actions.len() => c.actions[i] = action, + _ => c.actions.push(action), + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); + return; + } + // For KeyPress, picker stores the friendly name; resolve to VK code. + let (prop, disp) = if kind == ActionKind::KeyPress { + let name = result.property.to_string(); + let vk = crate::keys_library::KEYS.iter() + .find(|k| k.name.eq_ignore_ascii_case(&name)) + .map(|k| k.vk) + .or_else(|| name.parse::().ok()) + .unwrap_or(0); + (Some(vk.to_string()), Some(name)) + } else if kind == ActionKind::CycleOutput { + // Property is the newline-separated device list (empty = all). + // The human-readable label is derived at render time + // (`action_secondary`), so no display is stored here. + let list = result.property.to_string(); + let has_any = list.lines().any(|s| !s.is_empty()); + (has_any.then_some(list), None) + } else { + let prop = if result.property.is_empty() { None } else { Some(result.property.to_string()) }; + let disp = if result.display.is_empty() { None } else { Some(result.display.to_string()) }; + (prop, disp) + }; + let action = ButtonAction { kind, property: prop, display: disp, api: None }; + match editing { + Some(i) if i < c.actions.len() => c.actions[i] = action, + _ => c.actions.push(action), + } + } + let _ = crate::storage::save_preset(&s.preset); + let Some(ui) = weak.upgrade() else { return }; + push_preset_to_ui(&ui, &s.preset); + toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); + }); + } + + // ─── settings ────────────────────────────────────────────────────────── + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_theme_changed(move |idx| { + let mode = match idx { + 1 => ThemeMode::Light, 2 => ThemeMode::Dark, _ => ThemeMode::System, + }; + { + let mut s = shared.lock(); + s.preset.settings.theme = mode; + let _ = crate::storage::save_preset(&s.preset); + } + if let Some(ui) = weak.upgrade() { + apply_color_scheme(&ui, mode); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_curve_preset_changed(move |idx| { + let mut s = shared.lock(); + let preset = index_to_curve_preset(idx); + s.preset.settings.curve_preset = preset; + // Custom starts from a gentle default each time it's freshly selected. + if matches!(preset, crate::curve::CurvePreset::Custom) { + s.preset.settings.custom_curve = crate::curve::BezierPoints::CUSTOM_DEFAULT; + } + let _ = crate::storage::save_preset(&s.preset); + if let Some(ui) = weak.upgrade() { + push_curve_to_ui(&ui, &s.preset.settings); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_curve_changed(move || { + let Some(ui) = weak.upgrade() else { return }; + let mut s = shared.lock(); + s.preset.settings.custom_curve = crate::curve::BezierPoints { + x1: ui.get_curve_x1(), y1: ui.get_curve_y1(), + x2: ui.get_curve_x2(), y2: ui.get_curve_y2(), + }; + s.preset.settings.curve_preset = crate::curve::CurvePreset::Custom; + let _ = crate::storage::save_preset(&s.preset); + }); + } + { + ui.on_exit_app(|| slint::quit_event_loop().ok().unwrap_or(())); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_rename_preset(move |old, new| { + if old.is_empty() || new.is_empty() || old == new { return } + // Read the old preset, write under new name, delete old. + if let Ok(mut p) = crate::storage::load_preset(&old) { + p.name = new.to_string(); + if crate::storage::save_preset(&p).is_ok() { + let _ = crate::storage::delete_preset(&old); + let mut s = shared.lock(); + if s.settings.active_preset == old.as_str() { + s.settings.active_preset = new.to_string(); + let _ = crate::storage::save_settings(&s.settings); + } + if s.preset.name == old.as_str() { + s.preset.name = new.to_string(); + } + if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &s.preset); + toast(&ui, &format!("Renamed to {new}")); + } + } + } + }); + } + { + let weak = ui.as_weak(); + ui.on_export_preset(move |name| { + let src = crate::storage::presets_dir().join(name.to_string()).join("preset.json"); + let dest = rfd::FileDialog::new() + .set_file_name(format!("slidr-preset-{name}.json")) + .add_filter("Slidr preset", &["json"]) + .save_file(); + let Some(dest) = dest else { return }; + match std::fs::copy(&src, &dest) { + Ok(_) => if let Some(ui) = weak.upgrade() { + toast(&ui, &format!("Exported to {}", dest.display())); + }, + Err(e) => if let Some(ui) = weak.upgrade() { + toast(&ui, &format!("Export failed: {e}")); + }, + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_import_preset(move || { + let Some(path) = rfd::FileDialog::new() + .add_filter("Slidr preset", &["json"]) + .pick_file() else { return }; + let Ok(data) = std::fs::read_to_string(&path) else { return }; + let Ok(mut preset) = serde_json::from_str::(&data) else { + if let Some(ui) = weak.upgrade() { toast(&ui, "Import failed: invalid preset file"); } + return; + }; + // Ensure a unique, non-empty name (avoid clobbering an existing one). + if preset.name.trim().is_empty() { + preset.name = path.file_stem().map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Imported".into()); + } + let existing = crate::storage::list_presets(); + let base = preset.name.clone(); + let mut n = 1; + while existing.iter().any(|e| e == &preset.name) { + n += 1; + preset.name = format!("{base} ({n})"); + } + match crate::storage::save_preset(&preset) { + Ok(()) => if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &shared.lock().preset); + toast(&ui, &format!("Imported profile: {}", preset.name)); + }, + Err(e) => if let Some(ui) = weak.upgrade() { + toast(&ui, &format!("Import failed: {e}")); + }, + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_save_settings(move || { + let Some(ui) = weak.upgrade() else { return }; + let mut s = shared.lock(); + // System → global settings; appearance/sliders → active profile. + pull_global_from_ui(&ui, &mut s.settings); + pull_profile_from_ui(&ui, &mut s.preset.settings); + let _ = crate::storage::save_settings(&s.settings); + let _ = crate::storage::save_preset(&s.preset); + let _ = crate::autostart::set_enabled(s.settings.start_with_os, s.settings.start_minimized); + // Live-apply appearance (accent / theme) immediately. + apply_appearance(&ui, &s.preset.settings); + // LED brightness / experimental flag may have changed. + s.push_leds(); + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_save_preset(move || { + let s = shared.lock(); + let result = crate::storage::save_preset(&s.preset); + if let Some(ui) = weak.upgrade() { + match result { + Ok(()) => toast(&ui, "Preset saved."), + Err(e) => toast(&ui, &format!("Save failed: {e}")), + } + push_preset_to_ui(&ui, &s.preset); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_load_preset(move |name| { + let Ok(p) = crate::storage::load_preset(&name) else { return }; + let mut s = shared.lock(); + s.preset = p; + s.settings.active_preset = s.preset.name.clone(); + let _ = crate::storage::save_settings(&s.settings); + if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &s.preset); + push_settings_to_ui(&ui, &s.settings, &s.preset.settings); + apply_appearance(&ui, &s.preset.settings); + s.push_leds(); + // Refresh Home immediately with the new assignments using the + // last live readings — no board input required. + refresh_home(&ui, &s); + toast(&ui, &format!("Switched to profile: {name}")); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_delete_preset(move |name| { + let _ = crate::storage::delete_preset(&name); + if let Some(ui) = weak.upgrade() { + push_preset_to_ui(&ui, &shared.lock().preset); + toast(&ui, &format!("Deleted profile: {name}")); + } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_set_active_preset(move |name| { + let mut s = shared.lock(); + s.settings.active_preset = name.to_string(); + let _ = crate::storage::save_settings(&s.settings); + if let Some(ui) = weak.upgrade() { + push_settings_to_ui(&ui, &s.settings, &s.preset.settings); + } + }); + } + + // ─── LED state persistence (board EEPROM) ────────────────────────────── + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_save_state(move || { + shared.lock().cmd_tx.send(crate::events::Cmd::LedSaveState).ok(); + if let Some(ui) = weak.upgrade() { + toast(&ui, if ui.get_connected() { "Saved LED state to the board." } + else { "Not connected — connect the board first." }); + } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_clear_state(move || { + shared.lock().cmd_tx.send(crate::events::Cmd::LedClearSaved).ok(); + if let Some(ui) = weak.upgrade() { + toast(&ui, if ui.get_connected() { "Cleared saved LED state (all off)." } + else { "Not connected — connect the board first." }); + } + }); + } + + // ─── configure LED ───────────────────────────────────────────────────── + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_open_led_config(move |btn| { + let Some(led) = led_for_button(btn as usize) else { return }; + let cfg = { + let mut s = shared.lock(); + let cfg = s.preset.leds[led].clone().unwrap_or_default(); + s.editing_led = Some((led, cfg.clone())); + cfg + }; + let Some(ui) = weak.upgrade() else { return }; + ui.set_led_index(led as i32); + ui.set_led_control(if cfg.control == LedControl::Manual { 1 } else { 0 }); + ui.set_led_combine(if cfg.combine == CombineLogic::All { 1 } else { 0 }); + // Populate the device lists first so target indices resolve. + populate_live_lists(&ui, shared.clone()); + push_mode_to_ui(&ui, &cfg.active, true); + push_mode_to_ui(&ui, &cfg.inactive, false); + push_led_conditions(&ui, &cfg.conditions); + ui.set_led_live_active(false); + ui.set_led_popup_open(true); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_cancel(move || { + shared.lock().editing_led = None; + if let Some(ui) = weak.upgrade() { ui.set_led_popup_open(false); } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_save(move || { + let Some(ui) = weak.upgrade() else { return }; + let mut s = shared.lock(); + let Some((led, mut cfg)) = s.editing_led.take() else { return }; + cfg.control = if ui.get_led_control() == 1 { LedControl::Manual } else { LedControl::Conditional }; + cfg.combine = if ui.get_led_combine() == 1 { CombineLogic::All } else { CombineLogic::Any }; + cfg.active = led_mode_from_ui( + ui.get_led_active_kind(), + ui.get_led_active_on().as_str(), + ui.get_led_active_off().as_str(), + ui.get_led_active_cycle().as_str(), + ui.get_led_active_bmode(), + ui.get_led_active_bright(), + ui.get_led_active_baudio(), + ui.get_led_active_btarget().as_str(), + ); + cfg.inactive = led_mode_from_ui( + ui.get_led_inactive_kind(), + ui.get_led_inactive_on().as_str(), + ui.get_led_inactive_off().as_str(), + ui.get_led_inactive_cycle().as_str(), + ui.get_led_inactive_bmode(), + ui.get_led_inactive_bright(), + ui.get_led_inactive_baudio(), + ui.get_led_inactive_btarget().as_str(), + ); + s.preset.leds[led] = Some(cfg); + let _ = crate::storage::save_preset(&s.preset); + s.push_leds(); + drop(s); + ui.set_led_popup_open(false); + toast(&ui, "LED saved."); + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_add_condition(move || { + let conds = { + let mut s = shared.lock(); + let Some((_, cfg)) = s.editing_led.as_mut() else { return }; + cfg.conditions.push(LedCondition::default()); + cfg.conditions.clone() + }; + if let Some(ui) = weak.upgrade() { push_led_conditions(&ui, &conds); } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_remove_condition(move |idx| { + let conds = { + let mut s = shared.lock(); + let Some((_, cfg)) = s.editing_led.as_mut() else { return }; + let i = idx as usize; + if i < cfg.conditions.len() { cfg.conditions.remove(i); } + cfg.conditions.clone() + }; + if let Some(ui) = weak.upgrade() { push_led_conditions(&ui, &conds); } + }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_led_refresh(move || { + let Some(ui) = weak.upgrade() else { return }; + populate_live_lists(&ui, shared.clone()); + // Re-resolve dropdown indices against the freshly scanned lists. + let conds = shared.lock().editing_led.as_ref().map(|(_, c)| c.conditions.clone()); + if let Some(conds) = conds { + push_led_conditions(&ui, &conds); + } + ui.set_led_active_btidx(list_index_of(&ui, ui.get_led_active_baudio(), ui.get_led_active_btarget().as_str())); + ui.set_led_inactive_btidx(list_index_of(&ui, ui.get_led_inactive_baudio(), ui.get_led_inactive_btarget().as_str())); + }); + } + { + let shared = shared.clone(); + ui.on_led_manual_toggle(move || { + let mut s = shared.lock(); + let Some((led, cfg)) = s.editing_led.as_mut() else { return }; + cfg.manual_active = !cfg.manual_active; + let led = *led; + let _ = s.cmd_tx.send(crate::events::Cmd::LedManualToggle(led as u8)); + if let Some(saved) = s.preset.leds.get_mut(led).and_then(|c| c.as_mut()) { + if saved.control == LedControl::Manual { + saved.manual_active = !saved.manual_active; + let _ = crate::storage::save_preset(&s.preset); + } + } + }); + } + // Per-field condition mutators. Structural edits (kind/audio) re-push the + // model so dependent fields show/hide; scalar/text edits update in place. + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_cond_kind(move |idx, v| { + let conds = mutate_cond_struct(&shared, idx, |c| c.kind = cond_kind_from(v)); + if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } + }); + } + { + let weak = ui.as_weak(); + let shared = shared.clone(); + ui.on_led_cond_audio(move |idx, v| { + // Changing the endpoint family invalidates the previously picked target. + let conds = mutate_cond_struct(&shared, idx, |c| { + c.audio_kind = audio_kind_from(v); + c.target = None; + }); + if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } + }); + } + { + let shared = shared.clone(); + ui.on_led_cond_target(move |idx, v| { + mutate_cond(&shared, idx, |c| c.target = opt(v.as_str())); + }); + } + { + let shared = shared.clone(); + ui.on_led_cond_cmp(move |idx, v| { mutate_cond(&shared, idx, |c| c.cmp = cmp_from(v)); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_value(move |idx, v| { mutate_cond(&shared, idx, |c| c.value = v.clamp(0, 100) as u8); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_method(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.method = HttpMethod::from_index(v)); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_url(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.url = v.to_string()); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_payload(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.payload = opt(v.as_str())); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_bearer(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.bearer = opt(v.as_str())); }); + } + { + let shared = shared.clone(); + ui.on_led_cond_interval(move |idx, v| { + if let Ok(n) = v.as_str().trim().parse::() { + mutate_cond(&shared, idx, |c| c.api.interval_ms = n); + } + }); + } + { + let shared = shared.clone(); + ui.on_led_cond_expect(move |idx, v| { mutate_cond(&shared, idx, |c| c.api.expect_body = opt(v.as_str())); }); + } + + // ─── serial event pump ──────────────────────────────────────────────── + let weak = ui.as_weak(); + let timer = slint::Timer::default(); + timer.start(slint::TimerMode::Repeated, Duration::from_millis(16), move || { + let Some(ui) = weak.upgrade() else { return }; + let mut s = shared.lock(); + let mut changed = false; + while let Ok(ev) = rx.try_recv() { + match ev { + SerialEvent::Connected(port) => { + s.engine.set_connected(true); + ui.set_connected(true); + ui.set_connection_msg(format!("Connected on {port}").into()); + ui.set_retry_eta_secs(-1.0); + // Board reset on connect; re-push all desired LED state. + s.push_leds(); + changed = true; + } + SerialEvent::Disconnected { reason, retrying_in } => { + s.engine.set_connected(false); + ui.set_connected(false); + ui.set_connection_msg(reason.into()); + ui.set_retry_eta_secs(retrying_in.as_secs_f32()); + s.pending_retry_deadline = Some(Instant::now() + retrying_in); + changed = true; + } + SerialEvent::Frame(frame) => { + // Cheap diff on the UI thread; side effects go to the actuator. + let preset = s.preset.clone(); + let (dirty, cmds) = s.engine.ingest(&frame, &preset); + let mut led_toggled = false; + for cmd in cmds { + // Persist a manual-LED toggle into the preset so it + // survives a restart (the actuator owns the live state). + if let crate::events::Cmd::LedManualToggle(led) = &cmd { + if let Some(cfg) = s.preset.leds.get_mut(*led as usize).and_then(|c| c.as_mut()) { + cfg.manual_active = !cfg.manual_active; + led_toggled = true; + } + } + let _ = s.cmd_tx.send(cmd); + } + if led_toggled { + let _ = crate::storage::save_preset(&s.preset); + } + if dirty { changed = true; } + } + } + } + // Mirror live LED state into the configure popup (manual-mode readout). + if ui.get_led_popup_open() { + let led = ui.get_led_index() as usize; + let active = s.led_state.lock().get(led).map(|l| l.active).unwrap_or(false); + ui.set_led_live_active(active); + } + + // Animate retry countdown even between events. + if let Some(deadline) = s.pending_retry_deadline { + let remaining = deadline.saturating_duration_since(Instant::now()).as_secs_f32(); + if remaining > 0.0 { + ui.set_retry_eta_secs(remaining); + } + } + if changed { + let live = s.engine.state().clone(); + push_cells(&ui, &live.sliders, &live.buttons, &s.preset); + ui.set_badge_text(match live.badge { + crate::protocol::Badge::Supporter => "Supporter".into(), + crate::protocol::Badge::Premium => "Premium".into(), + _ => "".into(), + }); + } + }); + // Leak the timer so it survives until window close. + std::mem::forget(timer); +} + +// Helpers ---------------------------------------------------------------- + +/// Refresh the Home live cells from the last known board state — used after +/// preset/category edits so names update without waiting for a board input. +fn refresh_home(ui: &AppWindow, s: &Shared) { + let live = s.engine.state().clone(); + push_cells(ui, &live.sliders, &live.buttons, &s.preset); +} + +fn push_cells(ui: &AppWindow, sliders: &[i32; NUM_SLIDERS], buttons: &[u8; NUM_BUTTONS], preset: &Preset) { + let pts = crate::curve::BezierPoints::for_preset( + preset_curve(preset).0, preset_curve(preset).1); + let _ = pts; // curve preview unused at the cell level + let slider_cells: Vec = sliders + .iter().enumerate().map(|(i, &v)| { + let pct = crate::curve::normalize_raw(v); + let cat = preset.slider_category(i); + SliderCell { + value: v, + percent: pct, + category: cat.map(|c| c.name.clone()).unwrap_or_default().into(), + assigned: cat.is_some(), + } + }).collect(); + let button_cells: Vec = buttons + .iter().enumerate().map(|(i, &b)| { + let cat = preset.button_category(i); + ButtonCell { + pressed: b != 0, + category: cat.map(|c| c.name.clone()).unwrap_or_default().into(), + assigned: cat.is_some(), + } + }).collect(); + ui.set_sliders(ModelRc::new(VecModel::from(slider_cells))); + let (top, right): (Vec<_>, Vec<_>) = button_cells.into_iter().enumerate() + .partition(|(i, _)| *i < 6); + let top: Vec<_> = top.into_iter().map(|(_, b)| b).collect(); + let right: Vec<_> = right.into_iter().map(|(_, b)| b).collect(); + ui.set_top_buttons(ModelRc::new(VecModel::from(top))); + ui.set_right_buttons(ModelRc::new(VecModel::from(right))); +} + +fn preset_curve(_preset: &Preset) -> (crate::curve::CurvePreset, crate::curve::BezierPoints) { + (crate::curve::CurvePreset::Linear, crate::curve::BezierPoints::LINEAR) +} + +/// Move the category with `from_id` to insertion index `to_index` (0..=len). +fn reorder_by_id(cats: &mut Vec, from_id: u32, to_index: usize, id: impl Fn(&C) -> u32) { + let Some(from) = cats.iter().position(|c| id(c) == from_id) else { return }; + let item = cats.remove(from); + // `to_index` was computed against the original list; removing the item + // shifts everything after it down by one. + let to = if from < to_index { to_index - 1 } else { to_index }; + cats.insert(to.min(cats.len()), item); +} + +/// Move element `from_idx` of category `from_cat` to insertion index `to_idx` of +/// category `to_cat` (may be the same category). `to_idx` is in the destination's +/// original ordering. +fn move_line( + cats: &mut [C], + from_cat: u32, + from_idx: usize, + to_cat: u32, + to_idx: usize, + id: impl Fn(&C) -> u32, + lines: impl Fn(&mut C) -> &mut Vec, +) { + let Some(from_pos) = cats.iter().position(|c| id(c) == from_cat) else { return }; + let Some(to_pos) = cats.iter().position(|c| id(c) == to_cat) else { return }; + let item = { + let v = lines(&mut cats[from_pos]); + if from_idx >= v.len() { + return; + } + v.remove(from_idx) + }; + // Within the same category, a removal before the target shifts it down. + let to = if from_cat == to_cat && from_idx < to_idx { to_idx - 1 } else { to_idx }; + let v = lines(&mut cats[to_pos]); + v.insert(to.min(v.len()), item); +} + +fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { + let sliders: Vec = preset + .slider_categories + .iter() + .map(|c| CategorySummary { + id: c.id as i32, + name: c.name.clone().into(), + count: c.streams.len() as i32, + collapsed: c.collapsed, + lines: ModelRc::new(VecModel::from( + c.streams.iter().enumerate().map(|(i, s)| LineItem { + id: i as i32, + primary: s.label().into(), + secondary: stream_secondary(s).into(), + icon_kind: stream_icon_kind(s), + }).collect::>(), + )), + }) + .collect(); + let buttons: Vec = preset + .button_categories + .iter() + .map(|c| CategorySummary { + id: c.id as i32, + name: c.name.clone().into(), + count: c.actions.len() as i32, + collapsed: c.collapsed, + lines: ModelRc::new(VecModel::from( + c.actions.iter().enumerate().map(|(i, a)| LineItem { + id: i as i32, + primary: SharedString::from(a.kind.label()), + secondary: action_secondary(a).into(), + icon_kind: action_icon_kind(a.kind), + }).collect::>(), + )), + }) + .collect(); + ui.set_slider_categories(ModelRc::new(VecModel::from(sliders))); + ui.set_button_categories(ModelRc::new(VecModel::from(buttons))); + ui.set_active_preset_name(preset.name.clone().into()); + ui.set_preset_names(ModelRc::new(VecModel::from( + crate::storage::list_presets().into_iter().map(SharedString::from).collect::>(), + ))); +} + +fn stream_icon_kind(s: &AudioStream) -> i32 { + if s.api.is_some() { 3 } + else if s.process.is_some() { 0 } + else if s.mic_name.is_some() { 1 } + else { 2 } +} + +/// Secondary line for a button action. Cycle-output derives its label from the +/// device list (so it stays correct without a stored display); others fall back +/// to the display, then the raw property. +fn action_secondary(a: &crate::model::ButtonAction) -> String { + if a.kind == ActionKind::CycleOutput { + let names: Vec<&str> = + a.property.as_deref().unwrap_or("").lines().filter(|s| !s.is_empty()).collect(); + return if names.is_empty() { + "All output devices".into() + } else { + names.join(" → ") + }; + } + if a.kind == ActionKind::ApiCall { + return a.api.as_ref().map(|api| api.label()).unwrap_or_else(|| "API call".into()); + } + a.display.clone().or_else(|| a.property.clone()).unwrap_or_default() +} + +fn action_icon_kind(k: ActionKind) -> i32 { + use ActionKind::*; + match k { + MuteProcess => 0, MuteMic => 1, MuteMainAudio => 5, + OpenProcess => 0, OpenWebsite => 3, KeyPress => 4, CycleOutput => 2, ApiCall => 3, + } +} + +fn stream_secondary(s: &AudioStream) -> String { + if s.api.is_some() { + "API call".into() + } else if s.process.is_some() { + "process".into() + } else if s.mic_name.is_some() { + "microphone".into() + } else if s.device_name.is_some() { + "output device".into() + } else { + "default output".into() + } +} + +/// Push system (global) + appearance/slider (per-profile) settings into the UI. +fn push_settings_to_ui(ui: &AppWindow, global: &Settings, profile: &crate::model::ProfileSettings) { + // System (global) + // Gates the two tray rows: only Windows ships a tray (`src/tray.rs`), and + // without one "start minimized" would leave the app running with no window + // and no way to reopen it. + ui.set_has_tray(cfg!(target_os = "windows")); + ui.set_start_with_os(global.start_with_os); + ui.set_start_minimized(global.start_minimized); + ui.set_minimize_to_tray(global.minimize_to_tray); + ui.set_led_experimental(global.led_experimental); + ui.set_active_preset_name(global.active_preset.clone().into()); + // Appearance + sliders (profile) + ui.set_theme_index(match profile.theme { + ThemeMode::System => 0, ThemeMode::Light => 1, ThemeMode::Dark => 2, + }); + ui.set_accent_preset(profile.accent_preset); + ui.set_dead_zone(profile.slider_dead_zone); + ui.set_unmute_on_change(profile.unmute_on_change); + ui.set_curve_preset_index(curve_preset_to_index(profile.curve_preset)); + push_curve_to_ui(ui, profile); +} + +/// Push the active curve's control points + editability into the UI. +fn push_curve_to_ui(ui: &AppWindow, profile: &crate::model::ProfileSettings) { + let pts = crate::curve::BezierPoints::for_preset(profile.curve_preset, profile.custom_curve); + ui.set_curve_x1(pts.x1); + ui.set_curve_y1(pts.y1); + ui.set_curve_x2(pts.x2); + ui.set_curve_y2(pts.y2); + ui.set_curve_editable(matches!(profile.curve_preset, crate::curve::CurvePreset::Custom)); +} + +fn pull_global_from_ui(ui: &AppWindow, g: &mut Settings) { + g.start_with_os = ui.get_start_with_os(); + g.start_minimized = ui.get_start_minimized(); + g.minimize_to_tray = ui.get_minimize_to_tray(); + g.led_experimental = ui.get_led_experimental(); +} + +fn pull_profile_from_ui(ui: &AppWindow, p: &mut crate::model::ProfileSettings) { + p.theme = match ui.get_theme_index() { 1 => ThemeMode::Light, 2 => ThemeMode::Dark, _ => ThemeMode::System }; + p.accent_preset = ui.get_accent_preset(); + p.slider_dead_zone = ui.get_dead_zone(); + p.unmute_on_change = ui.get_unmute_on_change(); + p.curve_preset = index_to_curve_preset(ui.get_curve_preset_index()); +} + +/// Apply a profile's appearance (theme + accent) to the live UI. +fn apply_appearance(ui: &AppWindow, profile: &crate::model::ProfileSettings) { + apply_color_scheme(ui, profile.theme); + ui.set_accent_preset(profile.accent_preset); + ui.invoke_apply_accent(profile.accent_preset); +} + +fn curve_preset_to_index(p: crate::curve::CurvePreset) -> i32 { + use crate::curve::CurvePreset::*; + match p { Linear => 0, Ease => 1, EaseIn => 2, EaseOut => 3, EaseInOut => 4, Custom => 5 } +} +fn index_to_curve_preset(i: i32) -> crate::curve::CurvePreset { + use crate::curve::CurvePreset::*; + match i { 1 => Ease, 2 => EaseIn, 3 => EaseOut, 4 => EaseInOut, 5 => Custom, _ => Linear } +} + +/// Build an [`ApiCall`] from the wizard result's API fields. +fn api_from_result(r: &WizardResult) -> ApiCall { + let opt = |s: String| if s.is_empty() { None } else { Some(s) }; + ApiCall { + method: HttpMethod::from_index(r.method), + url: r.url.trim().to_string(), + payload: opt(r.payload.to_string()), + bearer: opt(r.bearer.trim().to_string()), + throttle_ms: r.throttle.clamp( + crate::model::API_THROTTLE_MIN_MS, + crate::model::API_THROTTLE_MAX_MS, + ), + } +} + +/// Push an existing [`ApiCall`] (or defaults) into the wizard API fields. +fn push_wizard_api(ui: &AppWindow, api: Option<&ApiCall>) { + match api { + Some(api) => { + ui.set_wizard_method(api.method.to_index()); + ui.set_wizard_url(api.url.clone().into()); + ui.set_wizard_payload(api.payload.clone().unwrap_or_default().into()); + ui.set_wizard_bearer(api.bearer.clone().unwrap_or_default().into()); + ui.set_wizard_throttle(api.throttle_ms); + } + None => reset_wizard_api(ui), + } +} + +fn reset_wizard_api(ui: &AppWindow) { + ui.set_wizard_method(HttpMethod::default().to_index()); + ui.set_wizard_url("".into()); + ui.set_wizard_payload("".into()); + ui.set_wizard_bearer("".into()); + ui.set_wizard_throttle(crate::model::default_api_throttle_ms()); +} + +fn action_kind_index(k: ActionKind) -> i32 { + use ActionKind::*; + match k { + MuteProcess => 0, MuteMainAudio => 1, MuteMic => 2, + OpenProcess => 3, OpenWebsite => 4, KeyPress => 5, CycleOutput => 6, ApiCall => 7, + } +} + +fn button_kind_from_index(i: i32) -> ActionKind { + use ActionKind::*; + match i { + 0 => MuteProcess, 1 => MuteMainAudio, 2 => MuteMic, + 3 => OpenProcess, 4 => OpenWebsite, 6 => CycleOutput, 7 => ApiCall, _ => KeyPress, + } +} + +fn apply_color_scheme(ui: &AppWindow, mode: ThemeMode) { + // Trigger the slint-side `apply-theme(idx)` which sets `Palette.color-scheme`. + let idx = match mode { + ThemeMode::System => 0, + ThemeMode::Light => 1, + ThemeMode::Dark => 2, + }; + ui.invoke_apply_theme(idx); +} + +fn push_wizard_picker(ui: &AppWindow, shared: Arc>) { + let target_kind = ui.get_wizard_target_kind(); + let kind = ui.get_wizard_kind(); + let filter = ui.get_wizard_filter().to_string().to_lowercase(); + + let s = shared.lock(); + let source: Vec = match (target_kind, kind) { + // Slider streams + (0, 0) => merged_processes(s.audio.as_ref()), + (0, 1) => s.audio.list_mics(), + (0, 2) => s.audio.list_outputs(), + // Button actions + (1, 0) => merged_processes(s.audio.as_ref()), + (1, 1) => s.audio.list_outputs(), + (1, 2) => s.audio.list_mics(), + // Simulate key — full library + (1, 5) => crate::keys_library::KEYS.iter().map(|k| k.name.to_string()).collect(), + // Cycle output device — multi-select list of outputs + (1, 6) => s.audio.list_outputs(), + _ => Vec::new(), + }; + drop(s); + + // Multi-select kinds (currently Cycle output) keep their chosen set in the + // wizard property as a newline-separated list; flag matching rows as selected. + let multi_select = target_kind == 1 && kind == 6; + let chosen: Vec = if multi_select { + ui.get_wizard_property().to_string().lines().map(str::to_string).collect() + } else { + Vec::new() + }; + + let filter_norm = filter.trim(); + let mut filtered: Vec = if filter_norm.is_empty() { + source + } else { + source.into_iter().filter(|n| n.to_lowercase().contains(filter_norm)).collect() + }; + // For non-key lists, dedup + sort. Keep key library order. + if !(target_kind == 1 && kind == 5) { + filtered.sort(); + filtered.dedup(); + } + let icon_kind: i32 = match (target_kind, kind) { + (_, 1) if target_kind == 0 => 1, // slider mic + (0, 2) => 2, // slider output + (1, 2) => 1, // button mic + (1, 1) => 2, // button output (main audio) + (1, 5) => 4, // key + (1, 6) => 2, // cycle output + _ => 0, // process + }; + ui.set_wizard_picker_source(ModelRc::new(VecModel::from( + filtered.into_iter() + .map(|n| crate::PickerEntry { + selected: chosen.iter().any(|c| c == &n), + name: n.into(), + icon_kind, + }) + .collect::>(), + ))); +} + +/// Merge sessions-with-audio with all running processes, so the picker isn't +/// empty when no audio is playing yet. +fn merged_processes(audio: &dyn AudioBackend) -> Vec { + let mut out = audio.list_processes(); + out.extend(list_running_processes()); + out +} + +fn populate_live_lists(ui: &AppWindow, shared: Arc>) { + let s = shared.lock(); + let mut procs = s.audio.list_processes(); + let mut mics = s.audio.list_mics(); + let mut outs = s.audio.list_outputs(); + // Also surface system processes (running apps) on top of audio-active ones, dedup. + procs.extend(list_running_processes()); + procs.sort(); + procs.dedup(); + mics.sort(); mics.dedup(); + outs.sort(); outs.dedup(); + ui.set_live_processes(ModelRc::new(VecModel::from( + procs.into_iter().map(SharedString::from).collect::>(), + ))); + ui.set_live_mics(ModelRc::new(VecModel::from( + mics.into_iter().map(SharedString::from).collect::>(), + ))); + ui.set_live_outputs(ModelRc::new(VecModel::from( + outs.into_iter().map(SharedString::from).collect::>(), + ))); +} + +#[cfg(target_os = "linux")] +fn list_running_processes() -> Vec { + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir("/proc") { + for e in rd.flatten() { + let n = e.file_name(); + let n = n.to_string_lossy(); + if n.is_empty() || !n.chars().all(|c| c.is_ascii_digit()) { continue } + if let Ok(comm) = std::fs::read_to_string(format!("/proc/{n}/comm")) { + let comm = comm.trim(); + if comm.is_empty() { continue } + out.push(untruncate_comm(&n, comm)); + } + } + } + out +} + +/// The kernel caps `/proc//comm` at 15 characters (`TASK_COMM_LEN - 1`), so +/// long binaries show up clipped ("gnome-terminal-", "systemd-journal") and then +/// fail to match what PulseAudio reports as `application.process.binary`. When a +/// name is exactly at the cap, recover the full one from `cmdline`'s argv[0]. +#[cfg(target_os = "linux")] +fn untruncate_comm(pid: &str, comm: &str) -> String { + const TASK_COMM_MAX: usize = 15; + if comm.len() < TASK_COMM_MAX { + return comm.to_string(); + } + let Ok(cmdline) = std::fs::read_to_string(format!("/proc/{pid}/cmdline")) else { + return comm.to_string(); + }; + let argv0 = cmdline.split('\0').next().unwrap_or(""); + let base = argv0.rsplit('/').next().unwrap_or(""); + // Only trust it when it really is the same program, just not clipped - + // argv[0] can be an interpreter path or a rewritten title. + if base.len() > comm.len() && base.starts_with(comm) { + base.to_string() + } else { + comm.to_string() + } +} + +#[cfg(target_os = "windows")] +fn list_running_processes() -> Vec { + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, + }; + use windows::Win32::Foundation::CloseHandle; + let mut out = Vec::new(); + unsafe { + let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else { return out }; + let mut entry = PROCESSENTRY32W { dwSize: std::mem::size_of::() as u32, ..Default::default() }; + if Process32FirstW(snap, &mut entry).is_ok() { + loop { + let len = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..len]); + if !name.is_empty() { out.push(name); } + if Process32NextW(snap, &mut entry).is_err() { break } + } + } + let _ = CloseHandle(snap); + } + out +} + +// ── LED config <-> UI conversions ────────────────────────────────────────── + +fn opt(s: &str) -> Option { + if s.is_empty() { None } else { Some(s.to_string()) } +} + +fn led_mode_kind_to(k: LedModeKind) -> i32 { + match k { LedModeKind::Off => 0, LedModeKind::On => 1, LedModeKind::Blink => 2, LedModeKind::Breathe => 3 } +} +fn audio_kind_to(k: AudioKind) -> i32 { + match k { AudioKind::Process => 0, AudioKind::Mic => 1, AudioKind::Output => 2 } +} + +/// Snapshot a UI string-model into a Vec. +fn ui_list(model: &ModelRc) -> Vec { + (0..model.row_count()).filter_map(|i| model.row_data(i)).map(|s| s.to_string()).collect() +} + +/// Index of `target` within the live list for audio kind `audio` (-1 if absent). +fn list_index_of(ui: &AppWindow, audio: i32, target: &str) -> i32 { + if target.is_empty() { + return -1; + } + let list = match audio { + 1 => ui_list(&ui.get_live_mics()), + 2 => ui_list(&ui.get_live_outputs()), + _ => ui_list(&ui.get_live_processes()), + }; + list.iter().position(|x| x == target).map(|p| p as i32).unwrap_or(-1) +} + +fn led_mode_from_ui( + kind: i32, on: &str, off: &str, cycle: &str, + bmode: i32, bright: i32, baudio: i32, btarget: &str, +) -> LedMode { + let kind = match kind { + 1 => LedModeKind::On, + 2 => LedModeKind::Blink, + 3 => LedModeKind::Breathe, + _ => LedModeKind::Off, + }; + LedMode { + kind, + on_ms: on.trim().parse().unwrap_or(500).max(1), + off_ms: off.trim().parse().unwrap_or(500).max(1), + cycle_ms: cycle.trim().parse().unwrap_or(2000).max(1), + brightness_mode: if bmode == 1 { BrightnessMode::Volume } else { BrightnessMode::Fixed }, + brightness: bright.clamp(0, 100) as u8, + brightness_audio: audio_kind_from(baudio), + brightness_target: opt(btarget), + } +} + +fn cond_kind_from(v: i32) -> LedConditionKind { + match v { 1 => LedConditionKind::Volume, 2 => LedConditionKind::Api, _ => LedConditionKind::Muted } +} +fn audio_kind_from(v: i32) -> AudioKind { + match v { 1 => AudioKind::Mic, 2 => AudioKind::Output, _ => AudioKind::Process } +} +fn cmp_from(v: i32) -> Comparison { + match v { 1 => Comparison::Above, 2 => Comparison::Exact, _ => Comparison::Below } +} + +fn cond_to_ui(c: &LedCondition, target_index: i32) -> LedConditionUI { + LedConditionUI { + kind: match c.kind { LedConditionKind::Muted => 0, LedConditionKind::Volume => 1, LedConditionKind::Api => 2 }, + audio_kind: audio_kind_to(c.audio_kind), + target: c.target.clone().unwrap_or_default().into(), + target_index, + cmp: match c.cmp { Comparison::Below => 0, Comparison::Above => 1, Comparison::Exact => 2 }, + value: c.value as i32, + method: c.api.method.to_index(), + url: c.api.url.clone().into(), + payload: c.api.payload.clone().unwrap_or_default().into(), + bearer: c.api.bearer.clone().unwrap_or_default().into(), + interval: c.api.interval_ms.to_string().into(), + expect_body: c.api.expect_body.clone().unwrap_or_default().into(), + } +} + +/// Rebuild the conditions model, resolving each target's index within the live +/// lists already on the UI (so its dropdown shows the right selection). +fn push_led_conditions(ui: &AppWindow, conds: &[LedCondition]) { + let rows: Vec = conds + .iter() + .map(|c| { + let tidx = list_index_of(ui, audio_kind_to(c.audio_kind), c.target.as_deref().unwrap_or("")); + cond_to_ui(c, tidx) + }) + .collect(); + ui.set_led_conditions(ModelRc::new(VecModel::from(rows))); +} + +/// Push one LED appearance into the UI scalar props (active or inactive). +fn push_mode_to_ui(ui: &AppWindow, m: &LedMode, active: bool) { + let kind = led_mode_kind_to(m.kind); + let on: SharedString = m.on_ms.to_string().into(); + let off: SharedString = m.off_ms.to_string().into(); + let cycle: SharedString = m.cycle_ms.to_string().into(); + let bmode = if m.brightness_mode == BrightnessMode::Volume { 1 } else { 0 }; + let bright = m.brightness as i32; + let baudio = audio_kind_to(m.brightness_audio); + let btarget: SharedString = m.brightness_target.clone().unwrap_or_default().into(); + let btidx = list_index_of(ui, baudio, m.brightness_target.as_deref().unwrap_or("")); + if active { + ui.set_led_active_kind(kind); + ui.set_led_active_on(on); + ui.set_led_active_off(off); + ui.set_led_active_cycle(cycle); + ui.set_led_active_bmode(bmode); + ui.set_led_active_bright(bright); + ui.set_led_active_baudio(baudio); + ui.set_led_active_btarget(btarget); + ui.set_led_active_btidx(btidx); + } else { + ui.set_led_inactive_kind(kind); + ui.set_led_inactive_on(on); + ui.set_led_inactive_off(off); + ui.set_led_inactive_cycle(cycle); + ui.set_led_inactive_bmode(bmode); + ui.set_led_inactive_bright(bright); + ui.set_led_inactive_baudio(baudio); + ui.set_led_inactive_btarget(btarget); + ui.set_led_inactive_btidx(btidx); + } +} + +/// Apply `f` to condition `idx` of the working LED config (no UI re-push). +fn mutate_cond(shared: &Arc>, idx: i32, f: F) { + let mut s = shared.lock(); + if let Some((_, cfg)) = s.editing_led.as_mut() { + if let Some(c) = cfg.conditions.get_mut(idx as usize) { + f(c); + } + } +} + +/// Like [`mutate_cond`] but returns the updated condition list so the caller can +/// re-push the model (for structural changes that show/hide dependent fields). +fn mutate_cond_struct( + shared: &Arc>, + idx: i32, + f: F, +) -> Option> { + let mut s = shared.lock(); + let (_, cfg) = s.editing_led.as_mut()?; + if let Some(c) = cfg.conditions.get_mut(idx as usize) { + f(c); + } + Some(cfg.conditions.clone()) +} + +fn toast(ui: &AppWindow, msg: &str) { + ui.set_toast_text(msg.into()); + let weak = ui.as_weak(); + let timer = slint::Timer::default(); + timer.start(slint::TimerMode::SingleShot, Duration::from_millis(1800), move || { + if let Some(ui) = weak.upgrade() { + ui.set_toast_text("".into()); + } + }); + std::mem::forget(timer); +}