mcpField
R
ratatui-tui

ratatui-tui

github.com/blacktopDevelopment17737 installsUpdated 1w ago
Repository

About ratatui-tui

ratatui-tui is a skill for developers building terminal UIs with Ratatui. It provides project templates, implementation patterns, and release checks, covering async events, images, and loading animations.

Key features

  1. 01Templates by project complexity
  2. 02TEA architecture and component patterns
  3. 03Async event handling patterns
  4. 04Image and loading animation integration
  5. 05Terminal safety and release checks
  6. 06Multi-agent TUI review workflow

Use cases

  1. 01Start a Ratatui demo or single-screen tool
  2. 02Build a TUI with async tasks
  3. 03Structure a multi-view component app
  4. 04Check a TUI project before release

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/blacktop/dotfiles --skill ratatui-tui

After installing, type/ratatui-tuiin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

assets/templates/async-app/
Cargo.toml
assets/templates/async-app/src/
main.rs
assets/templates/component-app/
Cargo.toml
assets/templates/component-app/src/
action.rs
app.rs
config.rs
event.rs
logging.rs
main.rs
tui.rs
ui.rs
assets/templates/hello-world/
Cargo.toml
assets/templates/hello-world/src/
main.rs
assets/templates/simple-app/
Cargo.toml
assets/templates/simple-app/src/
main.rs
references/
architecture-patterns.md
async-patterns.md
image-integration.md
style-guide.md
workflows/
tui-review.js

Details

From the project README

Ratatui TUI Development

Quick Start

  1. Copy template to project:

    cp -r ~/.agents/skills/ratatui-tui/assets/templates/<template>/* .
    

    Or generate from the official templates repo:

    cargo install --locked cargo-generate
    cargo generate ratatui/templates
    
  2. Run:

    cargo run
    

Version Notes (0.30.x)

Targets 0.30.x (MSRV 1.88, edition 2024); run cargo info ratatui for the current patch release.

  • Modular workspace: apps keep depending on ratatui; widget libraries should depend on ratatui-core for API stability and fewer dependencies.
  • ratatui::run(|terminal| ...): initializes the terminal, installs a panic hook that restores it, runs the closure, and restores on exit.
  • Block::shadow(...) (new in 0.30.1): drop shadows for blocks/popups.
  • Breaking since 0.29: block::Title removed, layout::Alignment renamed to HorizontalAlignment, Flex::SpaceAround now matches flexbox semantics (use Flex::SpaceEvenly for the old behavior), Marker is non-exhaustive.
  • Performance: disabling default-features also disables layout-cache; re-enable it explicitly or layout performance drops sharply.

Template Selection

ComplexityTemplateUse Case
Minimalhello-worldLearning, quick demos
Simplesimple-appSingle-screen apps, tools
Asyncasync-appBackground tasks, network
Fullcomponent-appMulti-view, config, logging

Decision tree:

  • Need async/network? → async-app
  • Multiple screens/components? → component-app
  • Just a simple tool? → simple-app
  • Learning ratatui? → hello-world

Project Setup

Minimal Cargo.toml
[package]
name = "my-tui"
version = "0.1.0"
edition = "2024"

[dependencies]
ratatui = "0.30"
crossterm = "0.29"
color-eyre = "0.6"
Full Dependencies (component-app)
[dependencies]
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
color-eyre = "0.6"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
clap = { version = "4", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
config = "0.15"
dirs = "6"

# Optional: image support (default features include chafa-dyn, which overrides chafa-static)
ratatui-image = { version = "11", default-features = false, features = ["crossterm", "image-defaults", "chafa-static"] }

# Optional: shimmer text animation
tui-shimmer = "0.1"
Release Profile
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true

Core Loop: TEA (The Elm Architecture)

Model → Message → Update → View
  ↑                         |
  └─────────────────────────┘
struct App {
    counter: i32,
    should_quit: bool,
}

enum Message {
    Increment,
    Decrement,
    Quit,
}

impl App {
    fn update(&mut self, msg: Message) {
        match msg {
            Message::Increment => self.counter += 1,
            Message::Decrement => self.counter -= 1,
            Message::Quit => self.should_quit = true,
        }
    }

    fn view(&self, frame: &mut Frame) {
        let text = format!("Counter: {}", self.counter);
        frame.render_widget(Paragraph::new(text), frame.area());
    }
}

Styling Rules

Use Stylize trait helpers:

use ratatui::style::Stylize;

// Good
"text".bold()
"text".dim()
"text".cyan()
"text".on_dark_gray()
"text".bold().cyan()

// Avoid
Style::default().fg(Color::White)  // hardcoded white
Style::default().fg(Color::Black)  // hardcoded black
Style::new().add_modifier(Modifier::BOLD)  // verbose

Color palette:

  • Primary: .cyan(), .green()
  • Error: .red()
  • Warning: .yellow() (sparingly)
  • Muted: .dim(), .dark_gray()
  • Accent: .magenta()

Text wrapping:

use textwrap::wrap;
use ratatui::text::Line;

let wrapped: Vec<Line> = wrap(&long_text, width as usize)
    .into_iter()
    .map(|cow| Line::from(cow.into_owned()))
    .collect();

See: references/style-guide.md

Widget Patterns

StatefulWidget
struct MyList {
    items: Vec<String>,
}

struct MyListState {
    selected: usize,
}

impl StatefulWidget for MyList {
    type State = MyListState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        // render with state.selected
    }
}

// Usage
frame.render_stateful_widget(my_list, area, &mut state);
Layout
let [header, main, footer] = Layout::vertical([
    Constraint::Length(1),
    Constraint::Fill(1),
    Constraint::Length(1),
]).areas(frame.area());

let [left, right] = Layout::horizontal([
    Constraint::Percentage(30),
    Constraint::Fill(1),
]).areas(main);
Built-in State Types
  • ListState - for List widget
  • TableState - for Table widget
  • ScrollbarState - for Scrollbar

See: references/architecture-patterns.md

Async Event Handling

use crossterm::event::{EventStream, Event, KeyCode};
use futures::StreamExt;
use tokio::select;

async fn run(mut app: App) -> Result<()> {
    let mut events = EventStream::new();

    loop {
        // Render
        terminal.draw(|f| app.view(f))?;

        // Handle events
        select! {
            Some(Ok(event)) = events.next() => {
                if let Event::Key(key) = event {
                    match key.code {
                        KeyCode::Char('q') => break,
                        KeyCode::Up => app.update(Message::Up),
                        KeyCode::Down => app.update(Message::Down),
                        _ => {}
                    }
                }
            }
            // Add other channels here (background tasks, timers)
        }

        if app.should_quit {
            break;
        }
    }
    Ok(())
}

See: references/async-patterns.md

Image Integration

Targets ratatui-image 11.x; its API changes between majors, so check docs.rs before reusing older snippets.

use ratatui::layout::Size;
use ratatui_image::{picker::Picker, protocol::Protocol, Image, Resize};

// Query protocol and font size once at startup; keep the picker on the app
let picker = Picker::from_query_stdio()?;

// Encode once, fitted inside a box of cells; do this outside `draw`
// (on a worker thread for large images)
let dyn_img = image::open("photo.png")?;
let protocol: Protocol = picker.new_protocol(dyn_img, Size::new(40, 20), Resize::Fit(None))?;

// In render, drawing a pre-encoded Protocol is cheap
frame.render_widget(Image::new(&protocol), area);

Key points:

  • For portable binaries use chafa-static with default-features = false; the default chafa-dyn takes precedence when both are enabled
  • Query the protocol once, not per frame
  • Image is stateless and fixed-size; all encoding happens in new_protocol
  • StatefulImage refits to its render area and encodes at render time, which blocks; drive it through ratatui_image::thread::ThreadProtocol (see the crate's examples/thread.rs and examples/tokio.rs)

See: references/image-integration.md

Shimmer / Loading Animation

tui-shimmer sweeps a highlight across text — the "Loading…"/"Thinking…" effect used by coding-agent TUIs.

use ratatui::style::Style;
use ratatui::text::Line;
use tui_shimmer::{shimmer_spans_with_style, shimmer_spans_with_style_at_phase};

// Time-driven (call every frame; re-render on a tick to animate)
let spans = shimmer_spans_with_style("Loading...", Style::new().cyan());
frame.render_widget(Line::from(spans), area);

// Deterministic: drive phase (0.0..1.0) from app state — testable, pausable
let phase = (self.start.elapsed().as_secs_f32() / 2.0) % 1.0;
let spans = shimmer_spans_with_style_at_phase("Working...", Style::new().cyan(), phase);

Key points:

  • Animation needs redraws: add a tick event (~80-120ms) to the event loop (select! with tokio::time::interval, or event::poll timeout)
  • Prefer the _at_phase variant with phase stored in the Model — keeps rendering pure and animation testable
  • True color with automatic fallback for limited terminals
  • API is experimental until 1.0 — pin and review minor bumps

Error Handling

ratatui::run() / ratatui::init() install a panic hook that restores the terminal before panicking — do not write one by hand. Install color-eyre first so the terminal is restored before its report prints:

use color_eyre::eyre::Result;

fn main() -> Result<()> {
    color_eyre::install()?;        // eyre hooks before terminal init
    // App::run is the app's own main loop (see templates), not a ratatui API
    let result = ratatui::run(|terminal| App::default().run(terminal));
    Ok(result?)
}

Only write a manual panic hook when constructing Terminal/Backend by hand instead of via ratatui::init().

Error propagation:

// Use ? for recoverable errors
let file = std::fs::read_to_string(path)?;

// Use color_eyre context
let config = load_config()
    .wrap_err("Failed to load configuration")?;

Release Build

cargo build --release

Binary at target/release/<name>.

Size optimization — replaces the Release Profile block above when binary size matters more than speed:

[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z"  # size over speed

Templates Overview

hello-world (~25 lines)

Minimal ratatui demo using ratatui::run().

simple-app (~80 lines)

Synchronous event loop, App struct, basic render.

async-app (~120 lines)

Tokio runtime, EventStream, select! pattern.

component-app (~300 lines)

Full modular structure:

  • main.rs - entry point
  • app.rs - App state, update logic
  • event.rs - event handling
  • ui.rs - rendering
  • action.rs - Action enum
  • tui.rs - terminal setup
  • config.rs - configuration with dirs
  • logging.rs - tracing setup

Common Patterns

Centered Popup
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let [_, center, _] = Layout::vertical([
        Constraint::Percentage((100 - percent_y) / 2),
        Constraint::Percentage(percent_y),
        Constraint::Percentage((100 - percent_y) / 2),
    ]).areas(area);

    let [_, center, _] = Layout::horizontal([
        Constraint::Percentage((100 - percent_x) / 2),
        Constraint::Percentage(percent_x),
        Constraint::Percentage((100 - percent_x) / 2),
    ]).areas(center);

    center
}

With a drop shadow (0.30.1+):

use ratatui::layout::Offset;
use ratatui::widgets::{Block, Shadow};

let popup = Block::bordered()
    .title("Confirm")
    .shadow(Shadow::dark_shade().offset(Offset::new(2, 1)));
Key Bindings Display
let help = Line::from(vec![
    " q ".bold().cyan(),
    "quit ".dim(),
    " ↑↓ ".bold().cyan(),
    "navigate ".dim(),
    " Enter ".bold().cyan(),
    "select ".dim(),
]);
Status Bar
let status = Line::from(vec![
    " MODE ".bold().on_cyan(),
    format!(" {} items ", count).dim().into(),
]);

Multi-Agent TUI Review Workflow

workflows/tui-review.js is a dynamic-workflow template for Claude Code's Workflow tool. It fans out one reviewer per TUI dimension — TEA architecture, terminal safety, styling, event handling, render performance — then adversarially verifies each finding before reporting, so only confirmed issues survive. In agents without the Workflow tool (Codex), skip the script and apply those five dimensions as a manual review checklist instead.

Treat it as a template, not a script to run verbatim: adjust the target path, dimensions, and severity threshold to the codebase. It fans out several agents, so run it when the user asks for a TUI review or a pre-release check:

Workflow({
  scriptPath: "~/.agents/skills/ratatui-tui/workflows/tui-review.js",
  args: { path: "src/" },
})

Or ask: "run the TUI review workflow from the ratatui-tui skill on src/".

Checklist

Before shipping:

  • cargo fmt
  • cargo clippy --all-features clean
  • No unwrap() outside tests
  • Terminal restored on all exit paths (ratatui::run() or init/restore)
  • cargo build --release succeeds
  • Test on target terminal(s)

Comments

Markdown supported