use std::time::{Duration, Instant};
use anyhow::Result;
use crossbeam_channel::Receiver;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::chart::BrailleChart;
use crate::history::History;
use crate::model::{GpuInfo, GpuProcess, GpuProcessKind, GpuSample, MetricKind};
use crate::sampler::SamplerEvent;
#[derive(Debug, Clone, Copy)]
pub struct TuiConfig {
pub sample_interval: Duration,
pub frame_interval: Duration,
pub history_retention: Duration,
pub initial_window: Duration,
}
pub struct App {
devices: Vec<GpuInfo>,
backend_label: String,
rx: Receiver<SamplerEvent>,
histories: Vec<History>,
config: TuiConfig,
started: Instant,
last_error: Option<String>,
samples_seen: u64,
window_index: usize,
selected_gpu: usize,
process_popup_open: bool,
process_scroll: usize,
}
impl App {
pub fn new(
devices: Vec<GpuInfo>,
backend_label: String,
rx: Receiver<SamplerEvent>,
config: TuiConfig,
) -> Self {
let histories = devices
.iter()
.map(|_| History::new(config.history_retention))
.collect();
let window_index = closest_window(config.initial_window);
Self {
devices,
backend_label,
rx,
histories,
config,
started: Instant::now(),
last_error: None,
samples_seen: 0,
window_index,
selected_gpu: 0,
process_popup_open: false,
process_scroll: 0,
}
}
fn ingest(&mut self) {
while let Ok(event) = self.rx.try_recv() {
match event {
SamplerEvent::Samples(samples) => {
self.samples_seen += samples.len() as u64;
for sample in samples {
if let Some(history) = self.histories.get_mut(sample.gpu_id) {
history.push(sample);
}
}
}
SamplerEvent::Error(error) => {
self.last_error = Some(error);
}
}
}
}
fn window(&self) -> Duration {
WINDOWS[self.window_index]
}
fn zoom_in(&mut self) {
self.window_index = self.window_index.saturating_sub(1);
}
fn zoom_out(&mut self) {
self.window_index = (self.window_index + 1).min(WINDOWS.len() - 1);
}
fn select_next_gpu(&mut self) {
if !self.devices.is_empty() {
self.selected_gpu = (self.selected_gpu + 1) % self.devices.len();
self.process_scroll = 0;
}
}
fn select_previous_gpu(&mut self) {
if !self.devices.is_empty() {
self.selected_gpu = if self.selected_gpu == 0 {
self.devices.len() - 1
} else {
self.selected_gpu - 1
};
self.process_scroll = 0;
}
}
fn open_process_popup(&mut self) {
if !self.devices.is_empty() {
self.process_popup_open = true;
self.process_scroll = 0;
}
}
fn close_process_popup(&mut self) {
self.process_popup_open = false;
}
fn scroll_processes_by(&mut self, delta: isize) {
if delta < 0 {
self.process_scroll = self.process_scroll.saturating_sub(delta.unsigned_abs());
} else {
let max_scroll = self.selected_process_count().saturating_sub(1);
self.process_scroll = (self.process_scroll + delta as usize).min(max_scroll);
}
}
fn scroll_processes_to_start(&mut self) {
self.process_scroll = 0;
}
fn scroll_processes_to_end(&mut self) {
self.process_scroll = self.selected_process_count().saturating_sub(1);
}
fn selected_process_count(&self) -> usize {
self.selected_sample()
.map(|sample| sample.processes.len())
.unwrap_or(0)
}
fn selected_sample(&self) -> Option<&GpuSample> {
self.histories
.get(self.selected_gpu)
.and_then(History::latest)
}
fn render(&self, frame: &mut Frame) {
let area = frame.area();
if area.width < 40 || area.height < 10 {
frame.render_widget(
Paragraph::new("gpu-histop needs at least 40x10 terminal cells"),
area,
);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
self.render_header(frame, chunks[0]);
self.render_body(frame, chunks[1]);
if self.process_popup_open {
self.render_process_popup(frame, area);
}
}
fn render_header(&self, frame: &mut Frame, area: Rect) {
let uptime = self.started.elapsed();
let sample_hz = hz(self.config.sample_interval);
let frame_hz = hz(self.config.frame_interval);
let status = Line::from(vec![
Span::styled(
"gpu-histop ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(
"{} GPU(s) | {} backend | sample {:.1} Hz | draw {:.1} Hz | view {} | stored {} | up {}",
self.devices.len(),
self.backend_label,
sample_hz,
frame_hz,
format_duration(self.window()),
self.total_samples(),
format_duration(uptime),
)),
]);
let controls = if let Some(error) = &self.last_error {
Line::from(vec![
Span::styled("last sampler error: ", Style::default().fg(Color::Red)),
Span::raw(truncate(error, area.width.saturating_sub(20) as usize)),
])
} else {
let controls = if self.process_popup_open {
"q quit | Esc/p close processes | Up/Down scroll | Tab switch GPU"
} else {
"q/Esc quit | Up/Down select GPU | p/Enter processes | +/- zoom | Braille min/max history"
};
Line::from(controls)
};
let paragraph = Paragraph::new(vec![status, controls]).block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Color::DarkGray),
);
frame.render_widget(paragraph, area);
}
fn render_body(&self, frame: &mut Frame, area: Rect) {
if self.devices.is_empty() {
frame.render_widget(Paragraph::new("no GPUs found"), area);
return;
}
let constraints = vec![Constraint::Ratio(1, self.devices.len() as u32); self.devices.len()];
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
for (index, device) in self.devices.iter().enumerate() {
self.render_gpu_panel(
frame,
rows[index],
device,
&self.histories[index],
index == self.selected_gpu,
);
}
}
fn render_gpu_panel(
&self,
frame: &mut Frame,
area: Rect,
device: &GpuInfo,
history: &History,
selected: bool,
) {
if area.height < 3 || area.width < 12 {
return;
}
let title = format!(
" {}GPU {} {}{} ",
if selected { ">" } else { "" },
device.id,
device.name,
device
.uuid
.as_deref()
.map(|uuid| format!(" ({})", short_uuid(uuid)))
.unwrap_or_default()
);
let border_style = if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(inner);
let summary = Paragraph::new(vec![
summary_line(history.latest()),
process_line(history.latest(), sections[0].width),
]);
frame.render_widget(summary, sections[0]);
if sections[1].height < 3 {
return;
}
self.render_charts(frame, sections[1], history);
}
fn render_charts(&self, frame: &mut Frame, area: Rect, history: &History) {
let now = Instant::now();
let window = self.window().min(history.retention());
if area.width >= 110 {
let chunks = split_even(area, Direction::Horizontal, MetricKind::ALL.len());
for (metric, chunk) in MetricKind::ALL.into_iter().zip(chunks.iter()) {
frame.render_widget(
BrailleChart {
history,
metric,
now,
window,
},
*chunk,
);
}
} else if area.height >= 12 {
let rows = split_even(area, Direction::Vertical, 2);
let top = split_even(rows[0], Direction::Horizontal, 3);
let bottom = split_even(rows[1], Direction::Horizontal, 3);
for (metric, chunk) in MetricKind::ALL[..3].iter().copied().zip(top.iter()) {
frame.render_widget(
BrailleChart {
history,
metric,
now,
window,
},
*chunk,
);
}
for (metric, chunk) in MetricKind::ALL[3..].iter().copied().zip(bottom.iter()) {
frame.render_widget(
BrailleChart {
history,
metric,
now,
window,
},
*chunk,
);
}
} else {
let compact = [MetricKind::GpuUtil, MetricKind::VramUsed, MetricKind::Power];
let chunks = split_even(area, Direction::Horizontal, compact.len());
for (metric, chunk) in compact.into_iter().zip(chunks.iter()) {
frame.render_widget(
BrailleChart {
history,
metric,
now,
window,
},
*chunk,
);
}
}
}
fn total_samples(&self) -> usize {
self.histories.iter().map(History::len).sum()
}
fn render_process_popup(&self, frame: &mut Frame, area: Rect) {
let Some(device) = self.devices.get(self.selected_gpu) else {
return;
};
let popup_area = centered_rect(area, 88, 74);
let title = format!(" GPU {} Processes: {} ", device.id, device.name);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(title);
let inner = block.inner(popup_area);
frame.render_widget(Clear, popup_area);
frame.render_widget(block, popup_area);
if inner.width == 0 || inner.height == 0 {
return;
}
let lines = process_popup_lines(
device,
self.selected_sample(),
inner.width,
inner.height,
self.process_scroll,
self.selected_gpu,
self.devices.len(),
);
frame.render_widget(Paragraph::new(lines), inner);
}
}
pub fn run(terminal: &mut ratatui::DefaultTerminal, mut app: App) -> Result<()> {
loop {
let frame_started = Instant::now();
app.ingest();
terminal.draw(|frame| app.render(frame))?;
let timeout = app
.config
.frame_interval
.saturating_sub(frame_started.elapsed());
if event::poll(timeout)? && handle_event(event::read()?, &mut app) {
return Ok(());
}
}
}
fn handle_event(event: Event, app: &mut App) -> bool {
match event {
Event::Key(key) if is_press(key) => handle_key_event(key, app),
_ => false,
}
}
fn handle_key_event(key: KeyEvent, app: &mut App) -> bool {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return true;
}
if app.process_popup_open {
return match key.code {
KeyCode::Char('q') => true,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('p') => {
app.close_process_popup();
false
}
KeyCode::Down | KeyCode::Char('j') => {
app.scroll_processes_by(1);
false
}
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_processes_by(-1);
false
}
KeyCode::PageDown => {
app.scroll_processes_by(8);
false
}
KeyCode::PageUp => {
app.scroll_processes_by(-8);
false
}
KeyCode::Home => {
app.scroll_processes_to_start();
false
}
KeyCode::End => {
app.scroll_processes_to_end();
false
}
KeyCode::Tab | KeyCode::Char('n') => {
app.select_next_gpu();
false
}
KeyCode::BackTab | KeyCode::Char('N') => {
app.select_previous_gpu();
false
}
_ => false,
};
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => true,
KeyCode::Char('+') | KeyCode::Char('=') => {
app.zoom_in();
false
}
KeyCode::Char('-') | KeyCode::Char('_') => {
app.zoom_out();
false
}
KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
app.select_next_gpu();
false
}
KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => {
app.select_previous_gpu();
false
}
KeyCode::Enter | KeyCode::Char('p') => {
app.open_process_popup();
false
}
_ => false,
}
}
fn is_press(key: KeyEvent) -> bool {
key.kind == KeyEventKind::Press
}
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let width = percent_len(area.width, percent_x).clamp(area.width.min(60), area.width);
let height = percent_len(area.height, percent_y).clamp(area.height.min(12), area.height);
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width,
height,
}
}
fn percent_len(value: u16, percent: u16) -> u16 {
((value as u32 * percent as u32) / 100) as u16
}
fn split_even(area: Rect, direction: Direction, count: usize) -> Vec<Rect> {
let constraints = vec![Constraint::Ratio(1, count as u32); count];
Layout::default()
.direction(direction)
.constraints(constraints)
.split(area)
.to_vec()
}
fn summary_line(sample: Option<&GpuSample>) -> Line<'static> {
let Some(sample) = sample else {
return Line::from(vec![Span::styled(
"waiting for samples",
Style::default().fg(Color::DarkGray),
)]);
};
Line::from(vec![
metric_span("GPU", sample.gpu_util_percent, "%", Color::Cyan),
Span::raw(" "),
metric_span("MEM", sample.mem_util_percent, "%", Color::Green),
Span::raw(" "),
Span::raw(format!(
"VRAM {}",
vram_summary(sample.vram_used_bytes, sample.vram_total_bytes)
)),
Span::raw(" "),
metric_span("PWR", sample.power_watts, "W", Color::Magenta),
power_limit_span(sample.power_limit_watts),
Span::raw(" "),
metric_span("TEMP", sample.temperature_celsius, "C", Color::Red),
Span::raw(" "),
metric_span("FAN", sample.fan_percent, "%", Color::Blue),
Span::raw(" "),
Span::raw(format!(
"CLK {}/{} MHz",
whole_or_na(sample.graphics_clock_mhz),
whole_or_na(sample.memory_clock_mhz)
)),
Span::raw(" "),
Span::raw(format!(
"PROC {}",
sample
.compute_processes
.map_or_else(|| "n/a".to_owned(), |count| format!("{count} total"))
)),
])
}
fn process_line(sample: Option<&GpuSample>, width: u16) -> Line<'static> {
let Some(sample) = sample else {
return Line::from("");
};
if sample.processes.is_empty() {
return Line::from(vec![
Span::styled("PROC", Style::default().fg(Color::DarkGray)),
Span::raw(" none"),
]);
}
let shown = sample.processes.iter().take(4).collect::<Vec<_>>();
let hidden = sample.processes.len().saturating_sub(shown.len());
let mut rendered = format!(
"PROC {}: {}",
sample.processes.len(),
shown
.iter()
.map(|process| format_process(process))
.collect::<Vec<_>>()
.join(" | ")
);
if hidden > 0 {
rendered.push_str(&format!(" | +{hidden}"));
}
Line::from(Span::styled(
truncate(&rendered, width as usize),
Style::default().fg(Color::Gray),
))
}
fn process_popup_lines(
device: &GpuInfo,
sample: Option<&GpuSample>,
width: u16,
height: u16,
scroll: usize,
selected_gpu: usize,
gpu_count: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let max_lines = height as usize;
if max_lines == 0 {
return lines;
}
let process_count = sample.map(|sample| sample.processes.len()).unwrap_or(0);
let last_sample = sample
.map(|sample| format!("sample age {}", format_duration(sample.at.elapsed())))
.unwrap_or_else(|| "waiting for sample".to_owned());
lines.push(Line::from(Span::styled(
truncate(
&format!(
"GPU {} | {} | {} | {} process(es)",
device.id, device.name, last_sample, process_count
),
width as usize,
),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
let Some(sample) = sample else {
push_footer_line(
&mut lines,
width,
selected_gpu,
gpu_count,
0,
0,
process_count,
);
return trim_lines(lines, max_lines);
};
if sample.processes.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"No NVML compute, graphics, or MPS processes reported for this GPU.",
Style::default().fg(Color::Gray),
)));
push_footer_line(
&mut lines,
width,
selected_gpu,
gpu_count,
0,
0,
process_count,
);
return trim_lines(lines, max_lines);
}
let rows_available = max_lines.saturating_sub(3);
let max_start = sample.processes.len().saturating_sub(rows_available);
let start = scroll.min(max_start);
let end = (start + rows_available).min(sample.processes.len());
lines.push(process_table_header(width));
for process in &sample.processes[start..end] {
lines.push(process_table_row(process, width));
}
push_footer_line(
&mut lines,
width,
selected_gpu,
gpu_count,
if rows_available == 0 { 0 } else { start },
end,
process_count,
);
trim_lines(lines, max_lines)
}
fn process_table_header(width: u16) -> Line<'static> {
let spec = process_column_spec(width);
let header = if spec.mig_width > 0 {
format!(
"{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {:<mig_w$} {}",
"TYPE",
"PID",
"USER",
"GPU MEM",
"MIG",
"COMMAND",
type_w = spec.type_width,
pid_w = spec.pid_width,
user_w = spec.user_width,
mem_w = spec.memory_width,
mig_w = spec.mig_width
)
} else {
format!(
"{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {}",
"TYPE",
"PID",
"USER",
"GPU MEM",
"COMMAND",
type_w = spec.type_width,
pid_w = spec.pid_width,
user_w = spec.user_width,
mem_w = spec.memory_width
)
};
Line::from(Span::styled(
truncate(&header, width as usize),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
))
}
fn process_table_row(process: &GpuProcess, width: u16) -> Line<'static> {
let spec = process_column_spec(width);
let kind = process.kind_label();
let user = process.user.as_deref().unwrap_or("?");
let memory = process
.used_gpu_memory_bytes
.map(format_bytes_compact)
.unwrap_or_else(|| "n/a".to_owned());
let mig = process_mig_label(process);
let command = process.command.as_deref().unwrap_or("?");
let row = if spec.mig_width > 0 {
format!(
"{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {:<mig_w$} {}",
fit_cell(&kind, spec.type_width),
process.pid,
fit_cell(user, spec.user_width),
fit_cell(&memory, spec.memory_width),
fit_cell(&mig, spec.mig_width),
truncate(command, spec.command_width),
type_w = spec.type_width,
pid_w = spec.pid_width,
user_w = spec.user_width,
mem_w = spec.memory_width,
mig_w = spec.mig_width
)
} else {
format!(
"{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {}",
fit_cell(&kind, spec.type_width),
process.pid,
fit_cell(user, spec.user_width),
fit_cell(&memory, spec.memory_width),
truncate(command, spec.command_width),
type_w = spec.type_width,
pid_w = spec.pid_width,
user_w = spec.user_width,
mem_w = spec.memory_width
)
};
Line::from(Span::styled(
truncate(&row, width as usize),
process_kind_style(process),
))
}
#[derive(Debug, Clone, Copy)]
struct ProcessColumnSpec {
type_width: usize,
pid_width: usize,
user_width: usize,
memory_width: usize,
mig_width: usize,
command_width: usize,
}
fn process_column_spec(width: u16) -> ProcessColumnSpec {
let total = width as usize;
let type_width = 5;
let pid_width = 8;
let user_width = if total >= 96 { 14 } else { 10 };
let memory_width = 8;
let mig_width = if total >= 88 { 11 } else { 0 };
let separators = if mig_width > 0 { 5 } else { 4 };
let fixed = type_width + pid_width + user_width + memory_width + mig_width + separators;
let command_width = total.saturating_sub(fixed).max(8);
ProcessColumnSpec {
type_width,
pid_width,
user_width,
memory_width,
mig_width,
command_width,
}
}
fn process_kind_style(process: &GpuProcess) -> Style {
let color = if process.kinds.contains(&GpuProcessKind::Graphics) {
Color::Yellow
} else if process.kinds.contains(&GpuProcessKind::Mps) {
Color::Magenta
} else {
Color::Green
};
Style::default().fg(color)
}
fn push_footer_line(
lines: &mut Vec<Line<'static>>,
width: u16,
selected_gpu: usize,
gpu_count: usize,
start: usize,
end: usize,
total: usize,
) {
let range = if total == 0 {
"0-0/0".to_owned()
} else if end == 0 {
format!("0-0/{total}")
} else {
format!("{}-{}/{}", start + 1, end, total)
};
lines.push(Line::from(Span::styled(
truncate(
&format!(
"GPU {}/{} | showing {} | Up/Down/PgUp/PgDn scroll | Tab GPU | Esc close | q quit",
selected_gpu + 1,
gpu_count,
range
),
width as usize,
),
Style::default().fg(Color::DarkGray),
)));
}
fn trim_lines(mut lines: Vec<Line<'static>>, max_lines: usize) -> Vec<Line<'static>> {
lines.truncate(max_lines);
lines
}
fn format_process(process: &GpuProcess) -> String {
let user = process.user.as_deref().unwrap_or("?");
let command = process
.command
.as_deref()
.map(compact_command)
.unwrap_or_else(|| "?".to_owned());
let memory = process
.used_gpu_memory_bytes
.map(format_bytes_compact)
.unwrap_or_else(|| "mem n/a".to_owned());
let mig = process_mig_label(process);
let mig = if mig.is_empty() {
String::new()
} else {
format!(" {mig}")
};
format!(
"{} pid={} user={} {} {}{}",
process.kind_label(),
process.pid,
user,
memory,
command,
mig
)
}
fn process_mig_label(process: &GpuProcess) -> String {
match (process.gpu_instance_id, process.compute_instance_id) {
(Some(gpu), Some(compute)) => format!("gi={gpu}/ci={compute}"),
(Some(gpu), None) => format!("gi={gpu}"),
_ => String::new(),
}
}
fn fit_cell(value: &str, width: usize) -> String {
truncate(value, width)
}
fn metric_span(
label: &'static str,
value: Option<f64>,
unit: &'static str,
color: Color,
) -> Span<'static> {
let rendered = value
.map(|v| format!("{label} {v:.0}{unit}"))
.unwrap_or_else(|| format!("{label} n/a"));
Span::styled(rendered, Style::default().fg(color))
}
fn power_limit_span(limit: Option<f64>) -> Span<'static> {
limit
.map(|limit| Span::raw(format!("/{limit:.0}W")))
.unwrap_or_else(|| Span::raw(""))
}
fn vram_summary(used: Option<u64>, total: Option<u64>) -> String {
match (used, total) {
(Some(used), Some(total)) if total > 0 => format!(
"{:.1}/{:.1} GiB {:>3.0}%",
bytes_to_gib(used),
bytes_to_gib(total),
used as f64 * 100.0 / total as f64
),
(Some(used), _) => format!("{:.1} GiB used", bytes_to_gib(used)),
_ => "n/a".to_owned(),
}
}
fn bytes_to_gib(bytes: u64) -> f64 {
bytes as f64 / 1024.0 / 1024.0 / 1024.0
}
fn format_bytes_compact(bytes: u64) -> String {
let gib = bytes_to_gib(bytes);
if gib >= 10.0 {
format!("{gib:.0}GiB")
} else if gib >= 1.0 {
format!("{gib:.1}GiB")
} else {
format!("{:.0}MiB", bytes as f64 / 1024.0 / 1024.0)
}
}
fn compact_command(command: &str) -> String {
let command = command.trim();
if command.is_empty() {
return "?".to_owned();
}
let mut parts = command.split_whitespace();
let executable = parts.next().unwrap_or(command);
let executable = executable.rsplit('/').next().unwrap_or(executable);
let rest = parts.take(2).collect::<Vec<_>>().join(" ");
if rest.is_empty() {
executable.to_owned()
} else {
format!("{executable} {rest}")
}
}
fn whole_or_na(value: Option<f64>) -> String {
value
.map(|v| format!("{v:.0}"))
.unwrap_or_else(|| "n/a".to_owned())
}
fn short_uuid(uuid: &str) -> &str {
uuid.rsplit('-').next().unwrap_or(uuid)
}
fn hz(duration: Duration) -> f64 {
1.0 / duration.as_secs_f64().max(0.001)
}
fn format_duration(duration: Duration) -> String {
let seconds = duration.as_secs();
if seconds < 60 {
format!("{seconds}s")
} else if seconds < 3600 {
format!("{}m{:02}s", seconds / 60, seconds % 60)
} else {
format!("{}h{:02}m", seconds / 3600, (seconds % 3600) / 60)
}
}
fn truncate(value: &str, max_len: usize) -> String {
if max_len == 0 {
return String::new();
}
if value.chars().count() <= max_len {
return value.to_owned();
}
if max_len <= 3 {
return ".".repeat(max_len);
}
value.chars().take(max_len - 3).collect::<String>() + "..."
}
const WINDOWS: [Duration; 8] = [
Duration::from_secs(10),
Duration::from_secs(30),
Duration::from_secs(60),
Duration::from_secs(5 * 60),
Duration::from_secs(10 * 60),
Duration::from_secs(30 * 60),
Duration::from_secs(60 * 60),
Duration::from_secs(3 * 60 * 60),
];
fn closest_window(target: Duration) -> usize {
WINDOWS
.iter()
.enumerate()
.min_by_key(|(_, window)| window.abs_diff(target))
.map(|(index, _)| index)
.unwrap_or(2)
}