64325790de
Borrowed from bottom v0.11.2 comparison assessment (17 implementation passes):
New modules:
- src/graph.rs: BrailleGraph widget (Canvas + Marker::Braille) for
time-series rendering; RingHistory buffer with display_max() for
stable y-axis scaling (nice rounding: 1,2,5,10,20,50,100...)
- src/kill.rs: process kill dialog with signal selection,
Cell<usize> interior mutability pattern, 2s auto-close timer
- src/event.rs: typed Event enum (Key, Mouse, Tick, Resize, Terminate)
New features:
- Braille graphs: 5 widgets across 4 tabs (CPU%, Temp°C, PkgW,
Net KiB/s, Disk KiB/s) with y-axis labels and reserved margin
- Theme system: dark/light/high-contrast presets, --theme CLI flag
with validation, wired into panel_border(), BrailleGraph,
header governor, cursor highlight
- Widget expansion (e key): full-screen tab toggle with hint bar
- Data freeze (f key): pause data updates with [FROZEN] indicator
- Process kill dialog (k key): SIGTERM/SIGKILL/SIGINT/SIGHUP
- --once mode: now renders 3 braille graphs + all text panels
- Double-refresh at startup for ≥2 graph data points
- Startup diagnostic to stderr (cores, governor, MSR, DMI name)
Stability fixes:
- MSR per-CPU failure cache (Mutex<Vec<bool>>) — avoids repeated
doomed /dev/cpu/*/msr opens; auto-clears every ~30s (60 refreshes)
- skip_refresh guard: if refresh >200ms, skip next cycle to
prevent refresh pile-up on slow MSR reads
- collector.rs: removed Barrier — threads start work immediately
instead of waiting for all threads to spawn
- panic hook: restores terminal on panic
- IsTerminal check: clear error message before entering raw mode
Bug fixes:
- 4 key shadowing bugs found and fixed:
'g' (governor vs. move-to-top) — removed unreachable arm
'T' (tab-cycle vs. tree-toggle) — changed tree to 'y'
'f' (freeze vs. process-filter) — changed filter to 'F'
- Key::Esc ordering: kill dialog → PID detail → quit
- process panel header updated ('y' for tree, 'F' for filter)
Build:
- Cargo.toml: [profile.release] lto=true, opt-level=3, codegen-units=1
→ 4.1 MB binary (was 6.1 MB, -33%)
- libc 0.2 dep for Linux kill() in kill dialog
- cargo fmt applied project-wide
Tests: 217 passed (was 198, +19 new)
- graph: 5 tests (RingHistory, display_max, 3 render smoke tests)
- kill: 6 tests (dialog lifecycle, selection wrap, auto-close, render)
- app: 8 integration tests (CPU detection, graph population, governor,
temp source, expand toggle, freeze, skip-refresh)
Docs:
- local/docs/RATATUI-APP-PATTERNS.md updated to v1.44+ status:
§13.14 current feature table, §15 braille graph pattern,
§16 modal dialog pattern, §17 key audit pattern,
§14 line counts updated (13,091 LoC, 27 modules, 217 tests),
summary expanded to 13 rules
1603 lines
55 KiB
Markdown
1603 lines
55 KiB
Markdown
# Building Rich Red Bear Ratatui Apps — Patterns Guide
|
||
|
||
**Created:** 2026-06-20
|
||
**Last updated:** 2026-07-06 (added §15–§18: braille graphs, modal dialogs, key audit, redbear-power v1.44 status)
|
||
**Source:** Extracted from TLC (Twilight Commander) production codebase — 46k+ lines of pure Rust ratatui
|
||
**Audience:** Developers porting or building TUI apps for Red Bear OS
|
||
**ratatui version:** 0.29 baseline (TLC), 0.30 for new apps
|
||
**Cross-references:**
|
||
- `local/recipes/system/redbear-power/` (production ratatui 0.30 consumer, 13,091 LoC, 27 modules, 217 tests)
|
||
- `local/recipes/tui/tlc/` (46k+ LoC TUI file manager, ratatui 0.29)
|
||
- `local/docs/redbear-power-improvement-plan.md` (improvement roadmap)
|
||
- `local/docs/bottom-vs-redbear-power-assessment.md` (bottom comparison assessment)
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
This document captures the reusable architectural patterns, rendering techniques,
|
||
and design decisions proven in the TLC codebase. TLC is a full TUI file manager
|
||
+ editor + viewer built with ratatui 0.29 + termion, running identically on
|
||
Linux and Redox. Every pattern below is battle-tested against 1093 unit tests
|
||
and real interactive use.
|
||
|
||
**Golden Rule:** Source colors exclusively from the `Theme` palette. Never
|
||
hardcode `Color::White`, `Color::Blue`, etc. Every `render()` accepts a
|
||
`theme: &Theme` parameter. This is non-negotiable.
|
||
|
||
**Version note (2026-06-20):** redbear-power uses ratatui 0.30 while TLC
|
||
still uses 0.29. §13 captures the 0.30 additions that apply going forward.
|
||
Most patterns in §1–12 are valid for both versions with the noted idiomatic
|
||
upgrades.
|
||
|
||
---
|
||
|
||
## 1. Event Loop Architecture
|
||
|
||
### Pattern: Poll-Based with Animation Ticks
|
||
|
||
TLC uses a `rustix::event::poll` loop with a 100ms timeout. This gives:
|
||
- Immediate key response (poll returns on stdin data)
|
||
- 10 FPS animation ticks (poll timeout fires when idle)
|
||
- Terminal resize detection (size check every tick)
|
||
|
||
```rust
|
||
// From tlc/src/app.rs
|
||
let poll_timeout = Timespec { tv_sec: 0, tv_nsec: 100_000_000 };
|
||
let mut prev_size = tui.size();
|
||
|
||
loop {
|
||
// 1. Handle external actions (shell suspend/resume)
|
||
if let Some(action) = take_external_action(&mut fm) {
|
||
tui = run_external(tui, &mut shell_manager, action)?;
|
||
render(&mut tui, &mut fm)?;
|
||
}
|
||
|
||
// 2. Poll stdin with 100ms timeout
|
||
let stdin_fd = raw_stdin();
|
||
let mut poll_fds = [PollFd::new(&stdin_fd, PollFlags::IN)];
|
||
let _ = poll(&mut poll_fds, Some(&poll_timeout));
|
||
|
||
// 3. Detect terminal resize
|
||
let size = tui.size();
|
||
if size != prev_size {
|
||
prev_size = size;
|
||
render(&mut tui, &mut fm)?;
|
||
}
|
||
|
||
// 4. If no input, advance animation state
|
||
if !poll_fds[0].revents().contains(PollFlags::IN) {
|
||
fm.frame_count = fm.frame_count.wrapping_add(1);
|
||
fm.sync_animations();
|
||
fm.spinner.tick();
|
||
let toast_active = fm.toasts.tick();
|
||
// Advance editor smooth-scroll animation
|
||
let editor_scrolling = fm.editor.as_mut()
|
||
.map_or(false, |ed| ed.tick_smooth_scroll());
|
||
if fm.spinner.is_active() || toast_active || editor_scrolling {
|
||
render(&mut tui, &mut fm)?;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// 5. Read and dispatch key event
|
||
let (event, _raw) = stdin.lock().events_and_raw().next()?;
|
||
// ... translate and dispatch
|
||
}
|
||
```
|
||
|
||
**Key Decisions:**
|
||
- `frame_count: u64` on the main struct (wrapping_add) — drives all animation timing
|
||
- Spinner, toasts, and animations are ticked on each idle cycle
|
||
- Re-render only when something changed (spinner active, toast visible, animation in flight)
|
||
- `_raw` bytes preserved for F-key parsing (some terminals send unsupported sequences)
|
||
|
||
---
|
||
|
||
## 2. Key System
|
||
|
||
### Pattern: u32 Codepoint + bitflags Modifiers
|
||
|
||
TLC's `Key` struct is deliberately simple — a Unicode codepoint plus modifier flags:
|
||
|
||
```rust
|
||
bitflags! {
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct Modifiers: u8 {
|
||
const SHIFT = 1 << 0;
|
||
const CTRL = 1 << 1;
|
||
const ALT = 1 << 2;
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct Key {
|
||
pub code: u32, // Unicode codepoint or private-use range
|
||
pub mods: Modifiers,
|
||
}
|
||
```
|
||
|
||
### Key Code Ranges
|
||
|
||
| Range | Meaning |
|
||
|-------|---------|
|
||
| `0x20..0x7F` | Printable ASCII |
|
||
| `0x80..0x10FFFF` | Unicode (best-effort single char insert) |
|
||
| `0x0D` | Enter |
|
||
| `0x08` | Backspace |
|
||
| `0x7F` | Delete |
|
||
| `0x09` | Tab |
|
||
| `0x1B` | Escape |
|
||
| `0x2190..0x21A0` | Arrow keys (Unicode arrows) |
|
||
| `0x21A1` | Home |
|
||
| `0x21A0` | End |
|
||
| `0x21DE` | PageUp |
|
||
| `0x21DF` | PageDown |
|
||
| `0xF100..0xF10B` | Function keys F1–F12 (private-use range) |
|
||
|
||
### Constructors
|
||
|
||
```rust
|
||
Key::ENTER // const
|
||
Key::ESCAPE // const
|
||
Key::TAB // const
|
||
Key::BACKSPACE // const
|
||
Key::DELETE // const
|
||
Key::f(n) // const fn — F1 = 0xF100, F2 = 0xF101, ...
|
||
Key::from_char('a')
|
||
Key::ctrl('s') // code = upper(c) - 'A' + 1, mods = CTRL
|
||
Key::alt('f') // code = c as u32, mods = ALT
|
||
```
|
||
|
||
### Termion → Key Translation
|
||
|
||
A `translate_key()` function maps termion's `Key` enum to TLC's `Key` struct.
|
||
For F-keys beyond F5, termion sends "unsupported" byte sequences — a
|
||
`parse_unsupported_fkey()` helper handles those.
|
||
|
||
**Why this matters:** A simple, hashable, `Copy` key type makes dispatch tables,
|
||
macro recording, and keymap configuration trivial.
|
||
|
||
---
|
||
|
||
## 3. Theme System
|
||
|
||
### Pattern: Shared Palette + 23-Field Theme Struct
|
||
|
||
TLC consumes the `redbear-tui-theme` crate which provides `REDBEAR_DARK` and
|
||
`REDBEAR_LIGHT` presets. The local `Theme` struct wraps these for ratatui:
|
||
|
||
```rust
|
||
const fn as_color(c: ThemeRgb) -> Color {
|
||
Color::Rgb(c.0, c.1, c.2)
|
||
}
|
||
|
||
pub const DEFAULT_THEME: Theme = Theme {
|
||
background: as_color(REDBEAR_DARK.background),
|
||
foreground: as_color(REDBEAR_DARK.text),
|
||
selection_bg: as_color(REDBEAR_DARK.selection_bg),
|
||
// ... 20 more fields
|
||
accent: as_color(REDBEAR_DARK.accent), // #B52430 brand red
|
||
};
|
||
```
|
||
|
||
### Theme Fields (23 total)
|
||
|
||
| Field | Purpose |
|
||
|-------|---------|
|
||
| `background` | Panel/editor background |
|
||
| `foreground` | Default text |
|
||
| `selection_bg` / `selection_fg` | Selected items |
|
||
| `cursor_bg` / `cursor_fg` | Editor cursor |
|
||
| `marked_bg` / `marked_fg` | Marked files / editor selection |
|
||
| `directory` | Directory entries |
|
||
| `executable` | Executable files |
|
||
| `symlink` | Symbolic links |
|
||
| `device` | Block/char devices |
|
||
| `hidden` | Dot-files |
|
||
| `accent` | Brand red (#B52430) — highlight, scrollbar, active |
|
||
| `status_bg` / `status_fg` | Status bar |
|
||
| `buttonbar_bg` / `buttonbar_fg` | F-key button bar |
|
||
| `title_bg` / `title_fg` | Panel/editor titles |
|
||
| `border` | Borders |
|
||
| `error` / `warning` / `info` | Status message colors |
|
||
|
||
### MC Skin Compatibility
|
||
|
||
TLC ships a `mc_skin` module that parses MC `.ini` skin files and maps them to
|
||
the `Theme` struct. This gives 8 built-in skins + user TOML skins:
|
||
|
||
```rust
|
||
let pair = mc_skin::color_pair(skin_name, "editor", "editmarked");
|
||
// Returns ColorPair { fg, bg } from the MC skin, or None
|
||
```
|
||
|
||
**Why this matters:** Users coming from MC can use their familiar skins. New
|
||
apps adopting this pattern get instant theme compatibility.
|
||
|
||
### Runtime Skin Switching
|
||
|
||
Skins are switched at runtime via `Alt-S` — no restart needed. User TOML skins
|
||
in `~/.config/tlc/skin/*.toml` are loaded on demand and cached in a
|
||
`RwLock<UserSkinCache>`.
|
||
|
||
---
|
||
|
||
## 4. Rendering Patterns
|
||
|
||
### Pattern A: Direct Buffer Manipulation
|
||
|
||
For effects that standard widgets can't produce, reach into `frame.buffer_mut()`:
|
||
|
||
```rust
|
||
// Bracket match flash — highlight the matching bracket
|
||
if let Some(flash) = &self.bracket_flash {
|
||
let buf = frame.buffer_mut();
|
||
let cell = buf.get_mut(flash.x, flash.y);
|
||
cell.set_style(Style::default().fg(theme.accent).add_modifier(StyleModifier::BOLD));
|
||
}
|
||
```
|
||
|
||
**Use cases in TLC:**
|
||
- Bracket match flash (temporary overlay on matching bracket)
|
||
- Vertical scrollbar (draw `│` characters in the gutter)
|
||
- Cursor shape (Block/Bar/Underline — overwrite the cursor cell)
|
||
- Accent bar (3px colored strip on the left of the active panel)
|
||
|
||
**Rule:** Direct buffer manipulation is for pixel-level effects only. Use
|
||
widgets for everything else.
|
||
|
||
### Pattern B: Nested Layout
|
||
|
||
TLC uses ratatui's `Layout` system extensively:
|
||
|
||
```rust
|
||
// Editor layout: title bar | body | status bar
|
||
let chunks = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Length(1), // title
|
||
Constraint::Min(1), // body
|
||
Constraint::Length(1), // status
|
||
])
|
||
.split(area);
|
||
|
||
// Body: gutter | text area | scrollbar
|
||
let body = Layout::default()
|
||
.direction(Direction::Horizontal)
|
||
.constraints([
|
||
Constraint::Length(line_num_width), // gutter
|
||
Constraint::Min(1), // text
|
||
Constraint::Length(1), // scrollbar
|
||
])
|
||
.split(chunks[1]);
|
||
```
|
||
|
||
### Pattern C: Centered Popup with Clear
|
||
|
||
```rust
|
||
use ratatui::widgets::{Clear, Block, Borders};
|
||
|
||
fn render_popup(frame: &mut Frame, area: Rect, title: &str, theme: &Theme) {
|
||
let popup_area = centered_percent_rect(60, 40, area);
|
||
Clear::default().render(popup_area, frame.buffer_mut());
|
||
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.title(Span::styled(title, Style::default().fg(theme.title_fg)))
|
||
.border_style(Style::default().fg(theme.border))
|
||
.style(Style::default().bg(theme.background));
|
||
|
||
// ... render content inside popup
|
||
}
|
||
```
|
||
|
||
### Pattern D: Shadow Effect on Dialogs
|
||
|
||
Draw a semi-transparent shadow Rect offset by (1,1) from the popup:
|
||
|
||
```rust
|
||
let shadow_area = Rect::new(popup_area.x + 1, popup_area.y + 1,
|
||
popup_area.width, popup_area.height);
|
||
let buf = frame.buffer_mut();
|
||
for x in shadow_area.left()..shadow_area.right() {
|
||
for y in shadow_area.top()..shadow_area.bottom() {
|
||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||
cell.set_style(Style::default().bg(Color::Black));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Pattern E: Animation State on Structs
|
||
|
||
Animation state lives as fields on the main struct, ticked by `frame_count`:
|
||
|
||
```rust
|
||
pub struct Editor {
|
||
// Animation: dialog slide-in (0..100 percent)
|
||
dialog_anim: u8,
|
||
// Animation: smooth scroll interpolation
|
||
smooth_scroll_from: u32,
|
||
smooth_scroll_to: u32,
|
||
// Bracket match flash timer (counts down)
|
||
bracket_flash: Option<BracketFlash>,
|
||
// Total frame counter
|
||
// (on FileManager, drives all animations)
|
||
}
|
||
|
||
// Tick function called from the event loop
|
||
fn sync_animations(&mut self) {
|
||
if self.dialog_anim < 100 {
|
||
self.dialog_anim = (self.dialog_anim + 10).min(100);
|
||
}
|
||
if self.bracket_flash.is_some() {
|
||
self.bracket_flash.as_mut().unwrap().tick();
|
||
if self.bracket_flash.as_ref().unwrap().expired() {
|
||
self.bracket_flash = None;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Editor Architecture
|
||
|
||
### Module Decomposition
|
||
|
||
TLC splits the editor into 23 focused modules:
|
||
|
||
```
|
||
src/editor/
|
||
├── mod.rs — Editor struct, public API, fields
|
||
├── buffer.rs — Gap buffer (text storage + cursor)
|
||
├── cursor.rs — Cursor position, selection state
|
||
├── handlers.rs — Key dispatch (handle_key → per-mode)
|
||
├── render.rs — ratatui rendering (layout, colors, effects)
|
||
├── mode.rs — Mode enum (Insert, Normal, Prompt)
|
||
├── prompt.rs — PromptKind enum, prompt input buffer
|
||
├── search.rs — Regex search engine + history
|
||
├── replace.rs — Find-and-replace with per-match state
|
||
├── save.rs — File save + SaveAs logic
|
||
├── history.rs — Undo/redo stack
|
||
├── bookmark.rs — Named bookmarks (a-z)
|
||
├── bracket.rs — Bracket matching utilities
|
||
├── goto.rs — Line/column offset resolution
|
||
├── format.rs — Paragraph formatting, auto-indent
|
||
├── syntax.rs — syntect integration (Highlighter struct)
|
||
├── completion.rs — Word completion engine
|
||
├── cursor_shape.rs — CursorShape enum (Block/Bar/Underline)
|
||
├── clipboard_osc52.rs — OSC 52 clipboard copy/paste
|
||
├── folding.rs — Code fold tracking (FoldSet)
|
||
├── tags.rs — ctags parser + tag jump
|
||
├── macro.rs — Macro recording/playback
|
||
└── view.rs — Viewport scrolling helpers
|
||
```
|
||
|
||
### Key Dispatch: Mode-Based
|
||
|
||
```rust
|
||
pub(crate) fn handle_key(&mut self, key: Key) -> EditorResult {
|
||
// Global intercepts (work in any mode):
|
||
// Ctrl-R: macro record toggle
|
||
// Ctrl-P: macro playback
|
||
// Ctrl-S/F2: save
|
||
// Esc/F10/Ctrl-Q: close
|
||
|
||
if self.mode.is_prompt() {
|
||
return self.handle_key_prompt(key);
|
||
}
|
||
|
||
// Close and Save are intercepted at dispatcher level
|
||
if key == Key::ESCAPE || key == Key::f(10) || key == Key::ctrl('q') {
|
||
// ... save-before-close logic
|
||
}
|
||
if key == Key::ctrl('s') || key == Key::f(2) {
|
||
return EditorResult::Save;
|
||
}
|
||
|
||
// Alt-letter shortcuts from any mode
|
||
if let Some(r) = self.try_global_shortcut(key) {
|
||
return r;
|
||
}
|
||
|
||
// Mode-specific dispatch
|
||
match self.mode {
|
||
Mode::Normal => self.handle_key_normal(key),
|
||
Mode::Insert => self.handle_key_insert(key),
|
||
Mode::Prompt(_) => EditorResult::Running,
|
||
}
|
||
}
|
||
```
|
||
|
||
### Editor Result Type
|
||
|
||
```rust
|
||
pub enum EditorResult {
|
||
Running, // continue editing
|
||
Close, // close editor, discard buffer
|
||
Save, // save buffer (caller calls editor.save())
|
||
SaveThenClose, // save then close
|
||
DiscardThenClose, // discard then close
|
||
}
|
||
```
|
||
|
||
### Cursor/Buffer Separation
|
||
|
||
The `Buffer` owns the text (gap buffer). The `Cursor` owns position + selection
|
||
state. They communicate via explicit calls:
|
||
|
||
```rust
|
||
self.buffer.set_cursor(pos);
|
||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||
```
|
||
|
||
This separation is critical: every mutation on `Buffer` must be followed by
|
||
a `cursor.set_position()` call to keep the cursor's line/column cache in sync.
|
||
|
||
---
|
||
|
||
## 6. Viewer Architecture
|
||
|
||
### Multi-Source Loading
|
||
|
||
```rust
|
||
pub enum FileSource {
|
||
Inline { bytes: Vec<u8> }, // small files (< 1 MiB)
|
||
Compressed { bytes: Vec<u8>, .. }, // .gz, .bz2, .xz, .zst
|
||
Chunked { file: File, size: u64 }, // large files (lazy read)
|
||
}
|
||
```
|
||
|
||
### View Modes
|
||
|
||
```rust
|
||
pub enum ViewMode {
|
||
Text, // Plain text with optional nroff/syntax
|
||
Hex, // Byte + ASCII columns
|
||
}
|
||
```
|
||
|
||
### Prompt-Driven Interactions
|
||
|
||
The viewer uses a simple prompt system for search and goto:
|
||
|
||
```rust
|
||
pub enum ViewerPrompt {
|
||
Search, // '/' key
|
||
GotoLine, // 'g' key
|
||
}
|
||
```
|
||
|
||
### Syntax Highlighting in Viewer
|
||
|
||
The viewer reuses the editor's `Highlighter` struct (syntect-based). When the
|
||
viewport scrolls, the highlighter is rebuilt from scratch by replaying lines
|
||
from file start to the new top line — this ensures parser state correctness
|
||
for multi-line constructs (block comments, strings).
|
||
|
||
---
|
||
|
||
## 7. Widget Patterns
|
||
|
||
### Spinner
|
||
|
||
A simple ASCII art spinner for long operations:
|
||
|
||
```rust
|
||
pub struct Spinner {
|
||
frames: &'static [&'static str], // ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||
index: usize,
|
||
active: bool,
|
||
}
|
||
|
||
impl Spinner {
|
||
pub fn tick(&mut self) {
|
||
if self.active {
|
||
self.index = (self.index + 1) % self.frames.len();
|
||
}
|
||
}
|
||
pub fn current(&self) -> &'static str {
|
||
self.frames[self.index]
|
||
}
|
||
}
|
||
```
|
||
|
||
### Toast Notifications
|
||
|
||
Transient messages that auto-dismiss after a TTL:
|
||
|
||
```rust
|
||
pub struct ToastSystem {
|
||
toasts: Vec<Toast>,
|
||
}
|
||
|
||
struct Toast {
|
||
text: String,
|
||
level: ToastLevel, // Info, Warning, Error
|
||
ttl: u32, // ticks remaining
|
||
}
|
||
|
||
impl ToastSystem {
|
||
pub fn push(&mut self, text: String, level: ToastLevel) {
|
||
self.toasts.push(Toast { text, level, ttl: 30 }); // 3 seconds at 10fps
|
||
}
|
||
pub fn tick(&mut self) -> bool {
|
||
self.toasts.retain(|t| t.ttl > 0);
|
||
self.toasts.iter_mut().for_each(|t| t.ttl -= 1);
|
||
!self.toasts.is_empty()
|
||
}
|
||
}
|
||
```
|
||
|
||
### Scrollbar (Direct Buffer)
|
||
|
||
```rust
|
||
fn render_scrollbar(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||
let buf = frame.buffer_mut();
|
||
let total = self.buffer.line_count() as u32;
|
||
let visible = area.height as u32;
|
||
if total <= visible { return; }
|
||
|
||
let thumb_height = ((visible * visible) / total).max(1);
|
||
let thumb_pos = (self.top_line * (visible - thumb_height)) / (total - visible);
|
||
|
||
for y in 0..area.height {
|
||
let cell = buf.get_mut(area.right() - 1, area.top() + y);
|
||
let in_thumb = y as u32 >= thumb_pos && y as u32 < thumb_pos + thumb_height;
|
||
let ch = if in_thumb { '┃' } else { '│' };
|
||
cell.set_char(ch);
|
||
cell.set_style(Style::default().fg(if in_thumb { theme.accent } else { theme.border }));
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Cross-Platform Patterns
|
||
|
||
### Terminal Init/Restore (termion)
|
||
|
||
```rust
|
||
pub struct Tui {
|
||
screen: AlternateScreen<RawTerminal<Stdout>>,
|
||
// ...
|
||
}
|
||
|
||
impl Tui {
|
||
pub fn new() -> Result<Self> {
|
||
let stdout = io::stdout();
|
||
let screen = AlternateScreen::from(stdout.into_raw_mode()?);
|
||
// Enter alternate screen + raw mode
|
||
Ok(Self { screen, ... })
|
||
}
|
||
}
|
||
|
||
impl Drop for Tui {
|
||
fn drop(&mut self) {
|
||
// AlternateScreen + RawTerminal handle restoration on drop
|
||
}
|
||
}
|
||
```
|
||
|
||
### No `target_os` Gates
|
||
|
||
TLC has zero `#[cfg(target_os = "...")]` gates. It runs identically on Linux
|
||
and Redox because:
|
||
- `std::fs` abstractions for all filesystem operations
|
||
- `cfg(unix)` gates for platform-specific behavior (stat, permissions)
|
||
- `ratatui` + `termion` work on any Unix tty
|
||
|
||
---
|
||
|
||
## 9. Testing Patterns
|
||
|
||
### TestBackend for UI Tests
|
||
|
||
ratatui's `TestBackend` enables snapshot-style UI testing:
|
||
|
||
```rust
|
||
#[test]
|
||
fn test_editor_renders_title() {
|
||
let backend = TestBackend::new(80, 24);
|
||
let mut terminal = Terminal::new(backend).unwrap();
|
||
let mut editor = Editor::new(None);
|
||
let theme = &DEFAULT_THEME;
|
||
|
||
terminal.draw(|f| editor.render(f, f.area(), theme)).unwrap();
|
||
|
||
let buffer = terminal.backend().buffer();
|
||
assert!(buffer.content().iter().any(|c| c.symbol() == "E"));
|
||
}
|
||
```
|
||
|
||
### 1093 Unit Tests
|
||
|
||
TLC's test suite covers:
|
||
- Buffer operations (insert, delete, gap movement)
|
||
- Cursor movement and selection
|
||
- Search (regex compilation, forward/backward, history dedup)
|
||
- Syntax highlighting (line-by-line replay)
|
||
- Bookmark set/jump/clear
|
||
- Macro record/playback
|
||
- Bracket matching
|
||
- Code folding
|
||
- Tag table parsing
|
||
- OSC 52 clipboard encoding
|
||
- Nroff processing (bold/underline escape sequences)
|
||
|
||
---
|
||
|
||
## 10. Syntax Highlighting
|
||
|
||
### Pattern: Stateful Highlighter with Viewport Replay
|
||
|
||
```rust
|
||
pub struct Highlighter {
|
||
syntax_set: SyntaxSet,
|
||
theme: Theme, // syntect theme
|
||
state: Vec<(usize, ParseState)>, // line → state cache
|
||
}
|
||
|
||
impl Highlighter {
|
||
pub fn new(path: &Path) -> Option<Self> {
|
||
// Detect language from extension
|
||
let syntax = Self::syntax_for_path(path)?;
|
||
Some(Self { syntax_set: ..., theme: ..., state: Vec::new() })
|
||
}
|
||
|
||
pub fn highlight_line(&mut self, line: &str) -> Vec<(Style, &str)> {
|
||
// Returns styled spans for ratatui Line
|
||
}
|
||
}
|
||
```
|
||
|
||
### Viewport Scroll Replay
|
||
|
||
When the user scrolls, the highlighter must rebuild parser state from the top
|
||
of the file to the new first visible line. Without this, multi-line constructs
|
||
(block comments, strings) would lose context:
|
||
|
||
```rust
|
||
// In Editor::render(), before drawing lines:
|
||
let current_top = self.effective_top_line();
|
||
if current_top != self.last_render_top {
|
||
self.last_render_top = current_top;
|
||
self.highlighter = Highlighter::new(path); // fresh state
|
||
for i in 0..current_top {
|
||
let line_text = self.buffer.line(i);
|
||
self.highlighter.highlight_line(line_text); // advance state
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Clipboard Integration
|
||
|
||
### OSC 52 Protocol
|
||
|
||
TLC implements OSC 52 for terminal clipboard access, enabling copy/paste over
|
||
SSH without local clipboard tools:
|
||
|
||
```rust
|
||
pub fn osc52_copy(text: &str) -> std::io::Result<()> {
|
||
let encoded = base64::encode(text.as_bytes());
|
||
// OSC 52 sequence: ESC ] 52 ; c ; <base64> BEL
|
||
write!(io::stdout(), "\x1B]52;c;{}\x07", encoded)?;
|
||
io::stdout().flush()
|
||
}
|
||
|
||
pub fn osc52_paste() -> Option<String> {
|
||
// Request clipboard content: ESC ] 52 ; c ; ? BEL
|
||
// Read response from terminal
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 12. Shared TUI Theme Crate
|
||
|
||
### `redbear-tui-theme`
|
||
|
||
All Red Bear TUI apps should consume the shared `redbear-tui-theme` crate for
|
||
consistent branding:
|
||
|
||
```toml
|
||
# Cargo.toml
|
||
[dependencies]
|
||
redbear-tui-theme = { path = "../../tui/redbear-tui-theme" }
|
||
```
|
||
|
||
```rust
|
||
use redbear_tui_theme::{REDBEAR_DARK, Rgb};
|
||
|
||
const fn as_color(c: Rgb) -> Color {
|
||
Color::Rgb(c.0, c.1, c.2)
|
||
}
|
||
|
||
pub const MY_BG: Color = as_color(REDBEAR_DARK.background);
|
||
pub const MY_ACCENT: Color = as_color(REDBEAR_DARK.accent); // #B52430
|
||
```
|
||
|
||
The brand red `#B52430` is the canonical accent across all Red Bear TUI apps.
|
||
|
||
---
|
||
|
||
## Quick-Start Template for New Ratatui Apps
|
||
|
||
```rust
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::TermionBackend;
|
||
use termion::raw::IntoRawMode;
|
||
use termion::screen::AlternateScreen;
|
||
use redbear_tui_theme::{REDBEAR_DARK, Rgb};
|
||
|
||
type Tui = Terminal<TermionBackend<AlternateScreen<RawTerminal<Stdout>>>>;
|
||
|
||
fn main() -> Result<()> {
|
||
let stdout = io::stdout().into_raw_mode()?;
|
||
let screen = AlternateScreen::from(stdout);
|
||
let backend = TermionBackend::new(screen);
|
||
let mut terminal = Terminal::new(backend)?;
|
||
|
||
// App state
|
||
let mut app = MyApp::new();
|
||
|
||
// Event loop (see §1)
|
||
loop {
|
||
terminal.draw(|f| app.render(f))?;
|
||
// ... poll + dispatch
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Summary: 13 Rules for Red Bear Ratatui Apps
|
||
|
||
1. **Theme-driven colors** — every render path takes `&Theme`, never hardcodes colors
|
||
2. **Poll-based event loop** — `rustix::event::poll` with 100ms timeout for animations
|
||
3. **Simple Key type** — `u32` codepoint + `Modifiers` bitflags, `Copy + Hash`
|
||
4. **Mode-based dispatch** — Insert/Normal/Prompt modes with global intercepts
|
||
5. **Direct buffer for effects** — `frame.buffer_mut()` for scrollbar, cursor, flash
|
||
6. **Animation fields on structs** — `frame_count`, `dialog_anim`, `bracket_flash`
|
||
7. **Separation of concerns** — buffer/cursor/handlers/render as separate modules
|
||
8. **Shared theme crate** — `redbear-tui-theme` for brand consistency
|
||
9. **No platform gates** — `cfg(unix)` only, same binary on Linux + Redox
|
||
10. **Test with TestBackend** — snapshot-style UI tests + thorough unit tests
|
||
11. **Braille graphs via Canvas** — `Marker::Braille` + `RingHistory::display_max()` for stable y-axis
|
||
12. **Modal dialogs with Cell** — `Cell<usize>` for interior mutability; `Widget for &Dialog`
|
||
13. **Audit key bindings** — `grep Key::Char` + `sort | uniq -c | sort -rn` after every change
|
||
|
||
---
|
||
|
||
## 13. ratatui 0.30 Best-Practices Update
|
||
|
||
**Added 2026-06-20** after a comprehensive audit of the redbear-power codebase
|
||
against the official ratatui 0.30.2 release (commit `e665c36c`). Most of §1–12
|
||
remain valid; this section captures additions and idiomatic upgrades.
|
||
|
||
### 13.1 Modular Crate Split
|
||
|
||
ratatui 0.30 split into multiple crates. `Cargo.toml` must depend on whichever
|
||
the app needs:
|
||
|
||
```toml
|
||
[dependencies]
|
||
ratatui = "0.30" # umbrella (re-exports all)
|
||
# OR explicit:
|
||
ratatui-core = "0.30" # Widget/StatefulWidget traits, Buffer, Frame
|
||
ratatui-widgets = "0.30" # Table, Sparkline, LineGauge, List, Tabs, etc.
|
||
ratatui-termion = "0.30" # termion backend
|
||
ratatui-crossterm = "0.30" # crossterm backend
|
||
ratatui-macros = "0.30" # derive macros (less used)
|
||
```
|
||
|
||
For Red Bear OS (termion backend), use the umbrella `ratatui = "0.30"` or the
|
||
explicit trio `(ratatui-core, ratatui-widgets, ratatui-termion)`. Both work.
|
||
|
||
### 13.2 WidgetRef / StatefulWidgetRef (unstable)
|
||
|
||
ratatui 0.30 introduced `WidgetRef` for non-consuming widget references. **Currently
|
||
flagged `unstable`** — opt in with:
|
||
|
||
```toml
|
||
[dependencies]
|
||
ratatui = { version = "0.30", features = ["unstable-widget-ref"] }
|
||
```
|
||
|
||
```rust
|
||
use ratatui::widgets::{WidgetRef, StatefulWidgetRef};
|
||
|
||
struct HeterogeneousTab {
|
||
title: String,
|
||
widget: Box<dyn WidgetRef>,
|
||
}
|
||
|
||
impl WidgetRef for HeterogeneousTab {
|
||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||
self.widget.render_ref(area, buf);
|
||
}
|
||
}
|
||
```
|
||
|
||
Use case: storing `Vec<Box<dyn WidgetRef>>` for runtime-tab selection (see §13.7).
|
||
|
||
### 13.3 `Frame::count()` for Frame-Rate-Stable Animations
|
||
|
||
Avoid `Instant::now()` math for visual state — it drifts relative to wall clock.
|
||
Instead, use `Frame::count()`, which increments on each `Terminal::draw`:
|
||
|
||
```rust
|
||
// BAD — frame-rate-dependent, drifts over time
|
||
let elapsed = start.elapsed().as_millis();
|
||
let phase = (elapsed / 250) % 2;
|
||
|
||
// GOOD — frame-rate-stable, monotonic
|
||
let phase = (frame.count() / 2) % 2; // 2 frames on, 2 frames off
|
||
```
|
||
|
||
`Frame::count()` source: `ratatui-core/src/terminal/frame.rs#L211-L237`.
|
||
|
||
**Bug avoided**: `render_prochot_alert` in redbear-power originally passed a
|
||
freshly-constructed `Instant::now()` to the alert renderer, causing `now.elapsed()`
|
||
to always be ~0. The pulse never changed phase. Always pass `Frame` into render-time
|
||
callbacks rather than constructing new `Instant` values.
|
||
|
||
### 13.4 Stylize Shorthand
|
||
|
||
ratatui 0.30 stabilized `Stylize` trait, allowing direct color/style methods on
|
||
types that implement it (`&str`, `String`, `Line`, `Span`, `Style`, primitives):
|
||
|
||
```rust
|
||
use ratatui::style::Stylize;
|
||
|
||
// Before (verbose)
|
||
let span = Span::styled("Vendor:", Style::default().fg(Color::Cyan));
|
||
let style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
|
||
|
||
// After (idiomatic 0.30)
|
||
let span = "Vendor:".cyan();
|
||
let style = Style::new().red().bold();
|
||
```
|
||
|
||
For `const` declarations (theme constants), `Stylize` is mandatory — only
|
||
shorthand works in `const` context:
|
||
|
||
```rust
|
||
pub const LABEL: Style = Style::new().cyan();
|
||
pub const FOCUS: Style = Style::new().yellow().bold();
|
||
```
|
||
|
||
This reinforces the Golden Rule (Theme-driven colors) — make your `Theme` use
|
||
`Stylize` shorthand.
|
||
|
||
### 13.5 `area.layout(&Layout)` Destructuring
|
||
|
||
Replace `Layout::default().split(area)` returning `Rc<[Rect]>` chunks with
|
||
compile-time-checked destructuring:
|
||
|
||
```rust
|
||
// Before (index-based, no compile check)
|
||
let chunks = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([Constraint::Length(6), Constraint::Min(0)])
|
||
.split(f.area());
|
||
f.render_widget(header, chunks[0]); // no check on chunks.len()
|
||
f.render_widget(body, chunks[1]);
|
||
|
||
// After (compile-time size check)
|
||
let [header_area, body_area] = f.area().layout(
|
||
&Layout::vertical([Constraint::Length(6), Constraint::Min(0)]),
|
||
);
|
||
f.render_widget(header, header_area);
|
||
f.render_widget(body, body_area);
|
||
```
|
||
|
||
Benefits:
|
||
- Compile error if constraints count mismatches destructuring (3 vs. 4 errors clearly)
|
||
- Self-documenting variable names
|
||
- Matches the canonical `demo2` pattern
|
||
|
||
### 13.6 `Rect::centered` Replaces Hand-Rolled Helpers
|
||
|
||
Common popup helper `centered_rect(percent_x, percent_y, r)` is now in the crate:
|
||
|
||
```rust
|
||
// Before (hand-rolled, error-prone)
|
||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||
let popup_w = r.width * percent_x / 100;
|
||
let popup_h = r.height * percent_y / 100;
|
||
Rect::new(
|
||
r.x + (r.width - popup_w) / 2,
|
||
r.y + (r.height - popup_h) / 2,
|
||
popup_w, popup_h,
|
||
)
|
||
}
|
||
|
||
// After (0.30 idiom)
|
||
let popup = f.area().centered(
|
||
Constraint::Percentage(70),
|
||
Constraint::Percentage(80),
|
||
);
|
||
```
|
||
|
||
Also: `Rect::centered_horizontally(Constraint)`, `Rect::centered_vertically(Constraint)`.
|
||
|
||
### 13.7 `Tabs` Widget for Multi-View Layouts
|
||
|
||
For TUI apps with multiple views (cpu-x has 8: CPU/Caches/Mobo/Memory/System/Graphics/Bench/About),
|
||
ratatui 0.30 has `Tabs` widget that pairs with `ratatui-widgets`:
|
||
|
||
```rust
|
||
use ratatui::widgets::Tabs;
|
||
|
||
let tab_titles = vec!["Per-CPU", "System", "Info"];
|
||
let tabs = Tabs::new(tab_titles)
|
||
.select(active_tab)
|
||
.style(Theme::BORDER_DIM)
|
||
.highlight_style(Theme::BORDER_FOCUSED)
|
||
.divider(" │ ");
|
||
|
||
f.render_widget(tabs, tab_bar_area);
|
||
```
|
||
|
||
For tabs that contain different widgets, store `Vec<Box<dyn StatefulWidgetRef>>`
|
||
(unstable feature flag) or a custom `enum AppTab { PerCpu, System, Info }` that
|
||
dispatches:
|
||
|
||
```rust
|
||
enum AppTab {
|
||
PerCpu(Table<'static>),
|
||
System(Paragraph<'static>),
|
||
Info(Paragraph<'static>),
|
||
}
|
||
|
||
impl Widget for AppTab {
|
||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||
match self {
|
||
AppTab::PerCpu(t) => t.render(area, buf),
|
||
AppTab::System(p) => p.render(area, buf),
|
||
AppTab::Info(p) => p.render(area, buf),
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 13.8 StatefulWidget Inventory
|
||
|
||
Stateful widgets in `ratatui-widgets` 0.30:
|
||
|
||
| Widget | State Type | Key methods |
|
||
|--------|-----------|-------------|
|
||
| `Table` | `TableState` | `select(usize)`, `select_next()`, `select_previous()`, `scroll_up_by(n)`, `scroll_down_by(n)`, `selected() -> Option<usize>` |
|
||
| `List` | `ListState` | `select(usize)`, `select_next()`, `select_previous()`, `offset()` |
|
||
| `Scrollbar` | `ScrollbarState` | `position(n)`, `content_length(n)`, `prev()`, `next()`, `first()`, `last()` |
|
||
| `Tabs` | (none — uses `.select(idx)` on the widget itself) | - |
|
||
| `Calendar` | `CalendarEventStore` | Event storage for monthly calendar view |
|
||
|
||
For Table, use `frame.render_stateful_widget(table, area, &mut state)` — not
|
||
`render_widget`. The state can be a field on your `App` struct.
|
||
|
||
### 13.9 Layout::try_areas for Safe Sizing
|
||
|
||
When you need to render only if the area is large enough:
|
||
|
||
```rust
|
||
match f.area().layout(&layout).try_into() {
|
||
Ok([header, body]) => {
|
||
f.render_widget(header_widget, header);
|
||
f.render_widget(body_widget, body);
|
||
}
|
||
Err(_) => {
|
||
// terminal too small — render a "resize me" message
|
||
f.render_widget(Paragraph::new("Window too small — please enlarge terminal"), f.area());
|
||
}
|
||
}
|
||
```
|
||
|
||
This pattern matches what cpu-x does:
|
||
|
||
```cpp
|
||
// cpu-x ncurses.cpp:113-118
|
||
if((startx < 0) || (starty < 0))
|
||
{
|
||
printw("%s\n", _("Window is too small!"));
|
||
timeout(-1);
|
||
ret = false;
|
||
}
|
||
```
|
||
|
||
### 13.10 Custom Widget Trait Implementation
|
||
|
||
When free `render_*` functions grow beyond ~100 lines, convert to a Widget impl:
|
||
|
||
```rust
|
||
pub struct CpuTable<'a> {
|
||
cpus: &'a [CpuRow],
|
||
expanded_cpu: Option<u32>,
|
||
focused: bool,
|
||
}
|
||
|
||
impl Widget for CpuTable<'_> {
|
||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||
let header = Row::new(/* ... */);
|
||
let rows = self.cpus.iter().map(/* ... */);
|
||
// ... etc.
|
||
Table::new(rows, widths)
|
||
.header(header)
|
||
.block(panel_border(self.focused, " Per-CPU "))
|
||
.render(area, buf);
|
||
}
|
||
}
|
||
```
|
||
|
||
Custom widgets make dependencies explicit (the struct captures exactly what data is
|
||
needed) and enable unit testing via `TestBackend`.
|
||
|
||
### 13.11 Frame::buffer_mut for Direct Effects
|
||
|
||
`frame.buffer_mut()` (existing since 0.27, but stable in 0.30) provides direct
|
||
buffer access for effects that don't fit the widget model:
|
||
|
||
```rust
|
||
// Scrollbar thumb rendering (redbear-power uses this for per-CPU table scrollbar)
|
||
let scrollbar_area = Rect::new(table_area.right() - 1, table_area.y + 1, 1, table_area.height - 2);
|
||
let scrollbar = Scrollbar::default()
|
||
.orientation(ScrollbarOrientation::VerticalRight)
|
||
.thumb_symbol("█")
|
||
.track_symbol(Some("│"));
|
||
f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
|
||
```
|
||
|
||
Or for low-level effect work (cursor positioning, char-level effects) use
|
||
`buf.cell_mut((x, y))`.
|
||
|
||
### 13.12 Async Event Handling (crossterm only)
|
||
|
||
For non-blocking event loops with tokio, the pattern requires `crossterm::event::EventStream`
|
||
(not available on `termion`):
|
||
|
||
```rust
|
||
let mut reader = crossterm::event::EventStream::new();
|
||
let mut tick_interval = tokio::time::interval(tick_delay);
|
||
let mut render_interval = tokio::time::interval(render_delay);
|
||
let mut cancellation_token = tokio_util::sync::CancellationToken::new();
|
||
|
||
tokio::select! {
|
||
_ = cancellation_token.cancelled() => break,
|
||
maybe_event = reader.next().fuse() => {
|
||
if let Some(Ok(event)) = maybe_event {
|
||
// handle event
|
||
}
|
||
}
|
||
_ = tick_interval.tick() => { /* advance state */ }
|
||
_ = render_interval.tick() => { /* trigger redraw */ }
|
||
}
|
||
```
|
||
|
||
**For termion (our Red Bear backend)**: this async pattern is not available.
|
||
Use the canonical pattern from §1 (poll + sleep).
|
||
|
||
### 13.13 Ratatui-vs-tui-rs Migration Status
|
||
|
||
| Concept | tui-rs 0.19 (legacy) | ratatui 0.29 | ratatui 0.30 |
|
||
|---------|----------------------|--------------|--------------|
|
||
| Stateful widget | Manual `app_state.selected` | `TableState` field | Same + `scroll_up_by`/`scroll_down_by` |
|
||
| Layout | `chunks[idx]` | `Layout::split(area)` | `area.layout(&Layout)` (destructure) |
|
||
| Frame counter | App-managed `frame_count: u64` | App-managed | **`Frame::count()`** (built-in) |
|
||
| Styling | `Style::default().fg(...)` | Same | **`Stylize` shorthand** |
|
||
| Popup centering | Hand-rolled | Hand-rolled | **`Rect::centered`** |
|
||
| Multi-view tabs | Manual | Manual | **`Tabs` widget** |
|
||
| `Box<dyn Widget>` | Yes | Yes | **`Box<dyn WidgetRef>`** (unstable) |
|
||
| `frame.buffer_mut()` | Yes | Yes | Stable |
|
||
| Modular crates | Single crate | Split (3-4 crates) | More granular split |
|
||
### 13.14 redbear-power Current Status (v1.44+, 2026-07-06)
|
||
|
||
A comprehensive audit and improvement cycle (16 passes) against bottom v0.11.2
|
||
brought redbear-power from v1.20 (6,360 LoC, 21 modules, 76 tests) to
|
||
**v1.44+ (13,091 LoC, 27 modules, 217 tests)**. All items below are
|
||
implemented and tested.
|
||
|
||
| Area | Status | Detail |
|
||
|------|--------|--------|
|
||
| Braille time-series graphs | ✅ v1.44 | `BrailleGraph` widget using ratatui's `Canvas` + `Marker::Braille`; 5 graphs across 4 tabs (CPU%, Temp°C, PkgW, Net KiB/s, Disk KiB/s); y-axis labels with reserved margin |
|
||
| RingHistory buffer | ✅ v1.44 | O(1) ring buffer with `display_max()` rounding to nice values (1,2,5,10,20,50...); prevents y-axis jitter |
|
||
| Theme system | ✅ v1.44 | `Theme` struct with `dark()`, `light()`, `high_contrast()` presets; `--theme` CLI flag with validation; wired into `panel_border()`, `BrailleGraph`, header governor, cursor highlight |
|
||
| Widget expansion | ✅ v1.44 | `e` key toggles full-screen tab rendering; hint bar at bottom |
|
||
| Data freeze | ✅ v1.44 | `f` key pauses data updates; `[FROZEN]` indicator in keybar |
|
||
| Process kill dialog | ✅ v1.44 | `k` key (Process tab) opens signal selection modal; `Cell<usize>` for interior mutability (avoids borrow conflicts); auto-closes 2s after signal sent; 6 unit tests |
|
||
| Panic hook | ✅ v1.44 | `panic::set_hook` restores terminal on panic |
|
||
| TTY check | ✅ v1.44 | `IsTerminal` check before raw mode; clear error message |
|
||
| MSR cache | ✅ v1.44 | Per-CPU failure cache avoids repeated doomed opens; auto-clears every ~30s |
|
||
| Skip-refresh guard | ✅ v1.44 | Adaptive throttle: if refresh >200ms, skip next cycle |
|
||
| Collector fix | ✅ v1.44 | Removed `Barrier` from per-CPU collector; threads start work immediately |
|
||
| Key shadowing audit | ✅ v1.44 | 4 bugs found and fixed: `g` (governor vs. move-to-top), `T` (tab-cycle vs. tree-toggle), `f` (freeze vs. process-filter), `f` duplicate removed |
|
||
| `--once` graph output | ✅ v1.44 | `--once` renders 3 braille graphs + all text panels |
|
||
| LTO release | ✅ v1.44 | `lto = true, opt-level = 3, codegen-units = 1` → 4.1 MB binary (-33%) |
|
||
| Integration tests | ✅ v1.44 | 8 new tests: CPU detection, graph population, process count, governor available, temp source, expand, freeze, skip-refresh |
|
||
| All previous v1.0–v1.21 features | ✅ | Mouse, config, tabs, D-Bus, Linux fallbacks, meminfo, DMI, battery, sensors, network, storage, process list, CPU%, throughput, sort, filter, PID detail, SMART, benchmarks, HWP, cpuid, scheduler stats, CPU affinity, per-thread IO |
|
||
|
||
Full assessment: see `local/docs/bottom-vs-redbear-power-assessment.md`.
|
||
|
||
### 13.15 v1.4 Module Pattern: `meminfo.rs` for Read-Only System Data
|
||
|
||
v1.4 added `meminfo.rs` (241 lines) as a self-contained read-only
|
||
data source module. The pattern:
|
||
|
||
```rust
|
||
// meminfo.rs skeleton
|
||
#[derive(Default, Clone, Debug)]
|
||
pub struct MemInfo { /* fields */ }
|
||
|
||
#[derive(Default, Clone, Debug)]
|
||
pub struct OsInfo { /* fields */ }
|
||
|
||
pub fn read_meminfo() -> MemInfo { /* Linux: parse /proc/meminfo */ }
|
||
pub fn read_os_info() -> OsInfo { /* Linux: parse /etc/os-release + /etc/hostname + /proc/uptime */ }
|
||
|
||
pub fn format_kib(kib: u64) -> String { /* "X.Y GiB" */ }
|
||
pub fn format_uptime(secs: u64) -> String { /* "Xd Yh Zm Ws" */ }
|
||
```
|
||
|
||
Key conventions:
|
||
- **All parsing logic in `meminfo.rs`** — not scattered in `app.rs`.
|
||
- **`App` holds `meminfo: MemInfo` + `os_info: OsInfo` fields**, refreshed
|
||
on a slower cadence (every 4th tick) than per-CPU stats.
|
||
- **Render helpers (`format_kib`, `format_uptime`) live next to the data
|
||
they format** — keeps the read-and-format story in one module.
|
||
- **Graceful degradation** on Redox where `/proc/meminfo` is absent —
|
||
return empty struct, render layer shows `?` rather than fake numbers
|
||
(zero-stub policy).
|
||
- **`mem_bar_line(label, used, total, width)` helper in `render.rs`** uses
|
||
Unicode block characters (`█` filled, `░` empty) directly into a `Line`
|
||
— no `Gauge` widget allocation, no widget state, just `Span` construction.
|
||
|
||
Pattern rationale: every read-only system data source (memory, OS info,
|
||
DMI/SMBIOS, battery, network, etc.) deserves its own dedicated module
|
||
that owns the read + parse + format story. The `App` struct stays small
|
||
and the `render.rs` stays pure.
|
||
|
||
### 13.16 v1.1+ Mouse Support Pattern
|
||
|
||
Implemented in v1.1 (uses `termion` 4 mouse events). The pattern:
|
||
|
||
```rust
|
||
// In the event loop, after keyboard handling:
|
||
if let Ok(event) = termion::Input::read() {
|
||
match event {
|
||
termion::event::Event::Mouse(mouse) => {
|
||
// hit-test by Rect::contains(mouse.column, mouse.row)
|
||
// delegate to the appropriate App action
|
||
}
|
||
termion::event::Event::Key(key) => { /* existing */ }
|
||
}
|
||
}
|
||
```
|
||
|
||
Hit-test pattern: each panel renders into a `Rect` from layout
|
||
destructuring; the panel handler checks `rect.contains(col, row)` and
|
||
routes accordingly. Avoid storing global mouse state.
|
||
|
||
### 13.17 v1.5 Module Pattern: `dmi.rs` for SMBIOS/DMI Hardware Identity
|
||
|
||
v1.5 added `dmi.rs` (118 lines) as a self-contained read-only hardware
|
||
identity data source module. The pattern:
|
||
|
||
```rust
|
||
// dmi.rs skeleton
|
||
#[derive(Default, Clone, Debug)]
|
||
pub struct DmiInfo { /* 18 Option<String> fields */ }
|
||
|
||
impl DmiInfo {
|
||
pub fn available() -> bool { /* /sys/class/dmi/id/ exists */ }
|
||
pub fn read() -> Self { /* read each file independently */ }
|
||
pub fn is_empty(&self) -> bool { /* all fields None */ }
|
||
pub fn display(field: &Option<String>) -> &str { /* Some→value, None→"?" */ }
|
||
}
|
||
```
|
||
|
||
Key conventions:
|
||
- **`Option<String>` per field** — one file failure doesn't poison others.
|
||
- **`read()` reads once at App::new()** — DMI is static, no per-tick refresh.
|
||
- **`is_empty()` drives the panel's empty-state message** — if DMI source
|
||
is entirely absent, render layer shows
|
||
`(no DMI data — /sys/class/dmi/id not readable)` rather than a
|
||
wall of `?` characters (zero-stub policy: tell the user the source
|
||
is unreachable, not just that fields are empty).
|
||
- **`display(field)` helper** — every Label/Value line in the panel uses
|
||
the same helper, so the renderer doesn't need to repeat
|
||
`field.as_deref().unwrap_or("?")` 18 times.
|
||
- **Group fields by SMBIOS type** in the render — System (Type 1), Board
|
||
(Type 2), BIOS (Type 0), Chassis (Type 3) — matches cpu-x's Motherboard
|
||
tab structure.
|
||
|
||
Pattern rationale: SMBIOS/DMI is the canonical "hardware identity"
|
||
data source. Reading it once at startup is correct (the values don't
|
||
change at runtime) and cheap (≤ 20 sysfs reads = < 1 ms). The
|
||
`Option<String>` per-field design means a permission error on
|
||
`product_serial` (root-only) doesn't disable the entire Motherboard tab.
|
||
|
||
### 13.18 v1.6 Module Pattern: `battery.rs` with Empty-State Short-Circuit
|
||
|
||
v1.6 added `battery.rs` (128 lines) as a self-contained power-source
|
||
data source module. The pattern differs from `dmi.rs` because the
|
||
battery is sometimes **entirely absent** (desktop without UPS, server,
|
||
container) — not just partially readable.
|
||
|
||
```rust
|
||
// battery.rs skeleton
|
||
#[derive(Default, Clone, Debug)]
|
||
pub struct BatteryInfo {
|
||
pub available: bool, // ← false on desktop, true on laptop
|
||
pub status: Option<String>,
|
||
pub capacity_percent: Option<u32>,
|
||
pub energy_now_wh: Option<f64>,
|
||
// ... 12 more fields
|
||
}
|
||
|
||
impl BatteryInfo {
|
||
pub fn read() -> Self {
|
||
let Some(base) = Self::find_battery_dir() else {
|
||
return Self::default(); // ← available=false
|
||
};
|
||
// ... populate all fields from sysfs
|
||
}
|
||
}
|
||
```
|
||
|
||
Key conventions:
|
||
- **`available: bool` is a top-level field**, not just `is_empty()`.
|
||
Distinguishes "battery doesn't exist" from "battery exists but I can't
|
||
read all its fields" — both render differently:
|
||
- `!available` → `(no battery detected — /sys/class/power_supply/BAT* not present)`
|
||
- `available && all fields None` → wall of `?` characters + empty sections
|
||
- **`RBP_BATTERY_PATH` env override** — redirects `find_battery_dir()` to
|
||
a fixture directory. Useful for:
|
||
- **Testing**: create a fake `/tmp/fake-battery/BAT0/` with realistic
|
||
values and verify the panel renders correctly without needing a
|
||
real laptop.
|
||
- **Dev workflow**: redirect to a non-standard sysfs mount.
|
||
- **Unit conversion inline** — `energy_now` is µWh, divide by 1_000_000
|
||
at read time. Don't store raw µWh and convert at render time.
|
||
- **`format_duration(secs)` helper** — converts seconds → "3h 0m" /
|
||
"0m 5s" / "0s". Hidden when `secs == 0` (matches `?` for "not
|
||
applicable" semantics, not "0 seconds remaining").
|
||
- **Per-tick refresh deferred** — battery state should ideally be
|
||
polled at 2-5 Hz, but `App::refresh()` doesn't call `read()` yet.
|
||
Reading once at startup is the safe default for the first iteration;
|
||
per-tick refresh is documented as v1.7 forward work.
|
||
|
||
Pattern rationale: many TUI monitoring tools (htop, btop, cpu-x) skip
|
||
the battery subsystem entirely on desktops. redbear-power follows the
|
||
**honest empty-state** pattern: if the source doesn't exist, say so
|
||
clearly in one line; don't render a wall of `?` characters that
|
||
confuses the user.
|
||
|
||
### 13.19 v1.7 Pattern: Coprime Refresh Moduli
|
||
|
||
v1.7 added per-tick battery refresh with 5-tick throttling. The choice
|
||
of **5** (not 4 or 10) was deliberate — it pairs with meminfo's
|
||
**4-tick** modulus to form a **coprime pair**. The pattern:
|
||
|
||
```rust
|
||
// In App::refresh():
|
||
self.refresh_counter = self.refresh_counter.wrapping_add(1);
|
||
|
||
if self.refresh_counter % 4 == 0 {
|
||
self.meminfo = crate::meminfo::read_meminfo(); // 2 sec cadence
|
||
self.os_info = crate::meminfo::read_os_info();
|
||
}
|
||
|
||
if self.refresh_counter % 5 == 0 {
|
||
self.battery = crate::battery::BatteryInfo::read(); // 2.5 sec cadence
|
||
}
|
||
|
||
for row in &mut self.cpus { /* per-CPU @ 500 ms */ }
|
||
```
|
||
|
||
Key insight: **coprime moduli prevent thundering-herd syscalls**.
|
||
|
||
| Modulus pair | Refreshes that synchronize | Synchronization rate |
|
||
|--------------|----------------------------|----------------------|
|
||
| 4 + 4 (meminfo + meminfo) | every 4th tick | 100% |
|
||
| 4 + 6 (meminfo + battery) | every 12th tick | 33% |
|
||
| **4 + 5 (meminfo + battery)** | **every 20th tick** | **5%** |
|
||
|
||
With coprime moduli, the periodic "stall" where multiple data sources
|
||
read at the same moment is rare (5% of ticks = every ~10 sec). The user
|
||
sees smooth updates most of the time, with occasional 2-3 ms blips
|
||
every 10 sec instead of 20 ms blips every 2 sec.
|
||
|
||
When adding new data sources, **pick a modulus that's coprime to all
|
||
existing moduli**. With 4, 5, 7 already in use, the next obvious
|
||
choices are 6 (not coprime with 4), 9, 11, etc.
|
||
|
||
Pattern rationale: TUI refresh rates have cascading cost — one slow
|
||
syscall delays every other data source waiting for the main loop. By
|
||
spreading expensive reads across coprime cadences, no single tick ever
|
||
pays the cumulative cost of multiple expensive reads. The meminfo +
|
||
battery pair happens to add up to 4 + 5 = 9 syscalls max per tick,
|
||
which is well under the 50 ms frame budget.
|
||
|
||
### 13.20 v1.8 Pattern: Worker Functions Returning u64 Counts
|
||
|
||
v1.8 added three new benchmark modes (FFT, AES, prime sieve) in
|
||
`bench.rs`. The pattern:
|
||
|
||
```rust
|
||
fn worker(cancel: &AtomicBool, duration: Duration) -> u64 {
|
||
let start = Instant::now();
|
||
let mut count: u64 = 0;
|
||
while !cancel.load(Ordering::Relaxed) && start.elapsed() < duration {
|
||
// ... do work ...
|
||
count += 1;
|
||
}
|
||
count
|
||
}
|
||
|
||
// In Bench::start(), spawn one thread per core:
|
||
for _ in 0..cores {
|
||
let units = Arc::clone(&self.units_done);
|
||
let cancel = Arc::clone(&self.cancel);
|
||
self.threads.push(thread::spawn(move || {
|
||
let delta = worker(&cancel, duration);
|
||
units.fetch_add(delta, Ordering::Relaxed);
|
||
}));
|
||
}
|
||
```
|
||
|
||
Key conventions:
|
||
- **Worker takes `&AtomicBool` + `Duration`** — the cancellation signal
|
||
and time budget are the only inputs. No mutable state shared between
|
||
threads; each worker is independent.
|
||
- **Returns `u64` count of units completed** — not seconds, not
|
||
percentages. The caller aggregates with `AtomicU64::fetch_add` across
|
||
all threads. Total throughput = sum of all worker deltas.
|
||
- **Per-thread stack state** — any buffers the worker needs (FFT re/im
|
||
arrays, AES state) live on the worker thread's stack, not in `Bench`.
|
||
This avoids contention and lets each thread run truly independently.
|
||
- **Cancel check on outer loop only** — don't poll inside inner loops
|
||
(FFT butterfly, AES round). One `cancel.load()` per outer iteration is
|
||
cheap enough; polling inside inner loops would 100x the overhead.
|
||
- **Pure-compute work** — no I/O in workers. File reads, syscalls, etc.
|
||
belong in the read-side modules (`meminfo.rs`, `dmi.rs`, `battery.rs`).
|
||
Workers must be cancellable in < 1 ms for snappy UI shutdown.
|
||
|
||
Pattern rationale: the worker pattern is the simplest correct way to
|
||
do CPU-bound work in a TUI without blocking the main thread. Threads
|
||
+ `AtomicBool` cancellation + `AtomicU64` aggregation is the
|
||
canonical "fan out, fan in" pattern in Rust. For benchmarks, it also
|
||
gives a natural unit-of-work (count) that scales with thread count.
|
||
|
||
---
|
||
|
||
## 15. Braille Time-Series Graph Pattern
|
||
|
||
**Source:** `redbear-power/src/graph.rs` (227 lines, 5 unit tests)
|
||
**Borrowed from:** bottom's `canvas/components/time_graph/` approach, simplified
|
||
|
||
### Pattern: Canvas + Marker::Braille + RingHistory
|
||
|
||
ratatui 0.30's built-in `Canvas` widget with `Marker::Braille` gives 8 data
|
||
points per terminal cell (2 columns × 4 rows). A 40-column graph area can
|
||
display 80 time points at 4-char y-axis label margin.
|
||
|
||
```rust
|
||
pub struct BrailleGraph<'a> {
|
||
pub values: &'a [f64], // time-series data
|
||
pub max_value: f64, // y-axis ceiling (use display_max())
|
||
pub color: Color, // line color
|
||
pub title: &'a str, // border title
|
||
pub focused: bool, // affects border style
|
||
pub x_labels: Option<(&'a str, &'a str)>,
|
||
pub y_labels: Option<(String, String)>,
|
||
pub theme: &'a Theme, // THEME-DRIVEN, never hardcoded
|
||
}
|
||
|
||
impl Widget for BrailleGraph<'_> {
|
||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||
// 1. Reserve 4 columns on left for y-axis labels
|
||
let label_w: u16 = if self.y_labels.is_some() { 4 } else { 0 };
|
||
let canvas_area = Rect { x: inner.x + label_w, width: inner.width - label_w, ..inner };
|
||
|
||
// 2. Draw lines via Canvas
|
||
Canvas::default()
|
||
.x_bounds([0.0, (n - 1) as f64])
|
||
.y_bounds([0.0, self.max_value])
|
||
.marker(Marker::Braille)
|
||
.paint(|ctx| {
|
||
for w in clamped.windows(2).enumerate() {
|
||
ctx.draw(&CanvasLine {
|
||
x1: i as f64, y1: w[0],
|
||
x2: (i+1) as f64, y2: w[1],
|
||
color: self.color,
|
||
});
|
||
}
|
||
})
|
||
.render(canvas_area, buf);
|
||
|
||
// 3. Y-axis labels in reserved margin
|
||
if let Some((ref top, ref bot)) = self.y_labels {
|
||
buf.set_string(inner.x, inner.y, &format!("{:>4}", top), label_style);
|
||
buf.set_string(inner.x, bottom_y, &format!("{:>4}", bot), label_style);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### RingHistory: Stable Y-Axis with Nice Maxima
|
||
|
||
```rust
|
||
pub struct RingHistory {
|
||
data: Vec<f64>,
|
||
capacity: usize,
|
||
len: usize,
|
||
}
|
||
|
||
impl RingHistory {
|
||
/// Round peak to nice values: 1,2,5,10,20,50,100,200,500...
|
||
/// Prevents y-axis jitter from every-tick rescaling.
|
||
pub fn display_max(&self) -> f64 {
|
||
let raw = self.max();
|
||
if raw <= 0.0 { return 1.0; }
|
||
let mag = 10.0_f64.powi((raw.log10().floor()) as i32);
|
||
let norm = raw / mag;
|
||
let nice = if norm <= 1.0 { 1.0 } else if norm <= 2.0 { 2.0 }
|
||
else if norm <= 5.0 { 5.0 } else { 10.0 };
|
||
nice * mag
|
||
}
|
||
}
|
||
```
|
||
|
||
**Key decisions:**
|
||
- Use `display_max()` **not** `max()` for the y-axis — empty data returns 1.0, populated data rounds to stable nice numbers
|
||
- Reserve 4-column label margin with `Rect` offset; labels use `buf.set_string` (multi-cell) not `cell.set_symbol` (single-cell)
|
||
- Canvas uses full area after margin — braille grid auto-scales within `x_bounds`/`y_bounds`
|
||
- Graph titles match between normal and expanded mode — audit for consistency
|
||
- Graphs render in both System tab (3 side-by-side) and expanded mode (stacked)
|
||
|
||
---
|
||
|
||
## 16. Modal Dialog Pattern (Interior Mutability)
|
||
|
||
**Source:** `redbear-power/src/kill.rs` (148 lines, 6 unit tests)
|
||
|
||
### Problem
|
||
|
||
Modal dialogs need mutable state (`ListState` for selection) but must render
|
||
via `&self` to avoid borrow conflicts with the parent `App` struct's immutable
|
||
borrows during `terminal.draw()`. `Widget for &mut KillDialog` causes
|
||
`E0502: cannot borrow as mutable because also borrowed as immutable`.
|
||
|
||
### Solution: `Cell<usize>` for Selection State
|
||
|
||
```rust
|
||
pub struct KillDialog {
|
||
pub open: bool,
|
||
pub pid: u32,
|
||
pub comm: String,
|
||
selected: Cell<usize>, // ← interior mutability
|
||
signals: Vec<(&'static str, i32)>,
|
||
pub result: Option<String>,
|
||
result_at: Option<Instant>, // auto-close timer
|
||
}
|
||
|
||
impl Widget for &KillDialog { // ← &self, not &mut self
|
||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||
let sel = self.selected.get();
|
||
// Render list with highlighted item at `sel`
|
||
for (i, (label, _)) in self.signals.iter().enumerate() {
|
||
let item = if i == sel {
|
||
ListItem::new(Line::styled(format!("▶ {label}"), highlight_style))
|
||
} else {
|
||
ListItem::new(Line::from(format!(" {label}")))
|
||
};
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Auto-Close Pattern
|
||
|
||
```rust
|
||
pub fn send_signal(&mut self) {
|
||
self.result = Some(format!("Sent signal {} to PID {}", sig, self.pid));
|
||
self.result_at = Some(Instant::now()); // start 2s timer
|
||
}
|
||
|
||
pub fn auto_close_if_done(&mut self) {
|
||
if let Some(at) = self.result_at {
|
||
if at.elapsed().as_millis() > 2000 {
|
||
self.close();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Called every tick in the main loop: `app.kill_dialog.auto_close_if_done();`
|
||
|
||
**Key decisions:**
|
||
- `Cell<usize>` avoids `RefCell` overhead — only one `usize` field, no runtime borrow checks
|
||
- `Widget for &KillDialog` (immutable ref) — compatible with `&app` borrows during draw
|
||
- Auto-close timer prevents stuck dialog after signal sent
|
||
- `Enter` blocked when `result.is_some()` to prevent double-send
|
||
|
||
---
|
||
|
||
## 17. Key Binding Audit Pattern
|
||
|
||
**Source:** 4 bugs found and fixed across 16 passes of redbear-power
|
||
|
||
### Pattern: Audit All `Key::Char` Arms for Shadowing
|
||
|
||
In a large `match k { ... }` block (200+ arms), duplicate `Key::Char('X')`
|
||
patterns silently shadow each other — the first one wins, the second is
|
||
unreachable dead code.
|
||
|
||
```bash
|
||
# Audit command: detect all duplicate key bindings
|
||
grep -oP "Key::Char\('[^']+'\)" main.rs | sort | uniq -c | sort -rn
|
||
```
|
||
|
||
**Bugs found in redbear-power:**
|
||
| Key | First handler | Shadowed handler | Fix |
|
||
|-----|--------------|------------------|-----|
|
||
| `'g'` | `cycle_governor()` | `move_to_edge(true)` | Removed (Home already does this) |
|
||
| `'T'` | `set_tab(next())` | `process_tree = !process_tree` | Changed tree toggle to `'y'` |
|
||
| `'f'` | `frozen = !frozen` | process filter prompt | Changed filter to `'F'` |
|
||
|
||
**Guard-based arms (`Key::Char('k') if condition`) are NOT duplicates** — they
|
||
fall through when the guard is false. The audit only flags identical
|
||
unguarded patterns.
|
||
|
||
**Rule:** After any key binding change, re-run the audit. Never have two
|
||
unguarded `Key::Char('X')` arms in the same match.
|
||
|
||
---
|
||
|
||
## 14. Cross-Reference: redbear-power as a Reference Implementation
|
||
|
||
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** (~13,100 LoC across 27 modules, with 217 unit tests)
|
||
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR/procfs + meminfo + DMI + battery + hwmon + net + storage + proc + pid_detail + smart
|
||
3. **Modern ratatui 0.30 patterns** — `TableState`, modular layout, status bars, `Tabs` widget, `Canvas` + `Marker::Braille` graphs, modal popups (`Clear` + centered `Rect`), theme-driven borders
|
||
4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback + hwmon fallback)
|
||
5. **Well-documented** — extensive code comments + this doc + improvement plan
|
||
6. **Testable** — 217 unit tests covering braille graphs, kill dialog, benchmarks, MSR cache, process tree, IO sparklines, sensor parsing, network parsing, storage parsing, /proc/[pid]/* parsers, SMART, sort modes, filter matching, PID detail, LRU eviction, display_max rounding
|
||
|
||
When porting a new Red Bear TUI app, structure it like redbear-power:
|
||
|
||
```
|
||
my-tui-app/
|
||
├── Cargo.toml # ratatui 0.30 + termion 4
|
||
├── recipe.toml # path = "source", template = "cargo"
|
||
└── source/
|
||
└── src/
|
||
├── main.rs # event loop, key + mouse + D-Bus dispatch
|
||
├── app.rs # App struct, all state, refresh cadence, coprime moduli
|
||
├── render.rs # render_header, render_*_panel, render_keybar, snapshot
|
||
├── theme.rs # Theme struct with presets, color helpers, all styles centralized
|
||
├── graph.rs # BrailleGraph widget + RingHistory buffer
|
||
├── kill.rs # Modal dialog with Cell<usize> for interior mutability
|
||
├── event.rs # Typed Event enum (Key, Mouse, Tick, Resize, Terminate)
|
||
├── platform.rs # runtime data-source probes
|
||
└── <data>.rs # detect, read_*, helpers, format_*
|
||
|
||
---
|
||
|
||
## See Also
|
||
|
||
- `local/recipes/system/redbear-power/source/src/` — reference implementation (v1.44+, 13,091 LoC, 27 modules, 217 tests)
|
||
- `local/recipes/tui/tlc/source/src/` — 46k+ LoC production TUI
|
||
- `local/recipes/tui/redbear-tui-theme/` — shared theme constants
|
||
- `local/docs/redbear-power-improvement-plan.md` — improvement roadmap
|
||
- `local/docs/bottom-vs-redbear-power-assessment.md` — bottom comparison and borrow analysis
|
||
- `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` — desktop stack planning
|
||
- https://ratatui.rs/ — official docs
|
||
- https://github.com/ratatui/ratatui/tree/main/examples — canonical patterns
|