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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user