atagen

headroom

public
Star0
Watch0
da174e47c3dfe446a07b3dd77fdfcd414277a44e
1
ssh://brewery@pls.lobotomise.me:33/atagen/headroom.git

Blame: crates/headroom-cli/src/tui.rs

c19702ae09beatagen2026-05-27 15:49 UTC1
//! `headroom monitor` tui — bus gauges, loudness, per-stream routing.
c19702ae09beatagen2026-05-27 15:49 UTC2
//!
c19702ae09beatagen2026-05-27 15:49 UTC3
//! main thread owns terminal + draw loop; reader thread owns the `Client`,
c19702ae09beatagen2026-05-27 15:49 UTC4
//! forwards events over a channel. no graceful reader shutdown — process
c19702ae09beatagen2026-05-27 15:49 UTC5
//! exit tears the socket down.
c19702ae09beatagen2026-05-27 15:49 UTC6
c19702ae09beatagen2026-05-27 15:49 UTC7
use std::collections::BTreeMap;
c19702ae09beatagen2026-05-27 15:49 UTC8
use std::io;
c19702ae09beatagen2026-05-27 15:49 UTC9
use std::thread;
c19702ae09beatagen2026-05-27 15:49 UTC10
use std::time::{Duration, Instant};
c19702ae09beatagen2026-05-27 15:49 UTC11
c19702ae09beatagen2026-05-27 15:49 UTC12
use crossbeam_channel::{select, tick, unbounded, Receiver};
c19702ae09beatagen2026-05-27 15:49 UTC13
use crossterm::event::{self, Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
c19702ae09beatagen2026-05-27 15:49 UTC14
use headroom_client::{Client, ClientError};
c19702ae09beatagen2026-05-27 15:49 UTC15
use headroom_ipc::{
c19702ae09beatagen2026-05-27 15:49 UTC16
    DaemonEvent, Event, LayerALevel, LayerASnapshot, MeterTick, Route, RoutingEvent, Status,
c19702ae09beatagen2026-05-27 15:49 UTC17
    StreamRoute, Topic,
c19702ae09beatagen2026-05-27 15:49 UTC18
};
c19702ae09beatagen2026-05-27 15:49 UTC19
use ratatui::{
c19702ae09beatagen2026-05-27 15:49 UTC20
    layout::{Alignment, Constraint, Direction, Layout, Rect},
c19702ae09beatagen2026-05-27 15:49 UTC21
    style::{Color, Modifier, Style, Stylize},
c19702ae09beatagen2026-05-27 15:49 UTC22
    text::{Line, Span},
c19702ae09beatagen2026-05-27 15:49 UTC23
    widgets::{Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, Wrap},
c19702ae09beatagen2026-05-27 15:49 UTC24
    Frame, Terminal,
c19702ae09beatagen2026-05-27 15:49 UTC25
};
c19702ae09beatagen2026-05-27 15:49 UTC26
c19702ae09beatagen2026-05-27 15:49 UTC27
#[derive(Debug, thiserror::Error)]
c19702ae09beatagen2026-05-27 15:49 UTC28
pub enum TuiError {
c19702ae09beatagen2026-05-27 15:49 UTC29
    #[error("client: {0}")]
c19702ae09beatagen2026-05-27 15:49 UTC30
    Client(#[from] ClientError),
c19702ae09beatagen2026-05-27 15:49 UTC31
c19702ae09beatagen2026-05-27 15:49 UTC32
    #[error("terminal: {0}")]
c19702ae09beatagen2026-05-27 15:49 UTC33
    Io(#[from] io::Error),
c19702ae09beatagen2026-05-27 15:49 UTC34
}
c19702ae09beatagen2026-05-27 15:49 UTC35
c19702ae09beatagen2026-05-27 15:49 UTC36
/// runs initial rpcs, then hands the client to the reader thread and
c19702ae09beatagen2026-05-27 15:49 UTC37
/// enters the draw loop.
c19702ae09beatagen2026-05-27 15:49 UTC38
pub fn run(mut client: Client) -> Result<(), TuiError> {
c19702ae09beatagen2026-05-27 15:49 UTC39
    // subscribe + initial state before raw mode.
c19702ae09beatagen2026-05-27 15:49 UTC40
    let topics = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon];
c19702ae09beatagen2026-05-27 15:49 UTC41
    client.subscribe(&topics)?;
c19702ae09beatagen2026-05-27 15:49 UTC42
    let status = client.status()?;
c19702ae09beatagen2026-05-27 15:49 UTC43
    let route_list = client.route_list()?;
c19702ae09beatagen2026-05-27 15:49 UTC44
c19702ae09beatagen2026-05-27 15:49 UTC45
    // client is single-connection + the reader owns it for events; open a
c19702ae09beatagen2026-05-27 15:49 UTC46
    // second connection for control (keypress ops).
c19702ae09beatagen2026-05-27 15:49 UTC47
    let mut control = Client::connect_at(client.socket_path())?;
c19702ae09beatagen2026-05-27 15:49 UTC48
c19702ae09beatagen2026-05-27 15:49 UTC49
    let (tx, rx) = unbounded::<Msg>();
c19702ae09beatagen2026-05-27 15:49 UTC50
    let reader_handle = thread::Builder::new()
c19702ae09beatagen2026-05-27 15:49 UTC51
        .name("headroom-monitor-rx".into())
c19702ae09beatagen2026-05-27 15:49 UTC52
        .spawn(move || reader_loop(client, tx))
c19702ae09beatagen2026-05-27 15:49 UTC53
        .map_err(TuiError::Io)?;
c19702ae09beatagen2026-05-27 15:49 UTC54
c19702ae09beatagen2026-05-27 15:49 UTC55
    let mut terminal = ratatui::init();
c19702ae09beatagen2026-05-27 15:49 UTC56
    let outcome = draw_loop(&mut terminal, status, route_list, rx, &mut control);
c19702ae09beatagen2026-05-27 15:49 UTC57
    ratatui::restore();
c19702ae09beatagen2026-05-27 15:49 UTC58
c19702ae09beatagen2026-05-27 15:49 UTC59
    // detach: process exit / dropped channel tears the connection down.
c19702ae09beatagen2026-05-27 15:49 UTC60
    drop(reader_handle);
c19702ae09beatagen2026-05-27 15:49 UTC61
c19702ae09beatagen2026-05-27 15:49 UTC62
    outcome
c19702ae09beatagen2026-05-27 15:49 UTC63
}
c19702ae09beatagen2026-05-27 15:49 UTC64
c19702ae09beatagen2026-05-27 15:49 UTC65
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC66
// Reader thread
c19702ae09beatagen2026-05-27 15:49 UTC67
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC68
c19702ae09beatagen2026-05-27 15:49 UTC69
enum Msg {
c19702ae09beatagen2026-05-27 15:49 UTC70
    Event(Event),
c19702ae09beatagen2026-05-27 15:49 UTC71
    Disconnected(String),
c19702ae09beatagen2026-05-27 15:49 UTC72
}
c19702ae09beatagen2026-05-27 15:49 UTC73
c19702ae09beatagen2026-05-27 15:49 UTC74
fn reader_loop(mut client: Client, tx: crossbeam_channel::Sender<Msg>) {
c19702ae09beatagen2026-05-27 15:49 UTC75
    loop {
c19702ae09beatagen2026-05-27 15:49 UTC76
        match client.next_event() {
c19702ae09beatagen2026-05-27 15:49 UTC77
            Ok(ev) => {
c19702ae09beatagen2026-05-27 15:49 UTC78
                if tx.send(Msg::Event(ev)).is_err() {
c19702ae09beatagen2026-05-27 15:49 UTC79
                    return;
c19702ae09beatagen2026-05-27 15:49 UTC80
                }
c19702ae09beatagen2026-05-27 15:49 UTC81
            }
c19702ae09beatagen2026-05-27 15:49 UTC82
            Err(e) => {
c19702ae09beatagen2026-05-27 15:49 UTC83
                let _ = tx.send(Msg::Disconnected(e.to_string()));
c19702ae09beatagen2026-05-27 15:49 UTC84
                return;
c19702ae09beatagen2026-05-27 15:49 UTC85
            }
c19702ae09beatagen2026-05-27 15:49 UTC86
        }
c19702ae09beatagen2026-05-27 15:49 UTC87
    }
c19702ae09beatagen2026-05-27 15:49 UTC88
}
c19702ae09beatagen2026-05-27 15:49 UTC89
c19702ae09beatagen2026-05-27 15:49 UTC90
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC91
// State
c19702ae09beatagen2026-05-27 15:49 UTC92
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC93
c19702ae09beatagen2026-05-27 15:49 UTC94
struct UiState {
c19702ae09beatagen2026-05-27 15:49 UTC95
    daemon_version: String,
c19702ae09beatagen2026-05-27 15:49 UTC96
    profile: String,
c19702ae09beatagen2026-05-27 15:49 UTC97
    bypass: bool,
c19702ae09beatagen2026-05-27 15:49 UTC98
    /// layer a master switch (per-app level control enabled globally).
c19702ae09beatagen2026-05-27 15:49 UTC99
    per_app_master: bool,
c19702ae09beatagen2026-05-27 15:49 UTC100
    /// overlay overrides shadowing the profile; flagged so a stale
c19702ae09beatagen2026-05-27 15:49 UTC101
    /// `headroom set` isn't invisible.
c19702ae09beatagen2026-05-27 15:49 UTC102
    setting_overrides: BTreeMap<String, serde_json::Value>,
c19702ae09beatagen2026-05-27 15:49 UTC103
    /// daemon uptime at connect, plus local elapsed.
c19702ae09beatagen2026-05-27 15:49 UTC104
    base_uptime_s: u64,
c19702ae09beatagen2026-05-27 15:49 UTC105
    connected_at: Instant,
c19702ae09beatagen2026-05-27 15:49 UTC106
    default_route: Route,
c19702ae09beatagen2026-05-27 15:49 UTC107
    streams: BTreeMap<u32, StreamRoute>,
c19702ae09beatagen2026-05-27 15:49 UTC108
    /// per-stream layer a. presence = tap attached; inner = latest smoothed
c19702ae09beatagen2026-05-27 15:49 UTC109
    /// reduction (none until the first `layer_a_level`).
c19702ae09beatagen2026-05-27 15:49 UTC110
    layer_a: BTreeMap<u32, Option<f32>>,
c19702ae09beatagen2026-05-27 15:49 UTC111
    /// richer layer a snapshots (ceiling/deferred/managed), polled from
c19702ae09beatagen2026-05-27 15:49 UTC112
    /// `per-app.list`; feeds the detail line.
c19702ae09beatagen2026-05-27 15:49 UTC113
    la_snapshots: BTreeMap<u32, LayerASnapshot>,
c19702ae09beatagen2026-05-27 15:49 UTC114
    /// selected stream node id; resolved against `streams` at draw time.
c19702ae09beatagen2026-05-27 15:49 UTC115
    selected: Option<u32>,
c19702ae09beatagen2026-05-27 15:49 UTC116
    meters: Option<MeterTick>,
c19702ae09beatagen2026-05-27 15:49 UTC117
    /// last tick arrival; drives staleness when the source goes silent.
c19702ae09beatagen2026-05-27 15:49 UTC118
    last_meter_at: Option<Instant>,
c19702ae09beatagen2026-05-27 15:49 UTC119
    overflow_total: u64,
c19702ae09beatagen2026-05-27 15:49 UTC120
    last_error: Option<String>,
c19702ae09beatagen2026-05-27 15:49 UTC121
    disconnected: Option<String>,
c19702ae09beatagen2026-05-27 15:49 UTC122
    /// modal profile picker; while `Some` it intercepts all keyboard input.
c19702ae09beatagen2026-05-27 15:49 UTC123
    picker: Option<ProfilePicker>,
c19702ae09beatagen2026-05-27 15:49 UTC124
}
c19702ae09beatagen2026-05-27 15:49 UTC125
c19702ae09beatagen2026-05-27 15:49 UTC126
/// modal profile-switcher; `selected` indexes `profiles`.
c19702ae09beatagen2026-05-27 15:49 UTC127
struct ProfilePicker {
c19702ae09beatagen2026-05-27 15:49 UTC128
    profiles: Vec<headroom_ipc::ProfileInfo>,
c19702ae09beatagen2026-05-27 15:49 UTC129
    selected: usize,
c19702ae09beatagen2026-05-27 15:49 UTC130
}
c19702ae09beatagen2026-05-27 15:49 UTC131
c19702ae09beatagen2026-05-27 15:49 UTC132
impl ProfilePicker {
c19702ae09beatagen2026-05-27 15:49 UTC133
    /// move the highlight by `delta` (negative = up), wrapping.
c19702ae09beatagen2026-05-27 15:49 UTC134
    fn move_sel(&mut self, delta: isize) {
c19702ae09beatagen2026-05-27 15:49 UTC135
        if self.profiles.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC136
            return;
c19702ae09beatagen2026-05-27 15:49 UTC137
        }
c19702ae09beatagen2026-05-27 15:49 UTC138
        let n = self.profiles.len() as isize;
c19702ae09beatagen2026-05-27 15:49 UTC139
        self.selected = (self.selected as isize + delta).rem_euclid(n) as usize;
c19702ae09beatagen2026-05-27 15:49 UTC140
    }
c19702ae09beatagen2026-05-27 15:49 UTC141
}
c19702ae09beatagen2026-05-27 15:49 UTC142
c19702ae09beatagen2026-05-27 15:49 UTC143
/// initial highlight: active profile, else the daemon's current name, else top.
c19702ae09beatagen2026-05-27 15:49 UTC144
fn initial_picker_selection(profiles: &[headroom_ipc::ProfileInfo], current: &str) -> usize {
c19702ae09beatagen2026-05-27 15:49 UTC145
    profiles
c19702ae09beatagen2026-05-27 15:49 UTC146
        .iter()
c19702ae09beatagen2026-05-27 15:49 UTC147
        .position(|p| p.active)
c19702ae09beatagen2026-05-27 15:49 UTC148
        .or_else(|| profiles.iter().position(|p| p.name == current))
c19702ae09beatagen2026-05-27 15:49 UTC149
        .unwrap_or(0)
c19702ae09beatagen2026-05-27 15:49 UTC150
}
c19702ae09beatagen2026-05-27 15:49 UTC151
c19702ae09beatagen2026-05-27 15:49 UTC152
impl UiState {
c19702ae09beatagen2026-05-27 15:49 UTC153
    fn new(status: Status, route_list: headroom_ipc::RouteList) -> Self {
c19702ae09beatagen2026-05-27 15:49 UTC154
        let mut streams = BTreeMap::new();
c19702ae09beatagen2026-05-27 15:49 UTC155
        for s in route_list.current {
c19702ae09beatagen2026-05-27 15:49 UTC156
            streams.insert(s.node_id, s);
c19702ae09beatagen2026-05-27 15:49 UTC157
        }
c19702ae09beatagen2026-05-27 15:49 UTC158
        // `status.streams` is a superset; merge.
c19702ae09beatagen2026-05-27 15:49 UTC159
        for s in status.streams.iter() {
c19702ae09beatagen2026-05-27 15:49 UTC160
            streams.entry(s.node_id).or_insert_with(|| s.clone());
c19702ae09beatagen2026-05-27 15:49 UTC161
        }
c19702ae09beatagen2026-05-27 15:49 UTC162
        // seed layer a from status so table/detail are populated pre-poll.
c19702ae09beatagen2026-05-27 15:49 UTC163
        let mut la_snapshots = BTreeMap::new();
c19702ae09beatagen2026-05-27 15:49 UTC164
        let mut layer_a = BTreeMap::new();
c19702ae09beatagen2026-05-27 15:49 UTC165
        for snap in status.layer_a {
c19702ae09beatagen2026-05-27 15:49 UTC166
            layer_a.insert(snap.node_id, Some(snap.reduction_db));
c19702ae09beatagen2026-05-27 15:49 UTC167
            la_snapshots.insert(snap.node_id, snap);
c19702ae09beatagen2026-05-27 15:49 UTC168
        }
c19702ae09beatagen2026-05-27 15:49 UTC169
        let selected = streams.keys().next().copied();
c19702ae09beatagen2026-05-27 15:49 UTC170
        Self {
c19702ae09beatagen2026-05-27 15:49 UTC171
            daemon_version: status.version,
c19702ae09beatagen2026-05-27 15:49 UTC172
            profile: status.profile,
c19702ae09beatagen2026-05-27 15:49 UTC173
            bypass: status.bypass,
c19702ae09beatagen2026-05-27 15:49 UTC174
            per_app_master: status.per_app,
c19702ae09beatagen2026-05-27 15:49 UTC175
            setting_overrides: status.setting_overrides,
c19702ae09beatagen2026-05-27 15:49 UTC176
            base_uptime_s: status.uptime_s,
c19702ae09beatagen2026-05-27 15:49 UTC177
            connected_at: Instant::now(),
c19702ae09beatagen2026-05-27 15:49 UTC178
            default_route: route_list.default_route,
c19702ae09beatagen2026-05-27 15:49 UTC179
            streams,
c19702ae09beatagen2026-05-27 15:49 UTC180
            layer_a,
c19702ae09beatagen2026-05-27 15:49 UTC181
            la_snapshots,
c19702ae09beatagen2026-05-27 15:49 UTC182
            selected,
c19702ae09beatagen2026-05-27 15:49 UTC183
            meters: None,
c19702ae09beatagen2026-05-27 15:49 UTC184
            last_meter_at: None,
c19702ae09beatagen2026-05-27 15:49 UTC185
            overflow_total: 0,
c19702ae09beatagen2026-05-27 15:49 UTC186
            last_error: None,
c19702ae09beatagen2026-05-27 15:49 UTC187
            disconnected: None,
c19702ae09beatagen2026-05-27 15:49 UTC188
            picker: None,
c19702ae09beatagen2026-05-27 15:49 UTC189
        }
c19702ae09beatagen2026-05-27 15:49 UTC190
    }
c19702ae09beatagen2026-05-27 15:49 UTC191
c19702ae09beatagen2026-05-27 15:49 UTC192
    fn uptime_s(&self) -> u64 {
c19702ae09beatagen2026-05-27 15:49 UTC193
        self.base_uptime_s
c19702ae09beatagen2026-05-27 15:49 UTC194
            .saturating_add(self.connected_at.elapsed().as_secs())
c19702ae09beatagen2026-05-27 15:49 UTC195
    }
c19702ae09beatagen2026-05-27 15:49 UTC196
c19702ae09beatagen2026-05-27 15:49 UTC197
    /// stream node ids in row order (`BTreeMap` keys).
c19702ae09beatagen2026-05-27 15:49 UTC198
    fn ordered_nodes(&self) -> Vec<u32> {
c19702ae09beatagen2026-05-27 15:49 UTC199
        self.streams.keys().copied().collect()
c19702ae09beatagen2026-05-27 15:49 UTC200
    }
c19702ae09beatagen2026-05-27 15:49 UTC201
c19702ae09beatagen2026-05-27 15:49 UTC202
    /// `selected` when still live, else the first row, else `None`.
c19702ae09beatagen2026-05-27 15:49 UTC203
    fn effective_selection(&self) -> Option<u32> {
c19702ae09beatagen2026-05-27 15:49 UTC204
        match self.selected {
c19702ae09beatagen2026-05-27 15:49 UTC205
            Some(id) if self.streams.contains_key(&id) => Some(id),
c19702ae09beatagen2026-05-27 15:49 UTC206
            _ => self.streams.keys().next().copied(),
c19702ae09beatagen2026-05-27 15:49 UTC207
        }
c19702ae09beatagen2026-05-27 15:49 UTC208
    }
c19702ae09beatagen2026-05-27 15:49 UTC209
c19702ae09beatagen2026-05-27 15:49 UTC210
    /// move the selection by `delta` rows (negative = up), wrapping.
c19702ae09beatagen2026-05-27 15:49 UTC211
    fn move_selection(&mut self, delta: isize) {
c19702ae09beatagen2026-05-27 15:49 UTC212
        let nodes = self.ordered_nodes();
c19702ae09beatagen2026-05-27 15:49 UTC213
        if nodes.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC214
            self.selected = None;
c19702ae09beatagen2026-05-27 15:49 UTC215
            return;
c19702ae09beatagen2026-05-27 15:49 UTC216
        }
c19702ae09beatagen2026-05-27 15:49 UTC217
        let cur = self
c19702ae09beatagen2026-05-27 15:49 UTC218
            .effective_selection()
c19702ae09beatagen2026-05-27 15:49 UTC219
            .and_then(|id| nodes.iter().position(|&n| n == id))
c19702ae09beatagen2026-05-27 15:49 UTC220
            .unwrap_or(0) as isize;
c19702ae09beatagen2026-05-27 15:49 UTC221
        let next = (cur + delta).rem_euclid(nodes.len() as isize) as usize;
c19702ae09beatagen2026-05-27 15:49 UTC222
        self.selected = Some(nodes[next]);
c19702ae09beatagen2026-05-27 15:49 UTC223
    }
c19702ae09beatagen2026-05-27 15:49 UTC224
c19702ae09beatagen2026-05-27 15:49 UTC225
    fn apply_event(&mut self, ev: Event) {
c19702ae09beatagen2026-05-27 15:49 UTC226
        match ev.topic {
c19702ae09beatagen2026-05-27 15:49 UTC227
            Topic::Meters if ev.event == "tick" => {
c19702ae09beatagen2026-05-27 15:49 UTC228
                if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) {
c19702ae09beatagen2026-05-27 15:49 UTC229
                    self.meters = Some(m);
c19702ae09beatagen2026-05-27 15:49 UTC230
                    self.last_meter_at = Some(Instant::now());
c19702ae09beatagen2026-05-27 15:49 UTC231
                }
c19702ae09beatagen2026-05-27 15:49 UTC232
            }
c19702ae09beatagen2026-05-27 15:49 UTC233
            Topic::Meters if ev.event == "layer_a_level" => {
c19702ae09beatagen2026-05-27 15:49 UTC234
                if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) {
c19702ae09beatagen2026-05-27 15:49 UTC235
                    self.layer_a.insert(l.node_id, Some(l.reduction_db));
c19702ae09beatagen2026-05-27 15:49 UTC236
                }
c19702ae09beatagen2026-05-27 15:49 UTC237
            }
c19702ae09beatagen2026-05-27 15:49 UTC238
            Topic::Routing => {
c19702ae09beatagen2026-05-27 15:49 UTC239
                if let Ok(re) = serde_json::from_value::<RoutingEvent>(routing_payload(&ev)) {
c19702ae09beatagen2026-05-27 15:49 UTC240
                    match re {
c19702ae09beatagen2026-05-27 15:49 UTC241
                        RoutingEvent::StreamRouted { node_id, app, to } => {
c19702ae09beatagen2026-05-27 15:49 UTC242
                            self.streams.insert(
c19702ae09beatagen2026-05-27 15:49 UTC243
                                node_id,
c19702ae09beatagen2026-05-27 15:49 UTC244
                                StreamRoute {
c19702ae09beatagen2026-05-27 15:49 UTC245
                                    node_id,
c19702ae09beatagen2026-05-27 15:49 UTC246
                                    app,
c19702ae09beatagen2026-05-27 15:49 UTC247
                                    route: to,
c19702ae09beatagen2026-05-27 15:49 UTC248
                                },
c19702ae09beatagen2026-05-27 15:49 UTC249
                            );
c19702ae09beatagen2026-05-27 15:49 UTC250
                        }
c19702ae09beatagen2026-05-27 15:49 UTC251
                        RoutingEvent::StreamRemoved { node_id } => {
c19702ae09beatagen2026-05-27 15:49 UTC252
                            self.streams.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC253
                            self.layer_a.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC254
                            self.la_snapshots.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC255
                        }
c19702ae09beatagen2026-05-27 15:49 UTC256
                        RoutingEvent::LayerAAttached { node_id, .. } => {
c19702ae09beatagen2026-05-27 15:49 UTC257
                            // reduction unknown until the first `layer_a_level`.
c19702ae09beatagen2026-05-27 15:49 UTC258
                            self.layer_a.entry(node_id).or_insert(None);
c19702ae09beatagen2026-05-27 15:49 UTC259
                        }
c19702ae09beatagen2026-05-27 15:49 UTC260
                        RoutingEvent::LayerADetached { node_id } => {
c19702ae09beatagen2026-05-27 15:49 UTC261
                            self.layer_a.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC262
                            self.la_snapshots.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC263
                        }
c19702ae09beatagen2026-05-27 15:49 UTC264
                        RoutingEvent::RuleChanged => { /* tui doesn't show rules */ }
c19702ae09beatagen2026-05-27 15:49 UTC265
                        _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC266
                    }
c19702ae09beatagen2026-05-27 15:49 UTC267
                }
c19702ae09beatagen2026-05-27 15:49 UTC268
            }
c19702ae09beatagen2026-05-27 15:49 UTC269
            Topic::Profile => {
c19702ae09beatagen2026-05-27 15:49 UTC270
                match ev.event.as_str() {
c19702ae09beatagen2026-05-27 15:49 UTC271
                    "used" | "changed" => {
c19702ae09beatagen2026-05-27 15:49 UTC272
                        if let Some(name) = ev.data.get("name").and_then(|v| v.as_str()) {
c19702ae09beatagen2026-05-27 15:49 UTC273
                            self.profile = name.to_string();
c19702ae09beatagen2026-05-27 15:49 UTC274
                        }
c19702ae09beatagen2026-05-27 15:49 UTC275
                    }
c19702ae09beatagen2026-05-27 15:49 UTC276
                    _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC277
                }
c19702ae09beatagen2026-05-27 15:49 UTC278
            }
c19702ae09beatagen2026-05-27 15:49 UTC279
            Topic::Daemon => {
c19702ae09beatagen2026-05-27 15:49 UTC280
                if let Ok(de) = serde_json::from_value::<DaemonEvent>(daemon_payload(&ev)) {
c19702ae09beatagen2026-05-27 15:49 UTC281
                    match de {
c19702ae09beatagen2026-05-27 15:49 UTC282
                        DaemonEvent::Overflow {
c19702ae09beatagen2026-05-27 15:49 UTC283
                            lost, total_lost, ..
c19702ae09beatagen2026-05-27 15:49 UTC284
                        } => {
c19702ae09beatagen2026-05-27 15:49 UTC285
                            self.overflow_total = total_lost.max(self.overflow_total + lost as u64);
c19702ae09beatagen2026-05-27 15:49 UTC286
                        }
c19702ae09beatagen2026-05-27 15:49 UTC287
                        DaemonEvent::Error { code, message } => {
c19702ae09beatagen2026-05-27 15:49 UTC288
                            self.last_error = Some(format!("{code}: {message}"));
c19702ae09beatagen2026-05-27 15:49 UTC289
                        }
c19702ae09beatagen2026-05-27 15:49 UTC290
                        DaemonEvent::Shutdown => {
c19702ae09beatagen2026-05-27 15:49 UTC291
                            self.disconnected = Some("daemon shutdown".into());
c19702ae09beatagen2026-05-27 15:49 UTC292
                        }
c19702ae09beatagen2026-05-27 15:49 UTC293
                        DaemonEvent::Started { version } => {
c19702ae09beatagen2026-05-27 15:49 UTC294
                            self.daemon_version = version;
c19702ae09beatagen2026-05-27 15:49 UTC295
                        }
c19702ae09beatagen2026-05-27 15:49 UTC296
                        _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC297
                    }
c19702ae09beatagen2026-05-27 15:49 UTC298
                }
c19702ae09beatagen2026-05-27 15:49 UTC299
            }
c19702ae09beatagen2026-05-27 15:49 UTC300
            _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC301
        }
c19702ae09beatagen2026-05-27 15:49 UTC302
    }
c19702ae09beatagen2026-05-27 15:49 UTC303
}
c19702ae09beatagen2026-05-27 15:49 UTC304
c19702ae09beatagen2026-05-27 15:49 UTC305
/// typed event enums are `#[serde(tag = "event")]` but the wire frame keeps
c19702ae09beatagen2026-05-27 15:49 UTC306
/// `event` outside `data`, so re-inject it before deserializing.
c19702ae09beatagen2026-05-27 15:49 UTC307
fn routing_payload(ev: &Event) -> serde_json::Value {
c19702ae09beatagen2026-05-27 15:49 UTC308
    inject_event(&ev.event, &ev.data)
c19702ae09beatagen2026-05-27 15:49 UTC309
}
c19702ae09beatagen2026-05-27 15:49 UTC310
fn daemon_payload(ev: &Event) -> serde_json::Value {
c19702ae09beatagen2026-05-27 15:49 UTC311
    inject_event(&ev.event, &ev.data)
c19702ae09beatagen2026-05-27 15:49 UTC312
}
c19702ae09beatagen2026-05-27 15:49 UTC313
c19702ae09beatagen2026-05-27 15:49 UTC314
fn inject_event(event: &str, data: &serde_json::Value) -> serde_json::Value {
c19702ae09beatagen2026-05-27 15:49 UTC315
    let mut obj = match data {
c19702ae09beatagen2026-05-27 15:49 UTC316
        serde_json::Value::Object(m) => m.clone(),
c19702ae09beatagen2026-05-27 15:49 UTC317
        _ => serde_json::Map::new(),
c19702ae09beatagen2026-05-27 15:49 UTC318
    };
c19702ae09beatagen2026-05-27 15:49 UTC319
    obj.insert("event".into(), serde_json::Value::String(event.to_string()));
c19702ae09beatagen2026-05-27 15:49 UTC320
    serde_json::Value::Object(obj)
c19702ae09beatagen2026-05-27 15:49 UTC321
}
c19702ae09beatagen2026-05-27 15:49 UTC322
c19702ae09beatagen2026-05-27 15:49 UTC323
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC324
// Draw loop
c19702ae09beatagen2026-05-27 15:49 UTC325
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC326
c19702ae09beatagen2026-05-27 15:49 UTC327
fn draw_loop<B: ratatui::backend::Backend>(
c19702ae09beatagen2026-05-27 15:49 UTC328
    terminal: &mut Terminal<B>,
c19702ae09beatagen2026-05-27 15:49 UTC329
    status: Status,
c19702ae09beatagen2026-05-27 15:49 UTC330
    route_list: headroom_ipc::RouteList,
c19702ae09beatagen2026-05-27 15:49 UTC331
    rx: Receiver<Msg>,
c19702ae09beatagen2026-05-27 15:49 UTC332
    control: &mut Client,
c19702ae09beatagen2026-05-27 15:49 UTC333
) -> Result<(), TuiError> {
c19702ae09beatagen2026-05-27 15:49 UTC334
    let mut state = UiState::new(status, route_list);
c19702ae09beatagen2026-05-27 15:49 UTC335
    // 10 Hz redraw floor so uptime/staleness tick with no events flowing.
c19702ae09beatagen2026-05-27 15:49 UTC336
    let ticker = tick(Duration::from_millis(100));
c19702ae09beatagen2026-05-27 15:49 UTC337
    let input_rx = spawn_input_thread();
c19702ae09beatagen2026-05-27 15:49 UTC338
    // ~1 Hz poll of the richer layer a snapshot.
c19702ae09beatagen2026-05-27 15:49 UTC339
    let mut poll_ticks: u32 = 0;
c19702ae09beatagen2026-05-27 15:49 UTC340
c19702ae09beatagen2026-05-27 15:49 UTC341
    loop {
c19702ae09beatagen2026-05-27 15:49 UTC342
        terminal.draw(|f| draw(f, &state))?;
c19702ae09beatagen2026-05-27 15:49 UTC343
c19702ae09beatagen2026-05-27 15:49 UTC344
        select! {
c19702ae09beatagen2026-05-27 15:49 UTC345
            recv(rx) -> msg => match msg {
c19702ae09beatagen2026-05-27 15:49 UTC346
                Ok(Msg::Event(ev)) => state.apply_event(ev),
c19702ae09beatagen2026-05-27 15:49 UTC347
                Ok(Msg::Disconnected(reason)) => {
c19702ae09beatagen2026-05-27 15:49 UTC348
                    state.disconnected = Some(reason);
c19702ae09beatagen2026-05-27 15:49 UTC349
                    // final paint, then linger so the banner is seen.
c19702ae09beatagen2026-05-27 15:49 UTC350
                    terminal.draw(|f| draw(f, &state))?;
c19702ae09beatagen2026-05-27 15:49 UTC351
                    thread::sleep(Duration::from_millis(800));
c19702ae09beatagen2026-05-27 15:49 UTC352
                    return Ok(());
c19702ae09beatagen2026-05-27 15:49 UTC353
                }
c19702ae09beatagen2026-05-27 15:49 UTC354
                Err(_) => return Ok(()),
c19702ae09beatagen2026-05-27 15:49 UTC355
            },
c19702ae09beatagen2026-05-27 15:49 UTC356
            recv(input_rx) -> msg => match msg {
c19702ae09beatagen2026-05-27 15:49 UTC357
                Ok(InputMsg::Key(k)) => {
c19702ae09beatagen2026-05-27 15:49 UTC358
                    if handle_key(&mut state, control, k) {
c19702ae09beatagen2026-05-27 15:49 UTC359
                        return Ok(());
c19702ae09beatagen2026-05-27 15:49 UTC360
                    }
c19702ae09beatagen2026-05-27 15:49 UTC361
                }
c19702ae09beatagen2026-05-27 15:49 UTC362
                Ok(InputMsg::Redraw) => {}
c19702ae09beatagen2026-05-27 15:49 UTC363
                Err(_) => return Ok(()),
c19702ae09beatagen2026-05-27 15:49 UTC364
            },
c19702ae09beatagen2026-05-27 15:49 UTC365
            recv(ticker) -> _ => {
c19702ae09beatagen2026-05-27 15:49 UTC366
                poll_ticks = poll_ticks.wrapping_add(1);
c19702ae09beatagen2026-05-27 15:49 UTC367
                if poll_ticks % 10 == 0 {
c19702ae09beatagen2026-05-27 15:49 UTC368
                    poll_layer_a(&mut state, control);
c19702ae09beatagen2026-05-27 15:49 UTC369
                }
c19702ae09beatagen2026-05-27 15:49 UTC370
            }
c19702ae09beatagen2026-05-27 15:49 UTC371
        }
c19702ae09beatagen2026-05-27 15:49 UTC372
    }
c19702ae09beatagen2026-05-27 15:49 UTC373
}
c19702ae09beatagen2026-05-27 15:49 UTC374
c19702ae09beatagen2026-05-27 15:49 UTC375
/// refresh layer a snapshots from the control connection; errors go to the
c19702ae09beatagen2026-05-27 15:49 UTC376
/// footer (non-fatal — the event stream keeps the ui live).
c19702ae09beatagen2026-05-27 15:49 UTC377
fn poll_layer_a(state: &mut UiState, control: &mut Client) {
c19702ae09beatagen2026-05-27 15:49 UTC378
    match control.layer_a_list() {
c19702ae09beatagen2026-05-27 15:49 UTC379
        Ok(list) => {
c19702ae09beatagen2026-05-27 15:49 UTC380
            state.la_snapshots = list.into_iter().map(|s| (s.node_id, s)).collect();
c19702ae09beatagen2026-05-27 15:49 UTC381
        }
c19702ae09beatagen2026-05-27 15:49 UTC382
        Err(e) => {
c19702ae09beatagen2026-05-27 15:49 UTC383
            state.last_error = Some(format!("layer-a poll: {e}"));
c19702ae09beatagen2026-05-27 15:49 UTC384
        }
c19702ae09beatagen2026-05-27 15:49 UTC385
    }
c19702ae09beatagen2026-05-27 15:49 UTC386
}
c19702ae09beatagen2026-05-27 15:49 UTC387
c19702ae09beatagen2026-05-27 15:49 UTC388
/// apply a keypress: nav + toggles + per-row actions. control ops run
c19702ae09beatagen2026-05-27 15:49 UTC389
/// synchronously on the control connection; failures land in the footer.
c19702ae09beatagen2026-05-27 15:49 UTC390
/// returns `true` to quit.
c19702ae09beatagen2026-05-27 15:49 UTC391
fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) -> bool {
c19702ae09beatagen2026-05-27 15:49 UTC392
    // ctrl-c: hard exit, even with the picker open.
c19702ae09beatagen2026-05-27 15:49 UTC393
    if k.modifiers.contains(KeyModifiers::CONTROL)
c19702ae09beatagen2026-05-27 15:49 UTC394
        && matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C'))
c19702ae09beatagen2026-05-27 15:49 UTC395
    {
c19702ae09beatagen2026-05-27 15:49 UTC396
        return true;
c19702ae09beatagen2026-05-27 15:49 UTC397
    }
c19702ae09beatagen2026-05-27 15:49 UTC398
    // picker is modal: while open it swallows every other key.
c19702ae09beatagen2026-05-27 15:49 UTC399
    if state.picker.is_some() {
c19702ae09beatagen2026-05-27 15:49 UTC400
        handle_picker_key(state, control, k);
c19702ae09beatagen2026-05-27 15:49 UTC401
        return false;
c19702ae09beatagen2026-05-27 15:49 UTC402
    }
c19702ae09beatagen2026-05-27 15:49 UTC403
    if is_quit(&k) {
c19702ae09beatagen2026-05-27 15:49 UTC404
        return true;
c19702ae09beatagen2026-05-27 15:49 UTC405
    }
c19702ae09beatagen2026-05-27 15:49 UTC406
    match k.code {
c19702ae09beatagen2026-05-27 15:49 UTC407
        KeyCode::Char('j') | KeyCode::Down => state.move_selection(1),
c19702ae09beatagen2026-05-27 15:49 UTC408
        KeyCode::Char('k') | KeyCode::Up => state.move_selection(-1),
c19702ae09beatagen2026-05-27 15:49 UTC409
        KeyCode::Char('P') => open_profile_picker(state, control),
c19702ae09beatagen2026-05-27 15:49 UTC410
        KeyCode::Char('b') => {
c19702ae09beatagen2026-05-27 15:49 UTC411
            let target = !state.bypass;
c19702ae09beatagen2026-05-27 15:49 UTC412
            match control.bypass_set(target) {
c19702ae09beatagen2026-05-27 15:49 UTC413
                Ok(()) => state.bypass = target,
c19702ae09beatagen2026-05-27 15:49 UTC414
                Err(e) => state.last_error = Some(format!("bypass: {e}")),
c19702ae09beatagen2026-05-27 15:49 UTC415
            }
c19702ae09beatagen2026-05-27 15:49 UTC416
        }
c19702ae09beatagen2026-05-27 15:49 UTC417
        KeyCode::Char('p') => {
c19702ae09beatagen2026-05-27 15:49 UTC418
            let target = !state.per_app_master;
c19702ae09beatagen2026-05-27 15:49 UTC419
            match control.per_app_master(target) {
c19702ae09beatagen2026-05-27 15:49 UTC420
                Ok(()) => state.per_app_master = target,
c19702ae09beatagen2026-05-27 15:49 UTC421
                Err(e) => state.last_error = Some(format!("per-app master: {e}")),
c19702ae09beatagen2026-05-27 15:49 UTC422
            }
c19702ae09beatagen2026-05-27 15:49 UTC423
        }
c19702ae09beatagen2026-05-27 15:49 UTC424
        KeyCode::Char('r') | KeyCode::Enter => {
c19702ae09beatagen2026-05-27 15:49 UTC425
            let Some(node) = state.effective_selection() else {
c19702ae09beatagen2026-05-27 15:49 UTC426
                return false;
c19702ae09beatagen2026-05-27 15:49 UTC427
            };
c19702ae09beatagen2026-05-27 15:49 UTC428
            let Some(stream) = state.streams.get(&node) else {
c19702ae09beatagen2026-05-27 15:49 UTC429
                return false;
c19702ae09beatagen2026-05-27 15:49 UTC430
            };
c19702ae09beatagen2026-05-27 15:49 UTC431
            let app = stream.app.clone();
c19702ae09beatagen2026-05-27 15:49 UTC432
            let to = match stream.route {
c19702ae09beatagen2026-05-27 15:49 UTC433
                Route::Processed => Route::Bypass,
c19702ae09beatagen2026-05-27 15:49 UTC434
                Route::Bypass => Route::Processed,
c19702ae09beatagen2026-05-27 15:49 UTC435
            };
c19702ae09beatagen2026-05-27 15:49 UTC436
            // prefer a persistent per-app rule (keyed by app label) so the
c19702ae09beatagen2026-05-27 15:49 UTC437
            // route survives the app recreating its stream between tracks;
c19702ae09beatagen2026-05-27 15:49 UTC438
            // fall back to a one-shot per-stream reroute when no label.
c19702ae09beatagen2026-05-27 15:49 UTC439
            let result = if app.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC440
                control.route_stream(node, to)
c19702ae09beatagen2026-05-27 15:49 UTC441
            } else {
c19702ae09beatagen2026-05-27 15:49 UTC442
                control.route_set(&app, to)
c19702ae09beatagen2026-05-27 15:49 UTC443
            };
c19702ae09beatagen2026-05-27 15:49 UTC444
            match result {
c19702ae09beatagen2026-05-27 15:49 UTC445
                Ok(()) => {
c19702ae09beatagen2026-05-27 15:49 UTC446
                    if let Some(s) = state.streams.get_mut(&node) {
c19702ae09beatagen2026-05-27 15:49 UTC447
                        s.route = to;
c19702ae09beatagen2026-05-27 15:49 UTC448
                    }
c19702ae09beatagen2026-05-27 15:49 UTC449
                }
c19702ae09beatagen2026-05-27 15:49 UTC450
                Err(e) => state.last_error = Some(format!("route: {e}")),
c19702ae09beatagen2026-05-27 15:49 UTC451
            }
c19702ae09beatagen2026-05-27 15:49 UTC452
        }
c19702ae09beatagen2026-05-27 15:49 UTC453
        KeyCode::Char('a') => {
c19702ae09beatagen2026-05-27 15:49 UTC454
            let Some(node) = state.effective_selection() else {
c19702ae09beatagen2026-05-27 15:49 UTC455
                return false;
c19702ae09beatagen2026-05-27 15:49 UTC456
            };
c19702ae09beatagen2026-05-27 15:49 UTC457
            let Some(app) = state.streams.get(&node).map(|s| s.app.clone()) else {
c19702ae09beatagen2026-05-27 15:49 UTC458
                return false;
c19702ae09beatagen2026-05-27 15:49 UTC459
            };
c19702ae09beatagen2026-05-27 15:49 UTC460
            if app.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC461
                state.last_error = Some("per-app: selected stream has no app label".into());
c19702ae09beatagen2026-05-27 15:49 UTC462
                return false;
c19702ae09beatagen2026-05-27 15:49 UTC463
            }
c19702ae09beatagen2026-05-27 15:49 UTC464
            let managed = state.la_snapshots.get(&node).is_some_and(|s| s.managed);
c19702ae09beatagen2026-05-27 15:49 UTC465
            if let Err(e) = control.per_app_set(&app, !managed) {
c19702ae09beatagen2026-05-27 15:49 UTC466
                state.last_error = Some(format!("per-app set: {e}"));
c19702ae09beatagen2026-05-27 15:49 UTC467
            }
c19702ae09beatagen2026-05-27 15:49 UTC468
        }
c19702ae09beatagen2026-05-27 15:49 UTC469
        KeyCode::Char('x') => {
c19702ae09beatagen2026-05-27 15:49 UTC470
            let Some(node) = state.effective_selection() else {
c19702ae09beatagen2026-05-27 15:49 UTC471
                return false;
c19702ae09beatagen2026-05-27 15:49 UTC472
            };
c19702ae09beatagen2026-05-27 15:49 UTC473
            if let Err(e) = control.layer_a_reset(node) {
c19702ae09beatagen2026-05-27 15:49 UTC474
                state.last_error = Some(format!("reset: {e}"));
c19702ae09beatagen2026-05-27 15:49 UTC475
            }
c19702ae09beatagen2026-05-27 15:49 UTC476
        }
c19702ae09beatagen2026-05-27 15:49 UTC477
        KeyCode::Char('c') => {
c19702ae09beatagen2026-05-27 15:49 UTC478
            // clear all overlay overrides; no-op when none.
c19702ae09beatagen2026-05-27 15:49 UTC479
            if state.setting_overrides.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC480
                return false;
c19702ae09beatagen2026-05-27 15:49 UTC481
            }
c19702ae09beatagen2026-05-27 15:49 UTC482
            match control.setting_reset() {
c19702ae09beatagen2026-05-27 15:49 UTC483
                Ok(_) => state.setting_overrides.clear(),
c19702ae09beatagen2026-05-27 15:49 UTC484
                Err(e) => state.last_error = Some(format!("clear overrides: {e}")),
c19702ae09beatagen2026-05-27 15:49 UTC485
            }
c19702ae09beatagen2026-05-27 15:49 UTC486
        }
c19702ae09beatagen2026-05-27 15:49 UTC487
        _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC488
    }
c19702ae09beatagen2026-05-27 15:49 UTC489
    false
c19702ae09beatagen2026-05-27 15:49 UTC490
}
c19702ae09beatagen2026-05-27 15:49 UTC491
c19702ae09beatagen2026-05-27 15:49 UTC492
/// open the modal profile picker from `profile.list`; errors → footer.
c19702ae09beatagen2026-05-27 15:49 UTC493
fn open_profile_picker(state: &mut UiState, control: &mut Client) {
c19702ae09beatagen2026-05-27 15:49 UTC494
    match control.profile_list() {
c19702ae09beatagen2026-05-27 15:49 UTC495
        Ok(profiles) if profiles.is_empty() => {
c19702ae09beatagen2026-05-27 15:49 UTC496
            state.last_error = Some("profile list is empty".into());
c19702ae09beatagen2026-05-27 15:49 UTC497
        }
c19702ae09beatagen2026-05-27 15:49 UTC498
        Ok(profiles) => {
c19702ae09beatagen2026-05-27 15:49 UTC499
            let selected = initial_picker_selection(&profiles, &state.profile);
c19702ae09beatagen2026-05-27 15:49 UTC500
            state.picker = Some(ProfilePicker { profiles, selected });
c19702ae09beatagen2026-05-27 15:49 UTC501
        }
c19702ae09beatagen2026-05-27 15:49 UTC502
        Err(e) => state.last_error = Some(format!("profile list: {e}")),
c19702ae09beatagen2026-05-27 15:49 UTC503
    }
c19702ae09beatagen2026-05-27 15:49 UTC504
}
c19702ae09beatagen2026-05-27 15:49 UTC505
c19702ae09beatagen2026-05-27 15:49 UTC506
/// handle a keypress while the profile picker is open.
c19702ae09beatagen2026-05-27 15:49 UTC507
fn handle_picker_key(state: &mut UiState, control: &mut Client, k: KeyEvent) {
c19702ae09beatagen2026-05-27 15:49 UTC508
    let Some(picker) = state.picker.as_mut() else {
c19702ae09beatagen2026-05-27 15:49 UTC509
        return;
c19702ae09beatagen2026-05-27 15:49 UTC510
    };
c19702ae09beatagen2026-05-27 15:49 UTC511
    match k.code {
c19702ae09beatagen2026-05-27 15:49 UTC512
        KeyCode::Char('j') | KeyCode::Down => picker.move_sel(1),
c19702ae09beatagen2026-05-27 15:49 UTC513
        KeyCode::Char('k') | KeyCode::Up => picker.move_sel(-1),
c19702ae09beatagen2026-05-27 15:49 UTC514
        KeyCode::Esc | KeyCode::Char('q') => state.picker = None,
c19702ae09beatagen2026-05-27 15:49 UTC515
        KeyCode::Enter => {
c19702ae09beatagen2026-05-27 15:49 UTC516
            let Some(name) = picker.profiles.get(picker.selected).map(|p| p.name.clone()) else {
c19702ae09beatagen2026-05-27 15:49 UTC517
                state.picker = None;
c19702ae09beatagen2026-05-27 15:49 UTC518
                return;
c19702ae09beatagen2026-05-27 15:49 UTC519
            };
c19702ae09beatagen2026-05-27 15:49 UTC520
            match control.profile_use(&name) {
c19702ae09beatagen2026-05-27 15:49 UTC521
                Ok(applied) => {
c19702ae09beatagen2026-05-27 15:49 UTC522
                    // set eagerly; the `profile.changed` event also lands.
c19702ae09beatagen2026-05-27 15:49 UTC523
                    state.profile = applied;
c19702ae09beatagen2026-05-27 15:49 UTC524
                    state.picker = None;
c19702ae09beatagen2026-05-27 15:49 UTC525
                }
c19702ae09beatagen2026-05-27 15:49 UTC526
                Err(e) => {
c19702ae09beatagen2026-05-27 15:49 UTC527
                    // keep the picker open to retry/cancel.
c19702ae09beatagen2026-05-27 15:49 UTC528
                    state.last_error = Some(format!("profile use: {e}"));
c19702ae09beatagen2026-05-27 15:49 UTC529
                }
c19702ae09beatagen2026-05-27 15:49 UTC530
            }
c19702ae09beatagen2026-05-27 15:49 UTC531
        }
c19702ae09beatagen2026-05-27 15:49 UTC532
        _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC533
    }
c19702ae09beatagen2026-05-27 15:49 UTC534
}
c19702ae09beatagen2026-05-27 15:49 UTC535
c19702ae09beatagen2026-05-27 15:49 UTC536
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC537
// Input thread
c19702ae09beatagen2026-05-27 15:49 UTC538
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC539
c19702ae09beatagen2026-05-27 15:49 UTC540
enum InputMsg {
c19702ae09beatagen2026-05-27 15:49 UTC541
    Key(KeyEvent),
c19702ae09beatagen2026-05-27 15:49 UTC542
    Redraw,
c19702ae09beatagen2026-05-27 15:49 UTC543
}
c19702ae09beatagen2026-05-27 15:49 UTC544
c19702ae09beatagen2026-05-27 15:49 UTC545
fn spawn_input_thread() -> Receiver<InputMsg> {
c19702ae09beatagen2026-05-27 15:49 UTC546
    let (tx, rx) = unbounded::<InputMsg>();
c19702ae09beatagen2026-05-27 15:49 UTC547
    thread::Builder::new()
c19702ae09beatagen2026-05-27 15:49 UTC548
        .name("headroom-monitor-input".into())
c19702ae09beatagen2026-05-27 15:49 UTC549
        .spawn(move || loop {
c19702ae09beatagen2026-05-27 15:49 UTC550
            let Ok(ev) = event::read() else { return };
c19702ae09beatagen2026-05-27 15:49 UTC551
            // forward keys to the draw loop; quit/modal decisions need ui state.
c19702ae09beatagen2026-05-27 15:49 UTC552
            let msg = match ev {
c19702ae09beatagen2026-05-27 15:49 UTC553
                CtEvent::Key(k) => InputMsg::Key(k),
c19702ae09beatagen2026-05-27 15:49 UTC554
                CtEvent::Resize(_, _) => InputMsg::Redraw,
c19702ae09beatagen2026-05-27 15:49 UTC555
                _ => continue,
c19702ae09beatagen2026-05-27 15:49 UTC556
            };
c19702ae09beatagen2026-05-27 15:49 UTC557
            if tx.send(msg).is_err() {
c19702ae09beatagen2026-05-27 15:49 UTC558
                return;
c19702ae09beatagen2026-05-27 15:49 UTC559
            }
c19702ae09beatagen2026-05-27 15:49 UTC560
        })
c19702ae09beatagen2026-05-27 15:49 UTC561
        .expect("spawn input thread");
c19702ae09beatagen2026-05-27 15:49 UTC562
    rx
c19702ae09beatagen2026-05-27 15:49 UTC563
}
c19702ae09beatagen2026-05-27 15:49 UTC564
c19702ae09beatagen2026-05-27 15:49 UTC565
fn is_quit(k: &KeyEvent) -> bool {
c19702ae09beatagen2026-05-27 15:49 UTC566
    matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
c19702ae09beatagen2026-05-27 15:49 UTC567
        || (k.modifiers.contains(KeyModifiers::CONTROL)
c19702ae09beatagen2026-05-27 15:49 UTC568
            && matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')))
c19702ae09beatagen2026-05-27 15:49 UTC569
}
c19702ae09beatagen2026-05-27 15:49 UTC570
c19702ae09beatagen2026-05-27 15:49 UTC571
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC572
// Drawing
c19702ae09beatagen2026-05-27 15:49 UTC573
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC574
c19702ae09beatagen2026-05-27 15:49 UTC575
fn draw(f: &mut Frame, state: &UiState) {
c19702ae09beatagen2026-05-27 15:49 UTC576
    let area = f.area();
c19702ae09beatagen2026-05-27 15:49 UTC577
    let outer = Block::default()
c19702ae09beatagen2026-05-27 15:49 UTC578
        .borders(Borders::ALL)
c19702ae09beatagen2026-05-27 15:49 UTC579
        .title(Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC580
            " headroom monitor ",
c19702ae09beatagen2026-05-27 15:49 UTC581
            Style::default().add_modifier(Modifier::BOLD),
c19702ae09beatagen2026-05-27 15:49 UTC582
        ))
c19702ae09beatagen2026-05-27 15:49 UTC583
        .title_top(Line::from(header_status(state)).right_aligned())
c19702ae09beatagen2026-05-27 15:49 UTC584
        .title_bottom(Line::from(footer_text(state)).right_aligned());
c19702ae09beatagen2026-05-27 15:49 UTC585
    let inner = outer.inner(area);
c19702ae09beatagen2026-05-27 15:49 UTC586
    f.render_widget(outer, area);
c19702ae09beatagen2026-05-27 15:49 UTC587
c19702ae09beatagen2026-05-27 15:49 UTC588
    let chunks = Layout::default()
c19702ae09beatagen2026-05-27 15:49 UTC589
        .direction(Direction::Vertical)
c19702ae09beatagen2026-05-27 15:49 UTC590
        .constraints([
c19702ae09beatagen2026-05-27 15:49 UTC591
            Constraint::Length(6), // bus gauges
c19702ae09beatagen2026-05-27 15:49 UTC592
            Constraint::Length(5), // loudness
c19702ae09beatagen2026-05-27 15:49 UTC593
            Constraint::Min(4),    // streams table
c19702ae09beatagen2026-05-27 15:49 UTC594
            Constraint::Length(3), // layer A detail (selected stream)
c19702ae09beatagen2026-05-27 15:49 UTC595
        ])
c19702ae09beatagen2026-05-27 15:49 UTC596
        .split(inner);
c19702ae09beatagen2026-05-27 15:49 UTC597
c19702ae09beatagen2026-05-27 15:49 UTC598
    draw_bus(f, chunks[0], state);
c19702ae09beatagen2026-05-27 15:49 UTC599
    draw_loudness(f, chunks[1], state);
c19702ae09beatagen2026-05-27 15:49 UTC600
    draw_streams(f, chunks[2], state);
c19702ae09beatagen2026-05-27 15:49 UTC601
    draw_layer_a_detail(f, chunks[3], state);
c19702ae09beatagen2026-05-27 15:49 UTC602
c19702ae09beatagen2026-05-27 15:49 UTC603
    // modal overlay, drawn last so it sits on top.
c19702ae09beatagen2026-05-27 15:49 UTC604
    if let Some(picker) = &state.picker {
c19702ae09beatagen2026-05-27 15:49 UTC605
        draw_profile_picker(f, area, picker);
c19702ae09beatagen2026-05-27 15:49 UTC606
    }
c19702ae09beatagen2026-05-27 15:49 UTC607
}
c19702ae09beatagen2026-05-27 15:49 UTC608
c19702ae09beatagen2026-05-27 15:49 UTC609
/// centered rect, at most `width`×`height`, clamped to `area`.
c19702ae09beatagen2026-05-27 15:49 UTC610
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
c19702ae09beatagen2026-05-27 15:49 UTC611
    let w = width.min(area.width);
c19702ae09beatagen2026-05-27 15:49 UTC612
    let h = height.min(area.height);
c19702ae09beatagen2026-05-27 15:49 UTC613
    Rect {
c19702ae09beatagen2026-05-27 15:49 UTC614
        x: area.x + (area.width - w) / 2,
c19702ae09beatagen2026-05-27 15:49 UTC615
        y: area.y + (area.height - h) / 2,
c19702ae09beatagen2026-05-27 15:49 UTC616
        width: w,
c19702ae09beatagen2026-05-27 15:49 UTC617
        height: h,
c19702ae09beatagen2026-05-27 15:49 UTC618
    }
c19702ae09beatagen2026-05-27 15:49 UTC619
}
c19702ae09beatagen2026-05-27 15:49 UTC620
c19702ae09beatagen2026-05-27 15:49 UTC621
/// modal profile switcher: centered list, active marked (●), highlighted
c19702ae09beatagen2026-05-27 15:49 UTC622
/// reversed, plus a description line.
c19702ae09beatagen2026-05-27 15:49 UTC623
fn draw_profile_picker(f: &mut Frame, area: Rect, picker: &ProfilePicker) {
c19702ae09beatagen2026-05-27 15:49 UTC624
    let rows = picker.profiles.len() as u16;
c19702ae09beatagen2026-05-27 15:49 UTC625
    // rows + 2 borders + 2 description lines.
c19702ae09beatagen2026-05-27 15:49 UTC626
    let rect = centered_rect(60, rows + 4, area);
c19702ae09beatagen2026-05-27 15:49 UTC627
c19702ae09beatagen2026-05-27 15:49 UTC628
    f.render_widget(Clear, rect);
c19702ae09beatagen2026-05-27 15:49 UTC629
    let block = Block::default()
c19702ae09beatagen2026-05-27 15:49 UTC630
        .borders(Borders::ALL)
c19702ae09beatagen2026-05-27 15:49 UTC631
        .title(" switch profile ")
c19702ae09beatagen2026-05-27 15:49 UTC632
        .title_bottom(Line::from(" j/k move · Enter apply · Esc cancel ").right_aligned());
c19702ae09beatagen2026-05-27 15:49 UTC633
    let inner = block.inner(rect);
c19702ae09beatagen2026-05-27 15:49 UTC634
    f.render_widget(block, rect);
c19702ae09beatagen2026-05-27 15:49 UTC635
c19702ae09beatagen2026-05-27 15:49 UTC636
    let parts = Layout::default()
c19702ae09beatagen2026-05-27 15:49 UTC637
        .direction(Direction::Vertical)
c19702ae09beatagen2026-05-27 15:49 UTC638
        .constraints([Constraint::Min(1), Constraint::Length(2)])
c19702ae09beatagen2026-05-27 15:49 UTC639
        .split(inner);
c19702ae09beatagen2026-05-27 15:49 UTC640
c19702ae09beatagen2026-05-27 15:49 UTC641
    let lines: Vec<Line> = picker
c19702ae09beatagen2026-05-27 15:49 UTC642
        .profiles
c19702ae09beatagen2026-05-27 15:49 UTC643
        .iter()
c19702ae09beatagen2026-05-27 15:49 UTC644
        .enumerate()
c19702ae09beatagen2026-05-27 15:49 UTC645
        .map(|(i, p)| {
c19702ae09beatagen2026-05-27 15:49 UTC646
            let marker = if p.active { "●" } else { " " };
c19702ae09beatagen2026-05-27 15:49 UTC647
            let style = if i == picker.selected {
c19702ae09beatagen2026-05-27 15:49 UTC648
                Style::default().add_modifier(Modifier::REVERSED)
c19702ae09beatagen2026-05-27 15:49 UTC649
            } else if p.active {
c19702ae09beatagen2026-05-27 15:49 UTC650
                Style::default().fg(Color::Green)
c19702ae09beatagen2026-05-27 15:49 UTC651
            } else {
c19702ae09beatagen2026-05-27 15:49 UTC652
                Style::default()
c19702ae09beatagen2026-05-27 15:49 UTC653
            };
c19702ae09beatagen2026-05-27 15:49 UTC654
            Line::from(vec![
c19702ae09beatagen2026-05-27 15:49 UTC655
                Span::raw(format!(" {marker} ")),
c19702ae09beatagen2026-05-27 15:49 UTC656
                Span::styled(p.name.clone(), style),
c19702ae09beatagen2026-05-27 15:49 UTC657
            ])
c19702ae09beatagen2026-05-27 15:49 UTC658
        })
c19702ae09beatagen2026-05-27 15:49 UTC659
        .collect();
c19702ae09beatagen2026-05-27 15:49 UTC660
    f.render_widget(Paragraph::new(lines), parts[0]);
c19702ae09beatagen2026-05-27 15:49 UTC661
c19702ae09beatagen2026-05-27 15:49 UTC662
    let desc = picker
c19702ae09beatagen2026-05-27 15:49 UTC663
        .profiles
c19702ae09beatagen2026-05-27 15:49 UTC664
        .get(picker.selected)
c19702ae09beatagen2026-05-27 15:49 UTC665
        .map(|p| p.description.clone())
c19702ae09beatagen2026-05-27 15:49 UTC666
        .unwrap_or_default();
c19702ae09beatagen2026-05-27 15:49 UTC667
    f.render_widget(
c19702ae09beatagen2026-05-27 15:49 UTC668
        Paragraph::new(Line::from(Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC669
            format!(" {desc}"),
c19702ae09beatagen2026-05-27 15:49 UTC670
            Style::default().fg(Color::DarkGray),
c19702ae09beatagen2026-05-27 15:49 UTC671
        )))
c19702ae09beatagen2026-05-27 15:49 UTC672
        .wrap(Wrap { trim: true }),
c19702ae09beatagen2026-05-27 15:49 UTC673
        parts[1],
c19702ae09beatagen2026-05-27 15:49 UTC674
    );
c19702ae09beatagen2026-05-27 15:49 UTC675
}
c19702ae09beatagen2026-05-27 15:49 UTC676
c19702ae09beatagen2026-05-27 15:49 UTC677
fn header_status(state: &UiState) -> Vec<Span<'static>> {
c19702ae09beatagen2026-05-27 15:49 UTC678
    let bypass_span = if state.bypass {
c19702ae09beatagen2026-05-27 15:49 UTC679
        Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC680
            " BYPASS ",
c19702ae09beatagen2026-05-27 15:49 UTC681
            Style::default().fg(Color::Black).bg(Color::Yellow),
c19702ae09beatagen2026-05-27 15:49 UTC682
        )
c19702ae09beatagen2026-05-27 15:49 UTC683
    } else {
c19702ae09beatagen2026-05-27 15:49 UTC684
        Span::styled(" processed ", Style::default().fg(Color::Green))
c19702ae09beatagen2026-05-27 15:49 UTC685
    };
c19702ae09beatagen2026-05-27 15:49 UTC686
    let per_app_span = if state.per_app_master {
c19702ae09beatagen2026-05-27 15:49 UTC687
        Span::styled(" per-app ", Style::default().fg(Color::Cyan))
c19702ae09beatagen2026-05-27 15:49 UTC688
    } else {
c19702ae09beatagen2026-05-27 15:49 UTC689
        Span::styled(" per-app off ", Style::default().fg(Color::DarkGray))
c19702ae09beatagen2026-05-27 15:49 UTC690
    };
c19702ae09beatagen2026-05-27 15:49 UTC691
    vec![
c19702ae09beatagen2026-05-27 15:49 UTC692
        Span::raw(" profile: "),
c19702ae09beatagen2026-05-27 15:49 UTC693
        Span::styled(state.profile.clone(), Style::default().bold()),
c19702ae09beatagen2026-05-27 15:49 UTC694
        Span::raw("  "),
c19702ae09beatagen2026-05-27 15:49 UTC695
        bypass_span,
c19702ae09beatagen2026-05-27 15:49 UTC696
        Span::raw(" "),
c19702ae09beatagen2026-05-27 15:49 UTC697
        per_app_span,
c19702ae09beatagen2026-05-27 15:49 UTC698
        Span::raw(format!(
c19702ae09beatagen2026-05-27 15:49 UTC699
            "  v{}  uptime {}  ",
c19702ae09beatagen2026-05-27 15:49 UTC700
            state.daemon_version,
c19702ae09beatagen2026-05-27 15:49 UTC701
            fmt_uptime(state.uptime_s())
c19702ae09beatagen2026-05-27 15:49 UTC702
        )),
c19702ae09beatagen2026-05-27 15:49 UTC703
    ]
c19702ae09beatagen2026-05-27 15:49 UTC704
}
c19702ae09beatagen2026-05-27 15:49 UTC705
c19702ae09beatagen2026-05-27 15:49 UTC706
fn footer_text(state: &UiState) -> Vec<Span<'static>> {
c19702ae09beatagen2026-05-27 15:49 UTC707
    let sep = || Span::styled("·", Style::default().fg(Color::DarkGray));
c19702ae09beatagen2026-05-27 15:49 UTC708
    let mut parts: Vec<Span> = vec![
c19702ae09beatagen2026-05-27 15:49 UTC709
        Span::raw(" j/k select "),
c19702ae09beatagen2026-05-27 15:49 UTC710
        sep(),
c19702ae09beatagen2026-05-27 15:49 UTC711
        Span::raw(" r route "),
c19702ae09beatagen2026-05-27 15:49 UTC712
        sep(),
c19702ae09beatagen2026-05-27 15:49 UTC713
        Span::raw(" a per-app "),
c19702ae09beatagen2026-05-27 15:49 UTC714
        sep(),
c19702ae09beatagen2026-05-27 15:49 UTC715
        Span::raw(" x reset "),
c19702ae09beatagen2026-05-27 15:49 UTC716
        sep(),
c19702ae09beatagen2026-05-27 15:49 UTC717
        Span::raw(" b bypass "),
c19702ae09beatagen2026-05-27 15:49 UTC718
        sep(),
c19702ae09beatagen2026-05-27 15:49 UTC719
        Span::raw(" p per-app "),
c19702ae09beatagen2026-05-27 15:49 UTC720
        sep(),
c19702ae09beatagen2026-05-27 15:49 UTC721
        Span::raw(" P profile "),
c19702ae09beatagen2026-05-27 15:49 UTC722
        sep(),
c19702ae09beatagen2026-05-27 15:49 UTC723
        Span::raw(" q quit "),
c19702ae09beatagen2026-05-27 15:49 UTC724
    ];
c19702ae09beatagen2026-05-27 15:49 UTC725
    if state.overflow_total > 0 {
c19702ae09beatagen2026-05-27 15:49 UTC726
        parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
c19702ae09beatagen2026-05-27 15:49 UTC727
        parts.push(Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC728
            format!(" dropped: {} ", state.overflow_total),
c19702ae09beatagen2026-05-27 15:49 UTC729
            Style::default().fg(Color::Yellow),
c19702ae09beatagen2026-05-27 15:49 UTC730
        ));
c19702ae09beatagen2026-05-27 15:49 UTC731
    }
c19702ae09beatagen2026-05-27 15:49 UTC732
    if let Some(err) = &state.last_error {
c19702ae09beatagen2026-05-27 15:49 UTC733
        parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
c19702ae09beatagen2026-05-27 15:49 UTC734
        parts.push(Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC735
            format!(" daemon error: {err} "),
c19702ae09beatagen2026-05-27 15:49 UTC736
            Style::default().fg(Color::Red),
c19702ae09beatagen2026-05-27 15:49 UTC737
        ));
c19702ae09beatagen2026-05-27 15:49 UTC738
    }
c19702ae09beatagen2026-05-27 15:49 UTC739
    if let Some(reason) = &state.disconnected {
c19702ae09beatagen2026-05-27 15:49 UTC740
        parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
c19702ae09beatagen2026-05-27 15:49 UTC741
        parts.push(Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC742
            format!(" disconnected: {reason} "),
c19702ae09beatagen2026-05-27 15:49 UTC743
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
c19702ae09beatagen2026-05-27 15:49 UTC744
        ));
c19702ae09beatagen2026-05-27 15:49 UTC745
    }
c19702ae09beatagen2026-05-27 15:49 UTC746
    if !state.setting_overrides.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC747
        parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
c19702ae09beatagen2026-05-27 15:49 UTC748
        parts.push(Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC749
            format!(
c19702ae09beatagen2026-05-27 15:49 UTC750
                " overrides: {} (c clear) ",
c19702ae09beatagen2026-05-27 15:49 UTC751
                fmt_overrides(&state.setting_overrides)
c19702ae09beatagen2026-05-27 15:49 UTC752
            ),
c19702ae09beatagen2026-05-27 15:49 UTC753
            Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
c19702ae09beatagen2026-05-27 15:49 UTC754
        ));
c19702ae09beatagen2026-05-27 15:49 UTC755
    }
c19702ae09beatagen2026-05-27 15:49 UTC756
    parts
c19702ae09beatagen2026-05-27 15:49 UTC757
}
c19702ae09beatagen2026-05-27 15:49 UTC758
c19702ae09beatagen2026-05-27 15:49 UTC759
/// active overrides as `key=value, …` for the footer (strings unquoted,
c19702ae09beatagen2026-05-27 15:49 UTC760
/// else json).
c19702ae09beatagen2026-05-27 15:49 UTC761
fn fmt_overrides(map: &BTreeMap<String, serde_json::Value>) -> String {
c19702ae09beatagen2026-05-27 15:49 UTC762
    map.iter()
c19702ae09beatagen2026-05-27 15:49 UTC763
        .map(|(k, v)| {
c19702ae09beatagen2026-05-27 15:49 UTC764
            let val = match v {
c19702ae09beatagen2026-05-27 15:49 UTC765
                serde_json::Value::String(s) => s.clone(),
c19702ae09beatagen2026-05-27 15:49 UTC766
                other => other.to_string(),
c19702ae09beatagen2026-05-27 15:49 UTC767
            };
c19702ae09beatagen2026-05-27 15:49 UTC768
            format!("{k}={val}")
c19702ae09beatagen2026-05-27 15:49 UTC769
        })
c19702ae09beatagen2026-05-27 15:49 UTC770
        .collect::<Vec<_>>()
c19702ae09beatagen2026-05-27 15:49 UTC771
        .join(", ")
c19702ae09beatagen2026-05-27 15:49 UTC772
}
c19702ae09beatagen2026-05-27 15:49 UTC773
c19702ae09beatagen2026-05-27 15:49 UTC774
fn draw_bus(f: &mut Frame, area: Rect, state: &UiState) {
c19702ae09beatagen2026-05-27 15:49 UTC775
    let block = Block::default()
c19702ae09beatagen2026-05-27 15:49 UTC776
        .borders(Borders::ALL)
c19702ae09beatagen2026-05-27 15:49 UTC777
        .title(" bus dsp ");
c19702ae09beatagen2026-05-27 15:49 UTC778
    let inner = block.inner(area);
c19702ae09beatagen2026-05-27 15:49 UTC779
    f.render_widget(block, area);
c19702ae09beatagen2026-05-27 15:49 UTC780
c19702ae09beatagen2026-05-27 15:49 UTC781
    let rows = Layout::default()
c19702ae09beatagen2026-05-27 15:49 UTC782
        .direction(Direction::Vertical)
c19702ae09beatagen2026-05-27 15:49 UTC783
        .constraints([
c19702ae09beatagen2026-05-27 15:49 UTC784
            Constraint::Length(1),
c19702ae09beatagen2026-05-27 15:49 UTC785
            Constraint::Length(1),
c19702ae09beatagen2026-05-27 15:49 UTC786
            Constraint::Length(1),
c19702ae09beatagen2026-05-27 15:49 UTC787
            Constraint::Length(1),
c19702ae09beatagen2026-05-27 15:49 UTC788
        ])
c19702ae09beatagen2026-05-27 15:49 UTC789
        .split(inner);
c19702ae09beatagen2026-05-27 15:49 UTC790
c19702ae09beatagen2026-05-27 15:49 UTC791
    let m = state.meters;
c19702ae09beatagen2026-05-27 15:49 UTC792
    draw_gauge_row(
c19702ae09beatagen2026-05-27 15:49 UTC793
        f,
c19702ae09beatagen2026-05-27 15:49 UTC794
        rows[0],
c19702ae09beatagen2026-05-27 15:49 UTC795
        GaugeRow {
c19702ae09beatagen2026-05-27 15:49 UTC796
            label: "AGC target",
c19702ae09beatagen2026-05-27 15:49 UTC797
            value: m.map(|t| t.agc_gain_db),
c19702ae09beatagen2026-05-27 15:49 UTC798
            min: -12.0,
c19702ae09beatagen2026-05-27 15:49 UTC799
            max: 12.0,
c19702ae09beatagen2026-05-27 15:49 UTC800
            unit: "dB",
c19702ae09beatagen2026-05-27 15:49 UTC801
            color: Color::Cyan,
c19702ae09beatagen2026-05-27 15:49 UTC802
            enabled: m.map(|t| t.agc_enabled).unwrap_or(true),
c19702ae09beatagen2026-05-27 15:49 UTC803
        },
c19702ae09beatagen2026-05-27 15:49 UTC804
    );
c19702ae09beatagen2026-05-27 15:49 UTC805
    draw_gauge_row(
c19702ae09beatagen2026-05-27 15:49 UTC806
        f,
c19702ae09beatagen2026-05-27 15:49 UTC807
        rows[1],
c19702ae09beatagen2026-05-27 15:49 UTC808
        GaugeRow {
c19702ae09beatagen2026-05-27 15:49 UTC809
            label: "Compressor GR",
c19702ae09beatagen2026-05-27 15:49 UTC810
            value: m.map(|t| t.compressor_gr_db),
c19702ae09beatagen2026-05-27 15:49 UTC811
            min: -24.0,
c19702ae09beatagen2026-05-27 15:49 UTC812
            max: 0.0,
c19702ae09beatagen2026-05-27 15:49 UTC813
            unit: "dB",
c19702ae09beatagen2026-05-27 15:49 UTC814
            color: Color::Magenta,
c19702ae09beatagen2026-05-27 15:49 UTC815
            enabled: m.map(|t| t.compressor_enabled).unwrap_or(true),
c19702ae09beatagen2026-05-27 15:49 UTC816
        },
c19702ae09beatagen2026-05-27 15:49 UTC817
    );
c19702ae09beatagen2026-05-27 15:49 UTC818
    draw_gauge_row(
c19702ae09beatagen2026-05-27 15:49 UTC819
        f,
c19702ae09beatagen2026-05-27 15:49 UTC820
        rows[2],
c19702ae09beatagen2026-05-27 15:49 UTC821
        GaugeRow {
c19702ae09beatagen2026-05-27 15:49 UTC822
            label: "Limiter GR",
c19702ae09beatagen2026-05-27 15:49 UTC823
            value: m.map(|t| t.limiter_gr_db),
c19702ae09beatagen2026-05-27 15:49 UTC824
            min: -24.0,
c19702ae09beatagen2026-05-27 15:49 UTC825
            max: 0.0,
c19702ae09beatagen2026-05-27 15:49 UTC826
            unit: "dB",
c19702ae09beatagen2026-05-27 15:49 UTC827
            color: Color::Red,
c19702ae09beatagen2026-05-27 15:49 UTC828
            enabled: true, // always-on safety backstop
c19702ae09beatagen2026-05-27 15:49 UTC829
        },
c19702ae09beatagen2026-05-27 15:49 UTC830
    );
c19702ae09beatagen2026-05-27 15:49 UTC831
    draw_gauge_row(
c19702ae09beatagen2026-05-27 15:49 UTC832
        f,
c19702ae09beatagen2026-05-27 15:49 UTC833
        rows[3],
c19702ae09beatagen2026-05-27 15:49 UTC834
        GaugeRow {
c19702ae09beatagen2026-05-27 15:49 UTC835
            label: "True peak",
c19702ae09beatagen2026-05-27 15:49 UTC836
            value: m.map(|t| t.true_peak_dbtp),
c19702ae09beatagen2026-05-27 15:49 UTC837
            min: -60.0,
c19702ae09beatagen2026-05-27 15:49 UTC838
            max: 3.0,
c19702ae09beatagen2026-05-27 15:49 UTC839
            unit: "dBTP",
c19702ae09beatagen2026-05-27 15:49 UTC840
            color: Color::Green,
c19702ae09beatagen2026-05-27 15:49 UTC841
            enabled: true, // a measurement, always meaningful
c19702ae09beatagen2026-05-27 15:49 UTC842
        },
c19702ae09beatagen2026-05-27 15:49 UTC843
    );
c19702ae09beatagen2026-05-27 15:49 UTC844
}
c19702ae09beatagen2026-05-27 15:49 UTC845
c19702ae09beatagen2026-05-27 15:49 UTC846
struct GaugeRow<'a> {
c19702ae09beatagen2026-05-27 15:49 UTC847
    label: &'a str,
c19702ae09beatagen2026-05-27 15:49 UTC848
    value: Option<f32>,
c19702ae09beatagen2026-05-27 15:49 UTC849
    min: f32,
c19702ae09beatagen2026-05-27 15:49 UTC850
    max: f32,
c19702ae09beatagen2026-05-27 15:49 UTC851
    unit: &'a str,
c19702ae09beatagen2026-05-27 15:49 UTC852
    color: Color,
c19702ae09beatagen2026-05-27 15:49 UTC853
    /// stage enabled in the active profile; when false the row renders
c19702ae09beatagen2026-05-27 15:49 UTC854
    /// "disabled" + a greyed bar so a bypassed stage isn't read as 0.
c19702ae09beatagen2026-05-27 15:49 UTC855
    enabled: bool,
c19702ae09beatagen2026-05-27 15:49 UTC856
}
c19702ae09beatagen2026-05-27 15:49 UTC857
c19702ae09beatagen2026-05-27 15:49 UTC858
/// one gauge row: `LABEL   VALUE   [████░░░░] min..max`.
c19702ae09beatagen2026-05-27 15:49 UTC859
fn draw_gauge_row(f: &mut Frame, area: Rect, row: GaugeRow<'_>) {
c19702ae09beatagen2026-05-27 15:49 UTC860
    let GaugeRow {
c19702ae09beatagen2026-05-27 15:49 UTC861
        label,
c19702ae09beatagen2026-05-27 15:49 UTC862
        value,
c19702ae09beatagen2026-05-27 15:49 UTC863
        min,
c19702ae09beatagen2026-05-27 15:49 UTC864
        max,
c19702ae09beatagen2026-05-27 15:49 UTC865
        unit,
c19702ae09beatagen2026-05-27 15:49 UTC866
        color,
c19702ae09beatagen2026-05-27 15:49 UTC867
        enabled,
c19702ae09beatagen2026-05-27 15:49 UTC868
    } = row;
c19702ae09beatagen2026-05-27 15:49 UTC869
    let cols = Layout::default()
c19702ae09beatagen2026-05-27 15:49 UTC870
        .direction(Direction::Horizontal)
c19702ae09beatagen2026-05-27 15:49 UTC871
        .constraints([
c19702ae09beatagen2026-05-27 15:49 UTC872
            Constraint::Length(16),
c19702ae09beatagen2026-05-27 15:49 UTC873
            Constraint::Length(14),
c19702ae09beatagen2026-05-27 15:49 UTC874
            Constraint::Min(8),
c19702ae09beatagen2026-05-27 15:49 UTC875
            Constraint::Length(14),
c19702ae09beatagen2026-05-27 15:49 UTC876
        ])
c19702ae09beatagen2026-05-27 15:49 UTC877
        .split(area);
c19702ae09beatagen2026-05-27 15:49 UTC878
c19702ae09beatagen2026-05-27 15:49 UTC879
    // disabled stage: grey the row + say so, so it can't read as 0.
c19702ae09beatagen2026-05-27 15:49 UTC880
    if !enabled {
c19702ae09beatagen2026-05-27 15:49 UTC881
        let dim = Style::default().fg(Color::DarkGray);
c19702ae09beatagen2026-05-27 15:49 UTC882
        f.render_widget(Paragraph::new(format!(" {label}")).style(dim), cols[0]);
c19702ae09beatagen2026-05-27 15:49 UTC883
        f.render_widget(
c19702ae09beatagen2026-05-27 15:49 UTC884
            Paragraph::new("disabled").alignment(Alignment::Right).style(dim),
c19702ae09beatagen2026-05-27 15:49 UTC885
            cols[1],
c19702ae09beatagen2026-05-27 15:49 UTC886
        );
c19702ae09beatagen2026-05-27 15:49 UTC887
        f.render_widget(
c19702ae09beatagen2026-05-27 15:49 UTC888
            Gauge::default()
c19702ae09beatagen2026-05-27 15:49 UTC889
                .gauge_style(dim)
c19702ae09beatagen2026-05-27 15:49 UTC890
                .ratio(0.0)
c19702ae09beatagen2026-05-27 15:49 UTC891
                .label("off"),
c19702ae09beatagen2026-05-27 15:49 UTC892
            cols[2],
c19702ae09beatagen2026-05-27 15:49 UTC893
        );
c19702ae09beatagen2026-05-27 15:49 UTC894
        f.render_widget(
c19702ae09beatagen2026-05-27 15:49 UTC895
            Paragraph::new(format!("{min:.0}..{max:.0} "))
c19702ae09beatagen2026-05-27 15:49 UTC896
                .alignment(Alignment::Right)
c19702ae09beatagen2026-05-27 15:49 UTC897
                .style(dim),
c19702ae09beatagen2026-05-27 15:49 UTC898
            cols[3],
c19702ae09beatagen2026-05-27 15:49 UTC899
        );
c19702ae09beatagen2026-05-27 15:49 UTC900
        return;
c19702ae09beatagen2026-05-27 15:49 UTC901
    }
c19702ae09beatagen2026-05-27 15:49 UTC902
c19702ae09beatagen2026-05-27 15:49 UTC903
    f.render_widget(Paragraph::new(format!(" {label}")), cols[0]);
c19702ae09beatagen2026-05-27 15:49 UTC904
c19702ae09beatagen2026-05-27 15:49 UTC905
    let value_str = value
c19702ae09beatagen2026-05-27 15:49 UTC906
        .map(|v| format!("{v:+7.2} {unit}"))
c19702ae09beatagen2026-05-27 15:49 UTC907
        .unwrap_or_else(|| "    -- ".to_string());
c19702ae09beatagen2026-05-27 15:49 UTC908
    f.render_widget(
c19702ae09beatagen2026-05-27 15:49 UTC909
        Paragraph::new(value_str).alignment(Alignment::Right),
c19702ae09beatagen2026-05-27 15:49 UTC910
        cols[1],
c19702ae09beatagen2026-05-27 15:49 UTC911
    );
c19702ae09beatagen2026-05-27 15:49 UTC912
c19702ae09beatagen2026-05-27 15:49 UTC913
    let pct = match value {
c19702ae09beatagen2026-05-27 15:49 UTC914
        Some(v) => {
c19702ae09beatagen2026-05-27 15:49 UTC915
            let clamped = v.clamp(min, max);
c19702ae09beatagen2026-05-27 15:49 UTC916
            ((clamped - min) / (max - min)).clamp(0.0, 1.0) as f64
c19702ae09beatagen2026-05-27 15:49 UTC917
        }
c19702ae09beatagen2026-05-27 15:49 UTC918
        None => 0.0,
c19702ae09beatagen2026-05-27 15:49 UTC919
    };
c19702ae09beatagen2026-05-27 15:49 UTC920
    let gauge = Gauge::default()
c19702ae09beatagen2026-05-27 15:49 UTC921
        .gauge_style(Style::default().fg(color))
c19702ae09beatagen2026-05-27 15:49 UTC922
        .ratio(pct)
c19702ae09beatagen2026-05-27 15:49 UTC923
        .label("");
c19702ae09beatagen2026-05-27 15:49 UTC924
    f.render_widget(gauge, cols[2]);
c19702ae09beatagen2026-05-27 15:49 UTC925
c19702ae09beatagen2026-05-27 15:49 UTC926
    f.render_widget(
c19702ae09beatagen2026-05-27 15:49 UTC927
        Paragraph::new(format!("{min:.0}..{max:.0} ")).alignment(Alignment::Right),
c19702ae09beatagen2026-05-27 15:49 UTC928
        cols[3],
c19702ae09beatagen2026-05-27 15:49 UTC929
    );
c19702ae09beatagen2026-05-27 15:49 UTC930
}
c19702ae09beatagen2026-05-27 15:49 UTC931
c19702ae09beatagen2026-05-27 15:49 UTC932
fn draw_loudness(f: &mut Frame, area: Rect, state: &UiState) {
c19702ae09beatagen2026-05-27 15:49 UTC933
    let block = Block::default()
c19702ae09beatagen2026-05-27 15:49 UTC934
        .borders(Borders::ALL)
c19702ae09beatagen2026-05-27 15:49 UTC935
        .title(" loudness (BS.1770) ");
c19702ae09beatagen2026-05-27 15:49 UTC936
    let inner = block.inner(area);
c19702ae09beatagen2026-05-27 15:49 UTC937
    f.render_widget(block, area);
c19702ae09beatagen2026-05-27 15:49 UTC938
c19702ae09beatagen2026-05-27 15:49 UTC939
    let staleness = state
c19702ae09beatagen2026-05-27 15:49 UTC940
        .last_meter_at
c19702ae09beatagen2026-05-27 15:49 UTC941
        .map(|t| t.elapsed())
c19702ae09beatagen2026-05-27 15:49 UTC942
        .unwrap_or(Duration::ZERO);
c19702ae09beatagen2026-05-27 15:49 UTC943
    let stale = staleness > Duration::from_millis(500);
c19702ae09beatagen2026-05-27 15:49 UTC944
c19702ae09beatagen2026-05-27 15:49 UTC945
    let (mom, st, intg) = match state.meters {
c19702ae09beatagen2026-05-27 15:49 UTC946
        Some(m) => (Some(m.momentary_lufs), Some(m.shortterm_lufs), Some(m.integrated_lufs)),
c19702ae09beatagen2026-05-27 15:49 UTC947
        None => (None, None, None),
c19702ae09beatagen2026-05-27 15:49 UTC948
    };
c19702ae09beatagen2026-05-27 15:49 UTC949
c19702ae09beatagen2026-05-27 15:49 UTC950
    let lines = vec![
c19702ae09beatagen2026-05-27 15:49 UTC951
        lufs_line("Momentary  (400 ms)", mom, stale),
c19702ae09beatagen2026-05-27 15:49 UTC952
        lufs_line("Short-term (3 s)", st, stale),
c19702ae09beatagen2026-05-27 15:49 UTC953
        lufs_line("Integrated (gated)", intg, stale),
c19702ae09beatagen2026-05-27 15:49 UTC954
    ];
c19702ae09beatagen2026-05-27 15:49 UTC955
    f.render_widget(Paragraph::new(lines), inner);
c19702ae09beatagen2026-05-27 15:49 UTC956
}
c19702ae09beatagen2026-05-27 15:49 UTC957
c19702ae09beatagen2026-05-27 15:49 UTC958
fn lufs_line(label: &str, v: Option<f32>, stale: bool) -> Line<'static> {
c19702ae09beatagen2026-05-27 15:49 UTC959
    let val = match v {
c19702ae09beatagen2026-05-27 15:49 UTC960
        Some(x) if x > headroom_core::agc::LOUDNESS_FLOOR_LUFS + 0.5 => {
c19702ae09beatagen2026-05-27 15:49 UTC961
            format!("{x:+7.2} LUFS")
c19702ae09beatagen2026-05-27 15:49 UTC962
        }
c19702ae09beatagen2026-05-27 15:49 UTC963
        Some(_) => "    -- LUFS".to_string(),
c19702ae09beatagen2026-05-27 15:49 UTC964
        None => "    -- LUFS".to_string(),
c19702ae09beatagen2026-05-27 15:49 UTC965
    };
c19702ae09beatagen2026-05-27 15:49 UTC966
    let style = if stale {
c19702ae09beatagen2026-05-27 15:49 UTC967
        Style::default().fg(Color::DarkGray)
c19702ae09beatagen2026-05-27 15:49 UTC968
    } else {
c19702ae09beatagen2026-05-27 15:49 UTC969
        Style::default()
c19702ae09beatagen2026-05-27 15:49 UTC970
    };
c19702ae09beatagen2026-05-27 15:49 UTC971
    Line::from(vec![
c19702ae09beatagen2026-05-27 15:49 UTC972
        Span::raw(format!(" {label:<24}")),
c19702ae09beatagen2026-05-27 15:49 UTC973
        Span::styled(val, style),
c19702ae09beatagen2026-05-27 15:49 UTC974
    ])
c19702ae09beatagen2026-05-27 15:49 UTC975
}
c19702ae09beatagen2026-05-27 15:49 UTC976
c19702ae09beatagen2026-05-27 15:49 UTC977
fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
c19702ae09beatagen2026-05-27 15:49 UTC978
    let title = format!(
c19702ae09beatagen2026-05-27 15:49 UTC979
        " streams ({}) — default: {} ",
c19702ae09beatagen2026-05-27 15:49 UTC980
        state.streams.len(),
c19702ae09beatagen2026-05-27 15:49 UTC981
        state.default_route
c19702ae09beatagen2026-05-27 15:49 UTC982
    );
c19702ae09beatagen2026-05-27 15:49 UTC983
    let block = Block::default().borders(Borders::ALL).title(title);
c19702ae09beatagen2026-05-27 15:49 UTC984
c19702ae09beatagen2026-05-27 15:49 UTC985
    let header = Row::new(vec!["", "node", "app", "route", "per-app"])
c19702ae09beatagen2026-05-27 15:49 UTC986
        .style(Style::default().add_modifier(Modifier::BOLD));
c19702ae09beatagen2026-05-27 15:49 UTC987
c19702ae09beatagen2026-05-27 15:49 UTC988
    let selected = state.effective_selection();
c19702ae09beatagen2026-05-27 15:49 UTC989
    let rows: Vec<Row> = state
c19702ae09beatagen2026-05-27 15:49 UTC990
        .streams
c19702ae09beatagen2026-05-27 15:49 UTC991
        .values()
c19702ae09beatagen2026-05-27 15:49 UTC992
        .map(|s| {
c19702ae09beatagen2026-05-27 15:49 UTC993
            let is_sel = selected == Some(s.node_id);
c19702ae09beatagen2026-05-27 15:49 UTC994
            let route_cell = match s.route {
c19702ae09beatagen2026-05-27 15:49 UTC995
                Route::Processed => Cell::from("processed").style(Style::default().fg(Color::Green)),
c19702ae09beatagen2026-05-27 15:49 UTC996
                Route::Bypass => Cell::from("bypass").style(Style::default().fg(Color::Yellow)),
c19702ae09beatagen2026-05-27 15:49 UTC997
            };
c19702ae09beatagen2026-05-27 15:49 UTC998
            let la_cell = match state.layer_a.get(&s.node_id) {
c19702ae09beatagen2026-05-27 15:49 UTC999
                Some(Some(db)) => Cell::from(format!("{db:+5.1} dB"))
c19702ae09beatagen2026-05-27 15:49 UTC1000
                    .style(Style::default().fg(Color::Magenta)),
c19702ae09beatagen2026-05-27 15:49 UTC1001
                Some(None) => Cell::from("attached")
c19702ae09beatagen2026-05-27 15:49 UTC1002
                    .style(Style::default().fg(Color::DarkGray)),
c19702ae09beatagen2026-05-27 15:49 UTC1003
                None => Cell::from("—").style(Style::default().fg(Color::DarkGray)),
c19702ae09beatagen2026-05-27 15:49 UTC1004
            };
c19702ae09beatagen2026-05-27 15:49 UTC1005
            let marker = if is_sel { "▶" } else { " " };
c19702ae09beatagen2026-05-27 15:49 UTC1006
            let row = Row::new(vec![
c19702ae09beatagen2026-05-27 15:49 UTC1007
                Cell::from(marker),
c19702ae09beatagen2026-05-27 15:49 UTC1008
                Cell::from(s.node_id.to_string()),
c19702ae09beatagen2026-05-27 15:49 UTC1009
                Cell::from(s.app.clone()),
c19702ae09beatagen2026-05-27 15:49 UTC1010
                route_cell,
c19702ae09beatagen2026-05-27 15:49 UTC1011
                la_cell,
c19702ae09beatagen2026-05-27 15:49 UTC1012
            ]);
c19702ae09beatagen2026-05-27 15:49 UTC1013
            if is_sel {
c19702ae09beatagen2026-05-27 15:49 UTC1014
                row.style(Style::default().add_modifier(Modifier::REVERSED))
c19702ae09beatagen2026-05-27 15:49 UTC1015
            } else {
c19702ae09beatagen2026-05-27 15:49 UTC1016
                row
c19702ae09beatagen2026-05-27 15:49 UTC1017
            }
c19702ae09beatagen2026-05-27 15:49 UTC1018
        })
c19702ae09beatagen2026-05-27 15:49 UTC1019
        .collect();
c19702ae09beatagen2026-05-27 15:49 UTC1020
c19702ae09beatagen2026-05-27 15:49 UTC1021
    let widths = [
c19702ae09beatagen2026-05-27 15:49 UTC1022
        Constraint::Length(2),
c19702ae09beatagen2026-05-27 15:49 UTC1023
        Constraint::Length(8),
c19702ae09beatagen2026-05-27 15:49 UTC1024
        Constraint::Min(20),
c19702ae09beatagen2026-05-27 15:49 UTC1025
        Constraint::Length(12),
c19702ae09beatagen2026-05-27 15:49 UTC1026
        Constraint::Length(10),
c19702ae09beatagen2026-05-27 15:49 UTC1027
    ];
c19702ae09beatagen2026-05-27 15:49 UTC1028
    let table = Table::new(rows, widths).header(header).block(block);
c19702ae09beatagen2026-05-27 15:49 UTC1029
    f.render_widget(table, area);
c19702ae09beatagen2026-05-27 15:49 UTC1030
}
c19702ae09beatagen2026-05-27 15:49 UTC1031
c19702ae09beatagen2026-05-27 15:49 UTC1032
/// read-only layer a detail for the selected stream: managed flag,
c19702ae09beatagen2026-05-27 15:49 UTC1033
/// smoothed reduction, user ceiling, deference lock.
c19702ae09beatagen2026-05-27 15:49 UTC1034
fn draw_layer_a_detail(f: &mut Frame, area: Rect, state: &UiState) {
c19702ae09beatagen2026-05-27 15:49 UTC1035
    let block = Block::default()
c19702ae09beatagen2026-05-27 15:49 UTC1036
        .borders(Borders::ALL)
c19702ae09beatagen2026-05-27 15:49 UTC1037
        .title(" per-app level (selected) ");
c19702ae09beatagen2026-05-27 15:49 UTC1038
    let inner = block.inner(area);
c19702ae09beatagen2026-05-27 15:49 UTC1039
    f.render_widget(block, area);
c19702ae09beatagen2026-05-27 15:49 UTC1040
c19702ae09beatagen2026-05-27 15:49 UTC1041
    let line = match state.effective_selection() {
c19702ae09beatagen2026-05-27 15:49 UTC1042
        None => Line::from(Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC1043
            " no stream selected",
c19702ae09beatagen2026-05-27 15:49 UTC1044
            Style::default().fg(Color::DarkGray),
c19702ae09beatagen2026-05-27 15:49 UTC1045
        )),
c19702ae09beatagen2026-05-27 15:49 UTC1046
        Some(node) => {
c19702ae09beatagen2026-05-27 15:49 UTC1047
            let app = state
c19702ae09beatagen2026-05-27 15:49 UTC1048
                .streams
c19702ae09beatagen2026-05-27 15:49 UTC1049
                .get(&node)
c19702ae09beatagen2026-05-27 15:49 UTC1050
                .map(|s| s.app.clone())
c19702ae09beatagen2026-05-27 15:49 UTC1051
                .unwrap_or_default();
c19702ae09beatagen2026-05-27 15:49 UTC1052
            match state.la_snapshots.get(&node) {
c19702ae09beatagen2026-05-27 15:49 UTC1053
                Some(snap) => {
c19702ae09beatagen2026-05-27 15:49 UTC1054
                    let ceiling = snap
c19702ae09beatagen2026-05-27 15:49 UTC1055
                        .user_ceiling_lin
c19702ae09beatagen2026-05-27 15:49 UTC1056
                        .map(|c| format!("{c:.2}"))
c19702ae09beatagen2026-05-27 15:49 UTC1057
                        .unwrap_or_else(|| "—".to_string());
c19702ae09beatagen2026-05-27 15:49 UTC1058
                    let deferred = if snap.deferred {
c19702ae09beatagen2026-05-27 15:49 UTC1059
                        Span::styled("deferred", Style::default().fg(Color::Yellow))
c19702ae09beatagen2026-05-27 15:49 UTC1060
                    } else {
c19702ae09beatagen2026-05-27 15:49 UTC1061
                        Span::styled("active", Style::default().fg(Color::Green))
c19702ae09beatagen2026-05-27 15:49 UTC1062
                    };
c19702ae09beatagen2026-05-27 15:49 UTC1063
                    Line::from(vec![
c19702ae09beatagen2026-05-27 15:49 UTC1064
                        Span::raw(format!(" node {node}  {app}  ")),
c19702ae09beatagen2026-05-27 15:49 UTC1065
                        Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC1066
                            if snap.managed { "managed" } else { "unmanaged" },
c19702ae09beatagen2026-05-27 15:49 UTC1067
                            Style::default().fg(if snap.managed {
c19702ae09beatagen2026-05-27 15:49 UTC1068
                                Color::Cyan
c19702ae09beatagen2026-05-27 15:49 UTC1069
                            } else {
c19702ae09beatagen2026-05-27 15:49 UTC1070
                                Color::DarkGray
c19702ae09beatagen2026-05-27 15:49 UTC1071
                            }),
c19702ae09beatagen2026-05-27 15:49 UTC1072
                        ),
c19702ae09beatagen2026-05-27 15:49 UTC1073
                        Span::raw(format!(
c19702ae09beatagen2026-05-27 15:49 UTC1074
                            "  reduction {:+.1} dB  ceiling {ceiling}  ",
c19702ae09beatagen2026-05-27 15:49 UTC1075
                            snap.reduction_db
c19702ae09beatagen2026-05-27 15:49 UTC1076
                        )),
c19702ae09beatagen2026-05-27 15:49 UTC1077
                        deferred,
c19702ae09beatagen2026-05-27 15:49 UTC1078
                    ])
c19702ae09beatagen2026-05-27 15:49 UTC1079
                }
c19702ae09beatagen2026-05-27 15:49 UTC1080
                None => Line::from(Span::styled(
c19702ae09beatagen2026-05-27 15:49 UTC1081
                    format!(" node {node}  {app}  not managed per-app"),
c19702ae09beatagen2026-05-27 15:49 UTC1082
                    Style::default().fg(Color::DarkGray),
c19702ae09beatagen2026-05-27 15:49 UTC1083
                )),
c19702ae09beatagen2026-05-27 15:49 UTC1084
            }
c19702ae09beatagen2026-05-27 15:49 UTC1085
        }
c19702ae09beatagen2026-05-27 15:49 UTC1086
    };
c19702ae09beatagen2026-05-27 15:49 UTC1087
    f.render_widget(Paragraph::new(line), inner);
c19702ae09beatagen2026-05-27 15:49 UTC1088
}
c19702ae09beatagen2026-05-27 15:49 UTC1089
c19702ae09beatagen2026-05-27 15:49 UTC1090
fn fmt_uptime(s: u64) -> String {
c19702ae09beatagen2026-05-27 15:49 UTC1091
    let h = s / 3600;
c19702ae09beatagen2026-05-27 15:49 UTC1092
    let m = (s % 3600) / 60;
c19702ae09beatagen2026-05-27 15:49 UTC1093
    let sec = s % 60;
c19702ae09beatagen2026-05-27 15:49 UTC1094
    if h > 0 {
c19702ae09beatagen2026-05-27 15:49 UTC1095
        format!("{h}h{m:02}m{sec:02}s")
c19702ae09beatagen2026-05-27 15:49 UTC1096
    } else if m > 0 {
c19702ae09beatagen2026-05-27 15:49 UTC1097
        format!("{m}m{sec:02}s")
c19702ae09beatagen2026-05-27 15:49 UTC1098
    } else {
c19702ae09beatagen2026-05-27 15:49 UTC1099
        format!("{sec}s")
c19702ae09beatagen2026-05-27 15:49 UTC1100
    }
c19702ae09beatagen2026-05-27 15:49 UTC1101
}
c19702ae09beatagen2026-05-27 15:49 UTC1102
c19702ae09beatagen2026-05-27 15:49 UTC1103
#[cfg(test)]
c19702ae09beatagen2026-05-27 15:49 UTC1104
mod state_tests;
c19702ae09beatagen2026-05-27 15:49 UTC1105
c19702ae09beatagen2026-05-27 15:49 UTC1106
#[cfg(test)]
c19702ae09beatagen2026-05-27 15:49 UTC1107
mod tests {
c19702ae09beatagen2026-05-27 15:49 UTC1108
    use super::*;
c19702ae09beatagen2026-05-27 15:49 UTC1109
    use headroom_ipc::{Sinks, Status};
c19702ae09beatagen2026-05-27 15:49 UTC1110
c19702ae09beatagen2026-05-27 15:49 UTC1111
    fn empty_state() -> UiState {
c19702ae09beatagen2026-05-27 15:49 UTC1112
        let status = Status {
c19702ae09beatagen2026-05-27 15:49 UTC1113
            version: "test".into(),
c19702ae09beatagen2026-05-27 15:49 UTC1114
            protocol: 1,
c19702ae09beatagen2026-05-27 15:49 UTC1115
            uptime_s: 0,
c19702ae09beatagen2026-05-27 15:49 UTC1116
            profile: "default".into(),
c19702ae09beatagen2026-05-27 15:49 UTC1117
            bypass: false,
c19702ae09beatagen2026-05-27 15:49 UTC1118
            per_app: false,
c19702ae09beatagen2026-05-27 15:49 UTC1119
            sinks: Sinks::default(),
c19702ae09beatagen2026-05-27 15:49 UTC1120
            streams: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1121
            layer_a: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1122
            warnings: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1123
            setting_overrides: Default::default(),
c19702ae09beatagen2026-05-27 15:49 UTC1124
        };
c19702ae09beatagen2026-05-27 15:49 UTC1125
        let route_list = headroom_ipc::RouteList {
c19702ae09beatagen2026-05-27 15:49 UTC1126
            rules: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1127
            current: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1128
            default_route: Route::Processed,
c19702ae09beatagen2026-05-27 15:49 UTC1129
        };
c19702ae09beatagen2026-05-27 15:49 UTC1130
        UiState::new(status, route_list)
c19702ae09beatagen2026-05-27 15:49 UTC1131
    }
c19702ae09beatagen2026-05-27 15:49 UTC1132
c19702ae09beatagen2026-05-27 15:49 UTC1133
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1134
    fn meter_tick_event_updates_state() {
c19702ae09beatagen2026-05-27 15:49 UTC1135
        let mut state = empty_state();
c19702ae09beatagen2026-05-27 15:49 UTC1136
        let tick = MeterTick {
c19702ae09beatagen2026-05-27 15:49 UTC1137
            momentary_lufs: -19.3,
c19702ae09beatagen2026-05-27 15:49 UTC1138
            shortterm_lufs: -20.1,
c19702ae09beatagen2026-05-27 15:49 UTC1139
            integrated_lufs: -19.8,
c19702ae09beatagen2026-05-27 15:49 UTC1140
            true_peak_dbtp: -1.4,
c19702ae09beatagen2026-05-27 15:49 UTC1141
            gain_reduction_db: -2.1,
c19702ae09beatagen2026-05-27 15:49 UTC1142
            compressor_gr_db: -0.8,
c19702ae09beatagen2026-05-27 15:49 UTC1143
            limiter_gr_db: -1.3,
c19702ae09beatagen2026-05-27 15:49 UTC1144
            agc_gain_db: 0.5,
c19702ae09beatagen2026-05-27 15:49 UTC1145
            agc_enabled: true,
c19702ae09beatagen2026-05-27 15:49 UTC1146
            compressor_enabled: true,
c19702ae09beatagen2026-05-27 15:49 UTC1147
        };
c19702ae09beatagen2026-05-27 15:49 UTC1148
        let ev = Event::new(Topic::Meters, "tick", &tick).unwrap();
c19702ae09beatagen2026-05-27 15:49 UTC1149
        state.apply_event(ev);
c19702ae09beatagen2026-05-27 15:49 UTC1150
        let got = state.meters.expect("meters set");
c19702ae09beatagen2026-05-27 15:49 UTC1151
        assert!((got.momentary_lufs - tick.momentary_lufs).abs() < f32::EPSILON);
c19702ae09beatagen2026-05-27 15:49 UTC1152
        assert!((got.true_peak_dbtp - tick.true_peak_dbtp).abs() < f32::EPSILON);
c19702ae09beatagen2026-05-27 15:49 UTC1153
        assert!(state.last_meter_at.is_some());
c19702ae09beatagen2026-05-27 15:49 UTC1154
    }
c19702ae09beatagen2026-05-27 15:49 UTC1155
c19702ae09beatagen2026-05-27 15:49 UTC1156
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1157
    fn stream_removed_prunes_state() {
c19702ae09beatagen2026-05-27 15:49 UTC1158
        let mut state = empty_state();
c19702ae09beatagen2026-05-27 15:49 UTC1159
        // insert via stream_routed first.
c19702ae09beatagen2026-05-27 15:49 UTC1160
        state.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1161
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1162
                Topic::Routing,
c19702ae09beatagen2026-05-27 15:49 UTC1163
                "stream_routed",
c19702ae09beatagen2026-05-27 15:49 UTC1164
                &serde_json::json!({ "node_id": 7, "app": "x", "to": "processed" }),
c19702ae09beatagen2026-05-27 15:49 UTC1165
            )
c19702ae09beatagen2026-05-27 15:49 UTC1166
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1167
        );
c19702ae09beatagen2026-05-27 15:49 UTC1168
        state.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1169
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1170
                Topic::Routing,
c19702ae09beatagen2026-05-27 15:49 UTC1171
                "layer_a_attached",
c19702ae09beatagen2026-05-27 15:49 UTC1172
                &serde_json::json!({ "node_id": 7, "app": "x" }),
c19702ae09beatagen2026-05-27 15:49 UTC1173
            )
c19702ae09beatagen2026-05-27 15:49 UTC1174
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1175
        );
c19702ae09beatagen2026-05-27 15:49 UTC1176
        assert!(state.streams.contains_key(&7));
c19702ae09beatagen2026-05-27 15:49 UTC1177
        assert!(state.layer_a.contains_key(&7));
c19702ae09beatagen2026-05-27 15:49 UTC1178
        state.la_snapshots.insert(
c19702ae09beatagen2026-05-27 15:49 UTC1179
            7,
c19702ae09beatagen2026-05-27 15:49 UTC1180
            LayerASnapshot {
c19702ae09beatagen2026-05-27 15:49 UTC1181
                node_id: 7,
c19702ae09beatagen2026-05-27 15:49 UTC1182
                app: "x".into(),
c19702ae09beatagen2026-05-27 15:49 UTC1183
                managed: true,
c19702ae09beatagen2026-05-27 15:49 UTC1184
                volume_lin: 1.0,
c19702ae09beatagen2026-05-27 15:49 UTC1185
                reduction_db: -3.0,
c19702ae09beatagen2026-05-27 15:49 UTC1186
                user_ceiling_lin: Some(0.8),
c19702ae09beatagen2026-05-27 15:49 UTC1187
                deferred: true,
c19702ae09beatagen2026-05-27 15:49 UTC1188
            },
c19702ae09beatagen2026-05-27 15:49 UTC1189
        );
c19702ae09beatagen2026-05-27 15:49 UTC1190
c19702ae09beatagen2026-05-27 15:49 UTC1191
        state.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1192
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1193
                Topic::Routing,
c19702ae09beatagen2026-05-27 15:49 UTC1194
                "stream_removed",
c19702ae09beatagen2026-05-27 15:49 UTC1195
                &serde_json::json!({ "node_id": 7 }),
c19702ae09beatagen2026-05-27 15:49 UTC1196
            )
c19702ae09beatagen2026-05-27 15:49 UTC1197
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1198
        );
c19702ae09beatagen2026-05-27 15:49 UTC1199
        assert!(!state.streams.contains_key(&7));
c19702ae09beatagen2026-05-27 15:49 UTC1200
        assert!(!state.layer_a.contains_key(&7));
c19702ae09beatagen2026-05-27 15:49 UTC1201
        assert!(!state.la_snapshots.contains_key(&7));
c19702ae09beatagen2026-05-27 15:49 UTC1202
    }
c19702ae09beatagen2026-05-27 15:49 UTC1203
c19702ae09beatagen2026-05-27 15:49 UTC1204
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1205
    fn layer_a_level_updates_reduction() {
c19702ae09beatagen2026-05-27 15:49 UTC1206
        let mut state = empty_state();
c19702ae09beatagen2026-05-27 15:49 UTC1207
        state.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1208
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1209
                Topic::Routing,
c19702ae09beatagen2026-05-27 15:49 UTC1210
                "layer_a_attached",
c19702ae09beatagen2026-05-27 15:49 UTC1211
                &serde_json::json!({ "node_id": 11, "app": "loud-app" }),
c19702ae09beatagen2026-05-27 15:49 UTC1212
            )
c19702ae09beatagen2026-05-27 15:49 UTC1213
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1214
        );
c19702ae09beatagen2026-05-27 15:49 UTC1215
        assert_eq!(state.layer_a.get(&11), Some(&None));
c19702ae09beatagen2026-05-27 15:49 UTC1216
c19702ae09beatagen2026-05-27 15:49 UTC1217
        state.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1218
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1219
                Topic::Meters,
c19702ae09beatagen2026-05-27 15:49 UTC1220
                "layer_a_level",
c19702ae09beatagen2026-05-27 15:49 UTC1221
                &serde_json::json!({
c19702ae09beatagen2026-05-27 15:49 UTC1222
                    "node_id": 11,
c19702ae09beatagen2026-05-27 15:49 UTC1223
                    "app": "loud-app",
c19702ae09beatagen2026-05-27 15:49 UTC1224
                    "volume_lin": 0.256_f32,
c19702ae09beatagen2026-05-27 15:49 UTC1225
                    "reduction_db": -11.8_f32,
c19702ae09beatagen2026-05-27 15:49 UTC1226
                }),
c19702ae09beatagen2026-05-27 15:49 UTC1227
            )
c19702ae09beatagen2026-05-27 15:49 UTC1228
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1229
        );
c19702ae09beatagen2026-05-27 15:49 UTC1230
        let r = state.layer_a.get(&11).copied().flatten().unwrap();
c19702ae09beatagen2026-05-27 15:49 UTC1231
        assert!((r - -11.8).abs() < 1e-4);
c19702ae09beatagen2026-05-27 15:49 UTC1232
    }
c19702ae09beatagen2026-05-27 15:49 UTC1233
c19702ae09beatagen2026-05-27 15:49 UTC1234
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1235
    fn routing_event_inserts_stream() {
c19702ae09beatagen2026-05-27 15:49 UTC1236
        let mut state = empty_state();
c19702ae09beatagen2026-05-27 15:49 UTC1237
        let ev = Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1238
            Topic::Routing,
c19702ae09beatagen2026-05-27 15:49 UTC1239
            "stream_routed",
c19702ae09beatagen2026-05-27 15:49 UTC1240
            &serde_json::json!({
c19702ae09beatagen2026-05-27 15:49 UTC1241
                "node_id": 42,
c19702ae09beatagen2026-05-27 15:49 UTC1242
                "app": "firefox",
c19702ae09beatagen2026-05-27 15:49 UTC1243
                "to": "bypass",
c19702ae09beatagen2026-05-27 15:49 UTC1244
            }),
c19702ae09beatagen2026-05-27 15:49 UTC1245
        )
c19702ae09beatagen2026-05-27 15:49 UTC1246
        .unwrap();
c19702ae09beatagen2026-05-27 15:49 UTC1247
        state.apply_event(ev);
c19702ae09beatagen2026-05-27 15:49 UTC1248
        let s = state.streams.get(&42).expect("stream tracked");
c19702ae09beatagen2026-05-27 15:49 UTC1249
        assert_eq!(s.app, "firefox");
c19702ae09beatagen2026-05-27 15:49 UTC1250
        assert_eq!(s.route, Route::Bypass);
c19702ae09beatagen2026-05-27 15:49 UTC1251
    }
c19702ae09beatagen2026-05-27 15:49 UTC1252
c19702ae09beatagen2026-05-27 15:49 UTC1253
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1254
    fn profile_changed_updates_active() {
c19702ae09beatagen2026-05-27 15:49 UTC1255
        let mut state = empty_state();
c19702ae09beatagen2026-05-27 15:49 UTC1256
        let ev = Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1257
            Topic::Profile,
c19702ae09beatagen2026-05-27 15:49 UTC1258
            "changed",
c19702ae09beatagen2026-05-27 15:49 UTC1259
            &serde_json::json!({
c19702ae09beatagen2026-05-27 15:49 UTC1260
                "name": "night",
c19702ae09beatagen2026-05-27 15:49 UTC1261
                "previous": "default",
c19702ae09beatagen2026-05-27 15:49 UTC1262
            }),
c19702ae09beatagen2026-05-27 15:49 UTC1263
        )
c19702ae09beatagen2026-05-27 15:49 UTC1264
        .unwrap();
c19702ae09beatagen2026-05-27 15:49 UTC1265
        state.apply_event(ev);
c19702ae09beatagen2026-05-27 15:49 UTC1266
        assert_eq!(state.profile, "night");
c19702ae09beatagen2026-05-27 15:49 UTC1267
    }
c19702ae09beatagen2026-05-27 15:49 UTC1268
c19702ae09beatagen2026-05-27 15:49 UTC1269
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1270
    fn daemon_overflow_accumulates() {
c19702ae09beatagen2026-05-27 15:49 UTC1271
        let mut state = empty_state();
c19702ae09beatagen2026-05-27 15:49 UTC1272
        let ev = Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1273
            Topic::Daemon,
c19702ae09beatagen2026-05-27 15:49 UTC1274
            "overflow",
c19702ae09beatagen2026-05-27 15:49 UTC1275
            &serde_json::json!({
c19702ae09beatagen2026-05-27 15:49 UTC1276
                "lost_topic": "meters",
c19702ae09beatagen2026-05-27 15:49 UTC1277
                "lost": 3u32,
c19702ae09beatagen2026-05-27 15:49 UTC1278
                "total_lost": 5u64,
c19702ae09beatagen2026-05-27 15:49 UTC1279
            }),
c19702ae09beatagen2026-05-27 15:49 UTC1280
        )
c19702ae09beatagen2026-05-27 15:49 UTC1281
        .unwrap();
c19702ae09beatagen2026-05-27 15:49 UTC1282
        state.apply_event(ev);
c19702ae09beatagen2026-05-27 15:49 UTC1283
        assert_eq!(state.overflow_total, 5);
c19702ae09beatagen2026-05-27 15:49 UTC1284
    }
c19702ae09beatagen2026-05-27 15:49 UTC1285
c19702ae09beatagen2026-05-27 15:49 UTC1286
    fn pinfo(name: &str, active: bool) -> headroom_ipc::ProfileInfo {
c19702ae09beatagen2026-05-27 15:49 UTC1287
        headroom_ipc::ProfileInfo {
c19702ae09beatagen2026-05-27 15:49 UTC1288
            name: name.into(),
c19702ae09beatagen2026-05-27 15:49 UTC1289
            active,
c19702ae09beatagen2026-05-27 15:49 UTC1290
            description: format!("{name} desc"),
c19702ae09beatagen2026-05-27 15:49 UTC1291
        }
c19702ae09beatagen2026-05-27 15:49 UTC1292
    }
c19702ae09beatagen2026-05-27 15:49 UTC1293
c19702ae09beatagen2026-05-27 15:49 UTC1294
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1295
    fn picker_initial_selection_prefers_active() {
c19702ae09beatagen2026-05-27 15:49 UTC1296
        let profiles = vec![
c19702ae09beatagen2026-05-27 15:49 UTC1297
            pinfo("default", false),
c19702ae09beatagen2026-05-27 15:49 UTC1298
            pinfo("night", true),
c19702ae09beatagen2026-05-27 15:49 UTC1299
            pinfo("speech", false),
c19702ae09beatagen2026-05-27 15:49 UTC1300
        ];
c19702ae09beatagen2026-05-27 15:49 UTC1301
        assert_eq!(initial_picker_selection(&profiles, "default"), 1);
c19702ae09beatagen2026-05-27 15:49 UTC1302
    }
c19702ae09beatagen2026-05-27 15:49 UTC1303
c19702ae09beatagen2026-05-27 15:49 UTC1304
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1305
    fn picker_initial_selection_falls_back_to_current_name_then_top() {
c19702ae09beatagen2026-05-27 15:49 UTC1306
        let profiles = vec![pinfo("default", false), pinfo("night", false)];
c19702ae09beatagen2026-05-27 15:49 UTC1307
        // no active flag: match the daemon's reported current name.
c19702ae09beatagen2026-05-27 15:49 UTC1308
        assert_eq!(initial_picker_selection(&profiles, "night"), 1);
c19702ae09beatagen2026-05-27 15:49 UTC1309
        // neither active nor a name match: default to the top.
c19702ae09beatagen2026-05-27 15:49 UTC1310
        assert_eq!(initial_picker_selection(&profiles, "ghost"), 0);
c19702ae09beatagen2026-05-27 15:49 UTC1311
    }
c19702ae09beatagen2026-05-27 15:49 UTC1312
c19702ae09beatagen2026-05-27 15:49 UTC1313
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1314
    fn picker_move_sel_wraps_both_ways() {
c19702ae09beatagen2026-05-27 15:49 UTC1315
        let mut p = ProfilePicker {
c19702ae09beatagen2026-05-27 15:49 UTC1316
            profiles: vec![pinfo("a", false), pinfo("b", false), pinfo("c", false)],
c19702ae09beatagen2026-05-27 15:49 UTC1317
            selected: 0,
c19702ae09beatagen2026-05-27 15:49 UTC1318
        };
c19702ae09beatagen2026-05-27 15:49 UTC1319
        p.move_sel(-1);
c19702ae09beatagen2026-05-27 15:49 UTC1320
        assert_eq!(p.selected, 2); // wrap up from top
c19702ae09beatagen2026-05-27 15:49 UTC1321
        p.move_sel(1);
c19702ae09beatagen2026-05-27 15:49 UTC1322
        assert_eq!(p.selected, 0); // wrap down from bottom
c19702ae09beatagen2026-05-27 15:49 UTC1323
        p.move_sel(2);
c19702ae09beatagen2026-05-27 15:49 UTC1324
        assert_eq!(p.selected, 2);
c19702ae09beatagen2026-05-27 15:49 UTC1325
    }
c19702ae09beatagen2026-05-27 15:49 UTC1326
c19702ae09beatagen2026-05-27 15:49 UTC1327
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1328
    fn fmt_uptime_buckets() {
c19702ae09beatagen2026-05-27 15:49 UTC1329
        assert_eq!(fmt_uptime(5), "5s");
c19702ae09beatagen2026-05-27 15:49 UTC1330
        assert_eq!(fmt_uptime(75), "1m15s");
c19702ae09beatagen2026-05-27 15:49 UTC1331
        assert_eq!(fmt_uptime(3725), "1h02m05s");
c19702ae09beatagen2026-05-27 15:49 UTC1332
    }
c19702ae09beatagen2026-05-27 15:49 UTC1333
}