1use crate::args::Column;
8use crate::sixel::SixelRenderer;
9use crate::SparklineScale;
10use crate::{HopStats, MtrSession, Result};
11use crossterm::{
12 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
13 execute,
14 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
15};
16use ratatui::{
17 backend::CrosstermBackend,
18 buffer::Buffer,
19 layout::{Alignment, Constraint, Direction, Layout, Rect},
20 style::{Color, Style},
21 text::{Line, Span},
22 widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Widget},
23 Frame, Terminal,
24};
25use std::{
26 io,
27 sync::{Arc, Mutex},
28 time::{Duration, Instant},
29};
30use tokio::sync::mpsc;
31use tracing::debug;
32
33#[derive(Debug, Clone)]
38pub struct UiState {
39 pub current_sparkline_scale: SparklineScale,
40 pub color_support: ColorSupport,
41 pub columns: Vec<Column>,
42 pub current_column_index: usize,
43 pub sixel_renderer: SixelRenderer,
44 pub show_help: bool,
45 pub visualization_mode: VisualizationMode,
46 pub show_hostnames: bool, pub show_column_selector: bool, pub column_selector_state: ColumnSelectorState, }
50
51#[derive(Debug, Clone, Copy)]
52pub enum ColorSupport {
53 None, Basic, Extended, TrueColor, }
58
59#[derive(Debug, Clone, Copy)]
60pub enum VisualizationMode {
61 Sparkline, Heatmap, }
64
65#[derive(Debug, Clone)]
70pub struct ColumnSelectorState {
71 pub selected_index: usize,
72 pub available_columns: Vec<(Column, bool)>, }
74
75impl ColumnSelectorState {
76 pub fn new(enabled_columns: &[Column]) -> Self {
77 let all_columns = Column::all();
78 let available_columns = all_columns
79 .into_iter()
80 .map(|col| (col, enabled_columns.contains(&col)))
81 .collect();
82
83 Self {
84 selected_index: 0,
85 available_columns,
86 }
87 }
88
89 pub fn move_up(&mut self) {
90 if self.selected_index > 0 {
91 self.selected_index -= 1;
92 }
93 }
94
95 pub fn move_down(&mut self) {
96 if self.selected_index < self.available_columns.len().saturating_sub(1) {
97 self.selected_index += 1;
98 }
99 }
100
101 pub fn toggle_selected(&mut self) {
102 if let Some((_col, enabled)) = self.available_columns.get_mut(self.selected_index) {
103 *enabled = !*enabled;
104 }
105 }
106
107 pub fn move_selected_up(&mut self) {
108 if self.selected_index > 0 {
109 self.available_columns
110 .swap(self.selected_index - 1, self.selected_index);
111 self.selected_index -= 1;
112 }
113 }
114
115 pub fn move_selected_down(&mut self) {
116 if self.selected_index < self.available_columns.len().saturating_sub(1) {
117 self.available_columns
118 .swap(self.selected_index, self.selected_index + 1);
119 self.selected_index += 1;
120 }
121 }
122
123 pub fn get_enabled_columns(&self) -> Vec<Column> {
124 self.available_columns
125 .iter()
126 .filter(|(_, enabled)| *enabled)
127 .map(|(col, _)| *col)
128 .collect()
129 }
130}
131
132impl UiState {
133 pub fn new(scale: SparklineScale, columns: Vec<Column>, enable_sixel: bool) -> Self {
134 let column_selector_state = ColumnSelectorState::new(&columns);
135 Self {
136 current_sparkline_scale: scale,
137 color_support: detect_color_support(),
138 columns,
139 current_column_index: 0,
140 sixel_renderer: SixelRenderer::new(enable_sixel),
141 show_help: false,
142 visualization_mode: VisualizationMode::Sparkline,
143 show_hostnames: true, show_column_selector: false,
145 column_selector_state,
146 }
147 }
148
149 pub fn toggle_help(&mut self) {
150 self.show_help = !self.show_help;
151 }
152
153 pub fn toggle_column_selector(&mut self) {
154 if self.show_column_selector {
155 } else {
157 self.column_selector_state = ColumnSelectorState::new(&self.columns);
159 }
160 self.show_column_selector = !self.show_column_selector;
161 }
162
163 pub fn toggle_selected_column_immediate(&mut self) {
165 self.column_selector_state.toggle_selected();
166 self.apply_column_changes_immediate();
167 }
168
169 pub fn move_selected_column_up_immediate(&mut self) {
170 self.column_selector_state.move_selected_up();
171 self.apply_column_changes_immediate();
172 }
173
174 pub fn move_selected_column_down_immediate(&mut self) {
175 self.column_selector_state.move_selected_down();
176 self.apply_column_changes_immediate();
177 }
178
179 fn apply_column_changes_immediate(&mut self) {
180 self.columns = self.column_selector_state.get_enabled_columns();
181 if self.columns.is_empty() {
183 self.columns.push(Column::Host);
184 self.column_selector_state
185 .available_columns
186 .iter_mut()
187 .find(|(col, _)| matches!(col, Column::Host))
188 .map(|(_, enabled)| *enabled = true);
189 }
190 }
191
192 pub fn toggle_visualization_mode(&mut self) {
193 self.visualization_mode = match self.visualization_mode {
194 VisualizationMode::Sparkline => VisualizationMode::Heatmap,
195 VisualizationMode::Heatmap => VisualizationMode::Sparkline,
196 };
197 }
198
199 pub fn toggle_hostnames(&mut self) {
200 self.show_hostnames = !self.show_hostnames;
201 }
202
203 pub fn toggle_sparkline_scale(&mut self) {
204 self.current_sparkline_scale = match self.current_sparkline_scale {
205 SparklineScale::Linear => SparklineScale::Logarithmic,
206 SparklineScale::Logarithmic => SparklineScale::Linear,
207 };
208 }
209
210 pub fn cycle_color_mode(&mut self) {
211 self.color_support = match self.color_support {
212 ColorSupport::None => ColorSupport::Basic,
213 ColorSupport::Basic => ColorSupport::Extended,
214 ColorSupport::Extended => ColorSupport::TrueColor,
215 ColorSupport::TrueColor => ColorSupport::None,
216 };
217 }
218
219 pub fn toggle_column(&mut self) {
220 if !self.columns.is_empty() {
221 self.current_column_index = (self.current_column_index + 1) % self.columns.len();
222 let all_columns = Column::all();
223 let removed_column = self.columns.remove(self.current_column_index);
224
225 for col in &all_columns {
226 if !self.columns.contains(col) && *col != removed_column {
227 self.columns.insert(self.current_column_index, *col);
228 break;
229 }
230 }
231
232 if self.current_column_index >= self.columns.len() {
233 self.current_column_index = 0;
234 }
235 }
236 }
237
238 pub fn add_column(&mut self, column: Column) {
239 if !self.columns.contains(&column) {
240 self.columns.push(column);
241 }
242 }
243
244 pub fn remove_column(&mut self, column: Column) {
245 if let Some(pos) = self.columns.iter().position(|&c| c == column) {
246 self.columns.remove(pos);
247 if self.current_column_index >= self.columns.len() && self.current_column_index > 0 {
248 self.current_column_index = self.columns.len() - 1;
249 }
250 }
251 }
252
253 pub fn get_header(&self) -> String {
254 let mut header = String::from(" ");
255 for (i, column) in self.columns.iter().enumerate() {
256 if i > 0 {
257 header.push(' ');
258 }
259 match column {
260 Column::Hop => {} Column::Host => header.push_str(&format!("{:21}", column.header())), Column::Loss => header.push_str(&format!("{:>7}", column.header())), Column::Sent => header.push_str(&format!("{:>4}", column.header())), Column::Last | Column::Avg | Column::Ema | Column::Best | Column::Worst => {
265 header.push_str(&format!("{:>9}", column.header())); }
267 Column::Jitter | Column::JitterAvg => {
268 header.push_str(&format!("{:>9}", column.header())); }
270 Column::Graph => header.push_str(column.header()), }
272 }
273 header
274 }
275}
276
277fn detect_color_support() -> ColorSupport {
279 if let Ok(colorterm) = std::env::var("COLORTERM") {
280 if colorterm.contains("truecolor") || colorterm.contains("24bit") {
281 return ColorSupport::TrueColor;
282 }
283 }
284
285 if let Ok(term) = std::env::var("TERM") {
286 if term.contains("256") || term.contains("256color") {
287 return ColorSupport::Extended;
288 }
289 if term.contains("color") || term.starts_with("screen") {
290 return ColorSupport::Basic;
291 }
292 }
293
294 ColorSupport::Basic
295}
296
297mod colors {
303 use super::{Color, ColorSupport};
304
305 pub fn get_rtt_color(ratio: f64, color_support: ColorSupport) -> (char, Color) {
306 let level = (ratio.clamp(0.0, 1.0) * 8.0) as usize;
307 let char = ['▁', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][level.min(8)];
308
309 let color = match color_support {
310 ColorSupport::None => Color::White,
311 ColorSupport::Basic => [
312 Color::Green,
313 Color::Green,
314 Color::Cyan,
315 Color::Yellow,
316 Color::Yellow,
317 Color::Magenta,
318 Color::Magenta,
319 Color::Red,
320 Color::Red,
321 ][level.min(8)],
322 ColorSupport::Extended => [17, 21, 39, 75, 111, 179, 215, 208, 130]
323 .get(level)
324 .map(|&i| Color::Indexed(i))
325 .unwrap_or(Color::Indexed(130)),
326 ColorSupport::TrueColor => {
327 let colors = [
328 (0, 50, 150),
329 (0, 100, 200),
330 (50, 150, 255),
331 (100, 200, 255),
332 (150, 220, 255),
333 (255, 200, 100),
334 (255, 150, 50),
335 (220, 120, 0),
336 (150, 80, 0),
337 ];
338 let (r, g, b) = colors[level.min(8)];
339 Color::Rgb(r, g, b)
340 }
341 };
342
343 (char, color)
344 }
345
346 pub fn get_smooth_gradient_color(ratio: f64, color_support: ColorSupport) -> Color {
347 let ratio = ratio.clamp(0.0, 1.0);
348
349 match color_support {
350 ColorSupport::None => Color::White,
351 ColorSupport::Basic => [
352 Color::Blue,
353 Color::Cyan,
354 Color::Yellow,
355 Color::Magenta,
356 Color::Red,
357 ][(ratio * 4.0) as usize],
358 ColorSupport::Extended => {
359 let steps = [17, 21, 33, 39, 75, 111, 179, 215];
360 let index = (ratio * (steps.len() - 1) as f64) as usize;
361 Color::Indexed(steps[index.min(steps.len() - 1)])
362 }
363 ColorSupport::TrueColor => {
364 let (r, g, b) = if ratio < 0.5 {
365 let t = ratio * 2.0;
366 interpolate_rgb((0.0, 50.0, 150.0), (150.0, 220.0, 255.0), t)
367 } else {
368 let t = (ratio - 0.5) * 2.0;
369 interpolate_rgb((150.0, 220.0, 255.0), (220.0, 120.0, 0.0), t)
370 };
371 Color::Rgb(r as u8, g as u8, b as u8)
372 }
373 }
374 }
375
376 pub fn get_loss_color(color_support: ColorSupport) -> Color {
377 match color_support {
378 ColorSupport::None => Color::White,
379 ColorSupport::Basic => Color::Red,
380 ColorSupport::Extended => Color::Indexed(196),
381 ColorSupport::TrueColor => Color::Rgb(255, 0, 0),
382 }
383 }
384
385 pub fn get_pending_color(color_support: ColorSupport) -> Color {
386 match color_support {
387 ColorSupport::None => Color::White,
388 ColorSupport::Basic => Color::Blue,
389 ColorSupport::Extended => Color::Indexed(27),
390 ColorSupport::TrueColor => Color::Rgb(100, 100, 255),
391 }
392 }
393
394 fn interpolate_rgb(
395 start: (f64, f64, f64),
396 end: (f64, f64, f64),
397 ratio: f64,
398 ) -> (f64, f64, f64) {
399 let ratio = ratio.clamp(0.0, 1.0);
400 (
401 start.0 + (end.0 - start.0) * ratio,
402 start.1 + (end.1 - start.1) * ratio,
403 start.2 + (end.2 - start.2) * ratio,
404 )
405 }
406}
407
408fn create_sparkline_spans(
414 hop: &HopStats,
415 global_min_rtt: u64,
416 global_max_rtt: u64,
417 scale: SparklineScale,
418 color_support: ColorSupport,
419 max_width: usize,
420) -> Vec<Span<'static>> {
421 if hop.sent == 0 || max_width == 0 {
422 return vec![];
423 }
424
425 let packet_outcomes: Vec<_> = hop.packet_history.iter().collect();
426 if packet_outcomes.is_empty() {
427 return vec![Span::raw(" ".repeat(max_width))];
428 }
429
430 let data_to_show = if packet_outcomes.len() > max_width {
431 &packet_outcomes[packet_outcomes.len() - max_width..]
432 } else {
433 &packet_outcomes[..]
434 };
435
436 let mut spans: Vec<Span<'static>> = data_to_show
437 .iter()
438 .map(|outcome| match outcome {
439 crate::hop_stats::PacketOutcome::Received(rtt) => {
440 let rtt_ms = (rtt.as_secs_f64() * 1000.0) as u64;
441 let ratio = calculate_rtt_ratio(rtt_ms, global_min_rtt, global_max_rtt, scale);
442 let (char, color) = colors::get_rtt_color(ratio, color_support);
443 Span::styled(char.to_string(), Style::default().fg(color))
444 }
445 crate::hop_stats::PacketOutcome::Lost => {
446 let color = colors::get_loss_color(color_support);
447 Span::styled("·".to_string(), Style::default().fg(color))
448 }
449 crate::hop_stats::PacketOutcome::Pending => {
450 let color = colors::get_pending_color(color_support);
451 Span::styled("?".to_string(), Style::default().fg(color))
452 }
453 })
454 .collect();
455
456 if spans.len() < max_width {
457 spans.push(Span::raw(" ".repeat(max_width - spans.len())));
458 }
459
460 spans
461}
462
463fn create_heatmap_spans(
465 hop: &HopStats,
466 global_min_rtt: u64,
467 global_max_rtt: u64,
468 scale: SparklineScale,
469 color_support: ColorSupport,
470 max_width: usize,
471) -> Vec<Span<'static>> {
472 if hop.sent == 0 || max_width == 0 {
473 return vec![];
474 }
475
476 let packet_outcomes: Vec<_> = hop.packet_history.iter().collect();
477 if packet_outcomes.is_empty() {
478 return vec![Span::raw(" ".repeat(max_width))];
479 }
480
481 let data_to_show = if packet_outcomes.len() > max_width {
482 &packet_outcomes[packet_outcomes.len() - max_width..]
483 } else {
484 &packet_outcomes[..]
485 };
486
487 let mut spans: Vec<Span<'static>> = data_to_show
488 .iter()
489 .map(|outcome| {
490 match outcome {
491 crate::hop_stats::PacketOutcome::Received(rtt) => {
492 let rtt_ms = (rtt.as_secs_f64() * 1000.0) as u64;
493 let ratio = calculate_rtt_ratio(rtt_ms, global_min_rtt, global_max_rtt, scale);
494 let color = colors::get_smooth_gradient_color(ratio, color_support);
496 Span::styled("█".to_string(), Style::default().fg(color))
497 }
498 crate::hop_stats::PacketOutcome::Lost => {
499 let color = colors::get_loss_color(color_support);
500 Span::styled("·".to_string(), Style::default().fg(color))
501 }
502 crate::hop_stats::PacketOutcome::Pending => {
503 let color = colors::get_pending_color(color_support);
504 Span::styled("·".to_string(), Style::default().fg(color))
505 }
506 }
507 })
508 .collect();
509
510 if spans.len() < max_width {
511 spans.push(Span::raw(" ".repeat(max_width - spans.len())));
512 }
513
514 spans
515}
516
517fn calculate_rtt_ratio(
518 rtt_ms: u64,
519 global_min: u64,
520 global_max: u64,
521 scale: SparklineScale,
522) -> f64 {
523 if global_min == global_max || rtt_ms == 0 {
524 return 0.0;
525 }
526
527 match scale {
528 SparklineScale::Linear => rtt_ms as f64 / global_max as f64,
529 SparklineScale::Logarithmic => {
530 let log_rtt = ((rtt_ms + 1) as f64).log10();
531 let log_min = ((global_min + 1) as f64).log10();
532 let log_max = ((global_max + 1) as f64).log10();
533 (log_rtt - log_min) / (log_max - log_min)
534 }
535 }
536}
537
538pub struct EnhancedTable<'a> {
544 table: Table<'a>,
545 sixel_renderer: &'a SixelRenderer,
546 columns: &'a [Column],
547}
548
549impl<'a> EnhancedTable<'a> {
550 pub fn new(table: Table<'a>, sixel_renderer: &'a SixelRenderer, columns: &'a [Column]) -> Self {
551 Self {
552 table,
553 sixel_renderer,
554 columns,
555 }
556 }
557}
558
559impl<'a> Widget for EnhancedTable<'a> {
560 fn render(self, area: Rect, buf: &mut Buffer) {
561 self.table.render(area, buf);
562
563 if !self.sixel_renderer.enabled {
564 return;
565 }
566
567 if let Some(_graph_col_idx) = self
569 .columns
570 .iter()
571 .position(|col| matches!(col, Column::Graph))
572 {
573 }
576 }
577}
578
579fn create_table_cells(
581 hop: &HopStats,
582 hostname: &str,
583 sparkline_spans: &[Span<'static>],
584 columns: &[Column],
585 sixel_enabled: bool,
586) -> Vec<Cell<'static>> {
587 columns
588 .iter()
589 .map(|column| {
590 match column {
591 Column::Hop => {
592 if hop.has_multiple_paths() {
593 Cell::from(format!("{:>1}*", hop.hop))
594 } else {
595 Cell::from(format!("{:>2}", hop.hop))
596 }
597 }
598 Column::Host => Cell::from(hostname.to_owned()),
599 Column::Loss => {
600 let loss_pct = hop.loss_percent;
601 let color = if loss_pct > 50.0 {
602 Color::Red
603 } else if loss_pct > 10.0 {
604 Color::Yellow
605 } else {
606 Color::Green
607 };
608 Cell::from(format!("{:>4.1}%", loss_pct)).style(Style::default().fg(color))
609 }
610 Column::Sent => Cell::from(format!("{:>3}", hop.sent)),
611 Column::Last => {
612 if let Some(last_rtt) = hop.rtts.back() {
613 Cell::from(format!("{:>6.1}", last_rtt.as_secs_f64() * 1000.0))
614 } else {
615 Cell::from(format!("{:>6}", "???"))
616 }
617 }
618 Column::Avg => {
619 if let Some(avg_rtt) = hop.avg_rtt {
620 Cell::from(format!("{:>6.1}", avg_rtt.as_secs_f64() * 1000.0))
621 } else {
622 Cell::from(format!("{:>6}", "???"))
623 }
624 }
625 Column::Ema => {
626 if let Some(ema_rtt) = hop.ema_rtt {
627 Cell::from(format!("{:>6.1}", ema_rtt.as_secs_f64() * 1000.0))
628 } else {
629 Cell::from(format!("{:>6}", "???"))
630 }
631 }
632 Column::Jitter => {
633 if let Some(jitter) = hop.last_jitter {
634 Cell::from(format!("{:>6.1}", jitter.as_secs_f64() * 1000.0))
635 } else {
636 Cell::from(format!("{:>6}", "???"))
637 }
638 }
639 Column::JitterAvg => {
640 if let Some(jitter_avg) = hop.jitter_avg {
641 Cell::from(format!("{:>6.1}", jitter_avg.as_secs_f64() * 1000.0))
642 } else {
643 Cell::from(format!("{:>6}", "???"))
644 }
645 }
646 Column::Best => {
647 if let Some(best_rtt) = hop.best_rtt {
648 Cell::from(format!("{:>6.1}", best_rtt.as_secs_f64() * 1000.0))
649 } else {
650 Cell::from(format!("{:>6}", "???"))
651 }
652 }
653 Column::Worst => {
654 if let Some(worst_rtt) = hop.worst_rtt {
655 Cell::from(format!("{:>6.1}", worst_rtt.as_secs_f64() * 1000.0))
656 } else {
657 Cell::from(format!("{:>6}", "???"))
658 }
659 }
660 Column::Graph => {
661 if sixel_enabled {
662 Cell::from("") } else if !sparkline_spans.is_empty() {
664 Cell::from(Line::from(sparkline_spans.to_vec()))
665 } else {
666 Cell::from("")
667 }
668 }
669 }
670 })
671 .collect()
672}
673
674fn create_column_constraints(columns: &[Column]) -> Vec<Constraint> {
676 let has_graph = columns.iter().any(|col| matches!(col, Column::Graph));
677
678 columns
679 .iter()
680 .map(|column| {
681 match column {
682 Column::Hop => Constraint::Length(3),
683 Column::Host => {
684 if has_graph {
685 Constraint::Percentage(35)
687 } else {
688 Constraint::Min(20)
689 }
690 }
691 Column::Loss => Constraint::Length(5),
692 Column::Sent => Constraint::Length(3),
693 Column::Last | Column::Avg | Column::Ema | Column::Best | Column::Worst => {
694 if has_graph {
695 Constraint::Length(6)
696 } else {
697 Constraint::Length(9)
698 }
699 }
700 Column::Jitter | Column::JitterAvg => {
701 if has_graph {
702 Constraint::Length(6)
703 } else {
704 Constraint::Length(9)
705 }
706 }
707 Column::Graph => Constraint::Percentage(65), }
709 })
710 .collect()
711}
712
713fn create_status_text(session: &MtrSession, ui_state: &UiState) -> Line<'static> {
719 let total_sent: usize = session.hops.iter().map(|h| h.sent).sum();
720 let total_received: usize = session.hops.iter().map(|h| h.received).sum();
721 let overall_loss = if total_sent > 0 {
722 ((total_sent - total_received) as f64 / total_sent as f64) * 100.0
723 } else {
724 0.0
725 };
726
727 let active_hops = session.hops.iter().filter(|h| h.sent > 0).count();
728 let scale_name = match ui_state.current_sparkline_scale {
729 SparklineScale::Linear => "Linear",
730 SparklineScale::Logarithmic => "Log",
731 };
732
733 let viz_mode = match ui_state.visualization_mode {
734 VisualizationMode::Sparkline => "Sparkline",
735 VisualizationMode::Heatmap => "Heatmap",
736 };
737
738 let hostname_mode = if ui_state.show_hostnames {
739 "Hostnames"
740 } else {
741 "IPs"
742 };
743
744 let main_text = format!(
745 "mtr-ng: {} → {} | Hops: {} | Sent: {} | Loss: {:.1}% | Scale: {} | Mode: {} | Display: {}",
746 session.target,
747 session.target_addr,
748 active_hops,
749 total_sent,
750 overall_loss,
751 scale_name,
752 viz_mode,
753 hostname_mode
754 );
755
756 Line::from(vec![
757 Span::raw(main_text),
758 Span::raw(" | "),
759 Span::styled("? for help", Style::default().fg(Color::Gray)),
760 ])
761}
762
763fn create_column_selector_popup(state: &ColumnSelectorState) -> Paragraph<'static> {
765 let mut lines = vec![
766 Line::from(vec![Span::styled(
767 "Column Selection & Ordering",
768 Style::default().fg(Color::Yellow),
769 )]),
770 Line::from(""),
771 Line::from(vec![
772 Span::styled("↑/↓", Style::default().fg(Color::Green)),
773 Span::raw(" - Navigate "),
774 Span::styled("Space", Style::default().fg(Color::Green)),
775 Span::raw(" - Toggle"),
776 ]),
777 Line::from(vec![
778 Span::styled("←/→", Style::default().fg(Color::Green)),
779 Span::raw(" or "),
780 Span::styled("Shift+↑/↓", Style::default().fg(Color::Green)),
781 Span::raw(" - Reorder columns"),
782 ]),
783 Line::from(""),
784 ];
785
786 for (i, (column, enabled)) in state.available_columns.iter().enumerate() {
787 let column_name = match column {
788 Column::Hop => "Hop Number",
789 Column::Host => "Hostname/IP",
790 Column::Loss => "Packet Loss %",
791 Column::Sent => "Packets Sent",
792 Column::Last => "Last RTT",
793 Column::Avg => "Average RTT",
794 Column::Ema => "EMA RTT",
795 Column::Jitter => "Last Jitter",
796 Column::JitterAvg => "Average Jitter",
797 Column::Best => "Best RTT",
798 Column::Worst => "Worst RTT",
799 Column::Graph => "RTT Graph",
800 };
801
802 let checkbox = if *enabled { "☑" } else { "☐" };
803 let is_selected = i == state.selected_index;
804
805 let style = if is_selected {
806 Style::default().fg(Color::Black).bg(Color::White)
807 } else {
808 Style::default()
809 };
810
811 let checkbox_style = if *enabled {
812 Style::default().fg(Color::Green)
813 } else {
814 Style::default().fg(Color::Gray)
815 };
816
817 let position_indicator = format!("{:2}.", i + 1);
819
820 lines.push(Line::from(vec![
821 Span::styled(
822 format!("{} ", position_indicator),
823 if is_selected {
824 Style::default().fg(Color::Yellow).bg(Color::White)
825 } else {
826 Style::default().fg(Color::Gray)
827 },
828 ),
829 Span::styled(
830 format!(" {} ", checkbox),
831 if is_selected {
832 checkbox_style.bg(Color::White)
833 } else {
834 checkbox_style
835 },
836 ),
837 Span::styled(format!("{}", column_name), style),
838 ]));
839 }
840
841 lines.push(Line::from(""));
842 lines.push(Line::from(vec![
843 Span::styled("Esc", Style::default().fg(Color::Green)),
844 Span::raw(" - Close"),
845 ]));
846
847 lines.push(Line::from(vec![Span::styled(
849 format!(
850 "Selection: {} of {}",
851 state.selected_index + 1,
852 state.available_columns.len()
853 ),
854 Style::default().fg(Color::Cyan),
855 )]));
856
857 Paragraph::new(lines)
858 .block(
859 Block::default()
860 .borders(Borders::ALL)
861 .title("Column Settings")
862 .title_alignment(Alignment::Center),
863 )
864 .alignment(Alignment::Left)
865}
866
867fn create_help_overlay() -> Paragraph<'static> {
869 let help_text = vec![
870 Line::from(vec![Span::styled(
871 "Keyboard Shortcuts",
872 Style::default().fg(Color::Yellow),
873 )]),
874 Line::from(""),
875 Line::from(vec![
876 Span::styled("q", Style::default().fg(Color::Green)),
877 Span::raw(" / "),
878 Span::styled("ESC", Style::default().fg(Color::Green)),
879 Span::raw(" - Quit application"),
880 ]),
881 Line::from(vec![
882 Span::styled("r", Style::default().fg(Color::Green)),
883 Span::raw(" - Reset statistics"),
884 ]),
885 Line::from(vec![
886 Span::styled("s", Style::default().fg(Color::Green)),
887 Span::raw(" - Toggle sparkline scale (Linear/Log)"),
888 ]),
889 Line::from(vec![
890 Span::styled("c", Style::default().fg(Color::Green)),
891 Span::raw(" - Cycle color modes"),
892 ]),
893 Line::from(vec![
894 Span::styled("f", Style::default().fg(Color::Green)),
895 Span::raw(" - Toggle column fields"),
896 ]),
897 Line::from(vec![
898 Span::styled("o", Style::default().fg(Color::Green)),
899 Span::raw(" - Open column selector"),
900 ]),
901 Line::from(vec![
902 Span::styled("v", Style::default().fg(Color::Green)),
903 Span::raw(" - Toggle visualization (Sparkline/Heatmap)"),
904 ]),
905 Line::from(vec![
906 Span::styled("h", Style::default().fg(Color::Green)),
907 Span::raw(" - Toggle hostnames/IP addresses"),
908 ]),
909 Line::from(vec![
910 Span::styled("?", Style::default().fg(Color::Green)),
911 Span::raw(" - Toggle this help"),
912 ]),
913 Line::from(""),
914 Line::from(vec![Span::styled(
915 "Press ? again to close",
916 Style::default().fg(Color::Cyan),
917 )]),
918 ];
919
920 Paragraph::new(help_text)
921 .block(
922 Block::default()
923 .borders(Borders::ALL)
924 .title("Help")
925 .title_alignment(Alignment::Center),
926 )
927 .alignment(Alignment::Left)
928}
929
930fn create_scale_widget(
932 min_rtt: u64,
933 max_rtt: u64,
934 scale: SparklineScale,
935 color_support: ColorSupport,
936 width: usize,
937) -> Paragraph<'static> {
938 if min_rtt == max_rtt {
939 return Paragraph::new("No RTT data");
940 }
941
942 let scale_width = (width / 2).clamp(40, 80);
943 let chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
944
945 let scale_spans: Vec<Span> = (0..scale_width)
946 .map(|i| {
947 let ratio = i as f64 / (scale_width - 1) as f64;
948 let level = (ratio * 7.0) as usize;
949 let char = chars[level.min(7)];
950 let color = colors::get_smooth_gradient_color(ratio, color_support);
951 Span::styled(char.to_string(), Style::default().fg(color))
952 })
953 .collect();
954
955 let scale_name = match scale {
956 SparklineScale::Linear => "Linear",
957 SparklineScale::Logarithmic => "Log₁₀",
958 };
959
960 let num_labels = 5;
962 let mut label_info = Vec::new();
963
964 for i in 0..num_labels {
965 let ratio = i as f64 / (num_labels - 1) as f64;
966
967 let value = match scale {
968 SparklineScale::Linear => min_rtt + (ratio * (max_rtt - min_rtt) as f64) as u64,
969 SparklineScale::Logarithmic => {
970 let log_min = (min_rtt as f64 + 1.0).ln();
971 let log_max = (max_rtt as f64 + 1.0).ln();
972 let log_value = log_min + ratio * (log_max - log_min);
973 (log_value.exp() - 1.0) as u64
974 }
975 };
976
977 let label = if value < 1000 {
978 format!("{}ms", value)
979 } else {
980 format!("{:.1}s", value as f64 / 1000.0)
981 };
982
983 let center_pos = (ratio * (scale_width - 1) as f64) as usize;
985
986 label_info.push((label, center_pos));
987 }
988
989 let mut label_spans = Vec::new();
991 let mut current_pos = 0;
992
993 for (i, (label, center_pos)) in label_info.iter().enumerate() {
994 let label_len = label.len();
996 let label_start = center_pos.saturating_sub(label_len / 2);
997
998 if label_start > current_pos {
1000 let padding = label_start - current_pos;
1001 label_spans.push(Span::raw(" ".repeat(padding)));
1002 current_pos += padding;
1003 }
1004
1005 label_spans.push(Span::raw(label.clone()));
1007 current_pos += label_len;
1008
1009 if i == label_info.len() - 1 {
1011 let remaining_space = scale_width.saturating_sub(current_pos);
1012 if remaining_space > scale_name.len() + 4 {
1013 label_spans.push(Span::raw(
1014 " ".repeat(remaining_space - scale_name.len() - 3),
1015 ));
1016 label_spans.push(Span::styled(
1017 format!("({})", scale_name),
1018 Style::default().fg(Color::Gray),
1019 ));
1020 }
1021 }
1022 }
1023
1024 let scale_text = vec![Line::from(label_spans), Line::from(scale_spans)];
1025
1026 Paragraph::new(scale_text)
1027}
1028
1029pub fn render_ui(f: &mut Frame, session: &MtrSession, ui_state: &UiState) {
1043 let area = f.area();
1044
1045 if area.height < 10 || area.width < 50 {
1047 let fallback = Paragraph::new(format!(
1048 "Terminal too small: {}x{}\nMinimum: 50x10\nPress 'q' to quit",
1049 area.width, area.height
1050 ));
1051 f.render_widget(fallback, area);
1052 return;
1053 }
1054
1055 let chunks = Layout::default()
1057 .direction(Direction::Vertical)
1058 .constraints([
1059 Constraint::Length(1), Constraint::Min(5), Constraint::Length(2), ])
1063 .split(area);
1064
1065 let rtt_values: Vec<u64> = session
1067 .hops
1068 .iter()
1069 .filter(|hop| hop.sent > 0)
1070 .flat_map(|hop| hop.rtts.iter())
1071 .map(|d| (d.as_secs_f64() * 1000.0) as u64)
1072 .collect();
1073
1074 let global_max_rtt = rtt_values.iter().max().copied().unwrap_or(1);
1075 let global_min_rtt = rtt_values.iter().min().copied().unwrap_or(1);
1076
1077 let status_line = create_status_text(session, ui_state);
1079 let status = Paragraph::new(vec![status_line]);
1080 f.render_widget(status, chunks[0]);
1081
1082 let header_cells = ui_state.columns.iter().map(|col| match col {
1084 Column::Loss
1085 | Column::Sent
1086 | Column::Last
1087 | Column::Avg
1088 | Column::Ema
1089 | Column::Jitter
1090 | Column::JitterAvg
1091 | Column::Best
1092 | Column::Worst => Cell::from(format!("{:>width$}", col.header(), width = col.width())),
1093 _ => Cell::from(col.header()),
1094 });
1095
1096 let header = Row::new(header_cells).style(Style::default().fg(Color::Yellow));
1097
1098 let mut rows = Vec::new();
1099
1100 for hop in session.hops.iter().filter(|hop| hop.sent > 0) {
1101 let hostname = format_hostname(session, hop, ui_state);
1102 let graph_width = calculate_graph_width(&chunks[1], &ui_state.columns);
1103
1104 let graph_spans = match ui_state.visualization_mode {
1105 VisualizationMode::Sparkline => create_sparkline_spans(
1106 hop,
1107 global_min_rtt,
1108 global_max_rtt,
1109 ui_state.current_sparkline_scale,
1110 ui_state.color_support,
1111 graph_width,
1112 ),
1113 VisualizationMode::Heatmap => create_heatmap_spans(
1114 hop,
1115 global_min_rtt,
1116 global_max_rtt,
1117 ui_state.current_sparkline_scale,
1118 ui_state.color_support,
1119 graph_width,
1120 ),
1121 };
1122
1123 let cells = create_table_cells(
1124 hop,
1125 &hostname,
1126 &graph_spans,
1127 &ui_state.columns,
1128 ui_state.sixel_renderer.enabled,
1129 );
1130
1131 rows.push(Row::new(cells));
1132
1133 if hop.has_multiple_paths() {
1135 for alt_path in hop.get_alternate_paths() {
1136 let percentage = hop.get_path_percentage(alt_path);
1137
1138 let alt_hostname = if let Some(hostname) = &alt_path.hostname {
1140 let full_name =
1141 format!(" ↳ {} ({}) ({:.0}%)", hostname, alt_path.addr, percentage);
1142 if full_name.len() > 50 {
1143 format!(
1144 " ↳ {}...{} ({:.0}%)",
1145 &hostname[..15],
1146 alt_path.addr,
1147 percentage
1148 )
1149 } else {
1150 full_name
1151 }
1152 } else {
1153 format!(" ↳ {} ({:.0}%)", alt_path.addr, percentage)
1154 };
1155
1156 let alt_rtt = alt_path.last_rtt.unwrap_or_default().as_secs_f64() * 1000.0;
1157
1158 let mut alt_cells = Vec::new();
1160 for column in &ui_state.columns {
1161 match column {
1162 Column::Hop => alt_cells.push(Cell::from("")),
1163 Column::Host => alt_cells.push(Cell::from(alt_hostname.clone())),
1164 Column::Loss => alt_cells.push(Cell::from("")), Column::Sent => alt_cells.push(Cell::from("")),
1166 Column::Last => alt_cells.push(Cell::from(format!("{:.1}", alt_rtt))),
1167 Column::Avg => alt_cells.push(Cell::from("")),
1168 Column::Ema => alt_cells.push(Cell::from("")),
1169 Column::Best => alt_cells.push(Cell::from("")),
1170 Column::Worst => alt_cells.push(Cell::from("")),
1171 Column::Jitter => alt_cells.push(Cell::from("")),
1172 Column::JitterAvg => alt_cells.push(Cell::from("")),
1173 Column::Graph => alt_cells.push(Cell::from("")),
1174 }
1175 }
1176
1177 let alt_row = Row::new(alt_cells);
1178 rows.push(alt_row);
1179 }
1180 }
1181 }
1182
1183 let constraints = create_column_constraints(&ui_state.columns);
1184 let table = Table::new(rows, &constraints).header(header);
1185
1186 if ui_state.sixel_renderer.enabled {
1188 let enhanced_table = EnhancedTable::new(table, &ui_state.sixel_renderer, &ui_state.columns);
1189 f.render_widget(enhanced_table, chunks[1]);
1190 } else {
1191 f.render_widget(table, chunks[1]);
1192 }
1193
1194 let scale_widget = create_scale_widget(
1196 global_min_rtt,
1197 global_max_rtt,
1198 ui_state.current_sparkline_scale,
1199 ui_state.color_support,
1200 chunks[2].width as usize,
1201 );
1202 f.render_widget(scale_widget, chunks[2]);
1203
1204 if ui_state.show_help {
1206 let area = f.area();
1207 let help_width = 50.min(area.width.saturating_sub(4));
1209 let help_height = 12.min(area.height.saturating_sub(4));
1210 let help_x = (area.width.saturating_sub(help_width)) / 2;
1211 let help_y = (area.height.saturating_sub(help_height)) / 2;
1212
1213 let help_area = Rect {
1214 x: help_x,
1215 y: help_y,
1216 width: help_width,
1217 height: help_height,
1218 };
1219
1220 f.render_widget(Clear, help_area);
1222 f.render_widget(create_help_overlay(), help_area);
1223 }
1224
1225 if ui_state.show_column_selector {
1227 let area = f.area();
1228 let popup_width = 60.min(area.width.saturating_sub(4));
1230 let popup_height = (ui_state.column_selector_state.available_columns.len() + 8)
1231 .min(area.height.saturating_sub(4) as usize) as u16;
1232 let popup_x = (area.width.saturating_sub(popup_width)) / 2;
1233 let popup_y = (area.height.saturating_sub(popup_height)) / 2;
1234
1235 let popup_area = Rect {
1236 x: popup_x,
1237 y: popup_y,
1238 width: popup_width,
1239 height: popup_height,
1240 };
1241
1242 f.render_widget(Clear, popup_area);
1244 f.render_widget(
1245 create_column_selector_popup(&ui_state.column_selector_state),
1246 popup_area,
1247 );
1248 }
1249}
1250
1251fn format_hostname(session: &MtrSession, hop: &HopStats, ui_state: &UiState) -> String {
1252 let base_hostname = if session.args.numeric || !ui_state.show_hostnames {
1253 hop.addr
1255 .map(|a| a.to_string())
1256 .unwrap_or_else(|| "???".to_string())
1257 } else {
1258 hop.hostname.clone().unwrap_or_else(|| {
1260 hop.addr
1261 .map(|a| a.to_string())
1262 .unwrap_or_else(|| "???".to_string())
1263 })
1264 };
1265
1266 let hostname = if hop.has_multiple_paths() {
1268 if hop.addr.is_some() {
1269 let primary_percentage = hop.get_primary_path_percentage();
1270 format!("{} ({:.0}%)", base_hostname, primary_percentage)
1271 } else {
1272 base_hostname
1273 }
1274 } else {
1275 base_hostname
1276 };
1277
1278 const MAX_HOSTNAME_LEN: usize = 40; const TRUNCATED_LEN: usize = 37;
1281
1282 if hostname.len() > MAX_HOSTNAME_LEN {
1283 format!("{}...", &hostname[..TRUNCATED_LEN])
1284 } else {
1285 hostname
1286 }
1287}
1288
1289fn calculate_graph_width(table_area: &Rect, columns: &[Column]) -> usize {
1290 if columns.contains(&Column::Graph) {
1291 let total_width = table_area.width.saturating_sub(4) as usize; let fixed_width: usize = columns
1296 .iter()
1297 .map(|col| {
1298 match col {
1299 Column::Hop => 3,
1300 Column::Loss => 5,
1301 Column::Sent => 3,
1302 Column::Last | Column::Avg | Column::Ema | Column::Best | Column::Worst => 6,
1303 Column::Jitter | Column::JitterAvg => 6,
1304 Column::Host | Column::Graph => 0, }
1306 })
1307 .sum();
1308
1309 let remaining_width = total_width.saturating_sub(fixed_width);
1311 let graph_width = (remaining_width * 65) / 100;
1312
1313 graph_width.max(20)
1315 } else {
1316 20
1317 }
1318}
1319
1320pub async fn run_interactive(session: MtrSession) -> Result<()> {
1325 enable_raw_mode()?;
1326 let mut stdout = io::stdout();
1327 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
1328 let backend = CrosstermBackend::new(stdout);
1329 let mut terminal = Terminal::new(backend)?;
1330
1331 let session_arc = Arc::new(Mutex::new(session.clone()));
1332 let session_clone = Arc::clone(&session_arc);
1333
1334 let mut ui_state = UiState::new(
1335 session.args.sparkline_scale,
1336 session.args.get_columns(),
1337 session.args.sixel,
1338 );
1339
1340 let (update_tx, mut update_rx) = mpsc::unbounded_channel::<()>();
1341
1342 {
1343 let mut session_guard = session_arc.lock().unwrap();
1344 let update_tx_for_callback = update_tx.clone();
1345 session_guard.set_update_callback(Arc::new(move || {
1346 let _ = update_tx_for_callback.send(());
1347 }));
1348 }
1349
1350 let trace_handle = {
1351 let session_for_trace = Arc::clone(&session_clone);
1352 tokio::spawn(async move {
1353 if let Err(e) = MtrSession::run_trace_with_realtime_updates(session_for_trace).await {
1354 debug!("Real-time trace failed: {}", e);
1355 }
1356 })
1357 };
1358
1359 let mut last_tick = Instant::now();
1360 let tick_rate = Duration::from_millis(100);
1361
1362 loop {
1363 let should_update = update_rx.try_recv().is_ok() || last_tick.elapsed() >= tick_rate;
1364
1365 if should_update {
1366 terminal.draw(|f| {
1368 let session_guard = session_clone.lock().unwrap();
1369 render_ui(f, &session_guard, &ui_state)
1370 })?;
1371 last_tick = Instant::now();
1372 }
1373
1374 let timeout = Duration::from_millis(10);
1375 if crossterm::event::poll(timeout)? {
1376 if let Event::Key(key) = event::read()? {
1377 if ui_state.show_column_selector {
1379 match key.code {
1380 KeyCode::Esc => {
1381 ui_state.show_column_selector = false;
1383 }
1384 KeyCode::Up => {
1385 ui_state.column_selector_state.move_up();
1386 }
1387 KeyCode::Down => {
1388 ui_state.column_selector_state.move_down();
1389 }
1390 KeyCode::Char(' ') => {
1391 ui_state.toggle_selected_column_immediate();
1392 }
1393 KeyCode::Left => {
1394 ui_state.move_selected_column_up_immediate();
1396 }
1397 KeyCode::Right => {
1398 ui_state.move_selected_column_down_immediate();
1400 }
1401 _ => {
1402 if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
1404 match key.code {
1405 KeyCode::Up => {
1406 ui_state.move_selected_column_up_immediate();
1407 }
1408 KeyCode::Down => {
1409 ui_state.move_selected_column_down_immediate();
1410 }
1411 _ => {}
1412 }
1413 }
1414 }
1415 }
1416 } else {
1417 match key.code {
1419 KeyCode::Char('q') | KeyCode::Esc => break,
1420 KeyCode::Char('r') => {
1421 let mut session_guard = session_clone.lock().unwrap();
1422 for hop in &mut session_guard.hops {
1423 *hop = HopStats::new(hop.hop);
1424 }
1425 }
1426 KeyCode::Char('s') => ui_state.toggle_sparkline_scale(),
1427 KeyCode::Char('c') => ui_state.cycle_color_mode(),
1428 KeyCode::Char('f') => ui_state.toggle_column(),
1429 KeyCode::Char('o') => ui_state.toggle_column_selector(),
1430 KeyCode::Char('v') => ui_state.toggle_visualization_mode(),
1431 KeyCode::Char('h') => ui_state.toggle_hostnames(),
1432 KeyCode::Char('?') => ui_state.toggle_help(),
1433 _ => {}
1434 }
1435 }
1436 }
1437 }
1438 }
1439
1440 trace_handle.abort();
1441 disable_raw_mode()?;
1442 execute!(
1443 terminal.backend_mut(),
1444 LeaveAlternateScreen,
1445 DisableMouseCapture
1446 )?;
1447 terminal.show_cursor()?;
1448
1449 Ok(())
1450}