redbear-power: v1.29 fold/expand tree

Activates the v1.27-deferred fold/expand feature. The tree
view from v1.27 is now interactive: pressing Space on a
parent row toggles whether its descendants are visible.

- New App.folded: BTreeSet<u32> (PIDs whose subtrees are
  collapsed; stable iteration order for future debug dumps)
- New App.process_cursor: usize (Process-tab cursor; distinct
  from table_state which tracks the Per-CPU tab)
- New process::apply_fold(processes, folded) -> Vec<ProcessInfo>
  Hides descendants of any PID in . The fold target
  itself stays visible. Roots are never hidden. Cycles
  tolerated (sort_tree's visited set prevents infinite loops).
- Fold indicator in tree_prefix: \u25b6 for folded, \u25bc for
  expanded, no marker for leaf rows
- Space keypress (in tree mode only) toggles fold on the
  cursor's selected PID; flashes 'folded PID N' or
  'unfolded PID N' (or 'has no children to fold' for leaves)
- sort_tree kept pure; apply_fold is a separate post-step
  applied in app.rs after sort_tree

Test count 107 -> 111 (+4):
- apply_fold_empty_set_is_identity
- apply_fold_hides_descendants_of_folded_root (folds root)
- apply_fold_hides_subtree_of_folded_child (folds middle;
  sibling of folded node stays visible)
- apply_fold_unfold_restores (toggle off)

Redox stripped binary: 4,180,840 bytes (-8 KiB from v1.28;
linker dedup'd some shared code).
Compile warnings: 55 (unchanged).

Notes:
- No cursor navigation yet (j/k, down/up). Default cursor
  is row 0, so user can fold the first process but cannot
  yet move down. Defer to v1.30.
- No persist of fold state across redbear-power restarts.
  Would require a config file. Defer.

Docs: local/docs/redbear-power-improvement-plan.md \xC2\xA753
This commit is contained in:
2026-06-21 01:55:48 +03:00
parent df3021575e
commit 988e8b29bb
5 changed files with 282 additions and 5 deletions
+127 -1
View File
@@ -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<u32>`** — 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<ProcessInfo>` and a `BTreeSet<u32>`
of folded PIDs, and returns a new `Vec` with descendants of folded
PIDs removed. The fold target itself stays visible. Algorithm:
- Maintain a `BTreeSet<u32>` 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.
@@ -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<u32>,
/// 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<crate::pid_detail::PidDetail>,
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;
}
@@ -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");
@@ -198,6 +198,40 @@ pub fn sort_tree(processes: &mut Vec<ProcessInfo>, 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<ProcessInfo>, folded: &std::collections::BTreeSet<u32>) -> Vec<ProcessInfo> {
if folded.is_empty() {
return processes;
}
let mut hidden: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
let mut out: Vec<ProcessInfo> = 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<u32, Vec<usize>>,
@@ -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<u32> = 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<u32> = 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);
}
}
@@ -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<u32>,
) -> 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> {