use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style, Stylize},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use crate::app::{App, ProcSort, Snapshot};
use crate::collect::ProcTick;
use crate::ui::{
palette as p,
widgets::{human_bytes, human_rate, panel},
};
pub fn draw(f: &mut Frame, area: Rect, app: &App, snap: &Snapshot) {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(7), ])
.split(area);
draw_sort_strip(f, v[0], app, snap);
let view = filtered_sorted(
&snap.procs,
app.proc_sort,
app.proc_filter_active.as_deref(),
);
draw_table(f, v[1], app, &view);
draw_drill_in(f, v[2], &view, app.proc_sel);
}
pub(crate) fn filtered_sorted(
procs: &[ProcTick],
key: ProcSort,
filter: Option<&str>,
) -> Vec<ProcTick> {
let needle = filter.map(|s| s.to_lowercase());
let mut out: Vec<ProcTick> = procs
.iter()
.filter(|p| match needle.as_deref() {
None => true,
Some(n) => {
p.name.to_lowercase().contains(n)
|| p.cmd.to_lowercase().contains(n)
|| p.user.to_lowercase().contains(n)
}
})
.cloned()
.collect();
sort_in_place(&mut out, key);
out
}
fn sort_in_place(out: &mut [ProcTick], key: ProcSort) {
match key {
ProcSort::Cpu => out.sort_by(|a, b| {
b.cpu_pct
.partial_cmp(&a.cpu_pct)
.unwrap_or(std::cmp::Ordering::Equal)
}),
ProcSort::Rss => out.sort_by(|a, b| b.mem_rss.cmp(&a.mem_rss)),
ProcSort::Io => out.sort_by(|a, b| {
b.io_rate
.partial_cmp(&a.io_rate)
.unwrap_or(std::cmp::Ordering::Equal)
}),
ProcSort::Start => out.sort_by(|a, b| b.start_time.cmp(&a.start_time)),
ProcSort::Name => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
}
}
fn draw_sort_strip(f: &mut Frame, area: Rect, app: &App, snap: &Snapshot) {
if app.proc_filter_input {
let line = Line::from(vec![
Span::styled(" / ", Style::default().fg(p::brand()).bold()),
Span::styled(
app.proc_filter_buf.clone(),
Style::default().fg(p::text_primary()),
),
Span::styled(
"▏",
Style::default().fg(p::brand()).add_modifier(Modifier::BOLD),
),
Span::styled(
" Enter:apply Esc:cancel",
Style::default().fg(p::text_muted()),
),
]);
f.render_widget(
Paragraph::new(line).style(Style::default().bg(p::bg())),
area,
);
return;
}
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(" sort ", Style::default().fg(p::text_muted())));
for s in ProcSort::ALL.iter() {
let active = *s == app.proc_sort;
let label = format!(" {} ", s.label());
if active {
spans.push(Span::styled(
label,
Style::default()
.fg(p::brand())
.bg(p::selection_bg())
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled("\u{25BC} ", Style::default().fg(p::brand())));
} else {
spans.push(Span::styled(label, Style::default().fg(p::text_primary())));
spans.push(Span::raw(" "));
}
}
let count_text = if let Some(f) = app.proc_filter_active.as_deref() {
let visible = filtered_sorted(&snap.procs, app.proc_sort, Some(f)).len();
format!(
" {}/{} procs filter: \"{}\" /:edit s:sort ↑↓:select",
visible,
snap.procs.len(),
f
)
} else {
format!(
" {} procs /:filter s:sort ↑↓:select",
snap.procs.len()
)
};
spans.push(Span::styled(
count_text,
Style::default().fg(p::text_muted()),
));
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(p::bg())),
area,
);
}
fn draw_table(f: &mut Frame, area: Rect, app: &App, procs: &[ProcTick]) {
let block = panel("PROCESSES");
let inner = block.inner(area);
f.render_widget(block, area);
let show_net = procs.iter().any(|p| p.net_rx_rate.is_some());
let header = if show_net {
Line::from(vec![
Span::styled(format!("{:>7} ", "PID"), header_style()),
Span::styled(format!("{:>7} ", "PPID"), header_style()),
Span::styled(format!("{:<14} ", "USER"), header_style()),
Span::styled(format!("{:>6} ", "%CPU"), header_style()),
Span::styled(format!("{:>9} ", "RSS"), header_style()),
Span::styled(format!("{:<5} ", "STATE"), header_style()),
Span::styled(format!("{:>11} ", "IO/s"), header_style()),
Span::styled(format!("{:>10} ", "NET ↓/s"), header_style()),
Span::styled(format!("{:>10} ", "NET ↑/s"), header_style()),
Span::styled("COMMAND", header_style()),
])
} else {
Line::from(vec![
Span::styled(format!("{:>7} ", "PID"), header_style()),
Span::styled(format!("{:>7} ", "PPID"), header_style()),
Span::styled(format!("{:<14} ", "USER"), header_style()),
Span::styled(format!("{:>6} ", "%CPU"), header_style()),
Span::styled(format!("{:>9} ", "RSS"), header_style()),
Span::styled(format!("{:>9} ", "VIRT"), header_style()),
Span::styled(format!("{:<5} ", "STATE"), header_style()),
Span::styled(format!("{:>11} ", "IO/s"), header_style()),
Span::styled("COMMAND", header_style()),
])
};
let take = inner.height.saturating_sub(1) as usize;
let sel_clamped = app.proc_sel.min(procs.len().saturating_sub(1));
let start = sel_clamped.saturating_sub(take.saturating_sub(1));
let end = (start + take).min(procs.len());
let mut lines = vec![header];
for (i, proc_) in procs[start..end].iter().enumerate() {
let abs = start + i;
let selected = abs == sel_clamped;
let row_bg = if selected { p::selection_bg() } else { p::bg() };
let dot_color = if proc_.cpu_pct >= 30.0 {
p::status_warn()
} else if matches!(proc_.state, 'R') {
p::status_good()
} else if matches!(proc_.state, 'Z') {
p::status_error()
} else {
p::border()
};
let cpu_color = if proc_.cpu_pct >= 30.0 {
p::status_warn()
} else {
p::text_primary()
};
let state_color = match proc_.state {
'R' => p::status_good(),
'S' | 'I' => p::text_primary(),
'Z' => p::status_error(),
_ => p::text_muted(),
};
let mut spans: Vec<Span> = vec![
Span::styled(
format!("{:>7} ", proc_.pid),
Style::default().fg(p::text_primary()).bg(row_bg),
),
Span::styled(
format!("{:>7} ", proc_.ppid),
Style::default().fg(p::text_muted()).bg(row_bg),
),
Span::styled(
format!("{:<14.14} ", proc_.user),
Style::default().fg(p::text_muted()).bg(row_bg),
),
Span::styled(
format!("{:>5.1} ", proc_.cpu_pct),
Style::default().fg(cpu_color).bg(row_bg),
),
Span::styled(
format!("{:>9} ", human_bytes(proc_.mem_rss)),
Style::default().fg(p::text_primary()).bg(row_bg),
),
];
if !show_net {
spans.push(Span::styled(
format!("{:>9} ", human_bytes(proc_.mem_virt)),
Style::default().fg(p::text_muted()).bg(row_bg),
));
}
spans.push(Span::styled(
format!(" {:<4} ", proc_.state),
Style::default()
.fg(state_color)
.bg(row_bg)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!("{:>11} ", human_rate(proc_.io_rate)),
Style::default()
.fg(if proc_.io_rate > 0.0 {
p::brand()
} else {
p::text_muted()
})
.bg(row_bg),
));
if show_net {
let rx = proc_.net_rx_rate.unwrap_or(0.0);
let tx = proc_.net_tx_rate.unwrap_or(0.0);
spans.push(Span::styled(
format!("{:>10} ", human_rate(rx)),
Style::default()
.fg(if rx > 0.0 {
p::status_good()
} else {
p::text_muted()
})
.bg(row_bg),
));
spans.push(Span::styled(
format!("{:>10} ", human_rate(tx)),
Style::default()
.fg(if tx > 0.0 {
p::tx_rate()
} else {
p::text_muted()
})
.bg(row_bg),
));
}
spans.push(Span::styled(
proc_.name.clone(),
Style::default().fg(p::text_primary()).bg(row_bg),
));
spans.push(Span::styled(
fill(inner.width as usize, &proc_.name, show_net),
Style::default().bg(row_bg),
));
let _ = dot_color;
lines.push(Line::from(spans));
}
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(p::bg())),
inner,
);
}
fn draw_drill_in(f: &mut Frame, area: Rect, procs: &[ProcTick], sel: usize) {
let Some(p_) = procs.get(sel.min(procs.len().saturating_sub(1))) else {
let block = panel("DRILL-IN");
f.render_widget(block, area);
return;
};
let block = panel(&format!("{} pid {} - drill-in", p_.name, p_.pid));
let inner = block.inner(area);
f.render_widget(block, area);
let cmd = if p_.cmd.is_empty() {
p_.name.clone()
} else {
p_.cmd.clone()
};
let started = p_
.start_time
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| {
chrono::DateTime::<chrono::Local>::from(
std::time::UNIX_EPOCH + std::time::Duration::from_secs(d.as_secs()),
)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|| "?".into());
let lines = vec![
kv("cmd", cmd, p::text_primary()),
kv("ppid", p_.ppid.to_string(), p::text_primary()),
kv("user", p_.user.clone(), p::text_primary()),
kv(
"rss / virt",
format!("{} / {}", human_bytes(p_.mem_rss), human_bytes(p_.mem_virt)),
p::text_primary(),
),
kv("cpu", format!("{:.1}%", p_.cpu_pct), p::text_primary()),
kv(
"io rate",
human_rate(p_.io_rate),
if p_.io_rate > 0.0 {
p::brand()
} else {
p::text_muted()
},
),
kv("started", started, p::text_muted()),
];
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(p::bg())),
inner,
);
}
fn kv(k: &str, v: String, val_color: ratatui::style::Color) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{:<11} ", k), Style::default().fg(p::text_muted())),
Span::styled(v, Style::default().fg(val_color)),
])
}
#[cfg(test)]
fn sort_procs(procs: &[ProcTick], key: ProcSort) -> Vec<ProcTick> {
filtered_sorted(procs, key, None)
}
fn fill(width: usize, used: &str, show_net: bool) -> String {
let base = 7 + 1 + 7 + 1 + 14 + 1 + 5 + 1 + 9 + 1 + 5 + 1 + 11 + 1;
let used_w = base + used.chars().count() + if show_net { 10 + 1 + 10 + 1 } else { 9 + 1 };
if width > used_w {
std::iter::repeat(' ').take(width - used_w).collect()
} else {
String::new()
}
}
fn header_style() -> Style {
Style::default()
.fg(p::text_muted())
.add_modifier(Modifier::BOLD)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, SystemTime};
fn p(pid: u32, name: &str, cpu: f32, rss: u64, io: f64, secs: u64) -> ProcTick {
ProcTick {
pid,
ppid: 1,
user: "u".into(),
name: name.into(),
cmd: name.into(),
cpu_pct: cpu,
mem_rss: rss,
mem_virt: 0,
threads: 1,
state: 'S',
start_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs)),
io_rate: io,
net_rx_rate: None,
net_tx_rate: None,
}
}
fn names(v: &[ProcTick]) -> Vec<&str> {
v.iter().map(|p| p.name.as_str()).collect()
}
fn fixture() -> Vec<ProcTick> {
vec![
p(1, "alpha", 5.0, 100, 10.0, 1000),
p(2, "Bravo", 90.0, 50, 5000.0, 2000),
p(3, "charlie", 30.0, 9999, 0.0, 500),
p(4, "delta", 0.5, 200, 20.0, 3000), ]
}
#[test]
fn sort_by_cpu_descending() {
let s = sort_procs(&fixture(), ProcSort::Cpu);
assert_eq!(names(&s), vec!["Bravo", "charlie", "alpha", "delta"]);
}
#[test]
fn sort_by_rss_descending() {
let s = sort_procs(&fixture(), ProcSort::Rss);
assert_eq!(names(&s), vec!["charlie", "delta", "alpha", "Bravo"]);
}
#[test]
fn sort_by_io_descending() {
let s = sort_procs(&fixture(), ProcSort::Io);
assert_eq!(names(&s), vec!["Bravo", "delta", "alpha", "charlie"]);
}
#[test]
fn sort_by_start_newest_first() {
let s = sort_procs(&fixture(), ProcSort::Start);
assert_eq!(names(&s), vec!["delta", "Bravo", "alpha", "charlie"]);
}
#[test]
fn sort_by_name_case_insensitive_ascending() {
let s = sort_procs(&fixture(), ProcSort::Name);
assert_eq!(names(&s), vec!["alpha", "Bravo", "charlie", "delta"]);
}
#[test]
fn sort_empty_is_empty() {
assert!(sort_procs(&[], ProcSort::Cpu).is_empty());
assert!(sort_procs(&[], ProcSort::Name).is_empty());
}
#[test]
fn sort_does_not_mutate_input() {
let input = fixture();
let original_first = input[0].name.clone();
let _ = sort_procs(&input, ProcSort::Cpu);
assert_eq!(input[0].name, original_first);
}
#[test]
fn filter_none_returns_full_sorted_list() {
let s = filtered_sorted(&fixture(), ProcSort::Cpu, None);
assert_eq!(s.len(), 4);
}
#[test]
fn filter_substring_is_case_insensitive() {
let a = filtered_sorted(&fixture(), ProcSort::Cpu, Some("BRAV"));
assert_eq!(names(&a), vec!["Bravo"]);
let b = filtered_sorted(&fixture(), ProcSort::Cpu, Some("Char"));
assert_eq!(names(&b), vec!["charlie"]);
}
#[test]
fn filter_no_match_returns_empty() {
let s = filtered_sorted(&fixture(), ProcSort::Cpu, Some("zzz_no_proc"));
assert!(s.is_empty());
}
#[test]
fn filter_then_sort_preserves_sort_order() {
let s = filtered_sorted(&fixture(), ProcSort::Cpu, Some("a"));
assert_eq!(names(&s), vec!["Bravo", "charlie", "alpha", "delta"]);
}
#[test]
fn filter_matches_user_field() {
let mut procs = fixture();
procs[0].user = "deploy".into();
let s = filtered_sorted(&procs, ProcSort::Cpu, Some("deploy"));
assert_eq!(names(&s), vec!["alpha"]);
}
#[test]
fn filter_matches_cmd_field() {
let mut procs = fixture();
procs[1].cmd = "/usr/bin/some-binary --flag".into();
let s = filtered_sorted(&procs, ProcSort::Cpu, Some("--flag"));
assert_eq!(names(&s), vec!["Bravo"]);
}
}