Commit c19702ae09be
tarball9 files changed+3841-0
@@ -0,0 +1,37 @@
1
+[package]2
+name = "headroom-cli"3
+description = "automatic loudness and per-app volume control (pipewire)"4
+version.workspace = true5
+edition.workspace = true6
+license.workspace = true7
+homepage.workspace = true8
+repository.workspace = true9
+authors.workspace = true10
+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` still36
+# works (daemon + every other subcommand unaffected).37
+tui = ["dep:crossbeam-channel", "dep:crossterm", "dep:ratatui"]@@ -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/user36
+ /// 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 persist63
+ /// 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 tui92
+ /// 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
+where264
+ 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 = s330
+ .user_ceiling_lin331
+ .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.deferred336
+ );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::SUCCESS442
+}@@ -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 — process5
+//! 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 and37
+/// 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 a46
+ // 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
+ outcome63
+}64
+65
+// ---------------------------------------------------------------------------66
+// Reader thread67
+// ---------------------------------------------------------------------------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
+// State92
+// ---------------------------------------------------------------------------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 stale101
+ /// `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 smoothed109
+ /// reduction (none until the first `layer_a_level`).110
+ layer_a: BTreeMap<u32, Option<f32>>,111
+ /// richer layer a snapshots (ceiling/deferred/managed), polled from112
+ /// `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
+ profiles146
+ .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_s194
+ .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 = self218
+ .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 keeps306
+/// `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 loop325
+// ---------------------------------------------------------------------------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 the376
+/// 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 run389
+/// 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 the437
+ // 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
+ false490
+}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 thread538
+// ---------------------------------------------------------------------------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
+ rx563
+}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
+// Drawing573
+// ---------------------------------------------------------------------------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 gauges592
+ Constraint::Length(5), // loudness593
+ Constraint::Min(4), // streams table594
+ 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 (●), highlighted622
+/// 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> = picker642
+ .profiles643
+ .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 = picker663
+ .profiles664
+ .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
+ parts757
+}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 backstop829
+ },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 meaningful842
+ },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 renders854
+ /// "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 = value906
+ .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 f64917
+ }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 = state940
+ .last_meter_at941
+ .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_route982
+ );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> = state990
+ .streams991
+ .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
+ row1017
+ }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 = state1048
+ .streams1049
+ .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 = snap1055
+ .user_ceiling_lin1056
+ .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::Cyan1069
+ } else {1070
+ Color::DarkGray1071
+ }),1072
+ ),1073
+ Span::raw(format!(1074
+ " reduction {:+.1} dB ceiling {ceiling} ",1075
+ snap.reduction_db1076
+ )),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 top1321
+ p.move_sel(1);1322
+ assert_eq!(p.selected, 0); // wrap down from bottom1323
+ 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
+}@@ -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
+}@@ -0,0 +1,51 @@
1
+[package]2
+name = "headroom-gui"3
+version.workspace = true4
+# 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; both7
+# supply iced's system libs.8
+edition = "2021"9
+license.workspace = true10
+homepage.workspace = true11
+repository.workspace = true12
+authors.workspace = true13
+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 — we26
+# 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` is29
+# 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 back33
+# 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 }@@ -0,0 +1,1498 @@
1
+//! `HeadroomApp` — iced application state + view; mirrors the tui's2
+//! read-only `UiState` (`crates/headroom-cli/src/tui.rs`).3
+//!4
+//! control ops never block the ui thread: sent over `cmd_tx` to the5
+//! control thread (`io.rs`), which replies over the same `rx` the ui6
+//! drains each tick. ui updates optimistically; daemon events + the7
+//! ~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 "--". mirrors25
+/// `headroom_core::agc::LOUDNESS_FLOOR_LUFS`, inlined so the gui doesn't26
+/// 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/staleness33
+/// 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 instantly58
+/// 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 from109
+ /// `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 the162
+ // user's selection, so this only runs at startup).163
+ app.selected = app.streams.keys().next().copied();164
+ app165
+ }166
+167
+ /// replace mirrored daemon state from a fresh [`Snapshot`]. shared by168
+ /// [`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 counter209
+ /// (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 focused225
+ /// 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 event241
+ // 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 daemon256
+ }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 the288
+ // 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 = self350
+ .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 tracks361
+ /// (Strawberry, mpv, …); fall back to a one-shot `route.stream` only362
+ /// when there's no label to key on. the optimistic update flips just363
+ /// this row; the daemon's reevaluate emits `stream_routed` for sibling364
+ /// 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 empty384
+ /// 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_s407
+ .saturating_add(self.connected_at.elapsed().as_secs())408
+ }409
+410
+ fn meters_stale(&self) -> bool {411
+ self.last_meter_at412
+ .map(|t| t.elapsed() > STALE_AFTER)413
+ .unwrap_or(true)414
+ }415
+416
+ /// apply one wire event. ported from the tui's `apply_event`, minus417
+ /// 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 (not463
+ // the typed `ProfileEvent::Changed` "changed"/`previous`464
+ // shape), so read `name` directly and accept either spelling465
+ // — 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
+ // View504
+ // -----------------------------------------------------------------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, click537
+ // 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 stale561
+ // `headroom set` is otherwise invisible (e.g. agc.enabled pinned562
+ // 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-on592
+ // 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_route681
+ );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 helpers729
+// ---------------------------------------------------------------------------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) for776
+/// 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 on805
+/// 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`. when822
+/// `!enabled` the stage is bypassed, so render "disabled" + a greyed bar823
+/// — 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
+ value843
+ .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 its886
+/// 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 row912
+ // can't fire a no-op control op.913
+ let actions: Element<'static, Message> = if s.app.is_empty() {914
+ route_btn915
+ } 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 it940
+ // (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 on952
+/// 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 are967
+/// `#[serde(tag = "event")]` inside `data`, so re-inject the event name968
+/// 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's993
+/// `handle_key`. unhandled keys / non-press events become994
+/// [`Message::Noop`]; selection-targeted messages no-op in `update` when995
+/// 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, keeping1029
+ /// 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![]).01085
+ }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 a1239
+ // *new* instance coming back with a different stream set + active1240
+ // 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 top1313
+ s.update(Message::SelectUp);1314
+ assert_eq!(s.selected, Some(30)); // wrap to bottom1315
+ }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); // optimistic1338
+ 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 rule1354
+ // (route.set), not a one-shot per-stream reroute — so it1355
+ // 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); // optimistic1366
+ }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 it1371
+ // 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 emitted1430
+ 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_overrides1454
+ .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()); // optimistic1458
+ }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 yet1467
+ s.update(Message::Tick); // the REFRESH_EVERY_TICKS-th tick1468
+ 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
+}@@ -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
+}@@ -0,0 +1,282 @@
1
+//! daemon bridge: two `Client` connections off the ui thread — a reader2
+//! thread forwarding subscription events, a control thread running3
+//! request/response ops fed by a `ControlCmd` channel.4
+//!5
+//! initial rpcs run on the calling thread so a dead daemon fails fast6
+//! before any window opens. both workers survive the daemon going away7
+//! (shutdown / crash / `systemctl restart`): the reader reconnects with8
+//! capped backoff and pushes a fresh [`Snapshot`] so the ui re-seeds9
+//! (node ids change across a restart); the control thread reconnects10
+//! 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 and22
+/// 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 one30
+/// `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 id37
+ /// (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 (matches40
+ /// `application.name` / `process.binary`). survives the app recreating41
+ /// 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 connect58
+/// 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 so75
+ /// 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 control96
+/// 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 as100
+/// [`AppMsg::Disconnected`] / [`AppMsg::Reconnected`]; control failures101
+/// 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 a143
+/// subscription is active (buffers interleaved events), so doing this on144
+/// 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 ui159
+/// 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` with186
+/// capped backoff until the daemon answers with a fresh snapshot, then187
+/// sends [`AppMsg::Reconnected`] and returns the live client. a daemon188
+/// 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 its207
+/// `Client` call. refresh ops reply with a snapshot `AppMsg`; failures208
+/// become [`AppMsg::ControlError`]. returns when either channel closes.209
+///210
+/// the control connection reconnects lazily: if torn down (daemon211
+/// restart) the next command opens a fresh one, and a command that fails212
+/// 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 tx222
+ .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 have232
+ // 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 the258
+/// caller can retry on a fresh connection. `Ok(Some(_))` for refreshes259
+/// 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 rejection278
+/// 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
+}@@ -0,0 +1,67 @@
1
+//! `headroom-gui` — native gpu-accelerated monitor for the headroom2
+//! daemon, built on [iced](https://iced.rs). additive and optional, with3
+//! full control parity with the ratatui tui (route/bypass/per-app/profile)4
+//! by mouse + keyboard; ships as its own nix package, separate from the5
+//! 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 daemon20
+ // 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 a25
+ // 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
+ None57
+}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
+}