redbear-power: v1.18 — Process filtering (closes v1.13 forward work)

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).
This commit is contained in:
2026-06-20 22:11:37 +03:00
parent 28906f16cf
commit 848d0fad94
7 changed files with 242 additions and 5 deletions
+41
View File
@@ -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 |
+5 -4
View File
@@ -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:
@@ -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.
@@ -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<Instant>,
@@ -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,
}
}
@@ -72,6 +72,18 @@ struct Args {
config_path: Option<std::path::PathBuf>,
}
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<String> = None;
let mut process_filter_input: Option<String> = 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),
@@ -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));
}
}
@@ -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<Line<'a>> = 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} {}",