use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::style::Modifier;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
};
use std::cmp::Ordering;
use crate::types::Metrics;
use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse};
use crate::ui::fit;
use crate::ui::theme::{
PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW,
SB_THUMB, SB_TRACK,
};
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
let mut haystack_chars = haystack.chars().map(|c| c.to_ascii_lowercase());
for needle_char in needle.chars().map(|c| c.to_ascii_lowercase()) {
if !haystack_chars.any(|c| c == needle_char) {
return false;
}
}
true
}
pub fn fill_filtered_sorted_indices(
metrics: &Metrics,
search_query: &str,
sort_by: ProcSortBy,
out: &mut Vec<usize>,
) {
out.clear();
out.reserve(metrics.top_processes.len());
if search_query.is_empty() {
out.extend(0..metrics.top_processes.len());
} else {
out.extend(
(0..metrics.top_processes.len())
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)),
);
}
match sort_by {
ProcSortBy::CpuDesc => out.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].cpu_usage;
let bb = metrics.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
}),
ProcSortBy::MemDesc => out.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].mem_bytes;
let bb = metrics.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
}
pub struct ProcessDisplayParams<'a> {
pub metrics: Option<&'a Metrics>,
pub scroll_offset: usize,
pub sort_by: ProcSortBy,
pub selected_process_pid: Option<u32>,
pub selected_process_index: Option<usize>,
pub search_query: &'a str,
pub search_active: bool,
pub filtered_indices: &'a [usize],
pub cached_rows: &'a [CachedRow],
pub peak_cpu: f32,
pub is_local: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProcSortBy {
#[default]
CpuDesc,
MemDesc,
}
#[derive(Debug, Clone)]
pub struct CachedRow {
pub pid_str: String,
pub cpu_str: String,
pub mem_str: String,
pub mem_pct_str: String,
pub mem_pct: f64,
pub cpu_val: f32,
}
pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec<CachedRow>) -> f32 {
out.clear();
out.reserve(metrics.top_processes.len());
let total = metrics.mem_total.max(1);
let mut peak = 0.0_f32;
for p in &metrics.top_processes {
let mem_pct = (p.mem_bytes as f64 / total as f64) * 100.0;
let cpu_val = p.cpu_usage;
if cpu_val > peak {
peak = cpu_val;
}
out.push(CachedRow {
pid_str: p.pid.to_string(),
cpu_str: format!("{:>5.1}", cpu_val.clamp(0.0, 100.0)),
mem_str: crate::ui::util::human(p.mem_bytes),
mem_pct_str: format!("{mem_pct:.2}%"),
mem_pct,
cpu_val,
});
}
peak
}
const PID_W: u16 = 8;
const CPU_W: u16 = 8;
const MEM_W: u16 = 12;
const MEM_PCT_W: u16 = 8;
const NAME_MIN_W: u16 = 8;
const COL_SPACING: u16 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProcColumns {
pub pid: bool,
pub cpu: bool,
pub mem: bool,
pub mem_pct: bool,
}
impl ProcColumns {
pub fn for_width(width: u16) -> Self {
let with_cpu = NAME_MIN_W + COL_SPACING + CPU_W;
let with_mem = with_cpu + COL_SPACING + MEM_W;
let with_pid = with_mem + COL_SPACING + PID_W;
let with_mem_pct = with_pid + COL_SPACING + MEM_PCT_W;
Self {
cpu: width >= with_cpu,
mem: width >= with_mem,
pid: width >= with_pid,
mem_pct: width >= with_mem_pct,
}
}
pub fn constraints(&self) -> Vec<Constraint> {
let mut c = Vec::with_capacity(5);
if self.pid {
c.push(Constraint::Length(PID_W));
}
c.push(Constraint::Fill(1)); if self.cpu {
c.push(Constraint::Length(CPU_W));
}
if self.mem {
c.push(Constraint::Length(MEM_W));
}
if self.mem_pct {
c.push(Constraint::Length(MEM_PCT_W));
}
c
}
pub fn cpu_index(&self) -> Option<usize> {
self.cpu.then(|| 1 + usize::from(self.pid))
}
pub fn mem_index(&self) -> Option<usize> {
self.mem
.then(|| 1 + usize::from(self.pid) + usize::from(self.cpu))
}
}
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
let Some(mm) = params.metrics else { return };
let total = mm.process_count.unwrap_or(mm.top_processes.len());
let block = Block::default()
.borders(Borders::ALL)
.title(format!("Top Processes ({total} total)"));
f.render_widget(block, area);
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
let content_start_y = if params.search_active || !params.search_query.is_empty() {
let search_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 3, };
let search_text = if params.search_active {
format!("Search: {}_", params.search_query)
} else {
format!(
"Filter: {} (press / to edit, c to clear)",
params.search_query
)
};
let search_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let search_paragraph = Paragraph::new(search_text)
.block(search_block)
.style(Style::default().fg(Color::Yellow));
f.render_widget(search_paragraph, search_area);
inner.y + 3
} else {
inner.y
};
let inner = Rect {
x: inner.x,
y: content_start_y,
width: inner.width,
height: inner.height.saturating_sub(content_start_y - (area.y + 1)),
};
if inner.height < 1 || inner.width < 3 {
return;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
let idxs = params.filtered_indices;
let total_rows = idxs.len();
let header_rows = 1usize;
let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize;
let max_off = total_rows.saturating_sub(viewport_rows);
let offset = params.scroll_offset.min(max_off);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
let cache_ok = params.cached_rows.len() == mm.top_processes.len();
let total_mem_bytes = mm.mem_total.max(1);
let peak_cpu = if cache_ok {
params.peak_cpu
} else {
mm.top_processes
.iter()
.map(|p| p.cpu_usage)
.fold(0.0_f32, f32::max)
};
let columns = ProcColumns::for_width(content.width);
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
let p = &mm.top_processes[ix];
let (
cpu_val,
mem_pct,
pid_span,
name_span,
cpu_span_text,
mem_span_text,
mem_pct_span_text,
) = if cache_ok {
let row = ¶ms.cached_rows[ix];
(
row.cpu_val,
row.mem_pct,
Span::raw(row.pid_str.as_str()),
Span::raw(p.name.as_str()),
row.cpu_str.as_str(),
row.mem_str.as_str(),
row.mem_pct_str.as_str(),
)
} else {
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
(
p.cpu_usage,
mem_pct,
Span::raw(""),
Span::raw(""),
"",
"",
"",
)
};
let cpu_fg = match cpu_val {
x if x < 25.0 => Color::Green,
x if x < 60.0 => Color::Yellow,
_ => Color::Red,
};
let mem_fg = match mem_pct {
x if x < 5.0 => Color::Blue,
x if x < 20.0 => Color::Magenta,
_ => Color::Red,
};
let mut emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let is_selected = if let Some(selected_pid) = params.selected_process_pid {
selected_pid == p.pid
} else if let Some(selected_idx) = params.selected_process_index {
selected_idx == ix
} else {
false
};
if is_selected {
emphasis = emphasis
.bg(PROCESS_SELECTION_BG)
.fg(PROCESS_SELECTION_FG)
.add_modifier(Modifier::BOLD);
}
let mut cells = Vec::with_capacity(5);
if columns.pid {
cells.push(
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
);
}
cells.push(ratatui::widgets::Cell::from(name_span));
if columns.cpu {
cells.push(
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
.style(Style::default().fg(cpu_fg)),
);
}
if columns.mem {
cells.push(ratatui::widgets::Cell::from(Span::raw(mem_span_text)));
}
if columns.mem_pct {
cells.push(
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
.style(Style::default().fg(mem_fg)),
);
}
ratatui::widgets::Row::new(cells).style(emphasis)
});
let cpu_hdr = match params.sort_by {
ProcSortBy::CpuDesc => "CPU % •",
_ => "CPU %",
};
let mem_hdr = match params.sort_by {
ProcSortBy::MemDesc => "Mem •",
_ => "Mem",
};
let mut header_cells = Vec::with_capacity(5);
if columns.pid {
header_cells.push("PID");
}
header_cells.push("Name");
if columns.cpu {
header_cells.push(cpu_hdr);
}
if columns.mem {
header_cells.push(mem_hdr);
}
if columns.mem_pct {
header_cells.push("Mem %");
}
let header = ratatui::widgets::Row::new(header_cells).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let table = Table::new(rows_iter, columns.constraints())
.header(header)
.column_spacing(COL_SPACING);
f.render_widget(table, content);
if let Some(selected_pid) = params.selected_process_pid {
let process_info = if let Some(metrics) = params.metrics {
metrics
.top_processes
.iter()
.find(|p| p.pid == selected_pid)
.map(|p| format!("PID {} • {}", p.pid, p.name))
.unwrap_or_else(|| format!("PID {selected_pid}"))
} else {
format!("PID {selected_pid}")
};
let key = Style::default()
.fg(PROCESS_TOOLTIP_FG)
.add_modifier(Modifier::BOLD);
let label = Style::default().fg(PROCESS_TOOLTIP_FG);
let mut hints: Vec<Span> = vec![
Span::styled("⏎", key),
Span::styled(" details", label),
Span::styled(" · ", label),
];
if params.is_local {
hints.push(Span::styled("t", key));
hints.push(Span::styled(" kill", label));
hints.push(Span::styled(" · ", label));
}
hints.push(Span::styled("x", key));
hints.push(Span::styled(" unselect", label));
let hints_w: u16 = hints.iter().map(|s| fit::cols(&s.content)).sum();
let tooltip_height = 3;
let max_w = area.width.saturating_sub(2);
if max_w > hints_w + 6 && area.height > tooltip_height + 1 {
let room_for_info = max_w - hints_w - 6;
let info = fit::truncate_cols(&process_info, room_for_info);
let mut spans: Vec<Span> = vec![
Span::styled(" ", label),
Span::styled(
info.clone(),
Style::default()
.fg(PROCESS_TOOLTIP_FG)
.add_modifier(Modifier::BOLD),
),
Span::styled(" │ ", label),
];
spans.extend(hints);
spans.push(Span::styled(" ", label));
let width = spans
.iter()
.map(|s| fit::cols(&s.content))
.sum::<u16>()
.saturating_add(2)
.min(area.width);
let tooltip_area = Rect {
x: area.x + area.width.saturating_sub(width + 1),
y: area.y + area.height.saturating_sub(tooltip_height + 1),
width,
height: tooltip_height,
};
let tooltip_block = Block::default().borders(Borders::ALL).style(
Style::default()
.bg(PROCESS_TOOLTIP_BG)
.fg(PROCESS_TOOLTIP_FG),
);
f.render_widget(
Paragraph::new(Line::from(spans)).block(tooltip_block),
tooltip_area,
);
}
}
let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1),
y: inner.y,
width: 1,
height: inner.height,
};
let max_off_for_bar = total_rows.saturating_sub(viewport_rows);
if scroll_area.height >= 3 && max_off_for_bar > 0 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"))
.thumb_symbol("█")
.track_symbol(Some("│"))
.thumb_style(Style::default().fg(SB_THUMB))
.track_style(Style::default().fg(SB_TRACK))
.begin_style(Style::default().fg(SB_ARROW))
.end_style(Style::default().fg(SB_ARROW));
let mut state = ScrollbarState::new(max_off_for_bar).position(offset);
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
}
}
pub struct ProcessKeyParams<'a> {
pub selected_process_pid: &'a mut Option<u32>,
pub selected_process_index: &'a mut Option<usize>,
pub key: crossterm::event::KeyEvent,
pub metrics: Option<&'a Metrics>,
pub filtered_indices: &'a [usize],
}
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
use crossterm::event::KeyCode;
let move_selection = |delta: isize,
sel_idx: &mut Option<usize>,
sel_pid: &mut Option<u32>,
metrics: Option<&Metrics>,
idxs: &[usize]| {
let Some(m) = metrics else { return };
if idxs.is_empty() {
*sel_idx = None;
*sel_pid = None;
return;
}
if sel_idx.is_none() || sel_pid.is_none() {
let first_idx = idxs[0];
*sel_idx = Some(first_idx);
*sel_pid = Some(m.top_processes[first_idx].pid);
return;
}
let current_idx = sel_idx.unwrap();
match idxs.iter().position(|&idx| idx == current_idx) {
Some(pos) => {
let new_pos = (pos as isize + delta).clamp(0, idxs.len() as isize - 1) as usize;
if new_pos != pos {
let new_idx = idxs[new_pos];
*sel_idx = Some(new_idx);
*sel_pid = Some(m.top_processes[new_idx].pid);
}
}
None => {
let first_idx = idxs[0];
*sel_idx = Some(first_idx);
*sel_pid = Some(m.top_processes[first_idx].pid);
}
}
};
match params.key.code {
KeyCode::Up => {
move_selection(
-1,
params.selected_process_index,
params.selected_process_pid,
params.metrics,
params.filtered_indices,
);
true
}
KeyCode::Down => {
move_selection(
1,
params.selected_process_index,
params.selected_process_pid,
params.metrics,
params.filtered_indices,
);
true
}
KeyCode::Char('x') | KeyCode::Char('X')
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() =>
{
*params.selected_process_pid = None;
*params.selected_process_index = None;
true
}
KeyCode::Char('x') | KeyCode::Char('X') => false,
KeyCode::Enter => {
params.selected_process_pid.is_some() }
_ => {
false
}
}
}
pub struct ProcessMouseParams<'a> {
pub scroll_offset: &'a mut usize,
pub selected_process_pid: &'a mut Option<u32>,
pub selected_process_index: &'a mut Option<usize>,
pub drag: &'a mut Option<crate::ui::cpu::PerCoreScrollDrag>,
pub mouse: MouseEvent,
pub area: Rect,
pub total_rows: usize,
pub metrics: Option<&'a Metrics>,
pub search_box_visible: bool,
pub filtered_indices: &'a [usize],
}
pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Option<ProcSortBy> {
let inner = Rect {
x: params.area.x + 1,
y: params.area.y + 1,
width: params.area.width.saturating_sub(2),
height: params.area.height.saturating_sub(2),
};
if inner.height == 0 || inner.width <= 2 {
return None;
}
let content_start_y = if params.search_box_visible {
inner.y + 3
} else {
inner.y
};
let content = Rect {
x: inner.x,
y: content_start_y,
width: inner.width.saturating_sub(2),
height: inner
.height
.saturating_sub(if params.search_box_visible { 3 } else { 0 }),
};
per_core_handle_scrollbar_mouse(
params.scroll_offset,
params.drag,
params.mouse,
params.area,
params.total_rows,
);
crate::ui::cpu::per_core_handle_mouse(
params.scroll_offset,
params.mouse,
content,
content.height as usize,
);
let header_area = Rect {
x: content.x,
y: content.y,
width: content.width,
height: 1,
};
let inside_header = params.mouse.row == header_area.y
&& params.mouse.column >= header_area.x
&& params.mouse.column < header_area.x + header_area.width;
if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
let columns = ProcColumns::for_width(header_area.width);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(columns.constraints())
.spacing(COL_SPACING) .split(header_area);
if let Some(cpu) = columns.cpu_index().map(|i| cols[i])
&& params.mouse.column >= cpu.x
&& params.mouse.column < cpu.x + cpu.width
{
return Some(ProcSortBy::CpuDesc);
}
if let Some(mem) = columns.mem_index().map(|i| cols[i])
&& params.mouse.column >= mem.x
&& params.mouse.column < mem.x + mem.width
{
return Some(ProcSortBy::MemDesc);
}
}
let data_start_row = content.y + 1; let data_area_height = content.height.saturating_sub(1);
if matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& params.mouse.row >= data_start_row
&& params.mouse.row < data_start_row + data_area_height
&& params.mouse.column >= content.x
&& params.mouse.column < content.x + content.width
{
let clicked_row = (params.mouse.row - data_start_row) as usize;
if let Some(m) = params.metrics {
let idxs = params.filtered_indices;
let visible_process_position = *params.scroll_offset + clicked_row;
if visible_process_position < idxs.len() {
let actual_process_index = idxs[visible_process_position];
let clicked_process = &m.top_processes[actual_process_index];
*params.selected_process_pid = Some(clicked_process.pid);
*params.selected_process_index = Some(actual_process_index);
}
}
}
per_core_clamp(
params.scroll_offset,
params.total_rows,
(content.height.saturating_sub(1)) as usize,
);
None
}
#[cfg(test)]
mod column_tests {
use super::*;
use ratatui::layout::{Direction, Layout, Rect};
fn name_width(w: u16) -> u16 {
let c = ProcColumns::for_width(w);
let rects = Layout::default()
.direction(Direction::Horizontal)
.constraints(c.constraints())
.spacing(COL_SPACING)
.split(Rect::new(0, 0, w, 1));
rects[usize::from(c.pid)].width
}
#[test]
fn name_is_never_the_column_that_gets_dropped() {
for width in NAME_MIN_W..=200u16 {
assert!(
name_width(width) >= 1,
"width {width}: Name was squeezed to nothing"
);
}
}
#[test]
fn columns_are_shed_in_priority_order() {
for width in 0..=200u16 {
let c = ProcColumns::for_width(width);
assert!(!c.mem_pct || c.pid, "width {width}: Mem % outlived PID");
assert!(!c.pid || c.mem, "width {width}: PID outlived Mem");
assert!(!c.mem || c.cpu, "width {width}: Mem outlived CPU %");
}
}
#[test]
fn columns_are_monotonic_in_width() {
let mut prev = ProcColumns::for_width(0);
for width in 1..=200u16 {
let c = ProcColumns::for_width(width);
for (was, now, name) in [
(prev.cpu, c.cpu, "CPU %"),
(prev.mem, c.mem, "Mem"),
(prev.pid, c.pid, "PID"),
(prev.mem_pct, c.mem_pct, "Mem %"),
] {
assert!(!was || now, "width {width}: {name} vanished as it widened");
}
prev = c;
}
}
#[test]
fn narrow_panes_shed_columns_in_order() {
let full = ProcColumns::for_width(48);
assert_eq!(full.constraints().len(), 5);
assert!(full.pid && full.cpu && full.mem && full.mem_pct);
let c = ProcColumns::for_width(45);
assert!(c.pid && c.mem && !c.mem_pct);
let c = ProcColumns::for_width(35);
assert!(!c.pid && c.cpu && c.mem);
let c = ProcColumns::for_width(20);
assert!(!c.mem && c.cpu);
assert_eq!(c.constraints().len(), 2);
let c = ProcColumns::for_width(10);
assert!(!c.cpu && !c.mem);
assert_eq!(c.constraints().len(), 1);
}
#[test]
fn a_wide_terminal_keeps_the_full_table() {
assert_eq!(ProcColumns::for_width(48).constraints().len(), 5);
}
#[test]
fn sort_indices_follow_the_rendered_columns() {
let wide = ProcColumns::for_width(48);
assert_eq!(wide.cpu_index(), Some(2)); assert_eq!(wide.mem_index(), Some(3));
let narrow = ProcColumns::for_width(35);
assert_eq!(narrow.cpu_index(), Some(1)); assert_eq!(narrow.mem_index(), Some(2));
let tiny = ProcColumns::for_width(10);
assert_eq!(tiny.cpu_index(), None);
assert_eq!(tiny.mem_index(), None);
for width in 0..=200u16 {
let c = ProcColumns::for_width(width);
let n = c.constraints().len();
for i in [c.cpu_index(), c.mem_index()].into_iter().flatten() {
assert!(i < n, "width {width}: index {i} outside {n} columns");
}
}
}
#[test]
fn name_absorbs_the_leftover_width() {
assert!(
name_width(80) > name_width(60),
"Name did not grow with the pane"
);
}
}
#[cfg(test)]
mod click_tests {
use super::*;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use socktop_connector::{Metrics, ProcessInfo};
fn metrics() -> Metrics {
Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 32_000_000_000,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![ProcessInfo {
pid: 4242,
name: "some-process".into(),
cpu_usage: 1.5,
mem_bytes: 1_000_000,
}],
gpus: None,
process_count: Some(1),
}
}
fn header_row(width: u16) -> String {
let m = metrics();
let mut cache = Vec::new();
let peak = rebuild_row_cache(&m, &mut cache);
let idxs = [0usize];
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
terminal
.draw(|f| {
draw_top_processes(
f,
Rect::new(0, 0, width, 8),
ProcessDisplayParams {
metrics: Some(&m),
scroll_offset: 0,
sort_by: ProcSortBy::CpuDesc,
selected_process_pid: None,
selected_process_index: None,
search_query: "",
search_active: false,
filtered_indices: &idxs,
cached_rows: &cache,
peak_cpu: peak,
is_local: false,
},
)
})
.unwrap();
let buf = terminal.backend().buffer();
(0..width)
.map(|x| buf[(x, 1)].symbol().to_string())
.collect()
}
fn click(width: u16, column: u16) -> Option<ProcSortBy> {
let m = metrics();
let mut scroll = 0usize;
let mut drag = None;
let mut sel_pid = None;
let mut sel_idx = None;
let idxs = [0usize];
processes_handle_mouse_with_selection(ProcessMouseParams {
scroll_offset: &mut scroll,
selected_process_pid: &mut sel_pid,
selected_process_index: &mut sel_idx,
drag: &mut drag,
mouse: MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row: 1,
modifiers: KeyModifiers::NONE,
},
area: Rect::new(0, 0, width, 8),
total_rows: 1,
metrics: Some(&m),
search_box_visible: false,
filtered_indices: &idxs,
})
}
#[test]
fn clicking_a_rendered_sort_header_sorts_by_that_column() {
for width in [40u16, 50, 60, 80, 120] {
let row = header_row(width);
let cpu_at = row.find("CPU").map(|i| row[..i].chars().count() as u16);
let mem_at = row.find("Mem").map(|i| row[..i].chars().count() as u16);
if let Some(x) = cpu_at {
assert_eq!(
click(width, x),
Some(ProcSortBy::CpuDesc),
"width {width}: clicking the rendered 'CPU %' header at column {x} \
did not sort by CPU (header row: {row:?})"
);
}
if let Some(x) = mem_at {
assert_eq!(
click(width, x),
Some(ProcSortBy::MemDesc),
"width {width}: clicking the rendered 'Mem' header at column {x} \
did not sort by Mem (header row: {row:?})"
);
}
}
}
#[test]
fn the_name_column_is_rendered_even_when_narrow() {
for width in [30u16, 40, 60, 120] {
let row = header_row(width);
assert!(
row.contains("Name"),
"width {width}: no Name column in header {row:?}"
);
}
}
}
#[cfg(test)]
mod tooltip_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use socktop_connector::{Metrics, ProcessInfo};
fn metrics(name: &str) -> Metrics {
Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 32_000_000_000,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![ProcessInfo {
pid: 4242,
name: name.into(),
cpu_usage: 1.5,
mem_bytes: 1_000_000,
}],
gpus: None,
process_count: Some(1),
}
}
fn rendered(name: &str, width: u16, is_local: bool) -> String {
let m = metrics(name);
let mut cache = Vec::new();
let peak = rebuild_row_cache(&m, &mut cache);
let idxs = [0usize];
let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap();
terminal
.draw(|f| {
draw_top_processes(
f,
Rect::new(0, 0, width, 12),
ProcessDisplayParams {
metrics: Some(&m),
scroll_offset: 0,
sort_by: ProcSortBy::CpuDesc,
selected_process_pid: Some(4242),
selected_process_index: Some(0),
search_query: "",
search_active: false,
filtered_indices: &idxs,
cached_rows: &cache,
peak_cpu: peak,
is_local,
},
)
})
.unwrap();
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..12 {
for x in 0..width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn hint_offers_kill_for_a_local_agent() {
let out = rendered("some-process", 80, true);
assert!(out.contains("details"), "no hint rendered at all:\n{out}");
assert!(
out.contains("kill"),
"local agent should offer kill:\n{out}"
);
assert!(out.contains("unselect"));
}
#[test]
fn hint_omits_kill_for_a_remote_agent() {
let out = rendered("some-process", 80, false);
assert!(out.contains("details"), "no hint rendered at all:\n{out}");
assert!(
!out.contains("kill"),
"remote agent must not offer kill:\n{out}"
);
}
#[test]
fn hint_survives_a_very_long_process_name() {
let long = "/usr/lib/firefox-esr/firefox-esr-with-a-really-long-suffix";
let out = rendered(long, 80, true);
assert!(
out.contains("details") && out.contains("kill"),
"hint disappeared for a long process name:\n{out}"
);
}
}