ed92bce14b
Comprehensive implementation per local/docs/redbear-power-improvement-plan.md. Source: 2376 LoC across 10 modules (was 1396/6 in v0.6, +980 LoC). Cross-compile: 2.8 MB stripped Redox ELF binary. SHA256: 1b6f9db6ce79e77957bbb1fd606c430516015d5f02f3b64cb6f395e2f63b8e04 Modules: - main.rs (376) — event loop, key + mouse dispatch, render orchestration - app.rs (421) — App, CpuRow, Governor, ThrottleMode, PackageThermal, HybridInfo - render.rs (498) — header/table/controls/help/snapshot rendering - acpi.rs (166) — CPU enumeration, ACPI _PSS, CPUID fallback - cpuid.rs (350) — CPUID leaf decoding (vendor, family, model, SIMD, cache, hybrid) - bench.rs (123) — prime-sieve stress benchmark for thermal response testing - dbus.rs (202) — D-Bus export via zbus 5 (org.redbear.Power, --dbus flag) - msr.rs (127) — MSR constants + PackageThermal decoder - cpufreq.rs (50) — governor hint read/write - theme.rs (72) — central color palette (const Style) Phase A — bug fixes: - R1: PROCHOT pulse bug — Instant::now() math always ~0, pulse never toggled. Replaced with Frame::count() so the bar pulses at a frame-rate-stable rate. - R5: removed duplicate comment block in snapshot(). - C2: PackageThermal struct + 13 PKG_THERM_* bit constants; full decode of IA32_PACKAGE_THERM_STATUS (PL1/PL2/CRIT/TT1/TT2/HFI/temp) surfaced in header. Phase B — quality: - R3: input poll decoupled from refresh cadence (50ms vs 250-2000ms). - R4: Rect::centered replaces hand-rolled centered_rect helper. - R6: area.layout(&Layout) destructuring with compile-time size check. - O2: theme.rs central color palette (LABEL, BORDER_*, STATUS_*). - C9: ratatui 0.30 Stylize shorthand across all renders. Phase C — features: - C1/C8: cpuid.rs reads leaves 0/1/4/7/0x80000000+/0x1A/0x8000001E. - C3: SIMD display header line. - C5: cache hierarchy header line. - C7: dynamic refresh interval via / key (typed input 50..60000ms + Enter). - C6: prime-sieve benchmark via b/B keys (one thread per core, AtomicU64 counter, run/stop/status). Phase D remaining (was deferred per plan s23): - C4: hybrid CPU detection (CoreType enum, Intel leaf 0x1A, AMD leaf 0x8000001E), per-CPU row prefixed with type label, Hybrid: 8P + 16E header. - O1: termion MouseTerminal wrapper enables xterm mouse protocols. Wheel = scroll, Left-click = select/toggle, Right-click = expand P-state. hit_test() maps (x, y) to panel rects cached after every render. - O3: dbus.rs publishes org.redbear.Power on session bus (opt-in via --dbus). Properties: cpu_count, avg_freq_khz, max_temp_c, avg_load_pct, governor, throttle_mode, prochot_asserted. Background thread owns the tokio runtime + zbus Connection; main thread sends snapshots via mpsc channel. Graceful degradation if redbear-sessiond is unreachable. Verification: - cargo build --release (host): 0 errors, 21 warnings. - ./redbear-power --once (Linux host, AMD 24-core): renders all features. - ./redbear-power --dbus (via script(1)): registers on session bus, emits xterm mouse capture sequences. - cook redbear-power (Redox target): 2.8 MB stripped binary at local/recipes/system/redbear-power/target/x86_64-unknown-redox/stage/usr/bin/redbear-power. ISO rebuild status: blocked by pre-existing upstream nix-0.30.1 vs Redox relibc SaFlags incompatibility in uutils (recipes/core/uutils). The v1.1 binary IS staged and will be packaged into the next successful ISO build once that issue is resolved (separate scope). Docs: - local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md s3.3.2 - v1.0 + v1.1 sections. - local/docs/redbear-power-improvement-plan.md s24 - full status update. - local/docs/RATATUI-APP-PATTERNS.md - canonical ratatui 0.30 guide (1161 lines), includes s13 ratatui 0.30 Best-Practices Update + s14 Cross-Reference redbear-power as a Reference Implementation. Cargo.toml: new dependencies zbus = "5" (tokio feature) and tokio = "1" (rt + rt-multi-thread + macros) for the D-Bus export.
1162 lines
36 KiB
Markdown
1162 lines
36 KiB
Markdown
# Building Rich Red Bear Ratatui Apps — Patterns Guide
|
||
|
||
**Created:** 2026-06-20
|
||
**Last updated:** 2026-06-20 (added §13 ratatui 0.30 best-practices update)
|
||
**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 update notes added §13
|
||
**Cross-references:**
|
||
- `local/recipes/system/redbear-power/` (production ratatui 0.30 consumer)
|
||
- `local/recipes/tui/tlc/` (46k+ LoC TUI file manager, ratatui 0.29)
|
||
- `local/docs/redbear-power-improvement-plan.md` (Phase 2 roadmap derived from this doc)
|
||
|
||
---
|
||
|
||
## 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: 10 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
|
||
|
||
---
|
||
|
||
## 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 Specific Findings
|
||
|
||
A targeted audit of `local/recipes/system/redbear-power/` (v0.6, 1396 LoC)
|
||
produced these actionable findings:
|
||
|
||
| Severity | Finding | Fix |
|
||
|----------|---------|-----|
|
||
| **bug** | `render_prochot_alert` always passes freshly-constructed `Instant::now()`, so the pulse never toggles | Use `Frame::count()` (§13.3) |
|
||
| minor | `centered_rect` hand-rolled | Use `Rect::centered` (§13.6) |
|
||
| minor | `Layout::default().split(...)` returns chunks | Use `area.layout(&Layout)` (§13.5) |
|
||
| cosmetic | `Style::default().fg(...)` chains | Use Stylize shorthand (§13.4) |
|
||
| cosmetic | `Theme` not centralized — colors scattered | Centralize as §12 (`redbear-tui-theme`) |
|
||
| minor | Input poll (250-2000ms) blocks snappy response | Decouple refresh from input (§1 ratatui audit §8) |
|
||
| cosmetic | Duplicate comment in `snapshot()` | Trivial cleanup |
|
||
| feature | No mouse support | Tier 4 follow-up (defer) |
|
||
| feature | No config file | Add `/etc/redbear-power.toml` (TOML) |
|
||
|
||
Full plan: see `local/docs/redbear-power-improvement-plan.md`.
|
||
|
||
---
|
||
|
||
## 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** (~1400 LoC across 6 modules)
|
||
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR
|
||
3. **Modern ratatui 0.30 patterns** — `TableState`, modular layout, status bars
|
||
4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme fallback)
|
||
5. **Well-documented** — extensive code comments + this doc + improvement plan
|
||
|
||
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 dispatch (~200 lines)
|
||
├── app.rs # App struct, all state (~400 lines)
|
||
├── render.rs # render_header, render_table, render_controls (~550 lines)
|
||
└── data.rs # detect, read_*, helpers (~150 lines)
|
||
```
|
||
|
||
Key conventions:
|
||
- **`App::new()`** initializes all state from data sources (no I/O during render)
|
||
- **`App::refresh()`** is the periodic pull (called on tick)
|
||
- **Render functions are pure** — they take `&App` and produce widgets
|
||
- **Status messages** via `App.flash_status(msg)` with `status_expires: Option<Instant>`
|
||
- **Help text** in a const `HELP_TEXT: &str` referenced by both inline `?` overlay and `--help` flag
|
||
|
||
This shape keeps `render.rs` purely view code and `app.rs` purely model code,
|
||
which matches the ratatui book recommendation.
|
||
|
||
---
|
||
|
||
## See Also
|
||
|
||
- `local/recipes/system/redbear-power/source/src/` — reference implementation
|
||
- `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` — Phase 2 roadmap derived from this doc
|
||
- `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
|
||
- https://github.com/X0rg/CPU-X — cpu-x v4.7 (7000+ LoC, mature CPU monitor reference)
|