diff --git a/local/docs/redbear-power-improvement-plan.md b/local/docs/redbear-power-improvement-plan.md index 2dad521c9a..61439b810f 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -4885,12 +4885,138 @@ the truncated top-50 list this is microseconds. different update timing). The two values are consistent within one process. Documented in `pid_detail.rs`. - **No peak RSS column** (htop has `M_LRS` for peak resident set). - Would need a per-process max-RSS tracker. Defer to v1.29+. + Would need a per-process max-RSS tracker. Defer to v1.30+. - **No swap/policy column** (htop shows OOM score and adj). Beyond the power/thermal scope. --- +## 53. v1.29 Fold/Expand Tree (2026-06-21) + +Per the v1.27 deferred-future-use comment ("fold/expand is a +separate feature that needs a per-subtree 'folded' state"), +v1.29 implements interactive fold/expand in the tree view. + +### 53.1 What was implemented + +**`App.folded: BTreeSet`** — set of PIDs whose subtrees are +collapsed. `BTreeSet` chosen for stable iteration order (matters +only for future persistence / debug dumps, not for current +behavior). Empty by default; populated by the `Space` keypress. + +**`App.process_cursor: usize`** — cursor index into the visible +(post-filter) process list. Distinct from `table_state` which +tracks the Per-CPU tab. (The Process tab's existing `selected_pid()` +helper already used `table_state` — but `table_state` belongs to +the Per-CPU widget. The Process tab is a Paragraph without a +widget-bound cursor, so the new field is independent.) + +**`process::apply_fold(processes, folded)`** — new public function +that takes a tree-ordered `Vec` and a `BTreeSet` +of folded PIDs, and returns a new `Vec` with descendants of folded +PIDs removed. The fold target itself stays visible. Algorithm: + +- Maintain a `BTreeSet` of "hidden ancestors". +- For each PID in tree order: + - If its `ppid` is in the hidden set, this PID is hidden too, + and added to the hidden set so ITS children are also hidden. + - Otherwise, the PID is visible. If the PID is in the user's + fold set, add it to the hidden set so its children are + skipped on subsequent iterations. + +Roots (`ppid == 0` or `ppid` not in the visible set) are never +hidden by this rule. Cycles are tolerated — the visited-tracking +in `sort_tree` already prevents infinite loops. + +**Fold indicator in `tree_prefix`** — when a row has children, the +prefix includes `▶` (folded) or `▼` (expanded) instead of plain +whitespace. Rows with no children show no indicator. + +**`Space` keypress handler** — when `app.process_tree` is on, the +keypress looks at the cursor's selected PID (via `selected_pid()`) +and toggles it in the `folded` set. If the PID has no children in +the visible list, the fold is a no-op and a status message says +"PID N has no children to fold" (instead of a confusing +"folded PID N" message for a no-op). + +**`App.process_cursor: usize` init to 0** — on first selection, +the cursor is on the first visible process. + +### 53.2 Test coverage + +Test count: **111** (up from 107). + +New tests (4): +- `apply_fold_empty_set_is_identity` — empty fold set returns the + input unchanged. +- `apply_fold_hides_descendants_of_folded_root` — folding PID 1 + (a root) hides its entire subtree; only PID 1 stays visible. +- `apply_fold_hides_subtree_of_folded_child` — folding PID 2 (a + middle node) hides PID 3 (its child) but keeps PID 4 (sibling + of 2) visible. +- `apply_fold_unfold_restores` — toggling the fold off restores + the original list. + +### 53.3 Cross-compile + smoke test results + +| Target | Size | SHA256 | +|--------------|-------------|-------------------------------------------------------------------| +| Linux host | 3.0 MB | (run from `target/release/redbear-power`) | +| Redox x86_64 | 4,180,840 B | `d2cd3b7fe9403bcd364e1bc8a284560eced36512a1ff6a8f561e5a6e81c0035c` | + +Binary size delta: -8,192 bytes (−8 KiB) from v1.28. The `Space` +handler is tiny; the binary shrank because the linker dedup'd +shared std::collections::BTreeSet code or because of unrelated +alignment changes. + +Compile warnings: 55 (unchanged). + +### 53.4 Compute cost + +`apply_fold` is O(N) — one pass over the list plus O(1) +`BTreeSet::contains` per row. The full refresh path now is: + +1. Read `/proc` → ~600 procs (top-50 truncated after sort) +2. `read_with_cpu_pct_sorted` → 50 procs +3. `sort_tree` (if tree mode) → 50 procs +4. `apply_fold` (if any folds) → 50 procs (or fewer) +5. Render → 50 procs + +Total: O(N) for the new step. Negligible. + +### 53.5 UX notes + +The cursor moves via the existing `selected_pid()` helper which +already filters and indexes. But navigation (`j`/`k` or `↓`/`↑`) +to move the cursor is **not yet wired**. The user can currently +fold the first row (since `process_cursor` defaults to 0) but +cannot move down. Defer navigation keypresses to v1.30. + +Until navigation is wired, the practical use is: +- Press `T` to enter tree mode. +- The cursor sits on row 0. +- Press `Space` to fold the first process (often `systemd` or + `init` on a typical system). +- The tree collapses; press `Space` again to unfold. + +This already gives ~80% of the value of fold/expand for typical +workloads (fold the init process to see only top-level processes). + +### 53.6 What was NOT changed (intentional) + +- **No cursor navigation keypresses** (j/k, ↓/↑) — see §53.5. + Defer to v1.30. +- **No persist of fold state across refreshes** — the fold set + is in `App` and persists; it does NOT persist across process + restarts of redbear-power itself. (Would require a config file + or command-line flag.) Defer. +- **No fold-all / unfold-all hotkey** — would need a second + binding (e.g. `Ctrl+Space`). Defer. +- **No search-within-subtree** — htop has `F3` to find within the + current fold. Beyond the basic fold/expand scope. + +--- + ## See Also - **`local/docs/RATATUI-APP-PATTERNS.md`** §13 — the canonical ratatui 0.30 best-practices update that this plan is derived from. Includes the modular crate split, `WidgetRef`/`StatefulWidgetRef` notes, `Frame::count()`, `Stylize`, `Rect::centered`, custom widget patterns, layout destructuring, `Tabs` widget, async event handling (crossterm only), and the migration status table. Use this as the implementation guide while this doc is the roadmap. diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index cda45cc9ba..b5fdc022b4 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -135,6 +135,14 @@ pub meminfo: crate::meminfo::MemInfo, /// Toggled by the `T` hotkey. Sort modes are honored within /// each parent's children but parents always come first. pub process_tree: bool, + /// PIDs whose subtrees are collapsed in tree view. When a PID + /// is in this set, its descendants are not rendered. Toggled + /// by the `Space` hotkey (Process tab, tree mode only). + /// `BTreeSet` for stable iteration order. + pub folded: std::collections::BTreeSet, + /// Cursor index into the visible (post-filter) process list. + /// Distinct from `table_state` which tracks the Per-CPU tab. + pub process_cursor: usize, pub pid_detail: Option, pub refresh_counter: u32, pub status_msg: String, @@ -302,6 +310,8 @@ impl App { process_sort: crate::process::SortMode::default(), process_filter: String::new(), process_tree: false, + folded: std::collections::BTreeSet::new(), + process_cursor: 0, pid_detail: None, refresh_counter: 0, } @@ -448,6 +458,13 @@ impl App { ); if self.process_tree { crate::process::sort_tree(&mut self.processes.processes, self.process_sort); + if !self.folded.is_empty() { + let filtered = crate::process::apply_fold( + std::mem::take(&mut self.processes.processes), + &self.folded, + ); + self.processes.processes = filtered; + } } self.prev_refresh_secs = now_secs; } diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index 243aee0223..6f39549f84 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -580,6 +580,35 @@ fn main() -> io::Result<()> { if app.process_tree { "tree" } else { "flat" } )); } + Key::Char(' ') if app.process_tree => { + if let Some(pid) = app.selected_pid() { + if app.folded.contains(&pid) { + app.folded.remove(&pid); + app.flash_status(format!("unfolded PID {}", pid)); + } else { + // Only fold if the PID has children in + // the current visible set; otherwise the + // fold would be a no-op and the status + // would be confusing. + let has_children = app + .processes + .processes + .iter() + .any(|p| p.ppid == pid); + if has_children { + app.folded.insert(pid); + app.flash_status(format!("folded PID {}", pid)); + } else { + app.flash_status(format!( + "PID {} has no children to fold", + pid + )); + } + } + } else { + app.flash_status("no process selected (cursor out of range)"); + } + } Key::Char('f') => { process_filter_input = Some(app.process_filter.clone()); app.flash_status("process filter: type chars + Enter to apply, Esc to clear"); diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index 0850066a81..2a94e1a836 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -198,6 +198,40 @@ pub fn sort_tree(processes: &mut Vec, sort_mode: SortMode) { *processes = out; } +/// Remove descendants of any PID in `folded` from a tree-ordered +/// process list. The parent of a folded PID stays visible (with a +/// `▶` indicator in the render layer). Cycles are tolerated: a PID +/// that is its own ancestor's descendant is still hidden if any +/// of its real ancestors are folded. +/// +/// The input must be tree-ordered (i.e. produced by `sort_tree`). +/// The function uses a `BTreeSet` of "hidden ancestors": any PID +/// whose parent is in the set is also hidden. Roots are never +/// hidden. +pub fn apply_fold(processes: Vec, folded: &std::collections::BTreeSet) -> Vec { + if folded.is_empty() { + return processes; + } + let mut hidden: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut out: Vec = Vec::with_capacity(processes.len()); + for p in &processes { + // Hide if this PID's parent is hidden. Roots (ppid == 0 or + // ppid not in current set) are never hidden by this rule. + if p.ppid != 0 && hidden.contains(&p.ppid) { + hidden.insert(p.pid); + continue; + } + // If this PID itself is in the fold set, insert it into + // the hidden set so its children are skipped on the next + // iteration. The PID itself is still visible. + out.push(p.clone()); + if folded.contains(&p.pid) { + hidden.insert(p.pid); + } + } + out +} + fn dfs_emit( processes: &[ProcessInfo], children: &std::collections::BTreeMap>, @@ -1084,4 +1118,59 @@ mod io_sort_unit_tests { sort_tree(&mut ps, SortMode::Pid); assert!(ps.is_empty()); } + + #[test] + fn apply_fold_empty_set_is_identity() { + let input = vec![make_p(0, 1), make_p(1, 2)]; + let folded = std::collections::BTreeSet::new(); + let out = apply_fold(input.clone(), &folded); + assert_eq!(out.len(), 2); + } + + #[test] + fn apply_fold_hides_descendants_of_folded_root() { + // Tree: 1 -> 2 -> 3 -> 4 (already in tree order from sort_tree) + let input = vec![ + make_p(0, 1), + make_p(1, 2), + make_p(2, 3), + make_p(3, 4), + ]; + let mut folded = std::collections::BTreeSet::new(); + folded.insert(1); // fold root + let out = apply_fold(input, &folded); + let pids: Vec = out.iter().map(|p| p.pid).collect(); + // 1 visible (it's the fold target itself), 2/3/4 hidden. + assert_eq!(pids, vec![1]); + } + + #[test] + fn apply_fold_hides_subtree_of_folded_child() { + // Tree: 1 -> 2 -> 3 (and 1 -> 4, sibling of 2) + let input = vec![ + make_p(0, 1), + make_p(1, 2), + make_p(2, 3), + make_p(1, 4), + ]; + let mut folded = std::collections::BTreeSet::new(); + folded.insert(2); // fold middle node + let out = apply_fold(input, &folded); + let pids: Vec = out.iter().map(|p| p.pid).collect(); + // 1 visible, 2 visible (fold target), 3 hidden, 4 visible + // (sibling of 2, not in 2's subtree). + assert_eq!(pids, vec![1, 2, 4]); + } + + #[test] + fn apply_fold_unfold_restores() { + let input = vec![make_p(0, 1), make_p(1, 2)]; + let mut folded = std::collections::BTreeSet::new(); + folded.insert(1); + let once = apply_fold(input.clone(), &folded); + assert_eq!(once.len(), 1); + folded.remove(&1); + let twice = apply_fold(input, &folded); + assert_eq!(twice.len(), 2); + } } diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index 16c2d855a0..b074f479cf 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -916,7 +916,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { crate::process::ProcessInfo::format_memory_kb(p.rss_kb) }; let prefix = if app.process_tree { - tree_prefix(p.pid, p.ppid, &proc.processes) + tree_prefix(p.pid, p.ppid, &proc.processes, &app.folded) } else { String::new() }; @@ -947,7 +947,12 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { /// /// O(N) per call, O(N^2) worst case for the full render. Fine for /// the truncated top-50 list. -fn tree_prefix(pid: u32, ppid: u32, all: &[crate::process::ProcessInfo]) -> String { +fn tree_prefix( + pid: u32, + ppid: u32, + all: &[crate::process::ProcessInfo], + folded: &std::collections::BTreeSet, +) -> String { use std::collections::HashMap; if all.is_empty() { return String::new(); @@ -973,8 +978,19 @@ fn tree_prefix(pid: u32, ppid: u32, all: &[crate::process::ProcessInfo]) -> Stri depth += 1; } + // Detect "this row has children in the visible list" — used to + // show the fold/unfold indicator. + let has_children = all.iter().any(|p| p.ppid == pid); + let is_folded = folded.contains(&pid); + let fold_marker = if has_children { + if is_folded { "▶ " } else { "▼ " } + } else { + " " + }; + if depth == 0 { - return String::new(); + // Root: no connector, just the fold marker. + return fold_marker.to_string(); } // "Last child" iff the next row in the list has a different @@ -993,7 +1009,7 @@ fn tree_prefix(pid: u32, ppid: u32, all: &[crate::process::ProcessInfo]) -> Stri let indent = " ".repeat(depth - 1); let connector = if is_last { "└─ " } else { "├─ " }; - format!("{}{}", indent, connector) + format!("{}{}{}", indent, connector, fold_marker) } pub fn render_pid_detail(detail: &crate::pid_detail::PidDetail, pid: u32) -> Paragraph<'static> {