atagen

headroom

public
Star0
Watch0

Commit c19702ae09be

tarball

atagen <boss@atagen.co>2026-06-04 03:27 UTC

9 files changed+3841-0
clients: cli, monitor tui, and iced gui
Added file: crates/headroom-cli/Cargo.toml+37 additions
@@ -0,0 +1,37 @@
1+[package]
2+name = "headroom-cli"
3+description = "automatic loudness and per-app volume control (pipewire)"
4+version.workspace = true
5+edition.workspace = true
6+license.workspace = true
7+homepage.workspace = true
8+repository.workspace = true
9+authors.workspace = true
10+
11+[[bin]]
12+name = "headroom"
13+path = "src/main.rs"
14+
15+[dependencies]
16+headroom-client = { workspace = true }
17+headroom-core = { workspace = true }
18+headroom-ipc = { workspace = true }
19+
20+assert_no_alloc = { workspace = true }
21+clap = { workspace = true }
22+serde_json = { workspace = true }
23+thiserror = { workspace = true }
24+tracing = { workspace = true }
25+tracing-subscriber = { workspace = true }
26+
27+# tui-only deps, behind the default `tui` feature. `--no-default-features`
28+# drops ratatui/crossterm for a graphics-free daemon + cli build.
29+crossbeam-channel = { workspace = true, optional = true }
30+crossterm = { workspace = true, optional = true }
31+ratatui = { workspace = true, optional = true }
32+
33+[features]
34+default = ["tui"]
35+# full-screen `headroom monitor` tui. without it, `monitor --json` still
36+# works (daemon + every other subcommand unaffected).
37+tui = ["dep:crossbeam-channel", "dep:crossterm", "dep:ratatui"]
Added file: crates/headroom-cli/src/main.rs+442 additions
@@ -0,0 +1,442 @@
1+//! `headroom` cli — connects to the daemon over its uds; `daemon` runs core in-process.
2+
3+#![forbid(unsafe_code)]
4+
5+#[cfg(feature = "tui")]
6+mod tui;
7+
8+use std::path::PathBuf;
9+use std::process::ExitCode;
10+
11+use clap::{Parser, Subcommand, ValueEnum};
12+use headroom_client::{Client, ClientError, Route, Topic};
13+
14+// debug-only: arms core's audio-thread `assert_no_alloc` blocks; no-op in release.
15+#[cfg(debug_assertions)]
16+#[global_allocator]
17+static ALLOCATOR: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler;
18+
19+/// headroom cli.
20+#[derive(Debug, Parser)]
21+#[command(version, about, long_about = None)]
22+struct Cli {
23+ /// override the daemon control socket path.
24+ #[arg(long, global = true)]
25+ socket: Option<PathBuf>,
26+
27+ #[command(subcommand)]
28+ cmd: Cmd,
29+}
30+
31+#[derive(Debug, Subcommand)]
32+enum Cmd {
33+ /// run the daemon in the foreground.
34+ Daemon {
35+ /// extra `*.toml` profile dir (repeatable). precedes shipped/user
36+ /// profiles; first dir is watched for live edits.
37+ #[arg(long = "profile-dir", value_name = "DIR")]
38+ profile_dir: Vec<PathBuf>,
39+ },
40+
41+ /// show daemon status.
42+ Status,
43+
44+ /// profile management.
45+ #[command(subcommand)]
46+ Profile(ProfileCmd),
47+
48+ /// routing rules and per-stream decisions.
49+ #[command(subcommand)]
50+ Route(RouteCmd),
51+
52+ /// per-app level control (layer a).
53+ #[command(subcommand)]
54+ PerApp(PerAppCmd),
55+
56+ /// get a setting value from the active profile.
57+ Get {
58+ /// dotted setting key.
59+ key: String,
60+ },
61+
62+ /// set a setting override (or clear with `--clear`). overrides persist
63+ /// across profile switches; `reset` clears all.
64+ Set {
65+ /// dotted setting key.
66+ key: String,
67+ /// new value, json-encoded. omit with `--clear`.
68+ // allow_hyphen_values so negatives aren't parsed as flags.
69+ #[arg(allow_hyphen_values = true)]
70+ value: Option<String>,
71+ /// clear this key's override instead of setting one.
72+ #[arg(long, conflicts_with = "value")]
73+ clear: bool,
74+ },
75+
76+ /// toggle the global bypass kill switch.
77+ Bypass {
78+ /// `on` or `off`.
79+ #[arg(value_enum)]
80+ state: BypassState,
81+ },
82+
83+ /// clear all setting overrides. leaves route/per-app/bypass intact.
84+ Reset,
85+
86+ /// reload profile files from disk.
87+ Reload,
88+
89+ /// live monitor. defaults to a tui; `--json` is the line-delimited stream.
90+ Monitor {
91+ /// topics to subscribe to (comma-separated). `--json` only; the tui
92+ /// subscribes to all.
93+ #[arg(value_delimiter = ',', default_value = "meters")]
94+ topics: Vec<MonitorTopic>,
95+
96+ /// emit one json event per line instead of the tui.
97+ #[arg(long)]
98+ json: bool,
99+ },
100+}
101+
102+#[derive(Debug, Clone, Copy, ValueEnum)]
103+enum MonitorTopic {
104+ Meters,
105+ Profile,
106+ Routing,
107+ Daemon,
108+}
109+
110+impl From<MonitorTopic> for Topic {
111+ fn from(t: MonitorTopic) -> Self {
112+ match t {
113+ MonitorTopic::Meters => Topic::Meters,
114+ MonitorTopic::Profile => Topic::Profile,
115+ MonitorTopic::Routing => Topic::Routing,
116+ MonitorTopic::Daemon => Topic::Daemon,
117+ }
118+ }
119+}
120+
121+#[derive(Debug, Subcommand)]
122+enum ProfileCmd {
123+ /// list known profiles.
124+ List,
125+ /// activate the named profile.
126+ Use {
127+ /// profile name.
128+ name: String,
129+ },
130+ /// show a profile in full.
131+ Show {
132+ /// profile name (defaults to the active profile).
133+ name: Option<String>,
134+ },
135+}
136+
137+#[derive(Debug, Subcommand)]
138+enum RouteCmd {
139+ /// list routing rules and current per-stream decisions.
140+ List,
141+ /// add or replace a routing rule for an app.
142+ Set {
143+ /// app identifier (e.g. `application.process.binary`).
144+ app: String,
145+ /// where to route.
146+ #[arg(value_enum)]
147+ to: RouteArg,
148+ },
149+ /// remove an app's user routing rule.
150+ Unset {
151+ /// app identifier.
152+ app: String,
153+ },
154+ /// reroute a specific live stream by node id.
155+ Stream {
156+ /// pipewire node id.
157+ node_id: u32,
158+ /// where to route.
159+ #[arg(value_enum)]
160+ to: RouteArg,
161+ },
162+}
163+
164+#[derive(Debug, Subcommand)]
165+enum PerAppCmd {
166+ /// show per-app layer a state for managed streams.
167+ Status {
168+ /// emit the snapshot list as json instead of a table.
169+ #[arg(long)]
170+ json: bool,
171+ },
172+ /// enable the layer a master switch (persisted).
173+ On,
174+ /// disable the layer a master switch (persisted).
175+ Off,
176+ /// enable or disable layer a for a specific app (persisted).
177+ Set {
178+ /// app identifier (process_binary or application_name).
179+ app: String,
180+ /// `on` or `off`.
181+ #[arg(value_enum)]
182+ state: BypassState,
183+ },
184+ /// clear a managed stream's deference lock.
185+ Reset {
186+ /// pipewire node id of the managed stream.
187+ node_id: u32,
188+ },
189+}
190+
191+#[derive(Debug, Clone, Copy, ValueEnum)]
192+enum BypassState {
193+ On,
194+ Off,
195+}
196+
197+#[derive(Debug, Clone, Copy, ValueEnum)]
198+enum RouteArg {
199+ Processed,
200+ Bypass,
201+}
202+
203+impl From<RouteArg> for Route {
204+ fn from(r: RouteArg) -> Self {
205+ match r {
206+ RouteArg::Processed => Route::Processed,
207+ RouteArg::Bypass => Route::Bypass,
208+ }
209+ }
210+}
211+
212+fn init_tracing() {
213+ let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
214+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("headroom=info"));
215+ tracing_subscriber::fmt()
216+ .with_env_filter(env_filter)
217+ .with_target(false)
218+ .compact()
219+ .init();
220+}
221+
222+fn run() -> Result<(), CliError> {
223+ let cli = Cli::parse();
224+
225+ // tui owns the terminal; keep `tracing` off it.
226+ let tui_mode = matches!(&cli.cmd, Cmd::Monitor { json: false, .. });
227+ if !tui_mode {
228+ init_tracing();
229+ }
230+
231+ match cli.cmd {
232+ Cmd::Daemon { profile_dir } => {
233+ headroom_core::run_with_profile_dirs(profile_dir)
234+ .map_err(|e| CliError::Daemon(e.to_string()))?;
235+ Ok(())
236+ }
237+ Cmd::Monitor { json: false, .. } => {
238+ #[cfg(feature = "tui")]
239+ {
240+ // connect on the main thread so initial rpcs run before raw mode.
241+ let client = match cli.socket.as_deref() {
242+ Some(p) => Client::connect_at(p)?,
243+ None => Client::connect()?,
244+ };
245+ tui::run(client).map_err(CliError::Tui)
246+ }
247+ // no `tui` feature: point at the json stream, not an opaque failure.
248+ #[cfg(not(feature = "tui"))]
249+ {
250+ let _ = cli.socket.as_deref();
251+ Err(CliError::Other(
252+ "this build was compiled without the TUI (`tui` feature disabled); \
253+ rerun `headroom monitor --json` for the line-delimited event stream"
254+ .into(),
255+ ))
256+ }
257+ }
258+ cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)),
259+ }
260+}
261+
262+fn with_client<F>(socket: Option<&std::path::Path>, f: F) -> Result<(), CliError>
263+where
264+ F: FnOnce(&mut Client) -> Result<(), CliError>,
265+{
266+ let mut client = match socket {
267+ Some(p) => Client::connect_at(p)?,
268+ None => Client::connect()?,
269+ };
270+ f(&mut client)
271+}
272+
273+fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
274+ match cmd {
275+ Cmd::Daemon { .. } => unreachable!("handled in `run`"),
276+
277+ Cmd::Status => {
278+ let status = client.status()?;
279+ println!("{}", serde_json::to_string_pretty(&status)?);
280+ }
281+
282+ Cmd::Profile(ProfileCmd::List) => {
283+ let profiles = client.profile_list()?;
284+ for p in profiles {
285+ let marker = if p.active { '*' } else { ' ' };
286+ println!("{marker} {:<16} {}", p.name, p.description);
287+ }
288+ }
289+ Cmd::Profile(ProfileCmd::Use { name }) => {
290+ let active = client.profile_use(&name)?;
291+ println!("active profile: {active}");
292+ }
293+ Cmd::Profile(ProfileCmd::Show { name }) => {
294+ let body = client.profile_show(name.as_deref())?;
295+ println!("{}", serde_json::to_string_pretty(&body)?);
296+ }
297+
298+ Cmd::Route(RouteCmd::List) => {
299+ let list = client.route_list()?;
300+ println!("{}", serde_json::to_string_pretty(&list)?);
301+ }
302+ Cmd::Route(RouteCmd::Set { app, to }) => {
303+ client.route_set(&app, to.into())?;
304+ }
305+ Cmd::Route(RouteCmd::Unset { app }) => {
306+ client.route_unset(&app)?;
307+ }
308+ Cmd::Route(RouteCmd::Stream { node_id, to }) => {
309+ client.route_stream(node_id, to.into())?;
310+ }
311+
312+ Cmd::PerApp(PerAppCmd::Status { json }) => {
313+ let list = client.layer_a_list()?;
314+ if json {
315+ println!("{}", serde_json::to_string_pretty(&list)?);
316+ } else if list.is_empty() {
317+ println!("no streams under Layer A management");
318+ } else {
319+ println!(
320+ "{:<8} {:<24} {:>10} {:>9} {:>9}",
321+ "node", "app", "reduction", "ceiling", "deferred"
322+ );
323+ for s in &list {
324+ let app = if s.app.len() > 24 {
325+ format!("{}…", &s.app[..23])
326+ } else {
327+ s.app.clone()
328+ };
329+ let ceiling = s
330+ .user_ceiling_lin
331+ .map(|c| format!("{c:.2}"))
332+ .unwrap_or_else(|| "—".to_string());
333+ println!(
334+ "{:<8} {:<24} {:>8.1}dB {:>9} {:>9}",
335+ s.node_id, app, s.reduction_db, ceiling, s.deferred
336+ );
337+ }
338+ }
339+ }
340+ Cmd::PerApp(PerAppCmd::On) => {
341+ client.per_app_master(true)?;
342+ }
343+ Cmd::PerApp(PerAppCmd::Off) => {
344+ client.per_app_master(false)?;
345+ }
346+ Cmd::PerApp(PerAppCmd::Set { app, state }) => {
347+ client.per_app_set(&app, matches!(state, BypassState::On))?;
348+ }
349+ Cmd::PerApp(PerAppCmd::Reset { node_id }) => {
350+ client.layer_a_reset(node_id)?;
351+ }
352+
353+ Cmd::Get { key } => {
354+ let v = client.setting_get(&key)?;
355+ println!("{}", serde_json::to_string(&v)?);
356+ }
357+ Cmd::Set { key, value, clear } => {
358+ if clear {
359+ let existed = client.setting_clear(&key)?;
360+ if existed {
361+ println!("cleared override for '{key}'");
362+ } else {
363+ println!("no override set for '{key}'");
364+ }
365+ } else {
366+ let value = value.ok_or_else(|| {
367+ CliError::Other("provide a value, or use --clear to remove the override".into())
368+ })?;
369+ let parsed: serde_json::Value = serde_json::from_str(&value)
370+ .map_err(|e| CliError::Other(format!("value is not valid JSON: {e}")))?;
371+ client.setting_set(&key, parsed)?;
372+ }
373+ }
374+ Cmd::Bypass { state } => {
375+ client.bypass_set(matches!(state, BypassState::On))?;
376+ }
377+ Cmd::Reset => {
378+ let cleared = client.setting_reset()?;
379+ println!("cleared {cleared} setting override(s)");
380+ }
381+ Cmd::Reload => {
382+ let reloaded = client.profile_reload()?;
383+ println!("reloaded: {reloaded:?}");
384+ }
385+ Cmd::Monitor { topics, json } => {
386+ if json {
387+ let pw_topics: Vec<Topic> =
388+ topics.iter().copied().map(Topic::from).collect();
389+ client.subscribe(&pw_topics)?;
390+ loop {
391+ let ev = client.next_event()?;
392+ println!(
393+ "{} {}/{} {}",
394+ chrono_like_now(),
395+ ev.topic,
396+ ev.event,
397+ serde_json::to_string(&ev.data)?,
398+ );
399+ }
400+ } else {
401+ unreachable!("TUI monitor is dispatched before `with_client`")
402+ }
403+ }
404+ }
405+ Ok(())
406+}
407+
408+#[derive(Debug, thiserror::Error)]
409+enum CliError {
410+ #[error("client: {0}")]
411+ Client(#[from] ClientError),
412+
413+ #[error("daemon: {0}")]
414+ Daemon(String),
415+
416+ #[error("json: {0}")]
417+ Json(#[from] serde_json::Error),
418+
419+ #[cfg(feature = "tui")]
420+ #[error("tui: {0}")]
421+ Tui(tui::TuiError),
422+
423+ #[error("{0}")]
424+ Other(String),
425+}
426+
427+/// timestamp label for monitor output; `SystemTime` to avoid chrono.
428+fn chrono_like_now() -> String {
429+ use std::time::{SystemTime, UNIX_EPOCH};
430+ let t = SystemTime::now()
431+ .duration_since(UNIX_EPOCH)
432+ .unwrap_or_default();
433+ format!("{}.{:03}", t.as_secs(), t.subsec_millis())
434+}
435+
436+fn main() -> ExitCode {
437+ if let Err(e) = run() {
438+ eprintln!("headroom: {e}");
439+ return ExitCode::from(1);
440+ }
441+ ExitCode::SUCCESS
442+}
Added file: crates/headroom-cli/src/tui.rs+1333 additions
@@ -0,0 +1,1333 @@
1+//! `headroom monitor` tui — bus gauges, loudness, per-stream routing.
2+//!
3+//! main thread owns terminal + draw loop; reader thread owns the `Client`,
4+//! forwards events over a channel. no graceful reader shutdown — process
5+//! exit tears the socket down.
6+
7+use std::collections::BTreeMap;
8+use std::io;
9+use std::thread;
10+use std::time::{Duration, Instant};
11+
12+use crossbeam_channel::{select, tick, unbounded, Receiver};
13+use crossterm::event::{self, Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
14+use headroom_client::{Client, ClientError};
15+use headroom_ipc::{
16+ DaemonEvent, Event, LayerALevel, LayerASnapshot, MeterTick, Route, RoutingEvent, Status,
17+ StreamRoute, Topic,
18+};
19+use ratatui::{
20+ layout::{Alignment, Constraint, Direction, Layout, Rect},
21+ style::{Color, Modifier, Style, Stylize},
22+ text::{Line, Span},
23+ widgets::{Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, Wrap},
24+ Frame, Terminal,
25+};
26+
27+#[derive(Debug, thiserror::Error)]
28+pub enum TuiError {
29+ #[error("client: {0}")]
30+ Client(#[from] ClientError),
31+
32+ #[error("terminal: {0}")]
33+ Io(#[from] io::Error),
34+}
35+
36+/// runs initial rpcs, then hands the client to the reader thread and
37+/// enters the draw loop.
38+pub fn run(mut client: Client) -> Result<(), TuiError> {
39+ // subscribe + initial state before raw mode.
40+ let topics = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon];
41+ client.subscribe(&topics)?;
42+ let status = client.status()?;
43+ let route_list = client.route_list()?;
44+
45+ // client is single-connection + the reader owns it for events; open a
46+ // second connection for control (keypress ops).
47+ let mut control = Client::connect_at(client.socket_path())?;
48+
49+ let (tx, rx) = unbounded::<Msg>();
50+ let reader_handle = thread::Builder::new()
51+ .name("headroom-monitor-rx".into())
52+ .spawn(move || reader_loop(client, tx))
53+ .map_err(TuiError::Io)?;
54+
55+ let mut terminal = ratatui::init();
56+ let outcome = draw_loop(&mut terminal, status, route_list, rx, &mut control);
57+ ratatui::restore();
58+
59+ // detach: process exit / dropped channel tears the connection down.
60+ drop(reader_handle);
61+
62+ outcome
63+}
64+
65+// ---------------------------------------------------------------------------
66+// Reader thread
67+// ---------------------------------------------------------------------------
68+
69+enum Msg {
70+ Event(Event),
71+ Disconnected(String),
72+}
73+
74+fn reader_loop(mut client: Client, tx: crossbeam_channel::Sender<Msg>) {
75+ loop {
76+ match client.next_event() {
77+ Ok(ev) => {
78+ if tx.send(Msg::Event(ev)).is_err() {
79+ return;
80+ }
81+ }
82+ Err(e) => {
83+ let _ = tx.send(Msg::Disconnected(e.to_string()));
84+ return;
85+ }
86+ }
87+ }
88+}
89+
90+// ---------------------------------------------------------------------------
91+// State
92+// ---------------------------------------------------------------------------
93+
94+struct UiState {
95+ daemon_version: String,
96+ profile: String,
97+ bypass: bool,
98+ /// layer a master switch (per-app level control enabled globally).
99+ per_app_master: bool,
100+ /// overlay overrides shadowing the profile; flagged so a stale
101+ /// `headroom set` isn't invisible.
102+ setting_overrides: BTreeMap<String, serde_json::Value>,
103+ /// daemon uptime at connect, plus local elapsed.
104+ base_uptime_s: u64,
105+ connected_at: Instant,
106+ default_route: Route,
107+ streams: BTreeMap<u32, StreamRoute>,
108+ /// per-stream layer a. presence = tap attached; inner = latest smoothed
109+ /// reduction (none until the first `layer_a_level`).
110+ layer_a: BTreeMap<u32, Option<f32>>,
111+ /// richer layer a snapshots (ceiling/deferred/managed), polled from
112+ /// `per-app.list`; feeds the detail line.
113+ la_snapshots: BTreeMap<u32, LayerASnapshot>,
114+ /// selected stream node id; resolved against `streams` at draw time.
115+ selected: Option<u32>,
116+ meters: Option<MeterTick>,
117+ /// last tick arrival; drives staleness when the source goes silent.
118+ last_meter_at: Option<Instant>,
119+ overflow_total: u64,
120+ last_error: Option<String>,
121+ disconnected: Option<String>,
122+ /// modal profile picker; while `Some` it intercepts all keyboard input.
123+ picker: Option<ProfilePicker>,
124+}
125+
126+/// modal profile-switcher; `selected` indexes `profiles`.
127+struct ProfilePicker {
128+ profiles: Vec<headroom_ipc::ProfileInfo>,
129+ selected: usize,
130+}
131+
132+impl ProfilePicker {
133+ /// move the highlight by `delta` (negative = up), wrapping.
134+ fn move_sel(&mut self, delta: isize) {
135+ if self.profiles.is_empty() {
136+ return;
137+ }
138+ let n = self.profiles.len() as isize;
139+ self.selected = (self.selected as isize + delta).rem_euclid(n) as usize;
140+ }
141+}
142+
143+/// initial highlight: active profile, else the daemon's current name, else top.
144+fn initial_picker_selection(profiles: &[headroom_ipc::ProfileInfo], current: &str) -> usize {
145+ profiles
146+ .iter()
147+ .position(|p| p.active)
148+ .or_else(|| profiles.iter().position(|p| p.name == current))
149+ .unwrap_or(0)
150+}
151+
152+impl UiState {
153+ fn new(status: Status, route_list: headroom_ipc::RouteList) -> Self {
154+ let mut streams = BTreeMap::new();
155+ for s in route_list.current {
156+ streams.insert(s.node_id, s);
157+ }
158+ // `status.streams` is a superset; merge.
159+ for s in status.streams.iter() {
160+ streams.entry(s.node_id).or_insert_with(|| s.clone());
161+ }
162+ // seed layer a from status so table/detail are populated pre-poll.
163+ let mut la_snapshots = BTreeMap::new();
164+ let mut layer_a = BTreeMap::new();
165+ for snap in status.layer_a {
166+ layer_a.insert(snap.node_id, Some(snap.reduction_db));
167+ la_snapshots.insert(snap.node_id, snap);
168+ }
169+ let selected = streams.keys().next().copied();
170+ Self {
171+ daemon_version: status.version,
172+ profile: status.profile,
173+ bypass: status.bypass,
174+ per_app_master: status.per_app,
175+ setting_overrides: status.setting_overrides,
176+ base_uptime_s: status.uptime_s,
177+ connected_at: Instant::now(),
178+ default_route: route_list.default_route,
179+ streams,
180+ layer_a,
181+ la_snapshots,
182+ selected,
183+ meters: None,
184+ last_meter_at: None,
185+ overflow_total: 0,
186+ last_error: None,
187+ disconnected: None,
188+ picker: None,
189+ }
190+ }
191+
192+ fn uptime_s(&self) -> u64 {
193+ self.base_uptime_s
194+ .saturating_add(self.connected_at.elapsed().as_secs())
195+ }
196+
197+ /// stream node ids in row order (`BTreeMap` keys).
198+ fn ordered_nodes(&self) -> Vec<u32> {
199+ self.streams.keys().copied().collect()
200+ }
201+
202+ /// `selected` when still live, else the first row, else `None`.
203+ fn effective_selection(&self) -> Option<u32> {
204+ match self.selected {
205+ Some(id) if self.streams.contains_key(&id) => Some(id),
206+ _ => self.streams.keys().next().copied(),
207+ }
208+ }
209+
210+ /// move the selection by `delta` rows (negative = up), wrapping.
211+ fn move_selection(&mut self, delta: isize) {
212+ let nodes = self.ordered_nodes();
213+ if nodes.is_empty() {
214+ self.selected = None;
215+ return;
216+ }
217+ let cur = self
218+ .effective_selection()
219+ .and_then(|id| nodes.iter().position(|&n| n == id))
220+ .unwrap_or(0) as isize;
221+ let next = (cur + delta).rem_euclid(nodes.len() as isize) as usize;
222+ self.selected = Some(nodes[next]);
223+ }
224+
225+ fn apply_event(&mut self, ev: Event) {
226+ match ev.topic {
227+ Topic::Meters if ev.event == "tick" => {
228+ if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) {
229+ self.meters = Some(m);
230+ self.last_meter_at = Some(Instant::now());
231+ }
232+ }
233+ Topic::Meters if ev.event == "layer_a_level" => {
234+ if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) {
235+ self.layer_a.insert(l.node_id, Some(l.reduction_db));
236+ }
237+ }
238+ Topic::Routing => {
239+ if let Ok(re) = serde_json::from_value::<RoutingEvent>(routing_payload(&ev)) {
240+ match re {
241+ RoutingEvent::StreamRouted { node_id, app, to } => {
242+ self.streams.insert(
243+ node_id,
244+ StreamRoute {
245+ node_id,
246+ app,
247+ route: to,
248+ },
249+ );
250+ }
251+ RoutingEvent::StreamRemoved { node_id } => {
252+ self.streams.remove(&node_id);
253+ self.layer_a.remove(&node_id);
254+ self.la_snapshots.remove(&node_id);
255+ }
256+ RoutingEvent::LayerAAttached { node_id, .. } => {
257+ // reduction unknown until the first `layer_a_level`.
258+ self.layer_a.entry(node_id).or_insert(None);
259+ }
260+ RoutingEvent::LayerADetached { node_id } => {
261+ self.layer_a.remove(&node_id);
262+ self.la_snapshots.remove(&node_id);
263+ }
264+ RoutingEvent::RuleChanged => { /* tui doesn't show rules */ }
265+ _ => {}
266+ }
267+ }
268+ }
269+ Topic::Profile => {
270+ match ev.event.as_str() {
271+ "used" | "changed" => {
272+ if let Some(name) = ev.data.get("name").and_then(|v| v.as_str()) {
273+ self.profile = name.to_string();
274+ }
275+ }
276+ _ => {}
277+ }
278+ }
279+ Topic::Daemon => {
280+ if let Ok(de) = serde_json::from_value::<DaemonEvent>(daemon_payload(&ev)) {
281+ match de {
282+ DaemonEvent::Overflow {
283+ lost, total_lost, ..
284+ } => {
285+ self.overflow_total = total_lost.max(self.overflow_total + lost as u64);
286+ }
287+ DaemonEvent::Error { code, message } => {
288+ self.last_error = Some(format!("{code}: {message}"));
289+ }
290+ DaemonEvent::Shutdown => {
291+ self.disconnected = Some("daemon shutdown".into());
292+ }
293+ DaemonEvent::Started { version } => {
294+ self.daemon_version = version;
295+ }
296+ _ => {}
297+ }
298+ }
299+ }
300+ _ => {}
301+ }
302+ }
303+}
304+
305+/// typed event enums are `#[serde(tag = "event")]` but the wire frame keeps
306+/// `event` outside `data`, so re-inject it before deserializing.
307+fn routing_payload(ev: &Event) -> serde_json::Value {
308+ inject_event(&ev.event, &ev.data)
309+}
310+fn daemon_payload(ev: &Event) -> serde_json::Value {
311+ inject_event(&ev.event, &ev.data)
312+}
313+
314+fn inject_event(event: &str, data: &serde_json::Value) -> serde_json::Value {
315+ let mut obj = match data {
316+ serde_json::Value::Object(m) => m.clone(),
317+ _ => serde_json::Map::new(),
318+ };
319+ obj.insert("event".into(), serde_json::Value::String(event.to_string()));
320+ serde_json::Value::Object(obj)
321+}
322+
323+// ---------------------------------------------------------------------------
324+// Draw loop
325+// ---------------------------------------------------------------------------
326+
327+fn draw_loop<B: ratatui::backend::Backend>(
328+ terminal: &mut Terminal<B>,
329+ status: Status,
330+ route_list: headroom_ipc::RouteList,
331+ rx: Receiver<Msg>,
332+ control: &mut Client,
333+) -> Result<(), TuiError> {
334+ let mut state = UiState::new(status, route_list);
335+ // 10 Hz redraw floor so uptime/staleness tick with no events flowing.
336+ let ticker = tick(Duration::from_millis(100));
337+ let input_rx = spawn_input_thread();
338+ // ~1 Hz poll of the richer layer a snapshot.
339+ let mut poll_ticks: u32 = 0;
340+
341+ loop {
342+ terminal.draw(|f| draw(f, &state))?;
343+
344+ select! {
345+ recv(rx) -> msg => match msg {
346+ Ok(Msg::Event(ev)) => state.apply_event(ev),
347+ Ok(Msg::Disconnected(reason)) => {
348+ state.disconnected = Some(reason);
349+ // final paint, then linger so the banner is seen.
350+ terminal.draw(|f| draw(f, &state))?;
351+ thread::sleep(Duration::from_millis(800));
352+ return Ok(());
353+ }
354+ Err(_) => return Ok(()),
355+ },
356+ recv(input_rx) -> msg => match msg {
357+ Ok(InputMsg::Key(k)) => {
358+ if handle_key(&mut state, control, k) {
359+ return Ok(());
360+ }
361+ }
362+ Ok(InputMsg::Redraw) => {}
363+ Err(_) => return Ok(()),
364+ },
365+ recv(ticker) -> _ => {
366+ poll_ticks = poll_ticks.wrapping_add(1);
367+ if poll_ticks % 10 == 0 {
368+ poll_layer_a(&mut state, control);
369+ }
370+ }
371+ }
372+ }
373+}
374+
375+/// refresh layer a snapshots from the control connection; errors go to the
376+/// footer (non-fatal — the event stream keeps the ui live).
377+fn poll_layer_a(state: &mut UiState, control: &mut Client) {
378+ match control.layer_a_list() {
379+ Ok(list) => {
380+ state.la_snapshots = list.into_iter().map(|s| (s.node_id, s)).collect();
381+ }
382+ Err(e) => {
383+ state.last_error = Some(format!("layer-a poll: {e}"));
384+ }
385+ }
386+}
387+
388+/// apply a keypress: nav + toggles + per-row actions. control ops run
389+/// synchronously on the control connection; failures land in the footer.
390+/// returns `true` to quit.
391+fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) -> bool {
392+ // ctrl-c: hard exit, even with the picker open.
393+ if k.modifiers.contains(KeyModifiers::CONTROL)
394+ && matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C'))
395+ {
396+ return true;
397+ }
398+ // picker is modal: while open it swallows every other key.
399+ if state.picker.is_some() {
400+ handle_picker_key(state, control, k);
401+ return false;
402+ }
403+ if is_quit(&k) {
404+ return true;
405+ }
406+ match k.code {
407+ KeyCode::Char('j') | KeyCode::Down => state.move_selection(1),
408+ KeyCode::Char('k') | KeyCode::Up => state.move_selection(-1),
409+ KeyCode::Char('P') => open_profile_picker(state, control),
410+ KeyCode::Char('b') => {
411+ let target = !state.bypass;
412+ match control.bypass_set(target) {
413+ Ok(()) => state.bypass = target,
414+ Err(e) => state.last_error = Some(format!("bypass: {e}")),
415+ }
416+ }
417+ KeyCode::Char('p') => {
418+ let target = !state.per_app_master;
419+ match control.per_app_master(target) {
420+ Ok(()) => state.per_app_master = target,
421+ Err(e) => state.last_error = Some(format!("per-app master: {e}")),
422+ }
423+ }
424+ KeyCode::Char('r') | KeyCode::Enter => {
425+ let Some(node) = state.effective_selection() else {
426+ return false;
427+ };
428+ let Some(stream) = state.streams.get(&node) else {
429+ return false;
430+ };
431+ let app = stream.app.clone();
432+ let to = match stream.route {
433+ Route::Processed => Route::Bypass,
434+ Route::Bypass => Route::Processed,
435+ };
436+ // prefer a persistent per-app rule (keyed by app label) so the
437+ // route survives the app recreating its stream between tracks;
438+ // fall back to a one-shot per-stream reroute when no label.
439+ let result = if app.is_empty() {
440+ control.route_stream(node, to)
441+ } else {
442+ control.route_set(&app, to)
443+ };
444+ match result {
445+ Ok(()) => {
446+ if let Some(s) = state.streams.get_mut(&node) {
447+ s.route = to;
448+ }
449+ }
450+ Err(e) => state.last_error = Some(format!("route: {e}")),
451+ }
452+ }
453+ KeyCode::Char('a') => {
454+ let Some(node) = state.effective_selection() else {
455+ return false;
456+ };
457+ let Some(app) = state.streams.get(&node).map(|s| s.app.clone()) else {
458+ return false;
459+ };
460+ if app.is_empty() {
461+ state.last_error = Some("per-app: selected stream has no app label".into());
462+ return false;
463+ }
464+ let managed = state.la_snapshots.get(&node).is_some_and(|s| s.managed);
465+ if let Err(e) = control.per_app_set(&app, !managed) {
466+ state.last_error = Some(format!("per-app set: {e}"));
467+ }
468+ }
469+ KeyCode::Char('x') => {
470+ let Some(node) = state.effective_selection() else {
471+ return false;
472+ };
473+ if let Err(e) = control.layer_a_reset(node) {
474+ state.last_error = Some(format!("reset: {e}"));
475+ }
476+ }
477+ KeyCode::Char('c') => {
478+ // clear all overlay overrides; no-op when none.
479+ if state.setting_overrides.is_empty() {
480+ return false;
481+ }
482+ match control.setting_reset() {
483+ Ok(_) => state.setting_overrides.clear(),
484+ Err(e) => state.last_error = Some(format!("clear overrides: {e}")),
485+ }
486+ }
487+ _ => {}
488+ }
489+ false
490+}
491+
492+/// open the modal profile picker from `profile.list`; errors → footer.
493+fn open_profile_picker(state: &mut UiState, control: &mut Client) {
494+ match control.profile_list() {
495+ Ok(profiles) if profiles.is_empty() => {
496+ state.last_error = Some("profile list is empty".into());
497+ }
498+ Ok(profiles) => {
499+ let selected = initial_picker_selection(&profiles, &state.profile);
500+ state.picker = Some(ProfilePicker { profiles, selected });
501+ }
502+ Err(e) => state.last_error = Some(format!("profile list: {e}")),
503+ }
504+}
505+
506+/// handle a keypress while the profile picker is open.
507+fn handle_picker_key(state: &mut UiState, control: &mut Client, k: KeyEvent) {
508+ let Some(picker) = state.picker.as_mut() else {
509+ return;
510+ };
511+ match k.code {
512+ KeyCode::Char('j') | KeyCode::Down => picker.move_sel(1),
513+ KeyCode::Char('k') | KeyCode::Up => picker.move_sel(-1),
514+ KeyCode::Esc | KeyCode::Char('q') => state.picker = None,
515+ KeyCode::Enter => {
516+ let Some(name) = picker.profiles.get(picker.selected).map(|p| p.name.clone()) else {
517+ state.picker = None;
518+ return;
519+ };
520+ match control.profile_use(&name) {
521+ Ok(applied) => {
522+ // set eagerly; the `profile.changed` event also lands.
523+ state.profile = applied;
524+ state.picker = None;
525+ }
526+ Err(e) => {
527+ // keep the picker open to retry/cancel.
528+ state.last_error = Some(format!("profile use: {e}"));
529+ }
530+ }
531+ }
532+ _ => {}
533+ }
534+}
535+
536+// ---------------------------------------------------------------------------
537+// Input thread
538+// ---------------------------------------------------------------------------
539+
540+enum InputMsg {
541+ Key(KeyEvent),
542+ Redraw,
543+}
544+
545+fn spawn_input_thread() -> Receiver<InputMsg> {
546+ let (tx, rx) = unbounded::<InputMsg>();
547+ thread::Builder::new()
548+ .name("headroom-monitor-input".into())
549+ .spawn(move || loop {
550+ let Ok(ev) = event::read() else { return };
551+ // forward keys to the draw loop; quit/modal decisions need ui state.
552+ let msg = match ev {
553+ CtEvent::Key(k) => InputMsg::Key(k),
554+ CtEvent::Resize(_, _) => InputMsg::Redraw,
555+ _ => continue,
556+ };
557+ if tx.send(msg).is_err() {
558+ return;
559+ }
560+ })
561+ .expect("spawn input thread");
562+ rx
563+}
564+
565+fn is_quit(k: &KeyEvent) -> bool {
566+ matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
567+ || (k.modifiers.contains(KeyModifiers::CONTROL)
568+ && matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')))
569+}
570+
571+// ---------------------------------------------------------------------------
572+// Drawing
573+// ---------------------------------------------------------------------------
574+
575+fn draw(f: &mut Frame, state: &UiState) {
576+ let area = f.area();
577+ let outer = Block::default()
578+ .borders(Borders::ALL)
579+ .title(Span::styled(
580+ " headroom monitor ",
581+ Style::default().add_modifier(Modifier::BOLD),
582+ ))
583+ .title_top(Line::from(header_status(state)).right_aligned())
584+ .title_bottom(Line::from(footer_text(state)).right_aligned());
585+ let inner = outer.inner(area);
586+ f.render_widget(outer, area);
587+
588+ let chunks = Layout::default()
589+ .direction(Direction::Vertical)
590+ .constraints([
591+ Constraint::Length(6), // bus gauges
592+ Constraint::Length(5), // loudness
593+ Constraint::Min(4), // streams table
594+ Constraint::Length(3), // layer A detail (selected stream)
595+ ])
596+ .split(inner);
597+
598+ draw_bus(f, chunks[0], state);
599+ draw_loudness(f, chunks[1], state);
600+ draw_streams(f, chunks[2], state);
601+ draw_layer_a_detail(f, chunks[3], state);
602+
603+ // modal overlay, drawn last so it sits on top.
604+ if let Some(picker) = &state.picker {
605+ draw_profile_picker(f, area, picker);
606+ }
607+}
608+
609+/// centered rect, at most `width`×`height`, clamped to `area`.
610+fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
611+ let w = width.min(area.width);
612+ let h = height.min(area.height);
613+ Rect {
614+ x: area.x + (area.width - w) / 2,
615+ y: area.y + (area.height - h) / 2,
616+ width: w,
617+ height: h,
618+ }
619+}
620+
621+/// modal profile switcher: centered list, active marked (●), highlighted
622+/// reversed, plus a description line.
623+fn draw_profile_picker(f: &mut Frame, area: Rect, picker: &ProfilePicker) {
624+ let rows = picker.profiles.len() as u16;
625+ // rows + 2 borders + 2 description lines.
626+ let rect = centered_rect(60, rows + 4, area);
627+
628+ f.render_widget(Clear, rect);
629+ let block = Block::default()
630+ .borders(Borders::ALL)
631+ .title(" switch profile ")
632+ .title_bottom(Line::from(" j/k move · Enter apply · Esc cancel ").right_aligned());
633+ let inner = block.inner(rect);
634+ f.render_widget(block, rect);
635+
636+ let parts = Layout::default()
637+ .direction(Direction::Vertical)
638+ .constraints([Constraint::Min(1), Constraint::Length(2)])
639+ .split(inner);
640+
641+ let lines: Vec<Line> = picker
642+ .profiles
643+ .iter()
644+ .enumerate()
645+ .map(|(i, p)| {
646+ let marker = if p.active { "●" } else { " " };
647+ let style = if i == picker.selected {
648+ Style::default().add_modifier(Modifier::REVERSED)
649+ } else if p.active {
650+ Style::default().fg(Color::Green)
651+ } else {
652+ Style::default()
653+ };
654+ Line::from(vec![
655+ Span::raw(format!(" {marker} ")),
656+ Span::styled(p.name.clone(), style),
657+ ])
658+ })
659+ .collect();
660+ f.render_widget(Paragraph::new(lines), parts[0]);
661+
662+ let desc = picker
663+ .profiles
664+ .get(picker.selected)
665+ .map(|p| p.description.clone())
666+ .unwrap_or_default();
667+ f.render_widget(
668+ Paragraph::new(Line::from(Span::styled(
669+ format!(" {desc}"),
670+ Style::default().fg(Color::DarkGray),
671+ )))
672+ .wrap(Wrap { trim: true }),
673+ parts[1],
674+ );
675+}
676+
677+fn header_status(state: &UiState) -> Vec<Span<'static>> {
678+ let bypass_span = if state.bypass {
679+ Span::styled(
680+ " BYPASS ",
681+ Style::default().fg(Color::Black).bg(Color::Yellow),
682+ )
683+ } else {
684+ Span::styled(" processed ", Style::default().fg(Color::Green))
685+ };
686+ let per_app_span = if state.per_app_master {
687+ Span::styled(" per-app ", Style::default().fg(Color::Cyan))
688+ } else {
689+ Span::styled(" per-app off ", Style::default().fg(Color::DarkGray))
690+ };
691+ vec![
692+ Span::raw(" profile: "),
693+ Span::styled(state.profile.clone(), Style::default().bold()),
694+ Span::raw(" "),
695+ bypass_span,
696+ Span::raw(" "),
697+ per_app_span,
698+ Span::raw(format!(
699+ " v{} uptime {} ",
700+ state.daemon_version,
701+ fmt_uptime(state.uptime_s())
702+ )),
703+ ]
704+}
705+
706+fn footer_text(state: &UiState) -> Vec<Span<'static>> {
707+ let sep = || Span::styled("·", Style::default().fg(Color::DarkGray));
708+ let mut parts: Vec<Span> = vec![
709+ Span::raw(" j/k select "),
710+ sep(),
711+ Span::raw(" r route "),
712+ sep(),
713+ Span::raw(" a per-app "),
714+ sep(),
715+ Span::raw(" x reset "),
716+ sep(),
717+ Span::raw(" b bypass "),
718+ sep(),
719+ Span::raw(" p per-app "),
720+ sep(),
721+ Span::raw(" P profile "),
722+ sep(),
723+ Span::raw(" q quit "),
724+ ];
725+ if state.overflow_total > 0 {
726+ parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
727+ parts.push(Span::styled(
728+ format!(" dropped: {} ", state.overflow_total),
729+ Style::default().fg(Color::Yellow),
730+ ));
731+ }
732+ if let Some(err) = &state.last_error {
733+ parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
734+ parts.push(Span::styled(
735+ format!(" daemon error: {err} "),
736+ Style::default().fg(Color::Red),
737+ ));
738+ }
739+ if let Some(reason) = &state.disconnected {
740+ parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
741+ parts.push(Span::styled(
742+ format!(" disconnected: {reason} "),
743+ Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
744+ ));
745+ }
746+ if !state.setting_overrides.is_empty() {
747+ parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
748+ parts.push(Span::styled(
749+ format!(
750+ " overrides: {} (c clear) ",
751+ fmt_overrides(&state.setting_overrides)
752+ ),
753+ Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
754+ ));
755+ }
756+ parts
757+}
758+
759+/// active overrides as `key=value, …` for the footer (strings unquoted,
760+/// else json).
761+fn fmt_overrides(map: &BTreeMap<String, serde_json::Value>) -> String {
762+ map.iter()
763+ .map(|(k, v)| {
764+ let val = match v {
765+ serde_json::Value::String(s) => s.clone(),
766+ other => other.to_string(),
767+ };
768+ format!("{k}={val}")
769+ })
770+ .collect::<Vec<_>>()
771+ .join(", ")
772+}
773+
774+fn draw_bus(f: &mut Frame, area: Rect, state: &UiState) {
775+ let block = Block::default()
776+ .borders(Borders::ALL)
777+ .title(" bus dsp ");
778+ let inner = block.inner(area);
779+ f.render_widget(block, area);
780+
781+ let rows = Layout::default()
782+ .direction(Direction::Vertical)
783+ .constraints([
784+ Constraint::Length(1),
785+ Constraint::Length(1),
786+ Constraint::Length(1),
787+ Constraint::Length(1),
788+ ])
789+ .split(inner);
790+
791+ let m = state.meters;
792+ draw_gauge_row(
793+ f,
794+ rows[0],
795+ GaugeRow {
796+ label: "AGC target",
797+ value: m.map(|t| t.agc_gain_db),
798+ min: -12.0,
799+ max: 12.0,
800+ unit: "dB",
801+ color: Color::Cyan,
802+ enabled: m.map(|t| t.agc_enabled).unwrap_or(true),
803+ },
804+ );
805+ draw_gauge_row(
806+ f,
807+ rows[1],
808+ GaugeRow {
809+ label: "Compressor GR",
810+ value: m.map(|t| t.compressor_gr_db),
811+ min: -24.0,
812+ max: 0.0,
813+ unit: "dB",
814+ color: Color::Magenta,
815+ enabled: m.map(|t| t.compressor_enabled).unwrap_or(true),
816+ },
817+ );
818+ draw_gauge_row(
819+ f,
820+ rows[2],
821+ GaugeRow {
822+ label: "Limiter GR",
823+ value: m.map(|t| t.limiter_gr_db),
824+ min: -24.0,
825+ max: 0.0,
826+ unit: "dB",
827+ color: Color::Red,
828+ enabled: true, // always-on safety backstop
829+ },
830+ );
831+ draw_gauge_row(
832+ f,
833+ rows[3],
834+ GaugeRow {
835+ label: "True peak",
836+ value: m.map(|t| t.true_peak_dbtp),
837+ min: -60.0,
838+ max: 3.0,
839+ unit: "dBTP",
840+ color: Color::Green,
841+ enabled: true, // a measurement, always meaningful
842+ },
843+ );
844+}
845+
846+struct GaugeRow<'a> {
847+ label: &'a str,
848+ value: Option<f32>,
849+ min: f32,
850+ max: f32,
851+ unit: &'a str,
852+ color: Color,
853+ /// stage enabled in the active profile; when false the row renders
854+ /// "disabled" + a greyed bar so a bypassed stage isn't read as 0.
855+ enabled: bool,
856+}
857+
858+/// one gauge row: `LABEL VALUE [████░░░░] min..max`.
859+fn draw_gauge_row(f: &mut Frame, area: Rect, row: GaugeRow<'_>) {
860+ let GaugeRow {
861+ label,
862+ value,
863+ min,
864+ max,
865+ unit,
866+ color,
867+ enabled,
868+ } = row;
869+ let cols = Layout::default()
870+ .direction(Direction::Horizontal)
871+ .constraints([
872+ Constraint::Length(16),
873+ Constraint::Length(14),
874+ Constraint::Min(8),
875+ Constraint::Length(14),
876+ ])
877+ .split(area);
878+
879+ // disabled stage: grey the row + say so, so it can't read as 0.
880+ if !enabled {
881+ let dim = Style::default().fg(Color::DarkGray);
882+ f.render_widget(Paragraph::new(format!(" {label}")).style(dim), cols[0]);
883+ f.render_widget(
884+ Paragraph::new("disabled").alignment(Alignment::Right).style(dim),
885+ cols[1],
886+ );
887+ f.render_widget(
888+ Gauge::default()
889+ .gauge_style(dim)
890+ .ratio(0.0)
891+ .label("off"),
892+ cols[2],
893+ );
894+ f.render_widget(
895+ Paragraph::new(format!("{min:.0}..{max:.0} "))
896+ .alignment(Alignment::Right)
897+ .style(dim),
898+ cols[3],
899+ );
900+ return;
901+ }
902+
903+ f.render_widget(Paragraph::new(format!(" {label}")), cols[0]);
904+
905+ let value_str = value
906+ .map(|v| format!("{v:+7.2} {unit}"))
907+ .unwrap_or_else(|| " -- ".to_string());
908+ f.render_widget(
909+ Paragraph::new(value_str).alignment(Alignment::Right),
910+ cols[1],
911+ );
912+
913+ let pct = match value {
914+ Some(v) => {
915+ let clamped = v.clamp(min, max);
916+ ((clamped - min) / (max - min)).clamp(0.0, 1.0) as f64
917+ }
918+ None => 0.0,
919+ };
920+ let gauge = Gauge::default()
921+ .gauge_style(Style::default().fg(color))
922+ .ratio(pct)
923+ .label("");
924+ f.render_widget(gauge, cols[2]);
925+
926+ f.render_widget(
927+ Paragraph::new(format!("{min:.0}..{max:.0} ")).alignment(Alignment::Right),
928+ cols[3],
929+ );
930+}
931+
932+fn draw_loudness(f: &mut Frame, area: Rect, state: &UiState) {
933+ let block = Block::default()
934+ .borders(Borders::ALL)
935+ .title(" loudness (BS.1770) ");
936+ let inner = block.inner(area);
937+ f.render_widget(block, area);
938+
939+ let staleness = state
940+ .last_meter_at
941+ .map(|t| t.elapsed())
942+ .unwrap_or(Duration::ZERO);
943+ let stale = staleness > Duration::from_millis(500);
944+
945+ let (mom, st, intg) = match state.meters {
946+ Some(m) => (Some(m.momentary_lufs), Some(m.shortterm_lufs), Some(m.integrated_lufs)),
947+ None => (None, None, None),
948+ };
949+
950+ let lines = vec![
951+ lufs_line("Momentary (400 ms)", mom, stale),
952+ lufs_line("Short-term (3 s)", st, stale),
953+ lufs_line("Integrated (gated)", intg, stale),
954+ ];
955+ f.render_widget(Paragraph::new(lines), inner);
956+}
957+
958+fn lufs_line(label: &str, v: Option<f32>, stale: bool) -> Line<'static> {
959+ let val = match v {
960+ Some(x) if x > headroom_core::agc::LOUDNESS_FLOOR_LUFS + 0.5 => {
961+ format!("{x:+7.2} LUFS")
962+ }
963+ Some(_) => " -- LUFS".to_string(),
964+ None => " -- LUFS".to_string(),
965+ };
966+ let style = if stale {
967+ Style::default().fg(Color::DarkGray)
968+ } else {
969+ Style::default()
970+ };
971+ Line::from(vec![
972+ Span::raw(format!(" {label:<24}")),
973+ Span::styled(val, style),
974+ ])
975+}
976+
977+fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
978+ let title = format!(
979+ " streams ({}) — default: {} ",
980+ state.streams.len(),
981+ state.default_route
982+ );
983+ let block = Block::default().borders(Borders::ALL).title(title);
984+
985+ let header = Row::new(vec!["", "node", "app", "route", "per-app"])
986+ .style(Style::default().add_modifier(Modifier::BOLD));
987+
988+ let selected = state.effective_selection();
989+ let rows: Vec<Row> = state
990+ .streams
991+ .values()
992+ .map(|s| {
993+ let is_sel = selected == Some(s.node_id);
994+ let route_cell = match s.route {
995+ Route::Processed => Cell::from("processed").style(Style::default().fg(Color::Green)),
996+ Route::Bypass => Cell::from("bypass").style(Style::default().fg(Color::Yellow)),
997+ };
998+ let la_cell = match state.layer_a.get(&s.node_id) {
999+ Some(Some(db)) => Cell::from(format!("{db:+5.1} dB"))
1000+ .style(Style::default().fg(Color::Magenta)),
1001+ Some(None) => Cell::from("attached")
1002+ .style(Style::default().fg(Color::DarkGray)),
1003+ None => Cell::from("—").style(Style::default().fg(Color::DarkGray)),
1004+ };
1005+ let marker = if is_sel { "▶" } else { " " };
1006+ let row = Row::new(vec![
1007+ Cell::from(marker),
1008+ Cell::from(s.node_id.to_string()),
1009+ Cell::from(s.app.clone()),
1010+ route_cell,
1011+ la_cell,
1012+ ]);
1013+ if is_sel {
1014+ row.style(Style::default().add_modifier(Modifier::REVERSED))
1015+ } else {
1016+ row
1017+ }
1018+ })
1019+ .collect();
1020+
1021+ let widths = [
1022+ Constraint::Length(2),
1023+ Constraint::Length(8),
1024+ Constraint::Min(20),
1025+ Constraint::Length(12),
1026+ Constraint::Length(10),
1027+ ];
1028+ let table = Table::new(rows, widths).header(header).block(block);
1029+ f.render_widget(table, area);
1030+}
1031+
1032+/// read-only layer a detail for the selected stream: managed flag,
1033+/// smoothed reduction, user ceiling, deference lock.
1034+fn draw_layer_a_detail(f: &mut Frame, area: Rect, state: &UiState) {
1035+ let block = Block::default()
1036+ .borders(Borders::ALL)
1037+ .title(" per-app level (selected) ");
1038+ let inner = block.inner(area);
1039+ f.render_widget(block, area);
1040+
1041+ let line = match state.effective_selection() {
1042+ None => Line::from(Span::styled(
1043+ " no stream selected",
1044+ Style::default().fg(Color::DarkGray),
1045+ )),
1046+ Some(node) => {
1047+ let app = state
1048+ .streams
1049+ .get(&node)
1050+ .map(|s| s.app.clone())
1051+ .unwrap_or_default();
1052+ match state.la_snapshots.get(&node) {
1053+ Some(snap) => {
1054+ let ceiling = snap
1055+ .user_ceiling_lin
1056+ .map(|c| format!("{c:.2}"))
1057+ .unwrap_or_else(|| "—".to_string());
1058+ let deferred = if snap.deferred {
1059+ Span::styled("deferred", Style::default().fg(Color::Yellow))
1060+ } else {
1061+ Span::styled("active", Style::default().fg(Color::Green))
1062+ };
1063+ Line::from(vec![
1064+ Span::raw(format!(" node {node} {app} ")),
1065+ Span::styled(
1066+ if snap.managed { "managed" } else { "unmanaged" },
1067+ Style::default().fg(if snap.managed {
1068+ Color::Cyan
1069+ } else {
1070+ Color::DarkGray
1071+ }),
1072+ ),
1073+ Span::raw(format!(
1074+ " reduction {:+.1} dB ceiling {ceiling} ",
1075+ snap.reduction_db
1076+ )),
1077+ deferred,
1078+ ])
1079+ }
1080+ None => Line::from(Span::styled(
1081+ format!(" node {node} {app} not managed per-app"),
1082+ Style::default().fg(Color::DarkGray),
1083+ )),
1084+ }
1085+ }
1086+ };
1087+ f.render_widget(Paragraph::new(line), inner);
1088+}
1089+
1090+fn fmt_uptime(s: u64) -> String {
1091+ let h = s / 3600;
1092+ let m = (s % 3600) / 60;
1093+ let sec = s % 60;
1094+ if h > 0 {
1095+ format!("{h}h{m:02}m{sec:02}s")
1096+ } else if m > 0 {
1097+ format!("{m}m{sec:02}s")
1098+ } else {
1099+ format!("{sec}s")
1100+ }
1101+}
1102+
1103+#[cfg(test)]
1104+mod state_tests;
1105+
1106+#[cfg(test)]
1107+mod tests {
1108+ use super::*;
1109+ use headroom_ipc::{Sinks, Status};
1110+
1111+ fn empty_state() -> UiState {
1112+ let status = Status {
1113+ version: "test".into(),
1114+ protocol: 1,
1115+ uptime_s: 0,
1116+ profile: "default".into(),
1117+ bypass: false,
1118+ per_app: false,
1119+ sinks: Sinks::default(),
1120+ streams: vec![],
1121+ layer_a: vec![],
1122+ warnings: vec![],
1123+ setting_overrides: Default::default(),
1124+ };
1125+ let route_list = headroom_ipc::RouteList {
1126+ rules: vec![],
1127+ current: vec![],
1128+ default_route: Route::Processed,
1129+ };
1130+ UiState::new(status, route_list)
1131+ }
1132+
1133+ #[test]
1134+ fn meter_tick_event_updates_state() {
1135+ let mut state = empty_state();
1136+ let tick = MeterTick {
1137+ momentary_lufs: -19.3,
1138+ shortterm_lufs: -20.1,
1139+ integrated_lufs: -19.8,
1140+ true_peak_dbtp: -1.4,
1141+ gain_reduction_db: -2.1,
1142+ compressor_gr_db: -0.8,
1143+ limiter_gr_db: -1.3,
1144+ agc_gain_db: 0.5,
1145+ agc_enabled: true,
1146+ compressor_enabled: true,
1147+ };
1148+ let ev = Event::new(Topic::Meters, "tick", &tick).unwrap();
1149+ state.apply_event(ev);
1150+ let got = state.meters.expect("meters set");
1151+ assert!((got.momentary_lufs - tick.momentary_lufs).abs() < f32::EPSILON);
1152+ assert!((got.true_peak_dbtp - tick.true_peak_dbtp).abs() < f32::EPSILON);
1153+ assert!(state.last_meter_at.is_some());
1154+ }
1155+
1156+ #[test]
1157+ fn stream_removed_prunes_state() {
1158+ let mut state = empty_state();
1159+ // insert via stream_routed first.
1160+ state.apply_event(
1161+ Event::new(
1162+ Topic::Routing,
1163+ "stream_routed",
1164+ &serde_json::json!({ "node_id": 7, "app": "x", "to": "processed" }),
1165+ )
1166+ .unwrap(),
1167+ );
1168+ state.apply_event(
1169+ Event::new(
1170+ Topic::Routing,
1171+ "layer_a_attached",
1172+ &serde_json::json!({ "node_id": 7, "app": "x" }),
1173+ )
1174+ .unwrap(),
1175+ );
1176+ assert!(state.streams.contains_key(&7));
1177+ assert!(state.layer_a.contains_key(&7));
1178+ state.la_snapshots.insert(
1179+ 7,
1180+ LayerASnapshot {
1181+ node_id: 7,
1182+ app: "x".into(),
1183+ managed: true,
1184+ volume_lin: 1.0,
1185+ reduction_db: -3.0,
1186+ user_ceiling_lin: Some(0.8),
1187+ deferred: true,
1188+ },
1189+ );
1190+
1191+ state.apply_event(
1192+ Event::new(
1193+ Topic::Routing,
1194+ "stream_removed",
1195+ &serde_json::json!({ "node_id": 7 }),
1196+ )
1197+ .unwrap(),
1198+ );
1199+ assert!(!state.streams.contains_key(&7));
1200+ assert!(!state.layer_a.contains_key(&7));
1201+ assert!(!state.la_snapshots.contains_key(&7));
1202+ }
1203+
1204+ #[test]
1205+ fn layer_a_level_updates_reduction() {
1206+ let mut state = empty_state();
1207+ state.apply_event(
1208+ Event::new(
1209+ Topic::Routing,
1210+ "layer_a_attached",
1211+ &serde_json::json!({ "node_id": 11, "app": "loud-app" }),
1212+ )
1213+ .unwrap(),
1214+ );
1215+ assert_eq!(state.layer_a.get(&11), Some(&None));
1216+
1217+ state.apply_event(
1218+ Event::new(
1219+ Topic::Meters,
1220+ "layer_a_level",
1221+ &serde_json::json!({
1222+ "node_id": 11,
1223+ "app": "loud-app",
1224+ "volume_lin": 0.256_f32,
1225+ "reduction_db": -11.8_f32,
1226+ }),
1227+ )
1228+ .unwrap(),
1229+ );
1230+ let r = state.layer_a.get(&11).copied().flatten().unwrap();
1231+ assert!((r - -11.8).abs() < 1e-4);
1232+ }
1233+
1234+ #[test]
1235+ fn routing_event_inserts_stream() {
1236+ let mut state = empty_state();
1237+ let ev = Event::new(
1238+ Topic::Routing,
1239+ "stream_routed",
1240+ &serde_json::json!({
1241+ "node_id": 42,
1242+ "app": "firefox",
1243+ "to": "bypass",
1244+ }),
1245+ )
1246+ .unwrap();
1247+ state.apply_event(ev);
1248+ let s = state.streams.get(&42).expect("stream tracked");
1249+ assert_eq!(s.app, "firefox");
1250+ assert_eq!(s.route, Route::Bypass);
1251+ }
1252+
1253+ #[test]
1254+ fn profile_changed_updates_active() {
1255+ let mut state = empty_state();
1256+ let ev = Event::new(
1257+ Topic::Profile,
1258+ "changed",
1259+ &serde_json::json!({
1260+ "name": "night",
1261+ "previous": "default",
1262+ }),
1263+ )
1264+ .unwrap();
1265+ state.apply_event(ev);
1266+ assert_eq!(state.profile, "night");
1267+ }
1268+
1269+ #[test]
1270+ fn daemon_overflow_accumulates() {
1271+ let mut state = empty_state();
1272+ let ev = Event::new(
1273+ Topic::Daemon,
1274+ "overflow",
1275+ &serde_json::json!({
1276+ "lost_topic": "meters",
1277+ "lost": 3u32,
1278+ "total_lost": 5u64,
1279+ }),
1280+ )
1281+ .unwrap();
1282+ state.apply_event(ev);
1283+ assert_eq!(state.overflow_total, 5);
1284+ }
1285+
1286+ fn pinfo(name: &str, active: bool) -> headroom_ipc::ProfileInfo {
1287+ headroom_ipc::ProfileInfo {
1288+ name: name.into(),
1289+ active,
1290+ description: format!("{name} desc"),
1291+ }
1292+ }
1293+
1294+ #[test]
1295+ fn picker_initial_selection_prefers_active() {
1296+ let profiles = vec![
1297+ pinfo("default", false),
1298+ pinfo("night", true),
1299+ pinfo("speech", false),
1300+ ];
1301+ assert_eq!(initial_picker_selection(&profiles, "default"), 1);
1302+ }
1303+
1304+ #[test]
1305+ fn picker_initial_selection_falls_back_to_current_name_then_top() {
1306+ let profiles = vec![pinfo("default", false), pinfo("night", false)];
1307+ // no active flag: match the daemon's reported current name.
1308+ assert_eq!(initial_picker_selection(&profiles, "night"), 1);
1309+ // neither active nor a name match: default to the top.
1310+ assert_eq!(initial_picker_selection(&profiles, "ghost"), 0);
1311+ }
1312+
1313+ #[test]
1314+ fn picker_move_sel_wraps_both_ways() {
1315+ let mut p = ProfilePicker {
1316+ profiles: vec![pinfo("a", false), pinfo("b", false), pinfo("c", false)],
1317+ selected: 0,
1318+ };
1319+ p.move_sel(-1);
1320+ assert_eq!(p.selected, 2); // wrap up from top
1321+ p.move_sel(1);
1322+ assert_eq!(p.selected, 0); // wrap down from bottom
1323+ p.move_sel(2);
1324+ assert_eq!(p.selected, 2);
1325+ }
1326+
1327+ #[test]
1328+ fn fmt_uptime_buckets() {
1329+ assert_eq!(fmt_uptime(5), "5s");
1330+ assert_eq!(fmt_uptime(75), "1m15s");
1331+ assert_eq!(fmt_uptime(3725), "1h02m05s");
1332+ }
1333+}
Added file: crates/headroom-cli/src/tui/state_tests.rs+66 additions
@@ -0,0 +1,66 @@
1+use super::*;
2+use headroom_ipc::{RouteList, Sinks, Status};
3+
4+fn stream(node_id: u32, app: &str, route: Route) -> StreamRoute {
5+ StreamRoute {
6+ node_id,
7+ app: app.into(),
8+ route,
9+ }
10+}
11+
12+fn state_from(status_streams: Vec<StreamRoute>, route_streams: Vec<StreamRoute>) -> UiState {
13+ let status = Status {
14+ version: "test".into(),
15+ protocol: 1,
16+ uptime_s: 0,
17+ profile: "default".into(),
18+ bypass: false,
19+ per_app: false,
20+ sinks: Sinks::default(),
21+ streams: status_streams,
22+ layer_a: vec![],
23+ warnings: vec![],
24+ setting_overrides: Default::default(),
25+ };
26+ let route_list = RouteList {
27+ rules: vec![],
28+ current: route_streams,
29+ default_route: Route::Processed,
30+ };
31+ UiState::new(status, route_list)
32+}
33+
34+#[test]
35+fn snapshot_merge_prefers_route_list_and_fills_missing_status_streams() {
36+ let state = state_from(
37+ vec![
38+ stream(1, "status-only", Route::Processed),
39+ stream(2, "stale-status", Route::Processed),
40+ ],
41+ vec![stream(2, "route-list", Route::Bypass)],
42+ );
43+
44+ assert_eq!(
45+ state.streams.get(&1).map(|s| s.route),
46+ Some(Route::Processed)
47+ );
48+ let got = state.streams.get(&2).expect("route list stream");
49+ assert_eq!(got.app, "route-list");
50+ assert_eq!(got.route, Route::Bypass);
51+}
52+
53+#[test]
54+fn profile_used_updates_active() {
55+ let mut state = state_from(vec![], vec![]);
56+ let ev = Event::new(
57+ Topic::Profile,
58+ "used",
59+ &serde_json::json!({ "name": "movie" }),
60+ )
61+ .unwrap();
62+
63+ state.apply_event(ev);
64+
65+ assert_eq!(state.profile, "movie");
66+}
Added file: crates/headroom-gui/Cargo.toml+51 additions
@@ -0,0 +1,51 @@
1+[package]
2+name = "headroom-gui"
3+version.workspace = true
4+# optional, additive crate: pulls iced's gpu/system-lib stack (wgpu,
5+# cosmic-text, winit), *not* part of the graphics-free daemon/cli build.
6+# build from the nix dev shell or the `headroom-gui` nix package; both
7+# supply iced's system libs.
8+edition = "2021"
9+license.workspace = true
10+homepage.workspace = true
11+repository.workspace = true
12+authors.workspace = true
13+
14+[[bin]]
15+name = "headroom-gui"
16+path = "src/main.rs"
17+
18+[dependencies]
19+# internal — same control-plane client/types the ratatui tui uses.
20+headroom-client = { workspace = true }
21+headroom-ipc = { workspace = true }
22+
23+# iced ui, trimmed feature set on purpose:
24+# - default-features off drops `linux-theme-detection` (pulls zbus +
25+# async-process + async-io just to probe the desktop theme — we
26+# hardcode dark), plus `web-colors` / `crisp` cosmetics.
27+# - `wgpu` + `tiny-skia` (software fallback) renderers, `wayland` only.
28+# - `smol` backs the `time::every` timer (no `tokio`); `thread-pool` is
29+# the subscription executor.
30+# net: ~199 crates vs gpui-component's ~525 (no av1 encoder, no http stack).
31+#
32+# x11 off: add iced's `"x11"` feature here (and the x11 client libs back
33+# to `guiLibs` in flake.nix) to build the x11 backend.
34+iced = { version = "0.14", default-features = false, features = [
35+ "wgpu",
36+ "tiny-skia",
37+ "wayland",
38+ "thread-pool",
39+ "smol",
40+] }
41+
42+# bridge: reader thread → ui channel.
43+crossbeam-channel = { workspace = true }
44+
45+# json event decoding (mirrors the tui's `apply_event`).
46+serde_json = { workspace = true }
47+
48+# errors / logging.
49+anyhow = "1.0"
50+tracing = { workspace = true }
51+tracing-subscriber = { workspace = true }
Added file: crates/headroom-gui/src/app.rs+1498 additions
@@ -0,0 +1,1498 @@
1+//! `HeadroomApp` — iced application state + view; mirrors the tui's
2+//! read-only `UiState` (`crates/headroom-cli/src/tui.rs`).
3+//!
4+//! control ops never block the ui thread: sent over `cmd_tx` to the
5+//! control thread (`io.rs`), which replies over the same `rx` the ui
6+//! drains each tick. ui updates optimistically; daemon events + the
7+//! ~1 Hz `per-app.list` refresh reconcile.
8+
9+use std::collections::BTreeMap;
10+use std::time::{Duration, Instant};
11+
12+use crossbeam_channel::{Receiver, Sender};
13+use iced::alignment::Horizontal;
14+use iced::widget::{button, column, container, mouse_area, pick_list, progress_bar, row, scrollable, text};
15+use iced::{keyboard, time, Alignment, Background, Border, Color, Element, Length, Subscription};
16+
17+use headroom_ipc::{
18+ DaemonEvent, Event, LayerALevel, LayerASnapshot, MeterTick, ProfileInfo, Route, RoutingEvent,
19+ StreamRoute, Topic,
20+};
21+
22+use crate::io::{AppMsg, Bootstrap, ControlCmd, Snapshot};
23+
24+/// loudness at/below this floor renders as "--". mirrors
25+/// `headroom_core::agc::LOUDNESS_FLOOR_LUFS`, inlined so the gui doesn't
26+/// pull in the audio/dsp crate.
27+const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
28+
29+/// meters older than this are dimmed (source went silent / suspended).
30+const STALE_AFTER: Duration = Duration::from_millis(500);
31+
32+/// ui drain + repaint interval (~20 Hz); also advances uptime/staleness
33+/// text with no events flowing.
34+const POLL_INTERVAL: Duration = Duration::from_millis(50);
35+
36+// palette — explicit colors mirroring the tui's intent.
37+const BG: u32 = 0x1e1e1e;
38+const PANEL_BG: u32 = 0x252526;
39+const TRACK_BG: u32 = 0x3a3a3a;
40+const FG: u32 = 0xd4d4d4;
41+const DIM: u32 = 0x808080;
42+const CYAN: u32 = 0x4ec9b0;
43+const MAGENTA: u32 = 0xc586c0;
44+const RED: u32 = 0xf44747;
45+const GREEN: u32 = 0x6a9955;
46+const YELLOW: u32 = 0xdcdcaa;
47+const BLACK: u32 = 0x101010;
48+/// tint behind the selected stream row.
49+const SEL_BG: u32 = 0x094771;
50+/// hover/press tint for header + row buttons.
51+const HOVER_BG: u32 = 0x37373a;
52+
53+/// poll ticks between `per-app.list` refreshes — ~1 Hz at [`POLL_INTERVAL`].
54+const REFRESH_EVERY_TICKS: u32 = 20;
55+
56+/// poll ticks between `profile.list` refreshes — ~10 s. the profile *set*
57+/// only changes on `profile reload`; the active profile updates instantly
58+/// via `profile.changed`.
59+const REFRESH_PROFILES_EVERY_TICKS: u32 = 200;
60+
61+#[derive(Debug, Clone)]
62+pub enum Message {
63+ /// poll tick: drain `rx`, repaint, periodically refresh.
64+ Tick,
65+ /// a keypress / event we don't act on.
66+ Noop,
67+ /// select a stream row by node id (mouse click).
68+ Select(u32),
69+ /// move the selection up / down one row (k/↑, j/↓).
70+ SelectUp,
71+ SelectDown,
72+ ToggleBypass,
73+ TogglePerAppMaster,
74+ /// toggle a stream's route (processed ↔ bypass).
75+ ToggleRoute(u32),
76+ /// toggle per-app management for a stream's app.
77+ TogglePerApp(u32),
78+ /// reset a stream's layer a deference.
79+ ResetLayerA(u32),
80+ /// hotkey variants acting on the current selection.
81+ RouteSelected,
82+ PerAppSelected,
83+ ResetSelected,
84+ /// switch to the named profile (pick_list).
85+ ProfileSelected(String),
86+ /// clear all overlay setting overrides.
87+ ClearOverrides,
88+}
89+
90+/// mirrored daemon snapshot plus the live event channel it drains.
91+pub struct HeadroomApp {
92+ daemon_version: String,
93+ profile: String,
94+ bypass: bool,
95+ /// layer a master switch (per-app level control enabled globally).
96+ per_app_master: bool,
97+ /// overlay overrides (dotted key → value) shadowing the profile;
98+ /// surfaced so a stale `headroom set` isn't invisible.
99+ setting_overrides: BTreeMap<String, serde_json::Value>,
100+ /// daemon uptime at connect, plus local elapsed.
101+ base_uptime_s: u64,
102+ connected_at: Instant,
103+ default_route: Route,
104+ streams: BTreeMap<u32, StreamRoute>,
105+ /// per-stream layer a reduction (dB). presence = managed; inner `None`
106+ /// until the first `layer_a_level`.
107+ layer_a: BTreeMap<u32, Option<f32>>,
108+ /// richer per-stream snapshots (managed/ceiling/deferred), polled from
109+ /// `per-app.list`; drives the per-app toggle target + row label.
110+ la_snapshots: BTreeMap<u32, LayerASnapshot>,
111+ /// profiles for the switcher (seeded at connect).
112+ profiles: Vec<ProfileInfo>,
113+ /// selected stream node id; resolved via [`Self::effective_selection`].
114+ selected: Option<u32>,
115+ meters: Option<MeterTick>,
116+ last_meter_at: Option<Instant>,
117+ overflow_total: u64,
118+ last_error: Option<String>,
119+ disconnected: Option<String>,
120+ /// live event + worker stream from the reader / control threads.
121+ rx: Receiver<AppMsg>,
122+ /// hands control ops to the control thread.
123+ cmd_tx: Sender<ControlCmd>,
124+ /// poll-tick counter for the paced `per-app.list` refresh.
125+ tick_count: u32,
126+}
127+
128+impl HeadroomApp {
129+ /// seed initial state from the snapshot, take ownership of the channel.
130+ pub fn new(boot: Bootstrap) -> Self {
131+ let Bootstrap {
132+ snapshot,
133+ rx,
134+ cmd_tx,
135+ } = boot;
136+
137+ let mut app = Self {
138+ daemon_version: String::new(),
139+ profile: String::new(),
140+ bypass: false,
141+ per_app_master: false,
142+ setting_overrides: BTreeMap::new(),
143+ base_uptime_s: 0,
144+ connected_at: Instant::now(),
145+ default_route: Route::Processed,
146+ streams: BTreeMap::new(),
147+ layer_a: BTreeMap::new(),
148+ la_snapshots: BTreeMap::new(),
149+ profiles: Vec::new(),
150+ selected: None,
151+ meters: None,
152+ last_meter_at: None,
153+ overflow_total: 0,
154+ last_error: None,
155+ disconnected: None,
156+ rx,
157+ cmd_tx,
158+ tick_count: 0,
159+ };
160+ app.apply_snapshot(snapshot);
161+ // initial selection: first stream row (reconnects preserve the
162+ // user's selection, so this only runs at startup).
163+ app.selected = app.streams.keys().next().copied();
164+ app
165+ }
166+
167+ /// replace mirrored daemon state from a fresh [`Snapshot`]. shared by
168+ /// [`Self::new`] + [`Self::reseed`]; leaves `selected` / channels /
169+ /// tick counter to the caller.
170+ fn apply_snapshot(&mut self, snapshot: Snapshot) {
171+ let Snapshot {
172+ status,
173+ route_list,
174+ profiles,
175+ } = snapshot;
176+
177+ let mut streams = BTreeMap::new();
178+ for s in route_list.current {
179+ streams.insert(s.node_id, s);
180+ }
181+ // `status.streams` is a superset; merge any not already present.
182+ for s in status.streams.iter() {
183+ streams.entry(s.node_id).or_insert_with(|| s.clone());
184+ }
185+ // seed meter column + richer snapshots from status (mirrors tui).
186+ let mut layer_a = BTreeMap::new();
187+ let mut la_snapshots = BTreeMap::new();
188+ for snap in status.layer_a {
189+ layer_a.insert(snap.node_id, Some(snap.reduction_db));
190+ la_snapshots.insert(snap.node_id, snap);
191+ }
192+
193+ self.daemon_version = status.version;
194+ self.profile = status.profile;
195+ self.bypass = status.bypass;
196+ self.per_app_master = status.per_app;
197+ self.setting_overrides = status.setting_overrides;
198+ self.base_uptime_s = status.uptime_s;
199+ self.connected_at = Instant::now();
200+ self.default_route = route_list.default_route;
201+ self.streams = streams;
202+ self.layer_a = layer_a;
203+ self.la_snapshots = la_snapshots;
204+ self.profiles = profiles;
205+ }
206+
207+ /// re-seed after the reader reconnected to a (possibly new) daemon.
208+ /// clears the disconnect/error banners + per-instance overflow counter
209+ /// (a restarted daemon resets its drop count). `selected` is preserved —
210+ /// `effective_selection` reconciles it against the new stream set.
211+ fn reseed(&mut self, snapshot: Snapshot) {
212+ self.apply_snapshot(snapshot);
213+ self.overflow_total = 0;
214+ self.last_error = None;
215+ self.disconnected = None;
216+ self.last_meter_at = None;
217+ }
218+
219+ pub fn title(&self) -> String {
220+ "headroom monitor".to_string()
221+ }
222+
223+ /// poll tick (`smol` backs `time::every`) + global keyboard input.
224+ /// `keyboard::listen` only surfaces keys not consumed by a focused
225+ /// widget, so the pick_list keeps its own arrow/enter handling.
226+ pub fn subscription(&self) -> Subscription<Message> {
227+ Subscription::batch([
228+ time::every(POLL_INTERVAL).map(|_| Message::Tick),
229+ keyboard::listen().map(map_key),
230+ ])
231+ }
232+
233+ pub fn update(&mut self, message: Message) {
234+ match message {
235+ Message::Noop => {}
236+ Message::Tick => {
237+ self.pump();
238+ self.tick_count = self.tick_count.wrapping_add(1);
239+ if self.tick_count.is_multiple_of(REFRESH_EVERY_TICKS) {
240+ // reconcile managed/ceiling/deferred state the event
241+ // stream doesn't push (mirrors the tui's poll).
242+ self.send_cmd(ControlCmd::RefreshLayerA);
243+ }
244+ if self.tick_count.is_multiple_of(REFRESH_PROFILES_EVERY_TICKS) {
245+ // pick up profiles added/removed by `profile reload`.
246+ self.send_cmd(ControlCmd::RefreshProfiles);
247+ }
248+ }
249+ Message::Select(id) => self.selected = Some(id),
250+ Message::SelectUp => self.move_selection(-1),
251+ Message::SelectDown => self.move_selection(1),
252+ Message::ToggleBypass => {
253+ let target = !self.bypass;
254+ self.send_cmd(ControlCmd::SetBypass(target));
255+ self.bypass = target; // optimistic; no bypass event from daemon
256+ }
257+ Message::TogglePerAppMaster => {
258+ let target = !self.per_app_master;
259+ self.send_cmd(ControlCmd::SetPerAppMaster(target));
260+ self.per_app_master = target;
261+ }
262+ Message::ToggleRoute(id) => self.toggle_route(id),
263+ Message::TogglePerApp(id) => self.toggle_per_app(id),
264+ Message::ResetLayerA(id) => self.reset_layer_a(id),
265+ Message::RouteSelected => {
266+ if let Some(id) = self.effective_selection() {
267+ self.toggle_route(id);
268+ }
269+ }
270+ Message::PerAppSelected => {
271+ if let Some(id) = self.effective_selection() {
272+ self.toggle_per_app(id);
273+ }
274+ }
275+ Message::ResetSelected => {
276+ if let Some(id) = self.effective_selection() {
277+ self.reset_layer_a(id);
278+ }
279+ }
280+ Message::ProfileSelected(name) => {
281+ // optimistic: update the header now; the daemon's "used"
282+ // event reconciles a frame or two later (mirrors the tui).
283+ self.profile = name.clone();
284+ self.send_cmd(ControlCmd::UseProfile(name));
285+ }
286+ Message::ClearOverrides => {
287+ // optimistic: drop the indicator now; the daemon clears the
288+ // overlay and reapplies the profile's values.
289+ self.setting_overrides.clear();
290+ self.send_cmd(ControlCmd::ClearOverrides);
291+ }
292+ }
293+ }
294+
295+ /// drain + apply every queued worker message.
296+ fn pump(&mut self) {
297+ while let Ok(msg) = self.rx.try_recv() {
298+ self.handle_msg(msg);
299+ }
300+ }
301+
302+ fn handle_msg(&mut self, msg: AppMsg) {
303+ match msg {
304+ AppMsg::Event(ev) => self.apply_event(ev),
305+ AppMsg::Disconnected(reason) => self.disconnected = Some(reason),
306+ AppMsg::Reconnected(snapshot) => self.reseed(*snapshot),
307+ AppMsg::ControlError(e) => self.last_error = Some(e),
308+ AppMsg::Profiles(p) => {
309+ // reconcile the displayed profile from the `active` flag —
310+ // covers external `profile use` even if "used" was missed.
311+ if let Some(active) = p.iter().find(|x| x.active) {
312+ self.profile = active.name.clone();
313+ }
314+ self.profiles = p;
315+ }
316+ AppMsg::LayerASnapshots(list) => {
317+ self.la_snapshots = list.into_iter().map(|s| (s.node_id, s)).collect();
318+ }
319+ }
320+ }
321+
322+ /// queue a control op; a dead control thread lands in the footer.
323+ fn send_cmd(&mut self, cmd: ControlCmd) {
324+ if self.cmd_tx.send(cmd).is_err() {
325+ self.last_error = Some("control thread gone".into());
326+ }
327+ }
328+
329+ /// stream node ids in row order (`BTreeMap` keys).
330+ fn ordered_nodes(&self) -> Vec<u32> {
331+ self.streams.keys().copied().collect()
332+ }
333+
334+ /// `selected` when still live, else the first row, else `None`.
335+ fn effective_selection(&self) -> Option<u32> {
336+ match self.selected {
337+ Some(id) if self.streams.contains_key(&id) => Some(id),
338+ _ => self.streams.keys().next().copied(),
339+ }
340+ }
341+
342+ /// move the selection by `delta` rows (negative = up), wrapping.
343+ fn move_selection(&mut self, delta: isize) {
344+ let nodes = self.ordered_nodes();
345+ if nodes.is_empty() {
346+ self.selected = None;
347+ return;
348+ }
349+ let cur = self
350+ .effective_selection()
351+ .and_then(|id| nodes.iter().position(|&n| n == id))
352+ .unwrap_or(0) as isize;
353+ let next = (cur + delta).rem_euclid(nodes.len() as isize) as usize;
354+ self.selected = Some(nodes[next]);
355+ }
356+
357+ /// flip a stream's route, optimistically updating local state.
358+ ///
359+ /// prefer a *persistent* per-app rule (`route.set`, keyed by app label)
360+ /// so the route survives the app recreating its stream between tracks
361+ /// (Strawberry, mpv, …); fall back to a one-shot `route.stream` only
362+ /// when there's no label to key on. the optimistic update flips just
363+ /// this row; the daemon's reevaluate emits `stream_routed` for sibling
364+ /// streams of the same app, which `apply_event` reconciles.
365+ fn toggle_route(&mut self, node: u32) {
366+ let Some((app, cur)) = self.streams.get(&node).map(|s| (s.app.clone(), s.route)) else {
367+ return;
368+ };
369+ let to = match cur {
370+ Route::Processed => Route::Bypass,
371+ Route::Bypass => Route::Processed,
372+ };
373+ if app.is_empty() {
374+ self.send_cmd(ControlCmd::RouteStream { node_id: node, to });
375+ } else {
376+ self.send_cmd(ControlCmd::SetRoute { app, to });
377+ }
378+ if let Some(s) = self.streams.get_mut(&node) {
379+ s.route = to;
380+ }
381+ }
382+
383+ /// toggle per-app management for a stream's app. guards an empty
384+ /// label; reconciliation comes from the poll.
385+ fn toggle_per_app(&mut self, node: u32) {
386+ let Some(app) = self.streams.get(&node).map(|s| s.app.clone()) else {
387+ return;
388+ };
389+ if app.is_empty() {
390+ self.last_error = Some("per-app: selected stream has no app label".into());
391+ return;
392+ }
393+ let managed = self.la_snapshots.get(&node).is_some_and(|s| s.managed);
394+ self.send_cmd(ControlCmd::SetPerApp {
395+ app,
396+ enabled: !managed,
397+ });
398+ }
399+
400+ /// reset a stream's layer a deference.
401+ fn reset_layer_a(&mut self, node: u32) {
402+ self.send_cmd(ControlCmd::ResetLayerA { node_id: node });
403+ }
404+
405+ fn uptime_s(&self) -> u64 {
406+ self.base_uptime_s
407+ .saturating_add(self.connected_at.elapsed().as_secs())
408+ }
409+
410+ fn meters_stale(&self) -> bool {
411+ self.last_meter_at
412+ .map(|t| t.elapsed() > STALE_AFTER)
413+ .unwrap_or(true)
414+ }
415+
416+ /// apply one wire event. ported from the tui's `apply_event`, minus
417+ /// the selection/snapshot bookkeeping the read-only gui doesn't need.
418+ fn apply_event(&mut self, ev: Event) {
419+ match ev.topic {
420+ Topic::Meters if ev.event == "tick" => {
421+ if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) {
422+ self.meters = Some(m);
423+ self.last_meter_at = Some(Instant::now());
424+ }
425+ }
426+ Topic::Meters if ev.event == "layer_a_level" => {
427+ if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) {
428+ self.layer_a.insert(l.node_id, Some(l.reduction_db));
429+ }
430+ }
431+ Topic::Routing => {
432+ if let Ok(re) = serde_json::from_value::<RoutingEvent>(inject_event(&ev)) {
433+ match re {
434+ RoutingEvent::StreamRouted { node_id, app, to } => {
435+ self.streams.insert(
436+ node_id,
437+ StreamRoute {
438+ node_id,
439+ app,
440+ route: to,
441+ },
442+ );
443+ }
444+ RoutingEvent::StreamRemoved { node_id } => {
445+ self.streams.remove(&node_id);
446+ self.layer_a.remove(&node_id);
447+ self.la_snapshots.remove(&node_id);
448+ }
449+ RoutingEvent::LayerAAttached { node_id, .. } => {
450+ self.layer_a.entry(node_id).or_insert(None);
451+ }
452+ RoutingEvent::LayerADetached { node_id } => {
453+ self.layer_a.remove(&node_id);
454+ self.la_snapshots.remove(&node_id);
455+ }
456+ RoutingEvent::RuleChanged => {}
457+ _ => {}
458+ }
459+ }
460+ }
461+ Topic::Profile => {
462+ // the daemon emits `profile` "used" `{name}` on apply (not
463+ // the typed `ProfileEvent::Changed` "changed"/`previous`
464+ // shape), so read `name` directly and accept either spelling
465+ // — keeps the header in sync with a cli `profile use` too.
466+ // on "reloaded" the profile *set* may have changed.
467+ match ev.event.as_str() {
468+ "used" | "changed" => {
469+ if let Some(name) = ev.data.get("name").and_then(|v| v.as_str()) {
470+ self.profile = name.to_string();
471+ }
472+ }
473+ "reloaded" => self.send_cmd(ControlCmd::RefreshProfiles),
474+ _ => {}
475+ }
476+ }
477+ Topic::Daemon => {
478+ if let Ok(de) = serde_json::from_value::<DaemonEvent>(inject_event(&ev)) {
479+ match de {
480+ DaemonEvent::Overflow {
481+ lost, total_lost, ..
482+ } => {
483+ self.overflow_total = total_lost.max(self.overflow_total + lost as u64);
484+ }
485+ DaemonEvent::Error { code, message } => {
486+ self.last_error = Some(format!("{code}: {message}"));
487+ }
488+ DaemonEvent::Shutdown => {
489+ self.disconnected = Some("daemon shutdown".into());
490+ }
491+ DaemonEvent::Started { version } => {
492+ self.daemon_version = version;
493+ }
494+ _ => {}
495+ }
496+ }
497+ }
498+ _ => {}
499+ }
500+ }
501+
502+ // -----------------------------------------------------------------
503+ // View
504+ // -----------------------------------------------------------------
505+
506+ pub fn view(&self) -> Element<'_, Message> {
507+ let content = column![
508+ self.header(),
509+ self.bus_panel(),
510+ self.loudness_panel(),
511+ self.streams_panel(),
512+ self.footer(),
513+ ]
514+ .spacing(8)
515+ .padding(12)
516+ .width(Length::Fill)
517+ .height(Length::Fill);
518+
519+ // paint the window with the base background.
520+ container(content)
521+ .width(Length::Fill)
522+ .height(Length::Fill)
523+ .style(|_theme: &iced::Theme| container::Style {
524+ background: Some(Background::Color(col(BG))),
525+ ..container::Style::default()
526+ })
527+ .into()
528+ }
529+
530+ fn header(&self) -> Element<'static, Message> {
531+ let bypass = if self.bypass {
532+ badge_button("BYPASS", BLACK, YELLOW, Message::ToggleBypass)
533+ } else {
534+ badge_button("processed", GREEN, PANEL_BG, Message::ToggleBypass)
535+ };
536+ // state semantics (not action): label = current state, click
537+ // toggles — same convention as the per-app row chip.
538+ let per_app = if self.per_app_master {
539+ badge_button("per-app on", CYAN, PANEL_BG, Message::TogglePerAppMaster)
540+ } else {
541+ badge_button("per-app off", DIM, PANEL_BG, Message::TogglePerAppMaster)
542+ };
543+
544+ // profile switcher: dropdown over the profile names.
545+ let names: Vec<String> = self.profiles.iter().map(|p| p.name.clone()).collect();
546+ let picker = pick_list(names, Some(self.profile.clone()), Message::ProfileSelected)
547+ .text_size(14)
548+ .padding(iced::Padding::from([2.0, 8.0]));
549+
550+ // left group fills the width so version/uptime is pushed right.
551+ let mut left = row![
552+ text("profile:").color(col(DIM)),
553+ picker,
554+ bypass,
555+ per_app,
556+ ]
557+ .spacing(8)
558+ .align_y(Alignment::Center);
559+
560+ // flag overlay overrides shadowing the profile — a stale
561+ // `headroom set` is otherwise invisible (e.g. agc.enabled pinned
562+ // off under a profile that enables it). click the pill to clear.
563+ if !self.setting_overrides.is_empty() {
564+ left = left.push(pill_button(
565+ format!("overrides: {} ✕", fmt_overrides(&self.setting_overrides)),
566+ BLACK,
567+ YELLOW,
568+ Message::ClearOverrides,
569+ ));
570+ }
571+
572+ let right = row![
573+ text(format!("v{}", self.daemon_version))
574+ .size(13)
575+ .color(col(DIM)),
576+ text(format!("uptime {}", fmt_uptime(self.uptime_s())))
577+ .size(13)
578+ .color(col(DIM)),
579+ ]
580+ .spacing(8)
581+ .align_y(Alignment::Center);
582+
583+ row![container(left).width(Length::Fill), right]
584+ .align_y(Alignment::Center)
585+ .into()
586+ }
587+
588+ fn bus_panel(&self) -> Element<'static, Message> {
589+ let m = self.meters;
590+ // per-stage enable flags from the tick (missing ⇒ assume enabled).
591+ // agc + compressor are per-profile; limiter is the always-on
592+ // backstop and true peak is a measurement, so both are always on.
593+ let agc_on = m.map(|t| t.agc_enabled).unwrap_or(true);
594+ let comp_on = m.map(|t| t.compressor_enabled).unwrap_or(true);
595+ let body = column![
596+ meter_row(
597+ "AGC target",
598+ m.map(|t| t.agc_gain_db),
599+ -12.0,
600+ 12.0,
601+ "dB",
602+ CYAN,
603+ agc_on,
604+ ),
605+ meter_row(
606+ "Compressor GR",
607+ m.map(|t| t.compressor_gr_db),
608+ -24.0,
609+ 0.0,
610+ "dB",
611+ MAGENTA,
612+ comp_on,
613+ ),
614+ meter_row(
615+ "Limiter GR",
616+ m.map(|t| t.limiter_gr_db),
617+ -24.0,
618+ 0.0,
619+ "dB",
620+ RED,
621+ true,
622+ ),
623+ meter_row(
624+ "True peak",
625+ m.map(|t| t.true_peak_dbtp),
626+ -60.0,
627+ 3.0,
628+ "dBTP",
629+ GREEN,
630+ true,
631+ ),
632+ ]
633+ .spacing(4);
634+ panel("bus dsp", body.into())
635+ }
636+
637+ fn loudness_panel(&self) -> Element<'static, Message> {
638+ let stale = self.meters_stale();
639+ let (mom, st, intg) = match self.meters {
640+ Some(m) => (
641+ Some(m.momentary_lufs),
642+ Some(m.shortterm_lufs),
643+ Some(m.integrated_lufs),
644+ ),
645+ None => (None, None, None),
646+ };
647+ let body = column![
648+ lufs_row("Momentary (400 ms)", mom, stale),
649+ lufs_row("Short-term (3 s)", st, stale),
650+ lufs_row("Integrated (gated)", intg, stale),
651+ ]
652+ .spacing(4);
653+ panel("loudness (BS.1770)", body.into())
654+ }
655+
656+ fn streams_panel(&self) -> Element<'static, Message> {
657+ let header = row![
658+ container(text("node").size(12).color(col(DIM))).width(Length::Fixed(64.0)),
659+ container(text("app").size(12).color(col(DIM))).width(Length::Fill),
660+ container(text("route").size(12).color(col(DIM))).width(Length::Fixed(96.0)),
661+ container(text("per-app").size(12).color(col(DIM))).width(Length::Fixed(96.0)),
662+ container(text("actions").size(12).color(col(DIM))).width(Length::Fixed(180.0)),
663+ ]
664+ .spacing(8);
665+
666+ let selected = self.effective_selection();
667+ let rows = self.streams.values().map(|s| {
668+ stream_row(
669+ s,
670+ &self.layer_a,
671+ &self.la_snapshots,
672+ selected == Some(s.node_id),
673+ )
674+ });
675+ let list = scrollable(column(rows).spacing(4).width(Length::Fill)).height(Length::Fill);
676+
677+ let title = format!(
678+ "streams ({}) — default: {}",
679+ self.streams.len(),
680+ self.default_route
681+ );
682+
683+ container(
684+ column![text(title).size(12).color(col(DIM)), header, list]
685+ .spacing(4)
686+ .height(Length::Fill),
687+ )
688+ .padding(8)
689+ .width(Length::Fill)
690+ .height(Length::Fill)
691+ .style(panel_style)
692+ .into()
693+ }
694+
695+ fn footer(&self) -> Element<'static, Message> {
696+ let mut items: Vec<Element<'static, Message>> = Vec::new();
697+ if self.overflow_total > 0 {
698+ items.push(
699+ text(format!("dropped: {}", self.overflow_total))
700+ .size(12)
701+ .color(col(YELLOW))
702+ .into(),
703+ );
704+ }
705+ if let Some(err) = &self.last_error {
706+ items.push(
707+ text(format!("daemon error: {err}"))
708+ .size(12)
709+ .color(col(RED))
710+ .into(),
711+ );
712+ }
713+ if let Some(reason) = &self.disconnected {
714+ items.push(
715+ text(format!("disconnected: {reason}"))
716+ .size(12)
717+ .color(col(RED))
718+ .into(),
719+ );
720+ } else if self.overflow_total == 0 && self.last_error.is_none() {
721+ items.push(text("connected").size(12).color(col(DIM)).into());
722+ }
723+ row(items).spacing(12).into()
724+ }
725+}
726+
727+// ---------------------------------------------------------------------------
728+// Free helpers
729+// ---------------------------------------------------------------------------
730+
731+/// `0xRRGGBB` → iced [`Color`].
732+fn col(hex: u32) -> Color {
733+ Color::from_rgb8((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
734+}
735+
736+/// `radius`-rounded border, no stroke.
737+fn rounded(radius: f32) -> Border {
738+ Border {
739+ radius: radius.into(),
740+ ..Border::default()
741+ }
742+}
743+
744+/// shared panel container style: dark fill, rounded corners.
745+fn panel_style(_theme: &iced::Theme) -> container::Style {
746+ container::Style {
747+ background: Some(Background::Color(col(PANEL_BG))),
748+ border: rounded(6.0),
749+ ..container::Style::default()
750+ }
751+}
752+
753+/// a titled panel: header label + body, on a rounded dark card.
754+fn panel(title: &'static str, body: Element<'static, Message>) -> Element<'static, Message> {
755+ container(
756+ column![text(title).size(12).color(col(DIM)), body]
757+ .spacing(4)
758+ .width(Length::Fill),
759+ )
760+ .padding(8)
761+ .width(Length::Fill)
762+ .style(panel_style)
763+ .into()
764+}
765+
766+/// like [`badge_button`] but owns its text, for runtime-built labels.
767+fn pill_button(label: String, fg: u32, bg: u32, msg: Message) -> Element<'static, Message> {
768+ button(text(label).size(12).color(col(fg)))
769+ .padding(iced::Padding::from([2.0, 8.0]))
770+ .on_press(msg)
771+ .style(pill_style(fg, bg))
772+ .into()
773+}
774+
775+/// active overrides as `key=value, …` (strings unquoted, else json) for
776+/// the header indicator.
777+fn fmt_overrides(map: &BTreeMap<String, serde_json::Value>) -> String {
778+ map.iter()
779+ .map(|(k, v)| {
780+ let val = match v {
781+ serde_json::Value::String(s) => s.clone(),
782+ other => other.to_string(),
783+ };
784+ format!("{k}={val}")
785+ })
786+ .collect::<Vec<_>>()
787+ .join(", ")
788+}
789+
790+/// a small colored pill that's also a button.
791+fn badge_button(
792+ label: &'static str,
793+ fg: u32,
794+ bg: u32,
795+ msg: Message,
796+) -> Element<'static, Message> {
797+ button(text(label).size(12).color(col(fg)))
798+ .padding(iced::Padding::from([2.0, 8.0]))
799+ .on_press(msg)
800+ .style(pill_style(fg, bg))
801+ .into()
802+}
803+
804+/// button style closure: flat `bg` pill, `fg` text, lightening on
805+/// hover/press.
806+fn pill_style(fg: u32, bg: u32) -> impl Fn(&iced::Theme, button::Status) -> button::Style {
807+ move |_theme, status| {
808+ let background = match status {
809+ button::Status::Hovered | button::Status::Pressed => col(HOVER_BG),
810+ _ => col(bg),
811+ };
812+ button::Style {
813+ background: Some(Background::Color(background)),
814+ text_color: col(fg),
815+ border: rounded(4.0),
816+ ..button::Style::default()
817+ }
818+ }
819+}
820+
821+/// one bus meter row: `LABEL VALUE [====----] min..max`. when
822+/// `!enabled` the stage is bypassed, so render "disabled" + a greyed bar
823+/// — a disabled stage must not look like one reading 0.
824+fn meter_row(
825+ label: &'static str,
826+ value: Option<f32>,
827+ min: f32,
828+ max: f32,
829+ unit: &'static str,
830+ color: u32,
831+ enabled: bool,
832+) -> Element<'static, Message> {
833+ // normalize into 0..100 for `progress_bar`, clamped. disabled ⇒ empty.
834+ let pct = match value {
835+ Some(v) if enabled => ((v.clamp(min, max) - min) / (max - min)).clamp(0.0, 1.0) * 100.0,
836+ _ => 0.0,
837+ };
838+ let bar_color = if enabled { color } else { DIM };
839+ let value_str = if !enabled {
840+ "disabled".to_string()
841+ } else {
842+ value
843+ .map(|v| format!("{v:+7.2} {unit}"))
844+ .unwrap_or_else(|| " --".to_string())
845+ };
846+ let value_color = if enabled { FG } else { DIM };
847+
848+ row![
849+ container(text(label).color(col(DIM))).width(Length::Fixed(130.0)),
850+ container(text(value_str).color(col(value_color)))
851+ .width(Length::Fixed(96.0))
852+ .align_x(Horizontal::Right),
853+ progress_bar(0.0..=100.0, pct)
854+ .length(Length::Fill)
855+ .girth(Length::Fixed(10.0))
856+ .style(move |_theme: &iced::Theme| progress_bar::Style {
857+ background: Background::Color(col(TRACK_BG)),
858+ bar: Background::Color(col(bar_color)),
859+ border: rounded(3.0),
860+ }),
861+ container(text(format!("{min:.0}..{max:.0}")).size(11).color(col(DIM)))
862+ .width(Length::Fixed(90.0))
863+ .align_x(Horizontal::Right),
864+ ]
865+ .spacing(8)
866+ .align_y(Alignment::Center)
867+ .into()
868+}
869+
870+/// one BS.1770 loudness row.
871+fn lufs_row(label: &'static str, v: Option<f32>, stale: bool) -> Element<'static, Message> {
872+ let val = match v {
873+ Some(x) if x > LOUDNESS_FLOOR_LUFS + 0.5 => format!("{x:+7.2} LUFS"),
874+ _ => " -- LUFS".to_string(),
875+ };
876+ row![
877+ container(text(label).color(col(DIM))).width(Length::Fixed(200.0)),
878+ text(val).color(col(if stale { DIM } else { FG })),
879+ ]
880+ .spacing(8)
881+ .into()
882+}
883+
884+/// one stream routing row: status cells + route/per-app/reset actions,
885+/// wrapped in a `mouse_area` so clicking the row selects it. owns its
886+/// text, so it's `'static`.
887+fn stream_row(
888+ s: &StreamRoute,
889+ layer_a: &BTreeMap<u32, Option<f32>>,
890+ la_snapshots: &BTreeMap<u32, LayerASnapshot>,
891+ selected: bool,
892+) -> Element<'static, Message> {
893+ let node = s.node_id;
894+ let (route_text, route_color) = match s.route {
895+ Route::Processed => ("processed", GREEN),
896+ Route::Bypass => ("bypass", YELLOW),
897+ };
898+ let (la_text, la_color) = match layer_a.get(&node) {
899+ Some(Some(db)) => (format!("{db:+5.1} dB"), MAGENTA),
900+ Some(None) => ("attached".to_string(), DIM),
901+ None => ("—".to_string(), DIM),
902+ };
903+
904+ // route button: always available, flips processed ↔ bypass.
905+ let (route_btn_label, route_btn_to) = match s.route {
906+ Route::Processed => ("→ bypass", BLACK),
907+ Route::Bypass => ("→ processed", BLACK),
908+ };
909+ let route_btn = action_button(route_btn_label, route_btn_to, CYAN, Message::ToggleRoute(node));
910+
911+ // per-app + reset need an app label; omit them otherwise so the row
912+ // can't fire a no-op control op.
913+ let actions: Element<'static, Message> = if s.app.is_empty() {
914+ route_btn
915+ } else {
916+ // state semantics (like the master badge): label = whether managed,
917+ // click toggles.
918+ let managed = la_snapshots.get(&node).is_some_and(|snap| snap.managed);
919+ let (pa_label, pa_bg) = if managed {
920+ ("per-app on", CYAN)
921+ } else {
922+ ("per-app off", DIM)
923+ };
924+ let per_app_btn = action_button(pa_label, BLACK, pa_bg, Message::TogglePerApp(node));
925+ let reset_btn = action_button("reset", FG, TRACK_BG, Message::ResetLayerA(node));
926+ row![route_btn, per_app_btn, reset_btn].spacing(4).into()
927+ };
928+
929+ let body = row![
930+ container(text(node.to_string()).color(col(DIM))).width(Length::Fixed(64.0)),
931+ container(text(s.app.clone())).width(Length::Fill),
932+ container(text(route_text).color(col(route_color))).width(Length::Fixed(96.0)),
933+ container(text(la_text).color(col(la_color))).width(Length::Fixed(96.0)),
934+ container(actions).width(Length::Fixed(180.0)),
935+ ]
936+ .spacing(8)
937+ .align_y(Alignment::Center);
938+
939+ // tint the selected row; wrap it so a click anywhere selects it
940+ // (buttons still take their own clicks first).
941+ let tinted = container(body).padding(iced::Padding::from([2.0, 4.0])).style(
942+ move |_theme: &iced::Theme| container::Style {
943+ background: selected.then(|| Background::Color(col(SEL_BG))),
944+ border: rounded(4.0),
945+ ..container::Style::default()
946+ },
947+ );
948+ mouse_area(tinted).on_press(Message::Select(node)).into()
949+}
950+
951+/// compact row-action button: `bg` pill, `fg` text, lightening on
952+/// hover/press.
953+fn action_button(
954+ label: &'static str,
955+ fg: u32,
956+ bg: u32,
957+ msg: Message,
958+) -> Element<'static, Message> {
959+ button(text(label).size(11).color(col(fg)))
960+ .padding(iced::Padding::from([1.0, 6.0]))
961+ .on_press(msg)
962+ .style(pill_style(fg, bg))
963+ .into()
964+}
965+
966+/// the wire frame carries `{event, topic, data}` but the typed enums are
967+/// `#[serde(tag = "event")]` inside `data`, so re-inject the event name
968+/// before deserializing. same dance as the tui.
969+fn inject_event(ev: &Event) -> serde_json::Value {
970+ let mut obj = match &ev.data {
971+ serde_json::Value::Object(m) => m.clone(),
972+ _ => serde_json::Map::new(),
973+ };
974+ obj.insert("event".into(), serde_json::Value::String(ev.event.clone()));
975+ serde_json::Value::Object(obj)
976+}
977+
978+/// `12345` seconds → `3h25m45s` / `25m45s` / `45s`.
979+fn fmt_uptime(s: u64) -> String {
980+ let h = s / 3600;
981+ let m = (s % 3600) / 60;
982+ let sec = s % 60;
983+ if h > 0 {
984+ format!("{h}h{m:02}m{sec:02}s")
985+ } else if m > 0 {
986+ format!("{m}m{sec:02}s")
987+ } else {
988+ format!("{sec}s")
989+ }
990+}
991+
992+/// map a keyboard event to a [`Message`], mirroring the tui's
993+/// `handle_key`. unhandled keys / non-press events become
994+/// [`Message::Noop`]; selection-targeted messages no-op in `update` when
995+/// nothing is selected.
996+fn map_key(event: keyboard::Event) -> Message {
997+ use keyboard::key::Named;
998+ let keyboard::Event::KeyPressed { key, .. } = event else {
999+ return Message::Noop;
1000+ };
1001+ match key {
1002+ keyboard::Key::Character(c) => match c.as_str() {
1003+ "b" => Message::ToggleBypass,
1004+ "p" => Message::TogglePerAppMaster,
1005+ "r" => Message::RouteSelected,
1006+ "a" => Message::PerAppSelected,
1007+ "x" => Message::ResetSelected,
1008+ "j" => Message::SelectDown,
1009+ "k" => Message::SelectUp,
1010+ _ => Message::Noop,
1011+ },
1012+ keyboard::Key::Named(Named::Enter) => Message::RouteSelected,
1013+ keyboard::Key::Named(Named::ArrowDown) => Message::SelectDown,
1014+ keyboard::Key::Named(Named::ArrowUp) => Message::SelectUp,
1015+ _ => Message::Noop,
1016+ }
1017+}
1018+
1019+#[cfg(test)]
1020+mod state_tests;
1021+
1022+#[cfg(test)]
1023+mod tests {
1024+ use super::*;
1025+ use crossbeam_channel::{unbounded, Receiver};
1026+ use headroom_ipc::{RouteList, Sinks, Status};
1027+
1028+ /// build an app over the given streams + layer a snapshots, keeping
1029+ /// the control receiver alive so toggle tests can drain it.
1030+ fn build(
1031+ streams: Vec<StreamRoute>,
1032+ layer_a: Vec<LayerASnapshot>,
1033+ ) -> (HeadroomApp, Receiver<ControlCmd>) {
1034+ let status = Status {
1035+ version: "test".into(),
1036+ protocol: 1,
1037+ uptime_s: 0,
1038+ profile: "default".into(),
1039+ bypass: false,
1040+ per_app: false,
1041+ sinks: Sinks::default(),
1042+ streams,
1043+ layer_a,
1044+ warnings: vec![],
1045+ setting_overrides: Default::default(),
1046+ };
1047+ let route_list = RouteList {
1048+ rules: vec![],
1049+ current: vec![],
1050+ default_route: Route::Processed,
1051+ };
1052+ let (_tx, rx) = unbounded();
1053+ let (cmd_tx, cmd_rx) = unbounded();
1054+ let app = HeadroomApp::new(Bootstrap {
1055+ snapshot: Snapshot {
1056+ status,
1057+ route_list,
1058+ profiles: vec![],
1059+ },
1060+ rx,
1061+ cmd_tx,
1062+ });
1063+ (app, cmd_rx)
1064+ }
1065+
1066+ /// build a `status` snapshot for reconnect tests.
1067+ fn status_with(profile: &str, streams: Vec<StreamRoute>) -> Status {
1068+ Status {
1069+ version: "test".into(),
1070+ protocol: 1,
1071+ uptime_s: 0,
1072+ profile: profile.into(),
1073+ bypass: false,
1074+ per_app: false,
1075+ sinks: Sinks::default(),
1076+ streams,
1077+ layer_a: vec![],
1078+ warnings: vec![],
1079+ setting_overrides: Default::default(),
1080+ }
1081+ }
1082+
1083+ fn app() -> HeadroomApp {
1084+ build(vec![], vec![]).0
1085+ }
1086+
1087+ fn stream(node_id: u32, app: &str, route: Route) -> StreamRoute {
1088+ StreamRoute {
1089+ node_id,
1090+ app: app.into(),
1091+ route,
1092+ }
1093+ }
1094+
1095+ fn snap(node_id: u32, app: &str, managed: bool) -> LayerASnapshot {
1096+ LayerASnapshot {
1097+ node_id,
1098+ app: app.into(),
1099+ managed,
1100+ volume_lin: 1.0,
1101+ reduction_db: 0.0,
1102+ user_ceiling_lin: None,
1103+ deferred: false,
1104+ }
1105+ }
1106+
1107+ #[test]
1108+ fn meter_tick_event_updates_state() {
1109+ let mut s = app();
1110+ let tick = MeterTick {
1111+ momentary_lufs: -19.3,
1112+ shortterm_lufs: -20.1,
1113+ integrated_lufs: -19.8,
1114+ true_peak_dbtp: -1.4,
1115+ gain_reduction_db: -2.1,
1116+ compressor_gr_db: -0.8,
1117+ limiter_gr_db: -1.3,
1118+ agc_gain_db: 0.5,
1119+ agc_enabled: true,
1120+ compressor_enabled: true,
1121+ };
1122+ s.apply_event(Event::new(Topic::Meters, "tick", &tick).unwrap());
1123+ assert!(s.meters.is_some());
1124+ assert!(s.last_meter_at.is_some());
1125+ assert!(!s.meters_stale());
1126+ }
1127+
1128+ #[test]
1129+ fn routing_event_inserts_then_removes_stream() {
1130+ let mut s = app();
1131+ s.apply_event(
1132+ Event::new(
1133+ Topic::Routing,
1134+ "stream_routed",
1135+ &serde_json::json!({ "node_id": 42, "app": "firefox", "to": "bypass" }),
1136+ )
1137+ .unwrap(),
1138+ );
1139+ let st = s.streams.get(&42).expect("tracked");
1140+ assert_eq!(st.app, "firefox");
1141+ assert_eq!(st.route, Route::Bypass);
1142+ s.layer_a.insert(42, Some(-3.0));
1143+ s.la_snapshots.insert(42, snap(42, "firefox", true));
1144+
1145+ s.apply_event(
1146+ Event::new(
1147+ Topic::Routing,
1148+ "stream_removed",
1149+ &serde_json::json!({ "node_id": 42 }),
1150+ )
1151+ .unwrap(),
1152+ );
1153+ assert!(!s.streams.contains_key(&42));
1154+ assert!(!s.layer_a.contains_key(&42));
1155+ assert!(!s.la_snapshots.contains_key(&42));
1156+ }
1157+
1158+ #[test]
1159+ fn layer_a_level_updates_reduction() {
1160+ let mut s = app();
1161+ s.apply_event(
1162+ Event::new(
1163+ Topic::Routing,
1164+ "layer_a_attached",
1165+ &serde_json::json!({ "node_id": 11, "app": "loud" }),
1166+ )
1167+ .unwrap(),
1168+ );
1169+ assert_eq!(s.layer_a.get(&11), Some(&None));
1170+ s.apply_event(
1171+ Event::new(
1172+ Topic::Meters,
1173+ "layer_a_level",
1174+ &serde_json::json!({
1175+ "node_id": 11, "app": "loud",
1176+ "volume_lin": 0.25_f32, "reduction_db": -11.8_f32,
1177+ }),
1178+ )
1179+ .unwrap(),
1180+ );
1181+ let r = s.layer_a.get(&11).copied().flatten().unwrap();
1182+ assert!((r - -11.8).abs() < 1e-4);
1183+ }
1184+
1185+ #[test]
1186+ fn profile_changed_updates_active() {
1187+ let mut s = app();
1188+ s.apply_event(
1189+ Event::new(
1190+ Topic::Profile,
1191+ "changed",
1192+ &serde_json::json!({ "name": "night", "previous": "default" }),
1193+ )
1194+ .unwrap(),
1195+ );
1196+ assert_eq!(s.profile, "night");
1197+ }
1198+
1199+ #[test]
1200+ fn profile_used_event_updates_active() {
1201+ // the shape the daemon actually emits on `profile.use`.
1202+ let mut s = app();
1203+ s.apply_event(
1204+ Event::new(Topic::Profile, "used", &serde_json::json!({ "name": "movie" })).unwrap(),
1205+ );
1206+ assert_eq!(s.profile, "movie");
1207+ }
1208+
1209+ #[test]
1210+ fn profile_selected_updates_header_optimistically() {
1211+ let (mut s, _cmd) = build(vec![], vec![]);
1212+ assert_eq!(s.profile, "default");
1213+ s.update(Message::ProfileSelected("party".into()));
1214+ assert_eq!(s.profile, "party");
1215+ }
1216+
1217+ #[test]
1218+ fn profiles_refresh_syncs_active_profile() {
1219+ let (mut s, _tx) = build(vec![], vec![]);
1220+ s.handle_msg(AppMsg::Profiles(vec![
1221+ ProfileInfo {
1222+ name: "default".into(),
1223+ active: false,
1224+ description: String::new(),
1225+ },
1226+ ProfileInfo {
1227+ name: "gaming".into(),
1228+ active: true,
1229+ description: String::new(),
1230+ },
1231+ ]));
1232+ assert_eq!(s.profile, "gaming");
1233+ assert_eq!(s.profiles.len(), 2);
1234+ }
1235+
1236+ #[test]
1237+ fn reconnect_reseeds_state_and_clears_disconnect() {
1238+ // start with one stream, then simulate the daemon dropping and a
1239+ // *new* instance coming back with a different stream set + active
1240+ // profile (node ids change across a restart).
1241+ let (mut s, _cmd) = build(vec![stream(42, "firefox", Route::Bypass)], vec![]);
1242+ s.handle_msg(AppMsg::Disconnected("daemon shutdown".into()));
1243+ s.overflow_total = 7;
1244+ s.last_error = Some("stale".into());
1245+ assert!(s.disconnected.is_some());
1246+
1247+ s.handle_msg(AppMsg::Reconnected(Box::new(Snapshot {
1248+ status: status_with("music", vec![stream(99, "mpv", Route::Processed)]),
1249+ route_list: RouteList {
1250+ rules: vec![],
1251+ current: vec![stream(99, "mpv", Route::Processed)],
1252+ default_route: Route::Processed,
1253+ },
1254+ profiles: vec![ProfileInfo {
1255+ name: "music".into(),
1256+ active: true,
1257+ description: String::new(),
1258+ }],
1259+ })));
1260+
1261+ // banners cleared, per-instance counter reset.
1262+ assert!(s.disconnected.is_none());
1263+ assert!(s.last_error.is_none());
1264+ assert_eq!(s.overflow_total, 0);
1265+ // mirrored state replaced with the new daemon's snapshot.
1266+ assert_eq!(s.profile, "music");
1267+ assert!(!s.streams.contains_key(&42));
1268+ assert_eq!(s.streams.get(&99).map(|r| r.route), Some(Route::Processed));
1269+ assert_eq!(s.profiles.len(), 1);
1270+ }
1271+
1272+ #[test]
1273+ fn daemon_overflow_accumulates() {
1274+ let mut s = app();
1275+ s.apply_event(
1276+ Event::new(
1277+ Topic::Daemon,
1278+ "overflow",
1279+ &serde_json::json!({ "lost_topic": "meters", "lost": 3u32, "total_lost": 5u64 }),
1280+ )
1281+ .unwrap(),
1282+ );
1283+ assert_eq!(s.overflow_total, 5);
1284+ }
1285+
1286+ #[test]
1287+ fn fmt_uptime_buckets() {
1288+ assert_eq!(fmt_uptime(5), "5s");
1289+ assert_eq!(fmt_uptime(75), "1m15s");
1290+ assert_eq!(fmt_uptime(3725), "1h02m05s");
1291+ }
1292+
1293+ // -- selection -------------------------------------------------------
1294+
1295+ #[test]
1296+ fn selection_moves_in_key_order_and_wraps() {
1297+ let (mut s, _cmd) = build(
1298+ vec![
1299+ stream(30, "c", Route::Processed),
1300+ stream(10, "a", Route::Processed),
1301+ stream(20, "b", Route::Processed),
1302+ ],
1303+ vec![],
1304+ );
1305+ // seeded selection is the first key (BTreeMap order: 10).
1306+ assert_eq!(s.effective_selection(), Some(10));
1307+ s.update(Message::SelectDown);
1308+ assert_eq!(s.selected, Some(20));
1309+ s.update(Message::SelectDown);
1310+ assert_eq!(s.selected, Some(30));
1311+ s.update(Message::SelectDown);
1312+ assert_eq!(s.selected, Some(10)); // wrap to top
1313+ s.update(Message::SelectUp);
1314+ assert_eq!(s.selected, Some(30)); // wrap to bottom
1315+ }
1316+
1317+ #[test]
1318+ fn selection_empty_is_none() {
1319+ let (mut s, _cmd) = build(vec![], vec![]);
1320+ s.update(Message::SelectDown);
1321+ assert_eq!(s.selected, None);
1322+ assert_eq!(s.effective_selection(), None);
1323+ }
1324+
1325+ // -- control commands emitted by toggles -----------------------------
1326+
1327+ fn drain(cmd: &Receiver<ControlCmd>) -> ControlCmd {
1328+ cmd.try_recv().expect("a command was emitted")
1329+ }
1330+
1331+ #[test]
1332+ fn toggle_bypass_emits_and_updates_optimistically() {
1333+ let (mut s, cmd) = build(vec![], vec![]);
1334+ assert!(!s.bypass);
1335+ s.update(Message::ToggleBypass);
1336+ assert!(matches!(drain(&cmd), ControlCmd::SetBypass(true)));
1337+ assert!(s.bypass); // optimistic
1338+ s.update(Message::ToggleBypass);
1339+ assert!(matches!(drain(&cmd), ControlCmd::SetBypass(false)));
1340+ assert!(!s.bypass);
1341+ }
1342+
1343+ #[test]
1344+ fn toggle_per_app_master_emits_and_updates() {
1345+ let (mut s, cmd) = build(vec![], vec![]);
1346+ s.update(Message::TogglePerAppMaster);
1347+ assert!(matches!(drain(&cmd), ControlCmd::SetPerAppMaster(true)));
1348+ assert!(s.per_app_master);
1349+ }
1350+
1351+ #[test]
1352+ fn toggle_route_sets_persistent_per_app_rule_and_flips_optimistically() {
1353+ // a labelled stream toggles via a persistent per-app rule
1354+ // (route.set), not a one-shot per-stream reroute — so it
1355+ // survives the app recreating its stream between tracks.
1356+ let (mut s, cmd) = build(vec![stream(42, "firefox", Route::Processed)], vec![]);
1357+ s.update(Message::ToggleRoute(42));
1358+ match drain(&cmd) {
1359+ ControlCmd::SetRoute { app, to } => {
1360+ assert_eq!(app, "firefox");
1361+ assert_eq!(to, Route::Bypass);
1362+ }
1363+ _ => panic!("expected SetRoute"),
1364+ }
1365+ assert_eq!(s.streams.get(&42).unwrap().route, Route::Bypass); // optimistic
1366+ }
1367+
1368+ #[test]
1369+ fn toggle_route_empty_label_falls_back_to_per_stream() {
1370+ // a stream with no app label can't key a per-app rule, so it
1371+ // falls back to the one-shot per-stream reroute.
1372+ let (mut s, cmd) = build(vec![stream(5, "", Route::Processed)], vec![]);
1373+ s.update(Message::ToggleRoute(5));
1374+ match drain(&cmd) {
1375+ ControlCmd::RouteStream { node_id, to } => {
1376+ assert_eq!(node_id, 5);
1377+ assert_eq!(to, Route::Bypass);
1378+ }
1379+ _ => panic!("expected RouteStream fallback"),
1380+ }
1381+ }
1382+
1383+ #[test]
1384+ fn route_selected_acts_on_selection() {
1385+ let (mut s, cmd) = build(vec![stream(7, "mpv", Route::Bypass)], vec![]);
1386+ s.update(Message::RouteSelected);
1387+ match drain(&cmd) {
1388+ ControlCmd::SetRoute { app, to } => {
1389+ assert_eq!(app, "mpv");
1390+ assert_eq!(to, Route::Processed);
1391+ }
1392+ _ => panic!("expected SetRoute"),
1393+ }
1394+ }
1395+
1396+ #[test]
1397+ fn toggle_per_app_targets_inverse_of_managed() {
1398+ // node 1 managed → toggling asks to disable; node 2 unmanaged →
1399+ // toggling asks to enable.
1400+ let (mut s, cmd) = build(
1401+ vec![
1402+ stream(1, "spotify", Route::Processed),
1403+ stream(2, "discord", Route::Processed),
1404+ ],
1405+ vec![snap(1, "spotify", true)],
1406+ );
1407+ s.update(Message::TogglePerApp(1));
1408+ match drain(&cmd) {
1409+ ControlCmd::SetPerApp { app, enabled } => {
1410+ assert_eq!(app, "spotify");
1411+ assert!(!enabled);
1412+ }
1413+ _ => panic!("expected SetPerApp"),
1414+ }
1415+ s.update(Message::TogglePerApp(2));
1416+ match drain(&cmd) {
1417+ ControlCmd::SetPerApp { app, enabled } => {
1418+ assert_eq!(app, "discord");
1419+ assert!(enabled);
1420+ }
1421+ _ => panic!("expected SetPerApp"),
1422+ }
1423+ }
1424+
1425+ #[test]
1426+ fn toggle_per_app_empty_label_errors_no_command() {
1427+ let (mut s, cmd) = build(vec![stream(5, "", Route::Processed)], vec![]);
1428+ s.update(Message::TogglePerApp(5));
1429+ assert!(cmd.try_recv().is_err()); // no command emitted
1430+ assert!(s.last_error.is_some());
1431+ }
1432+
1433+ #[test]
1434+ fn reset_selected_emits_reset() {
1435+ let (mut s, cmd) = build(vec![stream(9, "game", Route::Processed)], vec![]);
1436+ s.update(Message::ResetSelected);
1437+ assert!(matches!(drain(&cmd), ControlCmd::ResetLayerA { node_id: 9 }));
1438+ }
1439+
1440+ #[test]
1441+ fn profile_selected_emits_use_profile() {
1442+ let (mut s, cmd) = build(vec![], vec![]);
1443+ s.update(Message::ProfileSelected("night".into()));
1444+ match drain(&cmd) {
1445+ ControlCmd::UseProfile(name) => assert_eq!(name, "night"),
1446+ _ => panic!("expected UseProfile"),
1447+ }
1448+ }
1449+
1450+ #[test]
1451+ fn clear_overrides_emits_and_clears_optimistically() {
1452+ let (mut s, cmd) = build(vec![], vec![]);
1453+ s.setting_overrides
1454+ .insert("agc.enabled".into(), serde_json::json!(false));
1455+ s.update(Message::ClearOverrides);
1456+ assert!(matches!(drain(&cmd), ControlCmd::ClearOverrides));
1457+ assert!(s.setting_overrides.is_empty()); // optimistic
1458+ }
1459+
1460+ #[test]
1461+ fn tick_refreshes_layer_a_on_cadence() {
1462+ let (mut s, cmd) = build(vec![], vec![]);
1463+ for _ in 0..(REFRESH_EVERY_TICKS - 1) {
1464+ s.update(Message::Tick);
1465+ }
1466+ assert!(cmd.try_recv().is_err()); // not yet
1467+ s.update(Message::Tick); // the REFRESH_EVERY_TICKS-th tick
1468+ assert!(matches!(drain(&cmd), ControlCmd::RefreshLayerA));
1469+ }
1470+
1471+ // -- key mapping -----------------------------------------------------
1472+
1473+ fn press(c: &str) -> keyboard::Event {
1474+ keyboard::Event::KeyPressed {
1475+ key: keyboard::Key::Character(c.into()),
1476+ modified_key: keyboard::Key::Character(c.into()),
1477+ physical_key: keyboard::key::Physical::Unidentified(
1478+ keyboard::key::NativeCode::Unidentified,
1479+ ),
1480+ location: keyboard::Location::Standard,
1481+ modifiers: keyboard::Modifiers::default(),
1482+ text: None,
1483+ repeat: false,
1484+ }
1485+ }
1486+
1487+ #[test]
1488+ fn key_mapping_matches_tui_hotkeys() {
1489+ assert!(matches!(map_key(press("b")), Message::ToggleBypass));
1490+ assert!(matches!(map_key(press("p")), Message::TogglePerAppMaster));
1491+ assert!(matches!(map_key(press("r")), Message::RouteSelected));
1492+ assert!(matches!(map_key(press("a")), Message::PerAppSelected));
1493+ assert!(matches!(map_key(press("x")), Message::ResetSelected));
1494+ assert!(matches!(map_key(press("j")), Message::SelectDown));
1495+ assert!(matches!(map_key(press("k")), Message::SelectUp));
1496+ assert!(matches!(map_key(press("z")), Message::Noop));
1497+ }
1498+}
Added file: crates/headroom-gui/src/app/state_tests.rs+65 additions
@@ -0,0 +1,65 @@
1+use crossbeam_channel::unbounded;
2+use headroom_ipc::{RouteList, Sinks, Status};
3+
4+use super::*;
5+
6+fn stream(node_id: u32, app: &str, route: Route) -> StreamRoute {
7+ StreamRoute {
8+ node_id,
9+ app: app.into(),
10+ route,
11+ }
12+}
13+
14+fn status_with(streams: Vec<StreamRoute>) -> Status {
15+ Status {
16+ version: "test".into(),
17+ protocol: 1,
18+ uptime_s: 0,
19+ profile: "default".into(),
20+ bypass: false,
21+ per_app: false,
22+ sinks: Sinks::default(),
23+ streams,
24+ layer_a: vec![],
25+ warnings: vec![],
26+ setting_overrides: Default::default(),
27+ }
28+}
29+
30+fn app_from_snapshot(
31+ status_streams: Vec<StreamRoute>,
32+ route_streams: Vec<StreamRoute>,
33+) -> HeadroomApp {
34+ let (_tx, rx) = unbounded();
35+ let (cmd_tx, _cmd_rx) = unbounded();
36+ HeadroomApp::new(Bootstrap {
37+ snapshot: Snapshot {
38+ status: status_with(status_streams),
39+ route_list: RouteList {
40+ rules: vec![],
41+ current: route_streams,
42+ default_route: Route::Processed,
43+ },
44+ profiles: vec![],
45+ },
46+ rx,
47+ cmd_tx,
48+ })
49+}
50+
51+#[test]
52+fn snapshot_merge_prefers_route_list_and_fills_missing_status_streams() {
53+ let s = app_from_snapshot(
54+ vec![
55+ stream(1, "status-only", Route::Processed),
56+ stream(2, "stale-status", Route::Processed),
57+ ],
58+ vec![stream(2, "route-list", Route::Bypass)],
59+ );
60+
61+ assert_eq!(s.streams.get(&1).map(|r| r.route), Some(Route::Processed));
62+ let got = s.streams.get(&2).expect("route list stream");
63+ assert_eq!(got.app, "route-list");
64+ assert_eq!(got.route, Route::Bypass);
65+}
Added file: crates/headroom-gui/src/io.rs+282 additions
@@ -0,0 +1,282 @@
1+//! daemon bridge: two `Client` connections off the ui thread — a reader
2+//! thread forwarding subscription events, a control thread running
3+//! request/response ops fed by a `ControlCmd` channel.
4+//!
5+//! initial rpcs run on the calling thread so a dead daemon fails fast
6+//! before any window opens. both workers survive the daemon going away
7+//! (shutdown / crash / `systemctl restart`): the reader reconnects with
8+//! capped backoff and pushes a fresh [`Snapshot`] so the ui re-seeds
9+//! (node ids change across a restart); the control thread reconnects
10+//! lazily with a one-shot retry across a dropped connection.
11+
12+use std::path::{Path, PathBuf};
13+use std::thread;
14+use std::time::Duration;
15+
16+use anyhow::{Context as _, Result};
17+use crossbeam_channel::{unbounded, Receiver, Sender};
18+use headroom_client::{Client, ClientError};
19+use headroom_ipc::{Event, LayerASnapshot, ProfileInfo, Route, RouteList, Status, Topic};
20+
21+/// topics the monitor subscribes to. shared by the initial connect and
22+/// every reconnect.
23+const TOPICS: [Topic; 4] = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon];
24+
25+/// reconnect backoff: first retry after the min, doubling to the cap.
26+const RECONNECT_MIN_DELAY: Duration = Duration::from_millis(500);
27+const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(3);
28+
29+/// a control op the ui asks the control thread to run; each maps to one
30+/// `Client` request/response call.
31+pub enum ControlCmd {
32+ /// `bypass.set`
33+ SetBypass(bool),
34+ /// `per-app.master`
35+ SetPerAppMaster(bool),
36+ /// `route.stream` — one-shot reroute of a live stream by node id
37+ /// (doesn't survive the stream being torn down).
38+ RouteStream { node_id: u32, to: Route },
39+ /// `route.set` — persistent per-app rule, keyed by app label (matches
40+ /// `application.name` / `process.binary`). survives the app recreating
41+ /// its stream between tracks.
42+ SetRoute { app: String, to: Route },
43+ /// `per-app.set`
44+ SetPerApp { app: String, enabled: bool },
45+ /// `per-app.reset`
46+ ResetLayerA { node_id: u32 },
47+ /// `profile.use`
48+ UseProfile(String),
49+ /// `setting.reset` — clear all overlay setting overrides.
50+ ClearOverrides,
51+ /// `per-app.list` → [`AppMsg::LayerASnapshots`].
52+ RefreshLayerA,
53+ /// `profile.list` → [`AppMsg::Profiles`].
54+ RefreshProfiles,
55+}
56+
57+/// the daemon snapshot the ui seeds (and re-seeds) from; taken at connect
58+/// and again on every reconnect.
59+pub struct Snapshot {
60+ pub status: Status,
61+ pub route_list: RouteList,
62+ /// seeds the switcher.
63+ pub profiles: Vec<ProfileInfo>,
64+}
65+
66+/// a message from a worker thread to the ui.
67+pub enum AppMsg {
68+ /// a subscription event off the wire (reader thread).
69+ Event(Event),
70+ /// the event stream ended; carries a reason for the footer banner.
71+ /// the reader starts reconnecting immediately; [`AppMsg::Reconnected`]
72+ /// follows once it succeeds.
73+ Disconnected(String),
74+ /// the reader reconnected after a drop. carries a fresh snapshot so
75+ /// the ui re-seeds (node ids/uptime/profile change across a restart).
76+ /// boxed to keep the enum small.
77+ Reconnected(Box<Snapshot>),
78+ /// a control op failed; surfaced in the footer.
79+ ControlError(String),
80+ /// fresh `profile.list` snapshot.
81+ Profiles(Vec<ProfileInfo>),
82+ /// fresh `per-app.list` snapshot (managed / ceiling / deferred).
83+ LayerASnapshots(Vec<LayerASnapshot>),
84+}
85+
86+/// everything `main` needs to seed the window.
87+pub struct Bootstrap {
88+ pub snapshot: Snapshot,
89+ /// live event + worker channel, drained each tick.
90+ pub rx: Receiver<AppMsg>,
91+ /// hands control ops to the control thread.
92+ pub cmd_tx: Sender<ControlCmd>,
93+}
94+
95+/// connect, subscribe, fetch the initial snapshot, open a second control
96+/// connection, then spawn the reader + control threads.
97+///
98+/// errors here are returned to the caller (fail fast before any window).
99+/// once the threads run, a stream failure is reported in-band as
100+/// [`AppMsg::Disconnected`] / [`AppMsg::Reconnected`]; control failures
101+/// as [`AppMsg::ControlError`].
102+pub fn start(socket: Option<PathBuf>) -> Result<Bootstrap> {
103+ let mut client = match socket.as_deref() {
104+ Some(p) => Client::connect_at(p)
105+ .with_context(|| format!("connecting to headroom daemon at {}", p.display()))?,
106+ None => Client::connect().context("connecting to headroom daemon (is it running?)")?,
107+ };
108+
109+ // subscribe + initial snapshot before the client moves to the reader.
110+ // a dead/half-broken daemon surfaces here.
111+ let snapshot = fetch_snapshot(&mut client).context("initial daemon fetch")?;
112+
113+ // the resolved socket path drives reconnects on both worker threads.
114+ let socket_path = client.socket_path().to_path_buf();
115+
116+ // second connection for control, on the same resolved socket.
117+ let control = Client::connect_at(&socket_path).context("opening control connection")?;
118+
119+ let (tx, rx) = unbounded::<AppMsg>();
120+ let (cmd_tx, cmd_rx) = unbounded::<ControlCmd>();
121+
122+ let reader_tx = tx.clone();
123+ let reader_socket = socket_path.clone();
124+ thread::Builder::new()
125+ .name("headroom-gui-rx".into())
126+ .spawn(move || reader_loop(reader_socket, client, reader_tx))
127+ .context("spawning reader thread")?;
128+
129+ thread::Builder::new()
130+ .name("headroom-gui-ctl".into())
131+ .spawn(move || control_loop(socket_path, control, cmd_rx, tx))
132+ .context("spawning control thread")?;
133+
134+ Ok(Bootstrap {
135+ snapshot,
136+ rx,
137+ cmd_tx,
138+ })
139+}
140+
141+/// subscribe to [`TOPICS`] and pull `status` + `route.list` +
142+/// `profile.list`. the `Client` correlates rpc responses while a
143+/// subscription is active (buffers interleaved events), so doing this on
144+/// the event connection is fine — one path seeds connect + every reconnect.
145+fn fetch_snapshot(client: &mut Client) -> Result<Snapshot, ClientError> {
146+ client.subscribe(&TOPICS)?;
147+ let status = client.status()?;
148+ let route_list = client.route_list()?;
149+ let profiles = client.profile_list()?;
150+ Ok(Snapshot {
151+ status,
152+ route_list,
153+ profiles,
154+ })
155+}
156+
157+/// forward each event to the ui. on stream failure, announce the drop,
158+/// reconnect (with backoff), and resume — returning only when the ui
159+/// channel closes (window gone).
160+fn reader_loop(socket: PathBuf, mut client: Client, tx: Sender<AppMsg>) {
161+ loop {
162+ match client.next_event() {
163+ Ok(ev) => {
164+ if tx.send(AppMsg::Event(ev)).is_err() {
165+ return; // ui gone.
166+ }
167+ }
168+ Err(e) => {
169+ tracing::warn!(error = %e, "event stream lost; reconnecting to daemon");
170+ if tx.send(AppMsg::Disconnected(e.to_string())).is_err() {
171+ return;
172+ }
173+ match reconnect_event_client(&socket, &tx) {
174+ Some(c) => {
175+ tracing::info!("reconnected to daemon; re-seeded UI snapshot");
176+ client = c;
177+ }
178+ None => return, // ui gone while reconnecting.
179+ }
180+ }
181+ }
182+ }
183+}
184+
185+/// reconnect the event stream after a drop. retries `connect_at` with
186+/// capped backoff until the daemon answers with a fresh snapshot, then
187+/// sends [`AppMsg::Reconnected`] and returns the live client. a daemon
188+/// that's up but can't complete the snapshot (half-open / mid-restart)
189+/// counts as a failed attempt. returns `None` only if the ui closed.
190+fn reconnect_event_client(socket: &Path, tx: &Sender<AppMsg>) -> Option<Client> {
191+ let mut delay = RECONNECT_MIN_DELAY;
192+ loop {
193+ if let Ok(mut client) = Client::connect_at(socket) {
194+ if let Ok(snapshot) = fetch_snapshot(&mut client) {
195+ if tx.send(AppMsg::Reconnected(Box::new(snapshot))).is_err() {
196+ return None;
197+ }
198+ return Some(client);
199+ }
200+ }
201+ thread::sleep(delay);
202+ delay = (delay * 2).min(RECONNECT_MAX_DELAY);
203+ }
204+}
205+
206+/// block on the command channel, dispatching each [`ControlCmd`] to its
207+/// `Client` call. refresh ops reply with a snapshot `AppMsg`; failures
208+/// become [`AppMsg::ControlError`]. returns when either channel closes.
209+///
210+/// the control connection reconnects lazily: if torn down (daemon
211+/// restart) the next command opens a fresh one, and a command that fails
212+/// mid-flight on a connection error is retried once before being reported.
213+fn control_loop(socket: PathBuf, initial: Client, cmd_rx: Receiver<ControlCmd>, tx: Sender<AppMsg>) {
214+ let mut client: Option<Client> = Some(initial);
215+ while let Ok(cmd) = cmd_rx.recv() {
216+ // reconnect if a prior op tore the connection down.
217+ if client.is_none() {
218+ client = Client::connect_at(&socket).ok();
219+ }
220+ let Some(c) = client.as_mut() else {
221+ if tx
222+ .send(AppMsg::ControlError("daemon unreachable".into()))
223+ .is_err()
224+ {
225+ return;
226+ }
227+ continue;
228+ };
229+
230+ let mut result = run_cmd(c, &cmd);
231+ // one retry across a dropped connection — the daemon may have
232+ // restarted since the last op left the socket stale.
233+ if matches!(&result, Err(e) if is_connection_error(e)) {
234+ client = Client::connect_at(&socket).ok();
235+ if let Some(c) = client.as_mut() {
236+ result = run_cmd(c, &cmd);
237+ }
238+ }
239+
240+ let msg = match result {
241+ Ok(Some(reply)) => reply,
242+ Ok(None) => continue,
243+ Err(e) => {
244+ // drop a dead connection so the next command reconnects.
245+ if is_connection_error(&e) {
246+ client = None;
247+ }
248+ AppMsg::ControlError(e.to_string())
249+ }
250+ };
251+ if tx.send(msg).is_err() {
252+ return;
253+ }
254+ }
255+}
256+
257+/// run one [`ControlCmd`] against `client`. borrows the command so the
258+/// caller can retry on a fresh connection. `Ok(Some(_))` for refreshes
259+/// that reply, `Ok(None)` for fire-and-forget toggles.
260+fn run_cmd(client: &mut Client, cmd: &ControlCmd) -> Result<Option<AppMsg>, ClientError> {
261+ match cmd {
262+ ControlCmd::SetBypass(enabled) => client.bypass_set(*enabled).map(|()| None),
263+ ControlCmd::SetPerAppMaster(enabled) => client.per_app_master(*enabled).map(|()| None),
264+ ControlCmd::RouteStream { node_id, to } => {
265+ client.route_stream(*node_id, *to).map(|()| None)
266+ }
267+ ControlCmd::SetRoute { app, to } => client.route_set(app, *to).map(|()| None),
268+ ControlCmd::SetPerApp { app, enabled } => client.per_app_set(app, *enabled).map(|()| None),
269+ ControlCmd::ResetLayerA { node_id } => client.layer_a_reset(*node_id).map(|()| None),
270+ ControlCmd::UseProfile(name) => client.profile_use(name).map(|_| None),
271+ ControlCmd::ClearOverrides => client.setting_reset().map(|_| None),
272+ ControlCmd::RefreshLayerA => client.layer_a_list().map(|l| Some(AppMsg::LayerASnapshots(l))),
273+ ControlCmd::RefreshProfiles => client.profile_list().map(|p| Some(AppMsg::Profiles(p))),
274+ }
275+}
276+
277+/// whether an error means the socket is gone (vs a server-side rejection
278+/// of a delivered op). only the former warrants a reconnect; a `Protocol`
279+/// error is a logical failure to surface as-is.
280+fn is_connection_error(e: &ClientError) -> bool {
281+ matches!(e, ClientError::Ipc(_))
282+}
Added file: crates/headroom-gui/src/main.rs+67 additions
@@ -0,0 +1,67 @@
1+//! `headroom-gui` — native gpu-accelerated monitor for the headroom
2+//! daemon, built on [iced](https://iced.rs). additive and optional, with
3+//! full control parity with the ratatui tui (route/bypass/per-app/profile)
4+//! by mouse + keyboard; ships as its own nix package, separate from the
5+//! daemon. build from the nix dev shell (supplies iced's system libs).
6+
7+mod app;
8+mod io;
9+
10+use std::path::PathBuf;
11+
12+use crate::app::HeadroomApp;
13+
14+fn main() -> anyhow::Result<()> {
15+ init_tracing();
16+
17+ let socket = parse_socket_arg();
18+
19+ // connect + initial fetch before opening any window, so a dead daemon
20+ // fails fast. the reader thread lives inside the returned `Bootstrap`;
21+ // the iced `subscription` poll tick drains its channel.
22+ let bootstrap = io::start(socket)?;
23+
24+ // iced's `BootFn` is `Fn` (callable repeatedly) but `Bootstrap` owns a
25+ // receiver and is single-use; stash it and `take()` on the first boot.
26+ let boot = std::sync::Mutex::new(Some(bootstrap));
27+
28+ iced::application(
29+ move || HeadroomApp::new(boot.lock().unwrap().take().expect("boot runs once")),
30+ HeadroomApp::update,
31+ HeadroomApp::view,
32+ )
33+ .title(HeadroomApp::title)
34+ .subscription(HeadroomApp::subscription)
35+ .theme(|_state: &HeadroomApp| iced::Theme::Dark)
36+ .window_size(iced::Size::new(900.0, 680.0))
37+ .run()
38+ .map_err(|e| anyhow::anyhow!("iced: {e}"))?;
39+
40+ Ok(())
41+}
42+
43+/// minimal `--socket <path>` parsing — the only flag, so no clap.
44+fn parse_socket_arg() -> Option<PathBuf> {
45+ let mut args = std::env::args().skip(1);
46+ while let Some(arg) = args.next() {
47+ match arg.as_str() {
48+ "--socket" => return args.next().map(PathBuf::from),
49+ other => {
50+ if let Some(path) = other.strip_prefix("--socket=") {
51+ return Some(PathBuf::from(path));
52+ }
53+ }
54+ }
55+ }
56+ None
57+}
58+
59+fn init_tracing() {
60+ tracing_subscriber::fmt()
61+ .with_env_filter(
62+ tracing_subscriber::EnvFilter::try_from_default_env()
63+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("headroom_gui=info")),
64+ )
65+ .with_writer(std::io::stderr)
66+ .init();
67+}