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