Compare commits

...

1 Commits

Author SHA1 Message Date
jasonwitty 25837063f5 feat(ui): compact layout for small terminal windows
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
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 <noreply@anthropic.com>
2026-08-18 21:13:43 -07:00
7 changed files with 692 additions and 103 deletions
+27
View File
@@ -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 - Only top-level processes listed (threads hidden) — matches btop/top
- Optional GPU metrics (can be disabled) - Optional GPU metrics (can be disabled)
- Optional auth token for the agent - 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: # shorthand:
socktop -t /path/to/cert.pem wss://HOST:8443/ws socktop -t /path/to/cert.pem wss://HOST:8443/ws
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget # 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): Intervals (client-driven):
@@ -224,6 +228,29 @@ The agent stays idle unless queried. When queried, it collects just whats 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) ## Connection Profiles (Named)
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later. You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
+53 -95
View File
@@ -15,7 +15,7 @@ use ratatui::{
//style::Color, // + add Color //style::Color, // + add Color
Terminal, Terminal,
backend::CrosstermBackend, backend::CrosstermBackend,
layout::{Constraint, Direction, Rect}, layout::Rect,
}; };
use tokio::time::{sleep, timeout}; 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_content_area, per_core_handle_key, per_core_handle_mouse,
per_core_handle_scrollbar_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::modal::{ModalAction, ModalManager, ModalType};
use crate::ui::processes::{ use crate::ui::processes::{
ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection, ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection,
@@ -34,7 +35,7 @@ use crate::ui::processes::{
}; };
use crate::ui::{ use crate::ui::{
disks::draw_disks, disks::draw_disks,
gpu::draw_gpu, gpu::{draw_gpu, draw_gpu_compact},
header::{build_header_intervals, build_header_title, draw_header}, header::{build_header_intervals, build_header_title, draw_header},
mem::draw_mem, mem::draw_mem,
net::draw_net_spark, net::draw_net_spark,
@@ -145,6 +146,10 @@ pub struct App {
pub is_tls: bool, pub is_tls: bool,
pub has_token: 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 // Cached title strings — only rebuilt when source values change so the
// diff renderer can suppress redraws on idle frames. // diff renderer can suppress redraws on idle frames.
header_title: String, header_title: String,
@@ -229,6 +234,7 @@ impl App {
verify_hostname: false, verify_hostname: false,
is_tls: false, is_tls: false,
has_token: false, has_token: false,
force_compact: false,
header_title: String::new(), header_title: String::new(),
header_title_key: (String::new(), false, false), header_title_key: (String::new(), false, false),
header_intervals_text: String::new(), 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<u64>, procs_ms: Option<u64>) -> Self { pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
metrics_ms.inspect(|&m| { metrics_ms.inspect(|&m| {
self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS)); 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) // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
let sz = terminal.size()?; let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height); let area = Rect::new(0, 0, sz.width, sz.height);
let rows = ratatui::layout::Layout::default() let layout = self.layout(area);
.direction(Direction::Vertical) let content = per_core_content_area(layout.per_core);
.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]);
// Refresh the filtered+sorted index cache once before we // Refresh the filtered+sorted index cache once before we
// borrow individual fields of `self`. // borrow individual fields of `self`.
@@ -915,23 +925,10 @@ impl App {
// Layout to get areas // Layout to get areas
let sz = terminal.size()?; let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height); let area = Rect::new(0, 0, sz.width, sz.height);
let rows = ratatui::layout::Layout::default() let layout = self.layout(area);
.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]);
// Content wheel scrolling // Content wheel scrolling
let content = per_core_content_area(top[1]); let content = per_core_content_area(layout.per_core);
per_core_handle_mouse( per_core_handle_mouse(
&mut self.per_core_scroll, &mut self.per_core_scroll,
m, m,
@@ -949,7 +946,7 @@ impl App {
&mut self.per_core_scroll, &mut self.per_core_scroll,
&mut self.per_core_drag, &mut self.per_core_drag,
m, m,
top[1], layout.per_core,
total_rows, total_rows,
); );
@@ -1278,18 +1275,7 @@ impl App {
pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) { pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
let area = f.area(); let area = f.area();
let l = self.layout(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);
// Header — refresh cached strings only when their inputs change so the // Header — refresh cached strings only when their inputs change so the
// ratatui diff renderer can suppress repaints on idle frames. // ratatui diff renderer can suppress repaints on idle frames.
@@ -1315,69 +1301,41 @@ impl App {
self.header_intervals_key = intervals_key; self.header_intervals_key = intervals_key;
} }
} }
draw_header(f, rows[0], &self.header_title, &self.header_intervals_text); draw_header(f, l.header, &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_cpu_avg_graph( draw_cpu_avg_graph(
f, f,
top_lr[0], l.cpu,
&mut self.cpu_hist, &mut self.cpu_hist,
self.cpu_hist_sum, self.cpu_hist_sum,
self.last_metrics.as_ref(), self.last_metrics.as_ref(),
); );
draw_per_core_bars( draw_per_core_bars(
f, f,
top_lr[1], l.per_core,
self.last_metrics.as_ref(), self.last_metrics.as_ref(),
&mut self.per_core_hist, &mut self.per_core_hist,
self.per_core_scroll, self.per_core_scroll,
); );
// Memory + Swap rows split into left/right columns // Memory + Swap: stacked vertically in the normal layout, side by side in the
let mem_lr = ratatui::layout::Layout::default() // row Disks vacates in compact mode.
.direction(Direction::Horizontal) draw_mem(f, l.mem, self.last_metrics.as_ref());
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) draw_swap(f, l.swap, self.last_metrics.as_ref());
.split(rows[2]);
let swap_lr = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[3]);
// Left: Memory + Swap // GPU: a panel beside Memory/Swap normally, a single full-width line in compact
draw_mem(f, mem_lr[0], self.last_metrics.as_ref()); // mode, and absent entirely when the host reports no GPU while compact.
draw_swap(f, swap_lr[0], self.last_metrics.as_ref()); 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 if let Some(disks_area) = l.disks {
let gpu_area = ratatui::layout::Rect { draw_disks(f, disks_area, self.last_metrics.as_ref());
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());
// Net titles only change when the throughput or peak changes. // Net titles only change when the throughput or peak changes.
let rx_now = self.rx_hist.back().copied().unwrap_or(0); let rx_now = self.rx_hist.back().copied().unwrap_or(0);
@@ -1388,7 +1346,7 @@ impl App {
} }
draw_net_spark( draw_net_spark(
f, f,
left_stack[1], l.download,
&self.net_dl_title, &self.net_dl_title,
&mut self.rx_hist, &mut self.rx_hist,
ratatui::style::Color::Green, ratatui::style::Color::Green,
@@ -1402,14 +1360,14 @@ impl App {
} }
draw_net_spark( draw_net_spark(
f, f,
left_stack[2], l.upload,
&self.net_ul_title, &self.net_ul_title,
&mut self.tx_hist, &mut self.tx_hist,
ratatui::style::Color::Blue, ratatui::style::Color::Blue,
); );
// Right bottom: Top Processes fills the column // Right bottom: Top Processes fills the column
let procs_area = bottom_lr[1]; let procs_area = l.procs;
// Cache for input handlers // Cache for input handlers
self.last_procs_area = Some(procs_area); self.last_procs_area = Some(procs_area);
// Refresh the filter cache before partial borrows of self. // Refresh the filter cache before partial borrows of self.
+20 -8
View File
@@ -22,6 +22,7 @@ pub(crate) struct ParsedArgs {
metrics_interval_ms: Option<u64>, metrics_interval_ms: Option<u64>,
processes_interval_ms: Option<u64>, processes_interval_ms: Option<u64>,
verify_hostname: bool, verify_hostname: bool,
compact: bool,
} }
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> { pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
@@ -36,11 +37,12 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
let mut metrics_interval_ms: Option<u64> = None; let mut metrics_interval_ms: Option<u64> = None;
let mut processes_interval_ms: Option<u64> = None; let mut processes_interval_ms: Option<u64> = None;
let mut verify_hostname = false; let mut verify_hostname = false;
let mut compact = false;
while let Some(arg) = it.next() { while let Some(arg) = it.next() {
match arg.as_str() { match arg.as_str() {
"-h" | "--help" => { "-h" | "--help" => {
return Err(format!( 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" => { "--tls-ca" | "-t" => {
@@ -61,6 +63,11 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
"--demo" => { "--demo" => {
demo = true; 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" => { "--dry-run" => {
// intentionally undocumented // intentionally undocumented
dry_run = true; dry_run = true;
@@ -100,7 +107,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
url = Some(arg); url = Some(arg);
} else { } else {
return Err(format!( return Err(format!(
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]" "Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [ws://HOST:PORT/ws]"
)); ));
} }
} }
@@ -116,6 +123,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
metrics_interval_ms, metrics_interval_ms,
processes_interval_ms, processes_interval_ms,
verify_hostname, verify_hostname,
compact,
}) })
} }
@@ -136,7 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) { 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(); let profiles_file = load_profiles();
@@ -241,7 +249,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if (1..=names.len()).contains(&idx) { if (1..=names.len()).contains(&idx) {
let name = &names[idx - 1]; let name = &names[idx - 1];
if name == "demo" { 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) { if let Some(entry) = profiles_mut.profiles.get(name) {
( (
@@ -301,7 +309,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
eprintln!("If you don't have an agent running, you can try the demo mode."); 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]: ") { 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 { } else {
eprintln!("Aborting. You can run 'socktop --help' for usage information."); eprintln!("Aborting. You can run 'socktop --help' for usage information.");
return Ok(()); return Ok(());
@@ -315,7 +323,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let has_token = url.contains("token="); let has_token = url.contains("token=");
let mut app = App::new() let mut app = App::new()
.with_intervals(metrics_interval_ms, processes_interval_ms) .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 { if parsed.dry_run {
return Ok(()); return Ok(());
} }
@@ -379,7 +388,10 @@ fn gather_intervals(
} }
// Demo mode implementation // Demo mode implementation
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { async fn run_demo_mode(
_tls_ca: Option<&str>,
compact: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let port = 3231; let port = 3231;
let url = format!("ws://127.0.0.1:{port}/ws"); let url = format!("ws://127.0.0.1:{port}/ws");
let child = match spawn_demo_agent(port) { let child = match spawn_demo_agent(port) {
@@ -392,7 +404,7 @@ async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::
} }
Err(e) => return Err(e.into()), Err(e) => 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 // 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(()) } } tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
} }
+206
View File
@@ -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<Vec<GpuInfo>>) -> 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"));
}
}
+352
View File
@@ -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<Rect>,
pub mem: Rect,
pub swap: Rect,
pub disks: Option<Rect>,
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}");
}
}
}
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod cpu;
pub mod disks; pub mod disks;
pub mod gpu; pub mod gpu;
pub mod header; pub mod header;
pub mod layout;
pub mod mem; pub mod mem;
pub mod modal; pub mod modal;
pub mod modal_connection; pub mod modal_connection;
+33
View File
@@ -73,3 +73,36 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
); );
assert!(text3.contains("Usage:")); 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)
);
}