atagen

headroom

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