Files
RedBear-OS/local/docs/RATATUI-APP-PATTERNS.md
T
vasilito ed92bce14b redbear-power: v1.1 — full Phase A→D implementation
Comprehensive implementation per local/docs/redbear-power-improvement-plan.md.

Source: 2376 LoC across 10 modules (was 1396/6 in v0.6, +980 LoC).
Cross-compile: 2.8 MB stripped Redox ELF binary.
SHA256: 1b6f9db6ce79e77957bbb1fd606c430516015d5f02f3b64cb6f395e2f63b8e04

Modules:
- main.rs     (376) — event loop, key + mouse dispatch, render orchestration
- app.rs      (421) — App, CpuRow, Governor, ThrottleMode, PackageThermal, HybridInfo
- render.rs   (498) — header/table/controls/help/snapshot rendering
- acpi.rs     (166) — CPU enumeration, ACPI _PSS, CPUID fallback
- cpuid.rs    (350) — CPUID leaf decoding (vendor, family, model, SIMD, cache, hybrid)
- bench.rs    (123) — prime-sieve stress benchmark for thermal response testing
- dbus.rs     (202) — D-Bus export via zbus 5 (org.redbear.Power, --dbus flag)
- msr.rs      (127) — MSR constants + PackageThermal decoder
- cpufreq.rs  (50)  — governor hint read/write
- theme.rs    (72)  — central color palette (const Style)

Phase A — bug fixes:
- R1: PROCHOT pulse bug — Instant::now() math always ~0, pulse never toggled.
  Replaced with Frame::count() so the bar pulses at a frame-rate-stable rate.
- R5: removed duplicate comment block in snapshot().
- C2: PackageThermal struct + 13 PKG_THERM_* bit constants; full decode of
  IA32_PACKAGE_THERM_STATUS (PL1/PL2/CRIT/TT1/TT2/HFI/temp) surfaced in header.

Phase B — quality:
- R3: input poll decoupled from refresh cadence (50ms vs 250-2000ms).
- R4: Rect::centered replaces hand-rolled centered_rect helper.
- R6: area.layout(&Layout) destructuring with compile-time size check.
- O2: theme.rs central color palette (LABEL, BORDER_*, STATUS_*).
- C9: ratatui 0.30 Stylize shorthand across all renders.

Phase C — features:
- C1/C8: cpuid.rs reads leaves 0/1/4/7/0x80000000+/0x1A/0x8000001E.
- C3: SIMD display header line.
- C5: cache hierarchy header line.
- C7: dynamic refresh interval via / key (typed input 50..60000ms + Enter).
- C6: prime-sieve benchmark via b/B keys (one thread per core, AtomicU64
  counter, run/stop/status).

Phase D remaining (was deferred per plan s23):
- C4: hybrid CPU detection (CoreType enum, Intel leaf 0x1A, AMD leaf 0x8000001E),
  per-CPU row prefixed with type label, Hybrid: 8P + 16E header.
- O1: termion MouseTerminal wrapper enables xterm mouse protocols.
  Wheel = scroll, Left-click = select/toggle, Right-click = expand P-state.
  hit_test() maps (x, y) to panel rects cached after every render.
- O3: dbus.rs publishes org.redbear.Power on session bus (opt-in via --dbus).
  Properties: cpu_count, avg_freq_khz, max_temp_c, avg_load_pct, governor,
  throttle_mode, prochot_asserted. Background thread owns the tokio runtime
  + zbus Connection; main thread sends snapshots via mpsc channel. Graceful
  degradation if redbear-sessiond is unreachable.

Verification:
- cargo build --release (host): 0 errors, 21 warnings.
- ./redbear-power --once (Linux host, AMD 24-core): renders all features.
- ./redbear-power --dbus (via script(1)): registers on session bus,
  emits xterm mouse capture sequences.
- cook redbear-power (Redox target): 2.8 MB stripped binary at
  local/recipes/system/redbear-power/target/x86_64-unknown-redox/stage/usr/bin/redbear-power.

ISO rebuild status: blocked by pre-existing upstream nix-0.30.1 vs Redox
relibc SaFlags incompatibility in uutils (recipes/core/uutils). The v1.1
binary IS staged and will be packaged into the next successful ISO build
once that issue is resolved (separate scope).

Docs:
- local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md s3.3.2 - v1.0 + v1.1 sections.
- local/docs/redbear-power-improvement-plan.md s24 - full status update.
- local/docs/RATATUI-APP-PATTERNS.md - canonical ratatui 0.30 guide
  (1161 lines), includes s13 ratatui 0.30 Best-Practices Update + s14
  Cross-Reference redbear-power as a Reference Implementation.

Cargo.toml: new dependencies zbus = "5" (tokio feature) and tokio = "1"
(rt + rt-multi-thread + macros) for the D-Bus export.
2026-06-20 13:26:24 +03:00

36 KiB
Raw Blame History

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 §112 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: 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:

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 F1F12 (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::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:

#[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

  1. Theme-driven colors — every render path takes &Theme, never hardcodes colors
  2. Poll-based event looprustix::event::poll with 100ms timeout for animations
  3. Simple Key typeu32 codepoint + Modifiers bitflags, Copy + Hash
  4. Mode-based dispatch — Insert/Normal/Prompt modes with global intercepts
  5. Direct buffer for effectsframe.buffer_mut() for scrollbar, cursor, flash
  6. Animation fields on structsframe_count, dialog_anim, bracket_flash
  7. Separation of concerns — buffer/cursor/handlers/render as separate modules
  8. Shared theme crateredbear-tui-theme for brand consistency
  9. No platform gatescfg(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 §112 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 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:

// 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/ (v0.6, 1396 LoC) produced these actionable findings:

Severity Finding Fix
bug render_prochot_alert always passes freshly-constructed Instant::now(), so the pulse never toggles Use Frame::count() (§13.3)
minor centered_rect hand-rolled Use Rect::centered (§13.6)
minor Layout::default().split(...) returns chunks Use area.layout(&Layout) (§13.5)
cosmetic Style::default().fg(...) chains Use Stylize shorthand (§13.4)
cosmetic Theme not centralized — colors scattered Centralize as §12 (redbear-tui-theme)
minor Input poll (250-2000ms) blocks snappy response Decouple refresh from input (§1 ratatui audit §8)
cosmetic Duplicate comment in snapshot() Trivial cleanup
feature No mouse support Tier 4 follow-up (defer)
feature No config file Add /etc/redbear-power.toml (TOML)

Full plan: see local/docs/redbear-power-improvement-plan.md.


14. Cross-Reference: redbear-power as a Reference Implementation

The redbear-power recipe (local/recipes/system/redbear-power/) is a useful reference for new TUI apps because:

  1. Small enough to read in one sitting (~1400 LoC across 6 modules)
  2. Self-contained — no D-Bus, no external state, just sysfs/MSR
  3. Modern ratatui 0.30 patternsTableState, modular layout, status bars
  4. Cross-platform — same binary works on Linux + Redox (MSR/scheme fallback)
  5. Well-documented — extensive code comments + this doc + improvement plan

When porting a new Red Bear TUI app, structure it like redbear-power:

my-tui-app/
├── Cargo.toml              # ratatui 0.30 + termion 4
├── recipe.toml             # path = "source", template = "cargo"
└── source/
    └── src/
        ├── main.rs         # event loop, key dispatch (~200 lines)
        ├── app.rs          # App struct, all state (~400 lines)
        ├── render.rs       # render_header, render_table, render_controls (~550 lines)
        └── data.rs         # detect, read_*, helpers (~150 lines)

Key conventions:

  • App::new() initializes all state from data sources (no I/O during render)
  • App::refresh() is the periodic pull (called on tick)
  • Render functions are pure — they take &App and produce widgets
  • Status messages via App.flash_status(msg) with status_expires: Option<Instant>
  • Help text in a const HELP_TEXT: &str referenced by both inline ? overlay and --help flag

This shape keeps render.rs purely view code and app.rs purely model code, which matches the ratatui book recommendation.


See Also

  • local/recipes/system/redbear-power/source/src/ — reference implementation
  • local/recipes/tui/tlc/source/src/ — 46k+ LoC production TUI
  • local/recipes/tui/redbear-tui-theme/ — shared theme constants
  • local/docs/redbear-power-improvement-plan.md — Phase 2 roadmap derived from this doc
  • local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md — desktop stack planning
  • https://ratatui.rs/ — official docs
  • https://github.com/ratatui/ratatui/tree/main/examples — canonical patterns
  • https://github.com/X0rg/CPU-X — cpu-x v4.7 (7000+ LoC, mature CPU monitor reference)