Wires the v1.20 SMART data module into the Storage tab UI. Each disk now shows a health badge (✓ PASSED / ✗ FAILED / error). Implementation: - App.smart: SmartInfo field + 11-tick refresh (paired with Storage) - Conditional refresh (if self.smart.available guard — avoids re-running smartctl if we already know it's missing) - render_storage_panel: 4 SMART badge states 1. !available → '(SMART: install smartmontools)' 2. health.passed → ' ✓ PASSED' 3. !health.passed → ' ✗ FAILED' 4. health.error → ' (SMART: <error>)' Linux host smoke test (this dev host without smartctl): - Each disk shows '(SMART: install smartmontools)' hint - No panic, graceful degradation - Storage tab still works (no regression) Performance: smartctl subprocess ~5-50ms per disk, 3 disks = 15-150ms per 11-tick refresh (5.5 sec), well within budget. 76/76 tests pass (no new tests — UI integration only). Cross-compile SHA256: ed804710fa834f4453a236aa034d50668b948b391ec1d2ccea294d438016d855. Docs: improvement plan §45, CONSOLE-TO-KDE §3.3.2 v1.21, RATATUI-APP-PATTERNS §13.14 + §14 (6400 LoC, 21 modules, 76 tests).
49 KiB
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)
// 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: u64on 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)
_rawbytes 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:
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
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:
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:
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():
// 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:
// 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
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:
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:
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
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
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:
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
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
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:
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:
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:
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)
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)
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::fsabstractions for all filesystem operationscfg(unix)gates for platform-specific behavior (stat, permissions)ratatui+termionwork on any Unix tty
9. Testing Patterns
TestBackend for UI Tests
ratatui's TestBackend enables snapshot-style UI testing:
#[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
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:
// 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:
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:
# Cargo.toml
[dependencies]
redbear-tui-theme = { path = "../../tui/redbear-tui-theme" }
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
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
- Theme-driven colors — every render path takes
&Theme, never hardcodes colors - Poll-based event loop —
rustix::event::pollwith 100ms timeout for animations - Simple Key type —
u32codepoint +Modifiersbitflags,Copy + Hash - Mode-based dispatch — Insert/Normal/Prompt modes with global intercepts
- Direct buffer for effects —
frame.buffer_mut()for scrollbar, cursor, flash - Animation fields on structs —
frame_count,dialog_anim,bracket_flash - Separation of concerns — buffer/cursor/handlers/render as separate modules
- Shared theme crate —
redbear-tui-themefor brand consistency - No platform gates —
cfg(unix)only, same binary on Linux + Redox - 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:
[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:
[dependencies]
ratatui = { version = "0.30", features = ["unstable-widget-ref"] }
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:
// 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):
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:
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:
// 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
demo2pattern
13.6 Rect::centered Replaces Hand-Rolled Helpers
Common popup helper centered_rect(percent_x, percent_y, r) is now in the crate:
// 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:
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:
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:
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:
// 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:
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:
// 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):
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.20, 6360 LoC
across 21 modules, 76 unit tests) 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) |
| feature | No Sensors tab | Implemented in v1.9 (sensor.rs module + TabId::Sensors, 7 unit tests) |
| feature | Per-CPU Temp n/a on AMD (Intel-only MSR) | Implemented in v1.10 (SensorInfo::pkg_temp_c fallback to k10temp/coretemp/zenpower) |
| feature | No Network tab | Implemented in v1.11 (network.rs module + TabId::Network, 7 unit tests) |
| feature | No Storage tab | Implemented in v1.12 (storage.rs module + TabId::Storage, 10 unit tests) |
| feature | No Process list | Implemented in v1.13 (process.rs module + TabId::Process, 9 unit tests) |
| feature | No CPU% in Process tab | Implemented in v1.14 (ProcInfo::read_with_cpu_pct + 4 unit tests) |
| feature | No disk throughput in Storage tab | Implemented in v1.15 (StorageInfo::read_with_throughput + 3 unit tests) |
| feature | No network throughput in Network tab | Implemented in v1.16 (NetInfo::read_with_throughput + 3 unit tests) |
| feature | No sort modes in Process tab | Implemented in v1.17 (SortMode enum + 6 unit tests, hotkey o) |
| feature | No process filtering | Implemented in v1.18 (App.process_filter + hotkey f + 4 unit tests) |
| feature | No PID detail view | Implemented in v1.19 (pid_detail.rs module + Enter/Esc handling + 7 unit tests) |
| feature | No SMART disk health data | Implemented in v1.20 (smart.rs module + smartctl subprocess + 7 unit tests) |
| feature | No SMART UI integration | Implemented in v1.21 (Storage tab badge: PASSED/FAILED/missing/error) |
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:
// 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 inapp.rs. Appholdsmeminfo: MemInfo+os_info: OsInfofields, 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/meminfois absent — return empty struct, render layer shows?rather than fake numbers (zero-stub policy). mem_bar_line(label, used, total, width)helper inrender.rsuses Unicode block characters (█filled,░empty) directly into aLine— noGaugewidget allocation, no widget state, justSpanconstruction.
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:
// 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:
// 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 repeatfield.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.
// 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: boolis a top-level field, not justis_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_PATHenv override — redirectsfind_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.
- Testing: create a fake
- Unit conversion inline —
energy_nowis µ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 whensecs == 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 callread()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:
// 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:
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
u64count of units completed — not seconds, not percentages. The caller aggregates withAtomicU64::fetch_addacross 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
AtomicBoolcancellation +AtomicU64aggregation 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:
- Small enough to read in one sitting (~6400 LoC across 21 modules, with 76 unit tests)
- Self-contained — no D-Bus, no external state, just sysfs/MSR/procfs + meminfo + DMI + battery + hwmon + net + storage + proc + pid_detail + smart
- Modern ratatui 0.30 patterns —
TableState, modular layout, status bars,Tabswidget, modal popups (Clear+ centeredRect) - Cross-platform — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback + hwmon fallback for AMD CPUs + net/sysfs fallback + storage/sysfs fallback + procfs fallback + /proc/[pid]/* parsers + smartctl subprocess with graceful missing-binary degradation + UI badge display)
- Well-documented — extensive code comments + this doc + improvement plan
- Testable — bench + sensor + network + storage + process + pid_detail + smart modules have 76 unit tests covering stress modes + hwmon unit conversions + multi-vendor pkg_temp_c + binary byte formatting + disk stat parsing + delta math + /proc/[pid]/stat parser with space-handling + CPU% delta math + disk throughput delta math + network throughput delta math + sort mode comparisons + process filter matching + /proc/[pid]/{status,io,smaps_rollup} parsers + smartctl attribute parsing
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 implementationlocal/recipes/tui/tlc/source/src/— 46k+ LoC production TUIlocal/recipes/tui/redbear-tui-theme/— shared theme constantslocal/docs/redbear-power-improvement-plan.md— Phase 2 roadmap derived from this doc, with §28 v1.4 statuslocal/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)