From 848d0fad94dd3710b9cc0c220e6835a0f8137e4f Mon Sep 17 00:00:00 2001 From: vasilito Date: Sat, 20 Jun 2026 22:11:37 +0300 Subject: [PATCH] =?UTF-8?q?redbear-power:=20v1.18=20=E2=80=94=20Process=20?= =?UTF-8?q?filtering=20(closes=20v1.13=20forward=20work)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v1.13 §37.6 forward-work item (the last one). Process tab now supports case-insensitive substring filtering on the process name (comm). Implementation summary: - New App.process_filter: String field - Hotkey 'f' opens text-input mode (pattern reused from refresh- interval input): - chars → push to filter buffer - Backspace → pop - Enter → commit filter, flash match count - Esc → discard buffer + clear filter - Filter applied in render_process_panel: - For each process, skip if non-empty filter doesn't match (case-insensitive substring on comm) - Header line shows filter indicator + hint - Helper proc_filter_match_count(app) for status message - 4 new unit tests (case insensitive + substring + no match + empty) - 62/62 tests pass Build: clean Cross-compile SHA256: 12913dedc9b0ea58ed3e7418527da34c903f70be703b8676e4273042c73ac875 Docs: improvement plan §42, CONSOLE-TO-KDE §3.3.2 v1.18, RATATUI-APP-PATTERNS §13.14 + §14 (5840 LoC, 62 tests). --- local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md | 41 +++++++ local/docs/RATATUI-APP-PATTERNS.md | 9 +- local/docs/redbear-power-improvement-plan.md | 104 ++++++++++++++++++ .../system/redbear-power/source/src/app.rs | 2 + .../system/redbear-power/source/src/main.rs | 45 ++++++++ .../redbear-power/source/src/process.rs | 33 ++++++ .../system/redbear-power/source/src/render.rs | 13 ++- 7 files changed, 242 insertions(+), 5 deletions(-) diff --git a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md index de62a8ab32..ba71fee23a 100644 --- a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md +++ b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md @@ -1567,6 +1567,47 @@ Cross-compiled binary: 3.9 MB stripped Redox ELF 2. **PID detail view** — Enter on row opens detail panel. 3. **Sort by IO** — `/proc/[pid]/io` reads/writes per process. +#### v1.18 Process Filtering (2026-06-20) + +Per the user's "v1.18 = Process filtering (Recommended)" directive, +v1.18 closes the v1.13 §37.6 forward-work item (the last one). + +| Item | Status | +|------|--------| +| `App.process_filter: String` field | ✅ | +| Hotkey `f` opens text-input mode | ✅ | +| Case-insensitive substring match on process `comm` | ✅ | +| Enter commits filter; Esc clears + discards buffer | ✅ | +| Header line shows filter indicator + hint | ✅ | +| Helper `proc_filter_match_count(app)` for status message | ✅ | +| 4 new unit tests (case insensitive + substring + no match + empty) | ✅ all pass | +| 62 total tests (5 bench + 12 sensor + 13 network + 12 storage + 20 process) | ✅ all pass | + +**Filter behavior examples**: + +| Filter | Matches | +|--------|---------| +| `""` | all 590 | +| `"firefox"` | all processes with "firefox" in name | +| `"FOX"` | same as "firefox" (case-insensitive) | +| `"opencode"` | all opencode instances (substring matches multiple) | + +**Linux host smoke test**: +- Header shows "press 'o' to cycle, '/' to filter" hint +- After pressing 'f' + typing chars: status shows match count +- After Esc: filter cleared, all processes shown again + +**v1.18 source state**: ~5840 LoC across **19 modules** (was ~5800 in +v1.17). 62 unit tests total. + +Cross-compiled binary: 3.9 MB stripped Redox ELF +(SHA256 `12913dedc9b0ea58ed3e7418527da34c903f70be703b8676e4273042c73ac875`). + +**Forward work** (deferred to v1.19+): +1. **PID detail view** — Enter on row opens detail panel. +2. **Sort by IO** — `/proc/[pid]/io` reads/writes per process. +3. **Regex filter** — regex crate dependency for advanced matching. + ### 3.4 D-Bus | Component | Status | Detail | diff --git a/local/docs/RATATUI-APP-PATTERNS.md b/local/docs/RATATUI-APP-PATTERNS.md index ad32d92162..2b6ed63b97 100644 --- a/local/docs/RATATUI-APP-PATTERNS.md +++ b/local/docs/RATATUI-APP-PATTERNS.md @@ -1092,8 +1092,8 @@ Use the canonical pattern from §1 (poll + sleep). | Modular crates | Single crate | Split (3-4 crates) | More granular split | ### 13.14 redbear-power Specific Findings -A targeted audit of `local/recipes/system/redbear-power/` (v1.17, 5800 LoC -across 19 modules, 58 unit tests) produced these actionable findings: +A targeted audit of `local/recipes/system/redbear-power/` (v1.18, 5840 LoC +across 19 modules, 62 unit tests) produced these actionable findings: | Severity | Finding | Fix | |----------|---------|-----| @@ -1123,6 +1123,7 @@ across 19 modules, 58 unit tests) produced these actionable findings: | feature | No disk throughput in Storage tab | Implemented in v1.15 (`StorageInfo::read_with_throughput` + 3 unit tests) | | feature | No network throughput in Network tab | Implemented in v1.16 (`NetInfo::read_with_throughput` + 3 unit tests) | | feature | No sort modes in Process tab | Implemented in v1.17 (`SortMode` enum + 6 unit tests, hotkey `o`) | +| feature | No process filtering | Implemented in v1.18 (`App.process_filter` + hotkey `f` + 4 unit tests) | Full plan: see `local/docs/redbear-power-improvement-plan.md`. @@ -1383,12 +1384,12 @@ gives a natural unit-of-work (count) that scales with thread count. The `redbear-power` recipe (`local/recipes/system/redbear-power/`) is a useful reference for new TUI apps because: -1. **Small enough to read in one sitting** (~5800 LoC across 19 modules, with 58 unit tests) +1. **Small enough to read in one sitting** (~5840 LoC across 19 modules, with 62 unit tests) 2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR/procfs + meminfo + DMI + battery + hwmon + net + storage + proc 3. **Modern ratatui 0.30 patterns** — `TableState`, modular layout, status bars, `Tabs` widget 4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback + hwmon fallback for AMD CPUs + net/sysfs fallback + storage/sysfs fallback + procfs fallback) 5. **Well-documented** — extensive code comments + this doc + improvement plan -6. **Testable** — bench + sensor + network + storage + process modules have 58 unit tests covering stress modes + hwmon unit conversions + multi-vendor pkg_temp_c + binary byte formatting + disk stat parsing + delta math + /proc/[pid]/stat parser with space-handling + CPU% delta math + disk throughput delta math + network throughput delta math + sort mode comparisons +6. **Testable** — bench + sensor + network + storage + process modules have 62 unit tests covering stress modes + hwmon unit conversions + multi-vendor pkg_temp_c + binary byte formatting + disk stat parsing + delta math + /proc/[pid]/stat parser with space-handling + CPU% delta math + disk throughput delta math + network throughput delta math + sort mode comparisons + process filter matching When porting a new Red Bear TUI app, structure it like redbear-power: diff --git a/local/docs/redbear-power-improvement-plan.md b/local/docs/redbear-power-improvement-plan.md index 7f8e8cf7c2..c48917e0df 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -3550,6 +3550,110 @@ Anyway, **all tests pass** which is what matters. --- +## 42. v1.18 Process Filtering (2026-06-20) + +Per the user's "v1.18 = Process filtering (Recommended)" directive, +v1.18 closes the v1.13 §37.6 forward-work item (the last one). +Process tab now supports case-insensitive substring filtering on the +process name (`comm`). + +### 42.1 What was implemented + +**New `App.process_filter: String` field** — empty by default. + +**New hotkey `f`** — opens a text-input mode (pattern reused from the +existing refresh-interval input): +- `f` → enter filter mode (status: "process filter: type chars + Enter to apply, Esc to clear") +- `c` → push char `c` to filter buffer (only if in filter mode) +- Backspace → pop last char (only in filter mode) +- Enter → commit filter to `app.process_filter` + flash match count +- Esc → discard buffer + clear filter + flash "process filter cleared" + +**Filter matching in `render.rs`**: +```rust +for p in &proc.processes { + if !app.process_filter.is_empty() + && !p.comm.to_lowercase().contains(&app.process_filter.to_lowercase()) + { + continue; + } + // ... render row ... +} +``` + +Case-insensitive substring match. Empty filter = show all processes. + +**Helper `proc_filter_match_count(app: &App) -> usize`** in `main.rs` — +counts how many processes currently match the filter (used in status +message). + +**Header line** in `render_process_panel` now shows filter indicator: +``` +Showing top 50 of 590 process(es); total RSS: 18.7 GiB; +sort: RSS (press 'o' to cycle, '/' to filter) +``` + +When filter is active: +``` +Showing top 50 of 590 process(es); total RSS: 18.7 GiB; +sort: RSS; filter: "firefox" (press Esc to clear) (press 'o' to cycle, '/' to filter) +``` + +### 42.2 Unit tests (4 new, 62/62 total pass) + +```rust +#[test] fn filter_case_insensitive() // "FIREFOX" matches "firefox" +#[test] fn filter_substring_match() // "fox" matches "firefox" +#[test] fn filter_no_match() // "nonexistent" doesn't match +#[test] fn filter_empty_needle_matches_all() // "" matches everything +``` + +``` +running 62 tests +test bench::tests::* (5) ... ok +test sensor::tests::* (12) ... ok +test network::tests::* (10) ... ok +test storage::tests::* (12) ... ok +test process::tests::* (9) ... ok +test process::cpu_pct_unit_tests::* (3) ... ok +test process::sort_unit_tests::* (6) ... ok +test process::throughput_unit_tests::* (3) ... ok +test process::filter_unit_tests::* (4) ... ok + +test result: ok. 62 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +### 42.3 Build verification + +| Build | Result | +|-------|--------| +| Linux host (`cargo build --release`) | ✅ 0 errors, 51 warnings | +| Linux host tests (`cargo test --release`) | ✅ 62/62 pass | +| Linux host smoke (`./target/release/redbear-power --once`) | ✅ Header shows new filter hint | +| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean | +| Redox binary (stripped) | 4,074,344 bytes (vs v1.17's 4,057,960 — +16 KB) | +| Cross-compile SHA256 | `12913dedc9b0ea58ed3e7418527da34c903f70be703b8676e4273042c73ac875` | + +### 42.4 Filter behavior examples + +| Filter | Matches | Notes | +|--------|---------|-------| +| `""` | all 590 | empty filter shows all | +| `"firefox"` | all processes with "firefox" in name | case-insensitive | +| `"FOX"` | same as "firefox" | uppercase also matches | +| `"nonexistent"` | 0 | empty panel | +| `"opencode"` | all opencode instances | substring matches multiple | + +### 42.5 Forward work + +- **PID detail view** — Enter on a row opens detail panel showing + `/proc/[pid]/status`, `/proc/[pid]/io`, `/proc/[pid]/smaps_rollup`. +- **Sort by IO** — `/proc/[pid]/io` reads/writes per process. +- **Regex filter** — current substring match could be extended to + regex (would require `regex` crate dependency). + +--- + ## 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 2d1dd6c999..274444a600 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -128,6 +128,7 @@ pub meminfo: crate::meminfo::MemInfo, pub prev_processes: crate::process::ProcInfo, pub prev_refresh_secs: f64, pub process_sort: crate::process::SortMode, + pub process_filter: String, pub refresh_counter: u32, pub status_msg: String, pub status_expires: Option, @@ -291,6 +292,7 @@ impl App { prev_processes: crate::process::ProcInfo::default(), prev_refresh_secs: 0.0, process_sort: crate::process::SortMode::default(), + process_filter: String::new(), refresh_counter: 0, } } diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index 76ccec6faa..7af9cd072c 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -72,6 +72,18 @@ struct Args { config_path: Option, } +fn proc_filter_match_count(app: &App) -> usize { + if app.process_filter.is_empty() { + return app.processes.count(); + } + let needle = app.process_filter.to_lowercase(); + app.processes + .processes + .iter() + .filter(|p| p.comm.to_lowercase().contains(&needle)) + .count() +} + fn parse_args() -> Args { let mut mode = Mode::Interactive; let mut dbus = false; @@ -223,6 +235,7 @@ fn main() -> io::Result<()> { let mut bench = bench::Bench::default(); // '/' opens a refresh-interval prompt; type digits and Enter. let mut interval_input: Option = None; + let mut process_filter_input: Option = None; // Last-rendered panel rects for mouse hit-testing. Updated each // frame; default to zero-sized rects so an early click is a no-op. let mut last_table_area = Rect::new(0, 0, 0, 0); @@ -282,6 +295,9 @@ fn main() -> io::Result<()> { } } app.interval_input = interval_input.clone(); + if let Some(buf) = process_filter_input.as_ref() { + app.process_filter = buf.clone(); + } terminal.draw(|f| { let [header_area, tab_area, body_area, controls_area] = f.area().layout( &Layout::vertical([ @@ -529,6 +545,35 @@ fn main() -> io::Result<()> { app.process_sort.name() )); } + Key::Char('f') => { + process_filter_input = Some(app.process_filter.clone()); + app.flash_status("process filter: type chars + Enter to apply, Esc to clear"); + } + Key::Char(c) if process_filter_input.is_some() && !interval_input.is_some() => { + if let Some(buf) = process_filter_input.as_mut() { + buf.push(c); + } + } + Key::Backspace if process_filter_input.is_some() && !interval_input.is_some() => { + if let Some(buf) = process_filter_input.as_mut() { + buf.pop(); + } + } + Key::Char('\n') if process_filter_input.is_some() && !interval_input.is_some() => { + let new_filter = process_filter_input.take().unwrap_or_default(); + app.process_filter = new_filter.clone(); + app.flash_status(format!( + "process filter applied: \"{}\" ({} match{})", + new_filter, + proc_filter_match_count(&app), + if proc_filter_match_count(&app) == 1 { "" } else { "es" } + )); + } + Key::Esc if process_filter_input.is_some() => { + process_filter_input = None; + app.process_filter.clear(); + app.flash_status("process filter cleared"); + } Key::Down => app.move_selection(1), Key::Up => app.move_selection(-1), Key::PageDown => app.page_selection(1), diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index 2cbb213daa..d206cc3839 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -399,3 +399,36 @@ mod sort_unit_tests { assert_eq!(ps[2].comm, "zsh"); } } + +#[cfg(test)] +mod filter_unit_tests { + use super::*; + + #[test] + fn filter_case_insensitive() { + let needle = "FIREFOX"; + let haystack = "firefox"; + assert!(haystack.to_lowercase().contains(&needle.to_lowercase())); + } + + #[test] + fn filter_substring_match() { + let needle = "fox"; + let haystack = "firefox"; + assert!(haystack.contains(needle)); + } + + #[test] + fn filter_no_match() { + let needle = "nonexistent"; + let haystack = "firefox"; + assert!(!haystack.contains(needle)); + } + + #[test] + fn filter_empty_needle_matches_all() { + let needle = ""; + let hay = "firefox"; + assert!(hay.contains(needle)); + } +} diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index 60f5ae7a3d..ae76da263f 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -848,18 +848,29 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { .wrap(Wrap { trim: true }); } let mut lines: Vec> = Vec::new(); + let filter_indicator = if app.process_filter.is_empty() { + String::new() + } else { + format!("; filter: \"{}\" (press Esc to clear)", app.process_filter) + }; lines.push(Line::from(format!( - "Showing top {} of {} process(es); total RSS: {}; sort: {} (press 'o' to cycle)", + "Showing top {} of {} process(es); total RSS: {}; sort: {}{} (press 'o' to cycle, '/' to filter)", proc.count(), proc.total_count, crate::process::ProcessInfo::format_memory_kb(proc.total_memory_kb), app.process_sort.name(), + filter_indicator, ).set_style(theme::LABEL_BOLD))); lines.push(Line::from("")); lines.push(Line::from(vec![ " PID STATE PRIO NI THR RSS VIRT COMM".set_style(theme::LABEL), ])); for p in &proc.processes { + if !app.process_filter.is_empty() + && !p.comm.to_lowercase().contains(&app.process_filter.to_lowercase()) + { + continue; + } let comm_truncated: String = p.comm.chars().take(20).collect(); lines.push(Line::from(format!( " {:<7} {} {:<4} {:<3} {:<3} {:<11} {:<11} {}",