use crate::app::{App, GraphStyle};
use crate::backend::GpuSnapshot;
use crate::theme::UiTheme;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
};
const CARD_MIN: u16 = 8;
pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area();
frame.render_widget(
Block::new().style(Style::new().bg(app.theme.bg).fg(app.theme.fg)),
area,
);
if app.splash_active() {
crate::splash::render(frame, area, app.started, &app.splash_path, &app.theme);
return;
}
let [header, body, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(area);
{
let t = &app.theme;
let mut head = vec![
Span::styled(format!(" gpur v{} ", env!("CARGO_PKG_VERSION")), t.title),
Span::styled(format!("[{}] ", app.backend.name()), t.dim),
Span::styled(format!("{}ms ", app.tick_ms), t.dim),
];
if app.paused {
head.push(Span::styled("PAUSED ", t.temp_warn));
}
if let Some(err) = &app.poll_error {
head.push(Span::styled(format!("⚠ {err} "), t.temp_crit));
}
if let Some(msg) = app.status_line() {
head.push(Span::styled(format!("· {msg} "), t.spark_power));
}
frame.render_widget(Paragraph::new(Line::from(head)), header);
}
let want = app.procs.len() as u16 + 3;
let cap = ((body.height * 3) / 10).max(4);
let proc_height = want.min(cap).min(body.height);
let [gpus_area, proc_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(proc_height)]).areas(body);
app.gpus_rect = gpus_area;
app.proc_rect = proc_area;
draw_gpus(frame, gpus_area, app);
draw_processes(frame, proc_area, app);
let footer_line = if app.input_mode == crate::app::InputMode::Filter {
Line::from(vec![
Span::styled(" filter> ", app.theme.title),
Span::styled(app.filter_input.clone(), Style::new().fg(app.theme.fg)),
Span::styled("█", app.theme.title),
Span::styled(" (Enter apply · empty clears · Esc cancel)", app.theme.dim),
])
} else {
Line::styled(
" q quit ␣ pause p procs 0-9 gpu j/k move s sort r rev / filter x/X kill +/- rate",
app.theme.dim,
)
};
frame.render_widget(Paragraph::new(footer_line), footer);
draw_confirm_popup(frame, area, app);
}
fn draw_confirm_popup(frame: &mut Frame, area: Rect, app: &App) {
let Some((pid, force, cmd)) = &app.pending_kill else {
return;
};
let t = &app.theme;
let sig = if *force { "SIGKILL" } else { "SIGTERM" };
let text = format!("send {sig} to {pid}?");
let w = (text.len().max(cmd.len()) as u16 + 6).min(area.width);
let h = 5u16.min(area.height);
let popup = Rect::new(
area.x + (area.width.saturating_sub(w)) / 2,
area.y + (area.height.saturating_sub(h)) / 2,
w,
h,
);
frame.render_widget(ratatui::widgets::Clear, popup);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(t.temp_crit)
.title(caption("confirm".into(), t.temp_crit, t.temp_crit));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let lines = vec![
Line::styled(text, Style::new().fg(t.fg)),
Line::styled(cmd.clone(), t.dim),
Line::from(vec![
Span::styled("y", t.temp_crit),
Span::styled(" confirm · ", t.dim),
Span::styled("any other key", Style::new().fg(t.fg)),
Span::styled(" cancels", t.dim),
]),
];
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_gpus(frame: &mut Frame, area: Rect, app: &mut App) {
let t = &app.theme;
if app.gpus.is_empty() {
frame.render_widget(
Paragraph::new("no GPUs reported by backend").style(t.dim),
area,
);
return;
}
let height_of =
|app: &App, i: usize| -> u16 { if app.folded.contains(&i) { 1 } else { CARD_MIN } };
let n = app.gpus.len();
let needed: u16 = (0..n).map(|i| height_of(app, i)).sum();
if needed <= area.height {
app.gpu_scroll = 0;
let rows = Layout::vertical((0..n).map(|i| {
if app.folded.contains(&i) {
Constraint::Length(1)
} else {
Constraint::Fill(1)
}
}))
.split(area);
app.card_rects = rows.iter().copied().zip(0..n).collect();
for (i, gpu) in app.gpus.iter().enumerate() {
if app.folded.contains(&i) {
draw_gpu_folded(frame, rows[i], app, gpu, i);
} else {
draw_gpu(frame, rows[i], app, gpu, i);
}
}
return;
}
app.gpu_scroll = app.gpu_scroll.min(n - 1).min(app.selected);
loop {
let visible_span: u16 = (app.gpu_scroll..=app.selected)
.map(|i| height_of(app, i))
.sum();
if visible_span <= area.height || app.gpu_scroll >= app.selected {
break;
}
app.gpu_scroll += 1;
}
let mut shown = 0usize;
let mut used = 0u16;
for i in app.gpu_scroll..n {
let h = height_of(app, i);
if used + h > area.height {
break;
}
used += h;
shown += 1;
}
let shown = shown.max(1);
let cards = Rect {
width: area.width.saturating_sub(1),
..area
};
let window: Vec<usize> = (app.gpu_scroll..(app.gpu_scroll + shown).min(n)).collect();
let rows = Layout::vertical(window.iter().map(|i| {
if app.folded.contains(i) {
Constraint::Length(1)
} else {
Constraint::Fill(1)
}
}))
.split(cards);
app.card_rects = rows.iter().copied().zip(window.iter().copied()).collect();
for (slot, &i) in rows.iter().zip(&window) {
let gpu = &app.gpus[i];
if app.folded.contains(&i) {
draw_gpu_folded(frame, *slot, app, gpu, i);
} else {
draw_gpu(frame, *slot, app, gpu, i);
}
}
let max_scroll = n.saturating_sub(shown);
let mut sb = ScrollbarState::new(max_scroll + 1)
.position(app.gpu_scroll)
.viewport_content_length(shown);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.style(app.theme.dim),
area,
&mut sb,
);
}
fn draw_gpu_folded(frame: &mut Frame, area: Rect, app: &App, gpu: &GpuSnapshot, idx: usize) {
let t = &app.theme;
let selected = idx == app.selected;
let marker = if selected { t.border_selected } else { t.dim };
let mut line = vec![
Span::styled(" ▸ ", marker),
Span::styled(format!("{idx}·{} ", gpu.name), t.title),
Span::styled(format!("GPU {:>3.0}% ", gpu.utilization_pct), t.spark_util),
Span::styled(
format!(
"MEM {}/{} ",
human_bytes(gpu.vram_used_bytes),
human_bytes(gpu.vram_total_bytes)
),
Style::new().fg(t.accent),
),
];
if let Some(c) = gpu.temperature_c {
line.push(Span::styled(format!("{c:.0}°C "), t.temp_style(c)));
}
if let Some(w) = gpu.power_w {
line.push(Span::styled(format!("{w:.0}W "), t.spark_power));
}
if let Some(reason) = &gpu.throttle {
line.push(Span::styled(format!("⚠{reason} "), t.temp_crit));
}
frame.render_widget(Paragraph::new(Line::from(line)), area);
}
fn caption<'a>(text: String, text_style: Style, border: Style) -> Line<'a> {
Line::from(vec![
Span::styled("┐", border),
Span::styled(text, text_style),
Span::styled("┌", border),
])
}
fn draw_gpu(frame: &mut Frame, area: Rect, app: &App, gpu: &GpuSnapshot, idx: usize) {
let t = &app.theme;
let selected = idx == app.selected;
let border = if selected {
t.border_selected
} else {
t.border
};
let mut right_spans: Vec<Span> = Vec::new();
if gpu.integrated {
right_spans.push(Span::styled("integrated", t.dim));
} else if let (Some(g), Some(w)) = (gpu.pcie_gen, gpu.pcie_width) {
right_spans.push(Span::styled(format!("PCIe {g}.0@{w}x"), t.dim));
if let (Some(mg), Some(mw)) = (gpu.pcie_max_gen, gpu.pcie_max_width)
&& (g < mg || w < mw)
{
right_spans.push(Span::styled(format!(" (max {mg}.0@{mw}x)"), t.temp_warn));
}
}
let mut block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(border)
.title(caption(format!("{idx}·{}", gpu.name), t.title, border));
if !right_spans.is_empty() {
let mut line = vec![Span::styled("┐", border)];
line.extend(right_spans);
line.push(Span::styled("┌", border));
block = block.title_top(Line::from(line).right_aligned());
}
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let show_session = inner.height >= 7 && app.session.get(idx).is_some();
let [util_row, vram_row, spark_row, session_row, info_row] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(if show_session { 1 } else { 0 }),
Constraint::Length(1),
])
.areas(inner);
if show_session && let Some(sess) = app.session.get(idx) {
let line = Line::from(vec![
Span::styled(" session ", t.dim),
Span::styled(
format!(
"peak {:>3.0}% {:>3.0}°C {:>3.0}W ",
sess.max_util_pct, sess.max_temp_c, sess.max_power_w
),
Style::new().fg(t.fg),
),
Span::styled(
format!(
"avg {:>3.0}% {:>3.0}W",
sess.avg_util_pct(),
sess.avg_power_w()
),
t.dim,
),
]);
frame.render_widget(Paragraph::new(line), session_row);
}
let hist = app.history.get(idx);
draw_meter(
frame,
util_row,
"GPU ",
gpu.utilization_pct / 100.0,
format!(" {:>3.0}% ", gpu.utilization_pct),
&t.util_stops(),
t,
app.graph_style,
);
draw_meter(
frame,
vram_row,
"MEM ",
gpu.vram_pct() / 100.0,
format!(
" {}/{} ",
human_bytes(gpu.vram_used_bytes),
human_bytes(gpu.vram_total_bytes)
),
&t.vram_stops(),
t,
app.graph_style,
);
if spark_row.height >= 2
&& let Some(hist) = hist
{
draw_waveform(frame, spark_row, &hist.util, &hist.vram, t, app.graph_style);
}
let mut info: Vec<Span> = vec![Span::raw(" ")];
if let Some(reason) = &gpu.throttle {
info.push(Span::styled(format!("⚠{reason} "), t.temp_crit));
}
if let Some(c) = gpu.temperature_c {
if let Some(h) = hist {
info.push(Span::styled(
mini_spark(&h.temp, 100, app.graph_style),
t.dim,
));
}
info.push(Span::styled(format!(" {c:.0}°C "), t.temp_style(c)));
}
if let Some(w) = gpu.power_w {
let max_w = gpu.power_limit_w.unwrap_or(0.0).max(w).max(1.0) as u64;
if let Some(h) = hist {
info.push(Span::styled(
mini_spark(&h.power, max_w, app.graph_style),
t.dim,
));
}
let limit = gpu
.power_limit_w
.map(|l| format!("/{l:.0}"))
.unwrap_or_default();
info.push(Span::styled(format!(" {w:.0}{limit}W "), t.spark_power));
}
if let (Some(rx), Some(tx)) = (gpu.pcie_rx_kbs, gpu.pcie_tx_kbs) {
info.push(Span::styled(format!("▼{} ▲{} ", kbs(rx), kbs(tx)), t.dim));
}
if let Some(f) = gpu.fan_pct {
info.push(Span::styled(format!("fan {f:.0}% "), t.dim));
}
if let Some(c) = gpu.clock_mhz {
info.push(Span::styled(format!("core {c}MHz "), t.dim));
}
if let Some(m) = gpu.mem_clock_mhz {
info.push(Span::styled(format!("mem {m}MHz "), t.dim));
}
if let Some(mb) = gpu.mem_util_pct {
info.push(Span::styled(format!("membus {mb:.0}% "), t.dim));
}
if let Some(v) = gpu.video_util_pct {
info.push(Span::styled(format!("video {v:.0}% "), t.dim));
}
if let Some(e) = gpu.enc_util_pct {
info.push(Span::styled(format!("enc {e:.0}% "), t.dim));
}
if let Some(d) = gpu.dec_util_pct {
info.push(Span::styled(format!("dec {d:.0}% "), t.dim));
}
frame.render_widget(Paragraph::new(Line::from(info)), info_row);
}
#[allow(clippy::too_many_arguments)]
fn draw_meter(
frame: &mut Frame,
area: Rect,
label: &str,
frac: f64,
value: String,
stops: &[(u8, u8, u8)],
t: &UiTheme,
style: GraphStyle,
) {
if area.height == 0 {
return;
}
let (fill, empty) = match style {
GraphStyle::Ascii => ("=", "."),
_ => ("■", "·"),
};
let mut spans = vec![Span::styled(label.to_string(), Style::new().fg(t.fg))];
let meter_w = (area.width as usize)
.saturating_sub(label.chars().count() + value.chars().count())
.max(1);
let filled = (frac.clamp(0.0, 1.0) * meter_w as f64).round() as usize;
for i in 0..meter_w {
let pos = if meter_w > 1 {
i as f64 / (meter_w - 1) as f64
} else {
0.0
};
if i < filled {
spans.push(Span::styled(
fill,
Style::new().fg(crate::theme::gradient(stops, pos)),
));
} else {
spans.push(Span::styled(empty, t.dim));
}
}
spans.push(Span::styled(value, Style::new().fg(t.fg)));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn mini_spark(data: &[u64], max: u64, style: GraphStyle) -> String {
const CELLS: usize = 5;
let max = max.max(1);
if style != GraphStyle::Braille {
const ASCII_RAMP: [char; 5] = ['_', '.', '-', '+', '#'];
return (0..CELLS)
.map(|c| {
let v = if data.len() >= CELLS {
data[data.len() - CELLS + c]
} else {
let pad = CELLS - data.len();
if c < pad { 0 } else { data[c - pad] }
}
.min(max);
if style == GraphStyle::Block {
let lvl = ((v as usize * 8).div_ceil(max as usize)).clamp(1, 8);
EIGHTHS[lvl]
} else {
let lvl = ((v as usize * 4).div_ceil(max as usize)).clamp(0, 4);
ASCII_RAMP[lvl]
}
})
.collect();
}
let n = CELLS * 2;
let mut out = String::with_capacity(CELLS * 3);
for c in 0..CELLS {
let mut bits = 0u8;
for (s, bit_col) in DOT_BITS.iter().enumerate() {
let i = c * 2 + s;
let v = if data.len() >= n {
data[data.len() - n + i]
} else {
let pad = n - data.len();
if i < pad { 0 } else { data[i - pad] }
};
let dots = ((v.min(max) as usize * 4).div_ceil(max as usize)).clamp(1, 4);
for d in 0..dots {
bits |= bit_col[3 - d];
}
}
out.push(char::from_u32(BRAILLE_BASE + bits as u32).unwrap_or('⠀'));
}
out
}
fn human_bytes(b: u64) -> String {
let g = b as f64 / (1024.0 * 1024.0 * 1024.0);
if g >= 10.0 {
format!("{g:.0}G")
} else if g >= 1.0 {
format!("{g:.1}G")
} else {
format!("{}M", b / 1024 / 1024)
}
}
const EIGHTHS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
const BRAILLE_BASE: u32 = 0x2800;
const DOT_BITS: [[u8; 4]; 2] = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]];
fn draw_waveform(
frame: &mut Frame,
area: Rect,
up_data: &[u64],
down_data: &[u64],
t: &UiTheme,
style: GraphStyle,
) {
if area.height < 2 || area.width == 0 {
return;
}
if style != GraphStyle::Braille {
return draw_waveform_cells(frame, area, up_data, down_data, t, style);
}
let top_rows = (area.height / 2) as usize;
let bot_rows = area.height as usize - top_rows;
let cols = area.width as usize;
let n = cols * 2;
let up_stops = t.util_stops();
let down_stops = t.vram_stops();
let sample = |data: &[u64], i: usize| -> u64 {
if data.len() >= n {
data[data.len() - n + i]
} else {
let pad = n - data.len();
if i < pad { 0 } else { data[i - pad] }
}
};
let dots_for =
|v: u64, rows: usize| -> usize { ((v.min(100) as usize * rows * 4) / 100).max(1) };
let buf = frame.buffer_mut();
for half in 0..2 {
let (rows, data, stops) = if half == 0 {
(top_rows, up_data, &up_stops[..])
} else {
(bot_rows, down_data, &down_stops[..])
};
for cy in 0..rows {
let y = if half == 0 {
area.y + (top_rows - 1 - cy) as u16
} else {
area.y + (top_rows + cy) as u16
};
let frac = if rows > 1 {
cy as f64 / (rows - 1) as f64
} else {
0.0
};
let color = crate::theme::gradient(stops, frac);
for cx in 0..cols {
let mut bits = 0u8;
for (s, bit_col) in DOT_BITS.iter().enumerate() {
let dots = dots_for(sample(data, cx * 2 + s), rows);
let in_cell = dots.saturating_sub(cy * 4).min(4);
for d in 0..in_cell {
let row_in_cell = if half == 0 { 3 - d } else { d };
bits |= bit_col[row_in_cell];
}
}
if bits != 0
&& let Some(cell) = buf.cell_mut((area.x + cx as u16, y))
{
cell.set_char(char::from_u32(BRAILLE_BASE + bits as u32).unwrap_or('⠀'));
cell.set_fg(color);
}
}
}
}
buf.set_string(area.x, area.y, "gpu%", t.dim);
buf.set_string(area.x, area.y + area.height - 1, "vram%", t.dim);
}
fn draw_waveform_cells(
frame: &mut Frame,
area: Rect,
up_data: &[u64],
down_data: &[u64],
t: &UiTheme,
style: GraphStyle,
) {
let top_rows = (area.height / 2) as usize;
let bot_rows = area.height as usize - top_rows;
let cols = area.width as usize;
let up_stops = t.util_stops();
let down_stops = t.vram_stops();
let unit = if style == GraphStyle::Block { 8 } else { 4 };
const ASCII_RAMP: [char; 5] = [' ', '.', '-', '+', '#'];
let sample = |data: &[u64], i: usize| -> u64 {
if data.len() >= cols {
data[data.len() - cols + i]
} else {
let pad = cols - data.len();
if i < pad { 0 } else { data[i - pad] }
}
};
let bg = crate::theme::rgb_of(Some(t.bg), (0x1e, 0x1e, 0x2e));
let buf = frame.buffer_mut();
for half in 0..2 {
let (rows, data, stops) = if half == 0 {
(top_rows, up_data, &up_stops[..])
} else {
(bot_rows, down_data, &down_stops[..])
};
for cy in 0..rows {
let y = if half == 0 {
area.y + (top_rows - 1 - cy) as u16
} else {
area.y + (top_rows + cy) as u16
};
let frac = if rows > 1 {
cy as f64 / (rows - 1) as f64
} else {
0.0
};
let color = crate::theme::gradient(stops, frac);
for cx in 0..cols {
let v = sample(data, cx).min(100) as usize;
let units = ((v * rows * unit) / 100).max(1);
let in_cell = units.saturating_sub(cy * unit).min(unit);
if in_cell == 0 {
continue;
}
let (ch, cell_style) = match style {
GraphStyle::Block if half == 0 => (EIGHTHS[in_cell], Style::new().fg(color)),
GraphStyle::Block => {
if in_cell == 8 {
('█', Style::new().fg(color))
} else {
(
EIGHTHS[8 - in_cell],
Style::new()
.fg(ratatui::style::Color::Rgb(bg.0, bg.1, bg.2))
.bg(color),
)
}
}
_ => (ASCII_RAMP[in_cell], Style::new().fg(color)),
};
if let Some(cell) = buf.cell_mut((area.x + cx as u16, y)) {
cell.set_char(ch);
cell.set_style(cell_style);
}
}
}
}
buf.set_string(area.x, area.y, "gpu%", t.dim);
buf.set_string(area.x, area.y + area.height - 1, "vram%", t.dim);
}
fn draw_processes(frame: &mut Frame, area: Rect, app: &mut App) {
if area.height < 3 {
return;
}
let total = app.procs.len();
let visible = (area.height.saturating_sub(3) as usize).min(total);
let max_scroll = total - visible;
app.proc_sel = app.proc_sel.min(total.saturating_sub(1));
if app.proc_sel < app.proc_scroll {
app.proc_scroll = app.proc_sel;
} else if visible > 0 && app.proc_sel >= app.proc_scroll + visible {
app.proc_scroll = app.proc_sel + 1 - visible;
}
app.proc_scroll = app.proc_scroll.min(max_scroll);
let arrow = if app.sort_desc { "↓" } else { "↑" };
let mut counter = format!("{}{arrow}", app.sort_by.label());
if !app.filter.is_empty() {
counter = format!("filter:{} · {counter}", app.filter);
}
if max_scroll > 0 {
counter.push_str(&format!(
" · {}-{}/{total}",
app.proc_scroll + 1,
app.proc_scroll + visible
));
} else {
counter.push_str(&format!(" · {total}"));
}
let t = &app.theme;
let border = if app.focus == crate::app::Focus::Procs {
t.border_selected
} else {
t.border
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title(caption("processes".into(), t.title, border))
.title_top(caption(counter, t.dim, border).right_aligned())
.border_style(border);
if app.procs.is_empty() {
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new("no GPU processes visible (need same-user or root for fdinfo)")
.style(t.dim),
inner,
);
return;
}
let arrow = if app.sort_desc { "↓" } else { "↑" };
let mark = |label: &str, is: bool| -> String {
if is {
format!("{label}{arrow}")
} else {
label.to_string()
}
};
use crate::app::SortBy;
let header = Row::new(
[
mark("PID", app.sort_by == SortBy::Pid),
"USER".into(),
"DEV".into(),
"TYPE".into(),
mark("GPU%", app.sort_by == SortBy::GpuUtil),
mark("GPU MEM", app.sort_by == SortBy::GpuMem),
mark("CPU%", app.sort_by == SortBy::Cpu),
mark("HOST MEM", app.sort_by == SortBy::HostMem),
"COMMAND".into(),
]
.into_iter()
.map(Cell::from),
)
.style(t.title);
let proc_sel = app.proc_sel;
let selection = t.selection;
let rows = app.procs[app.proc_scroll..app.proc_scroll + visible]
.iter()
.enumerate()
.map(|(vi, p)| {
let row_style = if app.proc_scroll + vi == proc_sel {
selection
} else {
Style::default()
};
Row::new(vec![
Cell::from(p.pid.to_string()),
Cell::from(p.user.clone()),
Cell::from(p.gpu_index.to_string()),
Cell::from(p.kind.label()),
Cell::from(
p.gpu_util_pct
.map(|u| format!("{u:>3.0}%"))
.unwrap_or_else(|| "N/A".into()),
),
Cell::from(format!("{}MiB", p.gpu_mem_bytes / 1024 / 1024)),
Cell::from(format!("{:>3.0}%", p.cpu_pct)),
Cell::from(format!("{}MiB", p.host_mem_bytes / 1024 / 1024)),
Cell::from(p.command.clone()),
])
.style(row_style)
});
let table = Table::new(
rows,
[
Constraint::Length(8),
Constraint::Length(10),
Constraint::Length(3),
Constraint::Length(8),
Constraint::Length(5),
Constraint::Length(9),
Constraint::Length(5),
Constraint::Length(9),
Constraint::Fill(1),
],
)
.header(header)
.block(block);
frame.render_widget(table, area);
if max_scroll > 0 {
let track = Rect::new(
area.x,
area.y + 2,
area.width,
area.height.saturating_sub(3),
);
let mut sb = ScrollbarState::new(max_scroll + 1)
.position(app.proc_scroll)
.viewport_content_length(visible);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.style(app.theme.dim),
track,
&mut sb,
);
}
}
fn kbs(v: u64) -> String {
if v >= 1024 * 1024 {
format!("{:.1}GiB/s", v as f64 / (1024.0 * 1024.0))
} else if v >= 1024 {
format!("{:.1}MiB/s", v as f64 / 1024.0)
} else {
format!("{v}KiB/s")
}
}