diff options
| author | main <main@swarm.moe> | 2026-04-25 13:27:32 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-04-25 13:27:32 -0400 |
| commit | 66c996e6ae24ae496aa6b7ed07c85dce2b106d13 (patch) | |
| tree | b9ca28e39e70cc5fca489df97de430075720dda3 /crates/memview/src/ui.rs | |
| download | memview-1.0.0.zip | |
Release memview 1.0.0v1.0.0
Diffstat (limited to 'crates/memview/src/ui.rs')
| -rw-r--r-- | crates/memview/src/ui.rs | 1076 |
1 files changed, 1076 insertions, 0 deletions
diff --git a/crates/memview/src/ui.rs b/crates/memview/src/ui.rs new file mode 100644 index 0000000..d170d74 --- /dev/null +++ b/crates/memview/src/ui.rs @@ -0,0 +1,1076 @@ +use crate::app::{App, FlatProcessRow, FlatSharedRow, FlatTmpfsRow, Hotkey, RowFold}; +use crate::model::{Bytes, MeminfoEntry, ObjectUsage, Pid, Snapshot, TmpfsMount}; +use crate::search::SearchRole; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap}; +use std::time::Duration; + +const BG: Color = Color::Rgb(12, 17, 24); +const FG: Color = Color::Rgb(221, 227, 234); +const MUTED: Color = Color::Rgb(129, 145, 160); +const ACCENT: Color = Color::Rgb(64, 184, 173); +const HOT: Color = Color::Rgb(227, 116, 94); +const GOLD: Color = Color::Rgb(236, 180, 71); + +pub fn render(frame: &mut Frame<'_>, app: &App) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(area); + + frame.render_widget(header(app), chunks[0]); + match app.snapshot.as_ref() { + Some(snapshot) => render_body(frame, app, snapshot, chunks[1]), + None => render_loading(frame, app, chunks[1]), + } + frame.render_widget(footer(app), chunks[2]); + + if app.show_help { + render_help(frame, app, area); + } + if app.kill_confirmation.is_some() { + render_kill_confirmation(frame, app, area); + } + if app.search_draft().is_some() { + render_search_prompt(frame, app, area); + } +} + +fn render_body(frame: &mut Frame<'_>, app: &App, snapshot: &Snapshot, area: Rect) { + match app.tab { + crate::app::Tab::Overview => render_overview(frame, app, snapshot, area), + crate::app::Tab::Processes => render_processes(frame, app, snapshot, area), + crate::app::Tab::Tmpfs => render_tmpfs(frame, app, snapshot, area), + crate::app::Tab::Shared => render_shared(frame, app, snapshot, area), + } +} + +fn header(app: &App) -> Paragraph<'static> { + let mut spans = Vec::new(); + spans.push(Span::styled( + " memview ", + Style::default() + .fg(BG) + .bg(ACCENT) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + for (index, label) in App::tab_labels().into_iter().enumerate() { + let style = if app.tab == crate::app::Tab::ALL[index] { + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(MUTED) + }; + spans.push(Span::styled(label, style)); + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!("sort {}", app.metric.label()), + Style::default().fg(GOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("mode {}", app.process_scope.label()), + Style::default().fg(ACCENT), + )); + + Paragraph::new(Line::from(spans)) + .block(panel("Memory Ledger")) + .style(Style::default().fg(FG).bg(BG)) +} + +fn footer(app: &App) -> Paragraph<'static> { + let mut spans = Vec::new(); + if let Some(pattern) = app.search_pattern() { + spans.push(Span::styled( + format!(" FILTER /{pattern}/ "), + Style::default() + .fg(BG) + .bg(GOLD) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + "f clear", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!( + "q quit / search ? help {} {}", + pane_footer(app), + app.current_time_label() + ), + Style::default().fg(MUTED), + )); + if let Some(error) = &app.last_error { + spans.push(Span::styled(" last error: ", Style::default().fg(HOT))); + spans.push(Span::styled(error.clone(), Style::default().fg(HOT))); + } + if app.deletion_count() > 0 { + spans.push(Span::styled( + format!(" deleting {} in background", app.deletion_count()), + Style::default().fg(HOT), + )); + } + if let Some(confirmation) = &app.kill_confirmation { + if confirmation.armed() { + spans.push(Span::styled( + " SIGTERM armed: y confirms", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!( + " SIGTERM locked {} ms", + confirmation.lock_remaining().as_millis() + ), + Style::default().fg(GOLD), + )); + } + } + if app.tab.drives_process_scans() + && let Some(started) = app.process_scan_started_at + { + spans.push(Span::styled( + format!(" process scan {} ms", started.elapsed().as_millis()), + Style::default().fg(MUTED), + )); + } + if let Some((pid, started)) = app.process_mapping_started_at() { + spans.push(Span::styled( + format!(" mappings {pid} {} ms", started.elapsed().as_millis()), + Style::default().fg(MUTED), + )); + } + if let Some(started) = app.shared_scan_started_at() { + spans.push(Span::styled( + format!(" shared ledger {} ms", started.elapsed().as_millis()), + Style::default().fg(MUTED), + )); + } + Paragraph::new(Line::from(spans)).style(Style::default().bg(BG)) +} + +fn pane_footer(app: &App) -> &'static str { + match app.tab { + crate::app::Tab::Overview => "r refresh overview s lens", + crate::app::Tab::Processes => { + "j/k/Pg/wheel move gg/G edge Enter fold s sort m mode K SIGTERM r rescan" + } + crate::app::Tab::Tmpfs => { + "j/k/Pg/wheel move gg/G edge Enter fold m mode d delete r refresh mount" + } + crate::app::Tab::Shared => "j/k/Pg/wheel move gg/G edge s sort m mode r rescan", + } +} + +fn render_loading(frame: &mut Frame<'_>, app: &App, area: Rect) { + let (title, message) = match app.tab { + crate::app::Tab::Overview => ("Loading", "Reading kernel memory counters..."), + crate::app::Tab::Processes => ("Processes", "Capturing process memory snapshot..."), + crate::app::Tab::Tmpfs => ("Tmpfs", "Scanning tmpfs mounts..."), + crate::app::Tab::Shared => ("Shared", "Reading shared memory ledgers..."), + }; + frame.render_widget( + Paragraph::new(message) + .block(panel(title)) + .style(Style::default().fg(FG)), + area, + ); +} + +fn render_overview(frame: &mut Frame<'_>, _app: &App, snapshot: &Snapshot, area: Rect) { + let capacity = snapshot.meminfo.get("MemTotal"); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(52), Constraint::Percentage(48)]) + .split(area); + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(11), Constraint::Min(8)]) + .split(columns[1]); + + let mem_rows = snapshot + .meminfo + .entries + .iter() + .map(|entry| row_meminfo(entry, capacity)) + .collect::<Vec<_>>(); + frame.render_widget( + Table::new( + mem_rows, + [ + Constraint::Length(18), + Constraint::Length(16), + Constraint::Length(8), + ], + ) + .header(header_row(["Kernel Counter", "Value", "% total"])) + .block(panel("meminfo")) + .column_spacing(1), + columns[0], + ); + + let overview_rows = vec![ + summary_row( + "Σ process PSS", + snapshot.overview.process_pss_total, + capacity, + ), + summary_row( + "Σ process USS", + snapshot.overview.process_uss_total, + capacity, + ), + summary_row( + "Σ process RSS", + snapshot.overview.process_rss_total, + capacity, + ), + summary_row( + "Σ process SwapPSS", + snapshot.overview.process_swap_pss_total, + capacity, + ), + summary_row( + "Σ process PSS anon", + snapshot.overview.process_pss_anon_total, + capacity, + ), + summary_row( + "Σ process PSS file", + snapshot.overview.process_pss_file_total, + capacity, + ), + summary_row( + "Σ process PSS shmem", + snapshot.overview.process_pss_shmem_total, + capacity, + ), + summary_row( + "Σ tmpfs allocated", + snapshot.overview.tmpfs_allocated_total, + capacity, + ), + summary_row("Σ SysV shm RSS", snapshot.overview.sysv_rss_total, capacity), + summary_text_row("processes", &snapshot.overview.process_count.to_string()), + summary_text_row("SysV segments", &snapshot.sysv_segments.len().to_string()), + summary_text_row("scan millis", &snapshot.elapsed.as_millis().to_string()), + summary_row( + "inaccessible rollups", + Bytes(snapshot.overview.inaccessible_rollups as u64), + capacity, + ), + summary_row( + "inaccessible maps", + Bytes(snapshot.overview.inaccessible_maps as u64), + capacity, + ), + ]; + frame.render_widget( + Table::new( + overview_rows, + [Constraint::Length(24), Constraint::Length(16)], + ) + .header(header_row(["Lens", "Value"])) + .block(panel("reconciliation")) + .column_spacing(1), + right[0], + ); + + let warning_lines = if snapshot.warnings.is_empty() { + vec![Line::from(Span::styled( + "No probe warnings. PSS is the attribution lens; tmpfs uses allocated blocks.", + Style::default().fg(FG), + ))] + } else { + snapshot + .warnings + .iter() + .take(24) + .map(|warning| Line::from(Span::styled(warning.clone(), Style::default().fg(HOT)))) + .collect::<Vec<_>>() + }; + frame.render_widget( + Paragraph::new(warning_lines) + .block(panel("probe notes")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[1], + ); +} + +fn render_processes(frame: &mut Frame<'_>, app: &App, snapshot: &Snapshot, area: Rect) { + let capacity = snapshot.meminfo.get("MemTotal"); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(57), Constraint::Percentage(43)]) + .split(area); + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(12), Constraint::Min(8)]) + .split(columns[1]); + + if snapshot.process_tree.nodes.is_empty() && app.process_scan_started_at.is_some() { + frame.render_widget( + Paragraph::new("Capturing process memory snapshot...") + .block(panel("process tree")) + .style(Style::default().fg(FG)), + columns[0], + ); + frame.render_widget( + Paragraph::new("Per-process PSS, mappings, and object consumers will appear here.") + .block(panel("selected process")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(MUTED)), + right[0], + ); + return; + } + + let rows = app.process_rows(); + let selected = app.selected_process_row(); + let visible = slice_window(rows, selected, columns[0].height.saturating_sub(4) as usize); + let process_rows = visible + .iter() + .enumerate() + .map(|(offset, row)| { + row_process( + app, + snapshot, + row, + visible.start + offset == selected, + capacity, + ) + }) + .collect::<Vec<_>>(); + frame.render_widget( + Table::new( + process_rows, + [ + Constraint::Length(30), + Constraint::Length(8), + Constraint::Length(8), + Constraint::Length(9), + Constraint::Length(9), + Constraint::Length(9), + Constraint::Min(24), + ], + ) + .header(header_row([ + "Task", "PID", "User", "PSS", "USS", "RSS", "Command", + ])) + .block(panel(&format!( + "process tree ({})", + app.process_scope.label() + ))) + .column_spacing(1), + columns[0], + ); + + if let Some(process) = app.selected_process() { + let mut details = search_summary_lines(app, capacity); + details.extend([ + Line::from(vec![Span::styled( + process.title(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )]), + detail_line("State", &process.state), + detail_line("Threads", &process.threads.to_string()), + detail_line("PSS", &process.rollup.pss.human_exact()), + detail_line("USS", &process.rollup.uss().human_exact()), + detail_line("RSS", &process.rollup.rss.human_exact()), + detail_line("PSS anon", &process.rollup.pss_anon.human_exact()), + detail_line("PSS file", &process.rollup.pss_file.human_exact()), + detail_line("PSS shmem", &process.rollup.pss_shmem.human_exact()), + detail_line("SwapPSS", &process.rollup.swap_pss.human_exact()), + detail_line( + "Access", + &format!( + "rollup={} maps={}", + process.rollup_state.label(), + app.selected_process_mapping_status() + ), + ), + detail_line("Map scan", &app.selected_process_mapping_scan_label()), + ]); + frame.render_widget( + Paragraph::new(details) + .block(panel("selected process")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + } else if app.search_summary().is_some() { + frame.render_widget( + Paragraph::new(search_summary_lines(app, capacity)) + .block(panel("search total")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + } + + if let Some((pid, elapsed)) = app.selected_process_mapping_loading() { + frame.render_widget(mapping_loading(pid, elapsed), right[1]); + } else { + let objects = app.selected_process_objects(); + let object_rows = slice_window(objects, 0, right[1].height.saturating_sub(4) as usize) + .iter() + .map(|object| row_object_usage(object, capacity)) + .collect::<Vec<_>>(); + frame.render_widget( + Table::new( + object_rows, + [ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Min(24), + ], + ) + .header(header_row(["Kind", "PSS", "RSS", "VMAs", "Object"])) + .block(panel("selected mappings")) + .column_spacing(1), + right[1], + ); + } +} + +fn mapping_loading(pid: Pid, elapsed: Duration) -> Paragraph<'static> { + let dots = ".".repeat(((elapsed.as_millis() / 250) % 4) as usize); + Paragraph::new(vec![ + Line::from(vec![Span::styled( + format!("Loading /proc/{pid}/smaps{dots}"), + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![ + Span::styled("elapsed ", Style::default().fg(MUTED)), + Span::styled( + format!("{} ms", elapsed.as_millis()), + Style::default().fg(FG), + ), + ]), + Line::from(""), + Line::from("The kernel synthesizes per-VMA PSS/RSS here; large mapping tables can stall."), + ]) + .block(panel("selected mappings")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)) +} + +fn render_tmpfs(frame: &mut Frame<'_>, app: &App, _snapshot: &Snapshot, area: Rect) { + let capacity = app + .snapshot + .as_ref() + .map_or(Bytes::ZERO, |snapshot| snapshot.meminfo.get("MemTotal")); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(area); + let rows = app.tmpfs_rows(); + let selected = app.selected_tmpfs_row(); + let visible = slice_window(rows, selected, columns[0].height.saturating_sub(4) as usize); + let table_rows = visible + .iter() + .enumerate() + .map(|(offset, row)| row_tmpfs(row, visible.start + offset == selected, capacity)) + .collect::<Vec<_>>(); + frame.render_widget( + Table::new( + table_rows, + [ + Constraint::Length(32), + Constraint::Length(8), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Min(20), + ], + ) + .header(header_row([ + "Entry", + "Kind", + "Allocated", + "Logical", + "Path", + ])) + .block(panel("tmpfs tree")) + .column_spacing(1), + columns[0], + ); + + let mut detail_lines = search_summary_lines(app, capacity); + detail_lines.extend( + match (app.selected_tmpfs_mount(), app.selected_tmpfs_entry()) { + (Some(mount), Some(row)) => tmpfs_detail_lines(mount, row), + _ => vec![Line::from("No tmpfs node selected")], + }, + ); + frame.render_widget( + Paragraph::new(detail_lines) + .block(panel("selected tmpfs entry")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + columns[1], + ); +} + +fn render_shared(frame: &mut Frame<'_>, app: &App, snapshot: &Snapshot, area: Rect) { + let capacity = snapshot.meminfo.get("MemTotal"); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(area); + let rows = app.shared_rows(); + let selected = app.selected_shared_row(); + let visible = slice_window(rows, selected, columns[0].height.saturating_sub(4) as usize); + let rows = visible + .iter() + .enumerate() + .map(|(offset, row)| { + row_shared(snapshot, row, visible.start + offset == selected, capacity) + }) + .collect::<Vec<_>>(); + frame.render_widget( + Table::new( + rows, + [ + Constraint::Length(8), + Constraint::Length(7), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Min(24), + ], + ) + .header(header_row([ + "Kind", "Tasks", "PSS", "RSS", "VMAs", "Object", + ])) + .block(panel("global object ledger")) + .column_spacing(1), + columns[0], + ); + + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(8), Constraint::Min(8)]) + .split(columns[1]); + if let Some(object) = app.selected_shared_object() { + let mut summary = search_summary_lines(app, capacity); + summary.extend([ + Line::from(Span::styled( + object.label.clone(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + detail_line("Kind", object.kind.label()), + detail_line("PSS", &object.rollup.pss.human_exact()), + detail_line("RSS", &object.rollup.rss.human_exact()), + detail_line("Swap", &object.rollup.swap.human_exact()), + detail_line("Tasks", &object.mapped_processes.to_string()), + detail_line("VMAs", &object.regions.to_string()), + ]); + frame.render_widget( + Paragraph::new(summary) + .block(panel("selected object")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + + let consumers = slice_window( + &object.consumers, + 0, + right[1].height.saturating_sub(4) as usize, + ) + .iter() + .map(|consumer| { + Row::new(vec![ + Cell::from(consumer.pid.to_string()), + usage_cell(consumer.rollup.pss, capacity), + usage_cell(consumer.rollup.rss, capacity), + Cell::from(consumer.name.clone()), + Cell::from(consumer.command.clone()), + ]) + .style(usage_style(false, consumer.rollup.pss, capacity)) + }) + .collect::<Vec<_>>(); + frame.render_widget( + Table::new( + consumers, + [ + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(16), + Constraint::Min(20), + ], + ) + .header(header_row(["PID", "PSS", "RSS", "Name", "Command"])) + .block(panel("top consumers")) + .column_spacing(1), + right[1], + ); + } else if app.search_summary().is_some() { + frame.render_widget( + Paragraph::new(search_summary_lines(app, capacity)) + .block(panel("search total")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + } +} + +fn row_meminfo(entry: &MeminfoEntry, total: Bytes) -> Row<'static> { + Row::new(vec![ + Cell::from(entry.key.clone()), + usage_cell(entry.value, total), + Cell::from(format!("{:.1}", entry.value.pct_of(total))) + .style(Style::default().fg(usage_color(entry.value, total))), + ]) + .style(usage_style(false, entry.value, total)) +} + +fn summary_row(label: &str, value: Bytes, total: Bytes) -> Row<'static> { + Row::new(vec![ + Cell::from(label.to_string()), + usage_cell(value, total), + ]) + .style(usage_style(false, value, total)) +} + +fn summary_text_row(label: &str, value: &str) -> Row<'static> { + Row::new(vec![ + Cell::from(label.to_string()), + Cell::from(value.to_string()), + ]) +} + +fn row_process( + app: &App, + snapshot: &Snapshot, + row: &FlatProcessRow, + selected: bool, + capacity: Bytes, +) -> Row<'static> { + let node = &snapshot.process_tree.nodes[row.index]; + let rollup = app.process_scope.rollup(node); + let marker = match row.fold { + RowFold::Leaf => " ", + RowFold::Collapsed => "▸", + RowFold::Expanded => "▾", + }; + let name = format!("{}{} {}", " ".repeat(row.depth), marker, node.name); + Row::new(vec![ + Cell::from(name), + Cell::from(node.pid.to_string()), + Cell::from(node.username.clone()), + usage_cell(rollup.pss, capacity), + usage_cell(rollup.uss(), capacity), + usage_cell(rollup.rss, capacity), + Cell::from(node.command.clone()), + ]) + .style(usage_style_for_role( + selected, + rollup.metric(app.metric), + capacity, + row.search, + )) +} + +fn row_tmpfs(row: &FlatTmpfsRow, selected: bool, capacity: Bytes) -> Row<'static> { + let marker = match row.fold { + RowFold::Leaf => " ", + RowFold::Collapsed => "▸", + RowFold::Expanded => "▾", + }; + let label = format!("{}{} {}", " ".repeat(row.depth), marker, row.name); + Row::new(vec![ + Cell::from(label), + Cell::from(row.kind.label().to_string()), + usage_cell(row.allocated, capacity), + usage_cell(row.logical, capacity), + Cell::from(row.path.display().to_string()), + ]) + .style(usage_style_for_role( + selected, + row.allocated, + capacity, + row.search, + )) +} + +fn row_shared( + snapshot: &Snapshot, + row: &FlatSharedRow, + selected: bool, + capacity: Bytes, +) -> Row<'static> { + let object = &snapshot.shared_objects[row.index]; + Row::new(vec![ + Cell::from(object.kind.label().to_string()), + Cell::from(object.mapped_processes.to_string()), + usage_cell(object.rollup.pss, capacity), + usage_cell(object.rollup.rss, capacity), + Cell::from(object.regions.to_string()), + Cell::from(object.label.clone()), + ]) + .style(usage_style_for_role( + selected, + object.rollup.pss, + capacity, + row.search, + )) +} + +fn row_object_usage(object: &ObjectUsage, capacity: Bytes) -> Row<'static> { + Row::new(vec![ + Cell::from(object.kind.label().to_string()), + usage_cell(object.rollup.pss, capacity), + usage_cell(object.rollup.rss, capacity), + Cell::from(object.regions.to_string()), + Cell::from(object.label.clone()), + ]) + .style(usage_style(false, object.rollup.pss, capacity)) +} + +fn tmpfs_detail_lines(mount: &TmpfsMount, row: &FlatTmpfsRow) -> Vec<Line<'static>> { + let mut lines = vec![ + Line::from(Span::styled( + row.path.display().to_string(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + detail_line("Mount", &mount.mount_point.display().to_string()), + detail_line("Source", &mount.source), + detail_line("Kind", row.kind.label()), + detail_line("Allocated", &row.allocated.human_exact()), + detail_line("Logical", &row.logical.human_exact()), + ]; + if let Some(limit) = mount.size_limit { + lines.push(detail_line("Mount size", &limit.human_exact())); + lines.push(detail_line( + "Utilization", + &format!("{:.1}%", row.allocated.pct_of(limit)), + )); + } + lines +} + +fn search_summary_lines(app: &App, capacity: Bytes) -> Vec<Line<'static>> { + let Some(summary) = app.search_summary() else { + return Vec::new(); + }; + let pattern = app.search_pattern().unwrap_or_default(); + vec![ + Line::from(Span::styled( + "regexp matches", + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + )), + detail_line("regexp", &format!("/{pattern}/")), + detail_line("matches", &summary.matches.to_string()), + detail_line(summary.lens, &summary.total.human_exact()), + detail_line( + "pct total", + &format!("{:.2}%", summary.total.pct_of(capacity)), + ), + detail_line("mode", app.search_scope_label()), + Line::from(""), + ] +} + +fn detail_line(label: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:>12} "), Style::default().fg(MUTED)), + Span::styled(value.to_string(), Style::default().fg(FG)), + ]) +} + +fn header_row<const N: usize>(values: [&str; N]) -> Row<'static> { + Row::new( + values + .into_iter() + .map(|value| Cell::from(value.to_string())), + ) + .style(Style::default().fg(GOLD).add_modifier(Modifier::BOLD)) +} + +fn usage_style(selected: bool, value: Bytes, total: Bytes) -> Style { + row_fg_style(selected, usage_color(value, total)) +} + +fn usage_style_for_role(selected: bool, value: Bytes, total: Bytes, role: SearchRole) -> Style { + if role.is_context() && !selected { + Style::default().fg(MUTED) + } else { + usage_style(selected, value, total) + } +} + +fn row_fg_style(selected: bool, fg: Color) -> Style { + if selected { + Style::default() + .fg(fg) + .bg(Color::Rgb(28, 44, 61)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(fg) + } +} + +fn usage_cell(value: Bytes, total: Bytes) -> Cell<'static> { + Cell::from(value.human_iec()).style(Style::default().fg(usage_color(value, total))) +} + +fn usage_color(value: Bytes, total: Bytes) -> Color { + if value.0 == 0 || total.0 == 0 { + return Color::Rgb(105, 113, 121); + } + + let pct = (value.as_f64() / total.as_f64()).clamp(0.0, 1.0); + if pct <= 0.03 { + return blend_rgb((105, 113, 121), (246, 248, 250), pct / 0.03); + } + blend_rgb((246, 248, 250), (232, 58, 46), (pct - 0.03) / 0.97) +} + +fn blend_rgb(start: (u8, u8, u8), end: (u8, u8, u8), t: f64) -> Color { + Color::Rgb( + blend_channel(start.0, end.0, t), + blend_channel(start.1, end.1, t), + blend_channel(start.2, end.2, t), + ) +} + +fn blend_channel(start: u8, end: u8, t: f64) -> u8 { + (f64::from(start) + (f64::from(end) - f64::from(start)) * t) + .round() + .clamp(0.0, 255.0) as u8 +} + +fn panel(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .title(title.to_string()) + .style(Style::default().fg(FG).bg(BG)) +} + +fn render_help(frame: &mut Frame<'_>, app: &App, area: Rect) { + let popup = centered_rect(area, 82, 88); + frame.render_widget(Clear, popup); + let hotkeys = app.hotkey_sections(); + let mut text = vec![ + Line::from(Span::styled( + "memview keys", + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + section_heading("Global"), + ]; + text.extend(hotkeys.global.iter().map(hotkey_line)); + text.extend([ + Line::from(""), + section_heading(&format!("Pane: {}", hotkeys.pane_title)), + ]); + text.extend(hotkeys.pane.iter().map(hotkey_line)); + text.extend([ + Line::from(""), + section_heading("Notes"), + Line::from("Overview shows raw kernel counters and the main reconciliation lenses."), + Line::from("Processes uses PSS so shared pages are not double-counted."), + Line::from( + "Tmpfs uses allocated blocks, which is closer to actual backing than file length.", + ), + Line::from("Tree panes auto-fold subtrees below min(1% RAM, 3% largest non-root subtree)."), + Line::from( + "Shared aggregates mapped objects across tasks: tmpfs, memfd, SYSV, files, and anon.", + ), + ]); + frame.render_widget( + Paragraph::new(text) + .block(panel("Help")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + popup, + ); +} + +fn section_heading(label: &str) -> Line<'static> { + Line::from(Span::styled( + label.to_string(), + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + )) +} + +fn hotkey_line(hotkey: &Hotkey) -> Line<'static> { + detail_line(hotkey.key, hotkey.action) +} + +fn render_kill_confirmation(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(confirmation) = app.kill_confirmation.as_ref() else { + return; + }; + + let popup = centered_rect(area, 88, 88); + frame.render_widget(Clear, popup); + let block = panel("kill"); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(inner); + + let top = vec![ + Line::from(Span::styled( + "Send SIGTERM to this process?", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + detail_line("PID", &confirmation.target.pid.to_string()), + detail_line("Name", &confirmation.target.name), + ]; + frame.render_widget( + Paragraph::new(top).style(Style::default().fg(FG)), + sections[0], + ); + + frame.render_widget( + Paragraph::new(confirmation.target.cli().to_string()) + .block( + Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .title("Full CLI") + .style(Style::default().fg(ACCENT).bg(BG)), + ) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG).bg(BG)), + sections[1], + ); + + let mut controls = Vec::new(); + if confirmation.armed() { + controls.push(Line::from(Span::styled( + "press y to send SIGTERM", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + ))); + } else { + controls.push(detail_line( + "lockout", + &format!( + "{} ms before y is accepted", + confirmation.lock_remaining().as_millis() + ), + )); + } + controls.push(detail_line("cancel", "Esc or n")); + + frame.render_widget( + Paragraph::new(controls) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG).bg(BG)), + sections[2], + ); +} + +fn render_search_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(draft) = app.search_draft() else { + return; + }; + + let popup = centered_rect(area, 76, 22); + frame.render_widget(Clear, popup); + let mut lines = vec![ + Line::from(Span::styled( + "Filter rows by regexp", + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled("/", Style::default().fg(GOLD)), + Span::styled(draft.input().to_string(), Style::default().fg(FG)), + ]), + Line::from(""), + detail_line("accept", "Enter"), + detail_line("clear", "empty input, then Enter"), + detail_line("cancel", "Esc"), + ]; + if let Some(error) = draft.error() { + lines.push(detail_line("error", error)); + } + frame.render_widget( + Paragraph::new(lines) + .block(panel("search")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + popup, + ); +} + +fn centered_rect(area: Rect, width_pct: u16, height_pct: u16) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_pct) / 2), + Constraint::Percentage(height_pct), + Constraint::Percentage((100 - height_pct) / 2), + ]) + .split(area); + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_pct) / 2), + Constraint::Percentage(width_pct), + Constraint::Percentage((100 - width_pct) / 2), + ]) + .split(vertical[1]); + horizontal[1] +} + +struct SliceWindow<'a, T> { + start: usize, + slice: &'a [T], +} + +impl<T> std::ops::Deref for SliceWindow<'_, T> { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + self.slice + } +} + +fn slice_window<T>(items: &[T], selected: usize, height: usize) -> SliceWindow<'_, T> { + if items.is_empty() { + return SliceWindow { + start: 0, + slice: items, + }; + } + let height = height.max(1); + let half = height / 2; + let start = selected + .saturating_sub(half) + .min(items.len().saturating_sub(height)); + let end = (start + height).min(items.len()); + SliceWindow { + start, + slice: &items[start..end], + } +} |