Skip to main content

cu_tuimon/
ui.rs

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