c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1 | //! `HeadroomApp` — iced application state + view; mirrors the tui's |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 2 | //! read-only `UiState` (`crates/headroom-cli/src/tui.rs`). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 3 | //! |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 4 | //! control ops never block the ui thread: sent over `cmd_tx` to the |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 5 | //! control thread (`io.rs`), which replies over the same `rx` the ui |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 6 | //! drains each tick. ui updates optimistically; daemon events + the |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 7 | //! ~1 Hz `per-app.list` refresh reconcile. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 8 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 9 | use std::collections::BTreeMap; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 10 | use std::time::{Duration, Instant}; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 11 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 12 | use crossbeam_channel::{Receiver, Sender}; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 13 | use iced::alignment::Horizontal; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 14 | use iced::widget::{button, column, container, mouse_area, pick_list, progress_bar, row, scrollable, text}; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 15 | use iced::{keyboard, time, Alignment, Background, Border, Color, Element, Length, Subscription}; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 16 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 17 | use headroom_ipc::{ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 18 | DaemonEvent, Event, LayerALevel, LayerASnapshot, MeterTick, ProfileInfo, Route, RoutingEvent, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 19 | StreamRoute, Topic, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 20 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 21 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 22 | use crate::io::{AppMsg, Bootstrap, ControlCmd, Snapshot}; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 23 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 24 | /// loudness at/below this floor renders as "--". mirrors |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 25 | /// `headroom_core::agc::LOUDNESS_FLOOR_LUFS`, inlined so the gui doesn't |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 26 | /// pull in the audio/dsp crate. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 27 | const LOUDNESS_FLOOR_LUFS: f32 = -200.0; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 28 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 29 | /// meters older than this are dimmed (source went silent / suspended). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 30 | const STALE_AFTER: Duration = Duration::from_millis(500); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 31 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 32 | /// ui drain + repaint interval (~20 Hz); also advances uptime/staleness |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 33 | /// text with no events flowing. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 34 | const POLL_INTERVAL: Duration = Duration::from_millis(50); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 35 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 36 | // palette — explicit colors mirroring the tui's intent. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 37 | const BG: u32 = 0x1e1e1e; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 38 | const PANEL_BG: u32 = 0x252526; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 39 | const TRACK_BG: u32 = 0x3a3a3a; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 40 | const FG: u32 = 0xd4d4d4; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 41 | const DIM: u32 = 0x808080; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 42 | const CYAN: u32 = 0x4ec9b0; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 43 | const MAGENTA: u32 = 0xc586c0; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 44 | const RED: u32 = 0xf44747; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 45 | const GREEN: u32 = 0x6a9955; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 46 | const YELLOW: u32 = 0xdcdcaa; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 47 | const BLACK: u32 = 0x101010; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 48 | /// tint behind the selected stream row. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 49 | const SEL_BG: u32 = 0x094771; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 50 | /// hover/press tint for header + row buttons. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 51 | const HOVER_BG: u32 = 0x37373a; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 52 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 53 | /// poll ticks between `per-app.list` refreshes — ~1 Hz at [`POLL_INTERVAL`]. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 54 | const REFRESH_EVERY_TICKS: u32 = 20; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 55 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 56 | /// poll ticks between `profile.list` refreshes — ~10 s. the profile *set* |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 57 | /// only changes on `profile reload`; the active profile updates instantly |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 58 | /// via `profile.changed`. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 59 | const REFRESH_PROFILES_EVERY_TICKS: u32 = 200; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 60 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 61 | #[derive(Debug, Clone)] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 62 | pub enum Message { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 63 | /// poll tick: drain `rx`, repaint, periodically refresh. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 64 | Tick, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 65 | /// a keypress / event we don't act on. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 66 | Noop, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 67 | /// select a stream row by node id (mouse click). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 68 | Select(u32), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 69 | /// move the selection up / down one row (k/↑, j/↓). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 70 | SelectUp, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 71 | SelectDown, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 72 | ToggleBypass, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 73 | TogglePerAppMaster, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 74 | /// toggle a stream's route (processed ↔ bypass). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 75 | ToggleRoute(u32), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 76 | /// toggle per-app management for a stream's app. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 77 | TogglePerApp(u32), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 78 | /// reset a stream's layer a deference. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 79 | ResetLayerA(u32), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 80 | /// hotkey variants acting on the current selection. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 81 | RouteSelected, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 82 | PerAppSelected, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 83 | ResetSelected, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 84 | /// switch to the named profile (pick_list). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 85 | ProfileSelected(String), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 86 | /// clear all overlay setting overrides. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 87 | ClearOverrides, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 88 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 89 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 90 | /// mirrored daemon snapshot plus the live event channel it drains. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 91 | pub struct HeadroomApp { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 92 | daemon_version: String, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 93 | profile: String, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 94 | bypass: bool, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 95 | /// layer a master switch (per-app level control enabled globally). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 96 | per_app_master: bool, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 97 | /// overlay overrides (dotted key → value) shadowing the profile; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 98 | /// surfaced so a stale `headroom set` isn't invisible. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 99 | setting_overrides: BTreeMap<String, serde_json::Value>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 100 | /// daemon uptime at connect, plus local elapsed. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 101 | base_uptime_s: u64, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 102 | connected_at: Instant, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 103 | default_route: Route, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 104 | streams: BTreeMap<u32, StreamRoute>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 105 | /// per-stream layer a reduction (dB). presence = managed; inner `None` |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 106 | /// until the first `layer_a_level`. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 107 | layer_a: BTreeMap<u32, Option<f32>>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 108 | /// richer per-stream snapshots (managed/ceiling/deferred), polled from |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 109 | /// `per-app.list`; drives the per-app toggle target + row label. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 110 | la_snapshots: BTreeMap<u32, LayerASnapshot>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 111 | /// profiles for the switcher (seeded at connect). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 112 | profiles: Vec<ProfileInfo>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 113 | /// selected stream node id; resolved via [`Self::effective_selection`]. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 114 | selected: Option<u32>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 115 | meters: Option<MeterTick>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 116 | last_meter_at: Option<Instant>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 117 | overflow_total: u64, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 118 | last_error: Option<String>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 119 | disconnected: Option<String>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 120 | /// live event + worker stream from the reader / control threads. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 121 | rx: Receiver<AppMsg>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 122 | /// hands control ops to the control thread. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 123 | cmd_tx: Sender<ControlCmd>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 124 | /// poll-tick counter for the paced `per-app.list` refresh. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 125 | tick_count: u32, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 126 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 127 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 128 | impl HeadroomApp { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 129 | /// seed initial state from the snapshot, take ownership of the channel. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 130 | pub fn new(boot: Bootstrap) -> Self { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 131 | let Bootstrap { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 132 | snapshot, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 133 | rx, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 134 | cmd_tx, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 135 | } = boot; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 136 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 137 | let mut app = Self { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 138 | daemon_version: String::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 139 | profile: String::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 140 | bypass: false, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 141 | per_app_master: false, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 142 | setting_overrides: BTreeMap::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 143 | base_uptime_s: 0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 144 | connected_at: Instant::now(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 145 | default_route: Route::Processed, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 146 | streams: BTreeMap::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 147 | layer_a: BTreeMap::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 148 | la_snapshots: BTreeMap::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 149 | profiles: Vec::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 150 | selected: None, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 151 | meters: None, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 152 | last_meter_at: None, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 153 | overflow_total: 0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 154 | last_error: None, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 155 | disconnected: None, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 156 | rx, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 157 | cmd_tx, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 158 | tick_count: 0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 159 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 160 | app.apply_snapshot(snapshot); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 161 | // initial selection: first stream row (reconnects preserve the |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 162 | // user's selection, so this only runs at startup). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 163 | app.selected = app.streams.keys().next().copied(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 164 | app |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 165 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 166 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 167 | /// replace mirrored daemon state from a fresh [`Snapshot`]. shared by |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 168 | /// [`Self::new`] + [`Self::reseed`]; leaves `selected` / channels / |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 169 | /// tick counter to the caller. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 170 | fn apply_snapshot(&mut self, snapshot: Snapshot) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 171 | let Snapshot { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 172 | status, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 173 | route_list, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 174 | profiles, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 175 | } = snapshot; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 176 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 177 | let mut streams = BTreeMap::new(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 178 | for s in route_list.current { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 179 | streams.insert(s.node_id, s); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 180 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 181 | // `status.streams` is a superset; merge any not already present. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 182 | for s in status.streams.iter() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 183 | streams.entry(s.node_id).or_insert_with(|| s.clone()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 184 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 185 | // seed meter column + richer snapshots from status (mirrors tui). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 186 | let mut layer_a = BTreeMap::new(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 187 | let mut la_snapshots = BTreeMap::new(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 188 | for snap in status.layer_a { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 189 | layer_a.insert(snap.node_id, Some(snap.reduction_db)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 190 | la_snapshots.insert(snap.node_id, snap); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 191 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 192 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 193 | self.daemon_version = status.version; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 194 | self.profile = status.profile; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 195 | self.bypass = status.bypass; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 196 | self.per_app_master = status.per_app; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 197 | self.setting_overrides = status.setting_overrides; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 198 | self.base_uptime_s = status.uptime_s; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 199 | self.connected_at = Instant::now(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 200 | self.default_route = route_list.default_route; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 201 | self.streams = streams; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 202 | self.layer_a = layer_a; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 203 | self.la_snapshots = la_snapshots; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 204 | self.profiles = profiles; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 205 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 206 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 207 | /// re-seed after the reader reconnected to a (possibly new) daemon. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 208 | /// clears the disconnect/error banners + per-instance overflow counter |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 209 | /// (a restarted daemon resets its drop count). `selected` is preserved — |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 210 | /// `effective_selection` reconciles it against the new stream set. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 211 | fn reseed(&mut self, snapshot: Snapshot) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 212 | self.apply_snapshot(snapshot); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 213 | self.overflow_total = 0; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 214 | self.last_error = None; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 215 | self.disconnected = None; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 216 | self.last_meter_at = None; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 217 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 218 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 219 | pub fn title(&self) -> String { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 220 | "headroom monitor".to_string() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 221 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 222 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 223 | /// poll tick (`smol` backs `time::every`) + global keyboard input. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 224 | /// `keyboard::listen` only surfaces keys not consumed by a focused |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 225 | /// widget, so the pick_list keeps its own arrow/enter handling. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 226 | pub fn subscription(&self) -> Subscription<Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 227 | Subscription::batch([ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 228 | time::every(POLL_INTERVAL).map(|_| Message::Tick), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 229 | keyboard::listen().map(map_key), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 230 | ]) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 231 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 232 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 233 | pub fn update(&mut self, message: Message) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 234 | match message { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 235 | Message::Noop => {} |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 236 | Message::Tick => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 237 | self.pump(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 238 | self.tick_count = self.tick_count.wrapping_add(1); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 239 | if self.tick_count.is_multiple_of(REFRESH_EVERY_TICKS) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 240 | // reconcile managed/ceiling/deferred state the event |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 241 | // stream doesn't push (mirrors the tui's poll). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 242 | self.send_cmd(ControlCmd::RefreshLayerA); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 243 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 244 | if self.tick_count.is_multiple_of(REFRESH_PROFILES_EVERY_TICKS) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 245 | // pick up profiles added/removed by `profile reload`. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 246 | self.send_cmd(ControlCmd::RefreshProfiles); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 247 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 248 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 249 | Message::Select(id) => self.selected = Some(id), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 250 | Message::SelectUp => self.move_selection(-1), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 251 | Message::SelectDown => self.move_selection(1), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 252 | Message::ToggleBypass => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 253 | let target = !self.bypass; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 254 | self.send_cmd(ControlCmd::SetBypass(target)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 255 | self.bypass = target; // optimistic; no bypass event from daemon |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 256 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 257 | Message::TogglePerAppMaster => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 258 | let target = !self.per_app_master; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 259 | self.send_cmd(ControlCmd::SetPerAppMaster(target)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 260 | self.per_app_master = target; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 261 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 262 | Message::ToggleRoute(id) => self.toggle_route(id), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 263 | Message::TogglePerApp(id) => self.toggle_per_app(id), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 264 | Message::ResetLayerA(id) => self.reset_layer_a(id), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 265 | Message::RouteSelected => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 266 | if let Some(id) = self.effective_selection() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 267 | self.toggle_route(id); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 268 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 269 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 270 | Message::PerAppSelected => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 271 | if let Some(id) = self.effective_selection() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 272 | self.toggle_per_app(id); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 273 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 274 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 275 | Message::ResetSelected => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 276 | if let Some(id) = self.effective_selection() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 277 | self.reset_layer_a(id); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 278 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 279 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 280 | Message::ProfileSelected(name) => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 281 | // optimistic: update the header now; the daemon's "used" |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 282 | // event reconciles a frame or two later (mirrors the tui). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 283 | self.profile = name.clone(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 284 | self.send_cmd(ControlCmd::UseProfile(name)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 285 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 286 | Message::ClearOverrides => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 287 | // optimistic: drop the indicator now; the daemon clears the |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 288 | // overlay and reapplies the profile's values. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 289 | self.setting_overrides.clear(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 290 | self.send_cmd(ControlCmd::ClearOverrides); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 291 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 292 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 293 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 294 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 295 | /// drain + apply every queued worker message. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 296 | fn pump(&mut self) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 297 | while let Ok(msg) = self.rx.try_recv() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 298 | self.handle_msg(msg); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 299 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 300 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 301 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 302 | fn handle_msg(&mut self, msg: AppMsg) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 303 | match msg { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 304 | AppMsg::Event(ev) => self.apply_event(ev), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 305 | AppMsg::Disconnected(reason) => self.disconnected = Some(reason), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 306 | AppMsg::Reconnected(snapshot) => self.reseed(*snapshot), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 307 | AppMsg::ControlError(e) => self.last_error = Some(e), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 308 | AppMsg::Profiles(p) => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 309 | // reconcile the displayed profile from the `active` flag — |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 310 | // covers external `profile use` even if "used" was missed. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 311 | if let Some(active) = p.iter().find(|x| x.active) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 312 | self.profile = active.name.clone(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 313 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 314 | self.profiles = p; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 315 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 316 | AppMsg::LayerASnapshots(list) => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 317 | self.la_snapshots = list.into_iter().map(|s| (s.node_id, s)).collect(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 318 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 319 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 320 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 321 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 322 | /// queue a control op; a dead control thread lands in the footer. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 323 | fn send_cmd(&mut self, cmd: ControlCmd) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 324 | if self.cmd_tx.send(cmd).is_err() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 325 | self.last_error = Some("control thread gone".into()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 326 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 327 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 328 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 329 | /// stream node ids in row order (`BTreeMap` keys). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 330 | fn ordered_nodes(&self) -> Vec<u32> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 331 | self.streams.keys().copied().collect() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 332 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 333 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 334 | /// `selected` when still live, else the first row, else `None`. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 335 | fn effective_selection(&self) -> Option<u32> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 336 | match self.selected { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 337 | Some(id) if self.streams.contains_key(&id) => Some(id), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 338 | _ => self.streams.keys().next().copied(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 339 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 340 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 341 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 342 | /// move the selection by `delta` rows (negative = up), wrapping. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 343 | fn move_selection(&mut self, delta: isize) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 344 | let nodes = self.ordered_nodes(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 345 | if nodes.is_empty() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 346 | self.selected = None; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 347 | return; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 348 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 349 | let cur = self |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 350 | .effective_selection() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 351 | .and_then(|id| nodes.iter().position(|&n| n == id)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 352 | .unwrap_or(0) as isize; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 353 | let next = (cur + delta).rem_euclid(nodes.len() as isize) as usize; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 354 | self.selected = Some(nodes[next]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 355 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 356 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 357 | /// flip a stream's route, optimistically updating local state. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 358 | /// |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 359 | /// prefer a *persistent* per-app rule (`route.set`, keyed by app label) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 360 | /// so the route survives the app recreating its stream between tracks |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 361 | /// (Strawberry, mpv, …); fall back to a one-shot `route.stream` only |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 362 | /// when there's no label to key on. the optimistic update flips just |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 363 | /// this row; the daemon's reevaluate emits `stream_routed` for sibling |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 364 | /// streams of the same app, which `apply_event` reconciles. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 365 | fn toggle_route(&mut self, node: u32) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 366 | let Some((app, cur)) = self.streams.get(&node).map(|s| (s.app.clone(), s.route)) else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 367 | return; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 368 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 369 | let to = match cur { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 370 | Route::Processed => Route::Bypass, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 371 | Route::Bypass => Route::Processed, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 372 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 373 | if app.is_empty() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 374 | self.send_cmd(ControlCmd::RouteStream { node_id: node, to }); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 375 | } else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 376 | self.send_cmd(ControlCmd::SetRoute { app, to }); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 377 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 378 | if let Some(s) = self.streams.get_mut(&node) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 379 | s.route = to; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 380 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 381 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 382 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 383 | /// toggle per-app management for a stream's app. guards an empty |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 384 | /// label; reconciliation comes from the poll. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 385 | fn toggle_per_app(&mut self, node: u32) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 386 | let Some(app) = self.streams.get(&node).map(|s| s.app.clone()) else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 387 | return; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 388 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 389 | if app.is_empty() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 390 | self.last_error = Some("per-app: selected stream has no app label".into()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 391 | return; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 392 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 393 | let managed = self.la_snapshots.get(&node).is_some_and(|s| s.managed); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 394 | self.send_cmd(ControlCmd::SetPerApp { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 395 | app, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 396 | enabled: !managed, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 397 | }); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 398 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 399 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 400 | /// reset a stream's layer a deference. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 401 | fn reset_layer_a(&mut self, node: u32) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 402 | self.send_cmd(ControlCmd::ResetLayerA { node_id: node }); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 403 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 404 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 405 | fn uptime_s(&self) -> u64 { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 406 | self.base_uptime_s |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 407 | .saturating_add(self.connected_at.elapsed().as_secs()) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 408 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 409 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 410 | fn meters_stale(&self) -> bool { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 411 | self.last_meter_at |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 412 | .map(|t| t.elapsed() > STALE_AFTER) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 413 | .unwrap_or(true) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 414 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 415 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 416 | /// apply one wire event. ported from the tui's `apply_event`, minus |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 417 | /// the selection/snapshot bookkeeping the read-only gui doesn't need. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 418 | fn apply_event(&mut self, ev: Event) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 419 | match ev.topic { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 420 | Topic::Meters if ev.event == "tick" => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 421 | if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 422 | self.meters = Some(m); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 423 | self.last_meter_at = Some(Instant::now()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 424 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 425 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 426 | Topic::Meters if ev.event == "layer_a_level" => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 427 | if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 428 | self.layer_a.insert(l.node_id, Some(l.reduction_db)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 429 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 430 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 431 | Topic::Routing => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 432 | if let Ok(re) = serde_json::from_value::<RoutingEvent>(inject_event(&ev)) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 433 | match re { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 434 | RoutingEvent::StreamRouted { node_id, app, to } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 435 | self.streams.insert( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 436 | node_id, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 437 | StreamRoute { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 438 | node_id, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 439 | app, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 440 | route: to, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 441 | }, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 442 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 443 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 444 | RoutingEvent::StreamRemoved { node_id } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 445 | self.streams.remove(&node_id); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 446 | self.layer_a.remove(&node_id); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 447 | self.la_snapshots.remove(&node_id); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 448 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 449 | RoutingEvent::LayerAAttached { node_id, .. } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 450 | self.layer_a.entry(node_id).or_insert(None); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 451 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 452 | RoutingEvent::LayerADetached { node_id } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 453 | self.layer_a.remove(&node_id); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 454 | self.la_snapshots.remove(&node_id); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 455 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 456 | RoutingEvent::RuleChanged => {} |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 457 | _ => {} |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 458 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 459 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 460 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 461 | Topic::Profile => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 462 | // the daemon emits `profile` "used" `{name}` on apply (not |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 463 | // the typed `ProfileEvent::Changed` "changed"/`previous` |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 464 | // shape), so read `name` directly and accept either spelling |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 465 | // — keeps the header in sync with a cli `profile use` too. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 466 | // on "reloaded" the profile *set* may have changed. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 467 | match ev.event.as_str() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 468 | "used" | "changed" => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 469 | if let Some(name) = ev.data.get("name").and_then(|v| v.as_str()) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 470 | self.profile = name.to_string(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 471 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 472 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 473 | "reloaded" => self.send_cmd(ControlCmd::RefreshProfiles), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 474 | _ => {} |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 475 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 476 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 477 | Topic::Daemon => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 478 | if let Ok(de) = serde_json::from_value::<DaemonEvent>(inject_event(&ev)) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 479 | match de { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 480 | DaemonEvent::Overflow { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 481 | lost, total_lost, .. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 482 | } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 483 | self.overflow_total = total_lost.max(self.overflow_total + lost as u64); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 484 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 485 | DaemonEvent::Error { code, message } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 486 | self.last_error = Some(format!("{code}: {message}")); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 487 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 488 | DaemonEvent::Shutdown => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 489 | self.disconnected = Some("daemon shutdown".into()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 490 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 491 | DaemonEvent::Started { version } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 492 | self.daemon_version = version; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 493 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 494 | _ => {} |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 495 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 496 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 497 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 498 | _ => {} |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 499 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 500 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 501 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 502 | // ----------------------------------------------------------------- |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 503 | // View |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 504 | // ----------------------------------------------------------------- |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 505 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 506 | pub fn view(&self) -> Element<'_, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 507 | let content = column![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 508 | self.header(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 509 | self.bus_panel(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 510 | self.loudness_panel(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 511 | self.streams_panel(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 512 | self.footer(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 513 | ] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 514 | .spacing(8) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 515 | .padding(12) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 516 | .width(Length::Fill) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 517 | .height(Length::Fill); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 518 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 519 | // paint the window with the base background. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 520 | container(content) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 521 | .width(Length::Fill) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 522 | .height(Length::Fill) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 523 | .style(|_theme: &iced::Theme| container::Style { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 524 | background: Some(Background::Color(col(BG))), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 525 | ..container::Style::default() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 526 | }) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 527 | .into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 528 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 529 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 530 | fn header(&self) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 531 | let bypass = if self.bypass { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 532 | badge_button("BYPASS", BLACK, YELLOW, Message::ToggleBypass) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 533 | } else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 534 | badge_button("processed", GREEN, PANEL_BG, Message::ToggleBypass) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 535 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 536 | // state semantics (not action): label = current state, click |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 537 | // toggles — same convention as the per-app row chip. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 538 | let per_app = if self.per_app_master { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 539 | badge_button("per-app on", CYAN, PANEL_BG, Message::TogglePerAppMaster) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 540 | } else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 541 | badge_button("per-app off", DIM, PANEL_BG, Message::TogglePerAppMaster) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 542 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 543 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 544 | // profile switcher: dropdown over the profile names. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 545 | let names: Vec<String> = self.profiles.iter().map(|p| p.name.clone()).collect(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 546 | let picker = pick_list(names, Some(self.profile.clone()), Message::ProfileSelected) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 547 | .text_size(14) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 548 | .padding(iced::Padding::from([2.0, 8.0])); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 549 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 550 | // left group fills the width so version/uptime is pushed right. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 551 | let mut left = row![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 552 | text("profile:").color(col(DIM)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 553 | picker, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 554 | bypass, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 555 | per_app, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 556 | ] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 557 | .spacing(8) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 558 | .align_y(Alignment::Center); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 559 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 560 | // flag overlay overrides shadowing the profile — a stale |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 561 | // `headroom set` is otherwise invisible (e.g. agc.enabled pinned |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 562 | // off under a profile that enables it). click the pill to clear. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 563 | if !self.setting_overrides.is_empty() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 564 | left = left.push(pill_button( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 565 | format!("overrides: {} ✕", fmt_overrides(&self.setting_overrides)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 566 | BLACK, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 567 | YELLOW, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 568 | Message::ClearOverrides, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 569 | )); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 570 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 571 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 572 | let right = row![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 573 | text(format!("v{}", self.daemon_version)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 574 | .size(13) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 575 | .color(col(DIM)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 576 | text(format!("uptime {}", fmt_uptime(self.uptime_s()))) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 577 | .size(13) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 578 | .color(col(DIM)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 579 | ] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 580 | .spacing(8) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 581 | .align_y(Alignment::Center); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 582 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 583 | row![container(left).width(Length::Fill), right] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 584 | .align_y(Alignment::Center) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 585 | .into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 586 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 587 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 588 | fn bus_panel(&self) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 589 | let m = self.meters; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 590 | // per-stage enable flags from the tick (missing ⇒ assume enabled). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 591 | // agc + compressor are per-profile; limiter is the always-on |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 592 | // backstop and true peak is a measurement, so both are always on. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 593 | let agc_on = m.map(|t| t.agc_enabled).unwrap_or(true); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 594 | let comp_on = m.map(|t| t.compressor_enabled).unwrap_or(true); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 595 | let body = column![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 596 | meter_row( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 597 | "AGC target", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 598 | m.map(|t| t.agc_gain_db), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 599 | -12.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 600 | 12.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 601 | "dB", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 602 | CYAN, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 603 | agc_on, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 604 | ), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 605 | meter_row( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 606 | "Compressor GR", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 607 | m.map(|t| t.compressor_gr_db), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 608 | -24.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 609 | 0.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 610 | "dB", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 611 | MAGENTA, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 612 | comp_on, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 613 | ), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 614 | meter_row( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 615 | "Limiter GR", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 616 | m.map(|t| t.limiter_gr_db), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 617 | -24.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 618 | 0.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 619 | "dB", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 620 | RED, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 621 | true, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 622 | ), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 623 | meter_row( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 624 | "True peak", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 625 | m.map(|t| t.true_peak_dbtp), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 626 | -60.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 627 | 3.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 628 | "dBTP", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 629 | GREEN, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 630 | true, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 631 | ), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 632 | ] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 633 | .spacing(4); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 634 | panel("bus dsp", body.into()) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 635 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 636 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 637 | fn loudness_panel(&self) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 638 | let stale = self.meters_stale(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 639 | let (mom, st, intg) = match self.meters { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 640 | Some(m) => ( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 641 | Some(m.momentary_lufs), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 642 | Some(m.shortterm_lufs), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 643 | Some(m.integrated_lufs), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 644 | ), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 645 | None => (None, None, None), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 646 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 647 | let body = column![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 648 | lufs_row("Momentary (400 ms)", mom, stale), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 649 | lufs_row("Short-term (3 s)", st, stale), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 650 | lufs_row("Integrated (gated)", intg, stale), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 651 | ] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 652 | .spacing(4); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 653 | panel("loudness (BS.1770)", body.into()) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 654 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 655 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 656 | fn streams_panel(&self) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 657 | let header = row![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 658 | container(text("node").size(12).color(col(DIM))).width(Length::Fixed(64.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 659 | container(text("app").size(12).color(col(DIM))).width(Length::Fill), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 660 | container(text("route").size(12).color(col(DIM))).width(Length::Fixed(96.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 661 | container(text("per-app").size(12).color(col(DIM))).width(Length::Fixed(96.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 662 | container(text("actions").size(12).color(col(DIM))).width(Length::Fixed(180.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 663 | ] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 664 | .spacing(8); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 665 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 666 | let selected = self.effective_selection(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 667 | let rows = self.streams.values().map(|s| { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 668 | stream_row( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 669 | s, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 670 | &self.layer_a, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 671 | &self.la_snapshots, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 672 | selected == Some(s.node_id), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 673 | ) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 674 | }); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 675 | let list = scrollable(column(rows).spacing(4).width(Length::Fill)).height(Length::Fill); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 676 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 677 | let title = format!( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 678 | "streams ({}) — default: {}", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 679 | self.streams.len(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 680 | self.default_route |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 681 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 682 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 683 | container( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 684 | column![text(title).size(12).color(col(DIM)), header, list] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 685 | .spacing(4) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 686 | .height(Length::Fill), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 687 | ) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 688 | .padding(8) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 689 | .width(Length::Fill) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 690 | .height(Length::Fill) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 691 | .style(panel_style) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 692 | .into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 693 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 694 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 695 | fn footer(&self) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 696 | let mut items: Vec<Element<'static, Message>> = Vec::new(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 697 | if self.overflow_total > 0 { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 698 | items.push( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 699 | text(format!("dropped: {}", self.overflow_total)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 700 | .size(12) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 701 | .color(col(YELLOW)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 702 | .into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 703 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 704 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 705 | if let Some(err) = &self.last_error { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 706 | items.push( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 707 | text(format!("daemon error: {err}")) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 708 | .size(12) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 709 | .color(col(RED)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 710 | .into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 711 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 712 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 713 | if let Some(reason) = &self.disconnected { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 714 | items.push( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 715 | text(format!("disconnected: {reason}")) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 716 | .size(12) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 717 | .color(col(RED)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 718 | .into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 719 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 720 | } else if self.overflow_total == 0 && self.last_error.is_none() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 721 | items.push(text("connected").size(12).color(col(DIM)).into()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 722 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 723 | row(items).spacing(12).into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 724 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 725 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 726 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 727 | // --------------------------------------------------------------------------- |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 728 | // Free helpers |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 729 | // --------------------------------------------------------------------------- |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 730 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 731 | /// `0xRRGGBB` → iced [`Color`]. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 732 | fn col(hex: u32) -> Color { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 733 | Color::from_rgb8((hex >> 16) as u8, (hex >> 8) as u8, hex as u8) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 734 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 735 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 736 | /// `radius`-rounded border, no stroke. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 737 | fn rounded(radius: f32) -> Border { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 738 | Border { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 739 | radius: radius.into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 740 | ..Border::default() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 741 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 742 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 743 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 744 | /// shared panel container style: dark fill, rounded corners. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 745 | fn panel_style(_theme: &iced::Theme) -> container::Style { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 746 | container::Style { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 747 | background: Some(Background::Color(col(PANEL_BG))), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 748 | border: rounded(6.0), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 749 | ..container::Style::default() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 750 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 751 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 752 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 753 | /// a titled panel: header label + body, on a rounded dark card. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 754 | fn panel(title: &'static str, body: Element<'static, Message>) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 755 | container( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 756 | column![text(title).size(12).color(col(DIM)), body] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 757 | .spacing(4) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 758 | .width(Length::Fill), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 759 | ) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 760 | .padding(8) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 761 | .width(Length::Fill) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 762 | .style(panel_style) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 763 | .into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 764 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 765 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 766 | /// like [`badge_button`] but owns its text, for runtime-built labels. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 767 | fn pill_button(label: String, fg: u32, bg: u32, msg: Message) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 768 | button(text(label).size(12).color(col(fg))) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 769 | .padding(iced::Padding::from([2.0, 8.0])) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 770 | .on_press(msg) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 771 | .style(pill_style(fg, bg)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 772 | .into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 773 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 774 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 775 | /// active overrides as `key=value, …` (strings unquoted, else json) for |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 776 | /// the header indicator. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 777 | fn fmt_overrides(map: &BTreeMap<String, serde_json::Value>) -> String { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 778 | map.iter() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 779 | .map(|(k, v)| { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 780 | let val = match v { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 781 | serde_json::Value::String(s) => s.clone(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 782 | other => other.to_string(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 783 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 784 | format!("{k}={val}") |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 785 | }) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 786 | .collect::<Vec<_>>() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 787 | .join(", ") |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 788 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 789 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 790 | /// a small colored pill that's also a button. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 791 | fn badge_button( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 792 | label: &'static str, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 793 | fg: u32, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 794 | bg: u32, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 795 | msg: Message, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 796 | ) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 797 | button(text(label).size(12).color(col(fg))) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 798 | .padding(iced::Padding::from([2.0, 8.0])) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 799 | .on_press(msg) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 800 | .style(pill_style(fg, bg)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 801 | .into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 802 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 803 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 804 | /// button style closure: flat `bg` pill, `fg` text, lightening on |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 805 | /// hover/press. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 806 | fn pill_style(fg: u32, bg: u32) -> impl Fn(&iced::Theme, button::Status) -> button::Style { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 807 | move |_theme, status| { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 808 | let background = match status { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 809 | button::Status::Hovered | button::Status::Pressed => col(HOVER_BG), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 810 | _ => col(bg), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 811 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 812 | button::Style { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 813 | background: Some(Background::Color(background)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 814 | text_color: col(fg), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 815 | border: rounded(4.0), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 816 | ..button::Style::default() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 817 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 818 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 819 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 820 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 821 | /// one bus meter row: `LABEL VALUE [====----] min..max`. when |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 822 | /// `!enabled` the stage is bypassed, so render "disabled" + a greyed bar |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 823 | /// — a disabled stage must not look like one reading 0. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 824 | fn meter_row( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 825 | label: &'static str, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 826 | value: Option<f32>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 827 | min: f32, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 828 | max: f32, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 829 | unit: &'static str, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 830 | color: u32, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 831 | enabled: bool, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 832 | ) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 833 | // normalize into 0..100 for `progress_bar`, clamped. disabled ⇒ empty. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 834 | let pct = match value { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 835 | Some(v) if enabled => ((v.clamp(min, max) - min) / (max - min)).clamp(0.0, 1.0) * 100.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 836 | _ => 0.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 837 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 838 | let bar_color = if enabled { color } else { DIM }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 839 | let value_str = if !enabled { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 840 | "disabled".to_string() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 841 | } else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 842 | value |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 843 | .map(|v| format!("{v:+7.2} {unit}")) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 844 | .unwrap_or_else(|| " --".to_string()) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 845 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 846 | let value_color = if enabled { FG } else { DIM }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 847 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 848 | row![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 849 | container(text(label).color(col(DIM))).width(Length::Fixed(130.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 850 | container(text(value_str).color(col(value_color))) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 851 | .width(Length::Fixed(96.0)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 852 | .align_x(Horizontal::Right), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 853 | progress_bar(0.0..=100.0, pct) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 854 | .length(Length::Fill) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 855 | .girth(Length::Fixed(10.0)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 856 | .style(move |_theme: &iced::Theme| progress_bar::Style { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 857 | background: Background::Color(col(TRACK_BG)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 858 | bar: Background::Color(col(bar_color)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 859 | border: rounded(3.0), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 860 | }), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 861 | container(text(format!("{min:.0}..{max:.0}")).size(11).color(col(DIM))) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 862 | .width(Length::Fixed(90.0)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 863 | .align_x(Horizontal::Right), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 864 | ] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 865 | .spacing(8) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 866 | .align_y(Alignment::Center) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 867 | .into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 868 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 869 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 870 | /// one BS.1770 loudness row. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 871 | fn lufs_row(label: &'static str, v: Option<f32>, stale: bool) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 872 | let val = match v { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 873 | Some(x) if x > LOUDNESS_FLOOR_LUFS + 0.5 => format!("{x:+7.2} LUFS"), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 874 | _ => " -- LUFS".to_string(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 875 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 876 | row![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 877 | container(text(label).color(col(DIM))).width(Length::Fixed(200.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 878 | text(val).color(col(if stale { DIM } else { FG })), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 879 | ] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 880 | .spacing(8) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 881 | .into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 882 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 883 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 884 | /// one stream routing row: status cells + route/per-app/reset actions, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 885 | /// wrapped in a `mouse_area` so clicking the row selects it. owns its |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 886 | /// text, so it's `'static`. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 887 | fn stream_row( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 888 | s: &StreamRoute, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 889 | layer_a: &BTreeMap<u32, Option<f32>>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 890 | la_snapshots: &BTreeMap<u32, LayerASnapshot>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 891 | selected: bool, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 892 | ) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 893 | let node = s.node_id; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 894 | let (route_text, route_color) = match s.route { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 895 | Route::Processed => ("processed", GREEN), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 896 | Route::Bypass => ("bypass", YELLOW), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 897 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 898 | let (la_text, la_color) = match layer_a.get(&node) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 899 | Some(Some(db)) => (format!("{db:+5.1} dB"), MAGENTA), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 900 | Some(None) => ("attached".to_string(), DIM), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 901 | None => ("—".to_string(), DIM), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 902 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 903 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 904 | // route button: always available, flips processed ↔ bypass. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 905 | let (route_btn_label, route_btn_to) = match s.route { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 906 | Route::Processed => ("→ bypass", BLACK), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 907 | Route::Bypass => ("→ processed", BLACK), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 908 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 909 | let route_btn = action_button(route_btn_label, route_btn_to, CYAN, Message::ToggleRoute(node)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 910 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 911 | // per-app + reset need an app label; omit them otherwise so the row |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 912 | // can't fire a no-op control op. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 913 | let actions: Element<'static, Message> = if s.app.is_empty() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 914 | route_btn |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 915 | } else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 916 | // state semantics (like the master badge): label = whether managed, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 917 | // click toggles. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 918 | let managed = la_snapshots.get(&node).is_some_and(|snap| snap.managed); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 919 | let (pa_label, pa_bg) = if managed { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 920 | ("per-app on", CYAN) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 921 | } else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 922 | ("per-app off", DIM) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 923 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 924 | let per_app_btn = action_button(pa_label, BLACK, pa_bg, Message::TogglePerApp(node)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 925 | let reset_btn = action_button("reset", FG, TRACK_BG, Message::ResetLayerA(node)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 926 | row![route_btn, per_app_btn, reset_btn].spacing(4).into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 927 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 928 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 929 | let body = row![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 930 | container(text(node.to_string()).color(col(DIM))).width(Length::Fixed(64.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 931 | container(text(s.app.clone())).width(Length::Fill), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 932 | container(text(route_text).color(col(route_color))).width(Length::Fixed(96.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 933 | container(text(la_text).color(col(la_color))).width(Length::Fixed(96.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 934 | container(actions).width(Length::Fixed(180.0)), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 935 | ] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 936 | .spacing(8) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 937 | .align_y(Alignment::Center); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 938 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 939 | // tint the selected row; wrap it so a click anywhere selects it |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 940 | // (buttons still take their own clicks first). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 941 | let tinted = container(body).padding(iced::Padding::from([2.0, 4.0])).style( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 942 | move |_theme: &iced::Theme| container::Style { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 943 | background: selected.then(|| Background::Color(col(SEL_BG))), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 944 | border: rounded(4.0), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 945 | ..container::Style::default() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 946 | }, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 947 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 948 | mouse_area(tinted).on_press(Message::Select(node)).into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 949 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 950 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 951 | /// compact row-action button: `bg` pill, `fg` text, lightening on |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 952 | /// hover/press. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 953 | fn action_button( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 954 | label: &'static str, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 955 | fg: u32, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 956 | bg: u32, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 957 | msg: Message, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 958 | ) -> Element<'static, Message> { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 959 | button(text(label).size(11).color(col(fg))) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 960 | .padding(iced::Padding::from([1.0, 6.0])) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 961 | .on_press(msg) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 962 | .style(pill_style(fg, bg)) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 963 | .into() |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 964 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 965 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 966 | /// the wire frame carries `{event, topic, data}` but the typed enums are |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 967 | /// `#[serde(tag = "event")]` inside `data`, so re-inject the event name |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 968 | /// before deserializing. same dance as the tui. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 969 | fn inject_event(ev: &Event) -> serde_json::Value { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 970 | let mut obj = match &ev.data { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 971 | serde_json::Value::Object(m) => m.clone(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 972 | _ => serde_json::Map::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 973 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 974 | obj.insert("event".into(), serde_json::Value::String(ev.event.clone())); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 975 | serde_json::Value::Object(obj) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 976 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 977 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 978 | /// `12345` seconds → `3h25m45s` / `25m45s` / `45s`. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 979 | fn fmt_uptime(s: u64) -> String { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 980 | let h = s / 3600; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 981 | let m = (s % 3600) / 60; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 982 | let sec = s % 60; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 983 | if h > 0 { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 984 | format!("{h}h{m:02}m{sec:02}s") |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 985 | } else if m > 0 { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 986 | format!("{m}m{sec:02}s") |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 987 | } else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 988 | format!("{sec}s") |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 989 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 990 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 991 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 992 | /// map a keyboard event to a [`Message`], mirroring the tui's |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 993 | /// `handle_key`. unhandled keys / non-press events become |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 994 | /// [`Message::Noop`]; selection-targeted messages no-op in `update` when |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 995 | /// nothing is selected. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 996 | fn map_key(event: keyboard::Event) -> Message { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 997 | use keyboard::key::Named; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 998 | let keyboard::Event::KeyPressed { key, .. } = event else { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 999 | return Message::Noop; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1000 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1001 | match key { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1002 | keyboard::Key::Character(c) => match c.as_str() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1003 | "b" => Message::ToggleBypass, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1004 | "p" => Message::TogglePerAppMaster, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1005 | "r" => Message::RouteSelected, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1006 | "a" => Message::PerAppSelected, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1007 | "x" => Message::ResetSelected, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1008 | "j" => Message::SelectDown, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1009 | "k" => Message::SelectUp, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1010 | _ => Message::Noop, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1011 | }, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1012 | keyboard::Key::Named(Named::Enter) => Message::RouteSelected, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1013 | keyboard::Key::Named(Named::ArrowDown) => Message::SelectDown, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1014 | keyboard::Key::Named(Named::ArrowUp) => Message::SelectUp, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1015 | _ => Message::Noop, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1016 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1017 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1018 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1019 | #[cfg(test)] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1020 | mod state_tests; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1021 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1022 | #[cfg(test)] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1023 | mod tests { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1024 | use super::*; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1025 | use crossbeam_channel::{unbounded, Receiver}; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1026 | use headroom_ipc::{RouteList, Sinks, Status}; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1027 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1028 | /// build an app over the given streams + layer a snapshots, keeping |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1029 | /// the control receiver alive so toggle tests can drain it. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1030 | fn build( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1031 | streams: Vec<StreamRoute>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1032 | layer_a: Vec<LayerASnapshot>, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1033 | ) -> (HeadroomApp, Receiver<ControlCmd>) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1034 | let status = Status { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1035 | version: "test".into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1036 | protocol: 1, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1037 | uptime_s: 0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1038 | profile: "default".into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1039 | bypass: false, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1040 | per_app: false, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1041 | sinks: Sinks::default(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1042 | streams, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1043 | layer_a, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1044 | warnings: vec![], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1045 | setting_overrides: Default::default(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1046 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1047 | let route_list = RouteList { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1048 | rules: vec![], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1049 | current: vec![], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1050 | default_route: Route::Processed, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1051 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1052 | let (_tx, rx) = unbounded(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1053 | let (cmd_tx, cmd_rx) = unbounded(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1054 | let app = HeadroomApp::new(Bootstrap { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1055 | snapshot: Snapshot { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1056 | status, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1057 | route_list, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1058 | profiles: vec![], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1059 | }, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1060 | rx, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1061 | cmd_tx, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1062 | }); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1063 | (app, cmd_rx) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1064 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1065 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1066 | /// build a `status` snapshot for reconnect tests. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1067 | fn status_with(profile: &str, streams: Vec<StreamRoute>) -> Status { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1068 | Status { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1069 | version: "test".into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1070 | protocol: 1, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1071 | uptime_s: 0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1072 | profile: profile.into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1073 | bypass: false, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1074 | per_app: false, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1075 | sinks: Sinks::default(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1076 | streams, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1077 | layer_a: vec![], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1078 | warnings: vec![], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1079 | setting_overrides: Default::default(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1080 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1081 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1082 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1083 | fn app() -> HeadroomApp { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1084 | build(vec![], vec![]).0 |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1085 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1086 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1087 | fn stream(node_id: u32, app: &str, route: Route) -> StreamRoute { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1088 | StreamRoute { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1089 | node_id, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1090 | app: app.into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1091 | route, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1092 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1093 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1094 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1095 | fn snap(node_id: u32, app: &str, managed: bool) -> LayerASnapshot { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1096 | LayerASnapshot { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1097 | node_id, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1098 | app: app.into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1099 | managed, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1100 | volume_lin: 1.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1101 | reduction_db: 0.0, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1102 | user_ceiling_lin: None, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1103 | deferred: false, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1104 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1105 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1106 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1107 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1108 | fn meter_tick_event_updates_state() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1109 | let mut s = app(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1110 | let tick = MeterTick { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1111 | momentary_lufs: -19.3, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1112 | shortterm_lufs: -20.1, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1113 | integrated_lufs: -19.8, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1114 | true_peak_dbtp: -1.4, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1115 | gain_reduction_db: -2.1, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1116 | compressor_gr_db: -0.8, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1117 | limiter_gr_db: -1.3, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1118 | agc_gain_db: 0.5, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1119 | agc_enabled: true, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1120 | compressor_enabled: true, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1121 | }; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1122 | s.apply_event(Event::new(Topic::Meters, "tick", &tick).unwrap()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1123 | assert!(s.meters.is_some()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1124 | assert!(s.last_meter_at.is_some()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1125 | assert!(!s.meters_stale()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1126 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1127 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1128 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1129 | fn routing_event_inserts_then_removes_stream() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1130 | let mut s = app(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1131 | s.apply_event( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1132 | Event::new( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1133 | Topic::Routing, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1134 | "stream_routed", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1135 | &serde_json::json!({ "node_id": 42, "app": "firefox", "to": "bypass" }), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1136 | ) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1137 | .unwrap(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1138 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1139 | let st = s.streams.get(&42).expect("tracked"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1140 | assert_eq!(st.app, "firefox"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1141 | assert_eq!(st.route, Route::Bypass); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1142 | s.layer_a.insert(42, Some(-3.0)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1143 | s.la_snapshots.insert(42, snap(42, "firefox", true)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1144 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1145 | s.apply_event( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1146 | Event::new( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1147 | Topic::Routing, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1148 | "stream_removed", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1149 | &serde_json::json!({ "node_id": 42 }), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1150 | ) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1151 | .unwrap(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1152 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1153 | assert!(!s.streams.contains_key(&42)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1154 | assert!(!s.layer_a.contains_key(&42)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1155 | assert!(!s.la_snapshots.contains_key(&42)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1156 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1157 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1158 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1159 | fn layer_a_level_updates_reduction() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1160 | let mut s = app(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1161 | s.apply_event( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1162 | Event::new( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1163 | Topic::Routing, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1164 | "layer_a_attached", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1165 | &serde_json::json!({ "node_id": 11, "app": "loud" }), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1166 | ) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1167 | .unwrap(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1168 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1169 | assert_eq!(s.layer_a.get(&11), Some(&None)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1170 | s.apply_event( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1171 | Event::new( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1172 | Topic::Meters, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1173 | "layer_a_level", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1174 | &serde_json::json!({ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1175 | "node_id": 11, "app": "loud", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1176 | "volume_lin": 0.25_f32, "reduction_db": -11.8_f32, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1177 | }), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1178 | ) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1179 | .unwrap(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1180 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1181 | let r = s.layer_a.get(&11).copied().flatten().unwrap(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1182 | assert!((r - -11.8).abs() < 1e-4); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1183 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1184 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1185 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1186 | fn profile_changed_updates_active() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1187 | let mut s = app(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1188 | s.apply_event( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1189 | Event::new( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1190 | Topic::Profile, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1191 | "changed", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1192 | &serde_json::json!({ "name": "night", "previous": "default" }), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1193 | ) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1194 | .unwrap(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1195 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1196 | assert_eq!(s.profile, "night"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1197 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1198 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1199 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1200 | fn profile_used_event_updates_active() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1201 | // the shape the daemon actually emits on `profile.use`. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1202 | let mut s = app(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1203 | s.apply_event( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1204 | Event::new(Topic::Profile, "used", &serde_json::json!({ "name": "movie" })).unwrap(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1205 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1206 | assert_eq!(s.profile, "movie"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1207 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1208 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1209 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1210 | fn profile_selected_updates_header_optimistically() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1211 | let (mut s, _cmd) = build(vec![], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1212 | assert_eq!(s.profile, "default"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1213 | s.update(Message::ProfileSelected("party".into())); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1214 | assert_eq!(s.profile, "party"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1215 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1216 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1217 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1218 | fn profiles_refresh_syncs_active_profile() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1219 | let (mut s, _tx) = build(vec![], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1220 | s.handle_msg(AppMsg::Profiles(vec![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1221 | ProfileInfo { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1222 | name: "default".into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1223 | active: false, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1224 | description: String::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1225 | }, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1226 | ProfileInfo { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1227 | name: "gaming".into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1228 | active: true, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1229 | description: String::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1230 | }, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1231 | ])); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1232 | assert_eq!(s.profile, "gaming"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1233 | assert_eq!(s.profiles.len(), 2); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1234 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1235 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1236 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1237 | fn reconnect_reseeds_state_and_clears_disconnect() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1238 | // start with one stream, then simulate the daemon dropping and a |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1239 | // *new* instance coming back with a different stream set + active |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1240 | // profile (node ids change across a restart). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1241 | let (mut s, _cmd) = build(vec![stream(42, "firefox", Route::Bypass)], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1242 | s.handle_msg(AppMsg::Disconnected("daemon shutdown".into())); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1243 | s.overflow_total = 7; |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1244 | s.last_error = Some("stale".into()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1245 | assert!(s.disconnected.is_some()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1246 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1247 | s.handle_msg(AppMsg::Reconnected(Box::new(Snapshot { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1248 | status: status_with("music", vec![stream(99, "mpv", Route::Processed)]), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1249 | route_list: RouteList { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1250 | rules: vec![], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1251 | current: vec![stream(99, "mpv", Route::Processed)], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1252 | default_route: Route::Processed, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1253 | }, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1254 | profiles: vec![ProfileInfo { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1255 | name: "music".into(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1256 | active: true, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1257 | description: String::new(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1258 | }], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1259 | }))); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1260 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1261 | // banners cleared, per-instance counter reset. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1262 | assert!(s.disconnected.is_none()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1263 | assert!(s.last_error.is_none()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1264 | assert_eq!(s.overflow_total, 0); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1265 | // mirrored state replaced with the new daemon's snapshot. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1266 | assert_eq!(s.profile, "music"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1267 | assert!(!s.streams.contains_key(&42)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1268 | assert_eq!(s.streams.get(&99).map(|r| r.route), Some(Route::Processed)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1269 | assert_eq!(s.profiles.len(), 1); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1270 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1271 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1272 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1273 | fn daemon_overflow_accumulates() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1274 | let mut s = app(); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1275 | s.apply_event( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1276 | Event::new( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1277 | Topic::Daemon, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1278 | "overflow", |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1279 | &serde_json::json!({ "lost_topic": "meters", "lost": 3u32, "total_lost": 5u64 }), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1280 | ) |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1281 | .unwrap(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1282 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1283 | assert_eq!(s.overflow_total, 5); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1284 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1285 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1286 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1287 | fn fmt_uptime_buckets() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1288 | assert_eq!(fmt_uptime(5), "5s"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1289 | assert_eq!(fmt_uptime(75), "1m15s"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1290 | assert_eq!(fmt_uptime(3725), "1h02m05s"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1291 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1292 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1293 | // -- selection ------------------------------------------------------- |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1294 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1295 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1296 | fn selection_moves_in_key_order_and_wraps() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1297 | let (mut s, _cmd) = build( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1298 | vec![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1299 | stream(30, "c", Route::Processed), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1300 | stream(10, "a", Route::Processed), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1301 | stream(20, "b", Route::Processed), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1302 | ], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1303 | vec![], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1304 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1305 | // seeded selection is the first key (BTreeMap order: 10). |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1306 | assert_eq!(s.effective_selection(), Some(10)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1307 | s.update(Message::SelectDown); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1308 | assert_eq!(s.selected, Some(20)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1309 | s.update(Message::SelectDown); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1310 | assert_eq!(s.selected, Some(30)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1311 | s.update(Message::SelectDown); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1312 | assert_eq!(s.selected, Some(10)); // wrap to top |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1313 | s.update(Message::SelectUp); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1314 | assert_eq!(s.selected, Some(30)); // wrap to bottom |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1315 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1316 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1317 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1318 | fn selection_empty_is_none() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1319 | let (mut s, _cmd) = build(vec![], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1320 | s.update(Message::SelectDown); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1321 | assert_eq!(s.selected, None); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1322 | assert_eq!(s.effective_selection(), None); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1323 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1324 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1325 | // -- control commands emitted by toggles ----------------------------- |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1326 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1327 | fn drain(cmd: &Receiver<ControlCmd>) -> ControlCmd { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1328 | cmd.try_recv().expect("a command was emitted") |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1329 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1330 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1331 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1332 | fn toggle_bypass_emits_and_updates_optimistically() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1333 | let (mut s, cmd) = build(vec![], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1334 | assert!(!s.bypass); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1335 | s.update(Message::ToggleBypass); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1336 | assert!(matches!(drain(&cmd), ControlCmd::SetBypass(true))); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1337 | assert!(s.bypass); // optimistic |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1338 | s.update(Message::ToggleBypass); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1339 | assert!(matches!(drain(&cmd), ControlCmd::SetBypass(false))); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1340 | assert!(!s.bypass); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1341 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1342 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1343 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1344 | fn toggle_per_app_master_emits_and_updates() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1345 | let (mut s, cmd) = build(vec![], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1346 | s.update(Message::TogglePerAppMaster); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1347 | assert!(matches!(drain(&cmd), ControlCmd::SetPerAppMaster(true))); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1348 | assert!(s.per_app_master); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1349 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1350 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1351 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1352 | fn toggle_route_sets_persistent_per_app_rule_and_flips_optimistically() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1353 | // a labelled stream toggles via a persistent per-app rule |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1354 | // (route.set), not a one-shot per-stream reroute — so it |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1355 | // survives the app recreating its stream between tracks. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1356 | let (mut s, cmd) = build(vec![stream(42, "firefox", Route::Processed)], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1357 | s.update(Message::ToggleRoute(42)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1358 | match drain(&cmd) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1359 | ControlCmd::SetRoute { app, to } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1360 | assert_eq!(app, "firefox"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1361 | assert_eq!(to, Route::Bypass); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1362 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1363 | _ => panic!("expected SetRoute"), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1364 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1365 | assert_eq!(s.streams.get(&42).unwrap().route, Route::Bypass); // optimistic |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1366 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1367 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1368 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1369 | fn toggle_route_empty_label_falls_back_to_per_stream() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1370 | // a stream with no app label can't key a per-app rule, so it |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1371 | // falls back to the one-shot per-stream reroute. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1372 | let (mut s, cmd) = build(vec![stream(5, "", Route::Processed)], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1373 | s.update(Message::ToggleRoute(5)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1374 | match drain(&cmd) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1375 | ControlCmd::RouteStream { node_id, to } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1376 | assert_eq!(node_id, 5); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1377 | assert_eq!(to, Route::Bypass); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1378 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1379 | _ => panic!("expected RouteStream fallback"), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1380 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1381 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1382 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1383 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1384 | fn route_selected_acts_on_selection() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1385 | let (mut s, cmd) = build(vec![stream(7, "mpv", Route::Bypass)], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1386 | s.update(Message::RouteSelected); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1387 | match drain(&cmd) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1388 | ControlCmd::SetRoute { app, to } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1389 | assert_eq!(app, "mpv"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1390 | assert_eq!(to, Route::Processed); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1391 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1392 | _ => panic!("expected SetRoute"), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1393 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1394 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1395 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1396 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1397 | fn toggle_per_app_targets_inverse_of_managed() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1398 | // node 1 managed → toggling asks to disable; node 2 unmanaged → |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1399 | // toggling asks to enable. |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1400 | let (mut s, cmd) = build( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1401 | vec![ |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1402 | stream(1, "spotify", Route::Processed), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1403 | stream(2, "discord", Route::Processed), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1404 | ], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1405 | vec![snap(1, "spotify", true)], |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1406 | ); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1407 | s.update(Message::TogglePerApp(1)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1408 | match drain(&cmd) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1409 | ControlCmd::SetPerApp { app, enabled } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1410 | assert_eq!(app, "spotify"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1411 | assert!(!enabled); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1412 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1413 | _ => panic!("expected SetPerApp"), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1414 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1415 | s.update(Message::TogglePerApp(2)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1416 | match drain(&cmd) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1417 | ControlCmd::SetPerApp { app, enabled } => { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1418 | assert_eq!(app, "discord"); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1419 | assert!(enabled); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1420 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1421 | _ => panic!("expected SetPerApp"), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1422 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1423 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1424 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1425 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1426 | fn toggle_per_app_empty_label_errors_no_command() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1427 | let (mut s, cmd) = build(vec![stream(5, "", Route::Processed)], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1428 | s.update(Message::TogglePerApp(5)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1429 | assert!(cmd.try_recv().is_err()); // no command emitted |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1430 | assert!(s.last_error.is_some()); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1431 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1432 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1433 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1434 | fn reset_selected_emits_reset() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1435 | let (mut s, cmd) = build(vec![stream(9, "game", Route::Processed)], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1436 | s.update(Message::ResetSelected); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1437 | assert!(matches!(drain(&cmd), ControlCmd::ResetLayerA { node_id: 9 })); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1438 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1439 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1440 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1441 | fn profile_selected_emits_use_profile() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1442 | let (mut s, cmd) = build(vec![], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1443 | s.update(Message::ProfileSelected("night".into())); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1444 | match drain(&cmd) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1445 | ControlCmd::UseProfile(name) => assert_eq!(name, "night"), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1446 | _ => panic!("expected UseProfile"), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1447 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1448 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1449 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1450 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1451 | fn clear_overrides_emits_and_clears_optimistically() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1452 | let (mut s, cmd) = build(vec![], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1453 | s.setting_overrides |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1454 | .insert("agc.enabled".into(), serde_json::json!(false)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1455 | s.update(Message::ClearOverrides); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1456 | assert!(matches!(drain(&cmd), ControlCmd::ClearOverrides)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1457 | assert!(s.setting_overrides.is_empty()); // optimistic |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1458 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1459 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1460 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1461 | fn tick_refreshes_layer_a_on_cadence() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1462 | let (mut s, cmd) = build(vec![], vec![]); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1463 | for _ in 0..(REFRESH_EVERY_TICKS - 1) { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1464 | s.update(Message::Tick); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1465 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1466 | assert!(cmd.try_recv().is_err()); // not yet |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1467 | s.update(Message::Tick); // the REFRESH_EVERY_TICKS-th tick |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1468 | assert!(matches!(drain(&cmd), ControlCmd::RefreshLayerA)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1469 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1470 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1471 | // -- key mapping ----------------------------------------------------- |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1472 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1473 | fn press(c: &str) -> keyboard::Event { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1474 | keyboard::Event::KeyPressed { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1475 | key: keyboard::Key::Character(c.into()), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1476 | modified_key: keyboard::Key::Character(c.into()), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1477 | physical_key: keyboard::key::Physical::Unidentified( |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1478 | keyboard::key::NativeCode::Unidentified, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1479 | ), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1480 | location: keyboard::Location::Standard, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1481 | modifiers: keyboard::Modifiers::default(), |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1482 | text: None, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1483 | repeat: false, |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1484 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1485 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1486 | |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1487 | #[test] |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1488 | fn key_mapping_matches_tui_hotkeys() { |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1489 | assert!(matches!(map_key(press("b")), Message::ToggleBypass)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1490 | assert!(matches!(map_key(press("p")), Message::TogglePerAppMaster)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1491 | assert!(matches!(map_key(press("r")), Message::RouteSelected)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1492 | assert!(matches!(map_key(press("a")), Message::PerAppSelected)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1493 | assert!(matches!(map_key(press("x")), Message::ResetSelected)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1494 | assert!(matches!(map_key(press("j")), Message::SelectDown)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1495 | assert!(matches!(map_key(press("k")), Message::SelectUp)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1496 | assert!(matches!(map_key(press("z")), Message::Noop)); |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1497 | } |
c19702ae09be | atagen | 2026-05-27 15:49 UTC | 1498 | } |