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