atagen

headroom

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

Blame: crates/headroom-gui/src/app.rs

c19702ae09beatagen2026-05-27 15:49 UTC1
//! `HeadroomApp` — iced application state + view; mirrors the tui's
c19702ae09beatagen2026-05-27 15:49 UTC2
//! read-only `UiState` (`crates/headroom-cli/src/tui.rs`).
c19702ae09beatagen2026-05-27 15:49 UTC3
//!
c19702ae09beatagen2026-05-27 15:49 UTC4
//! control ops never block the ui thread: sent over `cmd_tx` to the
c19702ae09beatagen2026-05-27 15:49 UTC5
//! control thread (`io.rs`), which replies over the same `rx` the ui
c19702ae09beatagen2026-05-27 15:49 UTC6
//! drains each tick. ui updates optimistically; daemon events + the
c19702ae09beatagen2026-05-27 15:49 UTC7
//! ~1 Hz `per-app.list` refresh reconcile.
c19702ae09beatagen2026-05-27 15:49 UTC8
c19702ae09beatagen2026-05-27 15:49 UTC9
use std::collections::BTreeMap;
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::{Receiver, Sender};
c19702ae09beatagen2026-05-27 15:49 UTC13
use iced::alignment::Horizontal;
c19702ae09beatagen2026-05-27 15:49 UTC14
use iced::widget::{button, column, container, mouse_area, pick_list, progress_bar, row, scrollable, text};
c19702ae09beatagen2026-05-27 15:49 UTC15
use iced::{keyboard, time, Alignment, Background, Border, Color, Element, Length, Subscription};
c19702ae09beatagen2026-05-27 15:49 UTC16
c19702ae09beatagen2026-05-27 15:49 UTC17
use headroom_ipc::{
c19702ae09beatagen2026-05-27 15:49 UTC18
    DaemonEvent, Event, LayerALevel, LayerASnapshot, MeterTick, ProfileInfo, Route, RoutingEvent,
c19702ae09beatagen2026-05-27 15:49 UTC19
    StreamRoute, Topic,
c19702ae09beatagen2026-05-27 15:49 UTC20
};
c19702ae09beatagen2026-05-27 15:49 UTC21
c19702ae09beatagen2026-05-27 15:49 UTC22
use crate::io::{AppMsg, Bootstrap, ControlCmd, Snapshot};
c19702ae09beatagen2026-05-27 15:49 UTC23
c19702ae09beatagen2026-05-27 15:49 UTC24
/// loudness at/below this floor renders as "--". mirrors
c19702ae09beatagen2026-05-27 15:49 UTC25
/// `headroom_core::agc::LOUDNESS_FLOOR_LUFS`, inlined so the gui doesn't
c19702ae09beatagen2026-05-27 15:49 UTC26
/// pull in the audio/dsp crate.
c19702ae09beatagen2026-05-27 15:49 UTC27
const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
c19702ae09beatagen2026-05-27 15:49 UTC28
c19702ae09beatagen2026-05-27 15:49 UTC29
/// meters older than this are dimmed (source went silent / suspended).
c19702ae09beatagen2026-05-27 15:49 UTC30
const STALE_AFTER: Duration = Duration::from_millis(500);
c19702ae09beatagen2026-05-27 15:49 UTC31
c19702ae09beatagen2026-05-27 15:49 UTC32
/// ui drain + repaint interval (~20 Hz); also advances uptime/staleness
c19702ae09beatagen2026-05-27 15:49 UTC33
/// text with no events flowing.
c19702ae09beatagen2026-05-27 15:49 UTC34
const POLL_INTERVAL: Duration = Duration::from_millis(50);
c19702ae09beatagen2026-05-27 15:49 UTC35
c19702ae09beatagen2026-05-27 15:49 UTC36
// palette — explicit colors mirroring the tui's intent.
c19702ae09beatagen2026-05-27 15:49 UTC37
const BG: u32 = 0x1e1e1e;
c19702ae09beatagen2026-05-27 15:49 UTC38
const PANEL_BG: u32 = 0x252526;
c19702ae09beatagen2026-05-27 15:49 UTC39
const TRACK_BG: u32 = 0x3a3a3a;
c19702ae09beatagen2026-05-27 15:49 UTC40
const FG: u32 = 0xd4d4d4;
c19702ae09beatagen2026-05-27 15:49 UTC41
const DIM: u32 = 0x808080;
c19702ae09beatagen2026-05-27 15:49 UTC42
const CYAN: u32 = 0x4ec9b0;
c19702ae09beatagen2026-05-27 15:49 UTC43
const MAGENTA: u32 = 0xc586c0;
c19702ae09beatagen2026-05-27 15:49 UTC44
const RED: u32 = 0xf44747;
c19702ae09beatagen2026-05-27 15:49 UTC45
const GREEN: u32 = 0x6a9955;
c19702ae09beatagen2026-05-27 15:49 UTC46
const YELLOW: u32 = 0xdcdcaa;
c19702ae09beatagen2026-05-27 15:49 UTC47
const BLACK: u32 = 0x101010;
c19702ae09beatagen2026-05-27 15:49 UTC48
/// tint behind the selected stream row.
c19702ae09beatagen2026-05-27 15:49 UTC49
const SEL_BG: u32 = 0x094771;
c19702ae09beatagen2026-05-27 15:49 UTC50
/// hover/press tint for header + row buttons.
c19702ae09beatagen2026-05-27 15:49 UTC51
const HOVER_BG: u32 = 0x37373a;
c19702ae09beatagen2026-05-27 15:49 UTC52
c19702ae09beatagen2026-05-27 15:49 UTC53
/// poll ticks between `per-app.list` refreshes — ~1 Hz at [`POLL_INTERVAL`].
c19702ae09beatagen2026-05-27 15:49 UTC54
const REFRESH_EVERY_TICKS: u32 = 20;
c19702ae09beatagen2026-05-27 15:49 UTC55
c19702ae09beatagen2026-05-27 15:49 UTC56
/// poll ticks between `profile.list` refreshes — ~10 s. the profile *set*
c19702ae09beatagen2026-05-27 15:49 UTC57
/// only changes on `profile reload`; the active profile updates instantly
c19702ae09beatagen2026-05-27 15:49 UTC58
/// via `profile.changed`.
c19702ae09beatagen2026-05-27 15:49 UTC59
const REFRESH_PROFILES_EVERY_TICKS: u32 = 200;
c19702ae09beatagen2026-05-27 15:49 UTC60
c19702ae09beatagen2026-05-27 15:49 UTC61
#[derive(Debug, Clone)]
c19702ae09beatagen2026-05-27 15:49 UTC62
pub enum Message {
c19702ae09beatagen2026-05-27 15:49 UTC63
    /// poll tick: drain `rx`, repaint, periodically refresh.
c19702ae09beatagen2026-05-27 15:49 UTC64
    Tick,
c19702ae09beatagen2026-05-27 15:49 UTC65
    /// a keypress / event we don't act on.
c19702ae09beatagen2026-05-27 15:49 UTC66
    Noop,
c19702ae09beatagen2026-05-27 15:49 UTC67
    /// select a stream row by node id (mouse click).
c19702ae09beatagen2026-05-27 15:49 UTC68
    Select(u32),
c19702ae09beatagen2026-05-27 15:49 UTC69
    /// move the selection up / down one row (k/↑, j/↓).
c19702ae09beatagen2026-05-27 15:49 UTC70
    SelectUp,
c19702ae09beatagen2026-05-27 15:49 UTC71
    SelectDown,
c19702ae09beatagen2026-05-27 15:49 UTC72
    ToggleBypass,
c19702ae09beatagen2026-05-27 15:49 UTC73
    TogglePerAppMaster,
c19702ae09beatagen2026-05-27 15:49 UTC74
    /// toggle a stream's route (processed ↔ bypass).
c19702ae09beatagen2026-05-27 15:49 UTC75
    ToggleRoute(u32),
c19702ae09beatagen2026-05-27 15:49 UTC76
    /// toggle per-app management for a stream's app.
c19702ae09beatagen2026-05-27 15:49 UTC77
    TogglePerApp(u32),
c19702ae09beatagen2026-05-27 15:49 UTC78
    /// reset a stream's layer a deference.
c19702ae09beatagen2026-05-27 15:49 UTC79
    ResetLayerA(u32),
c19702ae09beatagen2026-05-27 15:49 UTC80
    /// hotkey variants acting on the current selection.
c19702ae09beatagen2026-05-27 15:49 UTC81
    RouteSelected,
c19702ae09beatagen2026-05-27 15:49 UTC82
    PerAppSelected,
c19702ae09beatagen2026-05-27 15:49 UTC83
    ResetSelected,
c19702ae09beatagen2026-05-27 15:49 UTC84
    /// switch to the named profile (pick_list).
c19702ae09beatagen2026-05-27 15:49 UTC85
    ProfileSelected(String),
c19702ae09beatagen2026-05-27 15:49 UTC86
    /// clear all overlay setting overrides.
c19702ae09beatagen2026-05-27 15:49 UTC87
    ClearOverrides,
c19702ae09beatagen2026-05-27 15:49 UTC88
}
c19702ae09beatagen2026-05-27 15:49 UTC89
c19702ae09beatagen2026-05-27 15:49 UTC90
/// mirrored daemon snapshot plus the live event channel it drains.
c19702ae09beatagen2026-05-27 15:49 UTC91
pub struct HeadroomApp {
c19702ae09beatagen2026-05-27 15:49 UTC92
    daemon_version: String,
c19702ae09beatagen2026-05-27 15:49 UTC93
    profile: String,
c19702ae09beatagen2026-05-27 15:49 UTC94
    bypass: bool,
c19702ae09beatagen2026-05-27 15:49 UTC95
    /// layer a master switch (per-app level control enabled globally).
c19702ae09beatagen2026-05-27 15:49 UTC96
    per_app_master: bool,
c19702ae09beatagen2026-05-27 15:49 UTC97
    /// overlay overrides (dotted key → value) shadowing the profile;
c19702ae09beatagen2026-05-27 15:49 UTC98
    /// surfaced so a stale `headroom set` isn't invisible.
c19702ae09beatagen2026-05-27 15:49 UTC99
    setting_overrides: BTreeMap<String, serde_json::Value>,
c19702ae09beatagen2026-05-27 15:49 UTC100
    /// daemon uptime at connect, plus local elapsed.
c19702ae09beatagen2026-05-27 15:49 UTC101
    base_uptime_s: u64,
c19702ae09beatagen2026-05-27 15:49 UTC102
    connected_at: Instant,
c19702ae09beatagen2026-05-27 15:49 UTC103
    default_route: Route,
c19702ae09beatagen2026-05-27 15:49 UTC104
    streams: BTreeMap<u32, StreamRoute>,
c19702ae09beatagen2026-05-27 15:49 UTC105
    /// per-stream layer a reduction (dB). presence = managed; inner `None`
c19702ae09beatagen2026-05-27 15:49 UTC106
    /// until the first `layer_a_level`.
c19702ae09beatagen2026-05-27 15:49 UTC107
    layer_a: BTreeMap<u32, Option<f32>>,
c19702ae09beatagen2026-05-27 15:49 UTC108
    /// richer per-stream snapshots (managed/ceiling/deferred), polled from
c19702ae09beatagen2026-05-27 15:49 UTC109
    /// `per-app.list`; drives the per-app toggle target + row label.
c19702ae09beatagen2026-05-27 15:49 UTC110
    la_snapshots: BTreeMap<u32, LayerASnapshot>,
c19702ae09beatagen2026-05-27 15:49 UTC111
    /// profiles for the switcher (seeded at connect).
c19702ae09beatagen2026-05-27 15:49 UTC112
    profiles: Vec<ProfileInfo>,
c19702ae09beatagen2026-05-27 15:49 UTC113
    /// selected stream node id; resolved via [`Self::effective_selection`].
c19702ae09beatagen2026-05-27 15:49 UTC114
    selected: Option<u32>,
c19702ae09beatagen2026-05-27 15:49 UTC115
    meters: Option<MeterTick>,
c19702ae09beatagen2026-05-27 15:49 UTC116
    last_meter_at: Option<Instant>,
c19702ae09beatagen2026-05-27 15:49 UTC117
    overflow_total: u64,
c19702ae09beatagen2026-05-27 15:49 UTC118
    last_error: Option<String>,
c19702ae09beatagen2026-05-27 15:49 UTC119
    disconnected: Option<String>,
c19702ae09beatagen2026-05-27 15:49 UTC120
    /// live event + worker stream from the reader / control threads.
c19702ae09beatagen2026-05-27 15:49 UTC121
    rx: Receiver<AppMsg>,
c19702ae09beatagen2026-05-27 15:49 UTC122
    /// hands control ops to the control thread.
c19702ae09beatagen2026-05-27 15:49 UTC123
    cmd_tx: Sender<ControlCmd>,
c19702ae09beatagen2026-05-27 15:49 UTC124
    /// poll-tick counter for the paced `per-app.list` refresh.
c19702ae09beatagen2026-05-27 15:49 UTC125
    tick_count: u32,
c19702ae09beatagen2026-05-27 15:49 UTC126
}
c19702ae09beatagen2026-05-27 15:49 UTC127
c19702ae09beatagen2026-05-27 15:49 UTC128
impl HeadroomApp {
c19702ae09beatagen2026-05-27 15:49 UTC129
    /// seed initial state from the snapshot, take ownership of the channel.
c19702ae09beatagen2026-05-27 15:49 UTC130
    pub fn new(boot: Bootstrap) -> Self {
c19702ae09beatagen2026-05-27 15:49 UTC131
        let Bootstrap {
c19702ae09beatagen2026-05-27 15:49 UTC132
            snapshot,
c19702ae09beatagen2026-05-27 15:49 UTC133
            rx,
c19702ae09beatagen2026-05-27 15:49 UTC134
            cmd_tx,
c19702ae09beatagen2026-05-27 15:49 UTC135
        } = boot;
c19702ae09beatagen2026-05-27 15:49 UTC136
c19702ae09beatagen2026-05-27 15:49 UTC137
        let mut app = Self {
c19702ae09beatagen2026-05-27 15:49 UTC138
            daemon_version: String::new(),
c19702ae09beatagen2026-05-27 15:49 UTC139
            profile: String::new(),
c19702ae09beatagen2026-05-27 15:49 UTC140
            bypass: false,
c19702ae09beatagen2026-05-27 15:49 UTC141
            per_app_master: false,
c19702ae09beatagen2026-05-27 15:49 UTC142
            setting_overrides: BTreeMap::new(),
c19702ae09beatagen2026-05-27 15:49 UTC143
            base_uptime_s: 0,
c19702ae09beatagen2026-05-27 15:49 UTC144
            connected_at: Instant::now(),
c19702ae09beatagen2026-05-27 15:49 UTC145
            default_route: Route::Processed,
c19702ae09beatagen2026-05-27 15:49 UTC146
            streams: BTreeMap::new(),
c19702ae09beatagen2026-05-27 15:49 UTC147
            layer_a: BTreeMap::new(),
c19702ae09beatagen2026-05-27 15:49 UTC148
            la_snapshots: BTreeMap::new(),
c19702ae09beatagen2026-05-27 15:49 UTC149
            profiles: Vec::new(),
c19702ae09beatagen2026-05-27 15:49 UTC150
            selected: None,
c19702ae09beatagen2026-05-27 15:49 UTC151
            meters: None,
c19702ae09beatagen2026-05-27 15:49 UTC152
            last_meter_at: None,
c19702ae09beatagen2026-05-27 15:49 UTC153
            overflow_total: 0,
c19702ae09beatagen2026-05-27 15:49 UTC154
            last_error: None,
c19702ae09beatagen2026-05-27 15:49 UTC155
            disconnected: None,
c19702ae09beatagen2026-05-27 15:49 UTC156
            rx,
c19702ae09beatagen2026-05-27 15:49 UTC157
            cmd_tx,
c19702ae09beatagen2026-05-27 15:49 UTC158
            tick_count: 0,
c19702ae09beatagen2026-05-27 15:49 UTC159
        };
c19702ae09beatagen2026-05-27 15:49 UTC160
        app.apply_snapshot(snapshot);
c19702ae09beatagen2026-05-27 15:49 UTC161
        // initial selection: first stream row (reconnects preserve the
c19702ae09beatagen2026-05-27 15:49 UTC162
        // user's selection, so this only runs at startup).
c19702ae09beatagen2026-05-27 15:49 UTC163
        app.selected = app.streams.keys().next().copied();
c19702ae09beatagen2026-05-27 15:49 UTC164
        app
c19702ae09beatagen2026-05-27 15:49 UTC165
    }
c19702ae09beatagen2026-05-27 15:49 UTC166
c19702ae09beatagen2026-05-27 15:49 UTC167
    /// replace mirrored daemon state from a fresh [`Snapshot`]. shared by
c19702ae09beatagen2026-05-27 15:49 UTC168
    /// [`Self::new`] + [`Self::reseed`]; leaves `selected` / channels /
c19702ae09beatagen2026-05-27 15:49 UTC169
    /// tick counter to the caller.
c19702ae09beatagen2026-05-27 15:49 UTC170
    fn apply_snapshot(&mut self, snapshot: Snapshot) {
c19702ae09beatagen2026-05-27 15:49 UTC171
        let Snapshot {
c19702ae09beatagen2026-05-27 15:49 UTC172
            status,
c19702ae09beatagen2026-05-27 15:49 UTC173
            route_list,
c19702ae09beatagen2026-05-27 15:49 UTC174
            profiles,
c19702ae09beatagen2026-05-27 15:49 UTC175
        } = snapshot;
c19702ae09beatagen2026-05-27 15:49 UTC176
c19702ae09beatagen2026-05-27 15:49 UTC177
        let mut streams = BTreeMap::new();
c19702ae09beatagen2026-05-27 15:49 UTC178
        for s in route_list.current {
c19702ae09beatagen2026-05-27 15:49 UTC179
            streams.insert(s.node_id, s);
c19702ae09beatagen2026-05-27 15:49 UTC180
        }
c19702ae09beatagen2026-05-27 15:49 UTC181
        // `status.streams` is a superset; merge any not already present.
c19702ae09beatagen2026-05-27 15:49 UTC182
        for s in status.streams.iter() {
c19702ae09beatagen2026-05-27 15:49 UTC183
            streams.entry(s.node_id).or_insert_with(|| s.clone());
c19702ae09beatagen2026-05-27 15:49 UTC184
        }
c19702ae09beatagen2026-05-27 15:49 UTC185
        // seed meter column + richer snapshots from status (mirrors tui).
c19702ae09beatagen2026-05-27 15:49 UTC186
        let mut layer_a = BTreeMap::new();
c19702ae09beatagen2026-05-27 15:49 UTC187
        let mut la_snapshots = BTreeMap::new();
c19702ae09beatagen2026-05-27 15:49 UTC188
        for snap in status.layer_a {
c19702ae09beatagen2026-05-27 15:49 UTC189
            layer_a.insert(snap.node_id, Some(snap.reduction_db));
c19702ae09beatagen2026-05-27 15:49 UTC190
            la_snapshots.insert(snap.node_id, snap);
c19702ae09beatagen2026-05-27 15:49 UTC191
        }
c19702ae09beatagen2026-05-27 15:49 UTC192
c19702ae09beatagen2026-05-27 15:49 UTC193
        self.daemon_version = status.version;
c19702ae09beatagen2026-05-27 15:49 UTC194
        self.profile = status.profile;
c19702ae09beatagen2026-05-27 15:49 UTC195
        self.bypass = status.bypass;
c19702ae09beatagen2026-05-27 15:49 UTC196
        self.per_app_master = status.per_app;
c19702ae09beatagen2026-05-27 15:49 UTC197
        self.setting_overrides = status.setting_overrides;
c19702ae09beatagen2026-05-27 15:49 UTC198
        self.base_uptime_s = status.uptime_s;
c19702ae09beatagen2026-05-27 15:49 UTC199
        self.connected_at = Instant::now();
c19702ae09beatagen2026-05-27 15:49 UTC200
        self.default_route = route_list.default_route;
c19702ae09beatagen2026-05-27 15:49 UTC201
        self.streams = streams;
c19702ae09beatagen2026-05-27 15:49 UTC202
        self.layer_a = layer_a;
c19702ae09beatagen2026-05-27 15:49 UTC203
        self.la_snapshots = la_snapshots;
c19702ae09beatagen2026-05-27 15:49 UTC204
        self.profiles = profiles;
c19702ae09beatagen2026-05-27 15:49 UTC205
    }
c19702ae09beatagen2026-05-27 15:49 UTC206
c19702ae09beatagen2026-05-27 15:49 UTC207
    /// re-seed after the reader reconnected to a (possibly new) daemon.
c19702ae09beatagen2026-05-27 15:49 UTC208
    /// clears the disconnect/error banners + per-instance overflow counter
c19702ae09beatagen2026-05-27 15:49 UTC209
    /// (a restarted daemon resets its drop count). `selected` is preserved —
c19702ae09beatagen2026-05-27 15:49 UTC210
    /// `effective_selection` reconciles it against the new stream set.
c19702ae09beatagen2026-05-27 15:49 UTC211
    fn reseed(&mut self, snapshot: Snapshot) {
c19702ae09beatagen2026-05-27 15:49 UTC212
        self.apply_snapshot(snapshot);
c19702ae09beatagen2026-05-27 15:49 UTC213
        self.overflow_total = 0;
c19702ae09beatagen2026-05-27 15:49 UTC214
        self.last_error = None;
c19702ae09beatagen2026-05-27 15:49 UTC215
        self.disconnected = None;
c19702ae09beatagen2026-05-27 15:49 UTC216
        self.last_meter_at = None;
c19702ae09beatagen2026-05-27 15:49 UTC217
    }
c19702ae09beatagen2026-05-27 15:49 UTC218
c19702ae09beatagen2026-05-27 15:49 UTC219
    pub fn title(&self) -> String {
c19702ae09beatagen2026-05-27 15:49 UTC220
        "headroom monitor".to_string()
c19702ae09beatagen2026-05-27 15:49 UTC221
    }
c19702ae09beatagen2026-05-27 15:49 UTC222
c19702ae09beatagen2026-05-27 15:49 UTC223
    /// poll tick (`smol` backs `time::every`) + global keyboard input.
c19702ae09beatagen2026-05-27 15:49 UTC224
    /// `keyboard::listen` only surfaces keys not consumed by a focused
c19702ae09beatagen2026-05-27 15:49 UTC225
    /// widget, so the pick_list keeps its own arrow/enter handling.
c19702ae09beatagen2026-05-27 15:49 UTC226
    pub fn subscription(&self) -> Subscription<Message> {
c19702ae09beatagen2026-05-27 15:49 UTC227
        Subscription::batch([
c19702ae09beatagen2026-05-27 15:49 UTC228
            time::every(POLL_INTERVAL).map(|_| Message::Tick),
c19702ae09beatagen2026-05-27 15:49 UTC229
            keyboard::listen().map(map_key),
c19702ae09beatagen2026-05-27 15:49 UTC230
        ])
c19702ae09beatagen2026-05-27 15:49 UTC231
    }
c19702ae09beatagen2026-05-27 15:49 UTC232
c19702ae09beatagen2026-05-27 15:49 UTC233
    pub fn update(&mut self, message: Message) {
c19702ae09beatagen2026-05-27 15:49 UTC234
        match message {
c19702ae09beatagen2026-05-27 15:49 UTC235
            Message::Noop => {}
c19702ae09beatagen2026-05-27 15:49 UTC236
            Message::Tick => {
c19702ae09beatagen2026-05-27 15:49 UTC237
                self.pump();
c19702ae09beatagen2026-05-27 15:49 UTC238
                self.tick_count = self.tick_count.wrapping_add(1);
c19702ae09beatagen2026-05-27 15:49 UTC239
                if self.tick_count.is_multiple_of(REFRESH_EVERY_TICKS) {
c19702ae09beatagen2026-05-27 15:49 UTC240
                    // reconcile managed/ceiling/deferred state the event
c19702ae09beatagen2026-05-27 15:49 UTC241
                    // stream doesn't push (mirrors the tui's poll).
c19702ae09beatagen2026-05-27 15:49 UTC242
                    self.send_cmd(ControlCmd::RefreshLayerA);
c19702ae09beatagen2026-05-27 15:49 UTC243
                }
c19702ae09beatagen2026-05-27 15:49 UTC244
                if self.tick_count.is_multiple_of(REFRESH_PROFILES_EVERY_TICKS) {
c19702ae09beatagen2026-05-27 15:49 UTC245
                    // pick up profiles added/removed by `profile reload`.
c19702ae09beatagen2026-05-27 15:49 UTC246
                    self.send_cmd(ControlCmd::RefreshProfiles);
c19702ae09beatagen2026-05-27 15:49 UTC247
                }
c19702ae09beatagen2026-05-27 15:49 UTC248
            }
c19702ae09beatagen2026-05-27 15:49 UTC249
            Message::Select(id) => self.selected = Some(id),
c19702ae09beatagen2026-05-27 15:49 UTC250
            Message::SelectUp => self.move_selection(-1),
c19702ae09beatagen2026-05-27 15:49 UTC251
            Message::SelectDown => self.move_selection(1),
c19702ae09beatagen2026-05-27 15:49 UTC252
            Message::ToggleBypass => {
c19702ae09beatagen2026-05-27 15:49 UTC253
                let target = !self.bypass;
c19702ae09beatagen2026-05-27 15:49 UTC254
                self.send_cmd(ControlCmd::SetBypass(target));
c19702ae09beatagen2026-05-27 15:49 UTC255
                self.bypass = target; // optimistic; no bypass event from daemon
c19702ae09beatagen2026-05-27 15:49 UTC256
            }
c19702ae09beatagen2026-05-27 15:49 UTC257
            Message::TogglePerAppMaster => {
c19702ae09beatagen2026-05-27 15:49 UTC258
                let target = !self.per_app_master;
c19702ae09beatagen2026-05-27 15:49 UTC259
                self.send_cmd(ControlCmd::SetPerAppMaster(target));
c19702ae09beatagen2026-05-27 15:49 UTC260
                self.per_app_master = target;
c19702ae09beatagen2026-05-27 15:49 UTC261
            }
c19702ae09beatagen2026-05-27 15:49 UTC262
            Message::ToggleRoute(id) => self.toggle_route(id),
c19702ae09beatagen2026-05-27 15:49 UTC263
            Message::TogglePerApp(id) => self.toggle_per_app(id),
c19702ae09beatagen2026-05-27 15:49 UTC264
            Message::ResetLayerA(id) => self.reset_layer_a(id),
c19702ae09beatagen2026-05-27 15:49 UTC265
            Message::RouteSelected => {
c19702ae09beatagen2026-05-27 15:49 UTC266
                if let Some(id) = self.effective_selection() {
c19702ae09beatagen2026-05-27 15:49 UTC267
                    self.toggle_route(id);
c19702ae09beatagen2026-05-27 15:49 UTC268
                }
c19702ae09beatagen2026-05-27 15:49 UTC269
            }
c19702ae09beatagen2026-05-27 15:49 UTC270
            Message::PerAppSelected => {
c19702ae09beatagen2026-05-27 15:49 UTC271
                if let Some(id) = self.effective_selection() {
c19702ae09beatagen2026-05-27 15:49 UTC272
                    self.toggle_per_app(id);
c19702ae09beatagen2026-05-27 15:49 UTC273
                }
c19702ae09beatagen2026-05-27 15:49 UTC274
            }
c19702ae09beatagen2026-05-27 15:49 UTC275
            Message::ResetSelected => {
c19702ae09beatagen2026-05-27 15:49 UTC276
                if let Some(id) = self.effective_selection() {
c19702ae09beatagen2026-05-27 15:49 UTC277
                    self.reset_layer_a(id);
c19702ae09beatagen2026-05-27 15:49 UTC278
                }
c19702ae09beatagen2026-05-27 15:49 UTC279
            }
c19702ae09beatagen2026-05-27 15:49 UTC280
            Message::ProfileSelected(name) => {
c19702ae09beatagen2026-05-27 15:49 UTC281
                // optimistic: update the header now; the daemon's "used"
c19702ae09beatagen2026-05-27 15:49 UTC282
                // event reconciles a frame or two later (mirrors the tui).
c19702ae09beatagen2026-05-27 15:49 UTC283
                self.profile = name.clone();
c19702ae09beatagen2026-05-27 15:49 UTC284
                self.send_cmd(ControlCmd::UseProfile(name));
c19702ae09beatagen2026-05-27 15:49 UTC285
            }
c19702ae09beatagen2026-05-27 15:49 UTC286
            Message::ClearOverrides => {
c19702ae09beatagen2026-05-27 15:49 UTC287
                // optimistic: drop the indicator now; the daemon clears the
c19702ae09beatagen2026-05-27 15:49 UTC288
                // overlay and reapplies the profile's values.
c19702ae09beatagen2026-05-27 15:49 UTC289
                self.setting_overrides.clear();
c19702ae09beatagen2026-05-27 15:49 UTC290
                self.send_cmd(ControlCmd::ClearOverrides);
c19702ae09beatagen2026-05-27 15:49 UTC291
            }
c19702ae09beatagen2026-05-27 15:49 UTC292
        }
c19702ae09beatagen2026-05-27 15:49 UTC293
    }
c19702ae09beatagen2026-05-27 15:49 UTC294
c19702ae09beatagen2026-05-27 15:49 UTC295
    /// drain + apply every queued worker message.
c19702ae09beatagen2026-05-27 15:49 UTC296
    fn pump(&mut self) {
c19702ae09beatagen2026-05-27 15:49 UTC297
        while let Ok(msg) = self.rx.try_recv() {
c19702ae09beatagen2026-05-27 15:49 UTC298
            self.handle_msg(msg);
c19702ae09beatagen2026-05-27 15:49 UTC299
        }
c19702ae09beatagen2026-05-27 15:49 UTC300
    }
c19702ae09beatagen2026-05-27 15:49 UTC301
c19702ae09beatagen2026-05-27 15:49 UTC302
    fn handle_msg(&mut self, msg: AppMsg) {
c19702ae09beatagen2026-05-27 15:49 UTC303
        match msg {
c19702ae09beatagen2026-05-27 15:49 UTC304
            AppMsg::Event(ev) => self.apply_event(ev),
c19702ae09beatagen2026-05-27 15:49 UTC305
            AppMsg::Disconnected(reason) => self.disconnected = Some(reason),
c19702ae09beatagen2026-05-27 15:49 UTC306
            AppMsg::Reconnected(snapshot) => self.reseed(*snapshot),
c19702ae09beatagen2026-05-27 15:49 UTC307
            AppMsg::ControlError(e) => self.last_error = Some(e),
c19702ae09beatagen2026-05-27 15:49 UTC308
            AppMsg::Profiles(p) => {
c19702ae09beatagen2026-05-27 15:49 UTC309
                // reconcile the displayed profile from the `active` flag —
c19702ae09beatagen2026-05-27 15:49 UTC310
                // covers external `profile use` even if "used" was missed.
c19702ae09beatagen2026-05-27 15:49 UTC311
                if let Some(active) = p.iter().find(|x| x.active) {
c19702ae09beatagen2026-05-27 15:49 UTC312
                    self.profile = active.name.clone();
c19702ae09beatagen2026-05-27 15:49 UTC313
                }
c19702ae09beatagen2026-05-27 15:49 UTC314
                self.profiles = p;
c19702ae09beatagen2026-05-27 15:49 UTC315
            }
c19702ae09beatagen2026-05-27 15:49 UTC316
            AppMsg::LayerASnapshots(list) => {
c19702ae09beatagen2026-05-27 15:49 UTC317
                self.la_snapshots = list.into_iter().map(|s| (s.node_id, s)).collect();
c19702ae09beatagen2026-05-27 15:49 UTC318
            }
c19702ae09beatagen2026-05-27 15:49 UTC319
        }
c19702ae09beatagen2026-05-27 15:49 UTC320
    }
c19702ae09beatagen2026-05-27 15:49 UTC321
c19702ae09beatagen2026-05-27 15:49 UTC322
    /// queue a control op; a dead control thread lands in the footer.
c19702ae09beatagen2026-05-27 15:49 UTC323
    fn send_cmd(&mut self, cmd: ControlCmd) {
c19702ae09beatagen2026-05-27 15:49 UTC324
        if self.cmd_tx.send(cmd).is_err() {
c19702ae09beatagen2026-05-27 15:49 UTC325
            self.last_error = Some("control thread gone".into());
c19702ae09beatagen2026-05-27 15:49 UTC326
        }
c19702ae09beatagen2026-05-27 15:49 UTC327
    }
c19702ae09beatagen2026-05-27 15:49 UTC328
c19702ae09beatagen2026-05-27 15:49 UTC329
    /// stream node ids in row order (`BTreeMap` keys).
c19702ae09beatagen2026-05-27 15:49 UTC330
    fn ordered_nodes(&self) -> Vec<u32> {
c19702ae09beatagen2026-05-27 15:49 UTC331
        self.streams.keys().copied().collect()
c19702ae09beatagen2026-05-27 15:49 UTC332
    }
c19702ae09beatagen2026-05-27 15:49 UTC333
c19702ae09beatagen2026-05-27 15:49 UTC334
    /// `selected` when still live, else the first row, else `None`.
c19702ae09beatagen2026-05-27 15:49 UTC335
    fn effective_selection(&self) -> Option<u32> {
c19702ae09beatagen2026-05-27 15:49 UTC336
        match self.selected {
c19702ae09beatagen2026-05-27 15:49 UTC337
            Some(id) if self.streams.contains_key(&id) => Some(id),
c19702ae09beatagen2026-05-27 15:49 UTC338
            _ => self.streams.keys().next().copied(),
c19702ae09beatagen2026-05-27 15:49 UTC339
        }
c19702ae09beatagen2026-05-27 15:49 UTC340
    }
c19702ae09beatagen2026-05-27 15:49 UTC341
c19702ae09beatagen2026-05-27 15:49 UTC342
    /// move the selection by `delta` rows (negative = up), wrapping.
c19702ae09beatagen2026-05-27 15:49 UTC343
    fn move_selection(&mut self, delta: isize) {
c19702ae09beatagen2026-05-27 15:49 UTC344
        let nodes = self.ordered_nodes();
c19702ae09beatagen2026-05-27 15:49 UTC345
        if nodes.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC346
            self.selected = None;
c19702ae09beatagen2026-05-27 15:49 UTC347
            return;
c19702ae09beatagen2026-05-27 15:49 UTC348
        }
c19702ae09beatagen2026-05-27 15:49 UTC349
        let cur = self
c19702ae09beatagen2026-05-27 15:49 UTC350
            .effective_selection()
c19702ae09beatagen2026-05-27 15:49 UTC351
            .and_then(|id| nodes.iter().position(|&n| n == id))
c19702ae09beatagen2026-05-27 15:49 UTC352
            .unwrap_or(0) as isize;
c19702ae09beatagen2026-05-27 15:49 UTC353
        let next = (cur + delta).rem_euclid(nodes.len() as isize) as usize;
c19702ae09beatagen2026-05-27 15:49 UTC354
        self.selected = Some(nodes[next]);
c19702ae09beatagen2026-05-27 15:49 UTC355
    }
c19702ae09beatagen2026-05-27 15:49 UTC356
c19702ae09beatagen2026-05-27 15:49 UTC357
    /// flip a stream's route, optimistically updating local state.
c19702ae09beatagen2026-05-27 15:49 UTC358
    ///
c19702ae09beatagen2026-05-27 15:49 UTC359
    /// prefer a *persistent* per-app rule (`route.set`, keyed by app label)
c19702ae09beatagen2026-05-27 15:49 UTC360
    /// so the route survives the app recreating its stream between tracks
c19702ae09beatagen2026-05-27 15:49 UTC361
    /// (Strawberry, mpv, …); fall back to a one-shot `route.stream` only
c19702ae09beatagen2026-05-27 15:49 UTC362
    /// when there's no label to key on. the optimistic update flips just
c19702ae09beatagen2026-05-27 15:49 UTC363
    /// this row; the daemon's reevaluate emits `stream_routed` for sibling
c19702ae09beatagen2026-05-27 15:49 UTC364
    /// streams of the same app, which `apply_event` reconciles.
c19702ae09beatagen2026-05-27 15:49 UTC365
    fn toggle_route(&mut self, node: u32) {
c19702ae09beatagen2026-05-27 15:49 UTC366
        let Some((app, cur)) = self.streams.get(&node).map(|s| (s.app.clone(), s.route)) else {
c19702ae09beatagen2026-05-27 15:49 UTC367
            return;
c19702ae09beatagen2026-05-27 15:49 UTC368
        };
c19702ae09beatagen2026-05-27 15:49 UTC369
        let to = match cur {
c19702ae09beatagen2026-05-27 15:49 UTC370
            Route::Processed => Route::Bypass,
c19702ae09beatagen2026-05-27 15:49 UTC371
            Route::Bypass => Route::Processed,
c19702ae09beatagen2026-05-27 15:49 UTC372
        };
c19702ae09beatagen2026-05-27 15:49 UTC373
        if app.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC374
            self.send_cmd(ControlCmd::RouteStream { node_id: node, to });
c19702ae09beatagen2026-05-27 15:49 UTC375
        } else {
c19702ae09beatagen2026-05-27 15:49 UTC376
            self.send_cmd(ControlCmd::SetRoute { app, to });
c19702ae09beatagen2026-05-27 15:49 UTC377
        }
c19702ae09beatagen2026-05-27 15:49 UTC378
        if let Some(s) = self.streams.get_mut(&node) {
c19702ae09beatagen2026-05-27 15:49 UTC379
            s.route = to;
c19702ae09beatagen2026-05-27 15:49 UTC380
        }
c19702ae09beatagen2026-05-27 15:49 UTC381
    }
c19702ae09beatagen2026-05-27 15:49 UTC382
c19702ae09beatagen2026-05-27 15:49 UTC383
    /// toggle per-app management for a stream's app. guards an empty
c19702ae09beatagen2026-05-27 15:49 UTC384
    /// label; reconciliation comes from the poll.
c19702ae09beatagen2026-05-27 15:49 UTC385
    fn toggle_per_app(&mut self, node: u32) {
c19702ae09beatagen2026-05-27 15:49 UTC386
        let Some(app) = self.streams.get(&node).map(|s| s.app.clone()) else {
c19702ae09beatagen2026-05-27 15:49 UTC387
            return;
c19702ae09beatagen2026-05-27 15:49 UTC388
        };
c19702ae09beatagen2026-05-27 15:49 UTC389
        if app.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC390
            self.last_error = Some("per-app: selected stream has no app label".into());
c19702ae09beatagen2026-05-27 15:49 UTC391
            return;
c19702ae09beatagen2026-05-27 15:49 UTC392
        }
c19702ae09beatagen2026-05-27 15:49 UTC393
        let managed = self.la_snapshots.get(&node).is_some_and(|s| s.managed);
c19702ae09beatagen2026-05-27 15:49 UTC394
        self.send_cmd(ControlCmd::SetPerApp {
c19702ae09beatagen2026-05-27 15:49 UTC395
            app,
c19702ae09beatagen2026-05-27 15:49 UTC396
            enabled: !managed,
c19702ae09beatagen2026-05-27 15:49 UTC397
        });
c19702ae09beatagen2026-05-27 15:49 UTC398
    }
c19702ae09beatagen2026-05-27 15:49 UTC399
c19702ae09beatagen2026-05-27 15:49 UTC400
    /// reset a stream's layer a deference.
c19702ae09beatagen2026-05-27 15:49 UTC401
    fn reset_layer_a(&mut self, node: u32) {
c19702ae09beatagen2026-05-27 15:49 UTC402
        self.send_cmd(ControlCmd::ResetLayerA { node_id: node });
c19702ae09beatagen2026-05-27 15:49 UTC403
    }
c19702ae09beatagen2026-05-27 15:49 UTC404
c19702ae09beatagen2026-05-27 15:49 UTC405
    fn uptime_s(&self) -> u64 {
c19702ae09beatagen2026-05-27 15:49 UTC406
        self.base_uptime_s
c19702ae09beatagen2026-05-27 15:49 UTC407
            .saturating_add(self.connected_at.elapsed().as_secs())
c19702ae09beatagen2026-05-27 15:49 UTC408
    }
c19702ae09beatagen2026-05-27 15:49 UTC409
c19702ae09beatagen2026-05-27 15:49 UTC410
    fn meters_stale(&self) -> bool {
c19702ae09beatagen2026-05-27 15:49 UTC411
        self.last_meter_at
c19702ae09beatagen2026-05-27 15:49 UTC412
            .map(|t| t.elapsed() > STALE_AFTER)
c19702ae09beatagen2026-05-27 15:49 UTC413
            .unwrap_or(true)
c19702ae09beatagen2026-05-27 15:49 UTC414
    }
c19702ae09beatagen2026-05-27 15:49 UTC415
c19702ae09beatagen2026-05-27 15:49 UTC416
    /// apply one wire event. ported from the tui's `apply_event`, minus
c19702ae09beatagen2026-05-27 15:49 UTC417
    /// the selection/snapshot bookkeeping the read-only gui doesn't need.
c19702ae09beatagen2026-05-27 15:49 UTC418
    fn apply_event(&mut self, ev: Event) {
c19702ae09beatagen2026-05-27 15:49 UTC419
        match ev.topic {
c19702ae09beatagen2026-05-27 15:49 UTC420
            Topic::Meters if ev.event == "tick" => {
c19702ae09beatagen2026-05-27 15:49 UTC421
                if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) {
c19702ae09beatagen2026-05-27 15:49 UTC422
                    self.meters = Some(m);
c19702ae09beatagen2026-05-27 15:49 UTC423
                    self.last_meter_at = Some(Instant::now());
c19702ae09beatagen2026-05-27 15:49 UTC424
                }
c19702ae09beatagen2026-05-27 15:49 UTC425
            }
c19702ae09beatagen2026-05-27 15:49 UTC426
            Topic::Meters if ev.event == "layer_a_level" => {
c19702ae09beatagen2026-05-27 15:49 UTC427
                if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) {
c19702ae09beatagen2026-05-27 15:49 UTC428
                    self.layer_a.insert(l.node_id, Some(l.reduction_db));
c19702ae09beatagen2026-05-27 15:49 UTC429
                }
c19702ae09beatagen2026-05-27 15:49 UTC430
            }
c19702ae09beatagen2026-05-27 15:49 UTC431
            Topic::Routing => {
c19702ae09beatagen2026-05-27 15:49 UTC432
                if let Ok(re) = serde_json::from_value::<RoutingEvent>(inject_event(&ev)) {
c19702ae09beatagen2026-05-27 15:49 UTC433
                    match re {
c19702ae09beatagen2026-05-27 15:49 UTC434
                        RoutingEvent::StreamRouted { node_id, app, to } => {
c19702ae09beatagen2026-05-27 15:49 UTC435
                            self.streams.insert(
c19702ae09beatagen2026-05-27 15:49 UTC436
                                node_id,
c19702ae09beatagen2026-05-27 15:49 UTC437
                                StreamRoute {
c19702ae09beatagen2026-05-27 15:49 UTC438
                                    node_id,
c19702ae09beatagen2026-05-27 15:49 UTC439
                                    app,
c19702ae09beatagen2026-05-27 15:49 UTC440
                                    route: to,
c19702ae09beatagen2026-05-27 15:49 UTC441
                                },
c19702ae09beatagen2026-05-27 15:49 UTC442
                            );
c19702ae09beatagen2026-05-27 15:49 UTC443
                        }
c19702ae09beatagen2026-05-27 15:49 UTC444
                        RoutingEvent::StreamRemoved { node_id } => {
c19702ae09beatagen2026-05-27 15:49 UTC445
                            self.streams.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC446
                            self.layer_a.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC447
                            self.la_snapshots.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC448
                        }
c19702ae09beatagen2026-05-27 15:49 UTC449
                        RoutingEvent::LayerAAttached { node_id, .. } => {
c19702ae09beatagen2026-05-27 15:49 UTC450
                            self.layer_a.entry(node_id).or_insert(None);
c19702ae09beatagen2026-05-27 15:49 UTC451
                        }
c19702ae09beatagen2026-05-27 15:49 UTC452
                        RoutingEvent::LayerADetached { node_id } => {
c19702ae09beatagen2026-05-27 15:49 UTC453
                            self.layer_a.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC454
                            self.la_snapshots.remove(&node_id);
c19702ae09beatagen2026-05-27 15:49 UTC455
                        }
c19702ae09beatagen2026-05-27 15:49 UTC456
                        RoutingEvent::RuleChanged => {}
c19702ae09beatagen2026-05-27 15:49 UTC457
                        _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC458
                    }
c19702ae09beatagen2026-05-27 15:49 UTC459
                }
c19702ae09beatagen2026-05-27 15:49 UTC460
            }
c19702ae09beatagen2026-05-27 15:49 UTC461
            Topic::Profile => {
c19702ae09beatagen2026-05-27 15:49 UTC462
                // the daemon emits `profile` "used" `{name}` on apply (not
c19702ae09beatagen2026-05-27 15:49 UTC463
                // the typed `ProfileEvent::Changed` "changed"/`previous`
c19702ae09beatagen2026-05-27 15:49 UTC464
                // shape), so read `name` directly and accept either spelling
c19702ae09beatagen2026-05-27 15:49 UTC465
                // — keeps the header in sync with a cli `profile use` too.
c19702ae09beatagen2026-05-27 15:49 UTC466
                // on "reloaded" the profile *set* may have changed.
c19702ae09beatagen2026-05-27 15:49 UTC467
                match ev.event.as_str() {
c19702ae09beatagen2026-05-27 15:49 UTC468
                    "used" | "changed" => {
c19702ae09beatagen2026-05-27 15:49 UTC469
                        if let Some(name) = ev.data.get("name").and_then(|v| v.as_str()) {
c19702ae09beatagen2026-05-27 15:49 UTC470
                            self.profile = name.to_string();
c19702ae09beatagen2026-05-27 15:49 UTC471
                        }
c19702ae09beatagen2026-05-27 15:49 UTC472
                    }
c19702ae09beatagen2026-05-27 15:49 UTC473
                    "reloaded" => self.send_cmd(ControlCmd::RefreshProfiles),
c19702ae09beatagen2026-05-27 15:49 UTC474
                    _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC475
                }
c19702ae09beatagen2026-05-27 15:49 UTC476
            }
c19702ae09beatagen2026-05-27 15:49 UTC477
            Topic::Daemon => {
c19702ae09beatagen2026-05-27 15:49 UTC478
                if let Ok(de) = serde_json::from_value::<DaemonEvent>(inject_event(&ev)) {
c19702ae09beatagen2026-05-27 15:49 UTC479
                    match de {
c19702ae09beatagen2026-05-27 15:49 UTC480
                        DaemonEvent::Overflow {
c19702ae09beatagen2026-05-27 15:49 UTC481
                            lost, total_lost, ..
c19702ae09beatagen2026-05-27 15:49 UTC482
                        } => {
c19702ae09beatagen2026-05-27 15:49 UTC483
                            self.overflow_total = total_lost.max(self.overflow_total + lost as u64);
c19702ae09beatagen2026-05-27 15:49 UTC484
                        }
c19702ae09beatagen2026-05-27 15:49 UTC485
                        DaemonEvent::Error { code, message } => {
c19702ae09beatagen2026-05-27 15:49 UTC486
                            self.last_error = Some(format!("{code}: {message}"));
c19702ae09beatagen2026-05-27 15:49 UTC487
                        }
c19702ae09beatagen2026-05-27 15:49 UTC488
                        DaemonEvent::Shutdown => {
c19702ae09beatagen2026-05-27 15:49 UTC489
                            self.disconnected = Some("daemon shutdown".into());
c19702ae09beatagen2026-05-27 15:49 UTC490
                        }
c19702ae09beatagen2026-05-27 15:49 UTC491
                        DaemonEvent::Started { version } => {
c19702ae09beatagen2026-05-27 15:49 UTC492
                            self.daemon_version = version;
c19702ae09beatagen2026-05-27 15:49 UTC493
                        }
c19702ae09beatagen2026-05-27 15:49 UTC494
                        _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC495
                    }
c19702ae09beatagen2026-05-27 15:49 UTC496
                }
c19702ae09beatagen2026-05-27 15:49 UTC497
            }
c19702ae09beatagen2026-05-27 15:49 UTC498
            _ => {}
c19702ae09beatagen2026-05-27 15:49 UTC499
        }
c19702ae09beatagen2026-05-27 15:49 UTC500
    }
c19702ae09beatagen2026-05-27 15:49 UTC501
c19702ae09beatagen2026-05-27 15:49 UTC502
    // -----------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC503
    // View
c19702ae09beatagen2026-05-27 15:49 UTC504
    // -----------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC505
c19702ae09beatagen2026-05-27 15:49 UTC506
    pub fn view(&self) -> Element<'_, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC507
        let content = column![
c19702ae09beatagen2026-05-27 15:49 UTC508
            self.header(),
c19702ae09beatagen2026-05-27 15:49 UTC509
            self.bus_panel(),
c19702ae09beatagen2026-05-27 15:49 UTC510
            self.loudness_panel(),
c19702ae09beatagen2026-05-27 15:49 UTC511
            self.streams_panel(),
c19702ae09beatagen2026-05-27 15:49 UTC512
            self.footer(),
c19702ae09beatagen2026-05-27 15:49 UTC513
        ]
c19702ae09beatagen2026-05-27 15:49 UTC514
        .spacing(8)
c19702ae09beatagen2026-05-27 15:49 UTC515
        .padding(12)
c19702ae09beatagen2026-05-27 15:49 UTC516
        .width(Length::Fill)
c19702ae09beatagen2026-05-27 15:49 UTC517
        .height(Length::Fill);
c19702ae09beatagen2026-05-27 15:49 UTC518
c19702ae09beatagen2026-05-27 15:49 UTC519
        // paint the window with the base background.
c19702ae09beatagen2026-05-27 15:49 UTC520
        container(content)
c19702ae09beatagen2026-05-27 15:49 UTC521
            .width(Length::Fill)
c19702ae09beatagen2026-05-27 15:49 UTC522
            .height(Length::Fill)
c19702ae09beatagen2026-05-27 15:49 UTC523
            .style(|_theme: &iced::Theme| container::Style {
c19702ae09beatagen2026-05-27 15:49 UTC524
                background: Some(Background::Color(col(BG))),
c19702ae09beatagen2026-05-27 15:49 UTC525
                ..container::Style::default()
c19702ae09beatagen2026-05-27 15:49 UTC526
            })
c19702ae09beatagen2026-05-27 15:49 UTC527
            .into()
c19702ae09beatagen2026-05-27 15:49 UTC528
    }
c19702ae09beatagen2026-05-27 15:49 UTC529
c19702ae09beatagen2026-05-27 15:49 UTC530
    fn header(&self) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC531
        let bypass = if self.bypass {
c19702ae09beatagen2026-05-27 15:49 UTC532
            badge_button("BYPASS", BLACK, YELLOW, Message::ToggleBypass)
c19702ae09beatagen2026-05-27 15:49 UTC533
        } else {
c19702ae09beatagen2026-05-27 15:49 UTC534
            badge_button("processed", GREEN, PANEL_BG, Message::ToggleBypass)
c19702ae09beatagen2026-05-27 15:49 UTC535
        };
c19702ae09beatagen2026-05-27 15:49 UTC536
        // state semantics (not action): label = current state, click
c19702ae09beatagen2026-05-27 15:49 UTC537
        // toggles — same convention as the per-app row chip.
c19702ae09beatagen2026-05-27 15:49 UTC538
        let per_app = if self.per_app_master {
c19702ae09beatagen2026-05-27 15:49 UTC539
            badge_button("per-app on", CYAN, PANEL_BG, Message::TogglePerAppMaster)
c19702ae09beatagen2026-05-27 15:49 UTC540
        } else {
c19702ae09beatagen2026-05-27 15:49 UTC541
            badge_button("per-app off", DIM, PANEL_BG, Message::TogglePerAppMaster)
c19702ae09beatagen2026-05-27 15:49 UTC542
        };
c19702ae09beatagen2026-05-27 15:49 UTC543
c19702ae09beatagen2026-05-27 15:49 UTC544
        // profile switcher: dropdown over the profile names.
c19702ae09beatagen2026-05-27 15:49 UTC545
        let names: Vec<String> = self.profiles.iter().map(|p| p.name.clone()).collect();
c19702ae09beatagen2026-05-27 15:49 UTC546
        let picker = pick_list(names, Some(self.profile.clone()), Message::ProfileSelected)
c19702ae09beatagen2026-05-27 15:49 UTC547
            .text_size(14)
c19702ae09beatagen2026-05-27 15:49 UTC548
            .padding(iced::Padding::from([2.0, 8.0]));
c19702ae09beatagen2026-05-27 15:49 UTC549
c19702ae09beatagen2026-05-27 15:49 UTC550
        // left group fills the width so version/uptime is pushed right.
c19702ae09beatagen2026-05-27 15:49 UTC551
        let mut left = row![
c19702ae09beatagen2026-05-27 15:49 UTC552
            text("profile:").color(col(DIM)),
c19702ae09beatagen2026-05-27 15:49 UTC553
            picker,
c19702ae09beatagen2026-05-27 15:49 UTC554
            bypass,
c19702ae09beatagen2026-05-27 15:49 UTC555
            per_app,
c19702ae09beatagen2026-05-27 15:49 UTC556
        ]
c19702ae09beatagen2026-05-27 15:49 UTC557
        .spacing(8)
c19702ae09beatagen2026-05-27 15:49 UTC558
        .align_y(Alignment::Center);
c19702ae09beatagen2026-05-27 15:49 UTC559
c19702ae09beatagen2026-05-27 15:49 UTC560
        // flag overlay overrides shadowing the profile — a stale
c19702ae09beatagen2026-05-27 15:49 UTC561
        // `headroom set` is otherwise invisible (e.g. agc.enabled pinned
c19702ae09beatagen2026-05-27 15:49 UTC562
        // off under a profile that enables it). click the pill to clear.
c19702ae09beatagen2026-05-27 15:49 UTC563
        if !self.setting_overrides.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC564
            left = left.push(pill_button(
c19702ae09beatagen2026-05-27 15:49 UTC565
                format!("overrides: {}  ✕", fmt_overrides(&self.setting_overrides)),
c19702ae09beatagen2026-05-27 15:49 UTC566
                BLACK,
c19702ae09beatagen2026-05-27 15:49 UTC567
                YELLOW,
c19702ae09beatagen2026-05-27 15:49 UTC568
                Message::ClearOverrides,
c19702ae09beatagen2026-05-27 15:49 UTC569
            ));
c19702ae09beatagen2026-05-27 15:49 UTC570
        }
c19702ae09beatagen2026-05-27 15:49 UTC571
c19702ae09beatagen2026-05-27 15:49 UTC572
        let right = row![
c19702ae09beatagen2026-05-27 15:49 UTC573
            text(format!("v{}", self.daemon_version))
c19702ae09beatagen2026-05-27 15:49 UTC574
                .size(13)
c19702ae09beatagen2026-05-27 15:49 UTC575
                .color(col(DIM)),
c19702ae09beatagen2026-05-27 15:49 UTC576
            text(format!("uptime {}", fmt_uptime(self.uptime_s())))
c19702ae09beatagen2026-05-27 15:49 UTC577
                .size(13)
c19702ae09beatagen2026-05-27 15:49 UTC578
                .color(col(DIM)),
c19702ae09beatagen2026-05-27 15:49 UTC579
        ]
c19702ae09beatagen2026-05-27 15:49 UTC580
        .spacing(8)
c19702ae09beatagen2026-05-27 15:49 UTC581
        .align_y(Alignment::Center);
c19702ae09beatagen2026-05-27 15:49 UTC582
c19702ae09beatagen2026-05-27 15:49 UTC583
        row![container(left).width(Length::Fill), right]
c19702ae09beatagen2026-05-27 15:49 UTC584
            .align_y(Alignment::Center)
c19702ae09beatagen2026-05-27 15:49 UTC585
            .into()
c19702ae09beatagen2026-05-27 15:49 UTC586
    }
c19702ae09beatagen2026-05-27 15:49 UTC587
c19702ae09beatagen2026-05-27 15:49 UTC588
    fn bus_panel(&self) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC589
        let m = self.meters;
c19702ae09beatagen2026-05-27 15:49 UTC590
        // per-stage enable flags from the tick (missing ⇒ assume enabled).
c19702ae09beatagen2026-05-27 15:49 UTC591
        // agc + compressor are per-profile; limiter is the always-on
c19702ae09beatagen2026-05-27 15:49 UTC592
        // backstop and true peak is a measurement, so both are always on.
c19702ae09beatagen2026-05-27 15:49 UTC593
        let agc_on = m.map(|t| t.agc_enabled).unwrap_or(true);
c19702ae09beatagen2026-05-27 15:49 UTC594
        let comp_on = m.map(|t| t.compressor_enabled).unwrap_or(true);
c19702ae09beatagen2026-05-27 15:49 UTC595
        let body = column![
c19702ae09beatagen2026-05-27 15:49 UTC596
            meter_row(
c19702ae09beatagen2026-05-27 15:49 UTC597
                "AGC target",
c19702ae09beatagen2026-05-27 15:49 UTC598
                m.map(|t| t.agc_gain_db),
c19702ae09beatagen2026-05-27 15:49 UTC599
                -12.0,
c19702ae09beatagen2026-05-27 15:49 UTC600
                12.0,
c19702ae09beatagen2026-05-27 15:49 UTC601
                "dB",
c19702ae09beatagen2026-05-27 15:49 UTC602
                CYAN,
c19702ae09beatagen2026-05-27 15:49 UTC603
                agc_on,
c19702ae09beatagen2026-05-27 15:49 UTC604
            ),
c19702ae09beatagen2026-05-27 15:49 UTC605
            meter_row(
c19702ae09beatagen2026-05-27 15:49 UTC606
                "Compressor GR",
c19702ae09beatagen2026-05-27 15:49 UTC607
                m.map(|t| t.compressor_gr_db),
c19702ae09beatagen2026-05-27 15:49 UTC608
                -24.0,
c19702ae09beatagen2026-05-27 15:49 UTC609
                0.0,
c19702ae09beatagen2026-05-27 15:49 UTC610
                "dB",
c19702ae09beatagen2026-05-27 15:49 UTC611
                MAGENTA,
c19702ae09beatagen2026-05-27 15:49 UTC612
                comp_on,
c19702ae09beatagen2026-05-27 15:49 UTC613
            ),
c19702ae09beatagen2026-05-27 15:49 UTC614
            meter_row(
c19702ae09beatagen2026-05-27 15:49 UTC615
                "Limiter GR",
c19702ae09beatagen2026-05-27 15:49 UTC616
                m.map(|t| t.limiter_gr_db),
c19702ae09beatagen2026-05-27 15:49 UTC617
                -24.0,
c19702ae09beatagen2026-05-27 15:49 UTC618
                0.0,
c19702ae09beatagen2026-05-27 15:49 UTC619
                "dB",
c19702ae09beatagen2026-05-27 15:49 UTC620
                RED,
c19702ae09beatagen2026-05-27 15:49 UTC621
                true,
c19702ae09beatagen2026-05-27 15:49 UTC622
            ),
c19702ae09beatagen2026-05-27 15:49 UTC623
            meter_row(
c19702ae09beatagen2026-05-27 15:49 UTC624
                "True peak",
c19702ae09beatagen2026-05-27 15:49 UTC625
                m.map(|t| t.true_peak_dbtp),
c19702ae09beatagen2026-05-27 15:49 UTC626
                -60.0,
c19702ae09beatagen2026-05-27 15:49 UTC627
                3.0,
c19702ae09beatagen2026-05-27 15:49 UTC628
                "dBTP",
c19702ae09beatagen2026-05-27 15:49 UTC629
                GREEN,
c19702ae09beatagen2026-05-27 15:49 UTC630
                true,
c19702ae09beatagen2026-05-27 15:49 UTC631
            ),
c19702ae09beatagen2026-05-27 15:49 UTC632
        ]
c19702ae09beatagen2026-05-27 15:49 UTC633
        .spacing(4);
c19702ae09beatagen2026-05-27 15:49 UTC634
        panel("bus dsp", body.into())
c19702ae09beatagen2026-05-27 15:49 UTC635
    }
c19702ae09beatagen2026-05-27 15:49 UTC636
c19702ae09beatagen2026-05-27 15:49 UTC637
    fn loudness_panel(&self) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC638
        let stale = self.meters_stale();
c19702ae09beatagen2026-05-27 15:49 UTC639
        let (mom, st, intg) = match self.meters {
c19702ae09beatagen2026-05-27 15:49 UTC640
            Some(m) => (
c19702ae09beatagen2026-05-27 15:49 UTC641
                Some(m.momentary_lufs),
c19702ae09beatagen2026-05-27 15:49 UTC642
                Some(m.shortterm_lufs),
c19702ae09beatagen2026-05-27 15:49 UTC643
                Some(m.integrated_lufs),
c19702ae09beatagen2026-05-27 15:49 UTC644
            ),
c19702ae09beatagen2026-05-27 15:49 UTC645
            None => (None, None, None),
c19702ae09beatagen2026-05-27 15:49 UTC646
        };
c19702ae09beatagen2026-05-27 15:49 UTC647
        let body = column![
c19702ae09beatagen2026-05-27 15:49 UTC648
            lufs_row("Momentary  (400 ms)", mom, stale),
c19702ae09beatagen2026-05-27 15:49 UTC649
            lufs_row("Short-term (3 s)", st, stale),
c19702ae09beatagen2026-05-27 15:49 UTC650
            lufs_row("Integrated (gated)", intg, stale),
c19702ae09beatagen2026-05-27 15:49 UTC651
        ]
c19702ae09beatagen2026-05-27 15:49 UTC652
        .spacing(4);
c19702ae09beatagen2026-05-27 15:49 UTC653
        panel("loudness (BS.1770)", body.into())
c19702ae09beatagen2026-05-27 15:49 UTC654
    }
c19702ae09beatagen2026-05-27 15:49 UTC655
c19702ae09beatagen2026-05-27 15:49 UTC656
    fn streams_panel(&self) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC657
        let header = row![
c19702ae09beatagen2026-05-27 15:49 UTC658
            container(text("node").size(12).color(col(DIM))).width(Length::Fixed(64.0)),
c19702ae09beatagen2026-05-27 15:49 UTC659
            container(text("app").size(12).color(col(DIM))).width(Length::Fill),
c19702ae09beatagen2026-05-27 15:49 UTC660
            container(text("route").size(12).color(col(DIM))).width(Length::Fixed(96.0)),
c19702ae09beatagen2026-05-27 15:49 UTC661
            container(text("per-app").size(12).color(col(DIM))).width(Length::Fixed(96.0)),
c19702ae09beatagen2026-05-27 15:49 UTC662
            container(text("actions").size(12).color(col(DIM))).width(Length::Fixed(180.0)),
c19702ae09beatagen2026-05-27 15:49 UTC663
        ]
c19702ae09beatagen2026-05-27 15:49 UTC664
        .spacing(8);
c19702ae09beatagen2026-05-27 15:49 UTC665
c19702ae09beatagen2026-05-27 15:49 UTC666
        let selected = self.effective_selection();
c19702ae09beatagen2026-05-27 15:49 UTC667
        let rows = self.streams.values().map(|s| {
c19702ae09beatagen2026-05-27 15:49 UTC668
            stream_row(
c19702ae09beatagen2026-05-27 15:49 UTC669
                s,
c19702ae09beatagen2026-05-27 15:49 UTC670
                &self.layer_a,
c19702ae09beatagen2026-05-27 15:49 UTC671
                &self.la_snapshots,
c19702ae09beatagen2026-05-27 15:49 UTC672
                selected == Some(s.node_id),
c19702ae09beatagen2026-05-27 15:49 UTC673
            )
c19702ae09beatagen2026-05-27 15:49 UTC674
        });
c19702ae09beatagen2026-05-27 15:49 UTC675
        let list = scrollable(column(rows).spacing(4).width(Length::Fill)).height(Length::Fill);
c19702ae09beatagen2026-05-27 15:49 UTC676
c19702ae09beatagen2026-05-27 15:49 UTC677
        let title = format!(
c19702ae09beatagen2026-05-27 15:49 UTC678
            "streams ({}) — default: {}",
c19702ae09beatagen2026-05-27 15:49 UTC679
            self.streams.len(),
c19702ae09beatagen2026-05-27 15:49 UTC680
            self.default_route
c19702ae09beatagen2026-05-27 15:49 UTC681
        );
c19702ae09beatagen2026-05-27 15:49 UTC682
c19702ae09beatagen2026-05-27 15:49 UTC683
        container(
c19702ae09beatagen2026-05-27 15:49 UTC684
            column![text(title).size(12).color(col(DIM)), header, list]
c19702ae09beatagen2026-05-27 15:49 UTC685
                .spacing(4)
c19702ae09beatagen2026-05-27 15:49 UTC686
                .height(Length::Fill),
c19702ae09beatagen2026-05-27 15:49 UTC687
        )
c19702ae09beatagen2026-05-27 15:49 UTC688
        .padding(8)
c19702ae09beatagen2026-05-27 15:49 UTC689
        .width(Length::Fill)
c19702ae09beatagen2026-05-27 15:49 UTC690
        .height(Length::Fill)
c19702ae09beatagen2026-05-27 15:49 UTC691
        .style(panel_style)
c19702ae09beatagen2026-05-27 15:49 UTC692
        .into()
c19702ae09beatagen2026-05-27 15:49 UTC693
    }
c19702ae09beatagen2026-05-27 15:49 UTC694
c19702ae09beatagen2026-05-27 15:49 UTC695
    fn footer(&self) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC696
        let mut items: Vec<Element<'static, Message>> = Vec::new();
c19702ae09beatagen2026-05-27 15:49 UTC697
        if self.overflow_total > 0 {
c19702ae09beatagen2026-05-27 15:49 UTC698
            items.push(
c19702ae09beatagen2026-05-27 15:49 UTC699
                text(format!("dropped: {}", self.overflow_total))
c19702ae09beatagen2026-05-27 15:49 UTC700
                    .size(12)
c19702ae09beatagen2026-05-27 15:49 UTC701
                    .color(col(YELLOW))
c19702ae09beatagen2026-05-27 15:49 UTC702
                    .into(),
c19702ae09beatagen2026-05-27 15:49 UTC703
            );
c19702ae09beatagen2026-05-27 15:49 UTC704
        }
c19702ae09beatagen2026-05-27 15:49 UTC705
        if let Some(err) = &self.last_error {
c19702ae09beatagen2026-05-27 15:49 UTC706
            items.push(
c19702ae09beatagen2026-05-27 15:49 UTC707
                text(format!("daemon error: {err}"))
c19702ae09beatagen2026-05-27 15:49 UTC708
                    .size(12)
c19702ae09beatagen2026-05-27 15:49 UTC709
                    .color(col(RED))
c19702ae09beatagen2026-05-27 15:49 UTC710
                    .into(),
c19702ae09beatagen2026-05-27 15:49 UTC711
            );
c19702ae09beatagen2026-05-27 15:49 UTC712
        }
c19702ae09beatagen2026-05-27 15:49 UTC713
        if let Some(reason) = &self.disconnected {
c19702ae09beatagen2026-05-27 15:49 UTC714
            items.push(
c19702ae09beatagen2026-05-27 15:49 UTC715
                text(format!("disconnected: {reason}"))
c19702ae09beatagen2026-05-27 15:49 UTC716
                    .size(12)
c19702ae09beatagen2026-05-27 15:49 UTC717
                    .color(col(RED))
c19702ae09beatagen2026-05-27 15:49 UTC718
                    .into(),
c19702ae09beatagen2026-05-27 15:49 UTC719
            );
c19702ae09beatagen2026-05-27 15:49 UTC720
        } else if self.overflow_total == 0 && self.last_error.is_none() {
c19702ae09beatagen2026-05-27 15:49 UTC721
            items.push(text("connected").size(12).color(col(DIM)).into());
c19702ae09beatagen2026-05-27 15:49 UTC722
        }
c19702ae09beatagen2026-05-27 15:49 UTC723
        row(items).spacing(12).into()
c19702ae09beatagen2026-05-27 15:49 UTC724
    }
c19702ae09beatagen2026-05-27 15:49 UTC725
}
c19702ae09beatagen2026-05-27 15:49 UTC726
c19702ae09beatagen2026-05-27 15:49 UTC727
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC728
// Free helpers
c19702ae09beatagen2026-05-27 15:49 UTC729
// ---------------------------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC730
c19702ae09beatagen2026-05-27 15:49 UTC731
/// `0xRRGGBB` → iced [`Color`].
c19702ae09beatagen2026-05-27 15:49 UTC732
fn col(hex: u32) -> Color {
c19702ae09beatagen2026-05-27 15:49 UTC733
    Color::from_rgb8((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
c19702ae09beatagen2026-05-27 15:49 UTC734
}
c19702ae09beatagen2026-05-27 15:49 UTC735
c19702ae09beatagen2026-05-27 15:49 UTC736
/// `radius`-rounded border, no stroke.
c19702ae09beatagen2026-05-27 15:49 UTC737
fn rounded(radius: f32) -> Border {
c19702ae09beatagen2026-05-27 15:49 UTC738
    Border {
c19702ae09beatagen2026-05-27 15:49 UTC739
        radius: radius.into(),
c19702ae09beatagen2026-05-27 15:49 UTC740
        ..Border::default()
c19702ae09beatagen2026-05-27 15:49 UTC741
    }
c19702ae09beatagen2026-05-27 15:49 UTC742
}
c19702ae09beatagen2026-05-27 15:49 UTC743
c19702ae09beatagen2026-05-27 15:49 UTC744
/// shared panel container style: dark fill, rounded corners.
c19702ae09beatagen2026-05-27 15:49 UTC745
fn panel_style(_theme: &iced::Theme) -> container::Style {
c19702ae09beatagen2026-05-27 15:49 UTC746
    container::Style {
c19702ae09beatagen2026-05-27 15:49 UTC747
        background: Some(Background::Color(col(PANEL_BG))),
c19702ae09beatagen2026-05-27 15:49 UTC748
        border: rounded(6.0),
c19702ae09beatagen2026-05-27 15:49 UTC749
        ..container::Style::default()
c19702ae09beatagen2026-05-27 15:49 UTC750
    }
c19702ae09beatagen2026-05-27 15:49 UTC751
}
c19702ae09beatagen2026-05-27 15:49 UTC752
c19702ae09beatagen2026-05-27 15:49 UTC753
/// a titled panel: header label + body, on a rounded dark card.
c19702ae09beatagen2026-05-27 15:49 UTC754
fn panel(title: &'static str, body: Element<'static, Message>) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC755
    container(
c19702ae09beatagen2026-05-27 15:49 UTC756
        column![text(title).size(12).color(col(DIM)), body]
c19702ae09beatagen2026-05-27 15:49 UTC757
            .spacing(4)
c19702ae09beatagen2026-05-27 15:49 UTC758
            .width(Length::Fill),
c19702ae09beatagen2026-05-27 15:49 UTC759
    )
c19702ae09beatagen2026-05-27 15:49 UTC760
    .padding(8)
c19702ae09beatagen2026-05-27 15:49 UTC761
    .width(Length::Fill)
c19702ae09beatagen2026-05-27 15:49 UTC762
    .style(panel_style)
c19702ae09beatagen2026-05-27 15:49 UTC763
    .into()
c19702ae09beatagen2026-05-27 15:49 UTC764
}
c19702ae09beatagen2026-05-27 15:49 UTC765
c19702ae09beatagen2026-05-27 15:49 UTC766
/// like [`badge_button`] but owns its text, for runtime-built labels.
c19702ae09beatagen2026-05-27 15:49 UTC767
fn pill_button(label: String, fg: u32, bg: u32, msg: Message) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC768
    button(text(label).size(12).color(col(fg)))
c19702ae09beatagen2026-05-27 15:49 UTC769
        .padding(iced::Padding::from([2.0, 8.0]))
c19702ae09beatagen2026-05-27 15:49 UTC770
        .on_press(msg)
c19702ae09beatagen2026-05-27 15:49 UTC771
        .style(pill_style(fg, bg))
c19702ae09beatagen2026-05-27 15:49 UTC772
        .into()
c19702ae09beatagen2026-05-27 15:49 UTC773
}
c19702ae09beatagen2026-05-27 15:49 UTC774
c19702ae09beatagen2026-05-27 15:49 UTC775
/// active overrides as `key=value, …` (strings unquoted, else json) for
c19702ae09beatagen2026-05-27 15:49 UTC776
/// the header indicator.
c19702ae09beatagen2026-05-27 15:49 UTC777
fn fmt_overrides(map: &BTreeMap<String, serde_json::Value>) -> String {
c19702ae09beatagen2026-05-27 15:49 UTC778
    map.iter()
c19702ae09beatagen2026-05-27 15:49 UTC779
        .map(|(k, v)| {
c19702ae09beatagen2026-05-27 15:49 UTC780
            let val = match v {
c19702ae09beatagen2026-05-27 15:49 UTC781
                serde_json::Value::String(s) => s.clone(),
c19702ae09beatagen2026-05-27 15:49 UTC782
                other => other.to_string(),
c19702ae09beatagen2026-05-27 15:49 UTC783
            };
c19702ae09beatagen2026-05-27 15:49 UTC784
            format!("{k}={val}")
c19702ae09beatagen2026-05-27 15:49 UTC785
        })
c19702ae09beatagen2026-05-27 15:49 UTC786
        .collect::<Vec<_>>()
c19702ae09beatagen2026-05-27 15:49 UTC787
        .join(", ")
c19702ae09beatagen2026-05-27 15:49 UTC788
}
c19702ae09beatagen2026-05-27 15:49 UTC789
c19702ae09beatagen2026-05-27 15:49 UTC790
/// a small colored pill that's also a button.
c19702ae09beatagen2026-05-27 15:49 UTC791
fn badge_button(
c19702ae09beatagen2026-05-27 15:49 UTC792
    label: &'static str,
c19702ae09beatagen2026-05-27 15:49 UTC793
    fg: u32,
c19702ae09beatagen2026-05-27 15:49 UTC794
    bg: u32,
c19702ae09beatagen2026-05-27 15:49 UTC795
    msg: Message,
c19702ae09beatagen2026-05-27 15:49 UTC796
) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC797
    button(text(label).size(12).color(col(fg)))
c19702ae09beatagen2026-05-27 15:49 UTC798
        .padding(iced::Padding::from([2.0, 8.0]))
c19702ae09beatagen2026-05-27 15:49 UTC799
        .on_press(msg)
c19702ae09beatagen2026-05-27 15:49 UTC800
        .style(pill_style(fg, bg))
c19702ae09beatagen2026-05-27 15:49 UTC801
        .into()
c19702ae09beatagen2026-05-27 15:49 UTC802
}
c19702ae09beatagen2026-05-27 15:49 UTC803
c19702ae09beatagen2026-05-27 15:49 UTC804
/// button style closure: flat `bg` pill, `fg` text, lightening on
c19702ae09beatagen2026-05-27 15:49 UTC805
/// hover/press.
c19702ae09beatagen2026-05-27 15:49 UTC806
fn pill_style(fg: u32, bg: u32) -> impl Fn(&iced::Theme, button::Status) -> button::Style {
c19702ae09beatagen2026-05-27 15:49 UTC807
    move |_theme, status| {
c19702ae09beatagen2026-05-27 15:49 UTC808
        let background = match status {
c19702ae09beatagen2026-05-27 15:49 UTC809
            button::Status::Hovered | button::Status::Pressed => col(HOVER_BG),
c19702ae09beatagen2026-05-27 15:49 UTC810
            _ => col(bg),
c19702ae09beatagen2026-05-27 15:49 UTC811
        };
c19702ae09beatagen2026-05-27 15:49 UTC812
        button::Style {
c19702ae09beatagen2026-05-27 15:49 UTC813
            background: Some(Background::Color(background)),
c19702ae09beatagen2026-05-27 15:49 UTC814
            text_color: col(fg),
c19702ae09beatagen2026-05-27 15:49 UTC815
            border: rounded(4.0),
c19702ae09beatagen2026-05-27 15:49 UTC816
            ..button::Style::default()
c19702ae09beatagen2026-05-27 15:49 UTC817
        }
c19702ae09beatagen2026-05-27 15:49 UTC818
    }
c19702ae09beatagen2026-05-27 15:49 UTC819
}
c19702ae09beatagen2026-05-27 15:49 UTC820
c19702ae09beatagen2026-05-27 15:49 UTC821
/// one bus meter row: `LABEL   VALUE   [====----]   min..max`. when
c19702ae09beatagen2026-05-27 15:49 UTC822
/// `!enabled` the stage is bypassed, so render "disabled" + a greyed bar
c19702ae09beatagen2026-05-27 15:49 UTC823
/// — a disabled stage must not look like one reading 0.
c19702ae09beatagen2026-05-27 15:49 UTC824
fn meter_row(
c19702ae09beatagen2026-05-27 15:49 UTC825
    label: &'static str,
c19702ae09beatagen2026-05-27 15:49 UTC826
    value: Option<f32>,
c19702ae09beatagen2026-05-27 15:49 UTC827
    min: f32,
c19702ae09beatagen2026-05-27 15:49 UTC828
    max: f32,
c19702ae09beatagen2026-05-27 15:49 UTC829
    unit: &'static str,
c19702ae09beatagen2026-05-27 15:49 UTC830
    color: u32,
c19702ae09beatagen2026-05-27 15:49 UTC831
    enabled: bool,
c19702ae09beatagen2026-05-27 15:49 UTC832
) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC833
    // normalize into 0..100 for `progress_bar`, clamped. disabled ⇒ empty.
c19702ae09beatagen2026-05-27 15:49 UTC834
    let pct = match value {
c19702ae09beatagen2026-05-27 15:49 UTC835
        Some(v) if enabled => ((v.clamp(min, max) - min) / (max - min)).clamp(0.0, 1.0) * 100.0,
c19702ae09beatagen2026-05-27 15:49 UTC836
        _ => 0.0,
c19702ae09beatagen2026-05-27 15:49 UTC837
    };
c19702ae09beatagen2026-05-27 15:49 UTC838
    let bar_color = if enabled { color } else { DIM };
c19702ae09beatagen2026-05-27 15:49 UTC839
    let value_str = if !enabled {
c19702ae09beatagen2026-05-27 15:49 UTC840
        "disabled".to_string()
c19702ae09beatagen2026-05-27 15:49 UTC841
    } else {
c19702ae09beatagen2026-05-27 15:49 UTC842
        value
c19702ae09beatagen2026-05-27 15:49 UTC843
            .map(|v| format!("{v:+7.2} {unit}"))
c19702ae09beatagen2026-05-27 15:49 UTC844
            .unwrap_or_else(|| "    --".to_string())
c19702ae09beatagen2026-05-27 15:49 UTC845
    };
c19702ae09beatagen2026-05-27 15:49 UTC846
    let value_color = if enabled { FG } else { DIM };
c19702ae09beatagen2026-05-27 15:49 UTC847
c19702ae09beatagen2026-05-27 15:49 UTC848
    row![
c19702ae09beatagen2026-05-27 15:49 UTC849
        container(text(label).color(col(DIM))).width(Length::Fixed(130.0)),
c19702ae09beatagen2026-05-27 15:49 UTC850
        container(text(value_str).color(col(value_color)))
c19702ae09beatagen2026-05-27 15:49 UTC851
            .width(Length::Fixed(96.0))
c19702ae09beatagen2026-05-27 15:49 UTC852
            .align_x(Horizontal::Right),
c19702ae09beatagen2026-05-27 15:49 UTC853
        progress_bar(0.0..=100.0, pct)
c19702ae09beatagen2026-05-27 15:49 UTC854
            .length(Length::Fill)
c19702ae09beatagen2026-05-27 15:49 UTC855
            .girth(Length::Fixed(10.0))
c19702ae09beatagen2026-05-27 15:49 UTC856
            .style(move |_theme: &iced::Theme| progress_bar::Style {
c19702ae09beatagen2026-05-27 15:49 UTC857
                background: Background::Color(col(TRACK_BG)),
c19702ae09beatagen2026-05-27 15:49 UTC858
                bar: Background::Color(col(bar_color)),
c19702ae09beatagen2026-05-27 15:49 UTC859
                border: rounded(3.0),
c19702ae09beatagen2026-05-27 15:49 UTC860
            }),
c19702ae09beatagen2026-05-27 15:49 UTC861
        container(text(format!("{min:.0}..{max:.0}")).size(11).color(col(DIM)))
c19702ae09beatagen2026-05-27 15:49 UTC862
            .width(Length::Fixed(90.0))
c19702ae09beatagen2026-05-27 15:49 UTC863
            .align_x(Horizontal::Right),
c19702ae09beatagen2026-05-27 15:49 UTC864
    ]
c19702ae09beatagen2026-05-27 15:49 UTC865
    .spacing(8)
c19702ae09beatagen2026-05-27 15:49 UTC866
    .align_y(Alignment::Center)
c19702ae09beatagen2026-05-27 15:49 UTC867
    .into()
c19702ae09beatagen2026-05-27 15:49 UTC868
}
c19702ae09beatagen2026-05-27 15:49 UTC869
c19702ae09beatagen2026-05-27 15:49 UTC870
/// one BS.1770 loudness row.
c19702ae09beatagen2026-05-27 15:49 UTC871
fn lufs_row(label: &'static str, v: Option<f32>, stale: bool) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC872
    let val = match v {
c19702ae09beatagen2026-05-27 15:49 UTC873
        Some(x) if x > LOUDNESS_FLOOR_LUFS + 0.5 => format!("{x:+7.2} LUFS"),
c19702ae09beatagen2026-05-27 15:49 UTC874
        _ => "    -- LUFS".to_string(),
c19702ae09beatagen2026-05-27 15:49 UTC875
    };
c19702ae09beatagen2026-05-27 15:49 UTC876
    row![
c19702ae09beatagen2026-05-27 15:49 UTC877
        container(text(label).color(col(DIM))).width(Length::Fixed(200.0)),
c19702ae09beatagen2026-05-27 15:49 UTC878
        text(val).color(col(if stale { DIM } else { FG })),
c19702ae09beatagen2026-05-27 15:49 UTC879
    ]
c19702ae09beatagen2026-05-27 15:49 UTC880
    .spacing(8)
c19702ae09beatagen2026-05-27 15:49 UTC881
    .into()
c19702ae09beatagen2026-05-27 15:49 UTC882
}
c19702ae09beatagen2026-05-27 15:49 UTC883
c19702ae09beatagen2026-05-27 15:49 UTC884
/// one stream routing row: status cells + route/per-app/reset actions,
c19702ae09beatagen2026-05-27 15:49 UTC885
/// wrapped in a `mouse_area` so clicking the row selects it. owns its
c19702ae09beatagen2026-05-27 15:49 UTC886
/// text, so it's `'static`.
c19702ae09beatagen2026-05-27 15:49 UTC887
fn stream_row(
c19702ae09beatagen2026-05-27 15:49 UTC888
    s: &StreamRoute,
c19702ae09beatagen2026-05-27 15:49 UTC889
    layer_a: &BTreeMap<u32, Option<f32>>,
c19702ae09beatagen2026-05-27 15:49 UTC890
    la_snapshots: &BTreeMap<u32, LayerASnapshot>,
c19702ae09beatagen2026-05-27 15:49 UTC891
    selected: bool,
c19702ae09beatagen2026-05-27 15:49 UTC892
) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC893
    let node = s.node_id;
c19702ae09beatagen2026-05-27 15:49 UTC894
    let (route_text, route_color) = match s.route {
c19702ae09beatagen2026-05-27 15:49 UTC895
        Route::Processed => ("processed", GREEN),
c19702ae09beatagen2026-05-27 15:49 UTC896
        Route::Bypass => ("bypass", YELLOW),
c19702ae09beatagen2026-05-27 15:49 UTC897
    };
c19702ae09beatagen2026-05-27 15:49 UTC898
    let (la_text, la_color) = match layer_a.get(&node) {
c19702ae09beatagen2026-05-27 15:49 UTC899
        Some(Some(db)) => (format!("{db:+5.1} dB"), MAGENTA),
c19702ae09beatagen2026-05-27 15:49 UTC900
        Some(None) => ("attached".to_string(), DIM),
c19702ae09beatagen2026-05-27 15:49 UTC901
        None => ("—".to_string(), DIM),
c19702ae09beatagen2026-05-27 15:49 UTC902
    };
c19702ae09beatagen2026-05-27 15:49 UTC903
c19702ae09beatagen2026-05-27 15:49 UTC904
    // route button: always available, flips processed ↔ bypass.
c19702ae09beatagen2026-05-27 15:49 UTC905
    let (route_btn_label, route_btn_to) = match s.route {
c19702ae09beatagen2026-05-27 15:49 UTC906
        Route::Processed => ("→ bypass", BLACK),
c19702ae09beatagen2026-05-27 15:49 UTC907
        Route::Bypass => ("→ processed", BLACK),
c19702ae09beatagen2026-05-27 15:49 UTC908
    };
c19702ae09beatagen2026-05-27 15:49 UTC909
    let route_btn = action_button(route_btn_label, route_btn_to, CYAN, Message::ToggleRoute(node));
c19702ae09beatagen2026-05-27 15:49 UTC910
c19702ae09beatagen2026-05-27 15:49 UTC911
    // per-app + reset need an app label; omit them otherwise so the row
c19702ae09beatagen2026-05-27 15:49 UTC912
    // can't fire a no-op control op.
c19702ae09beatagen2026-05-27 15:49 UTC913
    let actions: Element<'static, Message> = if s.app.is_empty() {
c19702ae09beatagen2026-05-27 15:49 UTC914
        route_btn
c19702ae09beatagen2026-05-27 15:49 UTC915
    } else {
c19702ae09beatagen2026-05-27 15:49 UTC916
        // state semantics (like the master badge): label = whether managed,
c19702ae09beatagen2026-05-27 15:49 UTC917
        // click toggles.
c19702ae09beatagen2026-05-27 15:49 UTC918
        let managed = la_snapshots.get(&node).is_some_and(|snap| snap.managed);
c19702ae09beatagen2026-05-27 15:49 UTC919
        let (pa_label, pa_bg) = if managed {
c19702ae09beatagen2026-05-27 15:49 UTC920
            ("per-app on", CYAN)
c19702ae09beatagen2026-05-27 15:49 UTC921
        } else {
c19702ae09beatagen2026-05-27 15:49 UTC922
            ("per-app off", DIM)
c19702ae09beatagen2026-05-27 15:49 UTC923
        };
c19702ae09beatagen2026-05-27 15:49 UTC924
        let per_app_btn = action_button(pa_label, BLACK, pa_bg, Message::TogglePerApp(node));
c19702ae09beatagen2026-05-27 15:49 UTC925
        let reset_btn = action_button("reset", FG, TRACK_BG, Message::ResetLayerA(node));
c19702ae09beatagen2026-05-27 15:49 UTC926
        row![route_btn, per_app_btn, reset_btn].spacing(4).into()
c19702ae09beatagen2026-05-27 15:49 UTC927
    };
c19702ae09beatagen2026-05-27 15:49 UTC928
c19702ae09beatagen2026-05-27 15:49 UTC929
    let body = row![
c19702ae09beatagen2026-05-27 15:49 UTC930
        container(text(node.to_string()).color(col(DIM))).width(Length::Fixed(64.0)),
c19702ae09beatagen2026-05-27 15:49 UTC931
        container(text(s.app.clone())).width(Length::Fill),
c19702ae09beatagen2026-05-27 15:49 UTC932
        container(text(route_text).color(col(route_color))).width(Length::Fixed(96.0)),
c19702ae09beatagen2026-05-27 15:49 UTC933
        container(text(la_text).color(col(la_color))).width(Length::Fixed(96.0)),
c19702ae09beatagen2026-05-27 15:49 UTC934
        container(actions).width(Length::Fixed(180.0)),
c19702ae09beatagen2026-05-27 15:49 UTC935
    ]
c19702ae09beatagen2026-05-27 15:49 UTC936
    .spacing(8)
c19702ae09beatagen2026-05-27 15:49 UTC937
    .align_y(Alignment::Center);
c19702ae09beatagen2026-05-27 15:49 UTC938
c19702ae09beatagen2026-05-27 15:49 UTC939
    // tint the selected row; wrap it so a click anywhere selects it
c19702ae09beatagen2026-05-27 15:49 UTC940
    // (buttons still take their own clicks first).
c19702ae09beatagen2026-05-27 15:49 UTC941
    let tinted = container(body).padding(iced::Padding::from([2.0, 4.0])).style(
c19702ae09beatagen2026-05-27 15:49 UTC942
        move |_theme: &iced::Theme| container::Style {
c19702ae09beatagen2026-05-27 15:49 UTC943
            background: selected.then(|| Background::Color(col(SEL_BG))),
c19702ae09beatagen2026-05-27 15:49 UTC944
            border: rounded(4.0),
c19702ae09beatagen2026-05-27 15:49 UTC945
            ..container::Style::default()
c19702ae09beatagen2026-05-27 15:49 UTC946
        },
c19702ae09beatagen2026-05-27 15:49 UTC947
    );
c19702ae09beatagen2026-05-27 15:49 UTC948
    mouse_area(tinted).on_press(Message::Select(node)).into()
c19702ae09beatagen2026-05-27 15:49 UTC949
}
c19702ae09beatagen2026-05-27 15:49 UTC950
c19702ae09beatagen2026-05-27 15:49 UTC951
/// compact row-action button: `bg` pill, `fg` text, lightening on
c19702ae09beatagen2026-05-27 15:49 UTC952
/// hover/press.
c19702ae09beatagen2026-05-27 15:49 UTC953
fn action_button(
c19702ae09beatagen2026-05-27 15:49 UTC954
    label: &'static str,
c19702ae09beatagen2026-05-27 15:49 UTC955
    fg: u32,
c19702ae09beatagen2026-05-27 15:49 UTC956
    bg: u32,
c19702ae09beatagen2026-05-27 15:49 UTC957
    msg: Message,
c19702ae09beatagen2026-05-27 15:49 UTC958
) -> Element<'static, Message> {
c19702ae09beatagen2026-05-27 15:49 UTC959
    button(text(label).size(11).color(col(fg)))
c19702ae09beatagen2026-05-27 15:49 UTC960
        .padding(iced::Padding::from([1.0, 6.0]))
c19702ae09beatagen2026-05-27 15:49 UTC961
        .on_press(msg)
c19702ae09beatagen2026-05-27 15:49 UTC962
        .style(pill_style(fg, bg))
c19702ae09beatagen2026-05-27 15:49 UTC963
        .into()
c19702ae09beatagen2026-05-27 15:49 UTC964
}
c19702ae09beatagen2026-05-27 15:49 UTC965
c19702ae09beatagen2026-05-27 15:49 UTC966
/// the wire frame carries `{event, topic, data}` but the typed enums are
c19702ae09beatagen2026-05-27 15:49 UTC967
/// `#[serde(tag = "event")]` inside `data`, so re-inject the event name
c19702ae09beatagen2026-05-27 15:49 UTC968
/// before deserializing. same dance as the tui.
c19702ae09beatagen2026-05-27 15:49 UTC969
fn inject_event(ev: &Event) -> serde_json::Value {
c19702ae09beatagen2026-05-27 15:49 UTC970
    let mut obj = match &ev.data {
c19702ae09beatagen2026-05-27 15:49 UTC971
        serde_json::Value::Object(m) => m.clone(),
c19702ae09beatagen2026-05-27 15:49 UTC972
        _ => serde_json::Map::new(),
c19702ae09beatagen2026-05-27 15:49 UTC973
    };
c19702ae09beatagen2026-05-27 15:49 UTC974
    obj.insert("event".into(), serde_json::Value::String(ev.event.clone()));
c19702ae09beatagen2026-05-27 15:49 UTC975
    serde_json::Value::Object(obj)
c19702ae09beatagen2026-05-27 15:49 UTC976
}
c19702ae09beatagen2026-05-27 15:49 UTC977
c19702ae09beatagen2026-05-27 15:49 UTC978
/// `12345` seconds → `3h25m45s` / `25m45s` / `45s`.
c19702ae09beatagen2026-05-27 15:49 UTC979
fn fmt_uptime(s: u64) -> String {
c19702ae09beatagen2026-05-27 15:49 UTC980
    let h = s / 3600;
c19702ae09beatagen2026-05-27 15:49 UTC981
    let m = (s % 3600) / 60;
c19702ae09beatagen2026-05-27 15:49 UTC982
    let sec = s % 60;
c19702ae09beatagen2026-05-27 15:49 UTC983
    if h > 0 {
c19702ae09beatagen2026-05-27 15:49 UTC984
        format!("{h}h{m:02}m{sec:02}s")
c19702ae09beatagen2026-05-27 15:49 UTC985
    } else if m > 0 {
c19702ae09beatagen2026-05-27 15:49 UTC986
        format!("{m}m{sec:02}s")
c19702ae09beatagen2026-05-27 15:49 UTC987
    } else {
c19702ae09beatagen2026-05-27 15:49 UTC988
        format!("{sec}s")
c19702ae09beatagen2026-05-27 15:49 UTC989
    }
c19702ae09beatagen2026-05-27 15:49 UTC990
}
c19702ae09beatagen2026-05-27 15:49 UTC991
c19702ae09beatagen2026-05-27 15:49 UTC992
/// map a keyboard event to a [`Message`], mirroring the tui's
c19702ae09beatagen2026-05-27 15:49 UTC993
/// `handle_key`. unhandled keys / non-press events become
c19702ae09beatagen2026-05-27 15:49 UTC994
/// [`Message::Noop`]; selection-targeted messages no-op in `update` when
c19702ae09beatagen2026-05-27 15:49 UTC995
/// nothing is selected.
c19702ae09beatagen2026-05-27 15:49 UTC996
fn map_key(event: keyboard::Event) -> Message {
c19702ae09beatagen2026-05-27 15:49 UTC997
    use keyboard::key::Named;
c19702ae09beatagen2026-05-27 15:49 UTC998
    let keyboard::Event::KeyPressed { key, .. } = event else {
c19702ae09beatagen2026-05-27 15:49 UTC999
        return Message::Noop;
c19702ae09beatagen2026-05-27 15:49 UTC1000
    };
c19702ae09beatagen2026-05-27 15:49 UTC1001
    match key {
c19702ae09beatagen2026-05-27 15:49 UTC1002
        keyboard::Key::Character(c) => match c.as_str() {
c19702ae09beatagen2026-05-27 15:49 UTC1003
            "b" => Message::ToggleBypass,
c19702ae09beatagen2026-05-27 15:49 UTC1004
            "p" => Message::TogglePerAppMaster,
c19702ae09beatagen2026-05-27 15:49 UTC1005
            "r" => Message::RouteSelected,
c19702ae09beatagen2026-05-27 15:49 UTC1006
            "a" => Message::PerAppSelected,
c19702ae09beatagen2026-05-27 15:49 UTC1007
            "x" => Message::ResetSelected,
c19702ae09beatagen2026-05-27 15:49 UTC1008
            "j" => Message::SelectDown,
c19702ae09beatagen2026-05-27 15:49 UTC1009
            "k" => Message::SelectUp,
c19702ae09beatagen2026-05-27 15:49 UTC1010
            _ => Message::Noop,
c19702ae09beatagen2026-05-27 15:49 UTC1011
        },
c19702ae09beatagen2026-05-27 15:49 UTC1012
        keyboard::Key::Named(Named::Enter) => Message::RouteSelected,
c19702ae09beatagen2026-05-27 15:49 UTC1013
        keyboard::Key::Named(Named::ArrowDown) => Message::SelectDown,
c19702ae09beatagen2026-05-27 15:49 UTC1014
        keyboard::Key::Named(Named::ArrowUp) => Message::SelectUp,
c19702ae09beatagen2026-05-27 15:49 UTC1015
        _ => Message::Noop,
c19702ae09beatagen2026-05-27 15:49 UTC1016
    }
c19702ae09beatagen2026-05-27 15:49 UTC1017
}
c19702ae09beatagen2026-05-27 15:49 UTC1018
c19702ae09beatagen2026-05-27 15:49 UTC1019
#[cfg(test)]
c19702ae09beatagen2026-05-27 15:49 UTC1020
mod state_tests;
c19702ae09beatagen2026-05-27 15:49 UTC1021
c19702ae09beatagen2026-05-27 15:49 UTC1022
#[cfg(test)]
c19702ae09beatagen2026-05-27 15:49 UTC1023
mod tests {
c19702ae09beatagen2026-05-27 15:49 UTC1024
    use super::*;
c19702ae09beatagen2026-05-27 15:49 UTC1025
    use crossbeam_channel::{unbounded, Receiver};
c19702ae09beatagen2026-05-27 15:49 UTC1026
    use headroom_ipc::{RouteList, Sinks, Status};
c19702ae09beatagen2026-05-27 15:49 UTC1027
c19702ae09beatagen2026-05-27 15:49 UTC1028
    /// build an app over the given streams + layer a snapshots, keeping
c19702ae09beatagen2026-05-27 15:49 UTC1029
    /// the control receiver alive so toggle tests can drain it.
c19702ae09beatagen2026-05-27 15:49 UTC1030
    fn build(
c19702ae09beatagen2026-05-27 15:49 UTC1031
        streams: Vec<StreamRoute>,
c19702ae09beatagen2026-05-27 15:49 UTC1032
        layer_a: Vec<LayerASnapshot>,
c19702ae09beatagen2026-05-27 15:49 UTC1033
    ) -> (HeadroomApp, Receiver<ControlCmd>) {
c19702ae09beatagen2026-05-27 15:49 UTC1034
        let status = Status {
c19702ae09beatagen2026-05-27 15:49 UTC1035
            version: "test".into(),
c19702ae09beatagen2026-05-27 15:49 UTC1036
            protocol: 1,
c19702ae09beatagen2026-05-27 15:49 UTC1037
            uptime_s: 0,
c19702ae09beatagen2026-05-27 15:49 UTC1038
            profile: "default".into(),
c19702ae09beatagen2026-05-27 15:49 UTC1039
            bypass: false,
c19702ae09beatagen2026-05-27 15:49 UTC1040
            per_app: false,
c19702ae09beatagen2026-05-27 15:49 UTC1041
            sinks: Sinks::default(),
c19702ae09beatagen2026-05-27 15:49 UTC1042
            streams,
c19702ae09beatagen2026-05-27 15:49 UTC1043
            layer_a,
c19702ae09beatagen2026-05-27 15:49 UTC1044
            warnings: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1045
            setting_overrides: Default::default(),
c19702ae09beatagen2026-05-27 15:49 UTC1046
        };
c19702ae09beatagen2026-05-27 15:49 UTC1047
        let route_list = RouteList {
c19702ae09beatagen2026-05-27 15:49 UTC1048
            rules: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1049
            current: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1050
            default_route: Route::Processed,
c19702ae09beatagen2026-05-27 15:49 UTC1051
        };
c19702ae09beatagen2026-05-27 15:49 UTC1052
        let (_tx, rx) = unbounded();
c19702ae09beatagen2026-05-27 15:49 UTC1053
        let (cmd_tx, cmd_rx) = unbounded();
c19702ae09beatagen2026-05-27 15:49 UTC1054
        let app = HeadroomApp::new(Bootstrap {
c19702ae09beatagen2026-05-27 15:49 UTC1055
            snapshot: Snapshot {
c19702ae09beatagen2026-05-27 15:49 UTC1056
                status,
c19702ae09beatagen2026-05-27 15:49 UTC1057
                route_list,
c19702ae09beatagen2026-05-27 15:49 UTC1058
                profiles: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1059
            },
c19702ae09beatagen2026-05-27 15:49 UTC1060
            rx,
c19702ae09beatagen2026-05-27 15:49 UTC1061
            cmd_tx,
c19702ae09beatagen2026-05-27 15:49 UTC1062
        });
c19702ae09beatagen2026-05-27 15:49 UTC1063
        (app, cmd_rx)
c19702ae09beatagen2026-05-27 15:49 UTC1064
    }
c19702ae09beatagen2026-05-27 15:49 UTC1065
c19702ae09beatagen2026-05-27 15:49 UTC1066
    /// build a `status` snapshot for reconnect tests.
c19702ae09beatagen2026-05-27 15:49 UTC1067
    fn status_with(profile: &str, streams: Vec<StreamRoute>) -> Status {
c19702ae09beatagen2026-05-27 15:49 UTC1068
        Status {
c19702ae09beatagen2026-05-27 15:49 UTC1069
            version: "test".into(),
c19702ae09beatagen2026-05-27 15:49 UTC1070
            protocol: 1,
c19702ae09beatagen2026-05-27 15:49 UTC1071
            uptime_s: 0,
c19702ae09beatagen2026-05-27 15:49 UTC1072
            profile: profile.into(),
c19702ae09beatagen2026-05-27 15:49 UTC1073
            bypass: false,
c19702ae09beatagen2026-05-27 15:49 UTC1074
            per_app: false,
c19702ae09beatagen2026-05-27 15:49 UTC1075
            sinks: Sinks::default(),
c19702ae09beatagen2026-05-27 15:49 UTC1076
            streams,
c19702ae09beatagen2026-05-27 15:49 UTC1077
            layer_a: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1078
            warnings: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1079
            setting_overrides: Default::default(),
c19702ae09beatagen2026-05-27 15:49 UTC1080
        }
c19702ae09beatagen2026-05-27 15:49 UTC1081
    }
c19702ae09beatagen2026-05-27 15:49 UTC1082
c19702ae09beatagen2026-05-27 15:49 UTC1083
    fn app() -> HeadroomApp {
c19702ae09beatagen2026-05-27 15:49 UTC1084
        build(vec![], vec![]).0
c19702ae09beatagen2026-05-27 15:49 UTC1085
    }
c19702ae09beatagen2026-05-27 15:49 UTC1086
c19702ae09beatagen2026-05-27 15:49 UTC1087
    fn stream(node_id: u32, app: &str, route: Route) -> StreamRoute {
c19702ae09beatagen2026-05-27 15:49 UTC1088
        StreamRoute {
c19702ae09beatagen2026-05-27 15:49 UTC1089
            node_id,
c19702ae09beatagen2026-05-27 15:49 UTC1090
            app: app.into(),
c19702ae09beatagen2026-05-27 15:49 UTC1091
            route,
c19702ae09beatagen2026-05-27 15:49 UTC1092
        }
c19702ae09beatagen2026-05-27 15:49 UTC1093
    }
c19702ae09beatagen2026-05-27 15:49 UTC1094
c19702ae09beatagen2026-05-27 15:49 UTC1095
    fn snap(node_id: u32, app: &str, managed: bool) -> LayerASnapshot {
c19702ae09beatagen2026-05-27 15:49 UTC1096
        LayerASnapshot {
c19702ae09beatagen2026-05-27 15:49 UTC1097
            node_id,
c19702ae09beatagen2026-05-27 15:49 UTC1098
            app: app.into(),
c19702ae09beatagen2026-05-27 15:49 UTC1099
            managed,
c19702ae09beatagen2026-05-27 15:49 UTC1100
            volume_lin: 1.0,
c19702ae09beatagen2026-05-27 15:49 UTC1101
            reduction_db: 0.0,
c19702ae09beatagen2026-05-27 15:49 UTC1102
            user_ceiling_lin: None,
c19702ae09beatagen2026-05-27 15:49 UTC1103
            deferred: false,
c19702ae09beatagen2026-05-27 15:49 UTC1104
        }
c19702ae09beatagen2026-05-27 15:49 UTC1105
    }
c19702ae09beatagen2026-05-27 15:49 UTC1106
c19702ae09beatagen2026-05-27 15:49 UTC1107
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1108
    fn meter_tick_event_updates_state() {
c19702ae09beatagen2026-05-27 15:49 UTC1109
        let mut s = app();
c19702ae09beatagen2026-05-27 15:49 UTC1110
        let tick = MeterTick {
c19702ae09beatagen2026-05-27 15:49 UTC1111
            momentary_lufs: -19.3,
c19702ae09beatagen2026-05-27 15:49 UTC1112
            shortterm_lufs: -20.1,
c19702ae09beatagen2026-05-27 15:49 UTC1113
            integrated_lufs: -19.8,
c19702ae09beatagen2026-05-27 15:49 UTC1114
            true_peak_dbtp: -1.4,
c19702ae09beatagen2026-05-27 15:49 UTC1115
            gain_reduction_db: -2.1,
c19702ae09beatagen2026-05-27 15:49 UTC1116
            compressor_gr_db: -0.8,
c19702ae09beatagen2026-05-27 15:49 UTC1117
            limiter_gr_db: -1.3,
c19702ae09beatagen2026-05-27 15:49 UTC1118
            agc_gain_db: 0.5,
c19702ae09beatagen2026-05-27 15:49 UTC1119
            agc_enabled: true,
c19702ae09beatagen2026-05-27 15:49 UTC1120
            compressor_enabled: true,
c19702ae09beatagen2026-05-27 15:49 UTC1121
        };
c19702ae09beatagen2026-05-27 15:49 UTC1122
        s.apply_event(Event::new(Topic::Meters, "tick", &tick).unwrap());
c19702ae09beatagen2026-05-27 15:49 UTC1123
        assert!(s.meters.is_some());
c19702ae09beatagen2026-05-27 15:49 UTC1124
        assert!(s.last_meter_at.is_some());
c19702ae09beatagen2026-05-27 15:49 UTC1125
        assert!(!s.meters_stale());
c19702ae09beatagen2026-05-27 15:49 UTC1126
    }
c19702ae09beatagen2026-05-27 15:49 UTC1127
c19702ae09beatagen2026-05-27 15:49 UTC1128
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1129
    fn routing_event_inserts_then_removes_stream() {
c19702ae09beatagen2026-05-27 15:49 UTC1130
        let mut s = app();
c19702ae09beatagen2026-05-27 15:49 UTC1131
        s.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1132
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1133
                Topic::Routing,
c19702ae09beatagen2026-05-27 15:49 UTC1134
                "stream_routed",
c19702ae09beatagen2026-05-27 15:49 UTC1135
                &serde_json::json!({ "node_id": 42, "app": "firefox", "to": "bypass" }),
c19702ae09beatagen2026-05-27 15:49 UTC1136
            )
c19702ae09beatagen2026-05-27 15:49 UTC1137
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1138
        );
c19702ae09beatagen2026-05-27 15:49 UTC1139
        let st = s.streams.get(&42).expect("tracked");
c19702ae09beatagen2026-05-27 15:49 UTC1140
        assert_eq!(st.app, "firefox");
c19702ae09beatagen2026-05-27 15:49 UTC1141
        assert_eq!(st.route, Route::Bypass);
c19702ae09beatagen2026-05-27 15:49 UTC1142
        s.layer_a.insert(42, Some(-3.0));
c19702ae09beatagen2026-05-27 15:49 UTC1143
        s.la_snapshots.insert(42, snap(42, "firefox", true));
c19702ae09beatagen2026-05-27 15:49 UTC1144
c19702ae09beatagen2026-05-27 15:49 UTC1145
        s.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1146
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1147
                Topic::Routing,
c19702ae09beatagen2026-05-27 15:49 UTC1148
                "stream_removed",
c19702ae09beatagen2026-05-27 15:49 UTC1149
                &serde_json::json!({ "node_id": 42 }),
c19702ae09beatagen2026-05-27 15:49 UTC1150
            )
c19702ae09beatagen2026-05-27 15:49 UTC1151
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1152
        );
c19702ae09beatagen2026-05-27 15:49 UTC1153
        assert!(!s.streams.contains_key(&42));
c19702ae09beatagen2026-05-27 15:49 UTC1154
        assert!(!s.layer_a.contains_key(&42));
c19702ae09beatagen2026-05-27 15:49 UTC1155
        assert!(!s.la_snapshots.contains_key(&42));
c19702ae09beatagen2026-05-27 15:49 UTC1156
    }
c19702ae09beatagen2026-05-27 15:49 UTC1157
c19702ae09beatagen2026-05-27 15:49 UTC1158
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1159
    fn layer_a_level_updates_reduction() {
c19702ae09beatagen2026-05-27 15:49 UTC1160
        let mut s = app();
c19702ae09beatagen2026-05-27 15:49 UTC1161
        s.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1162
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1163
                Topic::Routing,
c19702ae09beatagen2026-05-27 15:49 UTC1164
                "layer_a_attached",
c19702ae09beatagen2026-05-27 15:49 UTC1165
                &serde_json::json!({ "node_id": 11, "app": "loud" }),
c19702ae09beatagen2026-05-27 15:49 UTC1166
            )
c19702ae09beatagen2026-05-27 15:49 UTC1167
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1168
        );
c19702ae09beatagen2026-05-27 15:49 UTC1169
        assert_eq!(s.layer_a.get(&11), Some(&None));
c19702ae09beatagen2026-05-27 15:49 UTC1170
        s.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1171
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1172
                Topic::Meters,
c19702ae09beatagen2026-05-27 15:49 UTC1173
                "layer_a_level",
c19702ae09beatagen2026-05-27 15:49 UTC1174
                &serde_json::json!({
c19702ae09beatagen2026-05-27 15:49 UTC1175
                    "node_id": 11, "app": "loud",
c19702ae09beatagen2026-05-27 15:49 UTC1176
                    "volume_lin": 0.25_f32, "reduction_db": -11.8_f32,
c19702ae09beatagen2026-05-27 15:49 UTC1177
                }),
c19702ae09beatagen2026-05-27 15:49 UTC1178
            )
c19702ae09beatagen2026-05-27 15:49 UTC1179
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1180
        );
c19702ae09beatagen2026-05-27 15:49 UTC1181
        let r = s.layer_a.get(&11).copied().flatten().unwrap();
c19702ae09beatagen2026-05-27 15:49 UTC1182
        assert!((r - -11.8).abs() < 1e-4);
c19702ae09beatagen2026-05-27 15:49 UTC1183
    }
c19702ae09beatagen2026-05-27 15:49 UTC1184
c19702ae09beatagen2026-05-27 15:49 UTC1185
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1186
    fn profile_changed_updates_active() {
c19702ae09beatagen2026-05-27 15:49 UTC1187
        let mut s = app();
c19702ae09beatagen2026-05-27 15:49 UTC1188
        s.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1189
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1190
                Topic::Profile,
c19702ae09beatagen2026-05-27 15:49 UTC1191
                "changed",
c19702ae09beatagen2026-05-27 15:49 UTC1192
                &serde_json::json!({ "name": "night", "previous": "default" }),
c19702ae09beatagen2026-05-27 15:49 UTC1193
            )
c19702ae09beatagen2026-05-27 15:49 UTC1194
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1195
        );
c19702ae09beatagen2026-05-27 15:49 UTC1196
        assert_eq!(s.profile, "night");
c19702ae09beatagen2026-05-27 15:49 UTC1197
    }
c19702ae09beatagen2026-05-27 15:49 UTC1198
c19702ae09beatagen2026-05-27 15:49 UTC1199
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1200
    fn profile_used_event_updates_active() {
c19702ae09beatagen2026-05-27 15:49 UTC1201
        // the shape the daemon actually emits on `profile.use`.
c19702ae09beatagen2026-05-27 15:49 UTC1202
        let mut s = app();
c19702ae09beatagen2026-05-27 15:49 UTC1203
        s.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1204
            Event::new(Topic::Profile, "used", &serde_json::json!({ "name": "movie" })).unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1205
        );
c19702ae09beatagen2026-05-27 15:49 UTC1206
        assert_eq!(s.profile, "movie");
c19702ae09beatagen2026-05-27 15:49 UTC1207
    }
c19702ae09beatagen2026-05-27 15:49 UTC1208
c19702ae09beatagen2026-05-27 15:49 UTC1209
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1210
    fn profile_selected_updates_header_optimistically() {
c19702ae09beatagen2026-05-27 15:49 UTC1211
        let (mut s, _cmd) = build(vec![], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1212
        assert_eq!(s.profile, "default");
c19702ae09beatagen2026-05-27 15:49 UTC1213
        s.update(Message::ProfileSelected("party".into()));
c19702ae09beatagen2026-05-27 15:49 UTC1214
        assert_eq!(s.profile, "party");
c19702ae09beatagen2026-05-27 15:49 UTC1215
    }
c19702ae09beatagen2026-05-27 15:49 UTC1216
c19702ae09beatagen2026-05-27 15:49 UTC1217
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1218
    fn profiles_refresh_syncs_active_profile() {
c19702ae09beatagen2026-05-27 15:49 UTC1219
        let (mut s, _tx) = build(vec![], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1220
        s.handle_msg(AppMsg::Profiles(vec![
c19702ae09beatagen2026-05-27 15:49 UTC1221
            ProfileInfo {
c19702ae09beatagen2026-05-27 15:49 UTC1222
                name: "default".into(),
c19702ae09beatagen2026-05-27 15:49 UTC1223
                active: false,
c19702ae09beatagen2026-05-27 15:49 UTC1224
                description: String::new(),
c19702ae09beatagen2026-05-27 15:49 UTC1225
            },
c19702ae09beatagen2026-05-27 15:49 UTC1226
            ProfileInfo {
c19702ae09beatagen2026-05-27 15:49 UTC1227
                name: "gaming".into(),
c19702ae09beatagen2026-05-27 15:49 UTC1228
                active: true,
c19702ae09beatagen2026-05-27 15:49 UTC1229
                description: String::new(),
c19702ae09beatagen2026-05-27 15:49 UTC1230
            },
c19702ae09beatagen2026-05-27 15:49 UTC1231
        ]));
c19702ae09beatagen2026-05-27 15:49 UTC1232
        assert_eq!(s.profile, "gaming");
c19702ae09beatagen2026-05-27 15:49 UTC1233
        assert_eq!(s.profiles.len(), 2);
c19702ae09beatagen2026-05-27 15:49 UTC1234
    }
c19702ae09beatagen2026-05-27 15:49 UTC1235
c19702ae09beatagen2026-05-27 15:49 UTC1236
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1237
    fn reconnect_reseeds_state_and_clears_disconnect() {
c19702ae09beatagen2026-05-27 15:49 UTC1238
        // start with one stream, then simulate the daemon dropping and a
c19702ae09beatagen2026-05-27 15:49 UTC1239
        // *new* instance coming back with a different stream set + active
c19702ae09beatagen2026-05-27 15:49 UTC1240
        // profile (node ids change across a restart).
c19702ae09beatagen2026-05-27 15:49 UTC1241
        let (mut s, _cmd) = build(vec![stream(42, "firefox", Route::Bypass)], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1242
        s.handle_msg(AppMsg::Disconnected("daemon shutdown".into()));
c19702ae09beatagen2026-05-27 15:49 UTC1243
        s.overflow_total = 7;
c19702ae09beatagen2026-05-27 15:49 UTC1244
        s.last_error = Some("stale".into());
c19702ae09beatagen2026-05-27 15:49 UTC1245
        assert!(s.disconnected.is_some());
c19702ae09beatagen2026-05-27 15:49 UTC1246
c19702ae09beatagen2026-05-27 15:49 UTC1247
        s.handle_msg(AppMsg::Reconnected(Box::new(Snapshot {
c19702ae09beatagen2026-05-27 15:49 UTC1248
            status: status_with("music", vec![stream(99, "mpv", Route::Processed)]),
c19702ae09beatagen2026-05-27 15:49 UTC1249
            route_list: RouteList {
c19702ae09beatagen2026-05-27 15:49 UTC1250
                rules: vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1251
                current: vec![stream(99, "mpv", Route::Processed)],
c19702ae09beatagen2026-05-27 15:49 UTC1252
                default_route: Route::Processed,
c19702ae09beatagen2026-05-27 15:49 UTC1253
            },
c19702ae09beatagen2026-05-27 15:49 UTC1254
            profiles: vec![ProfileInfo {
c19702ae09beatagen2026-05-27 15:49 UTC1255
                name: "music".into(),
c19702ae09beatagen2026-05-27 15:49 UTC1256
                active: true,
c19702ae09beatagen2026-05-27 15:49 UTC1257
                description: String::new(),
c19702ae09beatagen2026-05-27 15:49 UTC1258
            }],
c19702ae09beatagen2026-05-27 15:49 UTC1259
        })));
c19702ae09beatagen2026-05-27 15:49 UTC1260
c19702ae09beatagen2026-05-27 15:49 UTC1261
        // banners cleared, per-instance counter reset.
c19702ae09beatagen2026-05-27 15:49 UTC1262
        assert!(s.disconnected.is_none());
c19702ae09beatagen2026-05-27 15:49 UTC1263
        assert!(s.last_error.is_none());
c19702ae09beatagen2026-05-27 15:49 UTC1264
        assert_eq!(s.overflow_total, 0);
c19702ae09beatagen2026-05-27 15:49 UTC1265
        // mirrored state replaced with the new daemon's snapshot.
c19702ae09beatagen2026-05-27 15:49 UTC1266
        assert_eq!(s.profile, "music");
c19702ae09beatagen2026-05-27 15:49 UTC1267
        assert!(!s.streams.contains_key(&42));
c19702ae09beatagen2026-05-27 15:49 UTC1268
        assert_eq!(s.streams.get(&99).map(|r| r.route), Some(Route::Processed));
c19702ae09beatagen2026-05-27 15:49 UTC1269
        assert_eq!(s.profiles.len(), 1);
c19702ae09beatagen2026-05-27 15:49 UTC1270
    }
c19702ae09beatagen2026-05-27 15:49 UTC1271
c19702ae09beatagen2026-05-27 15:49 UTC1272
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1273
    fn daemon_overflow_accumulates() {
c19702ae09beatagen2026-05-27 15:49 UTC1274
        let mut s = app();
c19702ae09beatagen2026-05-27 15:49 UTC1275
        s.apply_event(
c19702ae09beatagen2026-05-27 15:49 UTC1276
            Event::new(
c19702ae09beatagen2026-05-27 15:49 UTC1277
                Topic::Daemon,
c19702ae09beatagen2026-05-27 15:49 UTC1278
                "overflow",
c19702ae09beatagen2026-05-27 15:49 UTC1279
                &serde_json::json!({ "lost_topic": "meters", "lost": 3u32, "total_lost": 5u64 }),
c19702ae09beatagen2026-05-27 15:49 UTC1280
            )
c19702ae09beatagen2026-05-27 15:49 UTC1281
            .unwrap(),
c19702ae09beatagen2026-05-27 15:49 UTC1282
        );
c19702ae09beatagen2026-05-27 15:49 UTC1283
        assert_eq!(s.overflow_total, 5);
c19702ae09beatagen2026-05-27 15:49 UTC1284
    }
c19702ae09beatagen2026-05-27 15:49 UTC1285
c19702ae09beatagen2026-05-27 15:49 UTC1286
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1287
    fn fmt_uptime_buckets() {
c19702ae09beatagen2026-05-27 15:49 UTC1288
        assert_eq!(fmt_uptime(5), "5s");
c19702ae09beatagen2026-05-27 15:49 UTC1289
        assert_eq!(fmt_uptime(75), "1m15s");
c19702ae09beatagen2026-05-27 15:49 UTC1290
        assert_eq!(fmt_uptime(3725), "1h02m05s");
c19702ae09beatagen2026-05-27 15:49 UTC1291
    }
c19702ae09beatagen2026-05-27 15:49 UTC1292
c19702ae09beatagen2026-05-27 15:49 UTC1293
    // -- selection -------------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC1294
c19702ae09beatagen2026-05-27 15:49 UTC1295
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1296
    fn selection_moves_in_key_order_and_wraps() {
c19702ae09beatagen2026-05-27 15:49 UTC1297
        let (mut s, _cmd) = build(
c19702ae09beatagen2026-05-27 15:49 UTC1298
            vec![
c19702ae09beatagen2026-05-27 15:49 UTC1299
                stream(30, "c", Route::Processed),
c19702ae09beatagen2026-05-27 15:49 UTC1300
                stream(10, "a", Route::Processed),
c19702ae09beatagen2026-05-27 15:49 UTC1301
                stream(20, "b", Route::Processed),
c19702ae09beatagen2026-05-27 15:49 UTC1302
            ],
c19702ae09beatagen2026-05-27 15:49 UTC1303
            vec![],
c19702ae09beatagen2026-05-27 15:49 UTC1304
        );
c19702ae09beatagen2026-05-27 15:49 UTC1305
        // seeded selection is the first key (BTreeMap order: 10).
c19702ae09beatagen2026-05-27 15:49 UTC1306
        assert_eq!(s.effective_selection(), Some(10));
c19702ae09beatagen2026-05-27 15:49 UTC1307
        s.update(Message::SelectDown);
c19702ae09beatagen2026-05-27 15:49 UTC1308
        assert_eq!(s.selected, Some(20));
c19702ae09beatagen2026-05-27 15:49 UTC1309
        s.update(Message::SelectDown);
c19702ae09beatagen2026-05-27 15:49 UTC1310
        assert_eq!(s.selected, Some(30));
c19702ae09beatagen2026-05-27 15:49 UTC1311
        s.update(Message::SelectDown);
c19702ae09beatagen2026-05-27 15:49 UTC1312
        assert_eq!(s.selected, Some(10)); // wrap to top
c19702ae09beatagen2026-05-27 15:49 UTC1313
        s.update(Message::SelectUp);
c19702ae09beatagen2026-05-27 15:49 UTC1314
        assert_eq!(s.selected, Some(30)); // wrap to bottom
c19702ae09beatagen2026-05-27 15:49 UTC1315
    }
c19702ae09beatagen2026-05-27 15:49 UTC1316
c19702ae09beatagen2026-05-27 15:49 UTC1317
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1318
    fn selection_empty_is_none() {
c19702ae09beatagen2026-05-27 15:49 UTC1319
        let (mut s, _cmd) = build(vec![], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1320
        s.update(Message::SelectDown);
c19702ae09beatagen2026-05-27 15:49 UTC1321
        assert_eq!(s.selected, None);
c19702ae09beatagen2026-05-27 15:49 UTC1322
        assert_eq!(s.effective_selection(), None);
c19702ae09beatagen2026-05-27 15:49 UTC1323
    }
c19702ae09beatagen2026-05-27 15:49 UTC1324
c19702ae09beatagen2026-05-27 15:49 UTC1325
    // -- control commands emitted by toggles -----------------------------
c19702ae09beatagen2026-05-27 15:49 UTC1326
c19702ae09beatagen2026-05-27 15:49 UTC1327
    fn drain(cmd: &Receiver<ControlCmd>) -> ControlCmd {
c19702ae09beatagen2026-05-27 15:49 UTC1328
        cmd.try_recv().expect("a command was emitted")
c19702ae09beatagen2026-05-27 15:49 UTC1329
    }
c19702ae09beatagen2026-05-27 15:49 UTC1330
c19702ae09beatagen2026-05-27 15:49 UTC1331
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1332
    fn toggle_bypass_emits_and_updates_optimistically() {
c19702ae09beatagen2026-05-27 15:49 UTC1333
        let (mut s, cmd) = build(vec![], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1334
        assert!(!s.bypass);
c19702ae09beatagen2026-05-27 15:49 UTC1335
        s.update(Message::ToggleBypass);
c19702ae09beatagen2026-05-27 15:49 UTC1336
        assert!(matches!(drain(&cmd), ControlCmd::SetBypass(true)));
c19702ae09beatagen2026-05-27 15:49 UTC1337
        assert!(s.bypass); // optimistic
c19702ae09beatagen2026-05-27 15:49 UTC1338
        s.update(Message::ToggleBypass);
c19702ae09beatagen2026-05-27 15:49 UTC1339
        assert!(matches!(drain(&cmd), ControlCmd::SetBypass(false)));
c19702ae09beatagen2026-05-27 15:49 UTC1340
        assert!(!s.bypass);
c19702ae09beatagen2026-05-27 15:49 UTC1341
    }
c19702ae09beatagen2026-05-27 15:49 UTC1342
c19702ae09beatagen2026-05-27 15:49 UTC1343
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1344
    fn toggle_per_app_master_emits_and_updates() {
c19702ae09beatagen2026-05-27 15:49 UTC1345
        let (mut s, cmd) = build(vec![], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1346
        s.update(Message::TogglePerAppMaster);
c19702ae09beatagen2026-05-27 15:49 UTC1347
        assert!(matches!(drain(&cmd), ControlCmd::SetPerAppMaster(true)));
c19702ae09beatagen2026-05-27 15:49 UTC1348
        assert!(s.per_app_master);
c19702ae09beatagen2026-05-27 15:49 UTC1349
    }
c19702ae09beatagen2026-05-27 15:49 UTC1350
c19702ae09beatagen2026-05-27 15:49 UTC1351
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1352
    fn toggle_route_sets_persistent_per_app_rule_and_flips_optimistically() {
c19702ae09beatagen2026-05-27 15:49 UTC1353
        // a labelled stream toggles via a persistent per-app rule
c19702ae09beatagen2026-05-27 15:49 UTC1354
        // (route.set), not a one-shot per-stream reroute — so it
c19702ae09beatagen2026-05-27 15:49 UTC1355
        // survives the app recreating its stream between tracks.
c19702ae09beatagen2026-05-27 15:49 UTC1356
        let (mut s, cmd) = build(vec![stream(42, "firefox", Route::Processed)], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1357
        s.update(Message::ToggleRoute(42));
c19702ae09beatagen2026-05-27 15:49 UTC1358
        match drain(&cmd) {
c19702ae09beatagen2026-05-27 15:49 UTC1359
            ControlCmd::SetRoute { app, to } => {
c19702ae09beatagen2026-05-27 15:49 UTC1360
                assert_eq!(app, "firefox");
c19702ae09beatagen2026-05-27 15:49 UTC1361
                assert_eq!(to, Route::Bypass);
c19702ae09beatagen2026-05-27 15:49 UTC1362
            }
c19702ae09beatagen2026-05-27 15:49 UTC1363
            _ => panic!("expected SetRoute"),
c19702ae09beatagen2026-05-27 15:49 UTC1364
        }
c19702ae09beatagen2026-05-27 15:49 UTC1365
        assert_eq!(s.streams.get(&42).unwrap().route, Route::Bypass); // optimistic
c19702ae09beatagen2026-05-27 15:49 UTC1366
    }
c19702ae09beatagen2026-05-27 15:49 UTC1367
c19702ae09beatagen2026-05-27 15:49 UTC1368
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1369
    fn toggle_route_empty_label_falls_back_to_per_stream() {
c19702ae09beatagen2026-05-27 15:49 UTC1370
        // a stream with no app label can't key a per-app rule, so it
c19702ae09beatagen2026-05-27 15:49 UTC1371
        // falls back to the one-shot per-stream reroute.
c19702ae09beatagen2026-05-27 15:49 UTC1372
        let (mut s, cmd) = build(vec![stream(5, "", Route::Processed)], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1373
        s.update(Message::ToggleRoute(5));
c19702ae09beatagen2026-05-27 15:49 UTC1374
        match drain(&cmd) {
c19702ae09beatagen2026-05-27 15:49 UTC1375
            ControlCmd::RouteStream { node_id, to } => {
c19702ae09beatagen2026-05-27 15:49 UTC1376
                assert_eq!(node_id, 5);
c19702ae09beatagen2026-05-27 15:49 UTC1377
                assert_eq!(to, Route::Bypass);
c19702ae09beatagen2026-05-27 15:49 UTC1378
            }
c19702ae09beatagen2026-05-27 15:49 UTC1379
            _ => panic!("expected RouteStream fallback"),
c19702ae09beatagen2026-05-27 15:49 UTC1380
        }
c19702ae09beatagen2026-05-27 15:49 UTC1381
    }
c19702ae09beatagen2026-05-27 15:49 UTC1382
c19702ae09beatagen2026-05-27 15:49 UTC1383
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1384
    fn route_selected_acts_on_selection() {
c19702ae09beatagen2026-05-27 15:49 UTC1385
        let (mut s, cmd) = build(vec![stream(7, "mpv", Route::Bypass)], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1386
        s.update(Message::RouteSelected);
c19702ae09beatagen2026-05-27 15:49 UTC1387
        match drain(&cmd) {
c19702ae09beatagen2026-05-27 15:49 UTC1388
            ControlCmd::SetRoute { app, to } => {
c19702ae09beatagen2026-05-27 15:49 UTC1389
                assert_eq!(app, "mpv");
c19702ae09beatagen2026-05-27 15:49 UTC1390
                assert_eq!(to, Route::Processed);
c19702ae09beatagen2026-05-27 15:49 UTC1391
            }
c19702ae09beatagen2026-05-27 15:49 UTC1392
            _ => panic!("expected SetRoute"),
c19702ae09beatagen2026-05-27 15:49 UTC1393
        }
c19702ae09beatagen2026-05-27 15:49 UTC1394
    }
c19702ae09beatagen2026-05-27 15:49 UTC1395
c19702ae09beatagen2026-05-27 15:49 UTC1396
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1397
    fn toggle_per_app_targets_inverse_of_managed() {
c19702ae09beatagen2026-05-27 15:49 UTC1398
        // node 1 managed → toggling asks to disable; node 2 unmanaged →
c19702ae09beatagen2026-05-27 15:49 UTC1399
        // toggling asks to enable.
c19702ae09beatagen2026-05-27 15:49 UTC1400
        let (mut s, cmd) = build(
c19702ae09beatagen2026-05-27 15:49 UTC1401
            vec![
c19702ae09beatagen2026-05-27 15:49 UTC1402
                stream(1, "spotify", Route::Processed),
c19702ae09beatagen2026-05-27 15:49 UTC1403
                stream(2, "discord", Route::Processed),
c19702ae09beatagen2026-05-27 15:49 UTC1404
            ],
c19702ae09beatagen2026-05-27 15:49 UTC1405
            vec![snap(1, "spotify", true)],
c19702ae09beatagen2026-05-27 15:49 UTC1406
        );
c19702ae09beatagen2026-05-27 15:49 UTC1407
        s.update(Message::TogglePerApp(1));
c19702ae09beatagen2026-05-27 15:49 UTC1408
        match drain(&cmd) {
c19702ae09beatagen2026-05-27 15:49 UTC1409
            ControlCmd::SetPerApp { app, enabled } => {
c19702ae09beatagen2026-05-27 15:49 UTC1410
                assert_eq!(app, "spotify");
c19702ae09beatagen2026-05-27 15:49 UTC1411
                assert!(!enabled);
c19702ae09beatagen2026-05-27 15:49 UTC1412
            }
c19702ae09beatagen2026-05-27 15:49 UTC1413
            _ => panic!("expected SetPerApp"),
c19702ae09beatagen2026-05-27 15:49 UTC1414
        }
c19702ae09beatagen2026-05-27 15:49 UTC1415
        s.update(Message::TogglePerApp(2));
c19702ae09beatagen2026-05-27 15:49 UTC1416
        match drain(&cmd) {
c19702ae09beatagen2026-05-27 15:49 UTC1417
            ControlCmd::SetPerApp { app, enabled } => {
c19702ae09beatagen2026-05-27 15:49 UTC1418
                assert_eq!(app, "discord");
c19702ae09beatagen2026-05-27 15:49 UTC1419
                assert!(enabled);
c19702ae09beatagen2026-05-27 15:49 UTC1420
            }
c19702ae09beatagen2026-05-27 15:49 UTC1421
            _ => panic!("expected SetPerApp"),
c19702ae09beatagen2026-05-27 15:49 UTC1422
        }
c19702ae09beatagen2026-05-27 15:49 UTC1423
    }
c19702ae09beatagen2026-05-27 15:49 UTC1424
c19702ae09beatagen2026-05-27 15:49 UTC1425
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1426
    fn toggle_per_app_empty_label_errors_no_command() {
c19702ae09beatagen2026-05-27 15:49 UTC1427
        let (mut s, cmd) = build(vec![stream(5, "", Route::Processed)], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1428
        s.update(Message::TogglePerApp(5));
c19702ae09beatagen2026-05-27 15:49 UTC1429
        assert!(cmd.try_recv().is_err()); // no command emitted
c19702ae09beatagen2026-05-27 15:49 UTC1430
        assert!(s.last_error.is_some());
c19702ae09beatagen2026-05-27 15:49 UTC1431
    }
c19702ae09beatagen2026-05-27 15:49 UTC1432
c19702ae09beatagen2026-05-27 15:49 UTC1433
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1434
    fn reset_selected_emits_reset() {
c19702ae09beatagen2026-05-27 15:49 UTC1435
        let (mut s, cmd) = build(vec![stream(9, "game", Route::Processed)], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1436
        s.update(Message::ResetSelected);
c19702ae09beatagen2026-05-27 15:49 UTC1437
        assert!(matches!(drain(&cmd), ControlCmd::ResetLayerA { node_id: 9 }));
c19702ae09beatagen2026-05-27 15:49 UTC1438
    }
c19702ae09beatagen2026-05-27 15:49 UTC1439
c19702ae09beatagen2026-05-27 15:49 UTC1440
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1441
    fn profile_selected_emits_use_profile() {
c19702ae09beatagen2026-05-27 15:49 UTC1442
        let (mut s, cmd) = build(vec![], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1443
        s.update(Message::ProfileSelected("night".into()));
c19702ae09beatagen2026-05-27 15:49 UTC1444
        match drain(&cmd) {
c19702ae09beatagen2026-05-27 15:49 UTC1445
            ControlCmd::UseProfile(name) => assert_eq!(name, "night"),
c19702ae09beatagen2026-05-27 15:49 UTC1446
            _ => panic!("expected UseProfile"),
c19702ae09beatagen2026-05-27 15:49 UTC1447
        }
c19702ae09beatagen2026-05-27 15:49 UTC1448
    }
c19702ae09beatagen2026-05-27 15:49 UTC1449
c19702ae09beatagen2026-05-27 15:49 UTC1450
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1451
    fn clear_overrides_emits_and_clears_optimistically() {
c19702ae09beatagen2026-05-27 15:49 UTC1452
        let (mut s, cmd) = build(vec![], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1453
        s.setting_overrides
c19702ae09beatagen2026-05-27 15:49 UTC1454
            .insert("agc.enabled".into(), serde_json::json!(false));
c19702ae09beatagen2026-05-27 15:49 UTC1455
        s.update(Message::ClearOverrides);
c19702ae09beatagen2026-05-27 15:49 UTC1456
        assert!(matches!(drain(&cmd), ControlCmd::ClearOverrides));
c19702ae09beatagen2026-05-27 15:49 UTC1457
        assert!(s.setting_overrides.is_empty()); // optimistic
c19702ae09beatagen2026-05-27 15:49 UTC1458
    }
c19702ae09beatagen2026-05-27 15:49 UTC1459
c19702ae09beatagen2026-05-27 15:49 UTC1460
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1461
    fn tick_refreshes_layer_a_on_cadence() {
c19702ae09beatagen2026-05-27 15:49 UTC1462
        let (mut s, cmd) = build(vec![], vec![]);
c19702ae09beatagen2026-05-27 15:49 UTC1463
        for _ in 0..(REFRESH_EVERY_TICKS - 1) {
c19702ae09beatagen2026-05-27 15:49 UTC1464
            s.update(Message::Tick);
c19702ae09beatagen2026-05-27 15:49 UTC1465
        }
c19702ae09beatagen2026-05-27 15:49 UTC1466
        assert!(cmd.try_recv().is_err()); // not yet
c19702ae09beatagen2026-05-27 15:49 UTC1467
        s.update(Message::Tick); // the REFRESH_EVERY_TICKS-th tick
c19702ae09beatagen2026-05-27 15:49 UTC1468
        assert!(matches!(drain(&cmd), ControlCmd::RefreshLayerA));
c19702ae09beatagen2026-05-27 15:49 UTC1469
    }
c19702ae09beatagen2026-05-27 15:49 UTC1470
c19702ae09beatagen2026-05-27 15:49 UTC1471
    // -- key mapping -----------------------------------------------------
c19702ae09beatagen2026-05-27 15:49 UTC1472
c19702ae09beatagen2026-05-27 15:49 UTC1473
    fn press(c: &str) -> keyboard::Event {
c19702ae09beatagen2026-05-27 15:49 UTC1474
        keyboard::Event::KeyPressed {
c19702ae09beatagen2026-05-27 15:49 UTC1475
            key: keyboard::Key::Character(c.into()),
c19702ae09beatagen2026-05-27 15:49 UTC1476
            modified_key: keyboard::Key::Character(c.into()),
c19702ae09beatagen2026-05-27 15:49 UTC1477
            physical_key: keyboard::key::Physical::Unidentified(
c19702ae09beatagen2026-05-27 15:49 UTC1478
                keyboard::key::NativeCode::Unidentified,
c19702ae09beatagen2026-05-27 15:49 UTC1479
            ),
c19702ae09beatagen2026-05-27 15:49 UTC1480
            location: keyboard::Location::Standard,
c19702ae09beatagen2026-05-27 15:49 UTC1481
            modifiers: keyboard::Modifiers::default(),
c19702ae09beatagen2026-05-27 15:49 UTC1482
            text: None,
c19702ae09beatagen2026-05-27 15:49 UTC1483
            repeat: false,
c19702ae09beatagen2026-05-27 15:49 UTC1484
        }
c19702ae09beatagen2026-05-27 15:49 UTC1485
    }
c19702ae09beatagen2026-05-27 15:49 UTC1486
c19702ae09beatagen2026-05-27 15:49 UTC1487
    #[test]
c19702ae09beatagen2026-05-27 15:49 UTC1488
    fn key_mapping_matches_tui_hotkeys() {
c19702ae09beatagen2026-05-27 15:49 UTC1489
        assert!(matches!(map_key(press("b")), Message::ToggleBypass));
c19702ae09beatagen2026-05-27 15:49 UTC1490
        assert!(matches!(map_key(press("p")), Message::TogglePerAppMaster));
c19702ae09beatagen2026-05-27 15:49 UTC1491
        assert!(matches!(map_key(press("r")), Message::RouteSelected));
c19702ae09beatagen2026-05-27 15:49 UTC1492
        assert!(matches!(map_key(press("a")), Message::PerAppSelected));
c19702ae09beatagen2026-05-27 15:49 UTC1493
        assert!(matches!(map_key(press("x")), Message::ResetSelected));
c19702ae09beatagen2026-05-27 15:49 UTC1494
        assert!(matches!(map_key(press("j")), Message::SelectDown));
c19702ae09beatagen2026-05-27 15:49 UTC1495
        assert!(matches!(map_key(press("k")), Message::SelectUp));
c19702ae09beatagen2026-05-27 15:49 UTC1496
        assert!(matches!(map_key(press("z")), Message::Noop));
c19702ae09beatagen2026-05-27 15:49 UTC1497
    }
c19702ae09beatagen2026-05-27 15:49 UTC1498
}