Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12a757ba1e | |||
| 623a6e5f85 | |||
| 20966d0c94 |
Generated
+1
@@ -2426,6 +2426,7 @@ dependencies = [
|
|||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"unicode-width",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ sysinfo = "0.37"
|
|||||||
# CLI UI
|
# CLI UI
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
|
unicode-width = "0.2"
|
||||||
|
|
||||||
# web server (remote-agent)
|
# web server (remote-agent)
|
||||||
axum = { version = "0.7", features = ["ws"] }
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
|
|||||||
@@ -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 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)
|
## 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.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ serde_json = { workspace = true }
|
|||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
crossterm = { workspace = true }
|
crossterm = { workspace = true }
|
||||||
|
unicode-width = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
dirs-next = { workspace = true }
|
dirs-next = { workspace = true }
|
||||||
sysinfo = { workspace = true }
|
sysinfo = { workspace = true }
|
||||||
|
|||||||
+73
-114
@@ -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,8 +35,8 @@ 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::{HeaderState, build_header, draw_header},
|
||||||
mem::draw_mem,
|
mem::draw_mem,
|
||||||
net::draw_net_spark,
|
net::draw_net_spark,
|
||||||
swap::draw_swap,
|
swap::draw_swap,
|
||||||
@@ -145,12 +146,15 @@ 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,
|
||||||
header_title_key: (String, bool, bool),
|
|
||||||
header_intervals_text: String,
|
header_intervals_text: String,
|
||||||
header_intervals_key: (u128, u128),
|
header_key: (String, bool, bool, u128, u128, u16),
|
||||||
net_dl_title: String,
|
net_dl_title: String,
|
||||||
net_dl_key: (u64, u64),
|
net_dl_key: (u64, u64),
|
||||||
net_ul_title: String,
|
net_ul_title: String,
|
||||||
@@ -229,10 +233,10 @@ 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_intervals_text: String::new(),
|
header_intervals_text: String::new(),
|
||||||
header_intervals_key: (u128::MAX, u128::MAX),
|
header_key: (String::new(), false, false, u128::MAX, u128::MAX, u16::MAX),
|
||||||
net_dl_title: String::new(),
|
net_dl_title: String::new(),
|
||||||
net_dl_key: (u64::MAX, u64::MAX),
|
net_dl_key: (u64::MAX, u64::MAX),
|
||||||
net_ul_title: String::new(),
|
net_ul_title: String::new(),
|
||||||
@@ -247,6 +251,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 +824,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 +923,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 +944,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,106 +1273,70 @@ 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. The wording now
|
||||||
|
// depends on the row width too, so that is part of the key.
|
||||||
{
|
{
|
||||||
let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str());
|
let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str());
|
||||||
|
let state = HeaderState {
|
||||||
|
hostname,
|
||||||
|
is_tls: self.is_tls,
|
||||||
|
has_token: self.has_token,
|
||||||
|
metrics_ms: self.metrics_interval.as_millis(),
|
||||||
|
procs_ms: self.procs_interval.as_millis(),
|
||||||
|
};
|
||||||
let key = (
|
let key = (
|
||||||
hostname.unwrap_or("").to_string(),
|
hostname.unwrap_or("").to_string(),
|
||||||
self.is_tls,
|
self.is_tls,
|
||||||
self.has_token,
|
self.has_token,
|
||||||
|
state.metrics_ms,
|
||||||
|
state.procs_ms,
|
||||||
|
l.header.width,
|
||||||
);
|
);
|
||||||
if self.header_title_key != key {
|
if self.header_key != key {
|
||||||
self.header_title = build_header_title(hostname, self.is_tls, self.has_token);
|
let (title, intervals) = build_header(state, l.header.width);
|
||||||
self.header_title_key = key;
|
self.header_title = title;
|
||||||
}
|
self.header_intervals_text = intervals;
|
||||||
|
self.header_key = key;
|
||||||
let intervals_key = (
|
|
||||||
self.metrics_interval.as_millis(),
|
|
||||||
self.procs_interval.as_millis(),
|
|
||||||
);
|
|
||||||
if self.header_intervals_key != intervals_key {
|
|
||||||
self.header_intervals_text =
|
|
||||||
build_header_intervals(intervals_key.0, intervals_key.1);
|
|
||||||
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 +1347,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 +1361,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
@@ -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(()) } }
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-26
@@ -14,6 +14,10 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::history::PerCoreHistory;
|
use crate::history::PerCoreHistory;
|
||||||
use crate::types::Metrics;
|
use crate::types::Metrics;
|
||||||
|
use crate::ui::fit::{cols, pick_pair};
|
||||||
|
|
||||||
|
/// Columns kept clear between the CPU title and the temperature readout.
|
||||||
|
const TITLE_GAP: u16 = 2;
|
||||||
|
|
||||||
/// State for dragging the scrollbar thumb
|
/// State for dragging the scrollbar thumb
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
@@ -248,29 +252,12 @@ pub fn draw_cpu_avg_graph(
|
|||||||
hist_sum as f64 / hist.len() as f64
|
hist_sum as f64 / hist.len() as f64
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = if let Some(mm) = m {
|
let (title, top_right_info) = cpu_title_for_width(
|
||||||
format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu)
|
m.map(|mm| mm.cpu_total),
|
||||||
} else {
|
avg_cpu,
|
||||||
"CPU avg".into()
|
m.and_then(|mm| mm.cpu_temp_c),
|
||||||
};
|
area.width,
|
||||||
|
);
|
||||||
// Build the top-right info (CPU temp and polling intervals)
|
|
||||||
let top_right_info = if let Some(mm) = m {
|
|
||||||
mm.cpu_temp_c
|
|
||||||
.map(|t| {
|
|
||||||
let icon = if t < 50.0 {
|
|
||||||
"😎"
|
|
||||||
} else if t < 85.0 {
|
|
||||||
"⚠️"
|
|
||||||
} else {
|
|
||||||
"🔥"
|
|
||||||
};
|
|
||||||
format!("CPU Temp: {t:.1}°C {icon}")
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "CPU Temp: N/A".into())
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
|
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
|
||||||
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
|
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
|
||||||
@@ -286,12 +273,14 @@ pub fn draw_cpu_avg_graph(
|
|||||||
.style(Style::default().fg(Color::Cyan));
|
.style(Style::default().fg(Color::Cyan));
|
||||||
f.render_widget(spark, area);
|
f.render_widget(spark, area);
|
||||||
|
|
||||||
// Render the top-right info as text overlay in the top-right corner
|
// Temperature overlays the top border, right-aligned inside the corner. The title
|
||||||
|
// above is sized so the two cannot collide.
|
||||||
if !top_right_info.is_empty() {
|
if !top_right_info.is_empty() {
|
||||||
|
let w = cols(&top_right_info);
|
||||||
let info_area = Rect {
|
let info_area = Rect {
|
||||||
x: area.x + area.width.saturating_sub(top_right_info.len() as u16 + 2),
|
x: area.x + area.width.saturating_sub(w + 1),
|
||||||
y: area.y,
|
y: area.y,
|
||||||
width: top_right_info.len() as u16 + 1,
|
width: w,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let info_line = Line::from(Span::raw(top_right_info));
|
let info_line = Line::from(Span::raw(top_right_info));
|
||||||
@@ -299,6 +288,67 @@ pub fn draw_cpu_avg_graph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Health glyph for a CPU temperature.
|
||||||
|
fn temp_icon(t: f32) -> &'static str {
|
||||||
|
if t < 50.0 {
|
||||||
|
"😎"
|
||||||
|
} else if t < 85.0 {
|
||||||
|
"⚠️"
|
||||||
|
} else {
|
||||||
|
"🔥"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chooses the CPU pane's title and its right-aligned temperature readout for a pane
|
||||||
|
/// `width` columns wide.
|
||||||
|
///
|
||||||
|
/// Both are painted onto the pane's top border, so without a shared budget the
|
||||||
|
/// temperature simply overwrites the tail of the title on a narrow pane. Detail is given
|
||||||
|
/// up in this order: the `CPU Temp:` label, then the `now:`/`avg:` labels, then the
|
||||||
|
/// average reading, then the decimal on the temperature, and only last the temperature
|
||||||
|
/// itself — the readings are what the pane is for, but a thermal warning is worth more
|
||||||
|
/// than a second decimal place.
|
||||||
|
fn cpu_title_for_width(
|
||||||
|
cpu_now: Option<f32>,
|
||||||
|
avg_cpu: f64,
|
||||||
|
temp_c: Option<f32>,
|
||||||
|
width: u16,
|
||||||
|
) -> (String, String) {
|
||||||
|
let Some(now) = cpu_now else {
|
||||||
|
return ("CPU avg".into(), String::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two borders, plus a column of breathing room at each end of the title.
|
||||||
|
let budget = width.saturating_sub(4);
|
||||||
|
|
||||||
|
let labelled = format!("CPU (now: {now:>5.1}% | avg: {avg_cpu:>5.1}%)");
|
||||||
|
let bare = format!("CPU ({now:.1}% | {avg_cpu:.1}%)");
|
||||||
|
let now_only = format!("CPU ({now:.1}%)");
|
||||||
|
|
||||||
|
let (temp_labelled, temp_plain, temp_coarse) = match temp_c {
|
||||||
|
Some(t) => {
|
||||||
|
let icon = temp_icon(t);
|
||||||
|
(
|
||||||
|
format!("CPU Temp: {t:.1}°C {icon}"),
|
||||||
|
format!("{t:.1}°C {icon}"),
|
||||||
|
format!("{t:.0}°C {icon}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => ("CPU Temp: N/A".into(), "N/A".into(), "N/A".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ladder = [
|
||||||
|
(labelled.as_str(), temp_labelled.as_str()),
|
||||||
|
(labelled.as_str(), temp_plain.as_str()),
|
||||||
|
(bare.as_str(), temp_plain.as_str()),
|
||||||
|
(bare.as_str(), temp_coarse.as_str()),
|
||||||
|
(now_only.as_str(), temp_coarse.as_str()),
|
||||||
|
(now_only.as_str(), ""),
|
||||||
|
];
|
||||||
|
let (title, temp) = pick_pair(budget, TITLE_GAP, &ladder);
|
||||||
|
(title.to_string(), temp.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Draws the per-core CPU bars with sparklines and trends.
|
/// Draws the per-core CPU bars with sparklines and trends.
|
||||||
pub fn draw_per_core_bars(
|
pub fn draw_per_core_bars(
|
||||||
f: &mut ratatui::Frame<'_>,
|
f: &mut ratatui::Frame<'_>,
|
||||||
@@ -428,6 +478,108 @@ pub fn draw_per_core_bars(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod title_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The defect this replaces: the temperature was painted over the title's tail on a
|
||||||
|
/// narrow pane. Whatever the width, the two must fit side by side on the border.
|
||||||
|
#[test]
|
||||||
|
fn title_and_temperature_never_overlap() {
|
||||||
|
for width in 0..=200u16 {
|
||||||
|
let (title, temp) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
|
||||||
|
let budget = width.saturating_sub(4);
|
||||||
|
if temp.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
cols(&title) + cols(&temp) + TITLE_GAP <= budget,
|
||||||
|
"width {width}: {title:?} + {temp:?} do not fit in {budget} columns"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current CPU reading is the one thing the pane must always show.
|
||||||
|
#[test]
|
||||||
|
fn the_current_reading_always_survives() {
|
||||||
|
for width in 20..=200u16 {
|
||||||
|
let (title, _) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
|
||||||
|
assert!(
|
||||||
|
title.contains("3.4"),
|
||||||
|
"width {width}: lost the reading ({title:?})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ladder from the design: temp label, then now/avg labels, then the average,
|
||||||
|
/// then the temperature's decimal, then the temperature.
|
||||||
|
#[test]
|
||||||
|
fn detail_is_dropped_in_priority_order() {
|
||||||
|
let at = |w| cpu_title_for_width(Some(0.7), 1.3, Some(43.0), w);
|
||||||
|
|
||||||
|
let (title, temp) = at(80);
|
||||||
|
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
|
||||||
|
assert_eq!(temp, "CPU Temp: 43.0°C 😎");
|
||||||
|
|
||||||
|
// The "CPU Temp:" label goes first; the readings keep their labels.
|
||||||
|
let (title, temp) = at(50);
|
||||||
|
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
|
||||||
|
assert_eq!(temp, "43.0°C 😎");
|
||||||
|
|
||||||
|
// Then the now:/avg: labels.
|
||||||
|
let (title, temp) = at(40);
|
||||||
|
assert_eq!(title, "CPU (0.7% | 1.3%)");
|
||||||
|
assert_eq!(temp, "43.0°C 😎");
|
||||||
|
|
||||||
|
// Then the temperature's decimal.
|
||||||
|
let (title, temp) = at(31);
|
||||||
|
assert_eq!(title, "CPU (0.7% | 1.3%)");
|
||||||
|
assert_eq!(temp, "43°C 😎");
|
||||||
|
|
||||||
|
// Then the average reading.
|
||||||
|
let (title, temp) = at(26);
|
||||||
|
assert_eq!(title, "CPU (0.7%)");
|
||||||
|
assert_eq!(temp, "43°C 😎");
|
||||||
|
|
||||||
|
// Last of all, the temperature itself.
|
||||||
|
let (title, temp) = at(15);
|
||||||
|
assert_eq!(title, "CPU (0.7%)");
|
||||||
|
assert_eq!(temp, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hot CPU has to stay visible as a warning, so the glyph rides along with the
|
||||||
|
/// reading at every tier that shows a temperature at all.
|
||||||
|
#[test]
|
||||||
|
fn the_thermal_glyph_tracks_the_temperature() {
|
||||||
|
for (t, icon) in [(43.0, "😎"), (70.0, "⚠️"), (92.0, "🔥")] {
|
||||||
|
for width in 26..=80u16 {
|
||||||
|
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, Some(t), width);
|
||||||
|
assert!(
|
||||||
|
temp.contains(icon),
|
||||||
|
"width {width} at {t}°C: expected {icon} in {temp:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An agent that reports no temperature must not leave a stray label behind.
|
||||||
|
#[test]
|
||||||
|
fn a_missing_temperature_degrades_to_nothing() {
|
||||||
|
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 80);
|
||||||
|
assert_eq!(temp, "CPU Temp: N/A");
|
||||||
|
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 14);
|
||||||
|
assert_eq!(temp, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before the first payload arrives there are no readings to show.
|
||||||
|
#[test]
|
||||||
|
fn no_metrics_yet_shows_the_placeholder() {
|
||||||
|
let (title, temp) = cpu_title_for_width(None, 0.0, None, 80);
|
||||||
|
assert_eq!(title, "CPU avg");
|
||||||
|
assert!(temp.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod render_tests {
|
mod render_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! Fitting text to the columns actually available.
|
||||||
|
//!
|
||||||
|
//! Several panes paint two independent pieces of text onto one row — a left title and a
|
||||||
|
//! right-aligned readout. Nothing reserves space for the right piece, so on a narrow
|
||||||
|
//! terminal the right one is simply painted over the tail of the left one and the title
|
||||||
|
//! is clobbered mid-word. The helpers here let a caller measure in real terminal columns
|
||||||
|
//! and pick the richest wording that still fits, so the two never overlap.
|
||||||
|
//!
|
||||||
|
//! Note that `str::len()` is a byte count and must not be used for this: `⏱` is three
|
||||||
|
//! bytes wide but one column, and `🔒` is four bytes but two columns.
|
||||||
|
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
/// Terminal columns `s` occupies, saturating at `u16::MAX`.
|
||||||
|
pub fn cols(s: &str) -> u16 {
|
||||||
|
UnicodeWidthStr::width(s).min(u16::MAX as usize) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shortens `s` to at most `max` columns, marking the cut with `…`.
|
||||||
|
///
|
||||||
|
/// Cuts on character boundaries and accounts for wide characters, so the result never
|
||||||
|
/// exceeds `max` columns and never splits a multi-byte character.
|
||||||
|
pub fn truncate_cols(s: &str, max: u16) -> String {
|
||||||
|
if cols(s) <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
if max == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
// Reserve one column for the ellipsis.
|
||||||
|
let budget = max.saturating_sub(1);
|
||||||
|
let mut used = 0u16;
|
||||||
|
let mut out = String::new();
|
||||||
|
for ch in s.chars() {
|
||||||
|
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
|
||||||
|
if used + w > budget {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
used += w;
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
out.push('…');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks the first (richest) candidate pair that fits side by side in `width` columns
|
||||||
|
/// with at least `gap` columns between them.
|
||||||
|
///
|
||||||
|
/// Candidates are ordered most- to least-detailed; the last one is the floor and is
|
||||||
|
/// returned even if it does not fit, so callers always get something to render.
|
||||||
|
pub fn pick_pair<'a>(
|
||||||
|
width: u16,
|
||||||
|
gap: u16,
|
||||||
|
candidates: &[(&'a str, &'a str)],
|
||||||
|
) -> (&'a str, &'a str) {
|
||||||
|
let fits = |left: &str, right: &str| {
|
||||||
|
let needed = cols(left)
|
||||||
|
.saturating_add(cols(right))
|
||||||
|
.saturating_add(if right.is_empty() { 0 } else { gap });
|
||||||
|
needed <= width
|
||||||
|
};
|
||||||
|
for &(left, right) in candidates {
|
||||||
|
if fits(left, right) {
|
||||||
|
return (left, right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.last().copied().unwrap_or(("", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The bug these helpers exist to prevent: byte length overstates the width of the
|
||||||
|
/// glyphs socktop puts in its header, which is what pushed the right-hand text into
|
||||||
|
/// the title in the first place.
|
||||||
|
#[test]
|
||||||
|
fn cols_counts_columns_not_bytes() {
|
||||||
|
assert_eq!(cols("abc"), 3);
|
||||||
|
// Stopwatch: 3 bytes, 1 column.
|
||||||
|
assert_eq!("⏱".len(), 3);
|
||||||
|
assert_eq!(cols("⏱"), 1);
|
||||||
|
// Lock: 4 bytes, 2 columns.
|
||||||
|
assert_eq!("🔒".len(), 4);
|
||||||
|
assert_eq!(cols("🔒"), 2);
|
||||||
|
assert_eq!(cols("⏱ 500ms metrics | 2000ms procs"), 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_respects_the_column_budget() {
|
||||||
|
assert_eq!(truncate_cols("cachyos-gaming", 20), "cachyos-gaming");
|
||||||
|
assert_eq!(truncate_cols("cachyos-gaming", 14), "cachyos-gaming");
|
||||||
|
assert_eq!(truncate_cols("cachyos-gaming", 10), "cachyos-g…");
|
||||||
|
assert_eq!(cols(&truncate_cols("cachyos-gaming", 10)), 10);
|
||||||
|
assert_eq!(truncate_cols("cachyos-gaming", 1), "…");
|
||||||
|
assert_eq!(truncate_cols("cachyos-gaming", 0), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncation must never land mid-character or overrun the budget on wide glyphs.
|
||||||
|
#[test]
|
||||||
|
fn truncate_handles_wide_and_multibyte_characters() {
|
||||||
|
for max in 0..12u16 {
|
||||||
|
let out = truncate_cols("🔒🔒🔒 TLS", max);
|
||||||
|
assert!(cols(&out) <= max, "{out:?} exceeds {max} columns");
|
||||||
|
assert!(out.chars().all(|c| c != '\u{fffd}'), "{out:?} split a char");
|
||||||
|
}
|
||||||
|
// A wide glyph that cannot fit beside the ellipsis is dropped whole.
|
||||||
|
assert_eq!(truncate_cols("🔒ab", 2), "…");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_pair_takes_the_richest_that_fits() {
|
||||||
|
let candidates = [
|
||||||
|
("full left text", "full right text"),
|
||||||
|
("left text", "right text"),
|
||||||
|
("left", "right"),
|
||||||
|
];
|
||||||
|
assert_eq!(pick_pair(80, 2, &candidates), candidates[0]);
|
||||||
|
assert_eq!(pick_pair(24, 2, &candidates), candidates[1]);
|
||||||
|
assert_eq!(pick_pair(12, 2, &candidates), candidates[2]);
|
||||||
|
// Below the floor the last candidate is still returned.
|
||||||
|
assert_eq!(pick_pair(1, 2, &candidates), candidates[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gap is what keeps the two pieces from touching; it must not be charged when
|
||||||
|
/// there is no right-hand piece to separate.
|
||||||
|
#[test]
|
||||||
|
fn pick_pair_only_charges_the_gap_when_both_sides_are_present() {
|
||||||
|
let candidates = [("0123456789", "x"), ("0123456789", "")];
|
||||||
|
assert_eq!(pick_pair(11, 2, &candidates), candidates[1]);
|
||||||
|
assert_eq!(pick_pair(13, 2, &candidates), candidates[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+211
-23
@@ -1,44 +1,232 @@
|
|||||||
//! Top header with hostname and CPU temperature indicator.
|
//! Top header with hostname, connection status and polling intervals.
|
||||||
|
//!
|
||||||
|
//! The row carries two pieces of text — session identity on the left, polling intervals
|
||||||
|
//! on the right — and both matter. Rather than let the right one overwrite the left when
|
||||||
|
//! they no longer both fit, the header drops detail in priority order: the hostname and
|
||||||
|
//! the intervals are what survive longest, because they are what tells you *which* host
|
||||||
|
//! you are looking at and how fresh the numbers are.
|
||||||
|
|
||||||
|
use crate::ui::fit::{cols, pick_pair, truncate_cols};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Build the header's left-side title from session state. Callers cache the
|
/// Columns kept clear between the left and right halves.
|
||||||
/// returned String and only rebuild it when one of the inputs changes.
|
const GAP: u16 = 2;
|
||||||
pub fn build_header_title(hostname: Option<&str>, is_tls: bool, has_token: bool) -> String {
|
/// Never shorten the hostname below this before dropping the intervals instead.
|
||||||
let base = match hostname {
|
const HOSTNAME_FLOOR: u16 = 8;
|
||||||
Some(h) => format!("socktop — host: {h}"),
|
|
||||||
None => "socktop — connecting...".into(),
|
/// Session state the header renders.
|
||||||
};
|
#[derive(Clone, Copy)]
|
||||||
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
|
pub struct HeaderState<'a> {
|
||||||
let mut parts = vec![base, tls_txt.into()];
|
pub hostname: Option<&'a str>,
|
||||||
if has_token {
|
pub is_tls: bool,
|
||||||
parts.push("🔑 token".into());
|
pub has_token: bool,
|
||||||
}
|
pub metrics_ms: u128,
|
||||||
parts.push("(a: about, h: help, q: quit)".into());
|
pub procs_ms: u128,
|
||||||
parts.join(" | ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the right-side polling interval text. Callers cache this string.
|
/// Builds the left and right halves of the header for a row `width` columns wide.
|
||||||
pub fn build_header_intervals(metrics_ms: u128, procs_ms: u128) -> String {
|
///
|
||||||
format!("⏱ {metrics_ms}ms metrics | {procs_ms}ms procs")
|
/// Detail is dropped in this order as the row narrows: the key hints, then the TLS/token
|
||||||
|
/// badges, then the `socktop — host:` prefix (leaving the bare hostname), then the
|
||||||
|
/// `metrics`/`procs` words, and only then is the hostname itself shortened. The two
|
||||||
|
/// halves are always sized to sit side by side, so neither can paint over the other.
|
||||||
|
///
|
||||||
|
/// Callers cache the result and rebuild it only when the state or the width changes.
|
||||||
|
pub fn build_header(state: HeaderState<'_>, width: u16) -> (String, String) {
|
||||||
|
let host = state.hostname.unwrap_or("connecting...");
|
||||||
|
let tls = if state.is_tls {
|
||||||
|
"🔒 TLS"
|
||||||
|
} else {
|
||||||
|
"🔒✗ TLS"
|
||||||
|
};
|
||||||
|
let badges = if state.has_token {
|
||||||
|
format!("{tls} | 🔑 token")
|
||||||
|
} else {
|
||||||
|
tls.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let named = format!("socktop — host: {host}");
|
||||||
|
let with_badges = format!("{named} | {badges}");
|
||||||
|
let with_keys = format!("{with_badges} | (a: about, h: help, q: quit)");
|
||||||
|
|
||||||
|
let intervals = format!(
|
||||||
|
"⏱ {}ms metrics | {}ms procs",
|
||||||
|
state.metrics_ms, state.procs_ms
|
||||||
|
);
|
||||||
|
let intervals_short = format!("⏱ {}ms | {}ms", state.metrics_ms, state.procs_ms);
|
||||||
|
|
||||||
|
// Richest first. The bare hostname is reached before the intervals lose their
|
||||||
|
// labels, and the hostname is only shortened once nothing else is left to give.
|
||||||
|
let ladder = [
|
||||||
|
(with_keys.as_str(), intervals.as_str()),
|
||||||
|
(with_badges.as_str(), intervals.as_str()),
|
||||||
|
(named.as_str(), intervals.as_str()),
|
||||||
|
(host, intervals.as_str()),
|
||||||
|
(host, intervals_short.as_str()),
|
||||||
|
];
|
||||||
|
let (left, right) = pick_pair(width, GAP, &ladder);
|
||||||
|
if cols(left) + cols(right) + GAP <= width {
|
||||||
|
return (left.to_string(), right.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the floor of the ladder: shorten the hostname, and give up the intervals only
|
||||||
|
// if even a stub of a hostname will not fit beside them.
|
||||||
|
let room = width
|
||||||
|
.saturating_sub(cols(&intervals_short))
|
||||||
|
.saturating_sub(GAP);
|
||||||
|
if room >= HOSTNAME_FLOOR {
|
||||||
|
return (truncate_cols(host, room), intervals_short);
|
||||||
|
}
|
||||||
|
(truncate_cols(host, width), String::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
|
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
|
||||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||||
|
|
||||||
let intervals_width = intervals.len() as u16;
|
if intervals.is_empty() {
|
||||||
if area.width > intervals_width + 2 {
|
return;
|
||||||
|
}
|
||||||
|
let intervals_width = cols(intervals);
|
||||||
|
if area.width >= intervals_width {
|
||||||
let right_area = Rect {
|
let right_area = Rect {
|
||||||
x: area.x + area.width.saturating_sub(intervals_width + 1),
|
x: area.x + area.width - intervals_width,
|
||||||
y: area.y,
|
y: area.y,
|
||||||
width: intervals_width,
|
width: intervals_width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let intervals_line = Line::from(Span::raw(intervals));
|
f.render_widget(Paragraph::new(Line::from(Span::raw(intervals))), right_area);
|
||||||
f.render_widget(Paragraph::new(intervals_line), right_area);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn state(hostname: Option<&str>) -> HeaderState<'_> {
|
||||||
|
HeaderState {
|
||||||
|
hostname,
|
||||||
|
is_tls: false,
|
||||||
|
has_token: false,
|
||||||
|
metrics_ms: 500,
|
||||||
|
procs_ms: 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The defect this replaces: the two halves were painted independently, so below
|
||||||
|
/// ~105 columns the right half landed on top of the title. Whatever the width, they
|
||||||
|
/// must now fit side by side.
|
||||||
|
#[test]
|
||||||
|
fn halves_never_overlap_at_any_width() {
|
||||||
|
for width in 0..=200u16 {
|
||||||
|
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
|
||||||
|
let used = cols(&left) + cols(&right);
|
||||||
|
if right.is_empty() {
|
||||||
|
assert!(cols(&left) <= width, "width {width}: {left:?} overflows");
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
used + GAP <= width,
|
||||||
|
"width {width}: {left:?} + {right:?} = {used} cols, no room for both"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hostname and intervals are the two things worth keeping; everything else is
|
||||||
|
/// context that can go.
|
||||||
|
#[test]
|
||||||
|
fn hostname_and_intervals_survive_longest() {
|
||||||
|
for width in 34..=200u16 {
|
||||||
|
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
|
||||||
|
assert!(
|
||||||
|
left.contains("cachyos-gaming"),
|
||||||
|
"width {width}: lost the hostname ({left:?})"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
right.contains("500ms") && right.contains("2000ms"),
|
||||||
|
"width {width}: lost the intervals ({right:?})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ladder from the design: key hints, then badges, then the prefix, then the
|
||||||
|
/// interval labels, then the hostname itself.
|
||||||
|
#[test]
|
||||||
|
fn detail_is_dropped_in_priority_order() {
|
||||||
|
let s = state(Some("cachyos-gaming"));
|
||||||
|
|
||||||
|
let (left, right) = build_header(s, 120);
|
||||||
|
assert_eq!(
|
||||||
|
left,
|
||||||
|
"socktop — host: cachyos-gaming | 🔒✗ TLS | (a: about, h: help, q: quit)"
|
||||||
|
);
|
||||||
|
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
|
||||||
|
|
||||||
|
// Key hints go first.
|
||||||
|
let (left, _) = build_header(s, 80);
|
||||||
|
assert_eq!(left, "socktop — host: cachyos-gaming | 🔒✗ TLS");
|
||||||
|
|
||||||
|
// Then the badges.
|
||||||
|
let (left, _) = build_header(s, 70);
|
||||||
|
assert_eq!(left, "socktop — host: cachyos-gaming");
|
||||||
|
|
||||||
|
// Then the prefix, leaving the bare hostname.
|
||||||
|
let (left, right) = build_header(s, 50);
|
||||||
|
assert_eq!(left, "cachyos-gaming");
|
||||||
|
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
|
||||||
|
|
||||||
|
// Then the interval labels.
|
||||||
|
let (left, right) = build_header(s, 34);
|
||||||
|
assert_eq!(left, "cachyos-gaming");
|
||||||
|
assert_eq!(right, "⏱ 500ms | 2000ms");
|
||||||
|
|
||||||
|
// Only then is the hostname itself shortened.
|
||||||
|
// 30 columns - 16 for the short intervals - 2 gap leaves 12 for the hostname.
|
||||||
|
let (left, right) = build_header(s, 30);
|
||||||
|
assert_eq!(left, "cachyos-gam…");
|
||||||
|
assert_eq!(right, "⏱ 500ms | 2000ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A long hostname must not push the intervals off the row.
|
||||||
|
#[test]
|
||||||
|
fn a_long_hostname_is_shortened_rather_than_winning_the_row() {
|
||||||
|
let long = "a-very-long-hostname-that-will-not-fit-anywhere";
|
||||||
|
for width in 30..=100u16 {
|
||||||
|
let (left, right) = build_header(state(Some(long)), width);
|
||||||
|
assert!(!right.is_empty(), "width {width}: intervals were dropped");
|
||||||
|
assert!(cols(&left) + cols(&right) + GAP <= width, "width {width}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widths too small for both: the hostname is the last thing standing.
|
||||||
|
#[test]
|
||||||
|
fn hostname_is_the_final_survivor() {
|
||||||
|
let (left, right) = build_header(state(Some("cachyos-gaming")), 20);
|
||||||
|
assert!(right.is_empty(), "intervals should have been dropped");
|
||||||
|
assert!(!left.is_empty());
|
||||||
|
assert!(cols(&left) <= 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_and_token_badges_appear_when_there_is_room() {
|
||||||
|
let s = HeaderState {
|
||||||
|
hostname: Some("host"),
|
||||||
|
is_tls: true,
|
||||||
|
has_token: true,
|
||||||
|
metrics_ms: 500,
|
||||||
|
procs_ms: 2000,
|
||||||
|
};
|
||||||
|
let (left, _) = build_header(s, 200);
|
||||||
|
assert!(left.contains("🔒 TLS"), "{left}");
|
||||||
|
assert!(left.contains("🔑 token"), "{left}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_hostname_reads_as_connecting() {
|
||||||
|
let (left, _) = build_header(state(None), 120);
|
||||||
|
assert!(left.contains("connecting"), "{left}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
pub mod cpu;
|
pub mod cpu;
|
||||||
pub mod disks;
|
pub mod disks;
|
||||||
|
pub mod fit;
|
||||||
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;
|
||||||
|
|||||||
+400
-29
@@ -134,14 +134,83 @@ pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec<CachedRow>) -> f32 {
|
|||||||
peak
|
peak
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the original header widths here so drawing and hit-testing match.
|
const PID_W: u16 = 8;
|
||||||
const COLS: [Constraint; 5] = [
|
const CPU_W: u16 = 8;
|
||||||
Constraint::Length(8), // PID
|
const MEM_W: u16 = 12;
|
||||||
Constraint::Percentage(40), // Name
|
const MEM_PCT_W: u16 = 8;
|
||||||
Constraint::Length(8), // CPU %
|
/// Columns the Name field needs to identify anything. Every other column is only added
|
||||||
Constraint::Length(12), // Mem
|
/// once Name already has this much, so Name can no longer be squeezed to nothing.
|
||||||
Constraint::Length(8), // Mem %
|
const NAME_MIN_W: u16 = 8;
|
||||||
];
|
/// `Table::column_spacing`.
|
||||||
|
const COL_SPACING: u16 = 1;
|
||||||
|
|
||||||
|
/// Which process columns fit in the pane, and where they sit.
|
||||||
|
///
|
||||||
|
/// The table used to hand the layout solver a fixed, over-constrained set, so on a narrow
|
||||||
|
/// pane the solver crushed the percentage-sized Name column to nothing while the fixed
|
||||||
|
/// PID and Mem % columns kept their full width — losing the one field that identifies the
|
||||||
|
/// process while keeping the ones that do not.
|
||||||
|
///
|
||||||
|
/// Columns are now added in priority order as the pane widens, so they are shed in
|
||||||
|
/// reverse as it narrows: Name is unconditional, then CPU %, then Mem, then PID, and
|
||||||
|
/// Mem % last (it is derivable from Mem, so it is the least costly to lose).
|
||||||
|
///
|
||||||
|
/// Both the draw path and the header-click hit-testing build this from the same width, so
|
||||||
|
/// a sort click always lands on the column the user can actually see.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ProcColumns {
|
||||||
|
pub pid: bool,
|
||||||
|
pub cpu: bool,
|
||||||
|
pub mem: bool,
|
||||||
|
pub mem_pct: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcColumns {
|
||||||
|
pub fn for_width(width: u16) -> Self {
|
||||||
|
// Each tier is the previous one plus a column and the gap before it.
|
||||||
|
let with_cpu = NAME_MIN_W + COL_SPACING + CPU_W;
|
||||||
|
let with_mem = with_cpu + COL_SPACING + MEM_W;
|
||||||
|
let with_pid = with_mem + COL_SPACING + PID_W;
|
||||||
|
let with_mem_pct = with_pid + COL_SPACING + MEM_PCT_W;
|
||||||
|
Self {
|
||||||
|
cpu: width >= with_cpu,
|
||||||
|
mem: width >= with_mem,
|
||||||
|
pid: width >= with_pid,
|
||||||
|
mem_pct: width >= with_mem_pct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Column constraints in render order. Name takes whatever the others leave.
|
||||||
|
pub fn constraints(&self) -> Vec<Constraint> {
|
||||||
|
let mut c = Vec::with_capacity(5);
|
||||||
|
if self.pid {
|
||||||
|
c.push(Constraint::Length(PID_W));
|
||||||
|
}
|
||||||
|
c.push(Constraint::Fill(1)); // Name
|
||||||
|
if self.cpu {
|
||||||
|
c.push(Constraint::Length(CPU_W));
|
||||||
|
}
|
||||||
|
if self.mem {
|
||||||
|
c.push(Constraint::Length(MEM_W));
|
||||||
|
}
|
||||||
|
if self.mem_pct {
|
||||||
|
c.push(Constraint::Length(MEM_PCT_W));
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position of the CPU % column, which is clickable to sort. `None` when too narrow
|
||||||
|
/// to render it.
|
||||||
|
pub fn cpu_index(&self) -> Option<usize> {
|
||||||
|
self.cpu.then(|| 1 + usize::from(self.pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position of the Mem column, which is clickable to sort.
|
||||||
|
pub fn mem_index(&self) -> Option<usize> {
|
||||||
|
self.mem
|
||||||
|
.then(|| 1 + usize::from(self.pid) + usize::from(self.cpu))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
|
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
|
||||||
// Draw outer block and title
|
// Draw outer block and title
|
||||||
@@ -233,6 +302,8 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
|||||||
.fold(0.0_f32, f32::max)
|
.fold(0.0_f32, f32::max)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let columns = ProcColumns::for_width(content.width);
|
||||||
|
|
||||||
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
|
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
|
||||||
let p = &mm.top_processes[ix];
|
let p = &mm.top_processes[ix];
|
||||||
|
|
||||||
@@ -304,16 +375,29 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
|||||||
.add_modifier(Modifier::BOLD);
|
.add_modifier(Modifier::BOLD);
|
||||||
}
|
}
|
||||||
|
|
||||||
ratatui::widgets::Row::new(vec![
|
let mut cells = Vec::with_capacity(5);
|
||||||
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
|
if columns.pid {
|
||||||
ratatui::widgets::Cell::from(name_span),
|
cells.push(
|
||||||
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
|
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
|
||||||
.style(Style::default().fg(cpu_fg)),
|
);
|
||||||
ratatui::widgets::Cell::from(Span::raw(mem_span_text)),
|
}
|
||||||
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
|
cells.push(ratatui::widgets::Cell::from(name_span));
|
||||||
.style(Style::default().fg(mem_fg)),
|
if columns.cpu {
|
||||||
])
|
cells.push(
|
||||||
.style(emphasis)
|
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
|
||||||
|
.style(Style::default().fg(cpu_fg)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if columns.mem {
|
||||||
|
cells.push(ratatui::widgets::Cell::from(Span::raw(mem_span_text)));
|
||||||
|
}
|
||||||
|
if columns.mem_pct {
|
||||||
|
cells.push(
|
||||||
|
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
|
||||||
|
.style(Style::default().fg(mem_fg)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ratatui::widgets::Row::new(cells).style(emphasis)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Header with sort indicator
|
// Header with sort indicator
|
||||||
@@ -325,16 +409,30 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
|||||||
ProcSortBy::MemDesc => "Mem •",
|
ProcSortBy::MemDesc => "Mem •",
|
||||||
_ => "Mem",
|
_ => "Mem",
|
||||||
};
|
};
|
||||||
let header = ratatui::widgets::Row::new(vec!["PID", "Name", cpu_hdr, mem_hdr, "Mem %"]).style(
|
let mut header_cells = Vec::with_capacity(5);
|
||||||
|
if columns.pid {
|
||||||
|
header_cells.push("PID");
|
||||||
|
}
|
||||||
|
header_cells.push("Name");
|
||||||
|
if columns.cpu {
|
||||||
|
header_cells.push(cpu_hdr);
|
||||||
|
}
|
||||||
|
if columns.mem {
|
||||||
|
header_cells.push(mem_hdr);
|
||||||
|
}
|
||||||
|
if columns.mem_pct {
|
||||||
|
header_cells.push("Mem %");
|
||||||
|
}
|
||||||
|
let header = ratatui::widgets::Row::new(header_cells).style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Cyan)
|
.fg(Color::Cyan)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render table inside content area (no borders here; outer block already drawn)
|
// Render table inside content area (no borders here; outer block already drawn)
|
||||||
let table = Table::new(rows_iter, COLS.to_vec())
|
let table = Table::new(rows_iter, columns.constraints())
|
||||||
.header(header)
|
.header(header)
|
||||||
.column_spacing(1);
|
.column_spacing(COL_SPACING);
|
||||||
f.render_widget(table, content);
|
f.render_widget(table, content);
|
||||||
|
|
||||||
// Draw tooltip if a process is selected
|
// Draw tooltip if a process is selected
|
||||||
@@ -546,15 +644,24 @@ pub fn processes_handle_mouse(
|
|||||||
&& mouse.column < header_area.x + header_area.width;
|
&& mouse.column < header_area.x + header_area.width;
|
||||||
|
|
||||||
if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||||
// Split header into the same columns
|
// Split the header the same way the draw path did, so a click lands on the
|
||||||
|
// column actually on screen even when PID has been dropped.
|
||||||
|
let columns = ProcColumns::for_width(header_area.width);
|
||||||
let cols = Layout::default()
|
let cols = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints(COLS.to_vec())
|
.constraints(columns.constraints())
|
||||||
|
.spacing(COL_SPACING) // must match Table::column_spacing in the draw path
|
||||||
.split(header_area);
|
.split(header_area);
|
||||||
if mouse.column >= cols[2].x && mouse.column < cols[2].x + cols[2].width {
|
if let Some(cpu) = columns.cpu_index().map(|i| cols[i])
|
||||||
|
&& mouse.column >= cpu.x
|
||||||
|
&& mouse.column < cpu.x + cpu.width
|
||||||
|
{
|
||||||
return Some(ProcSortBy::CpuDesc);
|
return Some(ProcSortBy::CpuDesc);
|
||||||
}
|
}
|
||||||
if mouse.column >= cols[3].x && mouse.column < cols[3].x + cols[3].width {
|
if let Some(mem) = columns.mem_index().map(|i| cols[i])
|
||||||
|
&& mouse.column >= mem.x
|
||||||
|
&& mouse.column < mem.x + mem.width
|
||||||
|
{
|
||||||
return Some(ProcSortBy::MemDesc);
|
return Some(ProcSortBy::MemDesc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,15 +753,24 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
|||||||
&& params.mouse.column < header_area.x + header_area.width;
|
&& params.mouse.column < header_area.x + header_area.width;
|
||||||
|
|
||||||
if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||||
// Split header into the same columns
|
// Split the header the same way the draw path did, so a click lands on the
|
||||||
|
// column actually on screen even when PID has been dropped.
|
||||||
|
let columns = ProcColumns::for_width(header_area.width);
|
||||||
let cols = Layout::default()
|
let cols = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints(COLS.to_vec())
|
.constraints(columns.constraints())
|
||||||
|
.spacing(COL_SPACING) // must match Table::column_spacing in the draw path
|
||||||
.split(header_area);
|
.split(header_area);
|
||||||
if params.mouse.column >= cols[2].x && params.mouse.column < cols[2].x + cols[2].width {
|
if let Some(cpu) = columns.cpu_index().map(|i| cols[i])
|
||||||
|
&& params.mouse.column >= cpu.x
|
||||||
|
&& params.mouse.column < cpu.x + cpu.width
|
||||||
|
{
|
||||||
return Some(ProcSortBy::CpuDesc);
|
return Some(ProcSortBy::CpuDesc);
|
||||||
}
|
}
|
||||||
if params.mouse.column >= cols[3].x && params.mouse.column < cols[3].x + cols[3].width {
|
if let Some(mem) = columns.mem_index().map(|i| cols[i])
|
||||||
|
&& params.mouse.column >= mem.x
|
||||||
|
&& params.mouse.column < mem.x + mem.width
|
||||||
|
{
|
||||||
return Some(ProcSortBy::MemDesc);
|
return Some(ProcSortBy::MemDesc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,3 +807,258 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
|||||||
);
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod column_tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::layout::{Direction, Layout, Rect};
|
||||||
|
|
||||||
|
fn name_width(w: u16) -> u16 {
|
||||||
|
let c = ProcColumns::for_width(w);
|
||||||
|
let rects = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints(c.constraints())
|
||||||
|
.spacing(COL_SPACING)
|
||||||
|
.split(Rect::new(0, 0, w, 1));
|
||||||
|
rects[usize::from(c.pid)].width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complaint this fixes: on a narrow pane the Name column was the first thing to
|
||||||
|
/// disappear, leaving a table of numbers with nothing to identify the process. Name
|
||||||
|
/// must now be the last column standing, at every width that can render anything.
|
||||||
|
#[test]
|
||||||
|
fn name_is_never_the_column_that_gets_dropped() {
|
||||||
|
for width in NAME_MIN_W..=200u16 {
|
||||||
|
assert!(
|
||||||
|
name_width(width) >= 1,
|
||||||
|
"width {width}: Name was squeezed to nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Columns are shed in reverse priority order, so a narrower pane can never show a
|
||||||
|
/// column that a wider one hid.
|
||||||
|
#[test]
|
||||||
|
fn columns_are_shed_in_priority_order() {
|
||||||
|
for width in 0..=200u16 {
|
||||||
|
let c = ProcColumns::for_width(width);
|
||||||
|
assert!(!c.mem_pct || c.pid, "width {width}: Mem % outlived PID");
|
||||||
|
assert!(!c.pid || c.mem, "width {width}: PID outlived Mem");
|
||||||
|
assert!(!c.mem || c.cpu, "width {width}: Mem outlived CPU %");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Columns come back as the pane widens and never flap.
|
||||||
|
#[test]
|
||||||
|
fn columns_are_monotonic_in_width() {
|
||||||
|
let mut prev = ProcColumns::for_width(0);
|
||||||
|
for width in 1..=200u16 {
|
||||||
|
let c = ProcColumns::for_width(width);
|
||||||
|
for (was, now, name) in [
|
||||||
|
(prev.cpu, c.cpu, "CPU %"),
|
||||||
|
(prev.mem, c.mem, "Mem"),
|
||||||
|
(prev.pid, c.pid, "PID"),
|
||||||
|
(prev.mem_pct, c.mem_pct, "Mem %"),
|
||||||
|
] {
|
||||||
|
assert!(!was || now, "width {width}: {name} vanished as it widened");
|
||||||
|
}
|
||||||
|
prev = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tiers, from a comfortable pane down to a very narrow one.
|
||||||
|
#[test]
|
||||||
|
fn narrow_panes_shed_columns_in_order() {
|
||||||
|
let full = ProcColumns::for_width(48);
|
||||||
|
assert_eq!(full.constraints().len(), 5);
|
||||||
|
assert!(full.pid && full.cpu && full.mem && full.mem_pct);
|
||||||
|
|
||||||
|
// Mem % goes first.
|
||||||
|
let c = ProcColumns::for_width(45);
|
||||||
|
assert!(c.pid && c.mem && !c.mem_pct);
|
||||||
|
|
||||||
|
// Then PID.
|
||||||
|
let c = ProcColumns::for_width(35);
|
||||||
|
assert!(!c.pid && c.cpu && c.mem);
|
||||||
|
|
||||||
|
// Then Mem, leaving the name and its CPU load.
|
||||||
|
let c = ProcColumns::for_width(20);
|
||||||
|
assert!(!c.mem && c.cpu);
|
||||||
|
assert_eq!(c.constraints().len(), 2);
|
||||||
|
|
||||||
|
// At the floor, just the name.
|
||||||
|
let c = ProcColumns::for_width(10);
|
||||||
|
assert!(!c.cpu && !c.mem);
|
||||||
|
assert_eq!(c.constraints().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression guard for the old behaviour: a 130-column terminal gives the process
|
||||||
|
/// pane ~48 columns, and every column still fits there.
|
||||||
|
#[test]
|
||||||
|
fn a_wide_terminal_keeps_the_full_table() {
|
||||||
|
assert_eq!(ProcColumns::for_width(48).constraints().len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort clicks are resolved by index, so those indices must track the columns that
|
||||||
|
/// are actually rendered — otherwise clicking "CPU %" would sort by Mem.
|
||||||
|
#[test]
|
||||||
|
fn sort_indices_follow_the_rendered_columns() {
|
||||||
|
let wide = ProcColumns::for_width(48);
|
||||||
|
assert_eq!(wide.cpu_index(), Some(2)); // PID, Name, CPU %
|
||||||
|
assert_eq!(wide.mem_index(), Some(3));
|
||||||
|
|
||||||
|
let narrow = ProcColumns::for_width(35);
|
||||||
|
assert_eq!(narrow.cpu_index(), Some(1)); // Name, CPU %
|
||||||
|
assert_eq!(narrow.mem_index(), Some(2));
|
||||||
|
|
||||||
|
// A column that is not rendered has no index to click.
|
||||||
|
let tiny = ProcColumns::for_width(10);
|
||||||
|
assert_eq!(tiny.cpu_index(), None);
|
||||||
|
assert_eq!(tiny.mem_index(), None);
|
||||||
|
|
||||||
|
// Whatever the width, any index returned is inside the rendered set.
|
||||||
|
for width in 0..=200u16 {
|
||||||
|
let c = ProcColumns::for_width(width);
|
||||||
|
let n = c.constraints().len();
|
||||||
|
for i in [c.cpu_index(), c.mem_index()].into_iter().flatten() {
|
||||||
|
assert!(i < n, "width {width}: index {i} outside {n} columns");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name takes the slack, so it grows with the pane instead of being pinned to a
|
||||||
|
/// percentage that the fixed columns can crush.
|
||||||
|
#[test]
|
||||||
|
fn name_absorbs_the_leftover_width() {
|
||||||
|
assert!(
|
||||||
|
name_width(80) > name_width(60),
|
||||||
|
"Name did not grow with the pane"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod click_tests {
|
||||||
|
use super::*;
|
||||||
|
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use socktop_connector::{Metrics, ProcessInfo};
|
||||||
|
|
||||||
|
fn metrics() -> Metrics {
|
||||||
|
Metrics {
|
||||||
|
cpu_total: 0.0,
|
||||||
|
cpu_per_core: vec![],
|
||||||
|
mem_total: 32_000_000_000,
|
||||||
|
mem_used: 0,
|
||||||
|
swap_total: 0,
|
||||||
|
swap_used: 0,
|
||||||
|
hostname: "t".into(),
|
||||||
|
cpu_temp_c: None,
|
||||||
|
disks: vec![],
|
||||||
|
networks: vec![],
|
||||||
|
top_processes: vec![ProcessInfo {
|
||||||
|
pid: 4242,
|
||||||
|
name: "some-process".into(),
|
||||||
|
cpu_usage: 1.5,
|
||||||
|
mem_bytes: 1_000_000,
|
||||||
|
}],
|
||||||
|
gpus: None,
|
||||||
|
process_count: Some(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the pane and returns its header row as text.
|
||||||
|
fn header_row(width: u16) -> String {
|
||||||
|
let m = metrics();
|
||||||
|
let mut cache = Vec::new();
|
||||||
|
let peak = rebuild_row_cache(&m, &mut cache);
|
||||||
|
let idxs = [0usize];
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|f| {
|
||||||
|
draw_top_processes(
|
||||||
|
f,
|
||||||
|
Rect::new(0, 0, width, 8),
|
||||||
|
ProcessDisplayParams {
|
||||||
|
metrics: Some(&m),
|
||||||
|
scroll_offset: 0,
|
||||||
|
sort_by: ProcSortBy::CpuDesc,
|
||||||
|
selected_process_pid: None,
|
||||||
|
selected_process_index: None,
|
||||||
|
search_query: "",
|
||||||
|
search_active: false,
|
||||||
|
filtered_indices: &idxs,
|
||||||
|
cached_rows: &cache,
|
||||||
|
peak_cpu: peak,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let buf = terminal.backend().buffer();
|
||||||
|
(0..width)
|
||||||
|
.map(|x| buf[(x, 1)].symbol().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn click(width: u16, column: u16) -> Option<ProcSortBy> {
|
||||||
|
let mut scroll = 0usize;
|
||||||
|
let mut drag = None;
|
||||||
|
processes_handle_mouse(
|
||||||
|
&mut scroll,
|
||||||
|
&mut drag,
|
||||||
|
MouseEvent {
|
||||||
|
kind: MouseEventKind::Down(MouseButton::Left),
|
||||||
|
column,
|
||||||
|
row: 1,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
},
|
||||||
|
Rect::new(0, 0, width, 8),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hit-test rects are computed by a separate `Layout` call from the one `Table`
|
||||||
|
/// renders with. This walks the rendered header text and clicks each label where it
|
||||||
|
/// actually appears, which catches any drift between the two — including column
|
||||||
|
/// spacing, which the two APIs configure differently.
|
||||||
|
#[test]
|
||||||
|
fn clicking_a_rendered_sort_header_sorts_by_that_column() {
|
||||||
|
for width in [40u16, 50, 60, 80, 120] {
|
||||||
|
let row = header_row(width);
|
||||||
|
let cpu_at = row.find("CPU").map(|i| row[..i].chars().count() as u16);
|
||||||
|
let mem_at = row.find("Mem").map(|i| row[..i].chars().count() as u16);
|
||||||
|
|
||||||
|
if let Some(x) = cpu_at {
|
||||||
|
assert_eq!(
|
||||||
|
click(width, x),
|
||||||
|
Some(ProcSortBy::CpuDesc),
|
||||||
|
"width {width}: clicking the rendered 'CPU %' header at column {x} \
|
||||||
|
did not sort by CPU (header row: {row:?})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(x) = mem_at {
|
||||||
|
assert_eq!(
|
||||||
|
click(width, x),
|
||||||
|
Some(ProcSortBy::MemDesc),
|
||||||
|
"width {width}: clicking the rendered 'Mem' header at column {x} \
|
||||||
|
did not sort by Mem (header row: {row:?})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name is what identifies the row, so it must be rendered at every width the pane
|
||||||
|
/// can draw anything at.
|
||||||
|
#[test]
|
||||||
|
fn the_name_column_is_rendered_even_when_narrow() {
|
||||||
|
for width in [30u16, 40, 60, 120] {
|
||||||
|
let row = header_row(width);
|
||||||
|
assert!(
|
||||||
|
row.contains("Name"),
|
||||||
|
"width {width}: no Name column in header {row:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user