use std::collections::HashSet; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::Line, widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Wrap}, Frame, }; use crate::state::{sort_indicator, AppState, FocusPane, ProcessSample, ProcessSort}; pub fn draw(frame: &mut Frame, state: &AppState) { let root = frame.size(); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Min(8), Constraint::Length(1), ]) .split(root); draw_header(frame, chunks[0], state); draw_body(frame, chunks[1], state); draw_footer(frame, chunks[2], state); } fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) { let uptime = format_uptime(state.snapshot.uptime_seconds); let line = format!( "{} up {} refresh:{}ms procs:{} load:{:.2}/{:.2}/{:.2}{}", state.snapshot.host, uptime, state.refresh_interval.as_millis(), state.snapshot.process_count, state.snapshot.load_avg_1m, state.snapshot.load_avg_5m, state.snapshot.load_avg_15m, if state.paused { " [PAUSED]" } else { "" } ); frame.render_widget( Paragraph::new(line).style(Style::default().fg(Color::Gray)), area, ); } fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) { let has_right_sidebar = area.width >= 120; let body_chunks = if has_right_sidebar { Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(68), Constraint::Length(34)]) .split(area) } else { Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(1)]) .split(area) }; let main = body_chunks[0]; let right = if has_right_sidebar { Some(body_chunks[1]) } else { None }; 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(middle_height), Constraint::Min(7), ]) .split(main); draw_cpu_section(frame, main_chunks[0], state); draw_memory_disk_section(frame, main_chunks[1], 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 { 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(5), Constraint::Length(6), ]) .split(sidebar); draw_process_details(frame, split[0], state); draw_gpu(frame, split[1], state); draw_network(frame, split[2], state); } else { let split = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(5), Constraint::Min(4)]) .split(sidebar); draw_gpu(frame, split[0], state); draw_network(frame, split[1], state); } } else { let net_line = compact_status_line(state); let net_area = Rect { x: main.x, y: main.y, width: main.width.min(net_line.len() as u16 + 2), height: 1, }; frame.render_widget( Paragraph::new(net_line).style(Style::default().fg(Color::DarkGray)), net_area, ); } } 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 < 2 || inner.width < 10 { return; } let split = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(1)]) .split(inner); let total_label = format!( "total {:>5.1}%", state.snapshot.cpu_total_percent.clamp(0.0, 100.0) ); frame.render_widget( Gauge::default() .gauge_style( Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ) .percent(state.snapshot.cpu_total_percent.clamp(0.0, 100.0) as u16) .label(total_label), split[0], ); 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::(); 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) { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(52), Constraint::Percentage(48)]) .split(area); draw_mem_swap(frame, chunks[0], state); draw_disks(frame, chunks[1], state); } fn draw_mem_swap(frame: &mut Frame, area: Rect, state: &AppState) { let block = Block::default().title("Memory").borders(Borders::ALL); let inner = block.inner(area); frame.render_widget(block, area); if inner.height < 2 { return; } let rows = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Length(1)]) .split(inner); let mem_pct = percent( state.snapshot.mem_used_bytes, state.snapshot.mem_total_bytes, ); let swap_pct = percent( state.snapshot.swap_used_bytes, state.snapshot.swap_total_bytes, ); frame.render_widget( Gauge::default() .gauge_style(Style::default().fg(Color::Green)) .percent(mem_pct as u16) .label(format!( "mem {:>5.1}% {}/{}", mem_pct, fmt_bytes_compact(state.snapshot.mem_used_bytes), fmt_bytes_compact(state.snapshot.mem_total_bytes) )), rows[0], ); frame.render_widget( Gauge::default() .gauge_style(Style::default().fg(Color::Yellow)) .percent(swap_pct as u16) .label(format!( "swap {:>5.1}% {}/{}", swap_pct, 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(if state.show_pseudo_fs { "Disk [all]" } else { "Disk [filtered]" }) .borders(Borders::ALL); let inner = block.inner(area); frame.render_widget(block, area); if inner.height == 0 { return; } 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::>(); 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 = inner.height as usize; let layout = Layout::default() .direction(Direction::Vertical) .constraints(vec![Constraint::Length(1); rows.max(1)]) .split(inner); 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!( "{} {:>4.0}% {}/{}", truncate(&disk.mount, 12), pct, fmt_bytes_compact(disk.used_bytes), fmt_bytes_compact(disk.total_bytes) )), layout[i], ); } } fn draw_gpu(frame: &mut Frame, area: Rect, state: &AppState) { let block = Block::default().title("GPU").borders(Borders::ALL); let lines = if state.snapshot.gpus.is_empty() { vec![Line::from("No GPU detected")] } else { state .snapshot .gpus .iter() .take(2) .map(|gpu| { let util = gpu .utilization_percent .map(|u| format!("{u:.0}%")) .unwrap_or_else(|| "-".to_string()); let mem = match (gpu.mem_used_bytes, gpu.mem_total_bytes) { (Some(used), Some(total)) => { format!("{}/{}", fmt_bytes_compact(used), fmt_bytes_compact(total)) } _ => "-".to_string(), }; Line::from(format!( "{} {} u:{} m:{} t:{}", truncate(&gpu.vendor, 4), truncate(&gpu.name, 10), util, mem, gpu.temperature_c .map(|t| format!("{t:.0}C")) .unwrap_or_else(|| "-".to_string()) )) }) .collect::>() }; frame.render_widget( Paragraph::new(lines) .block(block) .alignment(Alignment::Left) .wrap(Wrap { trim: true }), area, ); } fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) { let block = Block::default().title("Net").borders(Borders::ALL); 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) .block(block) .alignment(Alignment::Left) .wrap(Wrap { trim: true }), area, ); } 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 [{}{}]", sort_key_label(state.process_sort), sort_char ); let border_style = if focused { Style::default().fg(Color::Cyan) } else { Style::default().fg(Color::DarkGray) }; let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(border_style); let inner = block.inner(area); frame.render_widget(block, area); if inner.height < 2 { return; } let show_user = inner.width >= 90; let visible_rows = inner.height.saturating_sub(1) as usize; let selected = state .selected_process .min(state.snapshot.processes.len().saturating_sub(1)); let mut start = selected.saturating_sub(visible_rows / 2); if start + visible_rows > state.snapshot.processes.len() { start = state.snapshot.processes.len().saturating_sub(visible_rows); } let rows = state .snapshot .processes .iter() .enumerate() .skip(start) .take(visible_rows) .map(|(idx, p)| { let mem_pct = percent(p.mem_bytes, state.snapshot.mem_total_bytes); let style = if idx == selected { Style::default().fg(Color::Black).bg(Color::Cyan) } else { Style::default() }; if show_user { Row::new(vec![ 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_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, 30)), ]) .style(style) } else { Row::new(vec![ 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_compact(p.mem_bytes))), Cell::from(truncate(&p.state, 6)), Cell::from(truncate(&p.command, 22)), ]) .style(style) } }) .collect::>(); let (header, widths) = if show_user { ( 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(), "USER".to_string(), "COMMAND".to_string(), ]) .style( Style::default() .fg(Color::Gray) .add_modifier(Modifier::BOLD), ), vec![ Constraint::Length(6), Constraint::Length(6), Constraint::Length(6), Constraint::Length(9), Constraint::Length(6), Constraint::Length(8), Constraint::Min(10), ], ) } else { ( 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(6), Constraint::Length(6), Constraint::Length(9), Constraint::Length(6), Constraint::Min(10), ], ) }; let table = Table::new(rows, widths) .header(header) .column_spacing(1) .block(Block::default()); 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 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) .style(Style::default().fg(Color::DarkGray)), area, ); } 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_status_line(state: &AppState) -> String { let gpu = state.snapshot.gpus.first().map_or_else( || "GPU -".to_string(), |g| { let util = g .utilization_percent .map(|u| format!("{u:.0}%")) .unwrap_or_else(|| "-".to_string()); format!("GPU {util}") }, ); 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), gpu ) } 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; } ((used as f64 / total as f64) * 100.0).clamp(0.0, 100.0) } fn fmt_bytes_compact(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; if bytes == 0 { return "0B".to_string(); } let mut value = bytes as f64; let mut idx = 0; while value >= 1024.0 && idx < UNITS.len() - 1 { value /= 1024.0; idx += 1; } if idx == 0 { format!("{}{}", bytes, UNITS[idx]) } else { format!("{value:.1}{}", UNITS[idx]) } } fn format_uptime(seconds: u64) -> String { let days = seconds / 86_400; let hours = (seconds % 86_400) / 3_600; let mins = (seconds % 3_600) / 60; let secs = seconds % 60; if days > 0 { format!("{days}d {hours:02}:{mins:02}:{secs:02}") } else { format!("{hours:02}:{mins:02}:{secs:02}") } } fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { return s.to_string(); } let mut out = s.chars().take(max.saturating_sub(1)).collect::(); 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: 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")); } }