Skip to main content

scx_cake/
tui.rs

1// SPDX-License-Identifier: GPL-2.0
2// TUI module - ratatui-based terminal UI for real-time scheduler statistics
3
4use std::io::{self, Stdout, Write};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, Result};
10use arboard::Clipboard;
11use crossterm::{
12    cursor::{Hide, MoveTo, Show},
13    event::{self, Event, KeyCode, KeyEventKind},
14    execute,
15    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
16    ExecutableCommand,
17};
18use ratatui::{
19    buffer::Buffer,
20    prelude::*,
21    widgets::{Block, BorderType, Borders, Cell, Padding, Paragraph, Row, Table, Widget},
22};
23use tachyonfx::{fx, EffectManager};
24
25use crate::bpf_skel::types::cake_stats;
26use crate::bpf_skel::BpfSkel;
27use crate::stats::TIER_NAMES;
28use crate::topology::TopologyInfo;
29
30fn aggregate_stats(skel: &BpfSkel) -> cake_stats {
31    let mut total: cake_stats = Default::default();
32
33    if let Some(bss) = &skel.maps.bss_data {
34        for s in &bss.global_stats {
35            // Sum all fields
36            total.nr_new_flow_dispatches += s.nr_new_flow_dispatches;
37            total.nr_old_flow_dispatches += s.nr_old_flow_dispatches;
38
39            for i in 0..crate::stats::TIER_NAMES.len() {
40                total.nr_tier_dispatches[i] += s.nr_tier_dispatches[i];
41                total.nr_starvation_preempts_tier[i] += s.nr_starvation_preempts_tier[i];
42            }
43        }
44    }
45
46    total
47}
48
49/// TUI Application state
50pub struct TuiApp {
51    start_time: Instant,
52    status_message: Option<(String, Instant)>,
53    topology: TopologyInfo,
54}
55
56impl TuiApp {
57    pub fn new(topology: TopologyInfo) -> Self {
58        Self {
59            start_time: Instant::now(),
60            status_message: None,
61            topology,
62        }
63    }
64
65    /// Format uptime as "Xm Ys" or "Xh Ym"
66    fn format_uptime(&self) -> String {
67        let elapsed = self.start_time.elapsed();
68        let secs = elapsed.as_secs();
69        if secs < 3600 {
70            format!("{}m {}s", secs / 60, secs % 60)
71        } else {
72            format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
73        }
74    }
75
76    /// Set a temporary status message that disappears after 2 seconds
77    fn set_status(&mut self, msg: &str) {
78        self.status_message = Some((msg.to_string(), Instant::now()));
79    }
80
81    /// Get current status message if not expired
82    fn get_status(&self) -> Option<&str> {
83        match &self.status_message {
84            Some((msg, timestamp)) if timestamp.elapsed() < Duration::from_secs(2) => Some(msg),
85            _ => None,
86        }
87    }
88}
89
90/// Initialize the terminal for TUI mode
91fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
92    enable_raw_mode().context("Failed to enable raw mode")?;
93    io::stdout()
94        .execute(EnterAlternateScreen)
95        .context("Failed to enter alternate screen")?;
96    let backend = CrosstermBackend::new(io::stdout());
97    Terminal::new(backend).context("Failed to create terminal")
98}
99
100/// Restore terminal to normal mode
101fn restore_terminal() -> Result<()> {
102    disable_raw_mode().context("Failed to disable raw mode")?;
103    io::stdout()
104        .execute(LeaveAlternateScreen)
105        .context("Failed to leave alternate screen")?;
106    Ok(())
107}
108
109/// Render a progress gauge inline for calibration progress
110/// Updates a single line in-place, no newlines until complete
111pub fn render_calibration_progress(current: usize, total: usize, is_complete: bool) {
112    use std::io::Write;
113
114    if total == 0 {
115        return;
116    }
117
118    let percent = ((current as f64 / total as f64) * 100.0) as u16;
119
120    // ANSI colors
121    let cyan = "\x1b[36m";
122    let green = "\x1b[32m";
123    let bold = "\x1b[1m";
124    let reset = "\x1b[0m";
125
126    // Build progress bar (40 chars wide)
127    let bar_width = 40;
128    let filled = ((current as f64 / total as f64) * bar_width as f64) as usize;
129    let empty = bar_width - filled;
130
131    let bar = format!(
132        "{}{}{}{}{}",
133        cyan,
134        "โ–ˆ".repeat(filled),
135        reset,
136        "โ–‘".repeat(empty),
137        reset
138    );
139
140    if is_complete {
141        // Final output with checkmark and newline
142        print!(
143            "\r{green}โœ“{reset} {bold}ETD Calibration Complete{reset} [{bar}] {current}/{total} pairs ({percent}%)\n",
144            green = green,
145            reset = reset,
146            bold = bold,
147            bar = bar,
148            current = current,
149            total = total,
150            percent = percent
151        );
152    } else {
153        // In-progress: overwrite same line with \r
154        print!(
155            "\r{cyan}โณ{reset} {bold}ETD Calibration{reset} [{bar}] {current}/{total} pairs ({percent}%)   ",
156            cyan = cyan,
157            reset = reset,
158            bold = bold,
159            bar = bar,
160            current = current,
161            total = total,
162            percent = percent
163        );
164    }
165
166    let _ = io::stdout().flush();
167}
168
169/// Parameters for the startup screen
170pub struct StartupParams<'a> {
171    pub topology: &'a TopologyInfo,
172    pub latency_matrix: &'a [Vec<f64>],
173    pub profile: &'a str,
174    pub quantum: u64,
175    pub starvation: u64,
176}
177
178/// Render a beautiful one-time startup screen using Ratatui
179/// This renders directly to stdout inline (persists in terminal like println)
180pub fn render_startup_screen(params: StartupParams) -> Result<()> {
181    // Get terminal size
182    let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
183    let nr_cpus = params.latency_matrix.len();
184
185    // Layout dimensions
186    let left_height = 6 + 6 + (nr_cpus / 8 + 6);
187    let matrix_height = nr_cpus + 6;
188    let body_height = left_height.max(matrix_height);
189    let total_height = (4 + body_height + 3) as u16;
190
191    let area = Rect::new(0, 0, width, total_height);
192    let mut buffer = Buffer::empty(area);
193
194    // tachyonfx setup
195    let mut fx_manager: EffectManager<()> = EffectManager::default();
196    let duration_ms = 4200u32;
197
198    // 1. Slow, elegant dissolve for the main UI
199    fx_manager.add_effect(fx::dissolve(2000u32));
200
201    // 2. Coalesce effect peaks in the middle-end
202    fx_manager.add_effect(fx::sequence(&[fx::delay(1200u32, fx::coalesce(1800u32))]));
203
204    // Enter Alternate Screen for smooth animation
205    execute!(io::stdout(), EnterAlternateScreen, Hide)?;
206
207    let start_time = Instant::now();
208    let frame_rate = Duration::from_millis(16); // ~60fps
209
210    while start_time.elapsed().as_millis() < duration_ms as u128 {
211        let frame_start = Instant::now();
212        let elapsed_ms = start_time.elapsed().as_millis() as u32;
213        buffer.reset();
214
215        render_startup_widgets(&mut buffer, area, &params, elapsed_ms);
216
217        let t_duration = tachyonfx::Duration::from_millis(elapsed_ms);
218        fx_manager.process_effects(t_duration, &mut buffer, area);
219
220        // Print frame starting from top of alternate screen
221        execute!(io::stdout(), MoveTo(0, 0))?;
222
223        // Render only what fits in the current terminal height to avoid scrolling artifacts
224        let render_height = total_height.min(height);
225        for y in 0..render_height {
226            let mut last_style = Style::default();
227            for x in 0..width {
228                let cell = &buffer[(x, y)];
229                let cell_style = cell.style();
230                if cell_style != last_style {
231                    print!("{}", cell_style.to_ansi_sequence());
232                    last_style = cell_style;
233                }
234                print!("{}", cell.symbol());
235            }
236            if y < render_height - 1 {
237                print!("\x1b[0m\r\n");
238            } else {
239                print!("\x1b[0m");
240            }
241        }
242        io::stdout().flush()?;
243
244        let sleep_time = frame_rate.saturating_sub(frame_start.elapsed());
245        if !sleep_time.is_zero() {
246            std::thread::sleep(sleep_time);
247        }
248    }
249
250    // Exit Alternate Screen and show cursor
251    execute!(io::stdout(), LeaveAlternateScreen, Show)?;
252
253    // Print final static frame to normal terminal (inline/persists)
254    buffer.reset();
255    render_startup_widgets(
256        &mut buffer,
257        area,
258        &params,
259        duration_ms, // Full completion
260    );
261
262    // Ensure animation is at 100% completion for final print
263    let final_duration = tachyonfx::Duration::from_millis(duration_ms);
264    fx_manager.process_effects(final_duration, &mut buffer, area);
265
266    for y in 0..total_height {
267        let mut last_style = Style::default();
268        for x in 0..width {
269            let cell = &buffer[(x, y)];
270            let cell_style = cell.style();
271            if cell_style != last_style {
272                print!("{}", cell_style.to_ansi_sequence());
273                last_style = cell_style;
274            }
275            print!("{}", cell.symbol());
276        }
277        println!("\x1b[0m");
278    }
279    io::stdout().flush()?;
280
281    Ok(())
282}
283
284// Helper trait to convert Ratatui Style to ANSI sequences for inline printing
285trait ToAnsi {
286    fn to_ansi_sequence(&self) -> String;
287}
288
289impl ToAnsi for Style {
290    fn to_ansi_sequence(&self) -> String {
291        let mut seq = String::from("\x1b[0");
292
293        if let Some(fg) = self.fg {
294            match fg {
295                Color::Rgb(r, g, b) => seq.push_str(&format!(";38;2;{};{};{}", r, g, b)),
296                Color::Black => seq.push_str(";30"),
297                Color::Red => seq.push_str(";31"),
298                Color::Green => seq.push_str(";32"),
299                Color::Yellow => seq.push_str(";33"),
300                Color::Blue => seq.push_str(";34"),
301                Color::Magenta => seq.push_str(";35"),
302                Color::Cyan => seq.push_str(";36"),
303                Color::Gray => seq.push_str(";37"),
304                Color::DarkGray => seq.push_str(";90"),
305                Color::LightRed => seq.push_str(";91"),
306                Color::LightGreen => seq.push_str(";92"),
307                Color::LightYellow => seq.push_str(";93"),
308                Color::LightBlue => seq.push_str(";94"),
309                Color::LightMagenta => seq.push_str(";95"),
310                Color::LightCyan => seq.push_str(";96"),
311                Color::White => seq.push_str(";97"),
312                _ => {}
313            }
314        }
315
316        if let Some(bg) = self.bg {
317            match bg {
318                Color::Rgb(r, g, b) => seq.push_str(&format!(";48;2;{};{};{}", r, g, b)),
319                Color::Black => seq.push_str(";40"),
320                _ => {} // Simplified bg for now
321            }
322        }
323
324        if self.add_modifier.contains(Modifier::BOLD) {
325            seq.push_str(";1");
326        }
327        if self.add_modifier.contains(Modifier::ITALIC) {
328            seq.push_str(";3");
329        }
330        if self.add_modifier.contains(Modifier::DIM) {
331            seq.push_str(";2");
332        }
333
334        seq.push('m');
335        seq
336    }
337}
338
339/// Render the startup UI widgets to a buffer (inline version for persistent terminal output)
340fn render_startup_widgets(
341    buffer: &mut Buffer,
342    area: Rect,
343    params: &StartupParams,
344    elapsed_ms: u32,
345) {
346    // --- Layout Configuration ---
347    // Split into Header and Body
348    let outer_layout = Layout::default()
349        .direction(Direction::Vertical)
350        .constraints([
351            Constraint::Length(4), // Header/Title (Needs 4 for subtitle + borders)
352            Constraint::Min(20),   // Dashboard Body
353            Constraint::Length(3), // Footer
354        ])
355        .split(area);
356
357    // Split Body into Left (Info), Middle (Heatmap), and Right (Data)
358    let dashboard_layout = Layout::default()
359        .direction(Direction::Horizontal)
360        .constraints([
361            Constraint::Percentage(22), // System info
362            Constraint::Percentage(39), // Latency heatmap
363            Constraint::Fill(1),        // Latency data (fills all remaining space)
364        ])
365        .split(outer_layout[1]);
366
367    // Left Column Layout: Specs, Profile, Topology
368    let left_layout = Layout::default()
369        .direction(Direction::Vertical)
370        .constraints([
371            Constraint::Length(6), // Specs
372            Constraint::Length(6), // Profile
373            Constraint::Min(8),    // Topology
374        ])
375        .split(dashboard_layout[0]);
376
377    // --- Title ---
378    let author_full = "by RitzDaCat";
379    // Typewriting effect: show 1 char every 100ms, starting at 1000ms
380    let typing_start = 1000u32;
381    let ms_per_char = 100u32;
382    let chars_to_show = if elapsed_ms < typing_start {
383        0
384    } else {
385        ((elapsed_ms - typing_start) / ms_per_char).min(author_full.len() as u32) as usize
386    };
387    let author_typed = &author_full[..chars_to_show];
388
389    let title = Paragraph::new(vec![
390        Line::from(vec![
391            Span::styled(" ๐Ÿฐ ", Style::default().fg(Color::Yellow)),
392            Span::styled(
393                "scx_cake ",
394                Style::default()
395                    .fg(Color::Cyan)
396                    .add_modifier(Modifier::BOLD),
397            ),
398            Span::styled("v1.02", Style::default().fg(Color::White)),
399            Span::styled(
400                " โ”‚ Gaming Oriented Scheduler",
401                Style::default()
402                    .fg(Color::White)
403                    .add_modifier(Modifier::BOLD),
404            ),
405        ]),
406        Line::from(vec![Span::styled(
407            author_typed,
408            Style::default()
409                .fg(Color::DarkGray)
410                .add_modifier(Modifier::ITALIC),
411        )]),
412    ])
413    .block(
414        Block::default()
415            .borders(Borders::ALL)
416            .border_type(BorderType::Rounded)
417            .border_style(Style::default().fg(Color::Cyan).dim()),
418    )
419    .alignment(Alignment::Center);
420    title.render(outer_layout[0], buffer);
421
422    // --- System Specs ---
423    let smt_str = if params.topology.smt_enabled {
424        "On"
425    } else {
426        "Off"
427    };
428    let hardware_rows = vec![
429        Row::new(vec![
430            Cell::from("CPUs").style(Style::default().fg(Color::Cyan)),
431            Cell::from(params.topology.nr_cpus.to_string()),
432        ]),
433        Row::new(vec![
434            Cell::from("SMT").style(Style::default().fg(Color::Cyan)),
435            Cell::from(smt_str),
436        ]),
437        Row::new(vec![
438            Cell::from("Layout").style(Style::default().fg(Color::Cyan)),
439            Cell::from(if params.topology.has_dual_ccd {
440                "Multi-CCD"
441            } else {
442                "Single"
443            }),
444        ]),
445    ];
446
447    let hardware_block = Table::new(hardware_rows, [Constraint::Length(10), Constraint::Min(10)])
448        .block(
449            Block::default()
450                .title(" System ")
451                .borders(Borders::ALL)
452                .border_type(BorderType::Rounded)
453                .border_style(Style::default().fg(Color::Cyan).dim()),
454        );
455    Widget::render(hardware_block, left_layout[0], buffer);
456
457    // --- Profile Intelligence ---
458    let profile_text = Paragraph::new(vec![
459        Line::from(vec![
460            Span::styled("Mode: ", Style::default().fg(Color::Cyan)),
461            Span::styled(
462                params.profile,
463                Style::default()
464                    .fg(Color::White)
465                    .add_modifier(Modifier::BOLD),
466            ),
467        ]),
468        Line::from(vec![
469            Span::styled("Quantum: ", Style::default().fg(Color::Cyan)),
470            Span::styled(
471                format!("{}ยตs", params.quantum),
472                Style::default().fg(Color::White),
473            ),
474        ]),
475        Line::from(vec![
476            Span::styled("Preempt: ", Style::default().fg(Color::Cyan)),
477            Span::styled(
478                format!("{}ms", params.starvation / 1000),
479                Style::default().fg(Color::White),
480            ),
481        ]),
482    ])
483    .block(
484        Block::default()
485            .title(" Profile ")
486            .borders(Borders::ALL)
487            .border_type(BorderType::Rounded)
488            .border_style(Style::default().fg(Color::Cyan).dim()),
489    );
490    profile_text.render(left_layout[1], buffer);
491
492    // --- Topology Overview ---
493    let topology_grid = build_cpu_topology_grid_compact(params.topology);
494    topology_grid.render(left_layout[2], buffer);
495
496    // --- Empirical Fabric (The Heatmap) ---
497    let heatmap = LatencyHeatmap::new(params.latency_matrix, params.topology);
498    heatmap.render(dashboard_layout[1], buffer);
499
500    // --- Numerical Truth (Raw Data) ---
501    let data_table = LatencyTable::new(params.latency_matrix, params.topology);
502    data_table.render(dashboard_layout[2], buffer);
503
504    // --- Footer ---
505    let footer = Paragraph::new(vec![Line::from(vec![
506        Span::styled(
507            "โ— ",
508            Style::default()
509                .fg(Color::Green)
510                .add_modifier(Modifier::BOLD),
511        ),
512        Span::styled(
513            "Cake is online!",
514            Style::default()
515                .fg(Color::Green)
516                .add_modifier(Modifier::BOLD),
517        ),
518    ])])
519    .block(
520        Block::default()
521            .borders(Borders::ALL)
522            .border_type(BorderType::Rounded)
523            .border_style(Style::default().fg(Color::Cyan).dim()),
524    )
525    .alignment(Alignment::Center);
526
527    footer.render(outer_layout[2], buffer);
528}
529
530/// Compact CPU topology schematic for Left Column
531fn build_cpu_topology_grid_compact(topology: &TopologyInfo) -> Paragraph<'static> {
532    let nr_cpus = topology.nr_cpus.min(64);
533    let mut lines = Vec::new();
534
535    lines.push(Line::from(""));
536
537    let mut current_line = Vec::new();
538    for cpu in 0..nr_cpus {
539        // Dot indicator for core type
540        let symbol = if topology.cpu_is_big.get(cpu).copied().unwrap_or(0) != 0 {
541            "โ—†" // P-core
542        } else {
543            "โ—‡" // E-core/Uniform
544        };
545
546        let color = if topology.cpu_is_big.get(cpu).copied().unwrap_or(0) != 0 {
547            Color::Magenta
548        } else {
549            Color::Cyan
550        };
551
552        current_line.push(Span::styled(
553            format!("{} ", symbol),
554            Style::default().fg(color),
555        ));
556
557        if (cpu + 1) % 8 == 0 {
558            lines.push(Line::from(current_line));
559            current_line = Vec::new();
560        }
561    }
562    if !current_line.is_empty() {
563        lines.push(Line::from(current_line));
564    }
565
566    lines.push(Line::from(""));
567    lines.push(Line::from(vec![
568        Span::styled(" โ—† ", Style::default().fg(Color::Magenta)),
569        Span::styled("Performance  ", Style::default().fg(Color::Gray).dim()),
570        Span::styled(" โ—‡ ", Style::default().fg(Color::Cyan)),
571        Span::styled("Efficiency", Style::default().fg(Color::Gray).dim()),
572    ]));
573
574    Paragraph::new(lines).block(
575        Block::default()
576            .title(" Topology ")
577            .borders(Borders::ALL)
578            .border_type(BorderType::Rounded)
579            .border_style(Style::default().fg(Color::Cyan).dim())
580            .padding(Padding::horizontal(1)),
581    )
582}
583
584/// Custom Widget for high-density Latency Heatmap
585struct LatencyHeatmap<'a> {
586    matrix: &'a [Vec<f64>],
587    topology: &'a TopologyInfo,
588}
589
590impl<'a> LatencyHeatmap<'a> {
591    fn new(matrix: &'a [Vec<f64>], topology: &'a TopologyInfo) -> Self {
592        Self { matrix, topology }
593    }
594}
595
596impl<'a> Widget for LatencyHeatmap<'a> {
597    fn render(self, area: Rect, buf: &mut Buffer) {
598        let nr_cpus = self.matrix.len();
599
600        let block = Block::default()
601            .title(" Latency Heatmap ")
602            .borders(Borders::ALL)
603            .border_type(BorderType::Rounded)
604            .border_style(Style::default().fg(Color::Cyan).dim());
605
606        let inner_area = block.inner(area);
607        block.render(area, buf);
608
609        if inner_area.width < 10 || inner_area.height < 5 {
610            return;
611        }
612
613        // Header for Target CPUs (X-axis)
614        for j in 0..nr_cpus {
615            let x = inner_area.x + 6 + (j as u16 * 2);
616            if x < inner_area.right() {
617                buf.set_string(
618                    x,
619                    inner_area.y,
620                    format!("{:1}", j % 10),
621                    Style::default().fg(Color::Cyan).dim(),
622                );
623            }
624        }
625
626        for i in 0..nr_cpus {
627            let y = inner_area.y + 1 + i as u16;
628            if y >= inner_area.bottom() {
629                break;
630            }
631
632            // Row Label (Source CPU)
633            buf.set_string(
634                inner_area.x + 1,
635                y,
636                format!("C{:02}", i),
637                Style::default().fg(Color::Cyan).dim(),
638            );
639
640            for j in 0..nr_cpus {
641                let x = inner_area.x + 6 + (j as u16 * 2);
642                if x >= inner_area.right() - 1 {
643                    continue;
644                }
645
646                let is_self = i == j;
647                let is_smt = self.topology.cpu_sibling_map[i] as usize == j;
648                let same_ccd = self.topology.cpu_llc_id[i] == self.topology.cpu_llc_id[j];
649
650                let style = if is_self {
651                    Style::default().fg(Color::Rgb(40, 40, 40))
652                } else if is_smt {
653                    Style::default().fg(Color::Rgb(0, 255, 150)) // Turquoise
654                } else if same_ccd {
655                    Style::default().fg(Color::Rgb(0, 200, 255)) // Cyan
656                } else {
657                    Style::default().fg(Color::Rgb(255, 180, 0)) // Amber
658                };
659
660                buf.set_string(x, y, "โ–ˆ", style);
661                buf.set_string(x + 1, y, " ", Style::default());
662            }
663        }
664
665        // Legend at bottom
666        let legend_y = inner_area.bottom().saturating_sub(1);
667        let legend_x = inner_area.x + 1;
668        if legend_y > inner_area.y + nr_cpus as u16 {
669            buf.set_string(
670                legend_x,
671                legend_y,
672                "โ–ˆ SMT",
673                Style::default().fg(Color::Rgb(0, 255, 150)),
674            );
675            buf.set_string(
676                legend_x + 9,
677                legend_y,
678                "โ–ˆ Same CCD",
679                Style::default().fg(Color::Rgb(0, 200, 255)),
680            );
681            buf.set_string(
682                legend_x + 22,
683                legend_y,
684                "โ–ˆ Cross-CCD",
685                Style::default().fg(Color::Rgb(255, 180, 0)),
686            );
687        }
688    }
689}
690
691/// Custom Widget for numerical latency table
692struct LatencyTable<'a> {
693    matrix: &'a [Vec<f64>],
694    topology: &'a TopologyInfo,
695}
696
697impl<'a> LatencyTable<'a> {
698    fn new(matrix: &'a [Vec<f64>], topology: &'a TopologyInfo) -> Self {
699        Self { matrix, topology }
700    }
701}
702
703impl<'a> Widget for LatencyTable<'a> {
704    fn render(self, area: Rect, buf: &mut Buffer) {
705        let nr_cpus = self.matrix.len();
706
707        let block = Block::default()
708            .title(" Latency Data ")
709            .borders(Borders::ALL)
710            .border_type(BorderType::Rounded)
711            .border_style(Style::default().fg(Color::Cyan).dim());
712
713        let inner_area = block.inner(area);
714        block.render(area, buf);
715
716        if inner_area.width < 10 || inner_area.height < 5 {
717            return;
718        }
719
720        // Header for Target CPUs
721        for j in 0..nr_cpus {
722            let x = inner_area.x + 5 + (j as u16 * 3);
723            if x < inner_area.right() {
724                buf.set_string(
725                    x,
726                    inner_area.y,
727                    format!("{:>2}", j),
728                    Style::default().fg(Color::Cyan).dim(),
729                );
730            }
731        }
732
733        for i in 0..nr_cpus {
734            let y = inner_area.y + 1 + i as u16;
735            if y >= inner_area.bottom() {
736                break;
737            }
738
739            buf.set_string(
740                inner_area.x + 1,
741                y,
742                format!("C{:02}", i),
743                Style::default().fg(Color::Cyan).dim(),
744            );
745
746            for j in 0..nr_cpus {
747                let x = inner_area.x + 5 + (j as u16 * 3);
748                if x >= inner_area.right() - 2 {
749                    continue;
750                }
751
752                let val = self.matrix[i][j].min(999.0);
753                let is_self = i == j;
754                let is_smt = self.topology.cpu_sibling_map[i] as usize == j;
755                let same_ccd = self.topology.cpu_llc_id[i] == self.topology.cpu_llc_id[j];
756
757                let style = if is_self {
758                    Style::default().fg(Color::Rgb(40, 40, 40))
759                } else if is_smt {
760                    Style::default().fg(Color::Rgb(0, 255, 150))
761                } else if same_ccd {
762                    Style::default().fg(Color::Rgb(0, 200, 255))
763                } else {
764                    Style::default().fg(Color::Rgb(255, 180, 0))
765                };
766
767                buf.set_string(x, y, format!("{:>2.0}", val), style);
768            }
769        }
770    }
771}
772
773/// Format stats as a copyable text string
774fn format_stats_for_clipboard(stats: &cake_stats, uptime: &str) -> String {
775    let total_dispatches = stats.nr_new_flow_dispatches + stats.nr_old_flow_dispatches;
776    let new_pct = if total_dispatches > 0 {
777        (stats.nr_new_flow_dispatches as f64 / total_dispatches as f64) * 100.0
778    } else {
779        0.0
780    };
781
782    let mut output = String::new();
783    output.push_str(&format!(
784        "=== scx_cake Statistics (Uptime: {}) ===\n\n",
785        uptime
786    ));
787    output.push_str(&format!(
788        "Dispatches: {} total ({:.1}% new-flow)\n\n",
789        total_dispatches, new_pct
790    ));
791
792    output.push_str("Tier           Dispatches    StarvPreempt\n");
793    output.push_str("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n");
794    for (i, name) in TIER_NAMES.iter().enumerate() {
795        output.push_str(&format!(
796            "{:12}   {:>10}    {:>12}\n",
797            name, stats.nr_tier_dispatches[i], stats.nr_starvation_preempts_tier[i]
798        ));
799    }
800
801    output
802}
803
804/// Draw the UI
805fn draw_ui(frame: &mut Frame, app: &TuiApp, stats: &cake_stats) {
806    let area = frame.area();
807
808    // Create main layout: header, stats table, footer
809    let layout = Layout::default()
810        .direction(Direction::Vertical)
811        .constraints([
812            Constraint::Length(3), // Header
813            Constraint::Min(10),   // Stats table
814            Constraint::Length(5), // Summary
815            Constraint::Length(3), // Footer
816        ])
817        .split(area);
818
819    // --- Header ---
820    let total_dispatches = stats.nr_new_flow_dispatches + stats.nr_old_flow_dispatches;
821    let new_pct = if total_dispatches > 0 {
822        (stats.nr_new_flow_dispatches as f64 / total_dispatches as f64) * 100.0
823    } else {
824        0.0
825    };
826
827    // Build topology info string
828    let topo_info = format!(
829        "CPUs: {} {}{}{}",
830        app.topology.nr_cpus,
831        if app.topology.has_dual_ccd {
832            "[Dual-CCD]"
833        } else {
834            ""
835        },
836        if app.topology.has_hybrid_cores {
837            "[Hybrid]"
838        } else {
839            ""
840        },
841        if app.topology.smt_enabled {
842            "[SMT]"
843        } else {
844            ""
845        },
846    );
847
848    let header_text = format!(
849        " {}  โ”‚  Dispatches: {} ({:.1}% new)  โ”‚  Uptime: {}",
850        topo_info,
851        total_dispatches,
852        new_pct,
853        app.format_uptime()
854    );
855    let header = Paragraph::new(header_text).block(
856        Block::default()
857            .title(" scx_cake Statistics ")
858            .title_style(
859                Style::default()
860                    .fg(Color::Cyan)
861                    .add_modifier(Modifier::BOLD),
862            )
863            .borders(Borders::ALL)
864            .border_style(Style::default().fg(Color::Blue)),
865    );
866    frame.render_widget(header, layout[0]);
867
868    // --- Stats Table ---
869    let header_cells = ["Tier", "Dispatches", "StarvPreempt"].iter().map(|h| {
870        Cell::from(*h).style(
871            Style::default()
872                .fg(Color::Yellow)
873                .add_modifier(Modifier::BOLD),
874        )
875    });
876    let header_row = Row::new(header_cells).height(1);
877
878    let rows: Vec<Row> = TIER_NAMES
879        .iter()
880        .enumerate()
881        .map(|(i, name)| {
882            let cells = vec![
883                Cell::from(*name).style(tier_style(i)),
884                Cell::from(format!("{}", stats.nr_tier_dispatches[i])),
885                Cell::from(format!("{}", stats.nr_starvation_preempts_tier[i])),
886            ];
887            Row::new(cells).height(1)
888        })
889        .collect();
890
891    let table = Table::new(
892        rows,
893        [
894            Constraint::Length(12),
895            Constraint::Length(12),
896            Constraint::Length(14),
897        ],
898    )
899    .header(header_row)
900    .block(
901        Block::default()
902            .title(" Per-Tier Statistics ")
903            .borders(Borders::ALL)
904            .border_style(Style::default().fg(Color::Blue)),
905    );
906    frame.render_widget(table, layout[1]);
907
908    // --- Summary ---
909    let total_starvation: u64 = stats.nr_starvation_preempts_tier.iter().sum();
910    let summary_text = format!(
911        " Dispatches: {} | Starvation preempts: {}",
912        stats.nr_new_flow_dispatches + stats.nr_old_flow_dispatches,
913        total_starvation
914    );
915
916    let summary = Paragraph::new(summary_text).block(
917        Block::default()
918            .title(" Summary ")
919            .borders(Borders::ALL)
920            .border_style(Style::default().fg(Color::Blue)),
921    );
922    frame.render_widget(summary, layout[2]);
923
924    // --- Footer (key bindings + status) ---
925    let footer_text = match app.get_status() {
926        Some(status) => format!(" [q] Quit  [c] Copy  [r] Reset  โ”‚  {}", status),
927        None => " [q] Quit  [c] Copy to clipboard  [r] Reset stats".to_string(),
928    };
929    let (fg_color, border_color) = if app.get_status().is_some() {
930        (Color::Green, Color::Green)
931    } else {
932        (Color::DarkGray, Color::DarkGray)
933    };
934    let footer = Paragraph::new(footer_text)
935        .style(Style::default().fg(fg_color))
936        .block(
937            Block::default()
938                .borders(Borders::ALL)
939                .border_style(Style::default().fg(border_color)),
940        );
941    frame.render_widget(footer, layout[3]);
942}
943
944/// Get color style for a tier
945fn tier_style(tier: usize) -> Style {
946    match tier {
947        0 => Style::default()
948            .fg(Color::Cyan)
949            .add_modifier(Modifier::BOLD), // Critical (<100ยตs)
950        1 => Style::default().fg(Color::Green), // Interactive (<2ms)
951        2 => Style::default().fg(Color::Yellow), // Frame (<8ms)
952        3 => Style::default().fg(Color::DarkGray), // Bulk (โ‰ฅ8ms)
953        _ => Style::default(),
954    }
955}
956
957/// Run the TUI event loop
958pub fn run_tui(
959    skel: &mut BpfSkel,
960    shutdown: Arc<AtomicBool>,
961    interval_secs: u64,
962    topology: TopologyInfo,
963) -> Result<()> {
964    let mut terminal = setup_terminal()?;
965    let mut app = TuiApp::new(topology);
966    let tick_rate = Duration::from_secs(interval_secs);
967    let mut last_tick = Instant::now();
968
969    // Initialize clipboard (may fail on headless systems)
970    let mut clipboard = Clipboard::new().ok();
971
972    loop {
973        // Check for shutdown signal
974        if shutdown.load(Ordering::Relaxed) {
975            break;
976        }
977
978        // Check for UEI exit
979        if scx_utils::uei_exited!(skel, uei) {
980            break;
981        }
982
983        // Get current stats (aggregate from per-cpu BSS array)
984        let stats = aggregate_stats(skel);
985
986        // Draw UI
987        terminal.draw(|frame| draw_ui(frame, &app, &stats))?;
988
989        // Handle events with timeout
990        let timeout = tick_rate.saturating_sub(last_tick.elapsed());
991        if event::poll(timeout)? {
992            if let Event::Key(key) = event::read()? {
993                if key.kind == KeyEventKind::Press {
994                    match key.code {
995                        KeyCode::Char('q') | KeyCode::Esc => {
996                            shutdown.store(true, Ordering::Relaxed);
997                            break;
998                        }
999                        KeyCode::Char('c') => {
1000                            // Copy stats to clipboard
1001                            let text = format_stats_for_clipboard(&stats, &app.format_uptime());
1002                            match &mut clipboard {
1003                                Some(cb) => match cb.set_text(text) {
1004                                    Ok(_) => app.set_status("โœ“ Copied to clipboard!"),
1005                                    Err(_) => app.set_status("โœ— Failed to copy"),
1006                                },
1007                                None => app.set_status("โœ— Clipboard not available"),
1008                            }
1009                        }
1010                        KeyCode::Char('r') => {
1011                            // Reset stats (clear the BSS array)
1012                            if let Some(bss) = &mut skel.maps.bss_data {
1013                                for s in &mut bss.global_stats {
1014                                    *s = Default::default();
1015                                }
1016                                app.set_status("โœ“ Stats reset");
1017                            }
1018                        }
1019                        _ => {}
1020                    }
1021                }
1022            }
1023        }
1024
1025        if last_tick.elapsed() >= tick_rate {
1026            last_tick = Instant::now();
1027        }
1028    }
1029
1030    restore_terminal()?;
1031    Ok(())
1032}