From 623a6e5f85a2cf6da2024aff19457af272e55d54 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Tue, 18 Aug 2026 22:29:54 -0700 Subject: [PATCH] feat(ui): compact layout for small terminal windows (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a short window the fixed root layout runs out of rows and the CPU graph and per-core bars are what collapse first: the header, gauges and process table hold fixed heights, so at ~18 rows the top row is left with no drawable interior and both panes disappear entirely. Add a second layout, entered automatically once the Disks pane can no longer show even one complete disk card: - Disks is dropped — it is the pane that degrades worst when partly drawn. - Memory and Swap move side by side into the row Disks vacated. - GPU collapses to a single full-width line (utilisation and VRAM, no device name), and is omitted entirely on a host that reports no GPU. - The reclaimed rows go to the CPU panes, with the surplus above their floor shared with the bottom half so the process table still grows with the window. `--compact` pins the layout at any size. The root layout was duplicated in three places (the draw path and both input hit-testing paths), which would have drifted the moment a second layout existed. Move it into ui::layout as the single source of truth and have all three callers go through it. Co-authored-by: Claude Opus 5 --- README.md | 27 +++ socktop/src/app.rs | 148 ++++++---------- socktop/src/main.rs | 28 ++- socktop/src/ui/gpu.rs | 206 ++++++++++++++++++++++ socktop/src/ui/layout.rs | 352 ++++++++++++++++++++++++++++++++++++++ socktop/src/ui/mod.rs | 1 + socktop/tests/cli_args.rs | 33 ++++ 7 files changed, 692 insertions(+), 103 deletions(-) create mode 100644 socktop/src/ui/layout.rs diff --git a/README.md b/README.md index 9edefff..4a44831 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin - Only top-level processes listed (threads hidden) — matches btop/top - Optional GPU metrics (can be disabled) - Optional auth token for the agent +- Compact layout for small windows: automatically drops the panes that no longer fit so + the CPU graph and per-core bars stay visible (see [Compact mode](#compact-mode)) --- @@ -213,6 +215,8 @@ socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws # shorthand: socktop -t /path/to/cert.pem wss://HOST:8443/ws # Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget +# force the small-window layout at any terminal size (normally automatic): +socktop --compact ws://HOST:3000/ws ``` Intervals (client-driven): @@ -224,6 +228,29 @@ The agent stays idle unless queried. When queried, it collects just what’s nee --- +## Compact mode + +In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars +are the first things to collapse — exactly the panes you are most likely watching. Once +the window is too short for the Disks pane to show even one disk, socktop switches to a +compact layout: + +- **Disks is dropped.** It is the pane that degrades worst when partially drawn. +- **Memory and Swap move side by side** into the row Disks vacated. +- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host + with no GPU the pane disappears entirely. +- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well + below the size where they used to vanish. + +The switch is automatic and needs no configuration. Pass `--compact` to pin the compact +layout at any window size: + +```bash +socktop --compact ws://HOST:3000/ws +``` + +--- + ## Connection Profiles (Named) You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later. diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 2e24882..68551b7 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -15,7 +15,7 @@ use ratatui::{ //style::Color, // + add Color Terminal, backend::CrosstermBackend, - layout::{Constraint, Direction, Rect}, + layout::Rect, }; use tokio::time::{sleep, timeout}; @@ -27,6 +27,7 @@ use crate::ui::cpu::{ per_core_content_area, per_core_handle_key, per_core_handle_mouse, per_core_handle_scrollbar_mouse, }; +use crate::ui::layout::{AppLayout, compute as compute_layout}; use crate::ui::modal::{ModalAction, ModalManager, ModalType}; use crate::ui::processes::{ ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection, @@ -34,7 +35,7 @@ use crate::ui::processes::{ }; use crate::ui::{ disks::draw_disks, - gpu::draw_gpu, + gpu::{draw_gpu, draw_gpu_compact}, header::{build_header_intervals, build_header_title, draw_header}, mem::draw_mem, net::draw_net_spark, @@ -145,6 +146,10 @@ pub struct App { pub is_tls: bool, pub has_token: bool, + // --compact: pin the compact layout regardless of window size. Without it the + // layout switches on its own once the window is too short for the Disks pane. + force_compact: bool, + // Cached title strings — only rebuilt when source values change so the // diff renderer can suppress redraws on idle frames. header_title: String, @@ -229,6 +234,7 @@ impl App { verify_hostname: false, is_tls: false, has_token: false, + force_compact: false, header_title: String::new(), header_title_key: (String::new(), false, false), header_intervals_text: String::new(), @@ -247,6 +253,23 @@ impl App { } } + /// Pins the compact layout at any window size (`--compact`). + pub fn with_compact(mut self, force_compact: bool) -> Self { + self.force_compact = force_compact; + self + } + + /// Pane rects for the current frame. The draw path and the mouse/key hit-testing + /// paths all go through here so they cannot disagree about where a pane is. + fn layout(&self, area: Rect) -> AppLayout { + let has_gpu = self + .last_metrics + .as_ref() + .and_then(|m| m.gpus.as_ref()) + .is_some_and(|g| !g.is_empty()); + compute_layout(area, self.force_compact, has_gpu) + } + pub fn with_intervals(mut self, metrics_ms: Option, procs_ms: Option) -> Self { metrics_ms.inspect(|&m| { self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS)); @@ -803,21 +826,8 @@ impl App { // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) let sz = terminal.size()?; let area = Rect::new(0, 0, sz.width, sz.height); - let rows = ratatui::layout::Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Ratio(1, 3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(10), - ]) - .split(area); - let top = ratatui::layout::Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) - .split(rows[1]); - let content = per_core_content_area(top[1]); + let layout = self.layout(area); + let content = per_core_content_area(layout.per_core); // Refresh the filtered+sorted index cache once before we // borrow individual fields of `self`. @@ -915,23 +925,10 @@ impl App { // Layout to get areas let sz = terminal.size()?; let area = Rect::new(0, 0, sz.width, sz.height); - let rows = ratatui::layout::Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Ratio(1, 3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(10), - ]) - .split(area); - let top = ratatui::layout::Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) - .split(rows[1]); + let layout = self.layout(area); // Content wheel scrolling - let content = per_core_content_area(top[1]); + let content = per_core_content_area(layout.per_core); per_core_handle_mouse( &mut self.per_core_scroll, m, @@ -949,7 +946,7 @@ impl App { &mut self.per_core_scroll, &mut self.per_core_drag, m, - top[1], + layout.per_core, total_rows, ); @@ -1278,18 +1275,7 @@ impl App { pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) { let area = f.area(); - - // Root rows: header, top (cpu avg + per-core), memory, swap, bottom - let rows = ratatui::layout::Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // header - Constraint::Ratio(1, 3), // top row - Constraint::Length(3), // memory (left) + GPU (right, part 1) - Constraint::Length(3), // swap (left) + GPU (right, part 2) - Constraint::Min(10), // bottom: disks + net (left), top procs (right) - ]) - .split(area); + let l = self.layout(area); // Header — refresh cached strings only when their inputs change so the // ratatui diff renderer can suppress repaints on idle frames. @@ -1315,69 +1301,41 @@ impl App { self.header_intervals_key = intervals_key; } } - draw_header(f, rows[0], &self.header_title, &self.header_intervals_text); - - // Top row: left CPU avg, right Per-core (full top-right) - let top_lr = ratatui::layout::Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) - .split(rows[1]); + draw_header(f, l.header, &self.header_title, &self.header_intervals_text); draw_cpu_avg_graph( f, - top_lr[0], + l.cpu, &mut self.cpu_hist, self.cpu_hist_sum, self.last_metrics.as_ref(), ); draw_per_core_bars( f, - top_lr[1], + l.per_core, self.last_metrics.as_ref(), &mut self.per_core_hist, self.per_core_scroll, ); - // Memory + Swap rows split into left/right columns - let mem_lr = ratatui::layout::Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) - .split(rows[2]); - let swap_lr = ratatui::layout::Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) - .split(rows[3]); + // Memory + Swap: stacked vertically in the normal layout, side by side in the + // row Disks vacates in compact mode. + draw_mem(f, l.mem, self.last_metrics.as_ref()); + draw_swap(f, l.swap, self.last_metrics.as_ref()); - // Left: Memory + Swap - draw_mem(f, mem_lr[0], self.last_metrics.as_ref()); - draw_swap(f, swap_lr[0], self.last_metrics.as_ref()); + // GPU: a panel beside Memory/Swap normally, a single full-width line in compact + // mode, and absent entirely when the host reports no GPU while compact. + if let Some(gpu_area) = l.gpu { + if l.mode.is_compact() { + draw_gpu_compact(f, gpu_area, self.last_metrics.as_ref()); + } else { + draw_gpu(f, gpu_area, self.last_metrics.as_ref()); + } + } - // Right: GPU spans the same vertical space as Memory + Swap - let gpu_area = ratatui::layout::Rect { - x: mem_lr[1].x, - y: mem_lr[1].y, - width: mem_lr[1].width, - height: mem_lr[1].height + swap_lr[1].height, - }; - draw_gpu(f, gpu_area, self.last_metrics.as_ref()); - - // Bottom area: left = Disks + Network, right = Top Processes - let bottom_lr = ratatui::layout::Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) - .split(rows[4]); - - // Left bottom: Disks + Net stacked (make net panes slightly taller) - let left_stack = ratatui::layout::Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(4), // Disks shrink slightly - Constraint::Length(5), // Download taller - Constraint::Length(5), // Upload taller - ]) - .split(bottom_lr[0]); - - draw_disks(f, left_stack[0], self.last_metrics.as_ref()); + if let Some(disks_area) = l.disks { + draw_disks(f, disks_area, self.last_metrics.as_ref()); + } // Net titles only change when the throughput or peak changes. let rx_now = self.rx_hist.back().copied().unwrap_or(0); @@ -1388,7 +1346,7 @@ impl App { } draw_net_spark( f, - left_stack[1], + l.download, &self.net_dl_title, &mut self.rx_hist, ratatui::style::Color::Green, @@ -1402,14 +1360,14 @@ impl App { } draw_net_spark( f, - left_stack[2], + l.upload, &self.net_ul_title, &mut self.tx_hist, ratatui::style::Color::Blue, ); // Right bottom: Top Processes fills the column - let procs_area = bottom_lr[1]; + let procs_area = l.procs; // Cache for input handlers self.last_procs_area = Some(procs_area); // Refresh the filter cache before partial borrows of self. diff --git a/socktop/src/main.rs b/socktop/src/main.rs index 242901b..78e62ef 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -22,6 +22,7 @@ pub(crate) struct ParsedArgs { metrics_interval_ms: Option, processes_interval_ms: Option, verify_hostname: bool, + compact: bool, } pub(crate) fn parse_args>(args: I) -> Result { @@ -36,11 +37,12 @@ pub(crate) fn parse_args>(args: I) -> Result = None; let mut processes_interval_ms: Option = None; let mut verify_hostname = false; + let mut compact = false; while let Some(arg) = it.next() { match arg.as_str() { "-h" | "--help" => { return Err(format!( - "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n" + "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n" )); } "--tls-ca" | "-t" => { @@ -61,6 +63,11 @@ pub(crate) fn parse_args>(args: I) -> Result { demo = true; } + "--compact" => { + // Force the small-window layout at any terminal size. Without it the + // layout switches on its own once the window gets too short. + compact = true; + } "--dry-run" => { // intentionally undocumented dry_run = true; @@ -100,7 +107,7 @@ pub(crate) fn parse_args>(args: I) -> Result>(args: I) -> Result Result<(), Box> { } if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) { - return run_demo_mode(parsed.tls_ca.as_deref()).await; + return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await; } let profiles_file = load_profiles(); @@ -241,7 +249,7 @@ async fn main() -> Result<(), Box> { if (1..=names.len()).contains(&idx) { let name = &names[idx - 1]; if name == "demo" { - return run_demo_mode(parsed.tls_ca.as_deref()).await; + return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await; } if let Some(entry) = profiles_mut.profiles.get(name) { ( @@ -301,7 +309,7 @@ async fn main() -> Result<(), Box> { ); eprintln!("If you don't have an agent running, you can try the demo mode."); if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") { - return run_demo_mode(parsed.tls_ca.as_deref()).await; + return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await; } else { eprintln!("Aborting. You can run 'socktop --help' for usage information."); return Ok(()); @@ -315,7 +323,8 @@ async fn main() -> Result<(), Box> { let has_token = url.contains("token="); let mut app = App::new() .with_intervals(metrics_interval_ms, processes_interval_ms) - .with_status(is_tls, has_token); + .with_status(is_tls, has_token) + .with_compact(parsed.compact); if parsed.dry_run { return Ok(()); } @@ -379,7 +388,10 @@ fn gather_intervals( } // Demo mode implementation -async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box> { +async fn run_demo_mode( + _tls_ca: Option<&str>, + compact: bool, +) -> Result<(), Box> { let port = 3231; let url = format!("ws://127.0.0.1:{port}/ws"); let child = match spawn_demo_agent(port) { @@ -392,7 +404,7 @@ async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box return Err(e.into()), }; - let mut app = App::new(); + let mut app = App::new().with_compact(compact); // Demo mode connects to localhost, so disable hostname verification tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } } } diff --git a/socktop/src/ui/gpu.rs b/socktop/src/ui/gpu.rs index dc9b842..d246c5f 100644 --- a/socktop/src/ui/gpu.rs +++ b/socktop/src/ui/gpu.rs @@ -121,3 +121,209 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { ); } } + +/// One-line GPU strip for compact mode: no device name (it is the first thing to lose +/// value when rows are scarce), just utilisation and VRAM on the single content row +/// between the block borders. Only the first GPU fits; the title says so when there are +/// more. +pub fn draw_gpu_compact(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { + let gpus = m.and_then(|mm| mm.gpus.as_ref()); + let count = gpus.map(|g| g.len()).unwrap_or(0); + let title = if count > 1 { + format!("GPU (1/{count})") + } else { + "GPU".to_string() + }; + f.render_widget(Block::default().borders(Borders::ALL).title(title), area); + + if area.height < 3 || area.width <= 2 { + return; + } + let inner = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width - 2, + height: 1, + }; + + let Some(g) = gpus.and_then(|v| v.first()) else { + f.render_widget(Paragraph::new("No GPUs"), inner); + return; + }; + + let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16; + let used = g.mem_used.unwrap_or(0); + let total = g.mem_total.unwrap_or(1); + let mem_ratio = if total > 0 { + (used as f64 / total as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + let util_label = format!("util: {util}%"); + let mem_label = format!( + "vram: {}/{} ({}%)", + fmt_bytes(used), + fmt_bytes(total), + (mem_ratio * 100.0).round() as u16 + ); + + // Bars are sized explicitly rather than left to stretch: an idle bar renders as + // empty cells, so a full-width one turns into a long blank run between two labels. + const MIN_GAUGE_W: u16 = 6; + const MAX_GAUGE_W: u16 = 24; + let labels_w = util_label.len() as u16 + mem_label.len() as u16 + 4; // one space each side + let gauge_w = inner + .width + .saturating_sub(labels_w) + .min(2 * MAX_GAUGE_W) + .div_euclid(2); + + // Too narrow for bars worth drawing: keep the numbers, drop the bars. + if gauge_w < MIN_GAUGE_W { + f.render_widget( + Paragraph::new(Span::raw(format!("{util_label} {mem_label}"))) + .style(Style::default().fg(Color::Gray)), + inner, + ); + return; + } + + // Each label leads its own bar. Bar-then-label (as the tall panel does) is ambiguous + // on a single line: with an idle bar rendering empty, the next pair's fill ends up + // flush against the previous pair's text and reads as belonging to it. + let mut x = inner.x; + let mut place = |w: u16| { + let r = Rect { + x, + y: inner.y, + width: w, + height: 1, + }; + x += w; + r + }; + let util_rect = place(util_label.len() as u16 + 2); + let util_bar = place(gauge_w); + let mem_rect = place(mem_label.len() as u16 + 2); + let mem_bar = place(gauge_w); + + let label = |text: &str| { + Paragraph::new(Span::raw(format!(" {text} "))).style(Style::default().fg(Color::Gray)) + }; + + f.render_widget(label(&util_label), util_rect); + f.render_widget( + Gauge::default() + .gauge_style(Style::default().fg(Color::Green)) + .label(Span::raw("")) + .ratio(util as f64 / 100.0), + util_bar, + ); + f.render_widget(label(&mem_label), mem_rect); + f.render_widget( + Gauge::default() + .gauge_style(Style::default().fg(Color::LightMagenta)) + .label(Span::raw("")) + .ratio(mem_ratio), + mem_bar, + ); +} + +#[cfg(test)] +mod render_tests { + use super::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use socktop_connector::{GpuInfo, Metrics}; + + fn gpu(name: &str) -> GpuInfo { + GpuInfo { + name: Some(name.into()), + vendor: None, + utilization: Some(42.0), + mem_used: Some(4_724_464_025), + mem_total: Some(17_070_817_280), + temp: None, + } + } + + fn metrics(gpus: Option>) -> Metrics { + Metrics { + cpu_total: 0.0, + cpu_per_core: vec![], + mem_total: 1024, + mem_used: 0, + swap_total: 0, + swap_used: 0, + hostname: "t".into(), + cpu_temp_c: None, + disks: vec![], + networks: vec![], + top_processes: vec![], + gpus, + process_count: Some(0), + } + } + + fn render(width: u16, m: &Metrics) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, 3)).unwrap(); + terminal + .draw(|f| draw_gpu_compact(f, Rect::new(0, 0, width, 3), Some(m))) + .unwrap(); + let buf = terminal.backend().buffer(); + let mut out = String::new(); + for y in 0..buf.area().height { + for x in 0..buf.area().width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + /// Compact mode drops the device name — the row is one line and the numbers are + /// what the space is for. + #[test] + fn compact_strip_omits_the_device_name() { + let m = metrics(Some(vec![gpu("NVIDIA GeForce RTX 5080")])); + let out = render(80, &m); + assert!( + !out.contains("NVIDIA"), + "name leaked into compact strip:\n{out}" + ); + assert!(out.contains("util: 42%"), "{out}"); + assert!(out.contains("vram: 4.4G/15.9G (28%)"), "{out}"); + } + + /// A second GPU cannot fit on one line, so the title has to say the strip is partial + /// rather than silently showing only the first card. + #[test] + fn multiple_gpus_are_flagged_in_the_title() { + let one = render(80, &metrics(Some(vec![gpu("a")]))); + assert!(one.contains("GPU") && !one.contains("1/"), "{one}"); + + let two = render(80, &metrics(Some(vec![gpu("a"), gpu("b")]))); + assert!(two.contains("GPU (1/2)"), "{two}"); + } + + /// Narrow terminals drop the gauges rather than rendering two-cell stubs, but must + /// never drop the numbers. + #[test] + fn narrow_strip_keeps_the_numbers() { + let m = metrics(Some(vec![gpu("a")])); + for width in [20u16, 30, 40, 47, 48, 80, 200] { + let out = render(width, &m); + if width >= 40 { + assert!(out.contains("util: 42%"), "width {width}:\n{out}"); + } + // No panic, and the block always closes on the last row. + assert_eq!(out.lines().count(), 3, "width {width}"); + } + } + + #[test] + fn missing_gpu_payload_does_not_panic() { + assert!(render(80, &metrics(None)).contains("No GPUs")); + assert!(render(80, &metrics(Some(vec![]))).contains("No GPUs")); + } +} diff --git a/socktop/src/ui/layout.rs b/socktop/src/ui/layout.rs new file mode 100644 index 0000000..d195f31 --- /dev/null +++ b/socktop/src/ui/layout.rs @@ -0,0 +1,352 @@ +//! Root layout computation, shared by the draw path and the input hit-testing paths. +//! +//! Two modes: +//! +//! * [`LayoutMode::Normal`] — the full layout. CPU graph and per-core bars on top, +//! Memory over Swap on the left with the GPU panel beside them, then Disks and the +//! network graphs next to the process table. +//! +//! * [`LayoutMode::Compact`] — entered when the window is too short for the Disks pane +//! to render even one complete disk card. Disks is dropped, Memory and Swap move side +//! by side into the space it vacated, the GPU collapses to a single full-width line +//! (and disappears entirely when the host has no GPU), and every row reclaimed goes to +//! the CPU graph and per-core bars — which in the fixed layout are squeezed to nothing +//! long before the rest of the panes stop being useful. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +/// Which of the two layouts [`compute`] produced. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LayoutMode { + Normal, + Compact, +} + +impl LayoutMode { + pub fn is_compact(self) -> bool { + matches!(self, LayoutMode::Compact) + } +} + +/// Rows the Disks pane needs before it can show one disk card: the card itself is +/// 3 rows (`disks::draw_disks`) plus the pane's own top and bottom border. +const DISKS_MIN_H: u16 = 5; + +/// Header line. +const HEADER_H: u16 = 1; +/// Memory and Swap gauges: 1 content row between borders. +const GAUGE_H: u16 = 3; +/// A network graph at its preferred height. +const NET_H: u16 = 5; + +// Compact-mode budget. The top row is kept at `TOP_MIN_H` (3 content rows between +// borders) before the network graphs are allowed to shrink, because restoring the CPU +// panes is the entire point of the mode. +const TOP_MIN_H: u16 = 5; +const BOTTOM_PREF_H: u16 = GAUGE_H + 2 * NET_H; +const BOTTOM_MIN_H: u16 = GAUGE_H + 2 * 3; + +/// Every pane rect for one frame. `disks` and `gpu` are `None` when the mode omits them. +#[derive(Clone, Copy, Debug)] +pub struct AppLayout { + pub mode: LayoutMode, + pub header: Rect, + pub cpu: Rect, + pub per_core: Rect, + pub gpu: Option, + pub mem: Rect, + pub swap: Rect, + pub disks: Option, + pub download: Rect, + pub upload: Rect, + pub procs: Rect, +} + +/// Splits `area` into pane rects. +/// +/// `force_compact` comes from `--compact` and pins the compact layout at any size. +/// `has_gpu` decides whether compact mode reserves its one-line GPU strip; it is false +/// until the first metrics payload arrives, so a GPU-less host never reserves the row. +pub fn compute(area: Rect, force_compact: bool, has_gpu: bool) -> AppLayout { + if force_compact { + return compact(area, has_gpu); + } + let normal = normal(area); + match normal.disks { + Some(d) if d.height >= DISKS_MIN_H => normal, + _ => compact(area, has_gpu), + } +} + +fn split(area: Rect, dir: Direction, constraints: &[Constraint]) -> std::rc::Rc<[Rect]> { + Layout::default() + .direction(dir) + .constraints(constraints) + .split(area) +} + +/// 66/34 split used by every full-width row in the normal layout. +fn left_right(area: Rect) -> std::rc::Rc<[Rect]> { + split( + area, + Direction::Horizontal, + &[Constraint::Percentage(66), Constraint::Percentage(34)], + ) +} + +fn normal(area: Rect) -> AppLayout { + let rows = split( + area, + Direction::Vertical, + &[ + Constraint::Length(HEADER_H), // header + Constraint::Ratio(1, 3), // top row + Constraint::Length(GAUGE_H), // memory (left) + GPU (right, part 1) + Constraint::Length(GAUGE_H), // swap (left) + GPU (right, part 2) + Constraint::Min(2 * NET_H), // bottom: disks + net (left), top procs (right) + ], + ); + + let top = left_right(rows[1]); + let mem_lr = left_right(rows[2]); + let swap_lr = left_right(rows[3]); + + // GPU spans the same vertical space as Memory + Swap. + let gpu = Rect { + x: mem_lr[1].x, + y: mem_lr[1].y, + width: mem_lr[1].width, + height: mem_lr[1].height + swap_lr[1].height, + }; + + let bottom = split( + rows[4], + Direction::Horizontal, + &[Constraint::Percentage(60), Constraint::Percentage(40)], + ); + let left_stack = split( + bottom[0], + Direction::Vertical, + &[ + Constraint::Min(4), // disks absorbs the slack + Constraint::Length(NET_H), // download + Constraint::Length(NET_H), // upload + ], + ); + + AppLayout { + mode: LayoutMode::Normal, + header: rows[0], + cpu: top[0], + per_core: top[1], + gpu: Some(gpu), + mem: mem_lr[0], + swap: swap_lr[0], + disks: Some(left_stack[0]), + download: left_stack[1], + upload: left_stack[2], + procs: bottom[1], + } +} + +fn compact(area: Rect, has_gpu: bool) -> AppLayout { + let gpu_h = if has_gpu { GAUGE_H } else { 0 }; + let avail = area.height.saturating_sub(HEADER_H + gpu_h); + + // Give the top row its floor first, then share any surplus with the bottom so the + // process table keeps growing with the window instead of staying pinned at 13 rows. + let (top_h, bottom_h) = if avail >= TOP_MIN_H + BOTTOM_PREF_H { + let top = TOP_MIN_H + (avail - TOP_MIN_H - BOTTOM_PREF_H) / 2; + (top, avail - top) + } else if avail >= TOP_MIN_H + BOTTOM_MIN_H { + (TOP_MIN_H, avail - TOP_MIN_H) + } else { + // Smaller than both floors: the network graphs are already at their minimum, so + // the top row takes what is left (panes clip below this point). + let bottom = BOTTOM_MIN_H.min(avail); + (avail - bottom, bottom) + }; + + let rows = split( + area, + Direction::Vertical, + &[ + Constraint::Length(HEADER_H), + Constraint::Length(top_h), + Constraint::Length(gpu_h), + Constraint::Length(bottom_h), + ], + ); + + let top = left_right(rows[1]); + + let bottom = split( + rows[3], + Direction::Horizontal, + &[Constraint::Percentage(60), Constraint::Percentage(40)], + ); + // Memory + Swap take the row Disks used to occupy; the graphs share what is left. + let left_stack = split( + bottom[0], + Direction::Vertical, + &[ + Constraint::Length(GAUGE_H), + Constraint::Fill(1), + Constraint::Fill(1), + ], + ); + let gauges = split( + left_stack[0], + Direction::Horizontal, + &[Constraint::Percentage(50), Constraint::Percentage(50)], + ); + + AppLayout { + mode: LayoutMode::Compact, + header: rows[0], + cpu: top[0], + per_core: top[1], + gpu: has_gpu.then_some(rows[2]), + mem: gauges[0], + swap: gauges[1], + disks: None, + download: left_stack[1], + upload: left_stack[2], + procs: bottom[1], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn area(w: u16, h: u16) -> Rect { + Rect::new(0, 0, w, h) + } + + /// The height where the normal layout still fits a full disk card. Below it the CPU + /// panes are the ones that collapse, which is what compact mode exists to prevent. + #[test] + fn tall_window_stays_normal() { + let l = compute(area(120, 40), false, true); + assert_eq!(l.mode, LayoutMode::Normal); + assert!(l.disks.expect("disks pane").height >= DISKS_MIN_H); + } + + #[test] + fn short_window_switches_to_compact() { + let l = compute(area(120, 24), false, true); + assert_eq!(l.mode, LayoutMode::Compact); + assert!(l.disks.is_none()); + } + + /// The switch happens exactly when Disks can no longer show one card, and never + /// oscillates: every height above the crossover is normal, every height below is + /// compact. + #[test] + fn mode_is_monotonic_in_height() { + let mut first_normal = None; + for h in 10..=60u16 { + let mode = compute(area(120, h), false, true).mode; + match (mode, first_normal) { + (LayoutMode::Normal, None) => first_normal = Some(h), + (LayoutMode::Compact, Some(prev)) => { + panic!("height {h} went back to compact after normal at {prev}") + } + _ => {} + } + } + assert!(first_normal.is_some(), "never reached the normal layout"); + } + + #[test] + fn force_compact_overrides_a_tall_window() { + let l = compute(area(200, 80), true, true); + assert_eq!(l.mode, LayoutMode::Compact); + assert!(l.disks.is_none()); + } + + #[test] + fn compact_drops_the_gpu_row_without_a_gpu() { + let with = compute(area(120, 24), true, true); + let without = compute(area(120, 24), true, false); + assert!(with.gpu.is_some()); + assert_eq!(with.gpu.expect("gpu strip").height, GAUGE_H); + assert!(without.gpu.is_none()); + // The rows a GPU-less host saves are shared between the CPU panes and the + // bottom half, and none of them are left as a gap. + assert!(without.cpu.height > with.cpu.height); + assert!(without.procs.height > with.procs.height); + assert_eq!(without.procs.y + without.procs.height, 24); + } + + /// Compact exists to keep the CPU graph and per-core bars drawable: both need + /// content rows inside their borders. + #[test] + fn compact_keeps_the_cpu_panes_drawable() { + for h in 18..=32u16 { + let l = compute(area(120, h), false, true); + assert_eq!(l.mode, LayoutMode::Compact, "height {h}"); + assert!( + l.cpu.height >= TOP_MIN_H, + "height {h}: cpu pane only {} rows", + l.cpu.height + ); + assert_eq!(l.per_core.height, l.cpu.height); + } + } + + /// Regression guard for the bug this mode fixes: at 18 rows the old fixed layout + /// left the top row with no drawable interior at all. + #[test] + fn compact_beats_the_fixed_layout_at_18_rows() { + let compact = compute(area(120, 18), false, true); + let fixed = normal(area(120, 18)); + assert!(fixed.cpu.height <= 2, "fixed layout unexpectedly usable"); + assert!(compact.cpu.height > fixed.cpu.height); + } + + #[test] + fn compact_panes_tile_the_area_without_gaps() { + for h in 16..=32u16 { + for has_gpu in [true, false] { + let l = compute(area(120, h), true, has_gpu); + assert_eq!(l.header.y, 0); + assert_eq!(l.cpu.y, l.header.y + l.header.height); + assert_eq!(l.per_core.x, l.cpu.x + l.cpu.width); + + let after_cpu = l.cpu.y + l.cpu.height; + let bottom_y = match l.gpu { + Some(g) => { + assert_eq!(g.y, after_cpu); + assert_eq!(g.width, 120, "gpu strip spans the full width"); + g.y + g.height + } + None => after_cpu, + }; + assert_eq!(l.mem.y, bottom_y); + // Memory and Swap sit side by side on one row. + assert_eq!(l.swap.y, l.mem.y); + assert_eq!(l.swap.x, l.mem.x + l.mem.width); + assert_eq!(l.mem.height, GAUGE_H); + assert_eq!(l.download.y, l.mem.y + l.mem.height); + assert_eq!(l.upload.y, l.download.y + l.download.height); + assert_eq!(l.procs.y, bottom_y); + } + } + } + + /// A degenerate size must not panic or produce rects outside the frame. + #[test] + fn tiny_windows_stay_inside_the_frame() { + for h in 0..=16u16 { + for w in [0u16, 1, 20, 80] { + let l = compute(area(w, h), false, true); + for r in [l.header, l.cpu, l.per_core, l.mem, l.swap, l.procs] { + assert!(r.y + r.height <= h, "{r:?} escapes height {h}"); + assert!(r.x + r.width <= w, "{r:?} escapes width {w}"); + } + } + } + } +} diff --git a/socktop/src/ui/mod.rs b/socktop/src/ui/mod.rs index 0e1e85e..7c1536a 100644 --- a/socktop/src/ui/mod.rs +++ b/socktop/src/ui/mod.rs @@ -4,6 +4,7 @@ pub mod cpu; pub mod disks; pub mod gpu; pub mod header; +pub mod layout; pub mod mem; pub mod modal; pub mod modal_connection; diff --git a/socktop/tests/cli_args.rs b/socktop/tests/cli_args.rs index debaa57..f767307 100644 --- a/socktop/tests/cli_args.rs +++ b/socktop/tests/cli_args.rs @@ -73,3 +73,36 @@ fn test_tlc_ca_arg_long_and_short_parsed() { ); assert!(text3.contains("Usage:")); } + +#[test] +fn test_compact_flag_documented_and_accepted() { + let exe = env!("CARGO_BIN_EXE_socktop"); + let out = Command::new(exe) + .args(["--compact", "--help"]) + .output() + .expect("run socktop --compact --help"); + assert!( + out.status.success(), + "socktop --compact --help did not succeed" + ); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + text.contains("--compact"), + "help text missing --compact\n{text}" + ); + + // The flag must not be mistaken for the positional URL argument. + let out2 = Command::new(exe) + .args(["--compact", "--dry-run", "ws://127.0.0.1:3000/ws"]) + .output() + .expect("run socktop --compact --dry-run"); + assert!( + out2.status.success(), + "socktop --compact with a URL was rejected: {}", + String::from_utf8_lossy(&out2.stderr) + ); +}