Skip to content

Commit 648e6fe

Browse files
Clean up process explorer UI
1 parent 6db875b commit 648e6fe

3 files changed

Lines changed: 153 additions & 34 deletions

File tree

doc/MANUAL.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -541,9 +541,12 @@ network — and killing a runaway process.
541541
**Operation.** The table lists processes with CPU%, memory, thread count and a
542542
per-process CPU sparkline; sort by **name, CPU, memory, threads or PID** (the
543543
sort hotkey is shown in each column header). The layout adds a CPU-load line
544-
graph and per-core meters, with memory, disk-I/O and network sparklines.
545-
**`+`/`-`** adjust the refresh interval. **`k`** kills the selected process,
546-
**`K`** force-kills it; both ask to confirm.
544+
graph and per-core meters, a memory sparkline, and two **centre-line graphs**
545+
that split a metric into its two directions around a drawn **horizontal axis
546+
line**: the **Disk** panel grows **writes upward (▲)** and **reads downward
547+
(▼)**, and the **Net** panel grows **uploads upward (▲)** and **downloads
548+
downward (▼)**, each direction scaled to their shared peak. **`+`/`-`** adjust the refresh interval.
549+
**`k`** kills the selected process, **`K`** force-kills it; both ask to confirm.
547550

548551
A couple of details are platform-specific: on **Unix**, `k`/`K` send SIGTERM
549552
/SIGKILL (graceful vs. forced), while on **Windows** both terminate the process

src/proc/mod.rs

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,12 @@ pub struct ProcView {
7272
pub mem_used: u64,
7373
/// Memory-used percentage history (0..=100) for the memory sparkline.
7474
pub mem_history: VecDeque<f64>,
75-
/// Combined disk read+write rate (bytes/s) and its history.
76-
pub disk_rate: f64,
77-
pub disk_history: VecDeque<f64>,
75+
/// Disk read and write rates (bytes/s) and their histories, sampled by
76+
/// summing each process's read/written bytes since the last refresh.
77+
pub disk_read: f64,
78+
pub disk_write: f64,
79+
pub disk_read_history: VecDeque<f64>,
80+
pub disk_write_history: VecDeque<f64>,
7881
/// Network receive/transmit rates (bytes/s) and their histories.
7982
pub net_down: f64,
8083
pub net_up: f64,
@@ -132,8 +135,10 @@ impl ProcView {
132135
mem_total: 0,
133136
mem_used: 0,
134137
mem_history: VecDeque::with_capacity(SYS_HISTORY),
135-
disk_rate: 0.0,
136-
disk_history: VecDeque::with_capacity(SYS_HISTORY),
138+
disk_read: 0.0,
139+
disk_write: 0.0,
140+
disk_read_history: VecDeque::with_capacity(SYS_HISTORY),
141+
disk_write_history: VecDeque::with_capacity(SYS_HISTORY),
137142
net_down: 0.0,
138143
net_up: 0.0,
139144
net_down_history: VecDeque::with_capacity(SYS_HISTORY),
@@ -383,11 +388,11 @@ impl ProcView {
383388
self.mem_total = self.sys.total_memory();
384389
self.mem_used = self.sys.used_memory();
385390

386-
// -- Processes, plus the system-wide disk throughput summed from each
387-
// process's read+written bytes since the last refresh. --
391+
// -- Processes, plus the system-wide disk read/write throughput summed
392+
// from each process's bytes read/written since the last refresh. --
388393
let max_cpu = 100.0 * self.ncores as f32;
389394
let mut procs = Vec::with_capacity(self.sys.processes().len());
390-
let mut disk_bytes = 0u64;
395+
let (mut disk_read, mut disk_write) = (0u64, 0u64);
391396
for (pid, p) in self.sys.processes() {
392397
let pid = pid.as_u32() as i32;
393398
let name = p.name().to_string_lossy().into_owned();
@@ -402,9 +407,8 @@ impl ProcView {
402407
// available (Linux); it reads 0 elsewhere (e.g. Windows/macOS).
403408
let threads = p.tasks().map(|t| t.len() as u32).unwrap_or(0);
404409
let du = p.disk_usage();
405-
disk_bytes = disk_bytes
406-
.saturating_add(du.read_bytes)
407-
.saturating_add(du.written_bytes);
410+
disk_read = disk_read.saturating_add(du.read_bytes);
411+
disk_write = disk_write.saturating_add(du.written_bytes);
408412

409413
// Append to this process's CPU sparkline history.
410414
let h = self.proc_cpu_history.entry(pid).or_default();
@@ -419,7 +423,8 @@ impl ProcView {
419423
let live: HashSet<i32> = procs.iter().map(|p| p.pid).collect();
420424
self.proc_cpu_history.retain(|pid, _| live.contains(pid));
421425
self.procs = procs;
422-
self.disk_rate = if dt > 0.0 { disk_bytes as f64 / dt } else { 0.0 };
426+
self.disk_read = if dt > 0.0 { disk_read as f64 / dt } else { 0.0 };
427+
self.disk_write = if dt > 0.0 { disk_write as f64 / dt } else { 0.0 };
423428

424429
// -- Network throughput (sum of non-loopback interfaces). --
425430
self.networks.refresh(true);
@@ -457,7 +462,8 @@ impl ProcView {
457462
0.0
458463
};
459464
push_sys(&mut self.mem_history, mem_pct);
460-
push_sys(&mut self.disk_history, self.disk_rate);
465+
push_sys(&mut self.disk_read_history, self.disk_read);
466+
push_sys(&mut self.disk_write_history, self.disk_write);
461467
push_sys(&mut self.net_down_history, self.net_down);
462468
push_sys(&mut self.net_up_history, self.net_up);
463469
}

src/proc/render.rs

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -274,37 +274,33 @@ fn render_mem_panel(f: &mut Frame, area: Rect, pv: &ProcView, theme: &Theme) {
274274
}
275275

276276
fn render_disk_panel(f: &mut Frame, area: Rect, pv: &ProcView, theme: &Theme) {
277-
let title = format!(" Disk {}/s ", human_size(pv.disk_rate as u64));
277+
// ▲ writes (grow upward), ▼ reads (grow downward) from the centre line.
278+
let title = format!(
279+
" Disk ▼{}/s ▲{}/s ",
280+
human_size(pv.disk_read as u64),
281+
human_size(pv.disk_write as u64)
282+
);
278283
let inner = titled(f, area, title, theme);
279-
let samples: Vec<f64> = pv.disk_history.iter().copied().collect();
280-
let max = peak(&samples);
281-
let color = theme.exec_fg;
282-
draw_sparkline(f, inner, &samples, max, &|_| color, theme);
284+
let read: Vec<f64> = pv.disk_read_history.iter().copied().collect();
285+
let write: Vec<f64> = pv.disk_write_history.iter().copied().collect();
286+
// Shared scale so reads and writes are directly comparable.
287+
let max = peak(&read).max(peak(&write));
288+
draw_mirror_bars(f, inner, (&write, theme.header_fg), (&read, theme.exec_fg), max, theme);
283289
}
284290

285291
fn render_net_panel(f: &mut Frame, area: Rect, pv: &ProcView, theme: &Theme) {
292+
// ▲ uploads (grow upward), ▼ downloads (grow downward) from the centre line.
286293
let title = format!(
287294
" Net ▼{}/s ▲{}/s ",
288295
human_size(pv.net_down as u64),
289296
human_size(pv.net_up as u64)
290297
);
291298
let inner = titled(f, area, title, theme);
292-
if inner.width == 0 || inner.height == 0 {
293-
return;
294-
}
295299
let down: Vec<f64> = pv.net_down_history.iter().copied().collect();
296300
let up: Vec<f64> = pv.net_up_history.iter().copied().collect();
297-
// Shared scale so the two halves are comparable; split interior in two.
301+
// Shared scale so the upload/download halves are directly comparable.
298302
let max = peak(&down).max(peak(&up));
299-
if inner.height >= 2 {
300-
let half = inner.height / 2;
301-
let top = Rect { height: half, ..inner };
302-
let bottom = Rect { y: inner.y + half, height: inner.height - half, ..inner };
303-
draw_sparkline(f, top, &down, max, &|_| theme.panel_border_active, theme);
304-
draw_sparkline(f, bottom, &up, max, &|_| theme.header_fg, theme);
305-
} else {
306-
draw_sparkline(f, inner, &down, max, &|_| theme.panel_border_active, theme);
307-
}
303+
draw_mirror_bars(f, inner, (&up, theme.header_fg), (&down, theme.panel_border_active), max, theme);
308304
}
309305

310306
/// The peak of `samples`, floored at 1.0 so a flat/empty series doesn't divide
@@ -354,6 +350,83 @@ fn draw_sparkline(
354350
}
355351
}
356352

353+
/// Draw a centre-line mirrored bar graph in `area`: `up` samples grow upward
354+
/// from the horizontal mid-line, `down` samples grow downward. Newest sample is
355+
/// at the right edge; both are scaled to the shared `max`. Used by the disk
356+
/// (write ▲ / read ▼) and network (upload ▲ / download ▼) panels.
357+
fn draw_mirror_bars(
358+
f: &mut Frame,
359+
area: Rect,
360+
up: (&[f64], ratatui::style::Color),
361+
down: (&[f64], ratatui::style::Color),
362+
max: f64,
363+
theme: &Theme,
364+
) {
365+
let (up, up_color) = up;
366+
let (down, down_color) = down;
367+
let (w, h) = (area.width as usize, area.height as usize);
368+
if w == 0 || h == 0 {
369+
return;
370+
}
371+
// One row is reserved for the horizontal centre axis; the remaining rows
372+
// split into an upper band (grows up) and a lower band (grows down). With an
373+
// odd leftover the spare row goes to the lower band.
374+
let up_h = (h - 1) / 2;
375+
let down_h = h - 1 - up_h;
376+
let axis_y = area.y + up_h as u16;
377+
let up_levels = up_h * 8;
378+
let down_levels = down_h * 8;
379+
let (nu, nd) = (up.len(), down.len());
380+
let bg = theme.panel_bg;
381+
let axis_style = Style::default().fg(theme.panel_border).bg(bg);
382+
let frac = |v: f64, levels: usize| -> usize {
383+
if max > 0.0 {
384+
((v / max).clamp(0.0, 1.0) * levels as f64).round() as usize
385+
} else {
386+
0
387+
}
388+
};
389+
let buf = f.buffer_mut();
390+
for col in 0..w {
391+
// Right-align: the rightmost column is the newest sample.
392+
let from_right = w - 1 - col;
393+
let uv = if from_right < nu { up[nu - 1 - from_right] } else { 0.0 };
394+
let dv = if from_right < nd { down[nd - 1 - from_right] } else { 0.0 };
395+
let u_filled = frac(uv, up_levels);
396+
let d_filled = frac(dv, down_levels);
397+
let x = area.x + col as u16;
398+
399+
// Upper band: bottom-anchored cells (fill from the centre upward), using
400+
// the lower-block glyphs in the bar colour.
401+
for r in 0..up_h {
402+
let from_centre = up_h - 1 - r; // 0 = the row just above the axis
403+
let cell = u_filled.saturating_sub(from_centre * 8).min(8);
404+
let (ch, style) = if cell == 0 {
405+
(' ', Style::default().fg(theme.panel_border).bg(bg))
406+
} else {
407+
(LEVELS[cell], Style::default().fg(up_color).bg(bg))
408+
};
409+
buf.set_string(x, area.y + r as u16, ch.to_string(), style);
410+
}
411+
// The centre axis line.
412+
buf.set_string(x, axis_y, "─", axis_style);
413+
// Lower band: top-anchored cells (fill from the centre downward). A cell's
414+
// top `cell`/8 is painted in the bar colour by using it as the cell
415+
// background and "erasing" the unfilled lower part with a lower-block
416+
// glyph drawn in the panel background colour.
417+
for r in 0..down_h {
418+
let from_centre = r; // 0 = the row just below the axis
419+
let cell = d_filled.saturating_sub(from_centre * 8).min(8);
420+
let (ch, style) = if cell == 0 {
421+
(' ', Style::default().fg(theme.panel_border).bg(bg))
422+
} else {
423+
(LEVELS[8 - cell], Style::default().fg(bg).bg(down_color))
424+
};
425+
buf.set_string(x, axis_y + 1 + r as u16, ch.to_string(), style);
426+
}
427+
}
428+
}
429+
357430
/// A centered top-border title showing battery charge: "BAT[+] 86% ▆▆▆▆░░".
358431
fn battery_title(pct: u8, charging: bool, theme: &Theme) -> Line<'static> {
359432
let header = Style::default()
@@ -536,3 +609,40 @@ fn render_footer(f: &mut Frame, area: Rect, _pv: &ProcView, theme: &Theme) {
536609
area,
537610
);
538611
}
612+
613+
#[cfg(test)]
614+
mod tests {
615+
use super::*;
616+
use ratatui::Terminal;
617+
use ratatui::backend::TestBackend;
618+
use ratatui::style::Color;
619+
620+
/// `draw_mirror_bars` must grow the `up` series upward (top band, bar-colored
621+
/// foreground) and the `down` series downward (bottom band, bar-colored
622+
/// background) from the centre line, with the newest sample at the right.
623+
#[test]
624+
fn mirror_bars_split_up_and_down() {
625+
let theme = crate::ui::theme::Theme::mc();
626+
let area = Rect { x: 0, y: 0, width: 3, height: 4 };
627+
let (up_c, down_c) = (Color::Red, Color::Blue);
628+
let cx = 2u16; // rightmost column = newest sample
629+
630+
// Upload-only: a full bar fills the TOP band; the bottom band stays clear.
631+
let mut t = Terminal::new(TestBackend::new(3, 4)).unwrap();
632+
t.draw(|f| draw_mirror_bars(f, area, (&[1.0], up_c), (&[], down_c), 1.0, &theme))
633+
.unwrap();
634+
let b = t.backend().buffer();
635+
assert_eq!(b[(cx, 0)].fg, up_c, "top band carries the up colour");
636+
assert_ne!(b[(cx, 0)].symbol(), " ", "top band draws a bar glyph");
637+
assert_eq!(b[(cx, 1)].symbol(), "─", "the centre axis line is drawn");
638+
assert_ne!(b[(cx, 3)].bg, down_c, "bottom band is clear with no downloads");
639+
640+
// Download-only: a full bar fills the BOTTOM band; the top band stays clear.
641+
let mut t = Terminal::new(TestBackend::new(3, 4)).unwrap();
642+
t.draw(|f| draw_mirror_bars(f, area, (&[], up_c), (&[1.0], down_c), 1.0, &theme))
643+
.unwrap();
644+
let b = t.backend().buffer();
645+
assert_eq!(b[(cx, 3)].bg, down_c, "bottom band carries the down colour");
646+
assert_ne!(b[(cx, 0)].fg, up_c, "top band is clear with no uploads");
647+
}
648+
}

0 commit comments

Comments
 (0)