32fac97c3f
Cleans up §14 (Cross-Reference: redbear-power as a Reference
Implementation) which had three duplicate headers from successive
edits. Collapses to a single canonical version and adds the
v1.8-specific bullet:
'Testable — bench module has 5 unit tests covering all stress
modes + toggles'
Net change: -25 lines, +2 lines.
Source code for v1.8 (bench.rs + main.rs + render.rs) and full
docs for §32 + v1.8 in improvement plan + CONSOLE-TO-KDE plan
landed in commit d6ac3d1377 (qtbase bundled). This commit
completes the doc-only cleanup that didn't fit in that bundle.
1412 lines
47 KiB
Markdown
1412 lines
47 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/` (v1.8, 4380 LoC
|
||
across 15 modules, bench.rs grew 123→304) 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 | Implemented in v1.1 (§13.16) |
|
||
| feature | No config file | Implemented in v1.2 (`config.rs` module) |
|
||
| feature | No multi-view tabs (single Per-CPU view only) | Implemented in v1.2 (`Tabs` widget + `TabId` enum) |
|
||
| feature | No D-Bus export for headless clients | Implemented in v1.1 (`dbus.rs` module + zbus 5) |
|
||
| feature | No Linux-host fallbacks (hardcoded `/scheme/sys/...` paths) | Implemented in v1.3 (`platform.rs` runtime probe + per-module fallbacks) |
|
||
| feature | No memory or OS info display | Implemented in v1.4 (`meminfo.rs` module + `mem_bar_line` helper) |
|
||
| feature | No Motherboard / DMI tab | Implemented in v1.5 (`dmi.rs` module + `TabId::Motherboard`) |
|
||
| feature | No Battery tab | Implemented in v1.6 (`battery.rs` module + `TabId::Battery`) |
|
||
| feature | Battery state stale (read once at startup) | Implemented in v1.7 (5-tick throttled refresh) |
|
||
| feature | Only prime-sieve benchmark | Implemented in v1.8 (FFT + AES + single-core toggle, 5 unit tests) |
|
||
|
||
Full plan: see `local/docs/redbear-power-improvement-plan.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.
|
||
|
||
---
|
||
|
||
## 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** (~4400 LoC across 15 modules, with 5 unit tests)
|
||
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR + meminfo + DMI + battery
|
||
3. **Modern ratatui 0.30 patterns** — `TableState`, modular layout, status bars, `Tabs` widget
|
||
4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback)
|
||
5. **Well-documented** — extensive code comments + this doc + improvement plan
|
||
6. **Testable** — bench module has 5 unit tests covering all stress modes + toggles
|
||
|
||
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 (~475 lines)
|
||
├── app.rs # App struct, all state, refresh cadence (~535 lines)
|
||
├── render.rs # render_header, render_table, render_controls (~925 lines)
|
||
├── platform.rs # runtime data-source probes (~290 lines)
|
||
└── <data>.rs # detect, read_*, helpers, format_*
|
||
```
|
||
|
||
---
|
||
|
||
## 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, with §28 v1.4 status
|
||
- `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` — desktop stack planning, §3.3.2 v0.1–v1.4
|
||
- 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)
|