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