Skip to main content

cu_tuimon/
ui.rs

1use crate::MonitorModel;
2#[cfg(feature = "log_pane")]
3use crate::logpane::StyledLine;
4use crate::model::{ComponentStatus, MonitorFooterIdentity};
5use crate::palette;
6#[cfg(feature = "sysinfo")]
7use crate::system_info::{SystemInfo, default_system_info};
8use crate::tui_nodes::{Connection, NodeGraph, NodeLayout};
9#[cfg(all(
10    not(all(target_family = "wasm", target_os = "unknown")),
11    feature = "sysinfo"
12))]
13use ansi_to_tui::IntoText;
14use ratatui::Frame;
15use ratatui::buffer::Buffer;
16use ratatui::layout::{Alignment, Constraint, Direction, Layout, Position, Rect, Size};
17use ratatui::prelude::Stylize;
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::text::{Line, Span, Text};
20use ratatui::widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, StatefulWidget, Table};
21use std::collections::HashMap;
22use std::marker::PhantomData;
23use tui_widgets::scrollview::{ScrollView, ScrollViewState};
24
25use cu29::monitoring::{ComponentId, ComponentType, MonitorComponentMetadata};
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum MonitorScreen {
29    #[cfg(feature = "sysinfo")]
30    System,
31    Dag,
32    Latency,
33    CopperList,
34    MemoryPools,
35    #[cfg(feature = "log_pane")]
36    Logs,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum ScrollDirection {
41    Up,
42    Down,
43    Left,
44    Right,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub enum MonitorUiAction {
49    None,
50    QuitRequested,
51    #[cfg(feature = "log_pane")]
52    CopyLogSelection(String),
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum MonitorUiKey {
57    Char(char),
58    Left,
59    Right,
60    Up,
61    Down,
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub enum MonitorUiEvent {
66    Key(MonitorUiKey),
67    MouseDown {
68        col: u16,
69        row: u16,
70    },
71    #[cfg(feature = "log_pane")]
72    MouseDrag {
73        col: u16,
74        row: u16,
75    },
76    #[cfg(feature = "log_pane")]
77    MouseUp {
78        col: u16,
79        row: u16,
80    },
81    Scroll {
82        direction: ScrollDirection,
83        steps: usize,
84    },
85}
86
87#[derive(Clone, Debug, Default)]
88pub struct MonitorUiOptions {
89    pub show_quit_hint: bool,
90}
91
92#[derive(Clone, Copy)]
93struct TabDef {
94    screen: MonitorScreen,
95    label: &'static str,
96}
97
98#[derive(Clone, Copy)]
99struct TabHitbox {
100    screen: MonitorScreen,
101    x: u16,
102    y: u16,
103    width: u16,
104    height: u16,
105}
106
107#[derive(Clone, Copy)]
108enum HelpAction {
109    ResetLatency,
110    Quit,
111}
112
113#[derive(Clone, Copy)]
114struct HelpHitbox {
115    action: HelpAction,
116    x: u16,
117    y: u16,
118    width: u16,
119    height: u16,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq)]
123struct FooterBadge {
124    inner: String,
125    bg: Color,
126    fg: Color,
127}
128
129const TAB_DEFS: &[TabDef] = &[
130    #[cfg(feature = "sysinfo")]
131    TabDef {
132        screen: MonitorScreen::System,
133        label: "SYS",
134    },
135    TabDef {
136        screen: MonitorScreen::Dag,
137        label: "DAG",
138    },
139    TabDef {
140        screen: MonitorScreen::Latency,
141        label: "LAT",
142    },
143    TabDef {
144        screen: MonitorScreen::CopperList,
145        label: "BW",
146    },
147    TabDef {
148        screen: MonitorScreen::MemoryPools,
149        label: "MEM",
150    },
151    #[cfg(feature = "log_pane")]
152    TabDef {
153        screen: MonitorScreen::Logs,
154        label: "LOG",
155    },
156];
157
158#[cfg(feature = "log_pane")]
159#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
160struct SelectionPoint {
161    row: usize,
162    col: usize,
163}
164
165#[cfg(feature = "log_pane")]
166#[derive(Clone, Copy, Debug, Default)]
167struct LogSelection {
168    anchor: Option<SelectionPoint>,
169    cursor: Option<SelectionPoint>,
170}
171
172#[cfg(feature = "log_pane")]
173impl LogSelection {
174    fn clear(&mut self) {
175        self.anchor = None;
176        self.cursor = None;
177    }
178
179    fn start(&mut self, point: SelectionPoint) {
180        self.anchor = Some(point);
181        self.cursor = Some(point);
182    }
183
184    fn update(&mut self, point: SelectionPoint) {
185        if self.anchor.is_some() {
186            self.cursor = Some(point);
187        }
188    }
189
190    fn range(&self) -> Option<(SelectionPoint, SelectionPoint)> {
191        let anchor = self.anchor?;
192        let cursor = self.cursor?;
193        if (anchor.row, anchor.col) <= (cursor.row, cursor.col) {
194            Some((anchor, cursor))
195        } else {
196            Some((cursor, anchor))
197        }
198    }
199}
200
201pub struct MonitorUi {
202    model: MonitorModel,
203    runtime_node_col_width: u16,
204    active_screen: MonitorScreen,
205    #[cfg(feature = "sysinfo")]
206    system_info: SystemInfo,
207    show_quit_hint: bool,
208    tab_hitboxes: Vec<TabHitbox>,
209    help_hitboxes: Vec<HelpHitbox>,
210    nodes_scrollable_widget_state: NodesScrollableWidgetState,
211    latency_scroll_state: ScrollViewState,
212    #[cfg(feature = "log_pane")]
213    log_area: Option<Rect>,
214    #[cfg(feature = "log_pane")]
215    log_lines: Vec<StyledLine>,
216    #[cfg(feature = "log_pane")]
217    log_selection: LogSelection,
218    #[cfg(feature = "log_pane")]
219    log_offset_from_bottom: usize,
220}
221
222impl MonitorUi {
223    pub fn new(model: MonitorModel, options: MonitorUiOptions) -> Self {
224        let runtime_node_col_width = Self::compute_runtime_node_col_width(model.components());
225        let nodes_scrollable_widget_state = NodesScrollableWidgetState::new(model.clone());
226        Self {
227            model,
228            runtime_node_col_width,
229            active_screen: MonitorScreen::Dag,
230            #[cfg(feature = "sysinfo")]
231            system_info: default_system_info(),
232            show_quit_hint: options.show_quit_hint,
233            tab_hitboxes: Vec::new(),
234            help_hitboxes: Vec::new(),
235            nodes_scrollable_widget_state,
236            latency_scroll_state: ScrollViewState::default(),
237            #[cfg(feature = "log_pane")]
238            log_area: None,
239            #[cfg(feature = "log_pane")]
240            log_lines: Vec::new(),
241            #[cfg(feature = "log_pane")]
242            log_selection: LogSelection::default(),
243            #[cfg(feature = "log_pane")]
244            log_offset_from_bottom: 0,
245        }
246    }
247
248    pub fn active_screen(&self) -> MonitorScreen {
249        self.active_screen
250    }
251
252    pub fn model(&self) -> &MonitorModel {
253        &self.model
254    }
255
256    pub fn set_active_screen(&mut self, screen: MonitorScreen) {
257        self.active_screen = screen;
258    }
259
260    pub fn handle_event(&mut self, event: MonitorUiEvent) -> MonitorUiAction {
261        match event {
262            MonitorUiEvent::Key(key) => self.handle_key(key),
263            MonitorUiEvent::MouseDown { col, row } => self.click(col, row),
264            #[cfg(feature = "log_pane")]
265            MonitorUiEvent::MouseDrag { col, row } => self.drag_log_selection(col, row),
266            #[cfg(feature = "log_pane")]
267            MonitorUiEvent::MouseUp { col, row } => self.finish_log_selection(col, row),
268            MonitorUiEvent::Scroll { direction, steps } => {
269                self.scroll(direction, steps);
270                MonitorUiAction::None
271            }
272        }
273    }
274
275    pub fn handle_key(&mut self, key: MonitorUiKey) -> MonitorUiAction {
276        match key {
277            MonitorUiKey::Char(key) => {
278                if let Some(screen) = screen_for_tab_key(key) {
279                    self.active_screen = screen;
280                } else {
281                    match key {
282                        'r' if self.active_screen == MonitorScreen::Latency => {
283                            self.model.reset_latency();
284                        }
285                        'j' => self.scroll(ScrollDirection::Down, 1),
286                        'k' => self.scroll(ScrollDirection::Up, 1),
287                        'h' => self.scroll(ScrollDirection::Left, 5),
288                        'l' => self.scroll(ScrollDirection::Right, 5),
289                        'q' if self.show_quit_hint => return MonitorUiAction::QuitRequested,
290                        _ => {}
291                    }
292                }
293            }
294            MonitorUiKey::Left => self.scroll(ScrollDirection::Left, 5),
295            MonitorUiKey::Right => self.scroll(ScrollDirection::Right, 5),
296            MonitorUiKey::Up => self.scroll(ScrollDirection::Up, 1),
297            MonitorUiKey::Down => self.scroll(ScrollDirection::Down, 1),
298        }
299
300        MonitorUiAction::None
301    }
302
303    pub fn handle_char_key(&mut self, key: char) -> MonitorUiAction {
304        self.handle_key(MonitorUiKey::Char(key))
305    }
306
307    pub fn scroll(&mut self, direction: ScrollDirection, steps: usize) {
308        match (self.active_screen, direction) {
309            (MonitorScreen::Dag, ScrollDirection::Down) => {
310                self.nodes_scrollable_widget_state
311                    .nodes_scrollable_state
312                    .scroll_down();
313            }
314            (MonitorScreen::Dag, ScrollDirection::Up) => {
315                self.nodes_scrollable_widget_state
316                    .nodes_scrollable_state
317                    .scroll_up();
318            }
319            (MonitorScreen::Latency, ScrollDirection::Down) => {
320                self.latency_scroll_state.scroll_down();
321            }
322            (MonitorScreen::Latency, ScrollDirection::Up) => {
323                self.latency_scroll_state.scroll_up();
324            }
325            (MonitorScreen::Dag, ScrollDirection::Right) => {
326                for _ in 0..steps {
327                    self.nodes_scrollable_widget_state
328                        .nodes_scrollable_state
329                        .scroll_right();
330                }
331            }
332            (MonitorScreen::Dag, ScrollDirection::Left) => {
333                for _ in 0..steps {
334                    self.nodes_scrollable_widget_state
335                        .nodes_scrollable_state
336                        .scroll_left();
337                }
338            }
339            (MonitorScreen::Latency, ScrollDirection::Right) => {
340                for _ in 0..steps {
341                    self.latency_scroll_state.scroll_right();
342                }
343            }
344            (MonitorScreen::Latency, ScrollDirection::Left) => {
345                for _ in 0..steps {
346                    self.latency_scroll_state.scroll_left();
347                }
348            }
349            #[cfg(feature = "log_pane")]
350            (MonitorScreen::Logs, ScrollDirection::Up) => {
351                self.log_offset_from_bottom = self.log_offset_from_bottom.saturating_add(steps);
352            }
353            #[cfg(feature = "log_pane")]
354            (MonitorScreen::Logs, ScrollDirection::Down) => {
355                self.log_offset_from_bottom = self.log_offset_from_bottom.saturating_sub(steps);
356            }
357            _ => {}
358        }
359    }
360
361    pub fn click(&mut self, x: u16, y: u16) -> MonitorUiAction {
362        for hitbox in &self.tab_hitboxes {
363            if point_inside(x, y, hitbox.x, hitbox.y, hitbox.width, hitbox.height) {
364                self.active_screen = hitbox.screen;
365                return MonitorUiAction::None;
366            }
367        }
368
369        for hitbox in &self.help_hitboxes {
370            if !point_inside(x, y, hitbox.x, hitbox.y, hitbox.width, hitbox.height) {
371                continue;
372            }
373            match hitbox.action {
374                HelpAction::ResetLatency => {
375                    if self.active_screen == MonitorScreen::Latency {
376                        self.model.reset_latency();
377                    }
378                }
379                HelpAction::Quit => return MonitorUiAction::QuitRequested,
380            }
381            return MonitorUiAction::None;
382        }
383
384        #[cfg(feature = "log_pane")]
385        if self.active_screen == MonitorScreen::Logs {
386            return self.start_log_selection(x, y);
387        }
388
389        MonitorUiAction::None
390    }
391
392    pub fn mark_graph_dirty(&mut self) {
393        self.nodes_scrollable_widget_state.mark_graph_dirty();
394    }
395
396    pub fn draw(&mut self, f: &mut Frame) {
397        let layout = Layout::default()
398            .direction(Direction::Vertical)
399            .constraints(
400                [
401                    Constraint::Length(1),
402                    Constraint::Min(0),
403                    Constraint::Length(1),
404                ]
405                .as_ref(),
406            )
407            .split(f.area());
408
409        self.render_tabs(f, layout[0]);
410        self.render_help(f, layout[2]);
411        self.draw_content(f, layout[1]);
412    }
413
414    pub fn draw_content(&mut self, f: &mut Frame, area: Rect) {
415        // Avoid backend-specific "reset" colors bleeding through the scrollable canvases.
416        f.render_widget(
417            Block::default().style(Style::default().bg(palette::BACKGROUND)),
418            area,
419        );
420
421        match self.active_screen {
422            #[cfg(feature = "sysinfo")]
423            MonitorScreen::System => self.draw_system_info(f, area),
424            MonitorScreen::Dag => self.draw_nodes(f, area),
425            MonitorScreen::Latency => self.draw_latency_table(f, area),
426            MonitorScreen::CopperList => self.draw_copperlist_stats(f, area),
427            MonitorScreen::MemoryPools => self.draw_memory_pools(f, area),
428            #[cfg(feature = "log_pane")]
429            MonitorScreen::Logs => self.draw_logs(f, area),
430        }
431    }
432
433    fn compute_runtime_node_col_width(components: &'static [MonitorComponentMetadata]) -> u16 {
434        const MIN_WIDTH: usize = 24;
435        const MAX_WIDTH: usize = 56;
436
437        let header_width = "Runtime Node".chars().count();
438        let max_name_width = components
439            .iter()
440            .map(|component| component.id().chars().count())
441            .max()
442            .unwrap_or(0);
443        let width = header_width.max(max_name_width).saturating_add(2);
444        width.clamp(MIN_WIDTH, MAX_WIDTH) as u16
445    }
446
447    fn component_label(&self, component_id: ComponentId) -> &'static str {
448        debug_assert!(component_id.index() < self.model.components().len());
449        self.model.components()[component_id.index()].id()
450    }
451
452    #[cfg(feature = "sysinfo")]
453    fn draw_system_info(&self, f: &mut Frame, area: Rect) {
454        const VERSION: &str = env!("CARGO_PKG_VERSION");
455        let mut lines = vec![
456            Line::raw(""),
457            Line::raw(format!("   -> Copper v{VERSION}")),
458            Line::raw(""),
459        ];
460        let mut body = match &self.system_info {
461            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
462            SystemInfo::Ansi(raw) => raw
463                .clone()
464                .into_text()
465                .map(|text| text.to_owned())
466                .unwrap_or_else(|_| Text::from(raw.clone())),
467            #[cfg(all(target_family = "wasm", target_os = "unknown"))]
468            SystemInfo::Rich(text) => text.clone(),
469        };
470        palette::normalize_text_colors(&mut body, palette::FOREGROUND, palette::BACKGROUND);
471        lines.append(&mut body.lines);
472        lines.push(Line::raw(" "));
473        let text = Text::from(lines);
474        let paragraph = Paragraph::new(text).block(
475            Block::default()
476                .title(" System Info ")
477                .borders(Borders::ALL)
478                .border_type(BorderType::Rounded),
479        );
480        f.render_widget(paragraph, area);
481    }
482
483    fn draw_latency_table(&mut self, f: &mut Frame, area: Rect) {
484        let header_cells = [
485            "⌘ Runtime Node",
486            "Kind",
487            "⬇ Min",
488            "⬆ Max",
489            "∅ Mean",
490            "σ Stddev",
491            "⧖∅ Jitter",
492            "⧗⬆ Jitter",
493        ]
494        .iter()
495        .enumerate()
496        .map(|(idx, header)| {
497            let align = if idx <= 1 {
498                Alignment::Left
499            } else {
500                Alignment::Right
501            };
502            Cell::from(Line::from(*header).alignment(align)).style(
503                Style::default()
504                    .fg(palette::YELLOW)
505                    .add_modifier(Modifier::BOLD),
506            )
507        });
508
509        let header = Row::new(header_cells)
510            .style(Style::default().fg(palette::YELLOW))
511            .bottom_margin(1)
512            .top_margin(1);
513
514        let component_stats = self.model.inner.component_stats.lock().unwrap();
515        let mut rows = component_stats
516            .stats
517            .iter()
518            .enumerate()
519            .map(|(index, stat)| {
520                let component_id = ComponentId::new(index);
521                let kind_label = match self.model.components()[component_id.index()].kind() {
522                    ComponentType::Source => "◈ Src",
523                    ComponentType::Task => "⚙ Task",
524                    ComponentType::Sink => "⭳ Sink",
525                    ComponentType::Bridge => "⇆ Brg",
526                    _ => "?",
527                };
528                let cells = vec![
529                    Cell::from(
530                        Line::from(self.component_label(component_id)).alignment(Alignment::Left),
531                    )
532                    .light_blue(),
533                    Cell::from(Line::from(kind_label).alignment(Alignment::Left)),
534                    Cell::from(Line::from(stat.min().to_string()).alignment(Alignment::Right)),
535                    Cell::from(Line::from(stat.max().to_string()).alignment(Alignment::Right)),
536                    Cell::from(Line::from(stat.mean().to_string()).alignment(Alignment::Right)),
537                    Cell::from(Line::from(stat.stddev().to_string()).alignment(Alignment::Right)),
538                    Cell::from(
539                        Line::from(stat.jitter_mean().to_string()).alignment(Alignment::Right),
540                    ),
541                    Cell::from(
542                        Line::from(stat.jitter_max().to_string()).alignment(Alignment::Right),
543                    ),
544                ];
545                Row::new(cells)
546            })
547            .collect::<Vec<Row>>();
548
549        let cells = vec![
550            Cell::from(Line::from("End2End").light_red().alignment(Alignment::Left)),
551            Cell::from(Line::from("All").light_red().alignment(Alignment::Left)),
552            Cell::from(
553                Line::from(component_stats.end2end.min().to_string())
554                    .light_red()
555                    .alignment(Alignment::Right),
556            ),
557            Cell::from(
558                Line::from(component_stats.end2end.max().to_string())
559                    .light_red()
560                    .alignment(Alignment::Right),
561            ),
562            Cell::from(
563                Line::from(component_stats.end2end.mean().to_string())
564                    .light_red()
565                    .alignment(Alignment::Right),
566            ),
567            Cell::from(
568                Line::from(component_stats.end2end.stddev().to_string())
569                    .light_red()
570                    .alignment(Alignment::Right),
571            ),
572            Cell::from(
573                Line::from(component_stats.end2end.jitter_mean().to_string())
574                    .light_red()
575                    .alignment(Alignment::Right),
576            ),
577            Cell::from(
578                Line::from(component_stats.end2end.jitter_max().to_string())
579                    .light_red()
580                    .alignment(Alignment::Right),
581            ),
582        ];
583        rows.push(Row::new(cells).top_margin(1));
584        let row_count = rows.len();
585        drop(component_stats);
586
587        let table = Table::new(
588            rows,
589            &[
590                Constraint::Length(self.runtime_node_col_width),
591                Constraint::Length(10),
592                Constraint::Length(10),
593                Constraint::Length(12),
594                Constraint::Length(12),
595                Constraint::Length(10),
596                Constraint::Length(12),
597                Constraint::Length(13),
598            ],
599        )
600        .header(header)
601        .block(
602            Block::default()
603                .borders(Borders::ALL)
604                .border_type(BorderType::Rounded)
605                .title(" Latencies "),
606        );
607
608        let content_width = self
609            .runtime_node_col_width
610            .saturating_add(10)
611            .saturating_add(10)
612            .saturating_add(12)
613            .saturating_add(12)
614            .saturating_add(10)
615            .saturating_add(12)
616            .saturating_add(13)
617            .saturating_add(24)
618            .max(area.width);
619        let content_height = (row_count as u16).saturating_add(6).max(area.height);
620        let content_size = Size::new(content_width, content_height);
621        self.clamp_latency_scroll_offset(area, content_size);
622        let mut scroll_view = ScrollView::new(content_size);
623        scroll_view.render_widget(
624            Block::default().style(Style::default().bg(palette::BACKGROUND)),
625            Rect::new(0, 0, content_size.width, content_size.height),
626        );
627        scroll_view.render_widget(
628            table,
629            Rect::new(0, 0, content_size.width, content_size.height),
630        );
631        scroll_view.render(area, f.buffer_mut(), &mut self.latency_scroll_state);
632    }
633
634    fn clamp_latency_scroll_offset(&mut self, area: Rect, content_size: Size) {
635        let max_x = content_size.width.saturating_sub(area.width);
636        let max_y = content_size.height.saturating_sub(area.height);
637        let offset = self.latency_scroll_state.offset();
638        let clamped = Position::new(offset.x.min(max_x), offset.y.min(max_y));
639        self.latency_scroll_state.set_offset(clamped);
640    }
641
642    fn draw_memory_pools(&self, f: &mut Frame, area: Rect) {
643        let header_cells = [
644            "Pool ID",
645            "Used/Total",
646            "Buffer Size",
647            "Handles in Use",
648            "Handles/sec",
649        ]
650        .iter()
651        .map(|header| {
652            Cell::from(Line::from(*header).alignment(Alignment::Right)).style(
653                Style::default()
654                    .fg(palette::YELLOW)
655                    .add_modifier(Modifier::BOLD),
656            )
657        });
658
659        let header = Row::new(header_cells)
660            .style(Style::default().fg(palette::YELLOW))
661            .bottom_margin(1);
662
663        let pool_stats = self.model.inner.pool_stats.lock().unwrap();
664        let rows = pool_stats
665            .iter()
666            .map(|stat| {
667                let used = stat.total_size.saturating_sub(stat.space_left);
668                let percent = if stat.total_size > 0 {
669                    100.0 * used as f64 / stat.total_size as f64
670                } else {
671                    0.0
672                };
673                let mb_unit = 1024.0 * 1024.0;
674
675                Row::new(vec![
676                    Cell::from(Line::from(stat.id.to_string()).alignment(Alignment::Right))
677                        .light_blue(),
678                    Cell::from(
679                        Line::from(format!(
680                            "{:.2} MB / {:.2} MB ({:.1}%)",
681                            used as f64 * stat.buffer_size as f64 / mb_unit,
682                            stat.total_size as f64 * stat.buffer_size as f64 / mb_unit,
683                            percent
684                        ))
685                        .alignment(Alignment::Right),
686                    ),
687                    Cell::from(
688                        Line::from(format!("{} KB", stat.buffer_size / 1024))
689                            .alignment(Alignment::Right),
690                    ),
691                    Cell::from(
692                        Line::from(format!("{}", stat.handles_in_use)).alignment(Alignment::Right),
693                    ),
694                    Cell::from(
695                        Line::from(format!("{}/s", stat.handles_per_second))
696                            .alignment(Alignment::Right),
697                    ),
698                ])
699            })
700            .collect::<Vec<Row>>();
701
702        let table = Table::new(
703            rows,
704            &[
705                Constraint::Percentage(30),
706                Constraint::Percentage(20),
707                Constraint::Percentage(15),
708                Constraint::Percentage(15),
709                Constraint::Percentage(20),
710            ],
711        )
712        .header(header)
713        .block(
714            Block::default()
715                .borders(Borders::ALL)
716                .border_type(BorderType::Rounded)
717                .title(" Memory Pools "),
718        );
719
720        f.render_widget(table, area);
721    }
722
723    fn draw_copperlist_stats(&self, f: &mut Frame, area: Rect) {
724        let stats = self.model.inner.copperlist_stats.lock().unwrap();
725        let size_display = format_bytes_or(stats.size_bytes as u64, "unknown");
726        let raw_total = stats.raw_culist_bytes.max(stats.size_bytes as u64);
727        let handles_display = format_bytes_or(stats.handle_bytes, "0 B");
728        let mem_total = raw_total
729            .saturating_add(stats.keyframe_bytes)
730            .saturating_add(stats.structured_bytes_per_cl);
731        let mem_total_display = format_bytes_or(mem_total, "unknown");
732        let encoded_display = format_bytes_or(stats.encoded_bytes, "n/a");
733        let space_saved_display = if raw_total > 0 && stats.encoded_bytes > 0 {
734            let saved = 1.0 - (stats.encoded_bytes as f64) / (raw_total as f64);
735            format!("{:.1}%", saved * 100.0)
736        } else {
737            "n/a".to_string()
738        };
739        let rate_display = format!("{:.2} Hz", stats.rate_hz);
740        let raw_bw = format_rate_bytes_or_na(mem_total, stats.rate_hz);
741        let keyframe_display = format_bytes_or(stats.keyframe_bytes, "0 B");
742        let structured_display = format_bytes_or(stats.structured_bytes_per_cl, "0 B");
743        let structured_bw = format_rate_bytes_or_na(stats.structured_bytes_per_cl, stats.rate_hz);
744        let disk_total_bytes = stats
745            .encoded_bytes
746            .saturating_add(stats.keyframe_bytes)
747            .saturating_add(stats.structured_bytes_per_cl);
748        let disk_total_bw = format_rate_bytes_or_na(disk_total_bytes, stats.rate_hz);
749
750        let header_cells = ["Metric", "Value"].iter().map(|header| {
751            Cell::from(Line::from(*header)).style(
752                Style::default()
753                    .fg(palette::YELLOW)
754                    .add_modifier(Modifier::BOLD),
755            )
756        });
757
758        let header = Row::new(header_cells).bottom_margin(1);
759        let row = |metric: &'static str, value: String| {
760            Row::new(vec![
761                Cell::from(Line::from(metric)),
762                Cell::from(Line::from(value).alignment(Alignment::Right)),
763            ])
764        };
765        let spacer = row(" ", " ".to_string());
766
767        let rate_style = Style::default().fg(palette::CYAN);
768        let mem_rows = vec![
769            row("Observed rate", rate_display).style(rate_style),
770            spacer.clone(),
771            row("CopperList size", size_display),
772            row("Pool memory used", handles_display),
773            row("Keyframe size", keyframe_display),
774            row("Mem total (CL+KF+SL)", mem_total_display),
775            spacer.clone(),
776            row("RAM BW (raw)", raw_bw),
777        ];
778
779        let disk_rows = vec![
780            row("CL serialized size", encoded_display),
781            row("Space saved", space_saved_display),
782            row("Structured log / CL", structured_display),
783            row("Structured BW", structured_bw),
784            spacer.clone(),
785            row("Total disk BW", disk_total_bw),
786        ];
787
788        let mem_table = Table::new(mem_rows, &[Constraint::Length(24), Constraint::Length(12)])
789            .header(header.clone())
790            .block(
791                Block::default()
792                    .borders(Borders::ALL)
793                    .border_type(BorderType::Rounded)
794                    .title(" Memory BW "),
795            );
796
797        let disk_table = Table::new(disk_rows, &[Constraint::Length(24), Constraint::Length(12)])
798            .header(header)
799            .block(
800                Block::default()
801                    .borders(Borders::ALL)
802                    .border_type(BorderType::Rounded)
803                    .title(" Disk / Encoding "),
804            );
805
806        let layout = Layout::default()
807            .direction(Direction::Horizontal)
808            .constraints([Constraint::Length(42), Constraint::Length(42)].as_ref())
809            .split(area);
810
811        f.render_widget(mem_table, layout[0]);
812        f.render_widget(disk_table, layout[1]);
813    }
814
815    fn draw_nodes(&mut self, f: &mut Frame, area: Rect) {
816        NodesScrollableWidget {
817            _marker: Default::default(),
818        }
819        .render(
820            area,
821            f.buffer_mut(),
822            &mut self.nodes_scrollable_widget_state,
823        );
824    }
825
826    #[cfg(feature = "log_pane")]
827    fn start_log_selection(&mut self, col: u16, row: u16) -> MonitorUiAction {
828        let Some(area) = self.log_area else {
829            self.log_selection.clear();
830            return MonitorUiAction::None;
831        };
832        if !point_inside(col, row, area.x, area.y, area.width, area.height) {
833            self.log_selection.clear();
834            return MonitorUiAction::None;
835        }
836
837        let Some(point) = self.log_selection_point(col, row) else {
838            return MonitorUiAction::None;
839        };
840        self.log_selection.start(point);
841        MonitorUiAction::None
842    }
843
844    #[cfg(feature = "log_pane")]
845    fn drag_log_selection(&mut self, col: u16, row: u16) -> MonitorUiAction {
846        let Some(point) = self.log_selection_point(col, row) else {
847            return MonitorUiAction::None;
848        };
849        self.log_selection.update(point);
850        MonitorUiAction::None
851    }
852
853    #[cfg(feature = "log_pane")]
854    fn finish_log_selection(&mut self, col: u16, row: u16) -> MonitorUiAction {
855        let Some(point) = self.log_selection_point(col, row) else {
856            self.log_selection.clear();
857            return MonitorUiAction::None;
858        };
859        self.log_selection.update(point);
860        self.selected_log_text()
861            .map(MonitorUiAction::CopyLogSelection)
862            .unwrap_or(MonitorUiAction::None)
863    }
864
865    #[cfg(feature = "log_pane")]
866    fn log_selection_point(&self, col: u16, row: u16) -> Option<SelectionPoint> {
867        let area = self.log_area?;
868        if !point_inside(col, row, area.x, area.y, area.width, area.height) {
869            return None;
870        }
871
872        let rel_row = (row - area.y) as usize;
873        let rel_col = (col - area.x) as usize;
874        let line_index = self.visible_log_offset(area).saturating_add(rel_row);
875        let line = self.log_lines.get(line_index)?;
876        Some(SelectionPoint {
877            row: line_index,
878            col: rel_col.min(line.text.chars().count()),
879        })
880    }
881
882    #[cfg(feature = "log_pane")]
883    fn visible_log_offset(&self, area: Rect) -> usize {
884        let visible_rows = area.height as usize;
885        let total_lines = self.log_lines.len();
886        let max_offset = total_lines.saturating_sub(visible_rows);
887        let offset_from_bottom = self.log_offset_from_bottom.min(max_offset);
888        total_lines.saturating_sub(visible_rows.saturating_add(offset_from_bottom))
889    }
890
891    #[cfg(feature = "log_pane")]
892    fn draw_logs(&mut self, f: &mut Frame, area: Rect) {
893        let block = Block::default()
894            .title(" Debug Output ")
895            .title_bottom(format!("{} log entries", self.model.log_line_count()))
896            .borders(Borders::ALL)
897            .border_type(BorderType::Rounded);
898        let inner = block.inner(area);
899        self.log_area = Some(inner);
900        self.log_lines = self.model.log_lines();
901
902        let visible_offset = self.visible_log_offset(inner);
903        if let Some((start, end)) = self.log_selection.range()
904            && (start.row >= self.log_lines.len() || end.row >= self.log_lines.len())
905        {
906            self.log_selection.clear();
907        }
908
909        let paragraph = Paragraph::new(self.build_log_text(inner, visible_offset)).block(block);
910        f.render_widget(paragraph, area);
911    }
912
913    #[cfg(feature = "log_pane")]
914    fn build_log_text(&self, area: Rect, visible_offset: usize) -> Text<'static> {
915        let mut rendered_lines = Vec::new();
916        let selection = self
917            .log_selection
918            .range()
919            .filter(|(start, end)| start != end);
920        let selection_style = Style::default().bg(palette::BLUE).fg(palette::BACKGROUND);
921        let visible_lines = self
922            .log_lines
923            .iter()
924            .skip(visible_offset)
925            .take(area.height as usize);
926
927        for (idx, line) in visible_lines.enumerate() {
928            let line_index = visible_offset + idx;
929            let spans = if let Some((start, end)) = selection {
930                let line_len = line.text.chars().count();
931                if let Some((start_col, end_col)) =
932                    line_selection_bounds(line_index, line_len, start, end)
933                {
934                    let (before, selected, after) =
935                        slice_char_range(&line.text, start_col, end_col);
936                    let mut spans = Vec::new();
937                    if !before.is_empty() {
938                        spans.push(Span::raw(before.to_string()));
939                    }
940                    spans.push(Span::styled(selected.to_string(), selection_style));
941                    if !after.is_empty() {
942                        spans.push(Span::raw(after.to_string()));
943                    }
944                    spans
945                } else {
946                    spans_from_runs(line)
947                }
948            } else {
949                spans_from_runs(line)
950            };
951            rendered_lines.push(Line::from(spans));
952        }
953
954        Text::from(rendered_lines)
955    }
956
957    #[cfg(feature = "log_pane")]
958    fn selected_log_text(&self) -> Option<String> {
959        let (start, end) = self.log_selection.range()?;
960        if start == end || self.log_lines.is_empty() {
961            return None;
962        }
963        if start.row >= self.log_lines.len() || end.row >= self.log_lines.len() {
964            return None;
965        }
966
967        let mut selected = Vec::new();
968        for row in start.row..=end.row {
969            let line = &self.log_lines[row];
970            let line_len = line.text.chars().count();
971            let Some((start_col, end_col)) = line_selection_bounds(row, line_len, start, end)
972            else {
973                selected.push(String::new());
974                continue;
975            };
976            let (_, selection, _) = slice_char_range(&line.text, start_col, end_col);
977            selected.push(selection.to_string());
978        }
979
980        Some(selected.join("\n"))
981    }
982
983    fn render_tabs(&mut self, f: &mut Frame, area: Rect) {
984        let base_bg = Color::Rgb(16, 18, 20);
985        let active_bg = Color::Rgb(56, 110, 120);
986        let inactive_bg = Color::Rgb(40, 44, 52);
987        let active_fg = Color::Rgb(245, 246, 247);
988        let inactive_fg = Color::Rgb(198, 200, 204);
989        let key_fg = Color::Rgb(255, 208, 128);
990
991        let mut spans = Vec::new();
992        self.tab_hitboxes.clear();
993        let mut cursor_x = area.x;
994        spans.push(Span::styled(" ", Style::default().bg(base_bg)));
995        cursor_x = cursor_x.saturating_add(1);
996
997        for (i, tab) in TAB_DEFS.iter().enumerate() {
998            let key = ((b'1' + i as u8) as char).to_string();
999            let is_active = self.active_screen == tab.screen;
1000            let bg = if is_active { active_bg } else { inactive_bg };
1001            let fg = if is_active { active_fg } else { inactive_fg };
1002            let label_style = if is_active {
1003                Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)
1004            } else {
1005                Style::default().fg(fg).bg(bg)
1006            };
1007            let tab_width = segment_width(&key, tab.label);
1008            self.tab_hitboxes.push(TabHitbox {
1009                screen: tab.screen,
1010                x: cursor_x,
1011                y: area.y,
1012                width: tab_width,
1013                height: area.height,
1014            });
1015            cursor_x = cursor_x.saturating_add(tab_width);
1016
1017            spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1018            spans.push(Span::styled(" ", Style::default().bg(bg)));
1019            spans.push(Span::styled(
1020                key,
1021                Style::default()
1022                    .fg(key_fg)
1023                    .bg(bg)
1024                    .add_modifier(Modifier::BOLD),
1025            ));
1026            spans.push(Span::styled(" ", Style::default().bg(bg)));
1027            spans.push(Span::styled(tab.label, label_style));
1028            spans.push(Span::styled(" ", Style::default().bg(bg)));
1029            spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1030            spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1031        }
1032
1033        let tabs = Paragraph::new(Line::from(spans))
1034            .style(Style::default().bg(base_bg))
1035            .block(Block::default().style(Style::default().bg(base_bg)));
1036        f.render_widget(tabs, area);
1037    }
1038
1039    fn render_help(&mut self, f: &mut Frame, area: Rect) {
1040        let base_bg = Color::Rgb(18, 16, 22);
1041        let key_fg = Color::Rgb(248, 231, 176);
1042        let text_fg = Color::Rgb(236, 236, 236);
1043
1044        let mut spans = Vec::new();
1045        self.help_hitboxes.clear();
1046        let mut cursor_x = area.x;
1047        spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1048        cursor_x = cursor_x.saturating_add(1);
1049
1050        let mut segments = vec![
1051            (tab_key_hint(), "Tabs", Color::Rgb(86, 114, 98), None),
1052            (
1053                "r".to_string(),
1054                "Reset latency",
1055                Color::Rgb(136, 92, 78),
1056                Some(HelpAction::ResetLatency),
1057            ),
1058            (
1059                "hjkl/←↑→↓".to_string(),
1060                "Scroll",
1061                Color::Rgb(92, 102, 150),
1062                None,
1063            ),
1064        ];
1065        if self.show_quit_hint {
1066            segments.push((
1067                "q".to_string(),
1068                "Quit",
1069                Color::Rgb(124, 118, 76),
1070                Some(HelpAction::Quit),
1071            ));
1072        }
1073
1074        for (key, label, bg, action) in segments {
1075            let segment_len = segment_width(&key, label);
1076            if let Some(action) = action {
1077                self.help_hitboxes.push(HelpHitbox {
1078                    action,
1079                    x: cursor_x,
1080                    y: area.y,
1081                    width: segment_len,
1082                    height: area.height,
1083                });
1084            }
1085            cursor_x = cursor_x.saturating_add(segment_len);
1086
1087            spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1088            spans.push(Span::styled(" ", Style::default().bg(bg)));
1089            spans.push(Span::styled(
1090                key,
1091                Style::default()
1092                    .fg(key_fg)
1093                    .bg(bg)
1094                    .add_modifier(Modifier::BOLD),
1095            ));
1096            spans.push(Span::styled(" ", Style::default().bg(bg)));
1097            spans.push(Span::styled(label, Style::default().fg(text_fg).bg(bg)));
1098            spans.push(Span::styled(" ", Style::default().bg(bg)));
1099            spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1100            spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1101        }
1102
1103        let help = Paragraph::new(Line::from(spans))
1104            .style(Style::default().bg(base_bg))
1105            .block(Block::default().style(Style::default().bg(base_bg)));
1106        f.render_widget(help, area);
1107
1108        self.render_footer_badges(f, area, base_bg);
1109    }
1110
1111    fn render_footer_badges(&self, f: &mut Frame, area: Rect, base_bg: Color) {
1112        if area.width == 0 || area.height == 0 {
1113            return;
1114        }
1115
1116        let clid = self
1117            .model
1118            .inner
1119            .copperlist_stats
1120            .lock()
1121            .unwrap()
1122            .last_seen_clid
1123            .unwrap_or(0);
1124        let mut badges = footer_badges(self.model.footer_identity(), clid);
1125        while !badges.is_empty() {
1126            let (line, width) = footer_badge_line(&badges, base_bg);
1127            if width <= area.width {
1128                f.render_widget(
1129                    Paragraph::new(line),
1130                    Rect {
1131                        x: area.x.saturating_add(area.width.saturating_sub(width)),
1132                        y: area.y,
1133                        width,
1134                        height: 1,
1135                    },
1136                );
1137                return;
1138            }
1139            badges.remove(0);
1140        }
1141    }
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147    use cu29::monitoring::{
1148        ComponentType, CopperListInfo, MonitorComponentMetadata, MonitorConnection, MonitorNode,
1149        MonitorTopology,
1150    };
1151
1152    #[test]
1153    fn normalize_text_colors_replaces_reset_fg_and_bg() {
1154        let mut text = Text::from(Line::from(vec![Span::styled(
1155            "pfetch",
1156            Style::default().fg(Color::Reset).bg(Color::Reset),
1157        )]));
1158
1159        palette::normalize_text_colors(&mut text, palette::FOREGROUND, palette::BACKGROUND);
1160
1161        let span = &text.lines[0].spans[0];
1162        assert_eq!(span.style.fg, Some(palette::FOREGROUND));
1163        assert_eq!(span.style.bg, Some(palette::BACKGROUND));
1164    }
1165
1166    #[test]
1167    fn monitor_ui_starts_on_dag_tab() {
1168        let ui = MonitorUi::new(test_monitor_model(), MonitorUiOptions::default());
1169
1170        assert_eq!(ui.active_screen(), MonitorScreen::Dag);
1171    }
1172
1173    #[test]
1174    fn initial_graph_scroll_offset_targets_center_right() {
1175        let area = Rect::new(0, 0, 80, 20);
1176        let content_size = Size::new(240, 90);
1177        let graph_bounds = Size::new(200, 70);
1178
1179        let offset = initial_graph_scroll_offset(area, content_size, graph_bounds);
1180
1181        assert_eq!(offset, Position::new(85, 25));
1182    }
1183
1184    #[test]
1185    fn first_graph_build_seeds_a_non_zero_horizontal_offset_for_wide_dags() {
1186        let mut state = NodesScrollableWidgetState::new(wide_test_monitor_model());
1187
1188        let content_size = state.ensure_graph_cache(Rect::new(0, 0, 80, 20));
1189        let offset = state.nodes_scrollable_state.offset();
1190
1191        assert!(content_size.width > 80);
1192        assert!(offset.x > 0);
1193    }
1194
1195    #[test]
1196    fn resizing_wide_dag_reuses_cached_graph_layout_and_clamps_scroll() {
1197        let mut state = NodesScrollableWidgetState::new(wide_test_monitor_model());
1198        let initial_area = Rect::new(0, 0, 80, 20);
1199        let resized_area = Rect::new(0, 0, 120, 24);
1200
1201        let initial_content_size = state.ensure_graph_cache(initial_area);
1202        let initial_key = state.graph_cache.key;
1203
1204        state
1205            .nodes_scrollable_state
1206            .set_offset(Position::new(u16::MAX, u16::MAX));
1207        let resized_content_size = state.ensure_graph_cache(resized_area);
1208        let offset = state.nodes_scrollable_state.offset();
1209        let max_x = resized_content_size
1210            .width
1211            .saturating_sub(resized_area.width.saturating_sub(1));
1212        let max_y = resized_content_size
1213            .height
1214            .saturating_sub(resized_area.height.saturating_sub(1));
1215
1216        assert_eq!(resized_content_size, initial_content_size);
1217        assert_eq!(state.graph_cache.key, initial_key);
1218        assert_eq!(offset, Position::new(max_x, max_y));
1219    }
1220
1221    #[test]
1222    fn footer_badges_render_identity_in_requested_order() {
1223        let badges = footer_badges(
1224            MonitorFooterIdentity {
1225                system_name: "robot-alpha".into(),
1226                subsystem_name: Some("drivetrain".into()),
1227                mission_name: "autonomous".into(),
1228                instance_id: 42,
1229            },
1230            12846,
1231        );
1232
1233        let labels = badges
1234            .into_iter()
1235            .map(|badge| badge.inner)
1236            .collect::<Vec<_>>();
1237        assert_eq!(
1238            labels,
1239            vec![
1240                " robot-alpha ".to_string(),
1241                " drivetrain ".to_string(),
1242                " 42 ".to_string(),
1243                " autonomous ".to_string(),
1244                " 00000000000000012846 ".to_string(),
1245            ]
1246        );
1247    }
1248
1249    #[test]
1250    fn footer_badges_skip_subsystem_when_absent() {
1251        let badges = footer_badges(
1252            MonitorFooterIdentity {
1253                system_name: "robot-alpha".into(),
1254                subsystem_name: None,
1255                mission_name: "autonomous".into(),
1256                instance_id: 42,
1257            },
1258            12846,
1259        );
1260
1261        let labels = badges
1262            .into_iter()
1263            .map(|badge| badge.inner)
1264            .collect::<Vec<_>>();
1265        assert_eq!(
1266            labels,
1267            vec![
1268                " robot-alpha ".to_string(),
1269                " 42 ".to_string(),
1270                " autonomous ".to_string(),
1271                " 00000000000000012846 ".to_string(),
1272            ]
1273        );
1274    }
1275
1276    #[test]
1277    fn clip_with_ellipsis_truncates_long_footer_values() {
1278        assert_eq!(
1279            clip_with_ellipsis("balancebot-simulator-east", 12),
1280            "balancebo..."
1281        );
1282    }
1283
1284    fn test_monitor_model() -> MonitorModel {
1285        static COMPONENTS: [MonitorComponentMetadata; 3] = [
1286            MonitorComponentMetadata::new("sensor", ComponentType::Source, Some("Sensor")),
1287            MonitorComponentMetadata::new("controller", ComponentType::Task, Some("Controller")),
1288            MonitorComponentMetadata::new("actuator", ComponentType::Sink, Some("Actuator")),
1289        ];
1290
1291        let topology = MonitorTopology {
1292            nodes: vec![
1293                MonitorNode {
1294                    id: "sensor".to_string(),
1295                    type_name: Some("Sensor".to_string()),
1296                    kind: ComponentType::Source,
1297                    inputs: Vec::new(),
1298                    outputs: vec!["imu".to_string()],
1299                },
1300                MonitorNode {
1301                    id: "controller".to_string(),
1302                    type_name: Some("Controller".to_string()),
1303                    kind: ComponentType::Task,
1304                    inputs: vec!["imu".to_string()],
1305                    outputs: vec!["cmd".to_string()],
1306                },
1307                MonitorNode {
1308                    id: "actuator".to_string(),
1309                    type_name: Some("Actuator".to_string()),
1310                    kind: ComponentType::Sink,
1311                    inputs: vec!["cmd".to_string()],
1312                    outputs: Vec::new(),
1313                },
1314            ],
1315            connections: Vec::new(),
1316        };
1317
1318        MonitorModel::from_parts(&COMPONENTS, CopperListInfo::new(0, 0), topology)
1319    }
1320
1321    fn wide_test_monitor_model() -> MonitorModel {
1322        static COMPONENTS: [MonitorComponentMetadata; 6] = [
1323            MonitorComponentMetadata::new("source", ComponentType::Source, Some("Source")),
1324            MonitorComponentMetadata::new("estimator", ComponentType::Task, Some("Estimator")),
1325            MonitorComponentMetadata::new("planner", ComponentType::Task, Some("Planner")),
1326            MonitorComponentMetadata::new("controller", ComponentType::Task, Some("Controller")),
1327            MonitorComponentMetadata::new("mixer", ComponentType::Task, Some("Mixer")),
1328            MonitorComponentMetadata::new("actuator", ComponentType::Sink, Some("Actuator")),
1329        ];
1330
1331        let ids = [
1332            "source",
1333            "estimator",
1334            "planner",
1335            "controller",
1336            "mixer",
1337            "actuator",
1338        ];
1339        let nodes = ids
1340            .iter()
1341            .map(|id| MonitorNode {
1342                id: (*id).to_string(),
1343                type_name: Some(id.to_string()),
1344                kind: if *id == "source" {
1345                    ComponentType::Source
1346                } else if *id == "actuator" {
1347                    ComponentType::Sink
1348                } else {
1349                    ComponentType::Task
1350                },
1351                inputs: if *id == "source" {
1352                    Vec::new()
1353                } else {
1354                    vec!["in".to_string()]
1355                },
1356                outputs: if *id == "actuator" {
1357                    Vec::new()
1358                } else {
1359                    vec!["out".to_string()]
1360                },
1361            })
1362            .collect();
1363        let connections = ids
1364            .windows(2)
1365            .map(|pair| MonitorConnection {
1366                src: pair[0].to_string(),
1367                src_port: Some("out".to_string()),
1368                dst: pair[1].to_string(),
1369                dst_port: Some("in".to_string()),
1370                msg: "msg".to_string(),
1371            })
1372            .collect();
1373        let topology = MonitorTopology { nodes, connections };
1374
1375        MonitorModel::from_parts(&COMPONENTS, CopperListInfo::new(0, 0), topology)
1376    }
1377}
1378
1379fn point_inside(px: u16, py: u16, x: u16, y: u16, width: u16, height: u16) -> bool {
1380    px >= x && px < x + width && py >= y && py < y + height
1381}
1382
1383fn segment_width(key: &str, label: &str) -> u16 {
1384    (6 + key.chars().count() + label.chars().count()) as u16
1385}
1386
1387fn screen_for_tab_key(key: char) -> Option<MonitorScreen> {
1388    TAB_DEFS
1389        .iter()
1390        .enumerate()
1391        .find(|(i, _)| (b'1' + *i as u8) as char == key)
1392        .map(|(_, tab)| tab.screen)
1393}
1394
1395fn tab_key_hint() -> String {
1396    let n = TAB_DEFS.len();
1397    if n == 0 {
1398        return "tabs".to_string();
1399    }
1400    if n == 1 {
1401        return "1".to_string();
1402    }
1403    format!("1-{n}")
1404}
1405
1406#[cfg(feature = "log_pane")]
1407fn char_to_byte_index(text: &str, char_idx: usize) -> usize {
1408    text.char_indices()
1409        .nth(char_idx)
1410        .map(|(idx, _)| idx)
1411        .unwrap_or(text.len())
1412}
1413
1414#[cfg(feature = "log_pane")]
1415fn slice_char_range(text: &str, start: usize, end: usize) -> (&str, &str, &str) {
1416    let start_idx = char_to_byte_index(text, start).min(text.len());
1417    let end_idx = char_to_byte_index(text, end).min(text.len());
1418    let (start_idx, end_idx) = if start_idx <= end_idx {
1419        (start_idx, end_idx)
1420    } else {
1421        (end_idx, start_idx)
1422    };
1423
1424    (
1425        &text[..start_idx],
1426        &text[start_idx..end_idx],
1427        &text[end_idx..],
1428    )
1429}
1430
1431#[cfg(feature = "log_pane")]
1432fn slice_chars_owned(text: &str, start: usize, end: usize) -> String {
1433    let start_idx = char_to_byte_index(text, start).min(text.len());
1434    let end_idx = char_to_byte_index(text, end).min(text.len());
1435    text[start_idx..end_idx].to_string()
1436}
1437
1438#[cfg(feature = "log_pane")]
1439fn line_selection_bounds(
1440    line_index: usize,
1441    line_len: usize,
1442    start: SelectionPoint,
1443    end: SelectionPoint,
1444) -> Option<(usize, usize)> {
1445    if line_index < start.row || line_index > end.row {
1446        return None;
1447    }
1448
1449    let start_col = if line_index == start.row {
1450        start.col
1451    } else {
1452        0
1453    };
1454    let mut end_col = if line_index == end.row {
1455        end.col
1456    } else {
1457        line_len
1458    };
1459    if line_index == end.row {
1460        end_col = end_col.saturating_add(1).min(line_len);
1461    }
1462
1463    let start_col = start_col.min(line_len);
1464    let end_col = end_col.min(line_len);
1465    if start_col >= end_col {
1466        return None;
1467    }
1468
1469    Some((start_col, end_col))
1470}
1471
1472#[cfg(feature = "log_pane")]
1473fn spans_from_runs(line: &StyledLine) -> Vec<Span<'static>> {
1474    if line.runs.is_empty() {
1475        return vec![Span::raw(line.text.clone())];
1476    }
1477
1478    let mut spans = Vec::new();
1479    let mut cursor = 0usize;
1480    let total_chars = line.text.chars().count();
1481    let mut runs = line.runs.clone();
1482    runs.sort_by_key(|run| run.start);
1483
1484    for run in runs {
1485        let start = run.start.min(total_chars);
1486        let end = run.end.min(total_chars);
1487        if start > cursor {
1488            let before = slice_chars_owned(&line.text, cursor, start);
1489            if !before.is_empty() {
1490                spans.push(Span::raw(before));
1491            }
1492        }
1493        if end > start {
1494            spans.push(Span::styled(
1495                slice_chars_owned(&line.text, start, end),
1496                run.style,
1497            ));
1498        }
1499        cursor = cursor.max(end);
1500    }
1501
1502    if cursor < total_chars {
1503        let tail = slice_chars_owned(&line.text, cursor, total_chars);
1504        if !tail.is_empty() {
1505            spans.push(Span::raw(tail));
1506        }
1507    }
1508
1509    spans
1510}
1511
1512fn format_bytes(bytes: f64) -> String {
1513    const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
1514    let mut value = bytes;
1515    let mut unit_idx = 0;
1516    while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
1517        value /= 1024.0;
1518        unit_idx += 1;
1519    }
1520    if unit_idx == 0 {
1521        format!("{:.0} {}", value, UNITS[unit_idx])
1522    } else {
1523        format!("{:.2} {}", value, UNITS[unit_idx])
1524    }
1525}
1526
1527fn format_bytes_or(bytes: u64, fallback: &str) -> String {
1528    if bytes > 0 {
1529        format_bytes(bytes as f64)
1530    } else {
1531        fallback.to_string()
1532    }
1533}
1534
1535fn format_rate_bytes_or_na(bytes: u64, rate_hz: f64) -> String {
1536    if bytes > 0 {
1537        format!("{}/s", format_bytes((bytes as f64) * rate_hz))
1538    } else {
1539        "n/a".to_string()
1540    }
1541}
1542
1543#[derive(Copy, Clone)]
1544enum NodeType {
1545    Unknown,
1546    Source,
1547    Sink,
1548    Task,
1549    Bridge,
1550}
1551
1552impl std::fmt::Display for NodeType {
1553    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1554        match self {
1555            Self::Unknown => write!(f, "?"),
1556            Self::Source => write!(f, "◈"),
1557            Self::Task => write!(f, "⚙"),
1558            Self::Sink => write!(f, "⭳"),
1559            Self::Bridge => write!(f, "⇆"),
1560        }
1561    }
1562}
1563
1564impl NodeType {
1565    fn color(self) -> Color {
1566        match self {
1567            Self::Unknown => palette::GRAY,
1568            Self::Source => Color::Rgb(255, 191, 0),
1569            Self::Sink => Color::Rgb(255, 102, 204),
1570            Self::Task => palette::WHITE,
1571            Self::Bridge => Color::Rgb(204, 153, 255),
1572        }
1573    }
1574}
1575
1576#[derive(Clone)]
1577struct DisplayNode {
1578    id: String,
1579    type_label: String,
1580    node_type: NodeType,
1581    inputs: Vec<String>,
1582    outputs: Vec<String>,
1583}
1584
1585#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1586struct GraphCacheKey {
1587    area: Option<Size>,
1588    node_count: usize,
1589    connection_count: usize,
1590}
1591
1592struct GraphCache {
1593    graph: Option<NodeGraph<'static>>,
1594    content_size: Size,
1595    key: Option<GraphCacheKey>,
1596    dirty: bool,
1597}
1598
1599impl GraphCache {
1600    fn new() -> Self {
1601        Self {
1602            graph: None,
1603            content_size: Size::ZERO,
1604            key: None,
1605            dirty: true,
1606        }
1607    }
1608
1609    fn needs_rebuild(&self, key: GraphCacheKey) -> bool {
1610        self.dirty || self.graph.is_none() || self.key != Some(key)
1611    }
1612}
1613
1614struct NodesScrollableWidgetState {
1615    model: MonitorModel,
1616    display_nodes: Vec<DisplayNode>,
1617    connections: Vec<Connection>,
1618    status_index_map: Vec<Option<ComponentId>>,
1619    nodes_scrollable_state: ScrollViewState,
1620    graph_cache: GraphCache,
1621    initial_viewport_pending: bool,
1622    last_viewport_area: Option<Size>,
1623}
1624
1625impl NodesScrollableWidgetState {
1626    fn new(model: MonitorModel) -> Self {
1627        let mut display_nodes = Vec::new();
1628        let mut status_index_map = Vec::new();
1629        let mut node_lookup = HashMap::new();
1630        let component_id_by_name: HashMap<&'static str, ComponentId> = model
1631            .components()
1632            .iter()
1633            .enumerate()
1634            .map(|(idx, component)| (component.id(), ComponentId::new(idx)))
1635            .collect();
1636
1637        for node in &model.topology().nodes {
1638            let node_type = match node.kind {
1639                ComponentType::Source => NodeType::Source,
1640                ComponentType::Task => NodeType::Task,
1641                ComponentType::Sink => NodeType::Sink,
1642                ComponentType::Bridge => NodeType::Bridge,
1643                _ => NodeType::Unknown,
1644            };
1645
1646            display_nodes.push(DisplayNode {
1647                id: node.id.clone(),
1648                type_label: node
1649                    .type_name
1650                    .clone()
1651                    .unwrap_or_else(|| "unknown".to_string()),
1652                node_type,
1653                inputs: node.inputs.clone(),
1654                outputs: node.outputs.clone(),
1655            });
1656            let idx = display_nodes.len() - 1;
1657            node_lookup.insert(node.id.clone(), idx);
1658            status_index_map.push(component_id_by_name.get(node.id.as_str()).copied());
1659        }
1660
1661        let mut connections = Vec::with_capacity(model.topology().connections.len());
1662        for connection in &model.topology().connections {
1663            let Some(&src_idx) = node_lookup.get(&connection.src) else {
1664                continue;
1665            };
1666            let Some(&dst_idx) = node_lookup.get(&connection.dst) else {
1667                continue;
1668            };
1669            let src_node = &display_nodes[src_idx];
1670            let dst_node = &display_nodes[dst_idx];
1671            let src_port = connection
1672                .src_port
1673                .as_ref()
1674                .and_then(|port| src_node.outputs.iter().position(|name| name == port))
1675                .unwrap_or(0);
1676            let dst_port = connection
1677                .dst_port
1678                .as_ref()
1679                .and_then(|port| dst_node.inputs.iter().position(|name| name == port))
1680                .unwrap_or(0);
1681
1682            connections.push(Connection::new(
1683                src_idx,
1684                src_port + NODE_PORT_ROW_OFFSET,
1685                dst_idx,
1686                dst_port + NODE_PORT_ROW_OFFSET,
1687            ));
1688        }
1689
1690        if !display_nodes.is_empty() {
1691            let mut from_set = std::collections::HashSet::new();
1692            for connection in &connections {
1693                from_set.insert(connection.from_node);
1694            }
1695            if from_set.len() == display_nodes.len() {
1696                connections.retain(|connection| connection.from_node != 0);
1697            }
1698        }
1699
1700        Self {
1701            model,
1702            display_nodes,
1703            connections,
1704            status_index_map,
1705            nodes_scrollable_state: ScrollViewState::default(),
1706            graph_cache: GraphCache::new(),
1707            initial_viewport_pending: true,
1708            last_viewport_area: None,
1709        }
1710    }
1711
1712    fn mark_graph_dirty(&mut self) {
1713        self.graph_cache.dirty = true;
1714    }
1715
1716    fn ensure_graph_cache(&mut self, area: Rect) -> Size {
1717        let viewport_area: Size = area.into();
1718        let key = self.graph_cache_key(area);
1719        if self.graph_cache.needs_rebuild(key) {
1720            self.rebuild_graph_cache(area, key);
1721        } else if self.last_viewport_area != Some(viewport_area) {
1722            self.clamp_scroll_offset(area, self.graph_cache.content_size);
1723        }
1724        self.last_viewport_area = Some(viewport_area);
1725        self.graph_cache.content_size
1726    }
1727
1728    fn graph(&self) -> &NodeGraph<'static> {
1729        self.graph_cache
1730            .graph
1731            .as_ref()
1732            .expect("graph cache must be initialized before render")
1733    }
1734
1735    fn graph_cache_key(&self, area: Rect) -> GraphCacheKey {
1736        GraphCacheKey {
1737            area: self.display_nodes.is_empty().then_some(area.into()),
1738            node_count: self.display_nodes.len(),
1739            connection_count: self.connections.len(),
1740        }
1741    }
1742
1743    fn build_graph(&self, content_size: Size) -> NodeGraph<'static> {
1744        let mut graph = NodeGraph::new(
1745            self.build_node_layouts(),
1746            self.connections.clone(),
1747            content_size.width as usize,
1748            content_size.height as usize,
1749        );
1750        graph.calculate();
1751        graph
1752    }
1753
1754    fn rebuild_graph_cache(&mut self, area: Rect, key: GraphCacheKey) {
1755        let content_size = if self.display_nodes.is_empty() {
1756            Size::new(area.width.max(NODE_WIDTH), area.height.max(NODE_HEIGHT))
1757        } else {
1758            let node_count = self.display_nodes.len();
1759            let content_width = (node_count as u16)
1760                .saturating_mul(NODE_WIDTH + 20)
1761                .max(NODE_WIDTH);
1762            let max_ports = self
1763                .display_nodes
1764                .iter()
1765                .map(|node| node.inputs.len().max(node.outputs.len()))
1766                .max()
1767                .unwrap_or_default();
1768            let content_height =
1769                (((max_ports + NODE_PORT_ROW_OFFSET) as u16) * 12).max(NODE_HEIGHT * 6);
1770
1771            let initial_size = Size::new(content_width, content_height);
1772            let graph = self.build_graph(initial_size);
1773            let bounds = graph.content_bounds();
1774            let desired_width = bounds
1775                .width
1776                .saturating_add(GRAPH_WIDTH_PADDING)
1777                .max(NODE_WIDTH);
1778            let desired_height = bounds
1779                .height
1780                .saturating_add(GRAPH_HEIGHT_PADDING)
1781                .max(NODE_HEIGHT);
1782            Size::new(desired_width, desired_height)
1783        };
1784
1785        let graph = self.build_graph(content_size);
1786        let graph_bounds = graph.content_bounds();
1787        self.graph_cache.graph = Some(graph);
1788        self.graph_cache.content_size = content_size;
1789        self.graph_cache.key = Some(key);
1790        self.graph_cache.dirty = false;
1791        self.last_viewport_area = Some(area.into());
1792
1793        if self.initial_viewport_pending {
1794            self.nodes_scrollable_state
1795                .set_offset(initial_graph_scroll_offset(
1796                    area,
1797                    content_size,
1798                    graph_bounds,
1799                ));
1800            self.initial_viewport_pending = false;
1801        } else {
1802            self.clamp_scroll_offset(area, content_size);
1803        }
1804    }
1805
1806    fn build_node_layouts(&self) -> Vec<NodeLayout<'static>> {
1807        self.display_nodes
1808            .iter()
1809            .map(|node| {
1810                let ports = node.inputs.len().max(node.outputs.len());
1811                let content_rows = ports + NODE_PORT_ROW_OFFSET;
1812                let height = (content_rows as u16).saturating_add(2).max(NODE_HEIGHT);
1813                let title_line = Line::from(vec![
1814                    Span::styled(
1815                        format!(" {}", node.node_type),
1816                        Style::default().fg(node.node_type.color()),
1817                    ),
1818                    Span::styled(
1819                        format!(" {} ", node.id),
1820                        Style::default().fg(palette::WHITE),
1821                    ),
1822                ]);
1823                NodeLayout::new((NODE_WIDTH, height)).with_title_line(title_line)
1824            })
1825            .collect()
1826    }
1827
1828    fn clamp_scroll_offset(&mut self, area: Rect, content_size: Size) {
1829        let max_x = content_size
1830            .width
1831            .saturating_sub(area.width.saturating_sub(1));
1832        let max_y = content_size
1833            .height
1834            .saturating_sub(area.height.saturating_sub(1));
1835        let offset = self.nodes_scrollable_state.offset();
1836        let clamped = Position::new(offset.x.min(max_x), offset.y.min(max_y));
1837        self.nodes_scrollable_state.set_offset(clamped);
1838    }
1839}
1840
1841struct NodesScrollableWidget<'a> {
1842    _marker: PhantomData<&'a ()>,
1843}
1844
1845const NODE_WIDTH: u16 = 29;
1846const NODE_WIDTH_CONTENT: u16 = NODE_WIDTH - 2;
1847const NODE_HEIGHT: u16 = 5;
1848const NODE_META_LINES: usize = 2;
1849const NODE_PORT_ROW_OFFSET: usize = NODE_META_LINES;
1850const GRAPH_WIDTH_PADDING: u16 = NODE_WIDTH * 2;
1851const GRAPH_HEIGHT_PADDING: u16 = NODE_HEIGHT * 4;
1852const INITIAL_GRAPH_FOCUS_X_NUMERATOR: u32 = 5;
1853const INITIAL_GRAPH_FOCUS_X_DENOMINATOR: u32 = 8;
1854
1855fn clip_tail(value: &str, max_chars: usize) -> String {
1856    if max_chars == 0 {
1857        return String::new();
1858    }
1859    let char_count = value.chars().count();
1860    if char_count <= max_chars {
1861        return value.to_string();
1862    }
1863    let skip = char_count.saturating_sub(max_chars);
1864    let start = value
1865        .char_indices()
1866        .nth(skip)
1867        .map(|(idx, _)| idx)
1868        .unwrap_or(value.len());
1869    value[start..].to_string()
1870}
1871
1872fn clip_with_ellipsis(value: &str, max_chars: usize) -> String {
1873    if max_chars == 0 {
1874        return String::new();
1875    }
1876    let char_count = value.chars().count();
1877    if char_count <= max_chars {
1878        return value.to_string();
1879    }
1880    if max_chars <= 3 {
1881        return value.chars().take(max_chars).collect();
1882    }
1883    let prefix: String = value.chars().take(max_chars - 3).collect();
1884    format!("{prefix}...")
1885}
1886
1887fn footer_badges(identity: MonitorFooterIdentity, clid: u64) -> Vec<FooterBadge> {
1888    let mut badges = vec![FooterBadge {
1889        inner: format!(
1890            " {} ",
1891            clip_with_ellipsis(identity.system_name.as_str(), 18)
1892        ),
1893        bg: Color::Rgb(92, 102, 150),
1894        fg: Color::Rgb(236, 236, 236),
1895    }];
1896    if let Some(subsystem_name) = identity.subsystem_name {
1897        badges.push(FooterBadge {
1898            inner: format!(" {} ", clip_with_ellipsis(subsystem_name.as_str(), 18)),
1899            bg: Color::Rgb(116, 88, 128),
1900            fg: Color::Rgb(236, 236, 236),
1901        });
1902    }
1903    badges.extend([
1904        FooterBadge {
1905            inner: format!(" {} ", identity.instance_id),
1906            bg: Color::Rgb(136, 92, 78),
1907            fg: Color::Rgb(248, 231, 176),
1908        },
1909        FooterBadge {
1910            inner: format!(
1911                " {} ",
1912                clip_with_ellipsis(identity.mission_name.as_str(), 16)
1913            ),
1914            bg: Color::Rgb(86, 114, 98),
1915            fg: Color::Rgb(236, 236, 236),
1916        },
1917        FooterBadge {
1918            inner: format!(" {:020} ", clid),
1919            bg: Color::Rgb(198, 146, 64),
1920            fg: palette::BACKGROUND,
1921        },
1922    ]);
1923    badges
1924}
1925
1926fn footer_badge_line(badges: &[FooterBadge], base_bg: Color) -> (Line<'static>, u16) {
1927    if badges.is_empty() {
1928        return (Line::default(), 0);
1929    }
1930
1931    let mut spans = Vec::with_capacity(badges.len().saturating_mul(2).saturating_add(1));
1932    let mut width = 0u16;
1933    spans.push(Span::styled(
1934        "",
1935        Style::default().fg(badges[0].bg).bg(base_bg),
1936    ));
1937    width = width.saturating_add(1);
1938
1939    for (idx, badge) in badges.iter().enumerate() {
1940        spans.push(Span::styled(
1941            badge.inner.clone(),
1942            Style::default()
1943                .fg(badge.fg)
1944                .bg(badge.bg)
1945                .add_modifier(Modifier::BOLD),
1946        ));
1947        width = width.saturating_add(badge.inner.chars().count() as u16);
1948
1949        let next_bg = badges.get(idx + 1).map(|next| next.bg).unwrap_or(base_bg);
1950        spans.push(Span::styled("", Style::default().fg(badge.bg).bg(next_bg)));
1951        width = width.saturating_add(1);
1952    }
1953
1954    (Line::from(spans), width)
1955}
1956
1957fn initial_graph_scroll_offset(area: Rect, content_size: Size, graph_bounds: Size) -> Position {
1958    let max_x = content_size
1959        .width
1960        .saturating_sub(area.width.saturating_sub(1));
1961    let max_y = content_size
1962        .height
1963        .saturating_sub(area.height.saturating_sub(1));
1964    let focus_x = (((graph_bounds.width as u32) * INITIAL_GRAPH_FOCUS_X_NUMERATOR)
1965        / INITIAL_GRAPH_FOCUS_X_DENOMINATOR) as u16;
1966    let focus_y = graph_bounds.height / 2;
1967
1968    Position::new(
1969        focus_x.saturating_sub(area.width / 2).min(max_x),
1970        focus_y.saturating_sub(area.height / 2).min(max_y),
1971    )
1972}
1973
1974impl StatefulWidget for NodesScrollableWidget<'_> {
1975    type State = NodesScrollableWidgetState;
1976
1977    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
1978        let content_size = state.ensure_graph_cache(area);
1979        let mut scroll_view = ScrollView::new(content_size);
1980        scroll_view.render_widget(
1981            Block::default().style(Style::default().bg(palette::BACKGROUND)),
1982            Rect::new(0, 0, content_size.width, content_size.height),
1983        );
1984
1985        {
1986            let graph = state.graph();
1987            let zones = graph.split(scroll_view.area());
1988
1989            let mut statuses = state.model.inner.component_statuses.lock().unwrap();
1990            for (idx, zone) in zones.into_iter().enumerate() {
1991                let status = state
1992                    .status_index_map
1993                    .get(idx)
1994                    .and_then(|component_id| *component_id)
1995                    .and_then(|component_id| statuses.get_mut(component_id.index()))
1996                    .map(|status| {
1997                        let snapshot: ComponentStatus = status.clone();
1998                        status.is_error = false;
1999                        snapshot
2000                    })
2001                    .unwrap_or_default();
2002                let node = &state.display_nodes[idx];
2003                let status_line = if status.is_error {
2004                    format!("❌ {}", status.error)
2005                } else {
2006                    format!("✓ {}", status.status_txt)
2007                };
2008
2009                let label_width = (NODE_WIDTH_CONTENT as usize).saturating_sub(2);
2010                let type_label = clip_tail(&node.type_label, label_width);
2011                let status_text = clip_tail(&status_line, label_width);
2012                let base_style = if status.is_error {
2013                    Style::default().fg(palette::RED)
2014                } else {
2015                    Style::default().fg(palette::GREEN)
2016                };
2017                let mut lines = vec![
2018                    Line::styled(format!(" {}", type_label), base_style),
2019                    Line::styled(format!(" {}", status_text), base_style),
2020                ];
2021
2022                let max_ports = node.inputs.len().max(node.outputs.len());
2023                if max_ports > 0 {
2024                    let left_width = (NODE_WIDTH_CONTENT as usize - 2) / 2;
2025                    let right_width = NODE_WIDTH_CONTENT as usize - 2 - left_width;
2026                    let input_style = Style::default().fg(palette::YELLOW);
2027                    let output_style = Style::default().fg(palette::CYAN);
2028                    let dotted_style = Style::default().fg(palette::DARK_GRAY);
2029                    for port_idx in 0..max_ports {
2030                        let input = node
2031                            .inputs
2032                            .get(port_idx)
2033                            .map(|label| clip_tail(label, left_width))
2034                            .unwrap_or_default();
2035                        let output = node
2036                            .outputs
2037                            .get(port_idx)
2038                            .map(|label| clip_tail(label, right_width))
2039                            .unwrap_or_default();
2040                        let mut port_line = Line::default();
2041                        port_line.spans.push(Span::styled(
2042                            format!(" {:<left_width$}", input, left_width = left_width),
2043                            input_style,
2044                        ));
2045                        port_line.spans.push(Span::styled("┆", dotted_style));
2046                        port_line.spans.push(Span::styled(
2047                            format!("{:>right_width$}", output, right_width = right_width),
2048                            output_style,
2049                        ));
2050                        lines.push(port_line);
2051                    }
2052                }
2053
2054                scroll_view.render_widget(Paragraph::new(Text::from(lines)), zone);
2055            }
2056
2057            let content_area = Rect::new(0, 0, content_size.width, content_size.height);
2058            scroll_view.render_widget(graph, content_area);
2059        }
2060
2061        scroll_view.render(area, buf, &mut state.nodes_scrollable_state);
2062    }
2063}