Skip to main content

cu_tuimon/
ui.rs

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