use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use super::sanitize::scrub_ctrl;
use super::theme::Theme;
use crate::app::AppState;
use muxtop_core::process::{ProcessInfo, SortField, SortOrder};
const COL_PID: usize = 7;
const COL_USER: usize = 10;
const COL_STATUS: usize = 2;
const COL_CPU: usize = 7;
const COL_MEM: usize = 7;
const FIXED_COLS: usize = COL_PID + COL_USER + COL_STATUS + COL_CPU + COL_MEM;
pub fn draw_processes_tab(frame: &mut Frame, area: Rect, app: &AppState, theme: &Theme) {
if app.last_snapshot.is_none() {
let para = Paragraph::new("Waiting for data...").alignment(Alignment::Center);
frame.render_widget(para, area);
return;
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(theme.text_dim));
let inner = block.inner(area);
frame.render_widget(block, area);
let filter_h = u16::from(app.filter_active);
let [table_area, filter_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(filter_h)]).areas(inner);
if table_area.height >= 2 {
let [header_area, body_area] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(table_area);
draw_header(frame, header_area, app, theme);
draw_body(frame, body_area, app, theme);
}
if app.filter_active {
draw_filter_bar(frame, filter_area, app, theme);
}
}
fn draw_header(frame: &mut Frame, area: Rect, app: &AppState, theme: &Theme) {
let arrow = if app.term_caps.unicode {
match app.sort_order {
SortOrder::Desc => "▼",
SortOrder::Asc => "▲",
}
} else {
match app.sort_order {
SortOrder::Desc => "v",
SortOrder::Asc => "^",
}
};
let cmd_w = (area.width as usize).saturating_sub(FIXED_COLS);
let style = Style::default()
.fg(theme.accent_primary)
.bg(theme.header_bg)
.add_modifier(Modifier::BOLD);
let header = format!(
"{}{}{}{}{}{}",
col_text(
&sort_label("PID", SortField::Pid, app.sort_field, arrow),
COL_PID
),
col_text(
&sort_label("USER", SortField::User, app.sort_field, arrow),
COL_USER,
),
col_text("S", COL_STATUS),
col_text(
&sort_label("CPU%", SortField::Cpu, app.sort_field, arrow),
COL_CPU
),
col_text(
&sort_label("MEM%", SortField::Mem, app.sort_field, arrow),
COL_MEM,
),
col_text(
&sort_label("COMMAND", SortField::Name, app.sort_field, arrow),
cmd_w,
),
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(header, style))),
area,
);
}
fn draw_body(frame: &mut Frame, area: Rect, app: &AppState, theme: &Theme) {
let vis_h = area.height as usize;
if vis_h == 0 {
return;
}
let scroll = effective_scroll(app.selected, app.scroll_offset, vis_h);
let cmd_w = (area.width as usize).saturating_sub(FIXED_COLS);
let unicode = app.term_caps.unicode;
let lines: Vec<Line<'static>> = if app.tree_mode {
let entries = &app.visible_tree;
let end = (scroll + vis_h).min(entries.len());
(scroll..end)
.map(|i| {
let pfx = tree_prefix_with_unicode(entries, i, unicode);
process_row(
&entries[i].0,
cmd_w,
i == app.selected,
Some(&pfx),
theme,
unicode,
i,
)
})
.collect()
} else {
let entries = &app.visible_processes;
let end = (scroll + vis_h).min(entries.len());
(scroll..end)
.map(|i| {
process_row(
&entries[i],
cmd_w,
i == app.selected,
None,
theme,
unicode,
i,
)
})
.collect()
};
frame.render_widget(Paragraph::new(lines), area);
}
fn draw_filter_bar(frame: &mut Frame, area: Rect, app: &AppState, theme: &Theme) {
let cursor = if app.term_caps.unicode { "█" } else { "_" };
let line = Line::from(vec![
Span::styled(
"Filter: ",
Style::default()
.fg(theme.accent_primary)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{}{cursor}", app.filter_input),
Style::default().fg(theme.fg),
),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn process_row(
proc: &ProcessInfo,
cmd_w: usize,
selected: bool,
tree_pfx: Option<&str>,
theme: &Theme,
unicode: bool,
row_idx: usize,
) -> Line<'static> {
let (sc, sc_color) = status_style(&proc.status, theme, unicode);
let safe_command = scrub_ctrl(&proc.command);
let safe_user = scrub_ctrl(&proc.user);
let cmd_text = match tree_pfx {
Some(pfx) => format!("{pfx}{safe_command}"),
None => safe_command.into_owned(),
};
let bg = if selected {
theme.selection_bg
} else if row_idx % 2 == 1 {
theme.surface
} else {
theme.bg
};
let fg = if selected {
theme.selection_fg
} else {
theme.fg
};
let base = if selected {
Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD)
} else {
Style::default().bg(bg).fg(fg)
};
let st = if selected {
base.fg(sc_color)
} else {
Style::default().fg(sc_color).bg(bg)
};
Line::from(vec![
Span::styled(format!("{:>6} ", proc.pid), base),
Span::styled(col_text(&safe_user, COL_USER), base),
Span::styled(format!("{sc} "), st),
Span::styled(format!("{:>5.1} ", proc.cpu_percent), base),
Span::styled(format!("{:>5.1} ", proc.memory_percent), base),
Span::styled(col_text(&cmd_text, cmd_w), base),
])
}
fn sort_label(name: &str, field: SortField, active: SortField, arrow: &str) -> String {
if std::mem::discriminant(&field) == std::mem::discriminant(&active) {
format!("{name}{arrow}")
} else {
name.to_string()
}
}
fn col_text(s: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let truncated: String = s.chars().take(width).collect();
format!("{truncated:<width$}")
}
fn effective_scroll(selected: usize, scroll_offset: usize, visible_height: usize) -> usize {
if visible_height == 0 {
return 0;
}
if selected < scroll_offset {
selected
} else if selected >= scroll_offset + visible_height {
selected.saturating_sub(visible_height - 1)
} else {
scroll_offset
}
}
fn status_style(status: &str, theme: &Theme, unicode: bool) -> (char, Color) {
if unicode {
match status {
"Running" => ('●', theme.success),
"Sleeping" | "Idle" => ('○', theme.sleeping),
"Zombie" => ('⚠', theme.danger),
"Stopped" => ('⏸', theme.warning),
_ => ('?', theme.text_dim),
}
} else {
match status {
"Running" => ('R', theme.success),
"Sleeping" | "Idle" => ('S', theme.sleeping),
"Zombie" => ('Z', theme.danger),
"Stopped" => ('T', theme.warning),
_ => ('?', theme.text_dim),
}
}
}
fn is_last_at_depth(entries: &[(ProcessInfo, usize)], idx: usize) -> bool {
let depth = entries[idx].1;
for (_, d) in &entries[idx + 1..] {
if *d < depth {
return true;
}
if *d == depth {
return false;
}
}
true
}
#[cfg(test)]
fn tree_prefix(entries: &[(ProcessInfo, usize)], idx: usize) -> String {
tree_prefix_with_unicode(entries, idx, true)
}
fn tree_prefix_with_unicode(entries: &[(ProcessInfo, usize)], idx: usize, unicode: bool) -> String {
let depth = entries[idx].1;
if depth == 0 {
return String::new();
}
let connector = if is_last_at_depth(entries, idx) {
if unicode { "└── " } else { "\\-- " }
} else if unicode {
"├── "
} else {
"|-- "
};
let indent = " ".repeat(depth.saturating_sub(1));
format!("{indent}{connector}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{AppState, Tab};
use muxtop_core::system::*;
use ratatui::{Terminal, backend::TestBackend};
fn render_with(app: &AppState, width: u16, height: u16) -> ratatui::buffer::Buffer {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| crate::ui::draw_root(frame, app))
.unwrap();
terminal.backend().buffer().clone()
}
fn buffer_line_text(buf: &ratatui::buffer::Buffer, row: u16) -> String {
let width = buf.area.width;
(0..width)
.map(|col| buf.cell((col, row)).map(|c| c.symbol()).unwrap_or(" "))
.collect::<String>()
.trim_end()
.to_string()
}
fn buffer_contains(buf: &ratatui::buffer::Buffer, needle: &str) -> bool {
let height = buf.area.height;
(0..height).any(|row| buffer_line_text(buf, row).contains(needle))
}
fn make_proc(pid: u32, name: &str, cpu: f32, mem_pct: f32) -> ProcessInfo {
ProcessInfo {
pid,
parent_pid: None,
name: name.to_string(),
command: format!("/usr/bin/{name}"),
user: "user".to_string(),
cpu_percent: cpu,
memory_bytes: 1000,
memory_percent: mem_pct,
status: "Running".to_string(),
}
}
fn make_proc_with_parent(pid: u32, ppid: Option<u32>, name: &str) -> ProcessInfo {
ProcessInfo {
pid,
parent_pid: ppid,
name: name.to_string(),
command: format!("/usr/bin/{name}"),
user: "root".to_string(),
cpu_percent: 1.0,
memory_bytes: 1000,
memory_percent: 1.0,
status: "Running".to_string(),
}
}
fn make_snapshot(procs: Vec<ProcessInfo>) -> SystemSnapshot {
SystemSnapshot {
cpu: CpuSnapshot {
global_usage: 25.0,
cores: vec![],
},
memory: MemorySnapshot {
total: 16_000_000_000,
used: 8_000_000_000,
available: 8_000_000_000,
swap_total: 0,
swap_used: 0,
},
load: LoadSnapshot {
one: 1.0,
five: 0.8,
fifteen: 0.5,
uptime_secs: 3600,
},
processes: procs,
networks: muxtop_core::network::NetworkSnapshot {
interfaces: vec![],
total_rx: 0,
total_tx: 0,
},
containers: None,
kube: None,
timestamp_ms: 0,
}
}
fn processes_app(procs: Vec<ProcessInfo>) -> AppState {
let mut app = AppState::new();
app.tab = Tab::Processes;
app.apply_snapshot(make_snapshot(procs));
app
}
#[test]
fn test_effective_scroll_in_view() {
assert_eq!(effective_scroll(5, 0, 10), 0);
assert_eq!(effective_scroll(9, 0, 10), 0);
}
#[test]
fn test_effective_scroll_below_view() {
assert_eq!(effective_scroll(15, 0, 10), 6);
}
#[test]
fn test_effective_scroll_above_view() {
assert_eq!(effective_scroll(2, 10, 10), 2);
}
#[test]
fn test_effective_scroll_zero_height() {
assert_eq!(effective_scroll(5, 0, 0), 0);
}
#[test]
fn test_status_running() {
let theme = super::super::theme::Theme::default();
let (c, color) = status_style("Running", &theme, false);
assert_eq!(c, 'R');
assert_eq!(color, theme.success);
}
#[test]
fn test_status_sleeping() {
let theme = super::super::theme::Theme::default();
let (c, _) = status_style("Sleeping", &theme, false);
assert_eq!(c, 'S');
}
#[test]
fn test_status_zombie() {
let theme = super::super::theme::Theme::default();
let (c, color) = status_style("Zombie", &theme, false);
assert_eq!(c, 'Z');
assert_eq!(color, theme.danger);
}
#[test]
fn test_status_stopped() {
let theme = super::super::theme::Theme::default();
let (c, color) = status_style("Stopped", &theme, false);
assert_eq!(c, 'T');
assert_eq!(color, theme.warning);
}
#[test]
fn test_status_unknown() {
let theme = super::super::theme::Theme::default();
let (c, _) = status_style("SomethingElse", &theme, false);
assert_eq!(c, '?');
}
#[test]
fn test_col_text_pad() {
assert_eq!(col_text("hi", 5), "hi ");
}
#[test]
fn test_col_text_truncate() {
assert_eq!(col_text("hello world", 5), "hello");
}
#[test]
fn test_col_text_exact() {
assert_eq!(col_text("abcde", 5), "abcde");
}
#[test]
fn test_col_text_zero_width() {
assert_eq!(col_text("anything", 0), "");
}
#[test]
fn test_sort_label_active() {
let label = sort_label("CPU%", SortField::Cpu, SortField::Cpu, "▼");
assert_eq!(label, "CPU%▼");
}
#[test]
fn test_sort_label_inactive() {
let label = sort_label("CPU%", SortField::Cpu, SortField::Mem, "▼");
assert_eq!(label, "CPU%");
}
#[test]
fn test_tree_prefix_root() {
let entries = vec![(make_proc(1, "init", 1.0, 1.0), 0)];
assert_eq!(tree_prefix(&entries, 0), "");
}
#[test]
fn test_tree_prefix_last_child() {
let entries = vec![
(make_proc(1, "init", 1.0, 1.0), 0),
(make_proc(2, "child", 1.0, 1.0), 1),
];
assert_eq!(tree_prefix(&entries, 1), "└── ");
}
#[test]
fn test_tree_prefix_non_last_child() {
let entries = vec![
(make_proc(1, "init", 1.0, 1.0), 0),
(make_proc(2, "child1", 1.0, 1.0), 1),
(make_proc(3, "child2", 1.0, 1.0), 1),
];
assert_eq!(tree_prefix(&entries, 1), "├── ");
assert_eq!(tree_prefix(&entries, 2), "└── ");
}
#[test]
fn test_tree_prefix_grandchild() {
let entries = vec![
(make_proc(1, "init", 1.0, 1.0), 0),
(make_proc(2, "child", 1.0, 1.0), 1),
(make_proc(3, "grandchild", 1.0, 1.0), 2),
];
assert_eq!(tree_prefix(&entries, 2), " └── ");
}
#[test]
fn test_is_last_at_depth_single() {
let entries = vec![
(make_proc(1, "init", 1.0, 1.0), 0),
(make_proc(2, "child", 1.0, 1.0), 1),
];
assert!(is_last_at_depth(&entries, 1));
}
#[test]
fn test_is_last_at_depth_with_sibling() {
let entries = vec![
(make_proc(1, "init", 1.0, 1.0), 0),
(make_proc(2, "child1", 1.0, 1.0), 1),
(make_proc(3, "child2", 1.0, 1.0), 1),
];
assert!(!is_last_at_depth(&entries, 1));
assert!(is_last_at_depth(&entries, 2));
}
#[test]
fn test_header_shows_columns() {
let app = processes_app(vec![make_proc(1, "test", 10.0, 5.0)]);
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "PID"));
assert!(buffer_contains(&buf, "USER"));
assert!(buffer_contains(&buf, "CPU%"));
assert!(buffer_contains(&buf, "MEM%"));
assert!(buffer_contains(&buf, "COMMAND"));
}
#[test]
fn test_rows_show_process_data() {
let app = processes_app(vec![make_proc(1234, "firefox", 25.3, 10.2)]);
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "1234"));
assert!(buffer_contains(&buf, "user"));
assert!(buffer_contains(&buf, "25.3"));
}
#[test]
fn test_selected_row_visible() {
let mut app = processes_app(vec![
make_proc(1, "alpha", 90.0, 5.0),
make_proc(2, "bravo", 50.0, 3.0),
make_proc(3, "charlie", 10.0, 1.0),
]);
app.selected = 1;
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "bravo"));
}
#[test]
fn test_selected_row_has_background() {
let mut app = processes_app(vec![
make_proc(1, "alpha", 90.0, 5.0),
make_proc(2, "bravo", 50.0, 3.0),
]);
app.selected = 0;
let buf = render_with(&app, 80, 24);
let cell = buf.cell((2, 5)).unwrap(); let theme = super::super::theme::Theme::default();
assert_eq!(
cell.bg, theme.selection_bg,
"Selected row should have selection background"
);
}
#[test]
fn test_sort_indicator_desc() {
let app = processes_app(vec![make_proc(1, "test", 10.0, 5.0)]);
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "▼"));
}
#[test]
fn test_sort_indicator_asc() {
let mut app = processes_app(vec![make_proc(1, "test", 10.0, 5.0)]);
app.sort_order = SortOrder::Asc;
app.recompute_visible();
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "▲"));
}
#[test]
fn test_no_data_shows_waiting() {
let mut app = AppState::new();
app.tab = Tab::Processes;
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "Waiting for data..."));
}
#[test]
fn test_scroll_follows_selection() {
let procs: Vec<ProcessInfo> = (0..100)
.map(|i| make_proc(i, &format!("proc{i}"), i as f32, 1.0))
.collect();
let mut app = processes_app(procs);
app.selected = 50;
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "proc50"));
}
#[test]
fn test_filter_bar_shown() {
let mut app = processes_app(vec![make_proc(1, "test", 10.0, 5.0)]);
app.filter_active = true;
app.filter_input = "fire".to_string();
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "Filter:"));
assert!(buffer_contains(&buf, "fire"));
}
#[test]
fn test_filter_bar_hidden_when_inactive() {
let app = processes_app(vec![make_proc(1, "test", 10.0, 5.0)]);
let buf = render_with(&app, 80, 24);
assert!(!buffer_contains(&buf, "Filter:"));
}
#[test]
fn test_filter_reduces_visible() {
let mut app = processes_app(vec![
make_proc(1, "firefox", 10.0, 5.0),
make_proc(2, "chrome", 20.0, 8.0),
]);
app.filter_input = "fire".to_string();
app.recompute_visible();
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "firefox"));
assert!(!buffer_contains(&buf, "chrome"));
}
#[test]
fn test_tree_mode_shows_connectors() {
let mut app = AppState::new();
app.tab = Tab::Processes;
let snap = make_snapshot(vec![
make_proc_with_parent(1, Some(0), "init"),
make_proc_with_parent(10, Some(1), "sshd"),
make_proc_with_parent(100, Some(10), "bash"),
]);
app.apply_snapshot(snap);
app.tree_mode = true;
app.recompute_visible();
let buf = render_with(&app, 80, 24);
assert!(
buffer_contains(&buf, "└──") || buffer_contains(&buf, "├──"),
"Tree mode should show connectors"
);
}
#[test]
fn test_tree_depth_indentation() {
let mut app = AppState::new();
app.tab = Tab::Processes;
let snap = make_snapshot(vec![
make_proc_with_parent(1, Some(0), "init"),
make_proc_with_parent(10, Some(1), "child"),
make_proc_with_parent(100, Some(10), "grandchild"),
]);
app.apply_snapshot(snap);
app.tree_mode = true;
app.recompute_visible();
let buf = render_with(&app, 100, 24);
assert!(buffer_contains(&buf, "grandchild"));
}
#[test]
fn test_minimal_terminal_no_panic() {
let app = processes_app(vec![make_proc(1, "test", 10.0, 5.0)]);
let _buf = render_with(&app, 40, 6);
}
#[test]
fn test_extreme_terminal_1x1() {
let app = processes_app(vec![make_proc(1, "test", 10.0, 5.0)]);
let _buf = render_with(&app, 1, 1);
}
#[test]
fn test_empty_process_list() {
let app = processes_app(vec![]);
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "PID"));
}
#[test]
fn test_large_terminal() {
let app = processes_app(vec![make_proc(1, "test", 10.0, 5.0)]);
let _buf = render_with(&app, 200, 50);
}
#[test]
fn test_many_processes_virtual_scroll() {
let procs: Vec<ProcessInfo> = (0..500)
.map(|i| make_proc(i, &format!("proc{i}"), 1.0, 0.1))
.collect();
let app = processes_app(procs);
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "PID"));
}
#[test]
fn test_status_colors_in_row() {
let mut proc = make_proc(1, "zombie_proc", 0.0, 0.0);
proc.status = "Zombie".to_string();
let app = processes_app(vec![proc]);
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "⚠") || buffer_contains(&buf, "Z"));
}
}