This commit is contained in:
2026-02-19 01:52:59 +00:00
parent 0784b7fc8f
commit c897d94180
6 changed files with 490 additions and 163 deletions

435
src/ui.rs
View File

@@ -1,12 +1,14 @@
use std::collections::HashSet;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
text::Line,
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Wrap},
Frame,
};
use crate::state::{AppState, FocusPane, ProcessSort};
use crate::state::{sort_indicator, AppState, FocusPane, ProcessSample, ProcessSort};
pub fn draw(frame: &mut Frame, state: &AppState) {
let root = frame.size();
@@ -45,11 +47,12 @@ fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) {
}
fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
let has_sidebar = area.width >= 100;
let body_chunks = if has_sidebar {
let has_right_sidebar = area.width >= 120;
let body_chunks = if has_right_sidebar {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(60), Constraint::Length(28)])
.constraints([Constraint::Min(68), Constraint::Length(34)])
.split(area)
} else {
Layout::default()
@@ -59,41 +62,53 @@ fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
};
let main = body_chunks[0];
let sidebar = if has_sidebar {
let right = if has_right_sidebar {
Some(body_chunks[1])
} else {
None
};
let cores = state.snapshot.cpu_per_core_percent.len();
let cores_visible = if main.width < 90 {
cores.min(4)
} else {
cores.min(8)
};
let cpu_height = (cores_visible as u16) + 3;
let cpu_height = (main.height / 4).clamp(6, 12);
let middle_height = (main.height / 4).clamp(7, 10);
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(cpu_height),
Constraint::Length(9),
Constraint::Length(middle_height),
Constraint::Min(7),
])
.split(main);
draw_cpu_section(frame, main_chunks[0], state, cores_visible);
draw_cpu_section(frame, main_chunks[0], state);
draw_memory_disk_section(frame, main_chunks[1], state);
draw_process_table(frame, main_chunks[2], state);
if let Some(net) = sidebar {
draw_network(frame, net, state);
let table_area = if !has_right_sidebar && state.show_process_details {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(6), Constraint::Length(8)])
.split(main_chunks[2]);
draw_process_details(frame, split[1], state);
split[0]
} else {
let net_line = format!(
"RX {}/s | TX {}/s",
fmt_bytes(state.network_rate.rx_bps as u64),
fmt_bytes(state.network_rate.tx_bps as u64)
);
main_chunks[2]
};
draw_process_table(frame, table_area, state);
if let Some(sidebar) = right {
if state.show_process_details {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(6)])
.split(sidebar);
draw_process_details(frame, split[0], state);
draw_network(frame, split[1], state);
} else {
draw_network(frame, sidebar, state);
}
} else {
let net_line = compact_network_line(state);
let net_area = Rect {
x: main.x,
y: main.y,
@@ -107,22 +122,18 @@ fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
}
}
fn draw_cpu_section(frame: &mut Frame, area: Rect, state: &AppState, cores_visible: usize) {
fn draw_cpu_section(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::default().title("CPU").borders(Borders::ALL);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
if inner.height < 2 || inner.width < 10 {
return;
}
let mut rows = vec![Constraint::Length(1)];
for _ in 0..cores_visible {
rows.push(Constraint::Length(1));
}
let rows = Layout::default()
let split = Layout::default()
.direction(Direction::Vertical)
.constraints(rows)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner);
let total_label = format!(
@@ -138,24 +149,51 @@ fn draw_cpu_section(frame: &mut Frame, area: Rect, state: &AppState, cores_visib
)
.percent(state.snapshot.cpu_total_percent.clamp(0.0, 100.0) as u16)
.label(total_label),
rows[0],
split[0],
);
for (i, usage) in state
.snapshot
.cpu_per_core_percent
.iter()
.take(cores_visible)
.enumerate()
{
frame.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Blue))
.percent(usage.clamp(0.0, 100.0) as u16)
.label(format!("cpu{:02} {:>5.1}%", i, usage)),
rows[i + 1],
);
let cores = &state.snapshot.cpu_per_core_percent;
if cores.is_empty() || split[1].height == 0 {
return;
}
let col_width = 20_u16;
let columns = (split[1].width / col_width).max(1) as usize;
let rows_per_col = split[1].height as usize;
let max_visible = (columns * rows_per_col).min(cores.len());
let mut lines = Vec::with_capacity(rows_per_col);
for row in 0..rows_per_col {
let mut line = String::new();
for col in 0..columns {
let idx = col * rows_per_col + row;
if idx >= max_visible {
continue;
}
let segment = format_core_mini(idx, cores[idx], col_width as usize);
line.push_str(&segment);
}
lines.push(Line::from(line));
}
if max_visible < cores.len() && !lines.is_empty() {
let extra = cores.len() - max_visible;
let last_line = lines.pop().unwrap_or_default();
let mut last = last_line
.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>();
last.push_str(&format!(" +{extra} more"));
lines.push(Line::from(last));
}
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().fg(Color::Gray)),
split[1],
);
}
fn draw_memory_disk_section(frame: &mut Frame, area: Rect, state: &AppState) {
@@ -196,10 +234,10 @@ fn draw_mem_swap(frame: &mut Frame, area: Rect, state: &AppState) {
.gauge_style(Style::default().fg(Color::Green))
.percent(mem_pct as u16)
.label(format!(
"mem {:>5.1}% {} / {}",
"mem {:>5.1}% {}/{}",
mem_pct,
fmt_bytes(state.snapshot.mem_used_bytes),
fmt_bytes(state.snapshot.mem_total_bytes)
fmt_bytes_compact(state.snapshot.mem_used_bytes),
fmt_bytes_compact(state.snapshot.mem_total_bytes)
)),
rows[0],
);
@@ -209,17 +247,23 @@ fn draw_mem_swap(frame: &mut Frame, area: Rect, state: &AppState) {
.gauge_style(Style::default().fg(Color::Yellow))
.percent(swap_pct as u16)
.label(format!(
"swap {:>5.1}% {} / {}",
"swap {:>5.1}% {}/{}",
swap_pct,
fmt_bytes(state.snapshot.swap_used_bytes),
fmt_bytes(state.snapshot.swap_total_bytes)
fmt_bytes_compact(state.snapshot.swap_used_bytes),
fmt_bytes_compact(state.snapshot.swap_total_bytes)
)),
rows[1],
);
}
fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::default().title("Disk").borders(Borders::ALL);
let block = Block::default()
.title(if state.show_pseudo_fs {
"Disk [all]"
} else {
"Disk [filtered]"
})
.borders(Borders::ALL);
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -227,65 +271,82 @@ fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) {
return;
}
let max_disks = inner.height as usize;
let mut disks = state.snapshot.disk_samples.clone();
let mut seen_mounts = HashSet::new();
let mut disks = state
.snapshot
.disk_samples
.iter()
.filter(|d| {
if state.show_pseudo_fs {
return true;
}
if is_noisy_pseudo_fs(&d.fs_type) {
return false;
}
if d.mount.starts_with("/tmp") {
return false;
}
seen_mounts.insert(d.mount.as_str())
})
.collect::<Vec<_>>();
disks.sort_by(|a, b| {
let ap = percent(a.used_bytes, a.total_bytes);
let bp = percent(b.used_bytes, b.total_bytes);
bp.partial_cmp(&ap).unwrap_or(std::cmp::Ordering::Equal)
});
let rows = Layout::default()
let rows = inner.height as usize;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1); max_disks.max(1)])
.constraints(vec![Constraint::Length(1); rows.max(1)])
.split(inner);
for (i, disk) in disks.iter().take(max_disks).enumerate() {
if disks.is_empty() {
frame.render_widget(Paragraph::new("no mounts"), inner);
return;
}
for (i, disk) in disks.into_iter().take(rows).enumerate() {
let pct = percent(disk.used_bytes, disk.total_bytes);
frame.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Magenta))
.percent(pct as u16)
.label(format!(
"{} {:>5.1}% {} / {}",
truncate(&disk.mount, 10),
"{} {:>4.0}% {}/{}",
truncate(&disk.mount, 12),
pct,
fmt_bytes(disk.used_bytes),
fmt_bytes(disk.total_bytes)
fmt_bytes_compact(disk.used_bytes),
fmt_bytes_compact(disk.total_bytes)
)),
rows[i],
layout[i],
);
}
}
fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::default().title("Net").borders(Borders::ALL);
let mut lines = vec![Line::from(vec![
Span::styled("RX ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}/s", fmt_bytes(state.network_rate.rx_bps as u64)),
Style::default().fg(Color::Cyan),
),
])];
lines.push(Line::from(vec![
Span::styled("TX ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}/s", fmt_bytes(state.network_rate.tx_bps as u64)),
Style::default().fg(Color::Cyan),
),
]));
if state.detailed_view {
lines.push(Line::from(""));
lines.push(Line::from(format!(
"tot rx {}",
fmt_bytes(state.snapshot.network_totals.rx_bytes)
)));
lines.push(Line::from(format!(
"tot tx {}",
fmt_bytes(state.snapshot.network_totals.tx_bytes)
)));
}
let iface_label = match state.snapshot.network_interfaces.split_first() {
Some((first, rest)) if !rest.is_empty() => format!("if {} (+{})", first, rest.len()),
Some((first, _)) => format!("if {first}"),
None => "if -".to_string(),
};
let lines = vec![
Line::from(format!(
"RX {}/s TX {}/s",
fmt_bytes_compact(state.network_rate.rx_bps as u64),
fmt_bytes_compact(state.network_rate.tx_bps as u64)
)),
Line::from(format!(
"tot {}/{}",
fmt_bytes_compact(state.snapshot.network_totals.rx_bytes),
fmt_bytes_compact(state.snapshot.network_totals.tx_bytes)
)),
Line::from(iface_label),
];
frame.render_widget(
Paragraph::new(lines)
@@ -298,13 +359,11 @@ fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) {
fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
let focused = state.focus == FocusPane::ProcessTable;
let sort_char = sort_indicator(state.process_sort_order);
let title = format!(
"Processes [{}]",
match state.process_sort {
ProcessSort::Cpu => "CPU",
ProcessSort::Mem => "MEM",
ProcessSort::Pid => "PID",
}
"Processes [{}{}]",
sort_key_label(state.process_sort),
sort_char
);
let border_style = if focused {
Style::default().fg(Color::Cyan)
@@ -324,7 +383,7 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
return;
}
let show_user = inner.width >= 84;
let show_user = inner.width >= 90;
let visible_rows = inner.height.saturating_sub(1) as usize;
let selected = state
.selected_process
@@ -355,10 +414,10 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
Cell::from(format!("{:>6}", p.pid)),
Cell::from(format!("{:>5.1}", p.cpu_percent)),
Cell::from(format!("{:>5.1}", mem_pct)),
Cell::from(format!("{:>9}", fmt_bytes(p.mem_bytes))),
Cell::from(format!("{:>9}", fmt_bytes_compact(p.mem_bytes))),
Cell::from(truncate(&p.state, 6)),
Cell::from(truncate(p.user.as_deref().unwrap_or("-"), 8)),
Cell::from(truncate(&p.command, 28)),
Cell::from(truncate(&p.command, 30)),
])
.style(style)
} else {
@@ -366,7 +425,7 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
Cell::from(format!("{:>6}", p.pid)),
Cell::from(format!("{:>5.1}", p.cpu_percent)),
Cell::from(format!("{:>5.1}", mem_pct)),
Cell::from(format!("{:>9}", fmt_bytes(p.mem_bytes))),
Cell::from(format!("{:>9}", fmt_bytes_compact(p.mem_bytes))),
Cell::from(truncate(&p.state, 6)),
Cell::from(truncate(&p.command, 22)),
])
@@ -378,7 +437,13 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
let (header, widths) = if show_user {
(
Row::new(vec![
"PID", "CPU%", "MEM%", "RSS", "STATE", "USER", "COMMAND",
sort_header("PID", ProcessSort::Pid, state),
sort_header("CPU%", ProcessSort::Cpu, state),
sort_header("MEM%", ProcessSort::Mem, state),
"RSS".to_string(),
"STATE".to_string(),
"USER".to_string(),
"COMMAND".to_string(),
])
.style(
Style::default()
@@ -387,8 +452,8 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
),
vec![
Constraint::Length(6),
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(9),
Constraint::Length(6),
Constraint::Length(8),
@@ -397,15 +462,23 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
)
} else {
(
Row::new(vec!["PID", "CPU%", "MEM%", "RSS", "STATE", "COMMAND"]).style(
Row::new(vec![
sort_header("PID", ProcessSort::Pid, state),
sort_header("CPU%", ProcessSort::Cpu, state),
sort_header("MEM%", ProcessSort::Mem, state),
"RSS".to_string(),
"STATE".to_string(),
"COMMAND".to_string(),
])
.style(
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
),
vec![
Constraint::Length(6),
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(9),
Constraint::Length(6),
Constraint::Min(10),
@@ -420,14 +493,75 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
frame.render_widget(table, inner);
}
fn draw_process_details(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::default().title("Details").borders(Borders::ALL);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 10 || inner.height < 2 {
return;
}
let Some(process) = selected_process(state) else {
frame.render_widget(Paragraph::new("no process selected"), inner);
return;
};
let lines = vec![
Line::from(format!(
"pid {} ppid {}",
process.pid,
process
.ppid
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string())
)),
Line::from(format!(
"user {} uid {}",
process.user.as_deref().unwrap_or("-"),
process.uid.as_deref().unwrap_or("-")
)),
Line::from(format!(
"thr {} nice {} cpu {}",
process
.threads
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string()),
process
.nice
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string()),
process
.cpu_time_seconds
.map(format_uptime)
.unwrap_or_else(|| "-".to_string())
)),
Line::from(format!(
"rss {} vms {}",
fmt_bytes_compact(process.mem_bytes),
fmt_bytes_compact(process.vms_bytes)
)),
Line::from("cmd:"),
Line::from(process.command.clone()),
];
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: false }),
inner,
);
}
fn draw_footer(frame: &mut Frame, area: Rect, state: &AppState) {
let focus = if state.focus == FocusPane::ProcessTable {
"focus:proc"
} else {
"focus:summary"
};
let help = "q quit | p/space pause | +/- interval | Tab focus | c/m/p sort | arrows/PgUp/PgDn";
let line = format!("{help} | {focus}");
let line = format!(
"q quit | p/space pause | +/- interval | Tab focus | c/m/p sort | r reverse | d details | f fs-filter | {focus}"
);
frame.render_widget(
Paragraph::new(line)
.alignment(Alignment::Left)
@@ -436,6 +570,44 @@ fn draw_footer(frame: &mut Frame, area: Rect, state: &AppState) {
);
}
fn selected_process(state: &AppState) -> Option<&ProcessSample> {
state.snapshot.processes.get(
state
.selected_process
.min(state.snapshot.processes.len().saturating_sub(1)),
)
}
fn sort_key_label(sort: ProcessSort) -> &'static str {
match sort {
ProcessSort::Cpu => "CPU",
ProcessSort::Mem => "MEM",
ProcessSort::Pid => "PID",
}
}
fn sort_header(name: &str, col: ProcessSort, state: &AppState) -> String {
if state.process_sort == col {
format!("{name}{}", sort_indicator(state.process_sort_order))
} else {
name.to_string()
}
}
fn compact_network_line(state: &AppState) -> String {
format!(
"RX {}/s | TX {}/s | tot {}/{}",
fmt_bytes_compact(state.network_rate.rx_bps as u64),
fmt_bytes_compact(state.network_rate.tx_bps as u64),
fmt_bytes_compact(state.snapshot.network_totals.rx_bytes),
fmt_bytes_compact(state.snapshot.network_totals.tx_bytes)
)
}
fn is_noisy_pseudo_fs(fs_type: &str) -> bool {
matches!(fs_type, "tmpfs" | "devtmpfs" | "overlay")
}
fn percent(used: u64, total: u64) -> f64 {
if total == 0 {
return 0.0;
@@ -443,10 +615,10 @@ fn percent(used: u64, total: u64) -> f64 {
((used as f64 / total as f64) * 100.0).clamp(0.0, 100.0)
}
fn fmt_bytes(bytes: u64) -> String {
fn fmt_bytes_compact(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
if bytes == 0 {
return "0 B".to_string();
return "0B".to_string();
}
let mut value = bytes as f64;
@@ -457,9 +629,9 @@ fn fmt_bytes(bytes: u64) -> String {
}
if idx == 0 {
format!("{} {}", bytes, UNITS[idx])
format!("{}{}", bytes, UNITS[idx])
} else {
format!("{value:.1} {}", UNITS[idx])
format!("{value:.1}{}", UNITS[idx])
}
}
@@ -484,3 +656,46 @@ fn truncate(s: &str, max: usize) -> String {
out.push('~');
out
}
fn format_core_mini(index: usize, pct: f32, width: usize) -> String {
let bars = mini_bar(pct, 6);
let segment = format!("cpu{:02} {} {:>4.1}%", index, bars, pct);
format!("{segment:<width$}", width = width)
}
fn mini_bar(pct: f32, width: usize) -> String {
let filled = ((pct.clamp(0.0, 100.0) / 100.0) * width as f32).round() as usize;
let mut out = String::with_capacity(width);
for i in 0..width {
if i < filled {
out.push('█');
} else {
out.push('░');
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mini_bar_shapes() {
assert_eq!(mini_bar(0.0, 4), "░░░░");
assert_eq!(mini_bar(100.0, 4), "████");
}
#[test]
fn compact_bytes_has_no_space() {
assert_eq!(fmt_bytes_compact(0), "0B");
assert!(fmt_bytes_compact(1024).ends_with("KiB"));
}
#[test]
fn noisy_fs_filter_matches_expected() {
assert!(is_noisy_pseudo_fs("tmpfs"));
assert!(is_noisy_pseudo_fs("overlay"));
assert!(!is_noisy_pseudo_fs("ext4"));
}
}