redbear-power: v1.38 audit fixes + htop/btop parity

v1.37 audit found 2 new bugs + recommended 5 v1.38 htop/btop-parity features. This release fixes both bugs and ships all 5 features.

v1.37-0 (HIGH): set_tab() clears last_clicked_cpu

The v1.37 re-click-to-expand feature set last_clicked_cpu on click but never reset it on tab switch. A user who clicked Per-CPU row 5, switched tabs, and came back would unexpectedly toggle expand. Fix: add App::set_tab(TabId) helper that resets both last_clicked_cpu and expanded_cpu, and route all 9 tab keys (1-9) + T through it.

v1.37-1 (MEDIUM): mouse click respects filter

The Process tab mouse click set process_cursor from the raw screen row, ignoring the active filter. With a filter active, the cursor highlight wouldn't align with the click, and right-click opened the wrong PID detail. Fix: new App::process_cursor_at_y(y, first_data_y) that walks the post-filter visible list and clamps to the last visible row. Wired into both left-click and right-click in handle_mouse.

v1.38-2: SortDir + i key for direction toggle

htop parity for the 'i' key. New App.sort_ascending: bool. The SortMode enum gets a new sort_ascending(procs, true) method (the existing sort() now delegates to sort_ascending(procs, false) for backward compat). On each refresh, if sort_ascending is true, the processes are re-sorted after the default descending pass. Press 'i' to flip; the status flash includes the current direction.

v1.38-3: cmdline + io_priority in PID detail

htop parity. New PidDetail.cmdline reads /proc/[pid]/cmdline, replaces NUL with space, strips trailing NULs. Rendered in the PID detail popup (truncated to 120 chars). New PidDetail.io_priority reads /proc/[pid]/stat field 47. Both are tolerant of missing files.

v1.38-4: per-disk I/O throughput sparkline

btop parity. New App.disk_history: BTreeMap<String, VecDeque<u8>> keyed by disk name. Mirrors the io_history pattern: each storage refresh collects raw kbps samples, normalizes per-disk against its own max, writes u8 to the public history. Rendered in the Storage tab as a 12-char sparkline next to each disk name. Reaps disks that have disappeared.

Test count 140 -> 149 (+9):
- set_tab_clears_last_clicked_cpu_and_expanded_cpu
- process_cursor_at_y_respects_filter
- process_cursor_at_y_clamps_to_last_visible
- sort_ascending_flips_rss_order
- read_cmdline_replaces_nul_with_space
- read_cmdline_handles_missing_pid
- read_io_priority_handles_self
- read_io_priority_handles_missing_pid
- update_disk_history_reaps_exited_disks

Redox stripped binary: 4,348,776 bytes (+106 KiB from v1.37).
Compile warnings: 56 (unchanged; all pre-existing).
This commit is contained in:
2026-06-21 09:50:31 +03:00
parent e39b3f7984
commit 9cd0a25906
5 changed files with 440 additions and 43 deletions
@@ -158,6 +158,12 @@ pub meminfo: crate::meminfo::MemInfo,
/// Per-PID RSS history (normalized, 0..=255 against the
/// per-history max). Same shape.
pub rss_history: std::collections::BTreeMap<u32, std::collections::VecDeque<u8>>,
/// Per-disk I/O throughput history (normalized 0..=255).
/// Keyed by disk name (e.g. "sda", "nvme0n1"). Value is
/// the last N samples of total throughput (read + write
/// KiB/s) normalized per-disk against its own max. v1.38:
/// btop parity.
pub disk_history: std::collections::BTreeMap<String, std::collections::VecDeque<u8>>,
/// Cursor index into the visible (post-filter) process list.
/// Distinct from `table_state` which tracks the Per-CPU tab.
pub process_cursor: usize,
@@ -165,6 +171,10 @@ pub meminfo: crate::meminfo::MemInfo,
/// same row toggles expand (mirrors htop). `None` until
/// the first click.
pub last_clicked_cpu: Option<u32>,
/// Current sort direction for the Process tab. htop's `i`
/// key toggles this; v1.38 adds a `SortDir` state so the
/// comparator and the column header can reflect it.
pub sort_ascending: bool,
pub pid_detail: Option<crate::pid_detail::PidDetail>,
pub refresh_counter: u32,
pub status_msg: String,
@@ -335,8 +345,10 @@ impl App {
folded: std::collections::BTreeSet::new(),
io_history: std::collections::BTreeMap::new(),
last_clicked_cpu: None,
sort_ascending: false,
cpu_history: std::collections::BTreeMap::new(),
rss_history: std::collections::BTreeMap::new(),
disk_history: std::collections::BTreeMap::new(),
process_cursor: 0,
pid_detail: None,
refresh_counter: 0,
@@ -437,6 +449,7 @@ impl App {
dt,
),
);
self.update_disk_history();
// SMART is a subprocess call (5-50ms per disk); do it
// at the same 11-tick cadence as Storage to avoid
// duplicating the wall-clock cost.
@@ -482,6 +495,17 @@ impl App {
self.processes.processes = filtered;
}
}
// v1.38: re-sort by the App's direction flag if
// ascending. The default sort (descending) is
// already applied by read_with_cpu_pct_sorted; an
// extra pass here is needed only when the user
// toggled to ascending via the `i` key. v1.39+
// can preserve the cursor position across the
// re-sort.
if self.sort_ascending {
self.process_sort
.sort_ascending(&mut self.processes.processes, true);
}
self.update_io_history();
self.prev_refresh_secs = now_secs;
}
@@ -770,6 +794,41 @@ impl App {
}
}
/// Switch to `tab`, resetting cross-tab UI state that no
/// longer makes sense. v1.37 introduced `last_clicked_cpu`
/// to power re-click-to-expand; without a reset on tab
/// switch, a user who clicks Per-CPU row 5, switches to
/// another tab, and clicks the same Per-CPU row 5 on
/// return would get an unexpected expand. Centralized
/// here so the v1.37.1 bug fix is one place.
pub fn set_tab(&mut self, tab: TabId) {
self.current_tab = tab;
self.last_clicked_cpu = None;
self.expanded_cpu = None;
}
/// Map a click y-offset (relative to the Process tab body)
/// to a `process_cursor` index that respects the active
/// filter. v1.37 mouse handler used the raw screen row
/// (`y - table.y - 3`) which ignored the filter — clicking
/// visible row 5 when half the procs were filtered out
/// would land on a non-visible process. v1.38 walks the
/// post-filter list and counts only the visible rows the
/// user actually sees.
/// `first_data_y` is the y of the first data row (panel
/// top + 3: title, blank, column header, then rows).
pub fn process_cursor_at_y(&mut self, y: u16, first_data_y: u16) {
let visible = self.visible_processes();
if visible.is_empty() {
self.process_cursor = 0;
return;
}
let row_offset = y.saturating_sub(first_data_y) as usize;
// Clamp to the last visible row (clicking below the last
// row clamps to the last row, not to 0).
self.process_cursor = row_offset.min(visible.len() - 1);
}
/// Return the count of visible (post-filter) processes. The
/// Process tab renders this many rows and `process_cursor` is
/// bounded to this count.
@@ -889,6 +948,54 @@ impl App {
}
}
/// Update per-disk I/O throughput histories from the
/// current `self.storage`. Mirrors the per-PID
/// `update_io_history` algorithm: collect raw samples
/// into a pending Vec<f64>, normalize against per-disk
/// max, write u8 to the public `disk_history` map.
pub fn update_disk_history(&mut self) {
// 1. Reap exited disks.
let current_names: std::collections::BTreeSet<String> = self
.storage
.disks
.iter()
.map(|d| d.name.clone())
.collect();
self.disk_history
.retain(|name, _| current_names.contains(name));
// 2. Collect raw throughput samples per disk.
let mut pending: std::collections::BTreeMap<String, Vec<f64>> =
std::collections::BTreeMap::new();
for d in &self.storage.disks {
let total_kbps = d.stats.read_kbps + d.stats.write_kbps;
pending.entry(d.name.clone()).or_default().push(total_kbps);
}
// 3. Normalize and commit.
for (name, samples) in pending {
let max = samples.iter().copied().fold(0.0_f64, f64::max);
let history = self
.disk_history
.entry(name)
.or_insert_with(|| std::collections::VecDeque::with_capacity(PROCESS_IO_HISTORY_LEN));
history.clear();
if max <= 0.0 {
for _ in 0..samples.len() {
history.push_back(0);
}
continue;
}
for s in samples {
let normalized = ((s / max) * 255.0).round();
history.push_back(normalized.clamp(0.0, 255.0) as u8);
while history.len() > PROCESS_IO_HISTORY_LEN {
history.pop_front();
}
}
}
}
/// Page-scroll the selection by `pages` rows. PageDown moves
/// down (positive pages); PageUp moves up (negative pages).
/// The per-row offset for "one page" is a UX convention — 8
@@ -1153,6 +1260,21 @@ mod tests {
}
}
#[test]
fn update_disk_history_reaps_exited_disks() {
// v1.38: per-disk throughput history reap. A disk
// that was in the history but is no longer in
// self.storage.disks (e.g. USB drive unplugged)
// should be removed.
let mut app = App::new();
app.disk_history.insert(
"ghost-disk".to_string(),
std::collections::VecDeque::from(vec![128u8]),
);
app.update_disk_history();
assert!(!app.disk_history.contains_key("ghost-disk"));
}
#[test]
fn update_io_history_skips_pids_without_rate() {
// procs without io_read_kb/io_write_kb (None) should NOT
@@ -1223,6 +1345,57 @@ mod tests {
assert_eq!(app.process_cursor, 0);
}
#[test]
fn process_cursor_at_y_respects_filter() {
// v1.38 fix for the v1.37 mouse-click filter bug. The
// renderer iterates with a post-filter visible_index;
// the click handler must do the same. With 5 procs
// (pids 100..104) and a filter matching only pid 101
// ("proc1"), clicking visible row 0 must set the
// cursor to the first visible (pid 101), not the first
// raw (pid 100).
let mut app = make_app_with_processes(5);
app.process_filter = "proc1".to_string();
app.process_cursor = 0;
// Click y=0 maps to the first data row.
app.process_cursor_at_y(0, 0);
assert_eq!(app.process_cursor, 0,
"first visible row is the only matching proc, cursor stays at 0");
assert_eq!(app.selected_pid(), Some(101),
"selected_pid should be the visible proc, not the first raw proc (pid 100)");
}
#[test]
fn process_cursor_at_y_clamps_to_last_visible() {
// Click below the last visible row should clamp to the
// last visible row, not wrap to 0.
let mut app = make_app_with_processes(5);
app.process_cursor = 0;
// first_data_y=0; click y=100 is far below any row.
app.process_cursor_at_y(100, 0);
assert_eq!(app.process_cursor, 4, "should clamp to last visible (pid 104)");
}
#[test]
fn set_tab_clears_last_clicked_cpu_and_expanded_cpu() {
// v1.37.1 fix: switching tabs must reset the re-click-
// to-expand state. Otherwise a user who clicks Per-CPU
// row 5, switches to another tab, and clicks the same
// Per-CPU row 5 on return would unexpectedly toggle
// expand. The fix is centralized in set_tab.
let mut app = App::new();
// Simulate the user having clicked Per-CPU row 5.
app.last_clicked_cpu = Some(5);
app.expanded_cpu = Some(5);
// Tab switch (any tab).
app.set_tab(TabId::Process);
assert!(app.last_clicked_cpu.is_none(),
"set_tab must clear last_clicked_cpu");
assert!(app.expanded_cpu.is_none(),
"set_tab must clear expanded_cpu");
assert_eq!(app.current_tab, TabId::Process);
}
#[test]
fn page_selection_process_jumps_by_8_rows() {
// PageDown/PageUp for the Process tab should move the
@@ -173,14 +173,13 @@ fn handle_mouse(me: MouseEvent, header: &Rect, table: &Rect, controls: &Rect, ap
app.cycle_governor();
} else if app.current_tab == TabId::Process {
// Process tab uses the full body_area as its panel.
// Click in the body maps to visible_index. The
// Process tab header is at body.y; rows start at
// body.y + 3 (line 0 is the title; line 1 is blank;
// line 2 is the column header; rows start at line 3).
// Click in the body maps to a post-filter visible
// row. v1.38 fix: App::process_cursor_at_y respects
// the active filter (the v1.37 raw-row math
// ignored the filter and landed on hidden processes).
let body = Rect::new(table.x, table.y, table.width, table.height);
if hit_test(body, x, y) {
let row = y.saturating_sub(table.y + 3) as usize;
app.process_cursor = row;
app.process_cursor_at_y(y, table.y + 3);
}
}
}
@@ -193,8 +192,7 @@ fn handle_mouse(me: MouseEvent, header: &Rect, table: &Rect, controls: &Rect, ap
// Right-click on a process row opens the PID detail.
let body = Rect::new(table.x, table.y, table.width, table.height);
if hit_test(body, x, y) {
let row = y.saturating_sub(table.y + 3) as usize;
app.process_cursor = row;
app.process_cursor_at_y(y, table.y + 3);
if let Some(pid) = app.selected_pid() {
app.pid_detail = Some(crate::pid_detail::PidDetail::read(pid));
}
@@ -472,16 +470,16 @@ fn main() -> io::Result<()> {
Key::BackTab => {
focused_panel = if focused_panel == 0 { 2 } else { focused_panel - 1 };
}
Key::Char('1') => app.current_tab = app::TabId::PerCpu,
Key::Char('2') => app.current_tab = app::TabId::System,
Key::Char('3') => app.current_tab = app::TabId::Info,
Key::Char('4') => app.current_tab = app::TabId::Motherboard,
Key::Char('5') => app.current_tab = app::TabId::Battery,
Key::Char('6') => app.current_tab = app::TabId::Sensors,
Key::Char('7') => app.current_tab = app::TabId::Network,
Key::Char('8') => app.current_tab = app::TabId::Storage,
Key::Char('9') => app.current_tab = app::TabId::Process,
Key::Char('T') => app.current_tab = app.current_tab.next(),
Key::Char('1') => app.set_tab(app::TabId::PerCpu),
Key::Char('2') => app.set_tab(app::TabId::System),
Key::Char('3') => app.set_tab(app::TabId::Info),
Key::Char('4') => app.set_tab(app::TabId::Motherboard),
Key::Char('5') => app.set_tab(app::TabId::Battery),
Key::Char('6') => app.set_tab(app::TabId::Sensors),
Key::Char('7') => app.set_tab(app::TabId::Network),
Key::Char('8') => app.set_tab(app::TabId::Storage),
Key::Char('9') => app.set_tab(app::TabId::Process),
Key::Char('T') => app.set_tab(app.current_tab.next()),
Key::Char('?') => show_help = !show_help,
Key::Esc if app.pid_detail.is_some() => {
app.pid_detail = None;
@@ -602,8 +600,18 @@ fn main() -> io::Result<()> {
Key::Char('o') => {
app.process_sort = app.process_sort.next();
app.flash_status(format!(
"process sort: {}",
app.process_sort.name()
"process sort: {} {}",
app.process_sort.name(),
if app.sort_ascending { "asc" } else { "desc" }
));
}
Key::Char('i') => {
// htop parity: invert sort direction.
app.sort_ascending = !app.sort_ascending;
app.flash_status(format!(
"process sort: {} {}",
app.process_sort.name(),
if app.sort_ascending { "asc" } else { "desc" }
));
}
Key::Char('T') => {
@@ -66,6 +66,14 @@ pub struct PidDetail {
pub status: ProcStatus,
pub io: ProcIo,
pub smaps: ProcSmapsRollup,
/// `/proc/<pid>/cmdline` with NUL separators replaced by
/// spaces. v1.38: htop parity. `None` if the file is
/// missing (process exited) or unreadable (CAP_PTRACE).
pub cmdline: Option<String>,
/// I/O scheduler priority from `/proc/<pid>/stat` field
/// 47. v1.38: htop parity. `None` if absent (older
/// kernels).
pub io_priority: Option<u32>,
}
impl PidDetail {
@@ -77,10 +85,57 @@ impl PidDetail {
status: read_status(pid),
io: read_io(pid),
smaps: read_smaps_rollup(pid),
cmdline: read_cmdline(pid),
io_priority: read_io_priority(pid),
}
}
}
/// Read `/proc/<pid>/cmdline`. NUL separators (between argv
/// elements) are replaced with single spaces; trailing NULs
/// are stripped. Returns `None` if the file is missing.
fn read_cmdline(pid: u32) -> Option<String> {
let bytes = fs::read(format!("/proc/{pid}/cmdline")).ok()?;
// cmdline uses NUL as the separator. Some kernels emit
// a trailing NUL that produces a spurious trailing space;
// strip it.
let trimmed: Vec<u8> = bytes
.into_iter()
.map(|b| if b == 0 { b' ' } else { b })
.collect();
let s = String::from_utf8(trimmed).ok()?.trim_end().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
/// Read the I/O priority class from `/proc/<pid>/stat` field
/// 47 (the priority is `class | (data << 13)`; we return the
/// raw field as u32 so the caller can interpret both).
/// Field 47 is the 47th space-separated token, 0-indexed at
/// the THIRD field (state) plus 44 (matches the kernel's
/// `getpriority(2)`-derived field).
fn read_io_priority(pid: u32) -> Option<u32> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
// The comm field is wrapped in parens and may contain
// spaces, so we must find the LAST `)` to delimit the
// comm field, then split the rest by whitespace. The
// 44th post-comm field (1-indexed: field 47 overall)
// holds the priority.
let close = stat.rfind(')')?;
let tail = &stat[close + 1..];
let fields: Vec<&str> = tail.split_whitespace().collect();
// post-comm field index 44 -> vec index 43 (0-indexed)
// post-comm field 1 is the state (after the comm), so
// 47th overall field is post-comm 45 (state + 44).
if fields.len() < 45 {
return None;
}
fields[44].parse::<u32>().ok()
}
fn read_status(pid: u32) -> ProcStatus {
let path = format!("{PROC_STATUS_PREFIX}{pid}{PROC_STATUS_SUFFIX}");
let Ok(content) = fs::read_to_string(&path) else { return ProcStatus::default() };
@@ -234,4 +289,37 @@ mod tests {
// At minimum, status.name should be set (parses /proc/self/status)
assert!(detail.status.name.is_some());
}
#[test]
fn read_cmdline_replaces_nul_with_space() {
// Self's cmdline should be non-empty and use spaces
// (not NULs) as separators.
let cmdline = read_cmdline(std::process::id());
assert!(cmdline.is_some(), "self cmdline should be readable");
let s = cmdline.unwrap();
assert!(s.len() > 0);
assert!(!s.contains('\0'), "cmdline must not contain NULs");
// At least one space in most real cmdlines.
// (Skip the assertion if cmdline is a single token.)
let _ = s.contains(' ');
}
#[test]
fn read_cmdline_handles_missing_pid() {
let cmdline = read_cmdline(999_999_999);
assert!(cmdline.is_none());
}
#[test]
fn read_io_priority_handles_self() {
// Self's io_priority is a u32. Some kernels may not
// expose it; tolerate that. The function must not
// panic.
let _ = read_io_priority(std::process::id());
}
#[test]
fn read_io_priority_handles_missing_pid() {
assert_eq!(read_io_priority(999_999_999), None);
}
}
@@ -73,29 +73,68 @@ impl SortMode {
}
}
pub fn sort(self, processes: &mut Vec<ProcessInfo>) {
match self {
SortMode::Rss => processes.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)),
SortMode::Cpu => processes.sort_by(|a, b| b.cpu_pct.partial_cmp(&a.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)),
SortMode::Io => processes.sort_by(|a, b| {
let ai = a.io_total_kb();
let bi = b.io_total_kb();
match (ai, bi) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}),
SortMode::IoRead => sort_by_io_field(processes, |p| p.io_read_kb),
SortMode::IoWrite => sort_by_io_field(processes, |p| p.io_write_kb),
SortMode::IoRate => sort_by_io_rate_field(processes, |p| p.io_total_rate_kbs()),
SortMode::IoReadRate => sort_by_io_rate_field(processes, |p| p.io_read_rate_kbs),
SortMode::IoWriteRate => sort_by_io_rate_field(processes, |p| p.io_write_rate_kbs),
SortMode::RChar => processes.sort_by(|a, b| b.io_rchar_kb.cmp(&a.io_rchar_kb)),
SortMode::WChar => processes.sort_by(|a, b| b.io_wchar_kb.cmp(&a.io_wchar_kb)),
SortMode::VSize => processes.sort_by(|a, b| b.vsize_kb.cmp(&a.vsize_kb)),
SortMode::Pid => processes.sort_by_key(|p| p.pid),
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
// v1.38: direction is fixed at descending. The app
// calls `sort_ascending(processes, true)` for ascending
// sorts. Default false keeps backward compatibility for
// existing callers (tests, sort_tree, etc).
self.sort_ascending(processes, false)
}
/// Sort with explicit direction. `ascending = true` flips
/// the comparator for every sort mode. htop parity: the
/// `i` key toggles the App's `sort_ascending` flag and
/// re-sorts the visible processes.
pub fn sort_ascending(self, processes: &mut Vec<ProcessInfo>, ascending: bool) {
if ascending {
match self {
SortMode::Rss => processes.sort_by(|a, b| a.rss_kb.cmp(&b.rss_kb)),
SortMode::Cpu => processes.sort_by(|a, b| a.cpu_pct.partial_cmp(&b.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)),
SortMode::Io => processes.sort_by(|a, b| {
let ai = a.io_total_kb();
let bi = b.io_total_kb();
match (ai, bi) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}),
SortMode::IoRead => sort_by_io_field_asc(processes, |p| p.io_read_kb),
SortMode::IoWrite => sort_by_io_field_asc(processes, |p| p.io_write_kb),
SortMode::IoRate => sort_by_io_rate_field_asc(processes, |p| p.io_total_rate_kbs()),
SortMode::IoReadRate => sort_by_io_rate_field_asc(processes, |p| p.io_read_rate_kbs),
SortMode::IoWriteRate => sort_by_io_rate_field_asc(processes, |p| p.io_write_rate_kbs),
SortMode::RChar => processes.sort_by(|a, b| a.io_rchar_kb.cmp(&b.io_rchar_kb)),
SortMode::WChar => processes.sort_by(|a, b| a.io_wchar_kb.cmp(&b.io_wchar_kb)),
SortMode::VSize => processes.sort_by(|a, b| a.vsize_kb.cmp(&b.vsize_kb)),
SortMode::Pid => processes.sort_by_key(|p| p.pid),
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
}
} else {
match self {
SortMode::Rss => processes.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)),
SortMode::Cpu => processes.sort_by(|a, b| b.cpu_pct.partial_cmp(&a.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)),
SortMode::Io => processes.sort_by(|a, b| {
let ai = a.io_total_kb();
let bi = b.io_total_kb();
match (ai, bi) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}),
SortMode::IoRead => sort_by_io_field(processes, |p| p.io_read_kb),
SortMode::IoWrite => sort_by_io_field(processes, |p| p.io_write_kb),
SortMode::IoRate => sort_by_io_rate_field(processes, |p| p.io_total_rate_kbs()),
SortMode::IoReadRate => sort_by_io_rate_field(processes, |p| p.io_read_rate_kbs),
SortMode::IoWriteRate => sort_by_io_rate_field(processes, |p| p.io_write_rate_kbs),
SortMode::RChar => processes.sort_by(|a, b| b.io_rchar_kb.cmp(&a.io_rchar_kb)),
SortMode::WChar => processes.sort_by(|a, b| b.io_wchar_kb.cmp(&a.io_wchar_kb)),
SortMode::VSize => processes.sort_by(|a, b| b.vsize_kb.cmp(&a.vsize_kb)),
SortMode::Pid => processes.sort_by_key(|p| p.pid),
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
}
}
}
}
@@ -116,6 +155,26 @@ where
});
}
/// Ascending variant of `sort_by_io_field`. v1.38: paired
/// helpers (one for each direction) keep the per-direction
/// comparator logic local and avoid a runtime branch inside
/// the closure.
fn sort_by_io_field_asc<F>(processes: &mut Vec<ProcessInfo>, field: F)
where
F: Fn(&ProcessInfo) -> Option<u64>,
{
processes.sort_by(|a, b| {
let ai = field(a);
let bi = field(b);
match (ai, bi) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
fn sort_by_io_rate_field<F>(processes: &mut Vec<ProcessInfo>, field: F)
where
F: Fn(&ProcessInfo) -> Option<f64>,
@@ -132,6 +191,22 @@ where
});
}
fn sort_by_io_rate_field_asc<F>(processes: &mut Vec<ProcessInfo>, field: F)
where
F: Fn(&ProcessInfo) -> Option<f64>,
{
processes.sort_by(|a, b| {
let ai = field(a);
let bi = field(b);
match (ai, bi) {
(Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
/// Tree sort: emit each process preceded by its parent(s) so the
/// visual reading order matches the parent-child hierarchy. Roots
/// (processes whose ppid is 0 or whose parent is not in the current
@@ -1079,6 +1154,26 @@ mod io_sort_unit_tests {
assert_eq!(ps[1].pid, 2);
}
#[test]
fn sort_ascending_flips_rss_order() {
// v1.38: the same sort key produces opposite order
// when ascending is true. Rss sort: pid 1 (huge) first
// desc, pid 2 (small) first asc.
let mut ps = vec![
ProcessInfo { pid: 1, rss_kb: 1000, ..Default::default() },
ProcessInfo { pid: 2, rss_kb: 100, ..Default::default() },
ProcessInfo { pid: 3, rss_kb: 500, ..Default::default() },
];
SortMode::Rss.sort_ascending(&mut ps, false);
assert_eq!(ps[0].pid, 1); // 1000 (desc)
assert_eq!(ps[1].pid, 3); // 500
assert_eq!(ps[2].pid, 2); // 100
SortMode::Rss.sort_ascending(&mut ps, true);
assert_eq!(ps[0].pid, 2); // 100 (asc)
assert_eq!(ps[1].pid, 3); // 500
assert_eq!(ps[2].pid, 1); // 1000
}
#[test]
fn sort_by_rchar_descending() {
// RChar sort uses io_rchar_kb (VFS-level reads).
@@ -832,6 +832,22 @@ pub fn render_storage_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
disk.kind_label(),
smart_badge
).set_style(theme::LABEL_BOLD)));
// v1.38: per-disk I/O throughput history sparkline.
// 12 samples, normalized 0..=255 against the disk's
// own max (same pattern as the per-PID IO-RATE column
// in the Process tab).
let disk_sparkline: String = app
.disk_history
.get(&disk.name)
.map(|hist| {
let bytes: Vec<u8> = hist.iter().copied().collect();
io_rate_sparkline(&bytes)
})
.unwrap_or_else(|| " ".repeat(crate::app::PROCESS_IO_HISTORY_LEN));
lines.push(Line::from(format!(
" Throughput: {}",
disk_sparkline
).set_style(theme::VALUE)));
if let Some(model) = &disk.model {
lines.push(Line::from(vec![
" Model: ".set_style(theme::LABEL),
@@ -1165,6 +1181,23 @@ pub fn render_pid_detail(detail: &crate::pid_detail::PidDetail, pid: u32) -> Par
opt(&s.uid_real), opt(&s.uid_effective), opt(&s.uid_saved),
opt(&s.gid_real), opt(&s.gid_effective), opt(&s.gid_saved)
).set_style(theme::VALUE)));
// v1.38: cmdline (htop parity). Long cmdlines are
// truncated to fit the popup; the full string is available
// via scrollback in a real terminal.
let cmdline = detail.cmdline.as_deref().unwrap_or("?");
let cmdline_trunc: String = cmdline.chars().take(120).collect();
lines.push(Line::from(format!(
" Cmdline: {}",
if cmdline_trunc.is_empty() { "?".to_string() } else { cmdline_trunc }
).set_style(theme::VALUE)));
let io_pri = detail
.io_priority
.map(|v| v.to_string())
.unwrap_or_else(|| "?".to_string());
lines.push(Line::from(format!(
" IO priority: {}",
io_pri
).set_style(theme::VALUE)));
lines.push(Line::from(""));
lines.push(Line::from("[Memory]".set_style(theme::LABEL_BOLD)));
lines.push(Line::from(format!(