atagen

headroom

public
Star0
Watch0
872b0bdcf4abfb7519411a52946efe6a02cf4bc4
1
ssh://brewery@pls.lobotomise.me:33/atagen/headroom.git
crates/headroom-cli/src/tui.rsRust1333 lines43.6 KB
1
//! `headroom monitor` tui — bus gauges, loudness, per-stream routing.
2
//!
3
//! main thread owns terminal + draw loop; reader thread owns the `Client`,
4
//! forwards events over a channel. no graceful reader shutdown — process
5
//! exit tears the socket down.
6
7
use std::collections::BTreeMap;
8
use std::io;
9
use std::thread;
10
use std::time::{Duration, Instant};
11
12
use crossbeam_channel::{select, tick, unbounded, Receiver};
13
use crossterm::event::{self, Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
14
use headroom_client::{Client, ClientError};
15
use headroom_ipc::{
16
    DaemonEvent, Event, LayerALevel, LayerASnapshot, MeterTick, Route, RoutingEvent, Status,
17
    StreamRoute, Topic,
18
};
19
use ratatui::{
20
    layout::{Alignment, Constraint, Direction, Layout, Rect},
21
    style::{Color, Modifier, Style, Stylize},
22
    text::{Line, Span},
23
    widgets::{Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, Wrap},
24
    Frame, Terminal,
25
};
26
27
#[derive(Debug, thiserror::Error)]
28
pub enum TuiError {
29
    #[error("client: {0}")]
30
    Client(#[from] ClientError),
31
32
    #[error("terminal: {0}")]
33
    Io(#[from] io::Error),
34
}
35
36
/// runs initial rpcs, then hands the client to the reader thread and
37
/// enters the draw loop.
38
pub fn run(mut client: Client) -> Result<(), TuiError> {
39
    // subscribe + initial state before raw mode.
40
    let topics = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon];
41
    client.subscribe(&topics)?;
42
    let status = client.status()?;
43
    let route_list = client.route_list()?;
44
45
    // client is single-connection + the reader owns it for events; open a
46
    // second connection for control (keypress ops).
47
    let mut control = Client::connect_at(client.socket_path())?;
48
49
    let (tx, rx) = unbounded::<Msg>();
50
    let reader_handle = thread::Builder::new()
51
        .name("headroom-monitor-rx".into())
52
        .spawn(move || reader_loop(client, tx))
53
        .map_err(TuiError::Io)?;
54
55
    let mut terminal = ratatui::init();
56
    let outcome = draw_loop(&mut terminal, status, route_list, rx, &mut control);
57
    ratatui::restore();
58
59
    // detach: process exit / dropped channel tears the connection down.
60
    drop(reader_handle);
61
62
    outcome
63
}
64
65
// ---------------------------------------------------------------------------
66
// Reader thread
67
// ---------------------------------------------------------------------------
68
69
enum Msg {
70
    Event(Event),
71
    Disconnected(String),
72
}
73
74
fn reader_loop(mut client: Client, tx: crossbeam_channel::Sender<Msg>) {
75
    loop {
76
        match client.next_event() {
77
            Ok(ev) => {
78
                if tx.send(Msg::Event(ev)).is_err() {
79
                    return;
80
                }
81
            }
82
            Err(e) => {
83
                let _ = tx.send(Msg::Disconnected(e.to_string()));
84
                return;
85
            }
86
        }
87
    }
88
}
89
90
// ---------------------------------------------------------------------------
91
// State
92
// ---------------------------------------------------------------------------
93
94
struct UiState {
95
    daemon_version: String,
96
    profile: String,
97
    bypass: bool,
98
    /// layer a master switch (per-app level control enabled globally).
99
    per_app_master: bool,
100
    /// overlay overrides shadowing the profile; flagged so a stale
101
    /// `headroom set` isn't invisible.
102
    setting_overrides: BTreeMap<String, serde_json::Value>,
103
    /// daemon uptime at connect, plus local elapsed.
104
    base_uptime_s: u64,
105
    connected_at: Instant,
106
    default_route: Route,
107
    streams: BTreeMap<u32, StreamRoute>,
108
    /// per-stream layer a. presence = tap attached; inner = latest smoothed
109
    /// reduction (none until the first `layer_a_level`).
110
    layer_a: BTreeMap<u32, Option<f32>>,
111
    /// richer layer a snapshots (ceiling/deferred/managed), polled from
112
    /// `per-app.list`; feeds the detail line.
113
    la_snapshots: BTreeMap<u32, LayerASnapshot>,
114
    /// selected stream node id; resolved against `streams` at draw time.
115
    selected: Option<u32>,
116
    meters: Option<MeterTick>,
117
    /// last tick arrival; drives staleness when the source goes silent.
118
    last_meter_at: Option<Instant>,
119
    overflow_total: u64,
120
    last_error: Option<String>,
121
    disconnected: Option<String>,
122
    /// modal profile picker; while `Some` it intercepts all keyboard input.
123
    picker: Option<ProfilePicker>,
124
}
125
126
/// modal profile-switcher; `selected` indexes `profiles`.
127
struct ProfilePicker {
128
    profiles: Vec<headroom_ipc::ProfileInfo>,
129
    selected: usize,
130
}
131
132
impl ProfilePicker {
133
    /// move the highlight by `delta` (negative = up), wrapping.
134
    fn move_sel(&mut self, delta: isize) {
135
        if self.profiles.is_empty() {
136
            return;
137
        }
138
        let n = self.profiles.len() as isize;
139
        self.selected = (self.selected as isize + delta).rem_euclid(n) as usize;
140
    }
141
}
142
143
/// initial highlight: active profile, else the daemon's current name, else top.
144
fn initial_picker_selection(profiles: &[headroom_ipc::ProfileInfo], current: &str) -> usize {
145
    profiles
146
        .iter()
147
        .position(|p| p.active)
148
        .or_else(|| profiles.iter().position(|p| p.name == current))
149
        .unwrap_or(0)
150
}
151
152
impl UiState {
153
    fn new(status: Status, route_list: headroom_ipc::RouteList) -> Self {
154
        let mut streams = BTreeMap::new();
155
        for s in route_list.current {
156
            streams.insert(s.node_id, s);
157
        }
158
        // `status.streams` is a superset; merge.
159
        for s in status.streams.iter() {
160
            streams.entry(s.node_id).or_insert_with(|| s.clone());
161
        }
162
        // seed layer a from status so table/detail are populated pre-poll.
163
        let mut la_snapshots = BTreeMap::new();
164
        let mut layer_a = BTreeMap::new();
165
        for snap in status.layer_a {
166
            layer_a.insert(snap.node_id, Some(snap.reduction_db));
167
            la_snapshots.insert(snap.node_id, snap);
168
        }
169
        let selected = streams.keys().next().copied();
170
        Self {
171
            daemon_version: status.version,
172
            profile: status.profile,
173
            bypass: status.bypass,
174
            per_app_master: status.per_app,
175
            setting_overrides: status.setting_overrides,
176
            base_uptime_s: status.uptime_s,
177
            connected_at: Instant::now(),
178
            default_route: route_list.default_route,
179
            streams,
180
            layer_a,
181
            la_snapshots,
182
            selected,
183
            meters: None,
184
            last_meter_at: None,
185
            overflow_total: 0,
186
            last_error: None,
187
            disconnected: None,
188
            picker: None,
189
        }
190
    }
191
192
    fn uptime_s(&self) -> u64 {
193
        self.base_uptime_s
194
            .saturating_add(self.connected_at.elapsed().as_secs())
195
    }
196
197
    /// stream node ids in row order (`BTreeMap` keys).
198
    fn ordered_nodes(&self) -> Vec<u32> {
199
        self.streams.keys().copied().collect()
200
    }
201
202
    /// `selected` when still live, else the first row, else `None`.
203
    fn effective_selection(&self) -> Option<u32> {
204
        match self.selected {
205
            Some(id) if self.streams.contains_key(&id) => Some(id),
206
            _ => self.streams.keys().next().copied(),
207
        }
208
    }
209
210
    /// move the selection by `delta` rows (negative = up), wrapping.
211
    fn move_selection(&mut self, delta: isize) {
212
        let nodes = self.ordered_nodes();
213
        if nodes.is_empty() {
214
            self.selected = None;
215
            return;
216
        }
217
        let cur = self
218
            .effective_selection()
219
            .and_then(|id| nodes.iter().position(|&n| n == id))
220
            .unwrap_or(0) as isize;
221
        let next = (cur + delta).rem_euclid(nodes.len() as isize) as usize;
222
        self.selected = Some(nodes[next]);
223
    }
224
225
    fn apply_event(&mut self, ev: Event) {
226
        match ev.topic {
227
            Topic::Meters if ev.event == "tick" => {
228
                if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) {
229
                    self.meters = Some(m);
230
                    self.last_meter_at = Some(Instant::now());
231
                }
232
            }
233
            Topic::Meters if ev.event == "layer_a_level" => {
234
                if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) {
235
                    self.layer_a.insert(l.node_id, Some(l.reduction_db));
236
                }
237
            }
238
            Topic::Routing => {
239
                if let Ok(re) = serde_json::from_value::<RoutingEvent>(routing_payload(&ev)) {
240
                    match re {
241
                        RoutingEvent::StreamRouted { node_id, app, to } => {
242
                            self.streams.insert(
243
                                node_id,
244
                                StreamRoute {
245
                                    node_id,
246
                                    app,
247
                                    route: to,
248
                                },
249
                            );
250
                        }
251
                        RoutingEvent::StreamRemoved { node_id } => {
252
                            self.streams.remove(&node_id);
253
                            self.layer_a.remove(&node_id);
254
                            self.la_snapshots.remove(&node_id);
255
                        }
256
                        RoutingEvent::LayerAAttached { node_id, .. } => {
257
                            // reduction unknown until the first `layer_a_level`.
258
                            self.layer_a.entry(node_id).or_insert(None);
259
                        }
260
                        RoutingEvent::LayerADetached { node_id } => {
261
                            self.layer_a.remove(&node_id);
262
                            self.la_snapshots.remove(&node_id);
263
                        }
264
                        RoutingEvent::RuleChanged => { /* tui doesn't show rules */ }
265
                        _ => {}
266
                    }
267
                }
268
            }
269
            Topic::Profile => {
270
                match ev.event.as_str() {
271
                    "used" | "changed" => {
272
                        if let Some(name) = ev.data.get("name").and_then(|v| v.as_str()) {
273
                            self.profile = name.to_string();
274
                        }
275
                    }
276
                    _ => {}
277
                }
278
            }
279
            Topic::Daemon => {
280
                if let Ok(de) = serde_json::from_value::<DaemonEvent>(daemon_payload(&ev)) {
281
                    match de {
282
                        DaemonEvent::Overflow {
283
                            lost, total_lost, ..
284
                        } => {
285
                            self.overflow_total = total_lost.max(self.overflow_total + lost as u64);
286
                        }
287
                        DaemonEvent::Error { code, message } => {
288
                            self.last_error = Some(format!("{code}: {message}"));
289
                        }
290
                        DaemonEvent::Shutdown => {
291
                            self.disconnected = Some("daemon shutdown".into());
292
                        }
293
                        DaemonEvent::Started { version } => {
294
                            self.daemon_version = version;
295
                        }
296
                        _ => {}
297
                    }
298
                }
299
            }
300
            _ => {}
301
        }
302
    }
303
}
304
305
/// typed event enums are `#[serde(tag = "event")]` but the wire frame keeps
306
/// `event` outside `data`, so re-inject it before deserializing.
307
fn routing_payload(ev: &Event) -> serde_json::Value {
308
    inject_event(&ev.event, &ev.data)
309
}
310
fn daemon_payload(ev: &Event) -> serde_json::Value {
311
    inject_event(&ev.event, &ev.data)
312
}
313
314
fn inject_event(event: &str, data: &serde_json::Value) -> serde_json::Value {
315
    let mut obj = match data {
316
        serde_json::Value::Object(m) => m.clone(),
317
        _ => serde_json::Map::new(),
318
    };
319
    obj.insert("event".into(), serde_json::Value::String(event.to_string()));
320
    serde_json::Value::Object(obj)
321
}
322
323
// ---------------------------------------------------------------------------
324
// Draw loop
325
// ---------------------------------------------------------------------------
326
327
fn draw_loop<B: ratatui::backend::Backend>(
328
    terminal: &mut Terminal<B>,
329
    status: Status,
330
    route_list: headroom_ipc::RouteList,
331
    rx: Receiver<Msg>,
332
    control: &mut Client,
333
) -> Result<(), TuiError> {
334
    let mut state = UiState::new(status, route_list);
335
    // 10 Hz redraw floor so uptime/staleness tick with no events flowing.
336
    let ticker = tick(Duration::from_millis(100));
337
    let input_rx = spawn_input_thread();
338
    // ~1 Hz poll of the richer layer a snapshot.
339
    let mut poll_ticks: u32 = 0;
340
341
    loop {
342
        terminal.draw(|f| draw(f, &state))?;
343
344
        select! {
345
            recv(rx) -> msg => match msg {
346
                Ok(Msg::Event(ev)) => state.apply_event(ev),
347
                Ok(Msg::Disconnected(reason)) => {
348
                    state.disconnected = Some(reason);
349
                    // final paint, then linger so the banner is seen.
350
                    terminal.draw(|f| draw(f, &state))?;
351
                    thread::sleep(Duration::from_millis(800));
352
                    return Ok(());
353
                }
354
                Err(_) => return Ok(()),
355
            },
356
            recv(input_rx) -> msg => match msg {
357
                Ok(InputMsg::Key(k)) => {
358
                    if handle_key(&mut state, control, k) {
359
                        return Ok(());
360
                    }
361
                }
362
                Ok(InputMsg::Redraw) => {}
363
                Err(_) => return Ok(()),
364
            },
365
            recv(ticker) -> _ => {
366
                poll_ticks = poll_ticks.wrapping_add(1);
367
                if poll_ticks % 10 == 0 {
368
                    poll_layer_a(&mut state, control);
369
                }
370
            }
371
        }
372
    }
373
}
374
375
/// refresh layer a snapshots from the control connection; errors go to the
376
/// footer (non-fatal — the event stream keeps the ui live).
377
fn poll_layer_a(state: &mut UiState, control: &mut Client) {
378
    match control.layer_a_list() {
379
        Ok(list) => {
380
            state.la_snapshots = list.into_iter().map(|s| (s.node_id, s)).collect();
381
        }
382
        Err(e) => {
383
            state.last_error = Some(format!("layer-a poll: {e}"));
384
        }
385
    }
386
}
387
388
/// apply a keypress: nav + toggles + per-row actions. control ops run
389
/// synchronously on the control connection; failures land in the footer.
390
/// returns `true` to quit.
391
fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) -> bool {
392
    // ctrl-c: hard exit, even with the picker open.
393
    if k.modifiers.contains(KeyModifiers::CONTROL)
394
        && matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C'))
395
    {
396
        return true;
397
    }
398
    // picker is modal: while open it swallows every other key.
399
    if state.picker.is_some() {
400
        handle_picker_key(state, control, k);
401
        return false;
402
    }
403
    if is_quit(&k) {
404
        return true;
405
    }
406
    match k.code {
407
        KeyCode::Char('j') | KeyCode::Down => state.move_selection(1),
408
        KeyCode::Char('k') | KeyCode::Up => state.move_selection(-1),
409
        KeyCode::Char('P') => open_profile_picker(state, control),
410
        KeyCode::Char('b') => {
411
            let target = !state.bypass;
412
            match control.bypass_set(target) {
413
                Ok(()) => state.bypass = target,
414
                Err(e) => state.last_error = Some(format!("bypass: {e}")),
415
            }
416
        }
417
        KeyCode::Char('p') => {
418
            let target = !state.per_app_master;
419
            match control.per_app_master(target) {
420
                Ok(()) => state.per_app_master = target,
421
                Err(e) => state.last_error = Some(format!("per-app master: {e}")),
422
            }
423
        }
424
        KeyCode::Char('r') | KeyCode::Enter => {
425
            let Some(node) = state.effective_selection() else {
426
                return false;
427
            };
428
            let Some(stream) = state.streams.get(&node) else {
429
                return false;
430
            };
431
            let app = stream.app.clone();
432
            let to = match stream.route {
433
                Route::Processed => Route::Bypass,
434
                Route::Bypass => Route::Processed,
435
            };
436
            // prefer a persistent per-app rule (keyed by app label) so the
437
            // route survives the app recreating its stream between tracks;
438
            // fall back to a one-shot per-stream reroute when no label.
439
            let result = if app.is_empty() {
440
                control.route_stream(node, to)
441
            } else {
442
                control.route_set(&app, to)
443
            };
444
            match result {
445
                Ok(()) => {
446
                    if let Some(s) = state.streams.get_mut(&node) {
447
                        s.route = to;
448
                    }
449
                }
450
                Err(e) => state.last_error = Some(format!("route: {e}")),
451
            }
452
        }
453
        KeyCode::Char('a') => {
454
            let Some(node) = state.effective_selection() else {
455
                return false;
456
            };
457
            let Some(app) = state.streams.get(&node).map(|s| s.app.clone()) else {
458
                return false;
459
            };
460
            if app.is_empty() {
461
                state.last_error = Some("per-app: selected stream has no app label".into());
462
                return false;
463
            }
464
            let managed = state.la_snapshots.get(&node).is_some_and(|s| s.managed);
465
            if let Err(e) = control.per_app_set(&app, !managed) {
466
                state.last_error = Some(format!("per-app set: {e}"));
467
            }
468
        }
469
        KeyCode::Char('x') => {
470
            let Some(node) = state.effective_selection() else {
471
                return false;
472
            };
473
            if let Err(e) = control.layer_a_reset(node) {
474
                state.last_error = Some(format!("reset: {e}"));
475
            }
476
        }
477
        KeyCode::Char('c') => {
478
            // clear all overlay overrides; no-op when none.
479
            if state.setting_overrides.is_empty() {
480
                return false;
481
            }
482
            match control.setting_reset() {
483
                Ok(_) => state.setting_overrides.clear(),
484
                Err(e) => state.last_error = Some(format!("clear overrides: {e}")),
485
            }
486
        }
487
        _ => {}
488
    }
489
    false
490
}
491
492
/// open the modal profile picker from `profile.list`; errors → footer.
493
fn open_profile_picker(state: &mut UiState, control: &mut Client) {
494
    match control.profile_list() {
495
        Ok(profiles) if profiles.is_empty() => {
496
            state.last_error = Some("profile list is empty".into());
497
        }
498
        Ok(profiles) => {
499
            let selected = initial_picker_selection(&profiles, &state.profile);
500
            state.picker = Some(ProfilePicker { profiles, selected });
501
        }
502
        Err(e) => state.last_error = Some(format!("profile list: {e}")),
503
    }
504
}
505
506
/// handle a keypress while the profile picker is open.
507
fn handle_picker_key(state: &mut UiState, control: &mut Client, k: KeyEvent) {
508
    let Some(picker) = state.picker.as_mut() else {
509
        return;
510
    };
511
    match k.code {
512
        KeyCode::Char('j') | KeyCode::Down => picker.move_sel(1),
513
        KeyCode::Char('k') | KeyCode::Up => picker.move_sel(-1),
514
        KeyCode::Esc | KeyCode::Char('q') => state.picker = None,
515
        KeyCode::Enter => {
516
            let Some(name) = picker.profiles.get(picker.selected).map(|p| p.name.clone()) else {
517
                state.picker = None;
518
                return;
519
            };
520
            match control.profile_use(&name) {
521
                Ok(applied) => {
522
                    // set eagerly; the `profile.changed` event also lands.
523
                    state.profile = applied;
524
                    state.picker = None;
525
                }
526
                Err(e) => {
527
                    // keep the picker open to retry/cancel.
528
                    state.last_error = Some(format!("profile use: {e}"));
529
                }
530
            }
531
        }
532
        _ => {}
533
    }
534
}
535
536
// ---------------------------------------------------------------------------
537
// Input thread
538
// ---------------------------------------------------------------------------
539
540
enum InputMsg {
541
    Key(KeyEvent),
542
    Redraw,
543
}
544
545
fn spawn_input_thread() -> Receiver<InputMsg> {
546
    let (tx, rx) = unbounded::<InputMsg>();
547
    thread::Builder::new()
548
        .name("headroom-monitor-input".into())
549
        .spawn(move || loop {
550
            let Ok(ev) = event::read() else { return };
551
            // forward keys to the draw loop; quit/modal decisions need ui state.
552
            let msg = match ev {
553
                CtEvent::Key(k) => InputMsg::Key(k),
554
                CtEvent::Resize(_, _) => InputMsg::Redraw,
555
                _ => continue,
556
            };
557
            if tx.send(msg).is_err() {
558
                return;
559
            }
560
        })
561
        .expect("spawn input thread");
562
    rx
563
}
564
565
fn is_quit(k: &KeyEvent) -> bool {
566
    matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
567
        || (k.modifiers.contains(KeyModifiers::CONTROL)
568
            && matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')))
569
}
570
571
// ---------------------------------------------------------------------------
572
// Drawing
573
// ---------------------------------------------------------------------------
574
575
fn draw(f: &mut Frame, state: &UiState) {
576
    let area = f.area();
577
    let outer = Block::default()
578
        .borders(Borders::ALL)
579
        .title(Span::styled(
580
            " headroom monitor ",
581
            Style::default().add_modifier(Modifier::BOLD),
582
        ))
583
        .title_top(Line::from(header_status(state)).right_aligned())
584
        .title_bottom(Line::from(footer_text(state)).right_aligned());
585
    let inner = outer.inner(area);
586
    f.render_widget(outer, area);
587
588
    let chunks = Layout::default()
589
        .direction(Direction::Vertical)
590
        .constraints([
591
            Constraint::Length(6), // bus gauges
592
            Constraint::Length(5), // loudness
593
            Constraint::Min(4),    // streams table
594
            Constraint::Length(3), // layer A detail (selected stream)
595
        ])
596
        .split(inner);
597
598
    draw_bus(f, chunks[0], state);
599
    draw_loudness(f, chunks[1], state);
600
    draw_streams(f, chunks[2], state);
601
    draw_layer_a_detail(f, chunks[3], state);
602
603
    // modal overlay, drawn last so it sits on top.
604
    if let Some(picker) = &state.picker {
605
        draw_profile_picker(f, area, picker);
606
    }
607
}
608
609
/// centered rect, at most `width`×`height`, clamped to `area`.
610
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
611
    let w = width.min(area.width);
612
    let h = height.min(area.height);
613
    Rect {
614
        x: area.x + (area.width - w) / 2,
615
        y: area.y + (area.height - h) / 2,
616
        width: w,
617
        height: h,
618
    }
619
}
620
621
/// modal profile switcher: centered list, active marked (●), highlighted
622
/// reversed, plus a description line.
623
fn draw_profile_picker(f: &mut Frame, area: Rect, picker: &ProfilePicker) {
624
    let rows = picker.profiles.len() as u16;
625
    // rows + 2 borders + 2 description lines.
626
    let rect = centered_rect(60, rows + 4, area);
627
628
    f.render_widget(Clear, rect);
629
    let block = Block::default()
630
        .borders(Borders::ALL)
631
        .title(" switch profile ")
632
        .title_bottom(Line::from(" j/k move · Enter apply · Esc cancel ").right_aligned());
633
    let inner = block.inner(rect);
634
    f.render_widget(block, rect);
635
636
    let parts = Layout::default()
637
        .direction(Direction::Vertical)
638
        .constraints([Constraint::Min(1), Constraint::Length(2)])
639
        .split(inner);
640
641
    let lines: Vec<Line> = picker
642
        .profiles
643
        .iter()
644
        .enumerate()
645
        .map(|(i, p)| {
646
            let marker = if p.active { "●" } else { " " };
647
            let style = if i == picker.selected {
648
                Style::default().add_modifier(Modifier::REVERSED)
649
            } else if p.active {
650
                Style::default().fg(Color::Green)
651
            } else {
652
                Style::default()
653
            };
654
            Line::from(vec![
655
                Span::raw(format!(" {marker} ")),
656
                Span::styled(p.name.clone(), style),
657
            ])
658
        })
659
        .collect();
660
    f.render_widget(Paragraph::new(lines), parts[0]);
661
662
    let desc = picker
663
        .profiles
664
        .get(picker.selected)
665
        .map(|p| p.description.clone())
666
        .unwrap_or_default();
667
    f.render_widget(
668
        Paragraph::new(Line::from(Span::styled(
669
            format!(" {desc}"),
670
            Style::default().fg(Color::DarkGray),
671
        )))
672
        .wrap(Wrap { trim: true }),
673
        parts[1],
674
    );
675
}
676
677
fn header_status(state: &UiState) -> Vec<Span<'static>> {
678
    let bypass_span = if state.bypass {
679
        Span::styled(
680
            " BYPASS ",
681
            Style::default().fg(Color::Black).bg(Color::Yellow),
682
        )
683
    } else {
684
        Span::styled(" processed ", Style::default().fg(Color::Green))
685
    };
686
    let per_app_span = if state.per_app_master {
687
        Span::styled(" per-app ", Style::default().fg(Color::Cyan))
688
    } else {
689
        Span::styled(" per-app off ", Style::default().fg(Color::DarkGray))
690
    };
691
    vec![
692
        Span::raw(" profile: "),
693
        Span::styled(state.profile.clone(), Style::default().bold()),
694
        Span::raw("  "),
695
        bypass_span,
696
        Span::raw(" "),
697
        per_app_span,
698
        Span::raw(format!(
699
            "  v{}  uptime {}  ",
700
            state.daemon_version,
701
            fmt_uptime(state.uptime_s())
702
        )),
703
    ]
704
}
705
706
fn footer_text(state: &UiState) -> Vec<Span<'static>> {
707
    let sep = || Span::styled("·", Style::default().fg(Color::DarkGray));
708
    let mut parts: Vec<Span> = vec![
709
        Span::raw(" j/k select "),
710
        sep(),
711
        Span::raw(" r route "),
712
        sep(),
713
        Span::raw(" a per-app "),
714
        sep(),
715
        Span::raw(" x reset "),
716
        sep(),
717
        Span::raw(" b bypass "),
718
        sep(),
719
        Span::raw(" p per-app "),
720
        sep(),
721
        Span::raw(" P profile "),
722
        sep(),
723
        Span::raw(" q quit "),
724
    ];
725
    if state.overflow_total > 0 {
726
        parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
727
        parts.push(Span::styled(
728
            format!(" dropped: {} ", state.overflow_total),
729
            Style::default().fg(Color::Yellow),
730
        ));
731
    }
732
    if let Some(err) = &state.last_error {
733
        parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
734
        parts.push(Span::styled(
735
            format!(" daemon error: {err} "),
736
            Style::default().fg(Color::Red),
737
        ));
738
    }
739
    if let Some(reason) = &state.disconnected {
740
        parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
741
        parts.push(Span::styled(
742
            format!(" disconnected: {reason} "),
743
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
744
        ));
745
    }
746
    if !state.setting_overrides.is_empty() {
747
        parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
748
        parts.push(Span::styled(
749
            format!(
750
                " overrides: {} (c clear) ",
751
                fmt_overrides(&state.setting_overrides)
752
            ),
753
            Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
754
        ));
755
    }
756
    parts
757
}
758
759
/// active overrides as `key=value, …` for the footer (strings unquoted,
760
/// else json).
761
fn fmt_overrides(map: &BTreeMap<String, serde_json::Value>) -> String {
762
    map.iter()
763
        .map(|(k, v)| {
764
            let val = match v {
765
                serde_json::Value::String(s) => s.clone(),
766
                other => other.to_string(),
767
            };
768
            format!("{k}={val}")
769
        })
770
        .collect::<Vec<_>>()
771
        .join(", ")
772
}
773
774
fn draw_bus(f: &mut Frame, area: Rect, state: &UiState) {
775
    let block = Block::default()
776
        .borders(Borders::ALL)
777
        .title(" bus dsp ");
778
    let inner = block.inner(area);
779
    f.render_widget(block, area);
780
781
    let rows = Layout::default()
782
        .direction(Direction::Vertical)
783
        .constraints([
784
            Constraint::Length(1),
785
            Constraint::Length(1),
786
            Constraint::Length(1),
787
            Constraint::Length(1),
788
        ])
789
        .split(inner);
790
791
    let m = state.meters;
792
    draw_gauge_row(
793
        f,
794
        rows[0],
795
        GaugeRow {
796
            label: "AGC target",
797
            value: m.map(|t| t.agc_gain_db),
798
            min: -12.0,
799
            max: 12.0,
800
            unit: "dB",
801
            color: Color::Cyan,
802
            enabled: m.map(|t| t.agc_enabled).unwrap_or(true),
803
        },
804
    );
805
    draw_gauge_row(
806
        f,
807
        rows[1],
808
        GaugeRow {
809
            label: "Compressor GR",
810
            value: m.map(|t| t.compressor_gr_db),
811
            min: -24.0,
812
            max: 0.0,
813
            unit: "dB",
814
            color: Color::Magenta,
815
            enabled: m.map(|t| t.compressor_enabled).unwrap_or(true),
816
        },
817
    );
818
    draw_gauge_row(
819
        f,
820
        rows[2],
821
        GaugeRow {
822
            label: "Limiter GR",
823
            value: m.map(|t| t.limiter_gr_db),
824
            min: -24.0,
825
            max: 0.0,
826
            unit: "dB",
827
            color: Color::Red,
828
            enabled: true, // always-on safety backstop
829
        },
830
    );
831
    draw_gauge_row(
832
        f,
833
        rows[3],
834
        GaugeRow {
835
            label: "True peak",
836
            value: m.map(|t| t.true_peak_dbtp),
837
            min: -60.0,
838
            max: 3.0,
839
            unit: "dBTP",
840
            color: Color::Green,
841
            enabled: true, // a measurement, always meaningful
842
        },
843
    );
844
}
845
846
struct GaugeRow<'a> {
847
    label: &'a str,
848
    value: Option<f32>,
849
    min: f32,
850
    max: f32,
851
    unit: &'a str,
852
    color: Color,
853
    /// stage enabled in the active profile; when false the row renders
854
    /// "disabled" + a greyed bar so a bypassed stage isn't read as 0.
855
    enabled: bool,
856
}
857
858
/// one gauge row: `LABEL   VALUE   [████░░░░] min..max`.
859
fn draw_gauge_row(f: &mut Frame, area: Rect, row: GaugeRow<'_>) {
860
    let GaugeRow {
861
        label,
862
        value,
863
        min,
864
        max,
865
        unit,
866
        color,
867
        enabled,
868
    } = row;
869
    let cols = Layout::default()
870
        .direction(Direction::Horizontal)
871
        .constraints([
872
            Constraint::Length(16),
873
            Constraint::Length(14),
874
            Constraint::Min(8),
875
            Constraint::Length(14),
876
        ])
877
        .split(area);
878
879
    // disabled stage: grey the row + say so, so it can't read as 0.
880
    if !enabled {
881
        let dim = Style::default().fg(Color::DarkGray);
882
        f.render_widget(Paragraph::new(format!(" {label}")).style(dim), cols[0]);
883
        f.render_widget(
884
            Paragraph::new("disabled").alignment(Alignment::Right).style(dim),
885
            cols[1],
886
        );
887
        f.render_widget(
888
            Gauge::default()
889
                .gauge_style(dim)
890
                .ratio(0.0)
891
                .label("off"),
892
            cols[2],
893
        );
894
        f.render_widget(
895
            Paragraph::new(format!("{min:.0}..{max:.0} "))
896
                .alignment(Alignment::Right)
897
                .style(dim),
898
            cols[3],
899
        );
900
        return;
901
    }
902
903
    f.render_widget(Paragraph::new(format!(" {label}")), cols[0]);
904
905
    let value_str = value
906
        .map(|v| format!("{v:+7.2} {unit}"))
907
        .unwrap_or_else(|| "    -- ".to_string());
908
    f.render_widget(
909
        Paragraph::new(value_str).alignment(Alignment::Right),
910
        cols[1],
911
    );
912
913
    let pct = match value {
914
        Some(v) => {
915
            let clamped = v.clamp(min, max);
916
            ((clamped - min) / (max - min)).clamp(0.0, 1.0) as f64
917
        }
918
        None => 0.0,
919
    };
920
    let gauge = Gauge::default()
921
        .gauge_style(Style::default().fg(color))
922
        .ratio(pct)
923
        .label("");
924
    f.render_widget(gauge, cols[2]);
925
926
    f.render_widget(
927
        Paragraph::new(format!("{min:.0}..{max:.0} ")).alignment(Alignment::Right),
928
        cols[3],
929
    );
930
}
931
932
fn draw_loudness(f: &mut Frame, area: Rect, state: &UiState) {
933
    let block = Block::default()
934
        .borders(Borders::ALL)
935
        .title(" loudness (BS.1770) ");
936
    let inner = block.inner(area);
937
    f.render_widget(block, area);
938
939
    let staleness = state
940
        .last_meter_at
941
        .map(|t| t.elapsed())
942
        .unwrap_or(Duration::ZERO);
943
    let stale = staleness > Duration::from_millis(500);
944
945
    let (mom, st, intg) = match state.meters {
946
        Some(m) => (Some(m.momentary_lufs), Some(m.shortterm_lufs), Some(m.integrated_lufs)),
947
        None => (None, None, None),
948
    };
949
950
    let lines = vec![
951
        lufs_line("Momentary  (400 ms)", mom, stale),
952
        lufs_line("Short-term (3 s)", st, stale),
953
        lufs_line("Integrated (gated)", intg, stale),
954
    ];
955
    f.render_widget(Paragraph::new(lines), inner);
956
}
957
958
fn lufs_line(label: &str, v: Option<f32>, stale: bool) -> Line<'static> {
959
    let val = match v {
960
        Some(x) if x > headroom_core::agc::LOUDNESS_FLOOR_LUFS + 0.5 => {
961
            format!("{x:+7.2} LUFS")
962
        }
963
        Some(_) => "    -- LUFS".to_string(),
964
        None => "    -- LUFS".to_string(),
965
    };
966
    let style = if stale {
967
        Style::default().fg(Color::DarkGray)
968
    } else {
969
        Style::default()
970
    };
971
    Line::from(vec![
972
        Span::raw(format!(" {label:<24}")),
973
        Span::styled(val, style),
974
    ])
975
}
976
977
fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
978
    let title = format!(
979
        " streams ({}) — default: {} ",
980
        state.streams.len(),
981
        state.default_route
982
    );
983
    let block = Block::default().borders(Borders::ALL).title(title);
984
985
    let header = Row::new(vec!["", "node", "app", "route", "per-app"])
986
        .style(Style::default().add_modifier(Modifier::BOLD));
987
988
    let selected = state.effective_selection();
989
    let rows: Vec<Row> = state
990
        .streams
991
        .values()
992
        .map(|s| {
993
            let is_sel = selected == Some(s.node_id);
994
            let route_cell = match s.route {
995
                Route::Processed => Cell::from("processed").style(Style::default().fg(Color::Green)),
996
                Route::Bypass => Cell::from("bypass").style(Style::default().fg(Color::Yellow)),
997
            };
998
            let la_cell = match state.layer_a.get(&s.node_id) {
999
                Some(Some(db)) => Cell::from(format!("{db:+5.1} dB"))
1000
                    .style(Style::default().fg(Color::Magenta)),
1001
                Some(None) => Cell::from("attached")
1002
                    .style(Style::default().fg(Color::DarkGray)),
1003
                None => Cell::from("—").style(Style::default().fg(Color::DarkGray)),
1004
            };
1005
            let marker = if is_sel { "▶" } else { " " };
1006
            let row = Row::new(vec![
1007
                Cell::from(marker),
1008
                Cell::from(s.node_id.to_string()),
1009
                Cell::from(s.app.clone()),
1010
                route_cell,
1011
                la_cell,
1012
            ]);
1013
            if is_sel {
1014
                row.style(Style::default().add_modifier(Modifier::REVERSED))
1015
            } else {
1016
                row
1017
            }
1018
        })
1019
        .collect();
1020
1021
    let widths = [
1022
        Constraint::Length(2),
1023
        Constraint::Length(8),
1024
        Constraint::Min(20),
1025
        Constraint::Length(12),
1026
        Constraint::Length(10),
1027
    ];
1028
    let table = Table::new(rows, widths).header(header).block(block);
1029
    f.render_widget(table, area);
1030
}
1031
1032
/// read-only layer a detail for the selected stream: managed flag,
1033
/// smoothed reduction, user ceiling, deference lock.
1034
fn draw_layer_a_detail(f: &mut Frame, area: Rect, state: &UiState) {
1035
    let block = Block::default()
1036
        .borders(Borders::ALL)
1037
        .title(" per-app level (selected) ");
1038
    let inner = block.inner(area);
1039
    f.render_widget(block, area);
1040
1041
    let line = match state.effective_selection() {
1042
        None => Line::from(Span::styled(
1043
            " no stream selected",
1044
            Style::default().fg(Color::DarkGray),
1045
        )),
1046
        Some(node) => {
1047
            let app = state
1048
                .streams
1049
                .get(&node)
1050
                .map(|s| s.app.clone())
1051
                .unwrap_or_default();
1052
            match state.la_snapshots.get(&node) {
1053
                Some(snap) => {
1054
                    let ceiling = snap
1055
                        .user_ceiling_lin
1056
                        .map(|c| format!("{c:.2}"))
1057
                        .unwrap_or_else(|| "—".to_string());
1058
                    let deferred = if snap.deferred {
1059
                        Span::styled("deferred", Style::default().fg(Color::Yellow))
1060
                    } else {
1061
                        Span::styled("active", Style::default().fg(Color::Green))
1062
                    };
1063
                    Line::from(vec![
1064
                        Span::raw(format!(" node {node}  {app}  ")),
1065
                        Span::styled(
1066
                            if snap.managed { "managed" } else { "unmanaged" },
1067
                            Style::default().fg(if snap.managed {
1068
                                Color::Cyan
1069
                            } else {
1070
                                Color::DarkGray
1071
                            }),
1072
                        ),
1073
                        Span::raw(format!(
1074
                            "  reduction {:+.1} dB  ceiling {ceiling}  ",
1075
                            snap.reduction_db
1076
                        )),
1077
                        deferred,
1078
                    ])
1079
                }
1080
                None => Line::from(Span::styled(
1081
                    format!(" node {node}  {app}  not managed per-app"),
1082
                    Style::default().fg(Color::DarkGray),
1083
                )),
1084
            }
1085
        }
1086
    };
1087
    f.render_widget(Paragraph::new(line), inner);
1088
}
1089
1090
fn fmt_uptime(s: u64) -> String {
1091
    let h = s / 3600;
1092
    let m = (s % 3600) / 60;
1093
    let sec = s % 60;
1094
    if h > 0 {
1095
        format!("{h}h{m:02}m{sec:02}s")
1096
    } else if m > 0 {
1097
        format!("{m}m{sec:02}s")
1098
    } else {
1099
        format!("{sec}s")
1100
    }
1101
}
1102
1103
#[cfg(test)]
1104
mod state_tests;
1105
1106
#[cfg(test)]
1107
mod tests {
1108
    use super::*;
1109
    use headroom_ipc::{Sinks, Status};
1110
1111
    fn empty_state() -> UiState {
1112
        let status = Status {
1113
            version: "test".into(),
1114
            protocol: 1,
1115
            uptime_s: 0,
1116
            profile: "default".into(),
1117
            bypass: false,
1118
            per_app: false,
1119
            sinks: Sinks::default(),
1120
            streams: vec![],
1121
            layer_a: vec![],
1122
            warnings: vec![],
1123
            setting_overrides: Default::default(),
1124
        };
1125
        let route_list = headroom_ipc::RouteList {
1126
            rules: vec![],
1127
            current: vec![],
1128
            default_route: Route::Processed,
1129
        };
1130
        UiState::new(status, route_list)
1131
    }
1132
1133
    #[test]
1134
    fn meter_tick_event_updates_state() {
1135
        let mut state = empty_state();
1136
        let tick = MeterTick {
1137
            momentary_lufs: -19.3,
1138
            shortterm_lufs: -20.1,
1139
            integrated_lufs: -19.8,
1140
            true_peak_dbtp: -1.4,
1141
            gain_reduction_db: -2.1,
1142
            compressor_gr_db: -0.8,
1143
            limiter_gr_db: -1.3,
1144
            agc_gain_db: 0.5,
1145
            agc_enabled: true,
1146
            compressor_enabled: true,
1147
        };
1148
        let ev = Event::new(Topic::Meters, "tick", &tick).unwrap();
1149
        state.apply_event(ev);
1150
        let got = state.meters.expect("meters set");
1151
        assert!((got.momentary_lufs - tick.momentary_lufs).abs() < f32::EPSILON);
1152
        assert!((got.true_peak_dbtp - tick.true_peak_dbtp).abs() < f32::EPSILON);
1153
        assert!(state.last_meter_at.is_some());
1154
    }
1155
1156
    #[test]
1157
    fn stream_removed_prunes_state() {
1158
        let mut state = empty_state();
1159
        // insert via stream_routed first.
1160
        state.apply_event(
1161
            Event::new(
1162
                Topic::Routing,
1163
                "stream_routed",
1164
                &serde_json::json!({ "node_id": 7, "app": "x", "to": "processed" }),
1165
            )
1166
            .unwrap(),
1167
        );
1168
        state.apply_event(
1169
            Event::new(
1170
                Topic::Routing,
1171
                "layer_a_attached",
1172
                &serde_json::json!({ "node_id": 7, "app": "x" }),
1173
            )
1174
            .unwrap(),
1175
        );
1176
        assert!(state.streams.contains_key(&7));
1177
        assert!(state.layer_a.contains_key(&7));
1178
        state.la_snapshots.insert(
1179
            7,
1180
            LayerASnapshot {
1181
                node_id: 7,
1182
                app: "x".into(),
1183
                managed: true,
1184
                volume_lin: 1.0,
1185
                reduction_db: -3.0,
1186
                user_ceiling_lin: Some(0.8),
1187
                deferred: true,
1188
            },
1189
        );
1190
1191
        state.apply_event(
1192
            Event::new(
1193
                Topic::Routing,
1194
                "stream_removed",
1195
                &serde_json::json!({ "node_id": 7 }),
1196
            )
1197
            .unwrap(),
1198
        );
1199
        assert!(!state.streams.contains_key(&7));
1200
        assert!(!state.layer_a.contains_key(&7));
1201
        assert!(!state.la_snapshots.contains_key(&7));
1202
    }
1203
1204
    #[test]
1205
    fn layer_a_level_updates_reduction() {
1206
        let mut state = empty_state();
1207
        state.apply_event(
1208
            Event::new(
1209
                Topic::Routing,
1210
                "layer_a_attached",
1211
                &serde_json::json!({ "node_id": 11, "app": "loud-app" }),
1212
            )
1213
            .unwrap(),
1214
        );
1215
        assert_eq!(state.layer_a.get(&11), Some(&None));
1216
1217
        state.apply_event(
1218
            Event::new(
1219
                Topic::Meters,
1220
                "layer_a_level",
1221
                &serde_json::json!({
1222
                    "node_id": 11,
1223
                    "app": "loud-app",
1224
                    "volume_lin": 0.256_f32,
1225
                    "reduction_db": -11.8_f32,
1226
                }),
1227
            )
1228
            .unwrap(),
1229
        );
1230
        let r = state.layer_a.get(&11).copied().flatten().unwrap();
1231
        assert!((r - -11.8).abs() < 1e-4);
1232
    }
1233
1234
    #[test]
1235
    fn routing_event_inserts_stream() {
1236
        let mut state = empty_state();
1237
        let ev = Event::new(
1238
            Topic::Routing,
1239
            "stream_routed",
1240
            &serde_json::json!({
1241
                "node_id": 42,
1242
                "app": "firefox",
1243
                "to": "bypass",
1244
            }),
1245
        )
1246
        .unwrap();
1247
        state.apply_event(ev);
1248
        let s = state.streams.get(&42).expect("stream tracked");
1249
        assert_eq!(s.app, "firefox");
1250
        assert_eq!(s.route, Route::Bypass);
1251
    }
1252
1253
    #[test]
1254
    fn profile_changed_updates_active() {
1255
        let mut state = empty_state();
1256
        let ev = Event::new(
1257
            Topic::Profile,
1258
            "changed",
1259
            &serde_json::json!({
1260
                "name": "night",
1261
                "previous": "default",
1262
            }),
1263
        )
1264
        .unwrap();
1265
        state.apply_event(ev);
1266
        assert_eq!(state.profile, "night");
1267
    }
1268
1269
    #[test]
1270
    fn daemon_overflow_accumulates() {
1271
        let mut state = empty_state();
1272
        let ev = Event::new(
1273
            Topic::Daemon,
1274
            "overflow",
1275
            &serde_json::json!({
1276
                "lost_topic": "meters",
1277
                "lost": 3u32,
1278
                "total_lost": 5u64,
1279
            }),
1280
        )
1281
        .unwrap();
1282
        state.apply_event(ev);
1283
        assert_eq!(state.overflow_total, 5);
1284
    }
1285
1286
    fn pinfo(name: &str, active: bool) -> headroom_ipc::ProfileInfo {
1287
        headroom_ipc::ProfileInfo {
1288
            name: name.into(),
1289
            active,
1290
            description: format!("{name} desc"),
1291
        }
1292
    }
1293
1294
    #[test]
1295
    fn picker_initial_selection_prefers_active() {
1296
        let profiles = vec![
1297
            pinfo("default", false),
1298
            pinfo("night", true),
1299
            pinfo("speech", false),
1300
        ];
1301
        assert_eq!(initial_picker_selection(&profiles, "default"), 1);
1302
    }
1303
1304
    #[test]
1305
    fn picker_initial_selection_falls_back_to_current_name_then_top() {
1306
        let profiles = vec![pinfo("default", false), pinfo("night", false)];
1307
        // no active flag: match the daemon's reported current name.
1308
        assert_eq!(initial_picker_selection(&profiles, "night"), 1);
1309
        // neither active nor a name match: default to the top.
1310
        assert_eq!(initial_picker_selection(&profiles, "ghost"), 0);
1311
    }
1312
1313
    #[test]
1314
    fn picker_move_sel_wraps_both_ways() {
1315
        let mut p = ProfilePicker {
1316
            profiles: vec![pinfo("a", false), pinfo("b", false), pinfo("c", false)],
1317
            selected: 0,
1318
        };
1319
        p.move_sel(-1);
1320
        assert_eq!(p.selected, 2); // wrap up from top
1321
        p.move_sel(1);
1322
        assert_eq!(p.selected, 0); // wrap down from bottom
1323
        p.move_sel(2);
1324
        assert_eq!(p.selected, 2);
1325
    }
1326
1327
    #[test]
1328
    fn fmt_uptime_buckets() {
1329
        assert_eq!(fmt_uptime(5), "5s");
1330
        assert_eq!(fmt_uptime(75), "1m15s");
1331
        assert_eq!(fmt_uptime(3725), "1h02m05s");
1332
    }
1333
}