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