use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use super::Render;
use super::filter_bar;
use super::sanitize::scrub_ctrl;
use super::widgets::badge;
use super::widgets::columns::{Align, Column, PRIO_ESSENTIAL, PRIO_HIGH, PRIO_LOW, PRIO_MEDIUM};
use super::widgets::empty::{self, EmptyState};
use super::widgets::meter;
use super::widgets::table::{self, Cell, Row, Spec};
use crate::ui::theme::Level;
use muxtop_core::process::{ProcessInfo, SortField, SortOrder};
const COL_PID: usize = 0;
const COL_USER: usize = 1;
const COL_CPU: usize = 3;
const COL_MEM: usize = 4;
const COL_COMMAND: usize = 6;
const COLUMNS: &[Column] = &[
Column::fixed("PID", 7, Align::Right, PRIO_ESSENTIAL),
Column::fixed("USER", 10, Align::Left, PRIO_MEDIUM),
Column::fixed("S", 2, Align::Left, PRIO_HIGH),
Column::fixed("CPU%", 7, Align::Right, PRIO_HIGH),
Column::fixed("MEM%", 7, Align::Right, PRIO_HIGH),
Column::fixed("RSS", 8, Align::Right, PRIO_LOW),
Column::flex("COMMAND", 12, PRIO_ESSENTIAL),
];
pub fn draw_processes_tab(frame: &mut Frame, area: Rect, r: &Render<'_>) {
let app = r.app;
if app.last_snapshot.is_none() {
let waiting = r.ellipsis("Waiting for data");
empty::render(frame, area, &EmptyState::waiting(&waiting), r.theme);
return;
}
let filter_h = u16::from(app.filter_editing());
let [table_area, filter_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(filter_h)]).areas(area);
let filtered = !app.filter_text().is_empty();
let empty_state = if filtered {
EmptyState::no_match("No matching processes")
} else {
EmptyState::empty("No processes", None)
};
let spec = Spec {
columns: COLUMNS,
sort_col: sort_column(app.sort_field),
descending: matches!(app.sort_order, SortOrder::Desc),
total: app.process_count(),
selected: app.selected,
scroll: app.scroll_offset,
col_scroll: app.col_scroll,
empty: empty_state,
};
let tree = app.tree_mode;
table::draw(frame, table_area, r, &spec, |idx| {
if tree {
let entries = &app.visible_tree;
match entries.get(idx) {
Some((proc, _)) => {
let prefix = tree_prefix(entries, idx, r);
process_row(proc, Some(&prefix), r)
}
None => Row::new(Vec::new()),
}
} else {
match app.visible_processes.get(idx) {
Some(proc) => process_row(proc, None, r),
None => Row::new(Vec::new()),
}
}
});
if filter_h > 0 {
filter_bar::draw(frame, filter_area, r, "Filter processes");
}
}
fn sort_column(field: SortField) -> Option<usize> {
Some(match field {
SortField::Pid => COL_PID,
SortField::User => COL_USER,
SortField::Cpu => COL_CPU,
SortField::Mem => COL_MEM,
SortField::Name => COL_COMMAND,
})
}
fn process_row(proc: &ProcessInfo, tree_prefix: Option<&str>, r: &Render<'_>) -> Row {
let command = scrub_ctrl(&proc.command);
let command = match tree_prefix {
Some(prefix) => format!("{prefix}{command}"),
None => command.into_owned(),
};
let level = state_level(&proc.status);
let theme = r.theme;
Row::new(vec![
Cell::new(proc.pid.to_string()),
Cell::new(scrub_ctrl(&proc.user).into_owned()),
Cell::colored(badge::marker(level, r.glyphs), theme.level_color(level)),
Cell::colored(
format!("{:.1}", proc.cpu_percent),
theme.gauge_color(f64::from(proc.cpu_percent)),
),
Cell::new(format!("{:.1}", proc.memory_percent)),
Cell::new(meter::human_bytes(proc.memory_bytes)),
Cell::new(command),
])
}
fn state_level(status: &str) -> Level {
match status {
"Running" => Level::Success,
"Sleeping" | "Idle" => Level::Neutral,
"Zombie" => Level::Error,
"Stopped" => Level::Warning,
_ => Level::Info,
}
}
fn tree_prefix(entries: &[(ProcessInfo, usize)], idx: usize, r: &Render<'_>) -> String {
let depth = entries[idx].1;
if depth == 0 {
return String::new();
}
let g = r.glyphs;
let mut prefix = String::with_capacity(depth * 4);
for level in 1..depth {
if ancestor_has_more_siblings(entries, idx, level) {
prefix.push_str(g.tree_pipe);
} else {
prefix.push_str(g.tree_gap);
}
}
prefix.push_str(if is_last_at_depth(entries, idx) {
g.tree_last
} else {
g.tree_branch
});
prefix
}
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
}
fn ancestor_has_more_siblings(entries: &[(ProcessInfo, usize)], idx: usize, level: usize) -> bool {
for (_, d) in &entries[idx + 1..] {
if *d < level {
return false;
}
if *d == level {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{AppState, Tab};
use crate::terminal::ColorSupport;
use crate::ui::test_support::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn app() -> AppState {
let mut app = app_with_data();
app.tab = Tab::Processes;
app
}
#[test]
fn table_renders_columns_and_rows() {
let text = all_text(&render_with(&app(), 120, 24));
assert!(text.contains("PID"));
assert!(text.contains("COMMAND"));
assert!(text.contains("proc0"));
}
#[test]
fn rss_column_shows_absolute_memory() {
let text = all_text(&render_with(&app(), 140, 24));
assert!(text.contains("RSS"));
}
#[test]
fn narrow_terminal_keeps_the_command_column() {
let text = all_text(&render_with(&app(), 60, 24));
assert!(
text.contains("COMMAND"),
"the identity column must survive:\n{text}"
);
}
#[test]
fn sort_indicator_follows_the_sort_field() {
let mut app = app();
app.sort_field = SortField::Mem;
app.sort_order = SortOrder::Desc;
let text = all_text(&render_with(&app, 140, 24));
assert!(text.contains("MEM%▼"), "sort marker missing:\n{text}");
}
#[test]
fn empty_filter_result_explains_itself() {
let mut app = app();
app.set_filter("zzzz-no-such-process");
let text = all_text(&render_with(&app, 100, 24));
assert!(text.contains("No matching processes"));
assert!(text.contains("Esc"), "a dead end must offer a way out");
}
#[test]
fn filter_bar_appears_only_while_editing() {
let mut app = app();
assert!(!all_text(&render_with(&app, 100, 24)).contains("Filter processes"));
app.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert!(all_text(&render_with(&app, 100, 24)).contains("Filter processes"));
}
#[test]
fn tree_view_draws_connectors() {
let mut app = app();
app.tree_mode = true;
app.recompute_visible();
let text = all_text(&render_with(&app, 120, 30));
assert!(
text.contains('├') || text.contains('└'),
"tree connectors missing:\n{text}"
);
}
#[test]
fn tree_view_uses_ascii_connectors_without_unicode() {
let mut app = app();
app.tree_mode = true;
app.recompute_visible();
let buf = render_caps(&mut app, 120, 30, ColorSupport::Basic, false);
let text = all_text(&buf);
assert!(text.is_ascii());
assert!(text.contains("|--") || text.contains("\\--"));
}
#[test]
fn control_characters_never_reach_the_screen() {
let mut snap = snapshot();
snap.processes.insert(
0,
ProcessInfo {
pid: 66,
parent_pid: None,
name: "evil".to_string(),
command: "/bin/evil\x1b[31m\x07".to_string(),
user: "ro\x1bot".to_string(),
cpu_percent: 99.0,
memory_bytes: 1,
memory_percent: 0.1,
status: "Running".to_string(),
},
);
let mut app = AppState::new();
app.tab = Tab::Processes;
app.apply_snapshot(snap);
let text = all_text(&render_with(&app, 120, 24));
assert!(!text.contains('\x1b'));
assert!(!text.contains('\x07'));
}
#[test]
fn state_levels_distinguish_the_states_that_matter() {
assert_eq!(state_level("Running"), Level::Success);
assert_eq!(state_level("Zombie"), Level::Error);
assert_eq!(state_level("Stopped"), Level::Warning);
assert_ne!(state_level("Running"), state_level("Sleeping"));
}
}