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 disconnected_pipelines_initial_graph_size_covers_graph_bounds() {
1187 let state = NodesScrollableWidgetState::new(parallel_pipelines_monitor_model(8));
1188 let area = Rect::new(0, 0, 80, 20);
1189 let initial_size = state.estimate_initial_graph_size(area);
1190 let graph = state.build_graph(initial_size);
1191
1192 assert!(graph.content_bounds().height <= initial_size.height);
1193 }
1194
1195 #[test]
1196 fn disconnected_pipelines_render_without_alias_fallback() {
1197 let mut state = NodesScrollableWidgetState::new(parallel_pipelines_monitor_model(8));
1198
1199 state.ensure_graph_cache(Rect::new(0, 0, 80, 20));
1200
1201 assert!(state.graph().conn_layout.alias_connections.is_empty());
1202 }
1203
1204 #[test]
1205 fn footer_badges_render_identity_in_requested_order() {
1206 let badges = footer_badges(
1207 MonitorFooterIdentity {
1208 system_name: "robot-alpha".into(),
1209 subsystem_name: Some("drivetrain".into()),
1210 mission_name: "autonomous".into(),
1211 instance_id: 42,
1212 },
1213 12846,
1214 );
1215
1216 let labels = badges
1217 .into_iter()
1218 .map(|badge| badge.inner)
1219 .collect::<Vec<_>>();
1220 assert_eq!(
1221 labels,
1222 vec![
1223 " robot-alpha ".to_string(),
1224 " drivetrain ".to_string(),
1225 " 42 ".to_string(),
1226 " autonomous ".to_string(),
1227 " 00000000000000012846 ".to_string(),
1228 ]
1229 );
1230 }
1231
1232 #[test]
1233 fn footer_badges_skip_subsystem_when_absent() {
1234 let badges = footer_badges(
1235 MonitorFooterIdentity {
1236 system_name: "robot-alpha".into(),
1237 subsystem_name: None,
1238 mission_name: "autonomous".into(),
1239 instance_id: 42,
1240 },
1241 12846,
1242 );
1243
1244 let labels = badges
1245 .into_iter()
1246 .map(|badge| badge.inner)
1247 .collect::<Vec<_>>();
1248 assert_eq!(
1249 labels,
1250 vec![
1251 " robot-alpha ".to_string(),
1252 " 42 ".to_string(),
1253 " autonomous ".to_string(),
1254 " 00000000000000012846 ".to_string(),
1255 ]
1256 );
1257 }
1258
1259 #[test]
1260 fn clip_with_ellipsis_truncates_long_footer_values() {
1261 assert_eq!(
1262 clip_with_ellipsis("balancebot-simulator-east", 12),
1263 "balancebo..."
1264 );
1265 }
1266
1267 fn test_monitor_model() -> MonitorModel {
1268 static COMPONENTS: [MonitorComponentMetadata; 3] = [
1269 MonitorComponentMetadata::new("sensor", ComponentType::Source, Some("Sensor")),
1270 MonitorComponentMetadata::new("controller", ComponentType::Task, Some("Controller")),
1271 MonitorComponentMetadata::new("actuator", ComponentType::Sink, Some("Actuator")),
1272 ];
1273
1274 let topology = MonitorTopology {
1275 nodes: vec![
1276 MonitorNode {
1277 id: "sensor".to_string(),
1278 type_name: Some("Sensor".to_string()),
1279 kind: ComponentType::Source,
1280 inputs: Vec::new(),
1281 outputs: vec!["imu".to_string()],
1282 },
1283 MonitorNode {
1284 id: "controller".to_string(),
1285 type_name: Some("Controller".to_string()),
1286 kind: ComponentType::Task,
1287 inputs: vec!["imu".to_string()],
1288 outputs: vec!["cmd".to_string()],
1289 },
1290 MonitorNode {
1291 id: "actuator".to_string(),
1292 type_name: Some("Actuator".to_string()),
1293 kind: ComponentType::Sink,
1294 inputs: vec!["cmd".to_string()],
1295 outputs: Vec::new(),
1296 },
1297 ],
1298 connections: Vec::new(),
1299 };
1300
1301 MonitorModel::from_parts(&COMPONENTS, CopperListInfo::new(0, 0), topology)
1302 }
1303
1304 fn wide_test_monitor_model() -> MonitorModel {
1305 static COMPONENTS: [MonitorComponentMetadata; 6] = [
1306 MonitorComponentMetadata::new("source", ComponentType::Source, Some("Source")),
1307 MonitorComponentMetadata::new("estimator", ComponentType::Task, Some("Estimator")),
1308 MonitorComponentMetadata::new("planner", ComponentType::Task, Some("Planner")),
1309 MonitorComponentMetadata::new("controller", ComponentType::Task, Some("Controller")),
1310 MonitorComponentMetadata::new("mixer", ComponentType::Task, Some("Mixer")),
1311 MonitorComponentMetadata::new("actuator", ComponentType::Sink, Some("Actuator")),
1312 ];
1313
1314 let ids = [
1315 "source",
1316 "estimator",
1317 "planner",
1318 "controller",
1319 "mixer",
1320 "actuator",
1321 ];
1322 let nodes = ids
1323 .iter()
1324 .map(|id| MonitorNode {
1325 id: (*id).to_string(),
1326 type_name: Some(id.to_string()),
1327 kind: if *id == "source" {
1328 ComponentType::Source
1329 } else if *id == "actuator" {
1330 ComponentType::Sink
1331 } else {
1332 ComponentType::Task
1333 },
1334 inputs: if *id == "source" {
1335 Vec::new()
1336 } else {
1337 vec!["in".to_string()]
1338 },
1339 outputs: if *id == "actuator" {
1340 Vec::new()
1341 } else {
1342 vec!["out".to_string()]
1343 },
1344 })
1345 .collect();
1346 let connections = ids
1347 .windows(2)
1348 .map(|pair| MonitorConnection {
1349 src: pair[0].to_string(),
1350 src_port: Some("out".to_string()),
1351 dst: pair[1].to_string(),
1352 dst_port: Some("in".to_string()),
1353 msg: "msg".to_string(),
1354 })
1355 .collect();
1356 let topology = MonitorTopology { nodes, connections };
1357
1358 MonitorModel::from_parts(&COMPONENTS, CopperListInfo::new(0, 0), topology)
1359 }
1360
1361 fn parallel_pipelines_monitor_model(pipeline_count: usize) -> MonitorModel {
1362 let mut components = Vec::with_capacity(pipeline_count * 2);
1363 let mut nodes = Vec::with_capacity(pipeline_count * 2);
1364 let mut connections = Vec::with_capacity(pipeline_count);
1365
1366 for idx in 0..pipeline_count {
1367 let source_id = format!("source_{idx}");
1368 let sink_id = format!("sink_{idx}");
1369
1370 components.push(MonitorComponentMetadata::new(
1371 Box::leak(source_id.clone().into_boxed_str()),
1372 ComponentType::Source,
1373 Some("Source"),
1374 ));
1375 components.push(MonitorComponentMetadata::new(
1376 Box::leak(sink_id.clone().into_boxed_str()),
1377 ComponentType::Sink,
1378 Some("Sink"),
1379 ));
1380
1381 nodes.push(MonitorNode {
1382 id: source_id.clone(),
1383 type_name: Some("Source".to_string()),
1384 kind: ComponentType::Source,
1385 inputs: Vec::new(),
1386 outputs: vec!["out".to_string()],
1387 });
1388 nodes.push(MonitorNode {
1389 id: sink_id.clone(),
1390 type_name: Some("Sink".to_string()),
1391 kind: ComponentType::Sink,
1392 inputs: vec!["in".to_string()],
1393 outputs: Vec::new(),
1394 });
1395 connections.push(MonitorConnection {
1396 src: source_id,
1397 src_port: Some("out".to_string()),
1398 dst: sink_id,
1399 dst_port: Some("in".to_string()),
1400 msg: "msg".to_string(),
1401 });
1402 }
1403
1404 let components: &'static [MonitorComponentMetadata] =
1405 Box::leak(components.into_boxed_slice());
1406 let topology = MonitorTopology { nodes, connections };
1407
1408 MonitorModel::from_parts(components, CopperListInfo::new(0, 0), topology)
1409 }
1410}
1411
1412fn point_inside(px: u16, py: u16, x: u16, y: u16, width: u16, height: u16) -> bool {
1413 px >= x && px < x + width && py >= y && py < y + height
1414}
1415
1416fn segment_width(key: &str, label: &str) -> u16 {
1417 (6 + key.chars().count() + label.chars().count()) as u16
1418}
1419
1420fn screen_for_tab_key(key: char) -> Option<MonitorScreen> {
1421 TAB_DEFS
1422 .iter()
1423 .enumerate()
1424 .find(|(i, _)| (b'1' + *i as u8) as char == key)
1425 .map(|(_, tab)| tab.screen)
1426}
1427
1428fn tab_key_hint() -> String {
1429 let n = TAB_DEFS.len();
1430 if n == 0 {
1431 return "tabs".to_string();
1432 }
1433 if n == 1 {
1434 return "1".to_string();
1435 }
1436 format!("1-{n}")
1437}
1438
1439#[cfg(feature = "log_pane")]
1440fn char_to_byte_index(text: &str, char_idx: usize) -> usize {
1441 text.char_indices()
1442 .nth(char_idx)
1443 .map(|(idx, _)| idx)
1444 .unwrap_or(text.len())
1445}
1446
1447#[cfg(feature = "log_pane")]
1448fn slice_char_range(text: &str, start: usize, end: usize) -> (&str, &str, &str) {
1449 let start_idx = char_to_byte_index(text, start).min(text.len());
1450 let end_idx = char_to_byte_index(text, end).min(text.len());
1451 let (start_idx, end_idx) = if start_idx <= end_idx {
1452 (start_idx, end_idx)
1453 } else {
1454 (end_idx, start_idx)
1455 };
1456
1457 (
1458 &text[..start_idx],
1459 &text[start_idx..end_idx],
1460 &text[end_idx..],
1461 )
1462}
1463
1464#[cfg(feature = "log_pane")]
1465fn slice_chars_owned(text: &str, start: usize, end: usize) -> String {
1466 let start_idx = char_to_byte_index(text, start).min(text.len());
1467 let end_idx = char_to_byte_index(text, end).min(text.len());
1468 text[start_idx..end_idx].to_string()
1469}
1470
1471#[cfg(feature = "log_pane")]
1472fn line_selection_bounds(
1473 line_index: usize,
1474 line_len: usize,
1475 start: SelectionPoint,
1476 end: SelectionPoint,
1477) -> Option<(usize, usize)> {
1478 if line_index < start.row || line_index > end.row {
1479 return None;
1480 }
1481
1482 let start_col = if line_index == start.row {
1483 start.col
1484 } else {
1485 0
1486 };
1487 let mut end_col = if line_index == end.row {
1488 end.col
1489 } else {
1490 line_len
1491 };
1492 if line_index == end.row {
1493 end_col = end_col.saturating_add(1).min(line_len);
1494 }
1495
1496 let start_col = start_col.min(line_len);
1497 let end_col = end_col.min(line_len);
1498 if start_col >= end_col {
1499 return None;
1500 }
1501
1502 Some((start_col, end_col))
1503}
1504
1505#[cfg(feature = "log_pane")]
1506fn spans_from_runs(line: &StyledLine) -> Vec<Span<'static>> {
1507 if line.runs.is_empty() {
1508 return vec![Span::raw(line.text.clone())];
1509 }
1510
1511 let mut spans = Vec::new();
1512 let mut cursor = 0usize;
1513 let total_chars = line.text.chars().count();
1514 let mut runs = line.runs.clone();
1515 runs.sort_by_key(|run| run.start);
1516
1517 for run in runs {
1518 let start = run.start.min(total_chars);
1519 let end = run.end.min(total_chars);
1520 if start > cursor {
1521 let before = slice_chars_owned(&line.text, cursor, start);
1522 if !before.is_empty() {
1523 spans.push(Span::raw(before));
1524 }
1525 }
1526 if end > start {
1527 spans.push(Span::styled(
1528 slice_chars_owned(&line.text, start, end),
1529 run.style,
1530 ));
1531 }
1532 cursor = cursor.max(end);
1533 }
1534
1535 if cursor < total_chars {
1536 let tail = slice_chars_owned(&line.text, cursor, total_chars);
1537 if !tail.is_empty() {
1538 spans.push(Span::raw(tail));
1539 }
1540 }
1541
1542 spans
1543}
1544
1545fn format_bytes(bytes: f64) -> String {
1546 const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
1547 let mut value = bytes;
1548 let mut unit_idx = 0;
1549 while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
1550 value /= 1024.0;
1551 unit_idx += 1;
1552 }
1553 if unit_idx == 0 {
1554 format!("{:.0} {}", value, UNITS[unit_idx])
1555 } else {
1556 format!("{:.2} {}", value, UNITS[unit_idx])
1557 }
1558}
1559
1560fn format_bytes_or(bytes: u64, fallback: &str) -> String {
1561 if bytes > 0 {
1562 format_bytes(bytes as f64)
1563 } else {
1564 fallback.to_string()
1565 }
1566}
1567
1568fn format_rate_bytes_or_na(bytes: u64, rate_hz: f64) -> String {
1569 if bytes > 0 {
1570 format!("{}/s", format_bytes((bytes as f64) * rate_hz))
1571 } else {
1572 "n/a".to_string()
1573 }
1574}
1575
1576#[derive(Copy, Clone)]
1577enum NodeType {
1578 Unknown,
1579 Source,
1580 Sink,
1581 Task,
1582 Bridge,
1583}
1584
1585impl std::fmt::Display for NodeType {
1586 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1587 match self {
1588 Self::Unknown => write!(f, "?"),
1589 Self::Source => write!(f, "◈"),
1590 Self::Task => write!(f, "⚙"),
1591 Self::Sink => write!(f, "⭳"),
1592 Self::Bridge => write!(f, "⇆"),
1593 }
1594 }
1595}
1596
1597impl NodeType {
1598 fn color(self) -> Color {
1599 match self {
1600 Self::Unknown => palette::GRAY,
1601 Self::Source => Color::Rgb(255, 191, 0),
1602 Self::Sink => Color::Rgb(255, 102, 204),
1603 Self::Task => palette::WHITE,
1604 Self::Bridge => Color::Rgb(204, 153, 255),
1605 }
1606 }
1607}
1608
1609#[derive(Clone)]
1610struct DisplayNode {
1611 id: String,
1612 type_label: String,
1613 node_type: NodeType,
1614 inputs: Vec<String>,
1615 outputs: Vec<String>,
1616}
1617
1618#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1619struct GraphCacheKey {
1620 area: Option<Size>,
1621 node_count: usize,
1622 connection_count: usize,
1623}
1624
1625struct GraphCache {
1626 graph: Option<NodeGraph<'static>>,
1627 content_size: Size,
1628 key: Option<GraphCacheKey>,
1629 dirty: bool,
1630}
1631
1632impl GraphCache {
1633 fn new() -> Self {
1634 Self {
1635 graph: None,
1636 content_size: Size::ZERO,
1637 key: None,
1638 dirty: true,
1639 }
1640 }
1641
1642 fn needs_rebuild(&self, key: GraphCacheKey) -> bool {
1643 self.dirty || self.graph.is_none() || self.key != Some(key)
1644 }
1645}
1646
1647struct NodesScrollableWidgetState {
1648 model: MonitorModel,
1649 display_nodes: Vec<DisplayNode>,
1650 connections: Vec<Connection>,
1651 status_index_map: Vec<Option<ComponentId>>,
1652 nodes_scrollable_state: ScrollViewState,
1653 graph_cache: GraphCache,
1654 initial_viewport_pending: bool,
1655 last_viewport_area: Option<Size>,
1656}
1657
1658impl NodesScrollableWidgetState {
1659 fn new(model: MonitorModel) -> Self {
1660 let mut display_nodes = Vec::new();
1661 let mut status_index_map = Vec::new();
1662 let mut node_lookup = HashMap::new();
1663 let component_id_by_name: HashMap<&'static str, ComponentId> = model
1664 .components()
1665 .iter()
1666 .enumerate()
1667 .map(|(idx, component)| (component.id(), ComponentId::new(idx)))
1668 .collect();
1669
1670 for node in &model.topology().nodes {
1671 let node_type = match node.kind {
1672 ComponentType::Source => NodeType::Source,
1673 ComponentType::Task => NodeType::Task,
1674 ComponentType::Sink => NodeType::Sink,
1675 ComponentType::Bridge => NodeType::Bridge,
1676 _ => NodeType::Unknown,
1677 };
1678
1679 display_nodes.push(DisplayNode {
1680 id: node.id.clone(),
1681 type_label: node
1682 .type_name
1683 .clone()
1684 .unwrap_or_else(|| "unknown".to_string()),
1685 node_type,
1686 inputs: node.inputs.clone(),
1687 outputs: node.outputs.clone(),
1688 });
1689 let idx = display_nodes.len() - 1;
1690 node_lookup.insert(node.id.clone(), idx);
1691 status_index_map.push(component_id_by_name.get(node.id.as_str()).copied());
1692 }
1693
1694 let mut connections = Vec::with_capacity(model.topology().connections.len());
1695 for connection in &model.topology().connections {
1696 let Some(&src_idx) = node_lookup.get(&connection.src) else {
1697 continue;
1698 };
1699 let Some(&dst_idx) = node_lookup.get(&connection.dst) else {
1700 continue;
1701 };
1702 let src_node = &display_nodes[src_idx];
1703 let dst_node = &display_nodes[dst_idx];
1704 let src_port = connection
1705 .src_port
1706 .as_ref()
1707 .and_then(|port| src_node.outputs.iter().position(|name| name == port))
1708 .unwrap_or(0);
1709 let dst_port = connection
1710 .dst_port
1711 .as_ref()
1712 .and_then(|port| dst_node.inputs.iter().position(|name| name == port))
1713 .unwrap_or(0);
1714
1715 connections.push(Connection::new(
1716 src_idx,
1717 src_port + NODE_PORT_ROW_OFFSET,
1718 dst_idx,
1719 dst_port + NODE_PORT_ROW_OFFSET,
1720 ));
1721 }
1722
1723 if !display_nodes.is_empty() {
1724 let mut from_set = std::collections::HashSet::new();
1725 for connection in &connections {
1726 from_set.insert(connection.from_node);
1727 }
1728 if from_set.len() == display_nodes.len() {
1729 connections.retain(|connection| connection.from_node != 0);
1730 }
1731 }
1732
1733 Self {
1734 model,
1735 display_nodes,
1736 connections,
1737 status_index_map,
1738 nodes_scrollable_state: ScrollViewState::default(),
1739 graph_cache: GraphCache::new(),
1740 initial_viewport_pending: true,
1741 last_viewport_area: None,
1742 }
1743 }
1744
1745 fn mark_graph_dirty(&mut self) {
1746 self.graph_cache.dirty = true;
1747 }
1748
1749 fn ensure_graph_cache(&mut self, area: Rect) -> Size {
1750 let viewport_area: Size = area.into();
1751 let key = self.graph_cache_key(area);
1752 if self.graph_cache.needs_rebuild(key) {
1753 self.rebuild_graph_cache(area, key);
1754 } else if self.last_viewport_area != Some(viewport_area) {
1755 self.clamp_scroll_offset(area, self.graph_cache.content_size);
1756 }
1757 self.last_viewport_area = Some(viewport_area);
1758 self.graph_cache.content_size
1759 }
1760
1761 fn graph(&self) -> &NodeGraph<'static> {
1762 self.graph_cache
1763 .graph
1764 .as_ref()
1765 .expect("graph cache must be initialized before render")
1766 }
1767
1768 fn graph_cache_key(&self, area: Rect) -> GraphCacheKey {
1769 GraphCacheKey {
1770 area: self.display_nodes.is_empty().then_some(area.into()),
1771 node_count: self.display_nodes.len(),
1772 connection_count: self.connections.len(),
1773 }
1774 }
1775
1776 fn estimate_initial_graph_size(&self, area: Rect) -> Size {
1777 if self.display_nodes.is_empty() {
1778 return Size::new(area.width.max(NODE_WIDTH), area.height.max(NODE_HEIGHT));
1779 }
1780
1781 let node_layouts = self.build_node_layouts();
1782 let content_width = (node_layouts.len() as u16)
1783 .saturating_mul(NODE_WIDTH + 20)
1784 .max(NODE_WIDTH);
1785 let stacked_height = node_layouts
1788 .iter()
1789 .fold(0u16, |height, node| height.saturating_add(node.size.1));
1790 let max_ports = self
1791 .display_nodes
1792 .iter()
1793 .map(|node| node.inputs.len().max(node.outputs.len()))
1794 .max()
1795 .unwrap_or_default();
1796 let min_height = (((max_ports + NODE_PORT_ROW_OFFSET) as u16) * 12).max(NODE_HEIGHT * 6);
1797 Size::new(content_width, stacked_height.max(min_height))
1798 }
1799
1800 fn build_graph(&self, content_size: Size) -> NodeGraph<'static> {
1801 let mut graph = NodeGraph::new(
1802 self.build_node_layouts(),
1803 self.connections.clone(),
1804 content_size.width as usize,
1805 content_size.height as usize,
1806 );
1807 graph.calculate();
1808 graph
1809 }
1810
1811 fn rebuild_graph_cache(&mut self, area: Rect, key: GraphCacheKey) {
1812 let content_size = if self.display_nodes.is_empty() {
1813 self.estimate_initial_graph_size(area)
1814 } else {
1815 let graph = self.build_graph(self.estimate_initial_graph_size(area));
1816 let bounds = graph.content_bounds();
1817 let desired_width = bounds
1818 .width
1819 .saturating_add(GRAPH_WIDTH_PADDING)
1820 .max(NODE_WIDTH);
1821 let desired_height = bounds
1822 .height
1823 .saturating_add(GRAPH_HEIGHT_PADDING)
1824 .max(NODE_HEIGHT);
1825 Size::new(desired_width, desired_height)
1826 };
1827
1828 let graph = self.build_graph(content_size);
1829 let graph_bounds = graph.content_bounds();
1830 self.graph_cache.graph = Some(graph);
1831 self.graph_cache.content_size = content_size;
1832 self.graph_cache.key = Some(key);
1833 self.graph_cache.dirty = false;
1834 self.last_viewport_area = Some(area.into());
1835
1836 if self.initial_viewport_pending {
1837 self.nodes_scrollable_state
1838 .set_offset(initial_graph_scroll_offset(
1839 area,
1840 content_size,
1841 graph_bounds,
1842 ));
1843 self.initial_viewport_pending = false;
1844 } else {
1845 self.clamp_scroll_offset(area, content_size);
1846 }
1847 }
1848
1849 fn build_node_layouts(&self) -> Vec<NodeLayout<'static>> {
1850 self.display_nodes
1851 .iter()
1852 .map(|node| {
1853 let ports = node.inputs.len().max(node.outputs.len());
1854 let content_rows = ports + NODE_PORT_ROW_OFFSET;
1855 let height = (content_rows as u16).saturating_add(2).max(NODE_HEIGHT);
1856 let title_line = Line::from(vec![
1857 Span::styled(
1858 format!(" {}", node.node_type),
1859 Style::default().fg(node.node_type.color()),
1860 ),
1861 Span::styled(
1862 format!(" {} ", node.id),
1863 Style::default().fg(palette::WHITE),
1864 ),
1865 ]);
1866 NodeLayout::new((NODE_WIDTH, height)).with_title_line(title_line)
1867 })
1868 .collect()
1869 }
1870
1871 fn clamp_scroll_offset(&mut self, area: Rect, content_size: Size) {
1872 let max_x = content_size
1873 .width
1874 .saturating_sub(area.width.saturating_sub(1));
1875 let max_y = content_size
1876 .height
1877 .saturating_sub(area.height.saturating_sub(1));
1878 let offset = self.nodes_scrollable_state.offset();
1879 let clamped = Position::new(offset.x.min(max_x), offset.y.min(max_y));
1880 self.nodes_scrollable_state.set_offset(clamped);
1881 }
1882}
1883
1884struct NodesScrollableWidget<'a> {
1885 _marker: PhantomData<&'a ()>,
1886}
1887
1888const NODE_WIDTH: u16 = 29;
1889const NODE_WIDTH_CONTENT: u16 = NODE_WIDTH - 2;
1890const NODE_HEIGHT: u16 = 5;
1891const NODE_META_LINES: usize = 2;
1892const NODE_PORT_ROW_OFFSET: usize = NODE_META_LINES;
1893const GRAPH_WIDTH_PADDING: u16 = NODE_WIDTH * 2;
1894const GRAPH_HEIGHT_PADDING: u16 = NODE_HEIGHT * 4;
1895const INITIAL_GRAPH_FOCUS_X_NUMERATOR: u32 = 5;
1896const INITIAL_GRAPH_FOCUS_X_DENOMINATOR: u32 = 8;
1897
1898fn clip_tail(value: &str, max_chars: usize) -> String {
1899 if max_chars == 0 {
1900 return String::new();
1901 }
1902 let char_count = value.chars().count();
1903 if char_count <= max_chars {
1904 return value.to_string();
1905 }
1906 let skip = char_count.saturating_sub(max_chars);
1907 let start = value
1908 .char_indices()
1909 .nth(skip)
1910 .map(|(idx, _)| idx)
1911 .unwrap_or(value.len());
1912 value[start..].to_string()
1913}
1914
1915fn clip_with_ellipsis(value: &str, max_chars: usize) -> String {
1916 if max_chars == 0 {
1917 return String::new();
1918 }
1919 let char_count = value.chars().count();
1920 if char_count <= max_chars {
1921 return value.to_string();
1922 }
1923 if max_chars <= 3 {
1924 return value.chars().take(max_chars).collect();
1925 }
1926 let prefix: String = value.chars().take(max_chars - 3).collect();
1927 format!("{prefix}...")
1928}
1929
1930fn footer_badges(identity: MonitorFooterIdentity, clid: u64) -> Vec<FooterBadge> {
1931 let mut badges = vec![FooterBadge {
1932 inner: format!(
1933 " {} ",
1934 clip_with_ellipsis(identity.system_name.as_str(), 18)
1935 ),
1936 bg: Color::Rgb(92, 102, 150),
1937 fg: Color::Rgb(236, 236, 236),
1938 }];
1939 if let Some(subsystem_name) = identity.subsystem_name {
1940 badges.push(FooterBadge {
1941 inner: format!(" {} ", clip_with_ellipsis(subsystem_name.as_str(), 18)),
1942 bg: Color::Rgb(116, 88, 128),
1943 fg: Color::Rgb(236, 236, 236),
1944 });
1945 }
1946 badges.extend([
1947 FooterBadge {
1948 inner: format!(" {} ", identity.instance_id),
1949 bg: Color::Rgb(136, 92, 78),
1950 fg: Color::Rgb(248, 231, 176),
1951 },
1952 FooterBadge {
1953 inner: format!(
1954 " {} ",
1955 clip_with_ellipsis(identity.mission_name.as_str(), 16)
1956 ),
1957 bg: Color::Rgb(86, 114, 98),
1958 fg: Color::Rgb(236, 236, 236),
1959 },
1960 FooterBadge {
1961 inner: format!(" {:020} ", clid),
1962 bg: Color::Rgb(198, 146, 64),
1963 fg: palette::BACKGROUND,
1964 },
1965 ]);
1966 badges
1967}
1968
1969fn footer_badge_line(badges: &[FooterBadge], base_bg: Color) -> (Line<'static>, u16) {
1970 if badges.is_empty() {
1971 return (Line::default(), 0);
1972 }
1973
1974 let mut spans = Vec::with_capacity(badges.len().saturating_mul(2).saturating_add(1));
1975 let mut width = 0u16;
1976 spans.push(Span::styled(
1977 "",
1978 Style::default().fg(badges[0].bg).bg(base_bg),
1979 ));
1980 width = width.saturating_add(1);
1981
1982 for (idx, badge) in badges.iter().enumerate() {
1983 spans.push(Span::styled(
1984 badge.inner.clone(),
1985 Style::default()
1986 .fg(badge.fg)
1987 .bg(badge.bg)
1988 .add_modifier(Modifier::BOLD),
1989 ));
1990 width = width.saturating_add(badge.inner.chars().count() as u16);
1991
1992 let next_bg = badges.get(idx + 1).map(|next| next.bg).unwrap_or(base_bg);
1993 spans.push(Span::styled("", Style::default().fg(badge.bg).bg(next_bg)));
1994 width = width.saturating_add(1);
1995 }
1996
1997 (Line::from(spans), width)
1998}
1999
2000fn initial_graph_scroll_offset(area: Rect, content_size: Size, graph_bounds: Size) -> Position {
2001 let max_x = content_size
2002 .width
2003 .saturating_sub(area.width.saturating_sub(1));
2004 let max_y = content_size
2005 .height
2006 .saturating_sub(area.height.saturating_sub(1));
2007 let focus_x = (((graph_bounds.width as u32) * INITIAL_GRAPH_FOCUS_X_NUMERATOR)
2008 / INITIAL_GRAPH_FOCUS_X_DENOMINATOR) as u16;
2009 let focus_y = graph_bounds.height / 2;
2010
2011 Position::new(
2012 focus_x.saturating_sub(area.width / 2).min(max_x),
2013 focus_y.saturating_sub(area.height / 2).min(max_y),
2014 )
2015}
2016
2017impl StatefulWidget for NodesScrollableWidget<'_> {
2018 type State = NodesScrollableWidgetState;
2019
2020 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
2021 let content_size = state.ensure_graph_cache(area);
2022 let mut scroll_view = ScrollView::new(content_size);
2023 scroll_view.render_widget(
2024 Block::default().style(Style::default().bg(palette::BACKGROUND)),
2025 Rect::new(0, 0, content_size.width, content_size.height),
2026 );
2027
2028 {
2029 let graph = state.graph();
2030 let zones = graph.split(scroll_view.area());
2031
2032 let mut statuses = state.model.inner.component_statuses.lock().unwrap();
2033 for (idx, zone) in zones.into_iter().enumerate() {
2034 let status = state
2035 .status_index_map
2036 .get(idx)
2037 .and_then(|component_id| *component_id)
2038 .and_then(|component_id| statuses.get_mut(component_id.index()))
2039 .map(|status| {
2040 let snapshot: ComponentStatus = status.clone();
2041 status.is_error = false;
2042 snapshot
2043 })
2044 .unwrap_or_default();
2045 let node = &state.display_nodes[idx];
2046 let status_line = if status.is_error {
2047 format!("❌ {}", status.error)
2048 } else {
2049 format!("✓ {}", status.status_txt)
2050 };
2051
2052 let label_width = (NODE_WIDTH_CONTENT as usize).saturating_sub(2);
2053 let type_label = clip_tail(&node.type_label, label_width);
2054 let status_text = clip_tail(&status_line, label_width);
2055 let base_style = if status.is_error {
2056 Style::default().fg(palette::RED)
2057 } else {
2058 Style::default().fg(palette::GREEN)
2059 };
2060 let mut lines = vec![
2061 Line::styled(format!(" {}", type_label), base_style),
2062 Line::styled(format!(" {}", status_text), base_style),
2063 ];
2064
2065 let max_ports = node.inputs.len().max(node.outputs.len());
2066 if max_ports > 0 {
2067 let left_width = (NODE_WIDTH_CONTENT as usize - 2) / 2;
2068 let right_width = NODE_WIDTH_CONTENT as usize - 2 - left_width;
2069 let input_style = Style::default().fg(palette::YELLOW);
2070 let output_style = Style::default().fg(palette::CYAN);
2071 let dotted_style = Style::default().fg(palette::DARK_GRAY);
2072 for port_idx in 0..max_ports {
2073 let input = node
2074 .inputs
2075 .get(port_idx)
2076 .map(|label| clip_tail(label, left_width))
2077 .unwrap_or_default();
2078 let output = node
2079 .outputs
2080 .get(port_idx)
2081 .map(|label| clip_tail(label, right_width))
2082 .unwrap_or_default();
2083 let mut port_line = Line::default();
2084 port_line.spans.push(Span::styled(
2085 format!(" {:<left_width$}", input, left_width = left_width),
2086 input_style,
2087 ));
2088 port_line.spans.push(Span::styled("┆", dotted_style));
2089 port_line.spans.push(Span::styled(
2090 format!("{:>right_width$}", output, right_width = right_width),
2091 output_style,
2092 ));
2093 lines.push(port_line);
2094 }
2095 }
2096
2097 scroll_view.render_widget(Paragraph::new(Text::from(lines)), zone);
2098 }
2099
2100 let content_area = Rect::new(0, 0, content_size.width, content_size.height);
2101 scroll_view.render_widget(graph, content_area);
2102 }
2103
2104 scroll_view.render(area, buf, &mut state.nodes_scrollable_state);
2105 }
2106}