Skip to main content

gpu_histop/
ui.rs

1use std::time::{Duration, Instant};
2
3use anyhow::Result;
4use crossbeam_channel::Receiver;
5use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
6use ratatui::Frame;
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Borders, Clear, Paragraph};
11
12use crate::chart::BrailleChart;
13use crate::history::History;
14use crate::model::{GpuInfo, GpuProcess, GpuProcessKind, GpuSample, MetricKind};
15use crate::sampler::SamplerEvent;
16
17#[derive(Debug, Clone, Copy)]
18pub struct TuiConfig {
19    pub sample_interval: Duration,
20    pub frame_interval: Duration,
21    pub history_retention: Duration,
22    pub initial_window: Duration,
23}
24
25pub struct App {
26    devices: Vec<GpuInfo>,
27    backend_label: String,
28    rx: Receiver<SamplerEvent>,
29    histories: Vec<History>,
30    config: TuiConfig,
31    started: Instant,
32    last_error: Option<String>,
33    samples_seen: u64,
34    window_index: usize,
35    selected_gpu: usize,
36    process_popup_open: bool,
37    process_scroll: usize,
38}
39
40impl App {
41    pub fn new(
42        devices: Vec<GpuInfo>,
43        backend_label: String,
44        rx: Receiver<SamplerEvent>,
45        config: TuiConfig,
46    ) -> Self {
47        let histories = devices
48            .iter()
49            .map(|_| History::new(config.history_retention))
50            .collect();
51        let window_index = closest_window(config.initial_window);
52
53        Self {
54            devices,
55            backend_label,
56            rx,
57            histories,
58            config,
59            started: Instant::now(),
60            last_error: None,
61            samples_seen: 0,
62            window_index,
63            selected_gpu: 0,
64            process_popup_open: false,
65            process_scroll: 0,
66        }
67    }
68
69    fn ingest(&mut self) {
70        while let Ok(event) = self.rx.try_recv() {
71            match event {
72                SamplerEvent::Samples(samples) => {
73                    self.samples_seen += samples.len() as u64;
74                    for sample in samples {
75                        if let Some(history) = self.histories.get_mut(sample.gpu_id) {
76                            history.push(sample);
77                        }
78                    }
79                }
80                SamplerEvent::Error(error) => {
81                    self.last_error = Some(error);
82                }
83            }
84        }
85    }
86
87    fn window(&self) -> Duration {
88        WINDOWS[self.window_index]
89    }
90
91    fn zoom_in(&mut self) {
92        self.window_index = self.window_index.saturating_sub(1);
93    }
94
95    fn zoom_out(&mut self) {
96        self.window_index = (self.window_index + 1).min(WINDOWS.len() - 1);
97    }
98
99    fn select_next_gpu(&mut self) {
100        if !self.devices.is_empty() {
101            self.selected_gpu = (self.selected_gpu + 1) % self.devices.len();
102            self.process_scroll = 0;
103        }
104    }
105
106    fn select_previous_gpu(&mut self) {
107        if !self.devices.is_empty() {
108            self.selected_gpu = if self.selected_gpu == 0 {
109                self.devices.len() - 1
110            } else {
111                self.selected_gpu - 1
112            };
113            self.process_scroll = 0;
114        }
115    }
116
117    fn open_process_popup(&mut self) {
118        if !self.devices.is_empty() {
119            self.process_popup_open = true;
120            self.process_scroll = 0;
121        }
122    }
123
124    fn close_process_popup(&mut self) {
125        self.process_popup_open = false;
126    }
127
128    fn scroll_processes_by(&mut self, delta: isize) {
129        if delta < 0 {
130            self.process_scroll = self.process_scroll.saturating_sub(delta.unsigned_abs());
131        } else {
132            let max_scroll = self.selected_process_count().saturating_sub(1);
133            self.process_scroll = (self.process_scroll + delta as usize).min(max_scroll);
134        }
135    }
136
137    fn scroll_processes_to_start(&mut self) {
138        self.process_scroll = 0;
139    }
140
141    fn scroll_processes_to_end(&mut self) {
142        self.process_scroll = self.selected_process_count().saturating_sub(1);
143    }
144
145    fn selected_process_count(&self) -> usize {
146        self.selected_sample()
147            .map(|sample| sample.processes.len())
148            .unwrap_or(0)
149    }
150
151    fn selected_sample(&self) -> Option<&GpuSample> {
152        self.histories
153            .get(self.selected_gpu)
154            .and_then(History::latest)
155    }
156
157    fn render(&self, frame: &mut Frame) {
158        let area = frame.area();
159        if area.width < 40 || area.height < 10 {
160            frame.render_widget(
161                Paragraph::new("gpu-histop needs at least 40x10 terminal cells"),
162                area,
163            );
164            return;
165        }
166
167        let chunks = Layout::default()
168            .direction(Direction::Vertical)
169            .constraints([Constraint::Length(3), Constraint::Min(0)])
170            .split(area);
171
172        self.render_header(frame, chunks[0]);
173        self.render_body(frame, chunks[1]);
174        if self.process_popup_open {
175            self.render_process_popup(frame, area);
176        }
177    }
178
179    fn render_header(&self, frame: &mut Frame, area: Rect) {
180        let uptime = self.started.elapsed();
181        let sample_hz = hz(self.config.sample_interval);
182        let frame_hz = hz(self.config.frame_interval);
183        let status = Line::from(vec![
184            Span::styled(
185                "gpu-histop ",
186                Style::default()
187                    .fg(Color::Cyan)
188                    .add_modifier(Modifier::BOLD),
189            ),
190            Span::raw(format!(
191                "{} GPU(s) | {} backend | sample {:.1} Hz | draw {:.1} Hz | view {} | stored {} | up {}",
192                self.devices.len(),
193                self.backend_label,
194                sample_hz,
195                frame_hz,
196                format_duration(self.window()),
197                self.total_samples(),
198                format_duration(uptime),
199            )),
200        ]);
201
202        let controls = if let Some(error) = &self.last_error {
203            Line::from(vec![
204                Span::styled("last sampler error: ", Style::default().fg(Color::Red)),
205                Span::raw(truncate(error, area.width.saturating_sub(20) as usize)),
206            ])
207        } else {
208            let controls = if self.process_popup_open {
209                "q quit | Esc/p close processes | Up/Down scroll | Tab switch GPU"
210            } else {
211                "q/Esc quit | Up/Down select GPU | p/Enter processes | +/- zoom | Braille min/max history"
212            };
213            Line::from(controls)
214        };
215
216        let paragraph = Paragraph::new(vec![status, controls]).block(
217            Block::default()
218                .borders(Borders::BOTTOM)
219                .border_style(Color::DarkGray),
220        );
221        frame.render_widget(paragraph, area);
222    }
223
224    fn render_body(&self, frame: &mut Frame, area: Rect) {
225        if self.devices.is_empty() {
226            frame.render_widget(Paragraph::new("no GPUs found"), area);
227            return;
228        }
229
230        let constraints = vec![Constraint::Ratio(1, self.devices.len() as u32); self.devices.len()];
231        let rows = Layout::default()
232            .direction(Direction::Vertical)
233            .constraints(constraints)
234            .split(area);
235
236        for (index, device) in self.devices.iter().enumerate() {
237            self.render_gpu_panel(
238                frame,
239                rows[index],
240                device,
241                &self.histories[index],
242                index == self.selected_gpu,
243            );
244        }
245    }
246
247    fn render_gpu_panel(
248        &self,
249        frame: &mut Frame,
250        area: Rect,
251        device: &GpuInfo,
252        history: &History,
253        selected: bool,
254    ) {
255        if area.height < 3 || area.width < 12 {
256            return;
257        }
258
259        let title = format!(
260            " {}GPU {} {}{} ",
261            if selected { ">" } else { "" },
262            device.id,
263            device.name,
264            device
265                .uuid
266                .as_deref()
267                .map(|uuid| format!(" ({})", short_uuid(uuid)))
268                .unwrap_or_default()
269        );
270        let border_style = if selected {
271            Style::default()
272                .fg(Color::Cyan)
273                .add_modifier(Modifier::BOLD)
274        } else {
275            Style::default().fg(Color::DarkGray)
276        };
277        let block = Block::default()
278            .borders(Borders::ALL)
279            .border_style(border_style)
280            .title(title);
281        let inner = block.inner(area);
282        frame.render_widget(block, area);
283
284        if inner.height == 0 {
285            return;
286        }
287
288        let sections = Layout::default()
289            .direction(Direction::Vertical)
290            .constraints([Constraint::Length(2), Constraint::Min(0)])
291            .split(inner);
292
293        let summary = Paragraph::new(vec![
294            summary_line(history.latest()),
295            process_line(history.latest(), sections[0].width),
296        ]);
297        frame.render_widget(summary, sections[0]);
298
299        if sections[1].height < 3 {
300            return;
301        }
302
303        self.render_charts(frame, sections[1], history);
304    }
305
306    fn render_charts(&self, frame: &mut Frame, area: Rect, history: &History) {
307        let now = Instant::now();
308        let window = self.window().min(history.retention());
309
310        if area.width >= 110 {
311            let chunks = split_even(area, Direction::Horizontal, MetricKind::ALL.len());
312            for (metric, chunk) in MetricKind::ALL.into_iter().zip(chunks.iter()) {
313                frame.render_widget(
314                    BrailleChart {
315                        history,
316                        metric,
317                        now,
318                        window,
319                    },
320                    *chunk,
321                );
322            }
323        } else if area.height >= 12 {
324            let rows = split_even(area, Direction::Vertical, 2);
325            let top = split_even(rows[0], Direction::Horizontal, 3);
326            let bottom = split_even(rows[1], Direction::Horizontal, 3);
327            for (metric, chunk) in MetricKind::ALL[..3].iter().copied().zip(top.iter()) {
328                frame.render_widget(
329                    BrailleChart {
330                        history,
331                        metric,
332                        now,
333                        window,
334                    },
335                    *chunk,
336                );
337            }
338            for (metric, chunk) in MetricKind::ALL[3..].iter().copied().zip(bottom.iter()) {
339                frame.render_widget(
340                    BrailleChart {
341                        history,
342                        metric,
343                        now,
344                        window,
345                    },
346                    *chunk,
347                );
348            }
349        } else {
350            let compact = [MetricKind::GpuUtil, MetricKind::VramUsed, MetricKind::Power];
351            let chunks = split_even(area, Direction::Horizontal, compact.len());
352            for (metric, chunk) in compact.into_iter().zip(chunks.iter()) {
353                frame.render_widget(
354                    BrailleChart {
355                        history,
356                        metric,
357                        now,
358                        window,
359                    },
360                    *chunk,
361                );
362            }
363        }
364    }
365
366    fn total_samples(&self) -> usize {
367        self.histories.iter().map(History::len).sum()
368    }
369
370    fn render_process_popup(&self, frame: &mut Frame, area: Rect) {
371        let Some(device) = self.devices.get(self.selected_gpu) else {
372            return;
373        };
374
375        let popup_area = centered_rect(area, 88, 74);
376        let title = format!(" GPU {} Processes: {} ", device.id, device.name);
377        let block = Block::default()
378            .borders(Borders::ALL)
379            .border_style(Style::default().fg(Color::Cyan))
380            .title(title);
381        let inner = block.inner(popup_area);
382
383        frame.render_widget(Clear, popup_area);
384        frame.render_widget(block, popup_area);
385
386        if inner.width == 0 || inner.height == 0 {
387            return;
388        }
389
390        let lines = process_popup_lines(
391            device,
392            self.selected_sample(),
393            inner.width,
394            inner.height,
395            self.process_scroll,
396            self.selected_gpu,
397            self.devices.len(),
398        );
399        frame.render_widget(Paragraph::new(lines), inner);
400    }
401}
402
403pub fn run(terminal: &mut ratatui::DefaultTerminal, mut app: App) -> Result<()> {
404    loop {
405        let frame_started = Instant::now();
406        app.ingest();
407        terminal.draw(|frame| app.render(frame))?;
408
409        let timeout = app
410            .config
411            .frame_interval
412            .saturating_sub(frame_started.elapsed());
413        if event::poll(timeout)? && handle_event(event::read()?, &mut app) {
414            return Ok(());
415        }
416    }
417}
418
419fn handle_event(event: Event, app: &mut App) -> bool {
420    match event {
421        Event::Key(key) if is_press(key) => handle_key_event(key, app),
422        _ => false,
423    }
424}
425
426fn handle_key_event(key: KeyEvent, app: &mut App) -> bool {
427    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
428        return true;
429    }
430
431    if app.process_popup_open {
432        return match key.code {
433            KeyCode::Char('q') => true,
434            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('p') => {
435                app.close_process_popup();
436                false
437            }
438            KeyCode::Down | KeyCode::Char('j') => {
439                app.scroll_processes_by(1);
440                false
441            }
442            KeyCode::Up | KeyCode::Char('k') => {
443                app.scroll_processes_by(-1);
444                false
445            }
446            KeyCode::PageDown => {
447                app.scroll_processes_by(8);
448                false
449            }
450            KeyCode::PageUp => {
451                app.scroll_processes_by(-8);
452                false
453            }
454            KeyCode::Home => {
455                app.scroll_processes_to_start();
456                false
457            }
458            KeyCode::End => {
459                app.scroll_processes_to_end();
460                false
461            }
462            KeyCode::Tab | KeyCode::Char('n') => {
463                app.select_next_gpu();
464                false
465            }
466            KeyCode::BackTab | KeyCode::Char('N') => {
467                app.select_previous_gpu();
468                false
469            }
470            _ => false,
471        };
472    }
473
474    match key.code {
475        KeyCode::Char('q') | KeyCode::Esc => true,
476        KeyCode::Char('+') | KeyCode::Char('=') => {
477            app.zoom_in();
478            false
479        }
480        KeyCode::Char('-') | KeyCode::Char('_') => {
481            app.zoom_out();
482            false
483        }
484        KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
485            app.select_next_gpu();
486            false
487        }
488        KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => {
489            app.select_previous_gpu();
490            false
491        }
492        KeyCode::Enter | KeyCode::Char('p') => {
493            app.open_process_popup();
494            false
495        }
496        _ => false,
497    }
498}
499
500fn is_press(key: KeyEvent) -> bool {
501    key.kind == KeyEventKind::Press
502}
503
504fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
505    let width = percent_len(area.width, percent_x).clamp(area.width.min(60), area.width);
506    let height = percent_len(area.height, percent_y).clamp(area.height.min(12), area.height);
507    Rect {
508        x: area.x + area.width.saturating_sub(width) / 2,
509        y: area.y + area.height.saturating_sub(height) / 2,
510        width,
511        height,
512    }
513}
514
515fn percent_len(value: u16, percent: u16) -> u16 {
516    ((value as u32 * percent as u32) / 100) as u16
517}
518
519fn split_even(area: Rect, direction: Direction, count: usize) -> Vec<Rect> {
520    let constraints = vec![Constraint::Ratio(1, count as u32); count];
521    Layout::default()
522        .direction(direction)
523        .constraints(constraints)
524        .split(area)
525        .to_vec()
526}
527
528fn summary_line(sample: Option<&GpuSample>) -> Line<'static> {
529    let Some(sample) = sample else {
530        return Line::from(vec![Span::styled(
531            "waiting for samples",
532            Style::default().fg(Color::DarkGray),
533        )]);
534    };
535
536    Line::from(vec![
537        metric_span("GPU", sample.gpu_util_percent, "%", Color::Cyan),
538        Span::raw("  "),
539        metric_span("MEM", sample.mem_util_percent, "%", Color::Green),
540        Span::raw("  "),
541        Span::raw(format!(
542            "VRAM {}",
543            vram_summary(sample.vram_used_bytes, sample.vram_total_bytes)
544        )),
545        Span::raw("  "),
546        metric_span("PWR", sample.power_watts, "W", Color::Magenta),
547        power_limit_span(sample.power_limit_watts),
548        Span::raw("  "),
549        metric_span("TEMP", sample.temperature_celsius, "C", Color::Red),
550        Span::raw("  "),
551        metric_span("FAN", sample.fan_percent, "%", Color::Blue),
552        Span::raw("  "),
553        Span::raw(format!(
554            "CLK {}/{} MHz",
555            whole_or_na(sample.graphics_clock_mhz),
556            whole_or_na(sample.memory_clock_mhz)
557        )),
558        Span::raw("  "),
559        Span::raw(format!(
560            "PROC {}",
561            sample
562                .compute_processes
563                .map_or_else(|| "n/a".to_owned(), |count| format!("{count} total"))
564        )),
565    ])
566}
567
568fn process_line(sample: Option<&GpuSample>, width: u16) -> Line<'static> {
569    let Some(sample) = sample else {
570        return Line::from("");
571    };
572
573    if sample.processes.is_empty() {
574        return Line::from(vec![
575            Span::styled("PROC", Style::default().fg(Color::DarkGray)),
576            Span::raw(" none"),
577        ]);
578    }
579
580    let shown = sample.processes.iter().take(4).collect::<Vec<_>>();
581    let hidden = sample.processes.len().saturating_sub(shown.len());
582    let mut rendered = format!(
583        "PROC {}: {}",
584        sample.processes.len(),
585        shown
586            .iter()
587            .map(|process| format_process(process))
588            .collect::<Vec<_>>()
589            .join(" | ")
590    );
591    if hidden > 0 {
592        rendered.push_str(&format!(" | +{hidden}"));
593    }
594
595    Line::from(Span::styled(
596        truncate(&rendered, width as usize),
597        Style::default().fg(Color::Gray),
598    ))
599}
600
601fn process_popup_lines(
602    device: &GpuInfo,
603    sample: Option<&GpuSample>,
604    width: u16,
605    height: u16,
606    scroll: usize,
607    selected_gpu: usize,
608    gpu_count: usize,
609) -> Vec<Line<'static>> {
610    let mut lines = Vec::new();
611    let max_lines = height as usize;
612    if max_lines == 0 {
613        return lines;
614    }
615
616    let process_count = sample.map(|sample| sample.processes.len()).unwrap_or(0);
617    let last_sample = sample
618        .map(|sample| format!("sample age {}", format_duration(sample.at.elapsed())))
619        .unwrap_or_else(|| "waiting for sample".to_owned());
620    lines.push(Line::from(Span::styled(
621        truncate(
622            &format!(
623                "GPU {} | {} | {} | {} process(es)",
624                device.id, device.name, last_sample, process_count
625            ),
626            width as usize,
627        ),
628        Style::default()
629            .fg(Color::Cyan)
630            .add_modifier(Modifier::BOLD),
631    )));
632
633    let Some(sample) = sample else {
634        push_footer_line(
635            &mut lines,
636            width,
637            selected_gpu,
638            gpu_count,
639            0,
640            0,
641            process_count,
642        );
643        return trim_lines(lines, max_lines);
644    };
645
646    if sample.processes.is_empty() {
647        lines.push(Line::from(""));
648        lines.push(Line::from(Span::styled(
649            "No NVML compute, graphics, or MPS processes reported for this GPU.",
650            Style::default().fg(Color::Gray),
651        )));
652        push_footer_line(
653            &mut lines,
654            width,
655            selected_gpu,
656            gpu_count,
657            0,
658            0,
659            process_count,
660        );
661        return trim_lines(lines, max_lines);
662    }
663
664    let rows_available = max_lines.saturating_sub(3);
665    let max_start = sample.processes.len().saturating_sub(rows_available);
666    let start = scroll.min(max_start);
667    let end = (start + rows_available).min(sample.processes.len());
668
669    lines.push(process_table_header(width));
670    for process in &sample.processes[start..end] {
671        lines.push(process_table_row(process, width));
672    }
673    push_footer_line(
674        &mut lines,
675        width,
676        selected_gpu,
677        gpu_count,
678        if rows_available == 0 { 0 } else { start },
679        end,
680        process_count,
681    );
682    trim_lines(lines, max_lines)
683}
684
685fn process_table_header(width: u16) -> Line<'static> {
686    let spec = process_column_spec(width);
687    let header = if spec.mig_width > 0 {
688        format!(
689            "{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {:<mig_w$} {}",
690            "TYPE",
691            "PID",
692            "USER",
693            "GPU MEM",
694            "MIG",
695            "COMMAND",
696            type_w = spec.type_width,
697            pid_w = spec.pid_width,
698            user_w = spec.user_width,
699            mem_w = spec.memory_width,
700            mig_w = spec.mig_width
701        )
702    } else {
703        format!(
704            "{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {}",
705            "TYPE",
706            "PID",
707            "USER",
708            "GPU MEM",
709            "COMMAND",
710            type_w = spec.type_width,
711            pid_w = spec.pid_width,
712            user_w = spec.user_width,
713            mem_w = spec.memory_width
714        )
715    };
716    Line::from(Span::styled(
717        truncate(&header, width as usize),
718        Style::default()
719            .fg(Color::DarkGray)
720            .add_modifier(Modifier::BOLD),
721    ))
722}
723
724fn process_table_row(process: &GpuProcess, width: u16) -> Line<'static> {
725    let spec = process_column_spec(width);
726    let kind = process.kind_label();
727    let user = process.user.as_deref().unwrap_or("?");
728    let memory = process
729        .used_gpu_memory_bytes
730        .map(format_bytes_compact)
731        .unwrap_or_else(|| "n/a".to_owned());
732    let mig = process_mig_label(process);
733    let command = process.command.as_deref().unwrap_or("?");
734
735    let row = if spec.mig_width > 0 {
736        format!(
737            "{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {:<mig_w$} {}",
738            fit_cell(&kind, spec.type_width),
739            process.pid,
740            fit_cell(user, spec.user_width),
741            fit_cell(&memory, spec.memory_width),
742            fit_cell(&mig, spec.mig_width),
743            truncate(command, spec.command_width),
744            type_w = spec.type_width,
745            pid_w = spec.pid_width,
746            user_w = spec.user_width,
747            mem_w = spec.memory_width,
748            mig_w = spec.mig_width
749        )
750    } else {
751        format!(
752            "{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {}",
753            fit_cell(&kind, spec.type_width),
754            process.pid,
755            fit_cell(user, spec.user_width),
756            fit_cell(&memory, spec.memory_width),
757            truncate(command, spec.command_width),
758            type_w = spec.type_width,
759            pid_w = spec.pid_width,
760            user_w = spec.user_width,
761            mem_w = spec.memory_width
762        )
763    };
764
765    Line::from(Span::styled(
766        truncate(&row, width as usize),
767        process_kind_style(process),
768    ))
769}
770
771#[derive(Debug, Clone, Copy)]
772struct ProcessColumnSpec {
773    type_width: usize,
774    pid_width: usize,
775    user_width: usize,
776    memory_width: usize,
777    mig_width: usize,
778    command_width: usize,
779}
780
781fn process_column_spec(width: u16) -> ProcessColumnSpec {
782    let total = width as usize;
783    let type_width = 5;
784    let pid_width = 8;
785    let user_width = if total >= 96 { 14 } else { 10 };
786    let memory_width = 8;
787    let mig_width = if total >= 88 { 11 } else { 0 };
788    let separators = if mig_width > 0 { 5 } else { 4 };
789    let fixed = type_width + pid_width + user_width + memory_width + mig_width + separators;
790    let command_width = total.saturating_sub(fixed).max(8);
791
792    ProcessColumnSpec {
793        type_width,
794        pid_width,
795        user_width,
796        memory_width,
797        mig_width,
798        command_width,
799    }
800}
801
802fn process_kind_style(process: &GpuProcess) -> Style {
803    let color = if process.kinds.contains(&GpuProcessKind::Graphics) {
804        Color::Yellow
805    } else if process.kinds.contains(&GpuProcessKind::Mps) {
806        Color::Magenta
807    } else {
808        Color::Green
809    };
810    Style::default().fg(color)
811}
812
813fn push_footer_line(
814    lines: &mut Vec<Line<'static>>,
815    width: u16,
816    selected_gpu: usize,
817    gpu_count: usize,
818    start: usize,
819    end: usize,
820    total: usize,
821) {
822    let range = if total == 0 {
823        "0-0/0".to_owned()
824    } else if end == 0 {
825        format!("0-0/{total}")
826    } else {
827        format!("{}-{}/{}", start + 1, end, total)
828    };
829    lines.push(Line::from(Span::styled(
830        truncate(
831            &format!(
832                "GPU {}/{} | showing {} | Up/Down/PgUp/PgDn scroll | Tab GPU | Esc close | q quit",
833                selected_gpu + 1,
834                gpu_count,
835                range
836            ),
837            width as usize,
838        ),
839        Style::default().fg(Color::DarkGray),
840    )));
841}
842
843fn trim_lines(mut lines: Vec<Line<'static>>, max_lines: usize) -> Vec<Line<'static>> {
844    lines.truncate(max_lines);
845    lines
846}
847
848fn format_process(process: &GpuProcess) -> String {
849    let user = process.user.as_deref().unwrap_or("?");
850    let command = process
851        .command
852        .as_deref()
853        .map(compact_command)
854        .unwrap_or_else(|| "?".to_owned());
855    let memory = process
856        .used_gpu_memory_bytes
857        .map(format_bytes_compact)
858        .unwrap_or_else(|| "mem n/a".to_owned());
859    let mig = process_mig_label(process);
860    let mig = if mig.is_empty() {
861        String::new()
862    } else {
863        format!(" {mig}")
864    };
865
866    format!(
867        "{} pid={} user={} {} {}{}",
868        process.kind_label(),
869        process.pid,
870        user,
871        memory,
872        command,
873        mig
874    )
875}
876
877fn process_mig_label(process: &GpuProcess) -> String {
878    match (process.gpu_instance_id, process.compute_instance_id) {
879        (Some(gpu), Some(compute)) => format!("gi={gpu}/ci={compute}"),
880        (Some(gpu), None) => format!("gi={gpu}"),
881        _ => String::new(),
882    }
883}
884
885fn fit_cell(value: &str, width: usize) -> String {
886    truncate(value, width)
887}
888
889fn metric_span(
890    label: &'static str,
891    value: Option<f64>,
892    unit: &'static str,
893    color: Color,
894) -> Span<'static> {
895    let rendered = value
896        .map(|v| format!("{label} {v:.0}{unit}"))
897        .unwrap_or_else(|| format!("{label} n/a"));
898    Span::styled(rendered, Style::default().fg(color))
899}
900
901fn power_limit_span(limit: Option<f64>) -> Span<'static> {
902    limit
903        .map(|limit| Span::raw(format!("/{limit:.0}W")))
904        .unwrap_or_else(|| Span::raw(""))
905}
906
907fn vram_summary(used: Option<u64>, total: Option<u64>) -> String {
908    match (used, total) {
909        (Some(used), Some(total)) if total > 0 => format!(
910            "{:.1}/{:.1} GiB {:>3.0}%",
911            bytes_to_gib(used),
912            bytes_to_gib(total),
913            used as f64 * 100.0 / total as f64
914        ),
915        (Some(used), _) => format!("{:.1} GiB used", bytes_to_gib(used)),
916        _ => "n/a".to_owned(),
917    }
918}
919
920fn bytes_to_gib(bytes: u64) -> f64 {
921    bytes as f64 / 1024.0 / 1024.0 / 1024.0
922}
923
924fn format_bytes_compact(bytes: u64) -> String {
925    let gib = bytes_to_gib(bytes);
926    if gib >= 10.0 {
927        format!("{gib:.0}GiB")
928    } else if gib >= 1.0 {
929        format!("{gib:.1}GiB")
930    } else {
931        format!("{:.0}MiB", bytes as f64 / 1024.0 / 1024.0)
932    }
933}
934
935fn compact_command(command: &str) -> String {
936    let command = command.trim();
937    if command.is_empty() {
938        return "?".to_owned();
939    }
940
941    let mut parts = command.split_whitespace();
942    let executable = parts.next().unwrap_or(command);
943    let executable = executable.rsplit('/').next().unwrap_or(executable);
944    let rest = parts.take(2).collect::<Vec<_>>().join(" ");
945    if rest.is_empty() {
946        executable.to_owned()
947    } else {
948        format!("{executable} {rest}")
949    }
950}
951
952fn whole_or_na(value: Option<f64>) -> String {
953    value
954        .map(|v| format!("{v:.0}"))
955        .unwrap_or_else(|| "n/a".to_owned())
956}
957
958fn short_uuid(uuid: &str) -> &str {
959    uuid.rsplit('-').next().unwrap_or(uuid)
960}
961
962fn hz(duration: Duration) -> f64 {
963    1.0 / duration.as_secs_f64().max(0.001)
964}
965
966fn format_duration(duration: Duration) -> String {
967    let seconds = duration.as_secs();
968    if seconds < 60 {
969        format!("{seconds}s")
970    } else if seconds < 3600 {
971        format!("{}m{:02}s", seconds / 60, seconds % 60)
972    } else {
973        format!("{}h{:02}m", seconds / 3600, (seconds % 3600) / 60)
974    }
975}
976
977fn truncate(value: &str, max_len: usize) -> String {
978    if max_len == 0 {
979        return String::new();
980    }
981    if value.chars().count() <= max_len {
982        return value.to_owned();
983    }
984    if max_len <= 3 {
985        return ".".repeat(max_len);
986    }
987    value.chars().take(max_len - 3).collect::<String>() + "..."
988}
989
990const WINDOWS: [Duration; 8] = [
991    Duration::from_secs(10),
992    Duration::from_secs(30),
993    Duration::from_secs(60),
994    Duration::from_secs(5 * 60),
995    Duration::from_secs(10 * 60),
996    Duration::from_secs(30 * 60),
997    Duration::from_secs(60 * 60),
998    Duration::from_secs(3 * 60 * 60),
999];
1000
1001fn closest_window(target: Duration) -> usize {
1002    WINDOWS
1003        .iter()
1004        .enumerate()
1005        .min_by_key(|(_, window)| window.abs_diff(target))
1006        .map(|(index, _)| index)
1007        .unwrap_or(2)
1008}