Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

tuile

tuile is a widget library and design system for ratatui. It gives you 109 widget types, a colour system that expands ten colours into thirty semantic roles, tweened animation, mouse handling with hit rectangles, overlays, and a 22-page gallery app that exercises all of it.

ratatui hands you a terminal buffer and a small set of drawing widgets. Everything above that line, a focus ring, hover states, scroll offsets, dropdowns that render on top, dialogs, toasts, a palette that works in both light and dark terminals, animation that does not burn a core while idle, is left to your app. Most TUI projects build that layer once, badly, and then live with it. tuile is that layer, ported from Textual and implemented in plain ratatui with no runtime and no CSS engine.

What using it looks like

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut dark = SwitchState::new(true);

Switch::new()
    .label("Dark mode")
    .focused(true)
    .now(now)
    .render(area, buf, &mut dark);
}
fn main() {}

Three ideas carry the whole library, and the core concepts chapters cover them in order:

  1. A builder configures one frame. A state lives between frames and owns the value, the hover flag, the scroll offset and any running tween.
  2. Events go into a state and come back as an Outcome: Ignored, Consumed or Changed.
  3. Focus belongs to your app. Hover belongs to the widget.

Once you know one widget you know the shape of all of them, so the widget chapters are reference material you can read out of order.

Where to start

If you want toRead
Add tuile to a projectInstallation
Build something end to endYour first app
See every widget runningRunning the showcase
Understand the modelBuilders and state
Change the coloursThe colour system
Find the widget you needWidget index
Write your own widgetWriting your own widget

Scope, and what this is not

tuile draws. It does not manage your application state, own your data, or talk to a network. There is no reactive graph, no style sheet, no macro DSL. A widget is a struct you configure and hand a buffer, which means you can drop a single DataTable into an existing ratatui app without adopting anything else.

Two dependencies beyond ratatui: unicode-width and unicode-segmentation. Both are used for the same reason, terminal cells are not bytes and not chars, and getting that wrong is how a TUI ends up with a skewed row.

The design language is Textual’s. The code is not: nothing here is a binding, a port of their Python, or affiliated with Textualize.

Versioning

Pre-1.0. The widget contract (builder plus state, Interactive, cached rects) is stable and every widget follows it, but individual builder methods can still be renamed between 0.x releases. The FAQ says what that means in practice.

Installation

[dependencies]
tuile = "0.3"

To track main instead of a release, depend on the repository:

[dependencies]
tuile = { git = "https://github.com/Osyna/tuile" }

That is the only dependency you need. tuile re-exports the crates you would otherwise pin yourself:

extern crate tuile;
use tuile::crossterm;         // key and mouse events, terminal control
use tuile::ratatui_core;      // Rect, Buffer, Style, Frame
use tuile::ratatui_crossterm; // the backend, if you build your own Terminal
fn main() {}

tuile depends on ratatui-core rather than the ratatui facade. Upstream splits the project exactly this way: applications use ratatui, widget libraries use ratatui-core. The types are the same ones the facade re-exports, so tuile widgets render into a Frame from a ratatui app without any conversion.

Pinning them yourself is allowed, but the versions must match tuile’s or you will get two incompatible Buffer types and a confusing type error at the render call. If you already depend on ratatui, check that it resolves to 0.30.

Requirements

Rust1.88 or newer, edition 2024
ratatui0.30
Terminalanything crossterm supports; true colour is used when available

The prelude

Every example in this book starts the same way:

extern crate tuile;
use tuile::prelude::*;
fn main() {}

The prelude brings in every widget and its state type, the theme API, the drawing and layout helpers, the core vocabulary (Outcome, Focus, HitBox, Look), the runtime (App, Flow, run), and the ratatui types you cannot avoid touching: Rect, Buffer, Frame, Line, Span, Text, Style, Modifier, Constraint, Layout, Direction, Alignment, Position, Margin, and the crossterm event types.

If you prefer explicit imports, everything is reachable through its module: tuile::widgets::Button, tuile::theme::Theme, tuile::draw::Border, tuile::layout::center, tuile::core::Outcome, tuile::anim::Tween, tuile::runtime::run.

Check it works

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

struct Hello;

impl App for Hello {
    fn draw(&mut self, frame: &mut Frame, _now: Instant) {
        let area = center(frame.area(), 30, 3);
        let buf = frame.buffer_mut();
        let mut state = ButtonState::default();
        Button::new("It works").variant(Variant::Success).render(area, buf, &mut state);
    }

    fn event(&mut self, ev: Event, _now: Instant) -> Flow {
        match ev {
            Event::Key(k) if k.code == KeyCode::Esc => Flow::Quit,
            _ => Flow::Continue,
        }
    }
}

fn main() -> std::io::Result<()> {
    run(&mut Hello)
}

cargo run should paint a green button in the middle of a cleared terminal, and Esc should put your shell back exactly as it was. The runtime restores the terminal on panic too, so a crash in your draw code does not leave you typing into a raw-mode shell.

Examples in the repository

cargo run -p tuile --example minimal    # a switch, an input and a button
cargo run -p tuile --example custom     # a custom theme, a custom spinner, chat widgets

Both are short enough to read in one sitting and are compiled by CI, so they always match the current API.

Your first app

This chapter builds a small settings card: a switch, a text field, a button, and a toast when you save. It is the minimal example in the repository, taken apart. Run the finished version with cargo run -p tuile --example minimal.

We build it in five steps, and each step compiles.

1. The App trait

An app is a struct that implements four methods, two of which have defaults:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Demo;
impl App for Demo {
    fn draw(&mut self, frame: &mut Frame, now: Instant) {
        let _ = (frame, now);
        // paint one frame
    }

    fn event(&mut self, ev: Event, now: Instant) -> Flow {
        let _ = (ev, now);
        Flow::Continue
    }

    fn update(&mut self, _now: Instant) {}          // optional: timers, tweens, async results

    fn animating(&self, _now: Instant) -> bool {    // optional: true while something moves
        false
    }
}
fn main() {}

draw gets a ratatui Frame and the Instant for this frame. Every animated widget takes that same instant, which is what keeps a screen full of spinners in phase.

event gets one crossterm event. Key releases are filtered out before you see them, so you do not have to check KeyEventKind. Return Flow::Quit to exit the loop.

animating is the frame-rate switch. Return true while a tween or spinner is live and the runtime redraws at 60 fps; return false and it goes back to blocking on input. Get this wrong in the lazy direction and your animations stutter; get it wrong in the eager direction and you spin a core on an idle screen.

2. State lives in your struct

Widgets are rebuilt every frame, so anything that must survive a frame lives in your app:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)]
enum Id { Dark, Name, Save }

struct Demo {
    dark: SwitchState,
    name: InputState,
    save: ButtonState,
    focus: Focus<Id>,
    toasts: Toaster,
}
fn main() {}

Focus<Id> is the tab ring. Any Copy + PartialEq type works as an id; a small enum is the usual choice because the compiler then catches a widget you forgot to wire up.

The states own more than the value. InputState holds the cursor, the selection, the scroll offset and the undo stack. SwitchState holds the on flag and the tween that slides the thumb. You can read and write their public fields directly.

3. Drawing

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Dark, Name, Save }
struct Demo { dark: SwitchState, name: InputState, save: ButtonState, focus: Focus<Id>, toasts: Toaster }
impl Demo {
fn draw(&mut self, frame: &mut Frame, now: Instant) {
    let area = frame.area();
    let buf = frame.buffer_mut();
    let th = theme::current();
    fill(buf, area, th.background);

    let card = center(area, 46, 13);
    let inner = Border::Round.draw_titled(
        buf, card, th.border_blurred, th.background, "tuile", Alignment::Left,
    );
    let [a, b, c] = Layout::vertical([Constraint::Length(3); 3]).areas(pad(inner, 1, 0));

    Switch::new()
        .label("Dark mode")
        .focused(self.focus.is(Id::Dark))
        .now(now)
        .render(a, buf, &mut self.dark);
    Input::new()
        .placeholder("Your name")
        .focused(self.focus.is(Id::Name))
        .now(now)
        .render(b, buf, &mut self.name);
    Button::new("Save")
        .variant(Variant::Primary)
        .focused(self.focus.is(Id::Save))
        .now(now)
        .render(Rect { width: 16, ..c }, buf, &mut self.save);

    ToastStack::new().now(now).render(area, buf, &mut self.toasts);
}
}
fn main() {}

Four things in that block are worth naming.

theme::current() returns the process-wide theme by value. Theme is Copy, so holding it in a local for the frame costs nothing.

Border::draw_titled draws the frame and returns the rectangle inside it. Every border helper returns its inner area, so layout code reads top to bottom without a separate inner() call.

.focused(..) is passed in by you. Widgets never decide their own focus, because only your app knows what the Tab order is.

The toast stack renders over the whole area, last. Overlays are just widgets drawn after everything else; the overlays chapter covers the cases where that is not enough.

4. Events

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Dark, Name, Save }
struct Demo { dark: SwitchState, name: InputState, save: ButtonState, focus: Focus<Id>, toasts: Toaster }
impl Demo {
fn event(&mut self, ev: Event, _now: Instant) -> Flow {
    if let Event::Key(k) = &ev {
        if ctrl(k, 'c') {
            return Flow::Quit;
        }
        if self.focus.handle_key(*k).is_consumed() {   // Tab and Shift-Tab
            return Flow::Continue;
        }
    }

    let out = match (&ev, self.focus.current()) {
        (Event::Key(_), Some(Id::Dark)) => self.dark.handle(&ev),
        (Event::Key(_), Some(Id::Name)) => self.name.handle(&ev),
        (Event::Key(_), Some(Id::Save)) => self.save.handle(&ev),
        // Mouse events go to every widget: each one checks the rect it drew.
        (Event::Mouse(_), _) => self.dark.handle(&ev) | self.name.handle(&ev) | self.save.handle(&ev),
        _ => Outcome::Ignored,
    };

    if out.is_changed() && self.focus.is(Id::Save) {
        self.toasts.success(format!("Saved {:?}", self.name.value));
    }
    Flow::Continue
}
}
fn main() {}

Keys go to the focused widget only. Mouse events go to all of them, because a click carries its own coordinates and each state knows the rectangle it last drew into. Outcome implements BitOr, so combining the results of several widgets is one expression.

Changed means the value moved: the switch flipped, the text changed, the button fired. That is the signal to act on. Consumed means the widget handled the event and wants a redraw but nothing you care about changed, which is what you get from hover moves and cursor blinks.

5. Wiring the loop

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Dark, Name, Save }
struct Demo { dark: SwitchState, name: InputState, save: ButtonState, focus: Focus<Id>, toasts: Toaster }
impl App for Demo {
    fn draw(&mut self, _f: &mut Frame, _n: Instant) {}
    fn event(&mut self, _e: Event, _n: Instant) -> Flow { Flow::Continue }
    fn update(&mut self, now: Instant) { self.toasts.tick(now); }
    fn animating(&self, now: Instant) -> bool {
        self.dark.animating(now) || self.save.animating(now) || self.toasts.animating(now)
    }
}
fn main() -> std::io::Result<()> {
    theme::set_by_name("textual-dark");
    run(&mut Demo {
        dark: SwitchState::new(true),
        name: InputState::new(),
        save: ButtonState::default(),
        focus: Focus::new([Id::Dark, Id::Name, Id::Save]),
        toasts: Toaster::new(),
    })
}
fn main2() {}

run enters the alternate screen, turns on raw mode and mouse capture, installs a panic hook that restores the terminal, and loops. run_with(app, RunOptions { .. }) is the same thing with the knobs exposed:

FieldDefaultEffect
mousetrueEnable mouse capture. Turn it off if you want the terminal’s own text selection.
fps60Redraw rate while animating() is true.
idle_redrawSome(250ms)Redraw interval when idle, for clocks and blinking cursors. None blocks until the next event.

The update(now) method is where per-frame bookkeeping goes. Here it drives toast timeouts. It runs before draw on every iteration, including idle redraws.

The core concepts chapters expand each of the five steps. If you would rather learn by reading working code, the showcase has a page per widget family and the source for each page is a single self-contained file.

Running the showcase

The showcase binary is a 22-page gallery of every widget in the library. It is the fastest way to see what a widget looks like before you write any code, and the source for each page is one self-contained file you can copy from.

git clone https://github.com/Osyna/tuile
cd tuile
cargo run --release -p showcase

Use --release. The debug build redraws animated pages noticeably slower, which makes the tweens look worse than they are.

Opening a page directly

cargo run --release -p showcase -- --page charts
cargo run --release -p showcase -- --page inputs --theme nord
cargo run --release -p showcase -- --help          # lists the theme names

--page matches the page title in lower case (big menus, ai tools, ai agents need quoting in most shells). --theme takes any built-in palette name.

Keys

KeyAction
] [Next and previous page
alt+1 to alt+9Jump to a page by number
Ctrl-PCommand palette (fuzzy page search)
Ctrl-TCycle to the next theme
Ctrl-BToggle the sidebar
F1Help overlay
F3Toggle reduced motion
Tab Shift-TabMove focus inside the page
EscDismiss all toasts
q or Ctrl-CQuit

Individual pages add their own keys, listed in the footer of each page. The Notifications page fires a different toast style on each of 1 to 9 and 0; the Big menus page cycles fonts on f, styles on s and gradients on g; the Loading page pauses on space and changes speed with + and -.

Every page is mouse-driven as well. Clicking a widget focuses it, wheel scrolls the thing under the pointer, and dragging works on sliders, split panes and table column edges.

The pages

PageWhat it demonstrates
WelcomeThe library’s own front page, big text and a card grid
DashboardEvery family composed into one realistic ops screen
MonitorA btop-style monitor: LED meters, dot-field graphs, mirrored network graph, process tree
ControlsButtons, checkboxes, switches, radios, segmented controls, sliders, steppers, ratings
InputsText fields, text areas, selects, comboboxes, multi-selects, all eight field shapes
NavigationTab bars in five styles, lists, trees, menus, breadcrumbs, paginator
Big menusFour big fonts, ten menu styles, gradients
TablesSortable data table, cell navigation, key-value lists, big digits
ChartsEvery chart type with live data
FeedbackProgress, spinners, modals, callouts, tooltips, the command palette
NotificationsTen toast styles, six positions, the inbox, banners, inline alerts
LoadingTwenty loader styles, skeletons, the loading overlay
SpinnersThe whole 102-entry catalog, filterable, with the one-liner for each
AIA replayed agent turn: thinking, markdown, tool calls, streaming, approvals
AI ToolsTool timeline, shell and code blocks, edit preview, change set, JSON tree
AI AgentsAgent tree and lanes, token and cost meters, context map, compaction, sessions, models
AI ComposerSlash commands, mentions, attachments, mode badge, status line, questions, plan, queue
LayoutSplit panes, scroll views, panels, collapsibles, headers and footers
ContentLabels, badges, markup, markdown, logs, calendars, colour pickers, timelines
SettingsA complete preferences form in about 300 lines
OptionsAn omp-style settings screen: icon tabs, group sidebar, option list, live preview
ThemesAll twelve palettes side by side with a live primary-hue override

Reading the source

Each page is showcase/src/pages/<name>.rs and implements the same small Page trait. They are written to be read: no shared helpers beyond the library itself, no cleverness, and the layout code sits next to the widget calls it feeds.

If you want to know how a particular screenshot in this book was produced, the page file has the answer.

Headless screenshots

The screenshots in this book are captured by a script, not by hand:

cargo xtask shot -s 130x42 -o out.png -- ./target/release/showcase --page charts
cargo xtask shot -s 90x28 -k "Tab Tab Enter" -o out.png -- ./target/release/showcase --page inputs

It runs the binary in a private tmux server at an exact size, sends the keys you list, and rasterises the captured ANSI to a PNG. The testing recipe explains how to use the same script in CI for your own app.

Builders and state

Every widget in tuile is split in two: a builder you create fresh each frame, and a state that lives in your app between frames.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, state: &mut CheckboxState) {
Checkbox::new("Send crash reports")
    .focused(true)
    .render(area, buf, state);
}
fn main() {}

Checkbox::new(..) is the builder. CheckboxState is the state. The split is the single most important thing to understand about the library, because it decides where your data lives and what you are allowed to mutate.

The builder is a value, not an object

A builder holds only configuration: the label, the variant, the focus flag, the frame’s Instant. It is created, consumed by render, and dropped. It does not allocate unless you hand it an owned String, it borrows slices rather than copying them, and it never reads global mutable state except the fallback theme.

This is why builder chains appear in the middle of a draw function rather than in a struct field: rebuilding one costs nothing, and it keeps the configuration next to the layout that feeds it.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, st: &mut ButtonState, enabled: bool) {
// Configuration follows the data it depends on, with no separate "update" step.
Button::new(if enabled { "Deploy" } else { "Deploy (blocked)" })
    .variant(if enabled { Variant::Primary } else { Variant::Default })
    .enabled(enabled)
    .render(area, buf, st);
}
fn main() {}

The state owns everything that must survive

A <Name>State holds the value and the interaction bookkeeping: hover and press flags, the cursor position, the scroll offset, running tweens, and the rectangles the widget drew into last frame.

States are plain data. They derive Clone and Debug, their useful fields are public, and they contain no callbacks or parent pointers:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
let mut state = InputState::new();
state.set_value("hello");           // write
let text: &str = &state.value;      // read
assert_eq!(text, "hello");
}

That has three consequences worth planning around. You can build a state in a test and assert on it without a terminal. You can serialise the parts you care about and restore them on the next run. And you can construct one in a non-default position before the first render, which is how you restore a scroll offset or a selected row.

Construction

Most states implement Default. Where a widget has an obvious initial value, there is also a constructor that takes it:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
let off = SwitchState::default();      // off
let on = SwitchState::new(true);       // on
let named = InputState::new();
let prefilled = InputState::with_value("root@localhost");
let _ = (off, on, named, prefilled);
}

Check the widget chapter for the constructor a given state offers. When in doubt, Default::default() is always valid and always inert.

Why the state must render before it can be clicked

A state learns its geometry during render. Mouse handling then compares the event coordinates against the rectangles it recorded. A state that has never been rendered has a zero-sized rectangle, so every click misses.

This matters in two situations. If you build a state and feed it mouse events before the first frame, nothing happens, which is correct but surprising. And if you render a widget into a different rectangle every frame (a list that scrolls, a table whose columns resize), the hit rectangles follow automatically, with no invalidation step for you to forget.

Rendering is a method on the builder

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, st: &mut ListViewState) {
ListView::new(vec![ListEntry::new("alpha"), ListEntry::new("beta")]).render(area, buf, st);
}
fn main() {}

The signature is render(self, area: Rect, buf: &mut Buffer, state: &mut S). It takes the builder by value, so a builder cannot be reused by accident.

Stateless widgets drop the last parameter: Rule::new().render(area, buf). The widget index marks which is which.

Widgets also implement ratatui’s own Widget and StatefulWidget traits where the signature allows, so frame.render_widget(..) works if you prefer it. The inherent render method is the one used throughout this book because it does not require importing the traits.

Where the theme comes from

A builder with no .theme(..) call reads theme::current() at render time. Pass one explicitly to override a single widget:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, st: &mut ButtonState) {
let danger = Theme::resolve(&ThemeSpec::new("danger", true, Rgb::hex(0xef4444)), None);
Button::new("Delete everything").theme(&danger).render(area, buf, st);
}
fn main() {}

The theming chapters cover what a theme contains and how to build one.

A rule of thumb for your own code

If a value changes when the user interacts, it belongs in the state. If it changes when your application logic changes, it belongs in your app struct and gets passed to the builder each frame. Text typed into a field is the first kind. Whether that field is disabled because a network request is in flight is the second.

Events and outcomes

Feeding an event to a widget returns an Outcome. It has three values and they mean different things to your app:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let _ = Outcome::Ignored;    // the widget did nothing; offer the event to someone else
let _ = Outcome::Consumed;   // handled; a redraw is enough
let _ = Outcome::Changed;    // the value the widget owns changed
}

Consumed covers hover moves, scrolling, a cursor blink, an arrow key that moved a list cursor without picking anything. Changed is the one your application logic reacts to: a toggle flipped, text was edited, a row was selected, a button fired.

The Interactive trait

Every stateful widget implements Interactive:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(state: &mut CheckboxState, key: KeyEvent, m: MouseEvent, ev: &Event) {
state.handle_key(key);     // key events only
state.handle_mouse(m);     // mouse events only
state.handle(ev);          // either: dispatches by variant, filters key releases
}
fn main() {}

handle is the one to reach for in an app. It matches on the event, forwards key presses to handle_key and mouse events to handle_mouse, and returns Ignored for anything else (resize, focus, paste). Key releases are dropped, so a widget cannot fire twice on one press under terminals that report releases.

Widgets that animate on interaction do not take the frame instant here. They stamp the event with Instant::now() and compare it against the .now(..) handed to the next render. ButtonState::pressed_at works that way: the press is recorded in handle_mouse, and the flash only appears if the builder was given a now to measure against.

Combining outcomes

Outcome implements BitOr, taking the strongest result:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
assert_eq!(Outcome::Ignored | Outcome::Consumed, Outcome::Consumed);
assert_eq!(Outcome::Consumed | Outcome::Changed, Outcome::Changed);
}

That is what makes broadcasting a mouse event to a screen of widgets one expression:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Page { name: InputState, email: InputState, save: ButtonState }
impl Page {
fn on_mouse(&mut self, ev: &Event) -> Outcome {
    self.name.handle(ev) | self.email.handle(ev) | self.save.handle(ev)
}
}
fn main() {}

There is also |=, and the helpers is_consumed() (true for Consumed and Changed) and is_changed() (true only for Changed).

Dispatch: keys go to one widget, mouse goes to all

Keyboard events have no coordinates, so only the focused widget may act on them. Mouse events carry coordinates, and every state knows the rectangle it drew into, so the cheapest correct dispatch is to offer the event to everyone and let each widget test its own rectangle.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Name, Save }
struct Page { name: InputState, save: ButtonState, focus: Focus<Id> }
impl Page {
fn event(&mut self, ev: &Event) -> Outcome {
    match ev {
        Event::Key(k) => {
            if self.focus.handle_key(*k).is_consumed() {
                return Outcome::Consumed;          // Tab or Shift-Tab
            }
            match self.focus.current() {
                Some(Id::Name) => self.name.handle(ev),
                Some(Id::Save) => self.save.handle(ev),
                None => Outcome::Ignored,
            }
        }
        Event::Mouse(_) => self.name.handle(ev) | self.save.handle(ev),
        _ => Outcome::Ignored,
    }
}
}
fn main() {}

The cost of broadcasting is one rectangle test per widget per event, which is not measurable at terminal scale.

Order matters for overlapping widgets

When two widgets overlap, the one drawn last is on top, so it must see the event first. Broadcasting in draw order gives the wrong answer for a dropdown covering a table: both would consume the click.

Handle overlays before the widgets beneath them, and stop when one consumes:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Page { menu: ContextMenuState, table: DataTableState }
impl Page {
fn event(&mut self, ev: &Event) -> Outcome {
    let out = self.menu.handle(ev);
    if out.is_consumed() {
        return out;                     // the menu is on top; the table never sees this
    }
    self.table.handle(ev)
}
}
fn main() {}

The overlays chapter covers the drawing half of the same problem.

Reading what changed

Changed says something moved, not what. Read the state:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(dark: &mut SwitchState, ev: &Event) {
if dark.handle(ev).is_changed() {
    theme::set_by_name(if dark.on { "textual-dark" } else { "textual-light" });
}
}
fn main() {}

Widgets that fire an event rather than hold a value use a take_* method that returns the pending action once and clears it: take_activated() on menus and lists, take_closed() on a closable tab bar, take_action() on an action toast. Calling take_* is what marks the event as handled, so a widget whose result you never take will keep reporting it.

Events you will not see

The runtime filters key releases before App::event. Everything else crossterm produces is forwarded: resize, focus gained and lost, paste, and mouse moves when capture is on. Widgets ignore the variants they do not care about, so passing them through costs nothing.

Focus

Focus belongs to your app, not to the widgets. A widget is told whether it is focused through .focused(bool) and styles itself accordingly. It never decides on its own, because only the app knows what else is on screen and what the tab order should be.

Focus<T> is the small helper that keeps that decision in one place.

extern crate tuile;
use tuile::prelude::*;
fn main() {
#[derive(Clone, Copy, PartialEq, Debug)]
enum Id { Name, Email, Save }

let mut focus = Focus::new([Id::Name, Id::Email, Id::Save]);
assert!(focus.is(Id::Name));
focus.next();
assert!(focus.is(Id::Email));
focus.prev();
focus.prev();
assert!(focus.is(Id::Save));     // wrapped around
}

T is any Copy + PartialEq. An enum is the usual choice: adding a field to the enum and forgetting to render it is then a compile error in the match, rather than a widget you cannot reach with Tab.

Wiring it up

Two calls. One in event to move the ring, one per widget in draw to report the answer:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Name, Save }
struct Page { name: InputState, save: ButtonState, focus: Focus<Id> }
impl Page {
fn event(&mut self, ev: &Event) -> Outcome {
    if let Event::Key(k) = ev
        && self.focus.handle_key(*k).is_consumed()
    {
        return Outcome::Consumed;
    }
    match self.focus.current() {
        Some(Id::Name) => self.name.handle(ev),
        Some(Id::Save) => self.save.handle(ev),
        None => Outcome::Ignored,
    }
}

fn draw(&mut self, area: Rect, buf: &mut Buffer) {
    let [a, b] = Layout::vertical([Constraint::Length(3); 2]).areas(area);
    Input::new().focused(self.focus.is(Id::Name)).render(a, buf, &mut self.name);
    Button::new("Save").focused(self.focus.is(Id::Save)).render(b, buf, &mut self.save);
}
}
fn main() {}

Focus::handle_key consumes Tab and Shift-Tab (crossterm reports the second as BackTab) and ignores everything else. Call it before dispatching to the focused widget, or a text field will swallow the Tab.

The API

MethodEffect
new(order)Build from anything that converts into a Vec<T>; starts at index 0
current()Option<T>, None only when the order is empty
is(id)The focus test to pass to .focused(..)
set(id)Jump to an id; returns false if it is not in the order
next() / prev()Step, honouring wrap
index() / set_index(i)Positional access; set_index clamps
order()The current order as a slice
set_order(order)Replace the order, keeping the current id if it survives
wrapPublic field, default true. Set false to stop at the ends

Dynamic tab orders

A collapsed section or a hidden field should not be a stop on the ring. set_order rebuilds it and keeps the user where they are when possible:

extern crate tuile;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Name, Advanced, Timeout, Save }
fn demo(focus: &mut Focus<Id>, advanced_open: bool) {
let order: Vec<Id> = if advanced_open {
    vec![Id::Name, Id::Advanced, Id::Timeout, Id::Save]
} else {
    vec![Id::Name, Id::Advanced, Id::Save]
};
focus.set_order(order);
}
fn main() {}

If the focused id is still in the new order, focus follows it. If it disappeared (the user collapsed the section that held it), focus falls back to index 0. Rebuilding the order every frame is fine: it is a Vec of Copy values and a linear search.

Clicking should move focus

Terminal users expect a click to focus what they clicked. Widgets do not do this for you, because the widget cannot know its own id. Do it where you dispatch the mouse event:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Name, Save }
struct Page { name: InputState, save: ButtonState, focus: Focus<Id> }
impl Page {
fn on_mouse(&mut self, ev: &Event) -> Outcome {
    let n = self.name.handle(ev);
    if n.is_consumed() {
        self.focus.set(Id::Name);
    }
    let s = self.save.handle(ev);
    if s.is_changed() {
        self.focus.set(Id::Save);
    }
    n | s
}
}
fn main() {}

Use is_consumed() for widgets where a press should focus (a text field: the click also places the cursor) and is_changed() for widgets where only a completed action should (a button: a press that slides off and releases elsewhere is a cancelled click).

Focus inside a widget

Some widgets have internal cursors: the selected row of a list, the current cell of a table, the open item of a menu. That is not app focus and Focus<T> knows nothing about it. The widget owns it, moves it with the arrow keys, and only sees those keys while your app says it is focused.

Nested focus rings work the same way: a panel with three fields can own a Focus<FieldId> and be a single stop in the parent’s Focus<PanelId>. Forward keys down only while the parent ring points at that panel, and let the child ring stop wrapping (wrap = false) if you want Tab to escape the panel at its ends.

Blurred styling

Widgets render differently when not focused, and the difference is a theme role, not an on-off switch. border_blurred, cursor_blurred_bg and the muted text roles are what a blurred widget uses. A text area keeps showing its cursor when blurred, in a dimmer colour, so a reader can still see where typing would resume. The theme roles table lists the pairs.

Mouse and hit boxes

A mouse event carries a column and a row. Turning that into “the user clicked the Save button” needs a rectangle to compare against, and the only code that knows where Save landed is the code that drew it. So widgets record their geometry during render and use it during handle_mouse.

You do not have to do anything for this to work. It matters when you write your own widget, and it explains two behaviours that otherwise look like bugs.

HitBox

HitBox is the tracker a widget stores for one rectangle:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
let mut hit = HitBox::default();
hit.set_area(Rect::new(10, 5, 20, 3));    // called during render
}

It holds three public fields: area, hover and pressed. Feeding it a mouse event returns what it saw:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(hit: &mut HitBox, m: MouseEvent) -> Outcome {
match hit.mouse(&m) {
    Hit::Click => Outcome::Changed,          // pressed and released inside
    Hit::Press => Outcome::Consumed,         // pressed inside, not released yet
    Hit::Drag => Outcome::Consumed,          // moved while pressed here
    Hit::Cancel => Outcome::Consumed,        // released outside after pressing inside
    Hit::HoverChanged => Outcome::Consumed,  // pointer entered or left
    Hit::Wheel(delta) => { let _ = delta; Outcome::Consumed }
    Hit::None => Outcome::Ignored,
}
}
fn main() {}

The press-then-release-inside rule is what makes a click cancellable. Pressing a button and sliding off before releasing gives Cancel, not Click, which is the behaviour users expect from every other interface they use.

hit.look(focused, enabled) combines the tracked hover and press flags with the focus flag your app owns, giving the Look that widget styling reads.

Widgets with many rectangles

A list, a table or a tab bar needs one rectangle per row, cell or tab. Those widgets keep a Vec<Rect> (often paired with the index it maps to) and rebuild it every render:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct MyListState { hits: Vec<Rect>, cursor: usize }
impl MyListState {
fn render_rows(&mut self, area: Rect, rows: &[&str]) {
    self.hits.clear();
    for (i, _row) in rows.iter().enumerate() {
        let y = area.y + i as u16;
        if y >= area.bottom() {
            break;                    // only what is visible gets a rectangle
        }
        self.hits.push(Rect::new(area.x, y, area.width, 1));
    }
}

fn on_click(&mut self, m: &MouseEvent) -> Outcome {
    for (i, r) in self.hits.iter().enumerate() {
        if mouse_in(*r, m) && is_left_down(m) {
            self.cursor = i;
            return Outcome::Changed;
        }
    }
    Outcome::Ignored
}
}
fn main() {}

Clearing and rebuilding the list each frame is deliberate. Scrolled-away rows lose their rectangles, so a click cannot select a row that is not on screen, and there is no cache to invalidate.

The two surprising behaviours

A widget that has never rendered ignores the mouse. Its rectangle is still Rect::ZERO. This shows up in tests that feed events to a fresh state, and in a widget hidden behind a tab that was never drawn. Render once, then click.

A widget hidden behind an overlay still gets the click unless you stop dispatching. The broadcast pattern offers the event to everyone, and a table under a dropdown does not know the dropdown is there. Handle overlays first and return early when they consume the event; the events chapter shows the shape.

The helpers

These are free functions in core, re-exported by the prelude:

FunctionReturns
mouse_pos(m)The event’s Position
mouse_in(area, m)Whether the event is inside area
is_left_down(m) / is_left_up(m) / is_left_drag(m)Left button press, release, drag
is_move(m)A bare pointer move
wheel_delta(m)Some(1) for wheel down, Some(-1) for wheel up, None otherwise

Wheel events are not routed by focus. The widget under the pointer scrolls, which is why scrollable widgets test mouse_in before acting on a wheel delta.

Mouse capture and its cost

run enables mouse capture by default. While it is on, the terminal sends events to your app instead of handling them itself, so the user loses click-to-select-text and middle-click paste inside your app. Most users know to hold Shift to get the terminal’s own selection back, but if your app is mostly text and rarely interactive, consider turning capture off:

extern crate tuile;
use tuile::prelude::*;
struct MyApp;
impl App for MyApp {
    fn draw(&mut self, _f: &mut Frame, _n: Instant) {}
    fn event(&mut self, _e: Event, _n: Instant) -> Flow { Flow::Continue }
}
fn main() -> std::io::Result<()> {
run_with(&mut MyApp, RunOptions { mouse: false, ..Default::default() })
}

With capture off, every widget still works from the keyboard. Nothing in the library requires a mouse.

Terminal differences

Drag events need a terminal that reports motion while a button is held. Most modern ones do (kitty, foot, WezTerm, Alacritty, iTerm2, Windows Terminal). Where drag is missing, the widgets that use it degrade rather than break: a split pane can still be resized from the keyboard, and a slider still responds to a plain click on the track.

The app loop

run(&mut app) is a plain loop over crossterm events. There is no executor, no reactor and no background thread. Understanding what it does per iteration is enough to predict your app’s CPU use and to decide whether you want it at all.

What run does

enter raw mode + alternate screen, install the panic hook
enable mouse capture       unless RunOptions { mouse: false }

loop {
    app.update(now)        your per-frame bookkeeping
    terminal.draw(..)      calls app.draw(frame, now)
    wait for an event      timeout depends on app.animating(now)
    app.event(ev, now)     for each event that arrived; Flow::Quit breaks
}

disable mouse capture
leave the alternate screen, restore the terminal mode

The wait is the interesting part. When animating() returns true the timeout is 1/fps, so the loop redraws 60 times a second. When it returns false the timeout is idle_redraw (250 ms by default), enough for a clock or a blinking cursor without burning a core. With idle_redraw: None the loop blocks until the user does something.

RunOptions

extern crate tuile;
use tuile::prelude::*;
struct MyApp;
impl App for MyApp {
    fn draw(&mut self, _f: &mut Frame, _n: Instant) {}
    fn event(&mut self, _e: Event, _n: Instant) -> Flow { Flow::Continue }
}
fn main() -> std::io::Result<()> {
run_with(&mut MyApp, RunOptions {
    mouse: true,
    fps: 60,
    idle_redraw: Some(std::time::Duration::from_millis(250)),
})
}

Lower fps if you are running over a slow SSH link: 30 looks fine for most tweens and halves the bytes written. Raising it above 60 gains nothing; terminals do not repaint faster.

animating: the one method to get right

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct MyApp { dark: SwitchState, save: ButtonState, toasts: Toaster }
impl MyApp {
fn animating(&self, now: Instant) -> bool {
    self.dark.animating(now) || self.save.animating(now) || self.toasts.animating(now)
}
}
fn main() {}

Every widget that can move has an animating(now) method that reports whether it is still moving. Or them together. Forget one and its animation freezes halfway until the next keystroke; return true unconditionally and an idle screen costs a core.

Widgets that loop forever (spinners, marquees, shimmer skeletons) animate for as long as they are on screen. If your app shows one permanently, it will redraw at fps permanently, which is correct and is what the gallery does. Hide the spinner when there is nothing to wait for.

update

update(now) runs before every draw, including idle redraws. It is where time-driven state that is not a tween belongs:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct MyApp { toasts: Toaster }
impl MyApp {
fn update(&mut self, now: Instant) {
    self.toasts.tick(now);          // expire toasts whose timeout elapsed
}
}
fn main() {}

It is also where you drain results from a worker thread. tuile has no opinion about concurrency: spawn a thread, send over a channel, and pick up whatever arrived in update. Set a flag so animating stays true while a request is in flight if you are showing a spinner.

Not using the runtime

Nothing in the library requires App. Every widget is render(area, buf, &mut state), so any loop that can produce a Buffer works, including an existing ratatui app:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(frame: &mut Frame, table: &mut DataTableState) {
// inside your own terminal.draw(|frame| { .. })
let area = frame.area();
let buf = frame.buffer_mut();
let cols = vec![TableColumn::new("Host"), TableColumn::new("Status")];
let rows = vec![TableRow::from(vec!["web-01", "up"])];
DataTable::new(cols, rows).render(area, buf, table);
}
fn main() {}

Two things the runtime does that you then own: passing a consistent Instant to every animated widget in a frame (use one Instant::now() at the top of your draw, not one per widget), and restoring the terminal when your app panics.

Panic safety

run installs a panic hook that restores the terminal before the message prints. Without it, a panic inside raw mode leaves the user typing into a shell that echoes nothing, and with mouse capture still on the terminal keeps emitting escape sequences on every mouse move. The hook undoes mouse capture, raw mode and the alternate screen, in that order.

If you build your own loop, install an equivalent hook. ratatui::init() and ratatui::restore() cover raw mode and the alternate screen but know nothing about mouse capture, so disable that yourself.

Quitting

Return Flow::Quit from event. The loop breaks, mouse capture is disabled, the alternate screen is left and run returns Ok(()). There is no separate teardown hook: run your own cleanup after run returns.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct MyApp;
impl App for MyApp {
    fn draw(&mut self, _f: &mut Frame, _n: Instant) {}
fn event(&mut self, ev: Event, _now: Instant) -> Flow {
    match ev {
        Event::Key(k) if ctrl(&k, 'c') => Flow::Quit,
        Event::Key(k) if k.code == KeyCode::Char('q') => Flow::Quit,
        _ => Flow::Continue,
    }
}
}
fn main() -> std::io::Result<()> {
    let result = run(&mut MyApp);
    println!("goodbye");        // prints to the restored terminal
    result
}

Animation

Animation in tuile is a pure function of time. Nothing runs on a timer, nothing mutates in the background. A widget is handed an Instant and computes where it should be at that instant. That single decision is why animated widgets are testable and why screenshots are reproducible.

Two clocks exist, and picking the right one is the whole API.

One-shot animations: Tween

A Tween interpolates a f32 from where it is now to a target over a duration. Widgets use it for switch thumbs, progress bars, sliding underlines and expanding panels.

extern crate tuile;
use tuile::prelude::*;
use std::time::Duration;
fn main() {
let now = Instant::now();
let mut t = Tween::new(0.0);

t.go(1.0, now, Duration::from_millis(180));
assert_eq!(t.target(), 1.0);
assert!(t.active(now));

let done = now + Duration::from_millis(200);
assert_eq!(t.value(done), 1.0);
assert!(!t.active(done));
}

go starts from the current value, not from the previous start value. Interrupting a tween halfway therefore never jumps: flicking a switch back and forth reverses smoothly from wherever the thumb happens to be.

MethodEffect
new(v) / fixed(v)A tween parked at v
with_easing(e)Builder-style easing, default InOutCubic
go(to, now, dur)Retarget from the current value; dur of zero jumps
go_with(to, now, dur, easing)Retarget and change the curve for this leg
set(v)Jump with no animation
value(now)Where it is at now
target()Where it is heading
active(now)Still moving
progress(now)0 to 1 for the current leg

Eight easing curves: Linear, InOutCubic (default), OutCubic, InCubic, InOutSine, OutBack, OutBounce, OutElastic. OutBack overshoots slightly and settles, which suits a small element appearing; OutElastic overshoots a lot and is worth using once per app at most.

Looping animations: the shared epoch

Spinners, shimmers, marquees and pulses do not have a start and an end. They sample a process-wide epoch, so every looping animation on screen stays in phase without your app tracking a start time:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Spinner::new(&spinners::DOTS).now(now).render(area, buf);
}
fn main() {}

anim::since(now) is the seconds elapsed since anim::EPOCH, which is fixed the first time it is read. Three helpers sample it:

FunctionReturns
pulse(elapsed, period)A smooth 0 to 1 to 0 oscillation
blink(elapsed, period)true for the first half of each period
frame_index(elapsed, fps, frames)The frame to show in a looping sequence

now versus elapsed

Animated builders take one of two calls, and the difference matters:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Spinner::new(&spinners::DOTS).now(now).render(area, buf);        // phase from the epoch
Spinner::new(&spinners::DOTS).elapsed(1.25).render(area, buf);   // exactly 1.25s in
}
fn main() {}

.now(instant) is what an app passes. .elapsed(secs) pins the animation to an exact point and is what tests and the screenshot tool use, because it makes rendering deterministic: the same elapsed always produces the same cells.

If you take a screenshot of an animated widget without pinning it, the image changes between runs and any test comparing rendered output will flake.

Telling the runtime you are moving

The loop only redraws at 60 fps while App::animating returns true:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct MyApp { switch: SwitchState, progress: ProgressState }
impl MyApp {
fn animating(&self, now: Instant) -> bool {
    self.switch.animating(now) || self.progress.animating(now)
}
}
fn main() {}

Every state that can move answers animating(now). The app loop chapter covers what happens when you get this wrong in either direction.

Reduced motion

Some users cannot tolerate movement, and some terminals over a slow link cannot keep up with it. The library has no global switch for this; the honest answer is that you implement it by choosing not to animate. In practice that means passing a fixed elapsed, or skipping the .now(..) call so the widget renders its resting state, and setting durations to zero so tweens jump.

The showcase does this on F3 and its page code is a working reference for the pattern.

One instant per frame

Sample Instant::now() once per frame and pass the same value everywhere. Calling it per widget means the widgets in a frame disagree about the time by microseconds, which is invisible for a tween but makes multi-part animations (a row of bars, a wave) shear.

The App::draw signature hands you the frame instant for exactly this reason.

Overlays

A dropdown list, a context menu, a tooltip and a modal all have the same problem: they belong to a widget somewhere in the middle of your layout, but they must paint over everything drawn after it. A terminal buffer has no z-index. The only ordering is the order you write cells.

tuile solves this two ways, and which one you need depends on who owns the overlay.

The simple case: draw it last

If your app owns the overlay, render it after everything else. Toasts, modals and the command palette work this way:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Page { table: DataTableState, toasts: Toaster, modal: ModalState }
impl Page {
fn draw(&mut self, area: Rect, buf: &mut Buffer, now: Instant) {
    let cols = vec![TableColumn::new("Host"), TableColumn::new("Status")];
    let rows = vec![TableRow::from(vec!["web-01", "up"])];
    DataTable::new(cols, rows).render(area, buf, &mut self.table);

    // after the page content, so they paint on top
    Modal::confirm("Delete host?", "This cannot be undone.").render(area, buf, &mut self.modal);
    ToastStack::new().now(now).render(area, buf, &mut self.toasts);
}
}
fn main() {}

Ordering the draw calls is the whole mechanism. The overlay widgets take the full area and place themselves inside it.

The harder case: a widget owns its own popup

A Select renders a field wherever your layout put it, and its dropdown must appear next to that field and over whatever follows. The widget cannot draw the list immediately, because the rest of your layout has not been drawn yet.

Overlay is a queue of deferred draw closures for this:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer) {
let mut overlay = Overlay::new();

overlay.push(|buf: &mut Buffer| {
    // runs after everything else has been drawn
    fill(buf, Rect::new(4, 6, 20, 5), Rgb::hex(0x1e1e2e));
});

// ... draw the rest of the page ...

overlay.draw(buf);      // drain the queue last
let _ = area;
}
fn main() {}

push takes any FnOnce(&mut Buffer), is_empty() asks whether anything is queued, and draw(self) consumes the queue in push order. The closures borrow for the lifetime of the overlay, so they can capture the widget state and the theme without copying.

Placing a popup

popup_below does the geometry every dropdown needs: prefer below the anchor, flip above when there is no room, and slide horizontally to stay inside the bounds.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
let screen = Rect::new(0, 0, 80, 24);
let field = Rect::new(10, 20, 20, 3);

let list = popup_below(field, 20, 8, screen);
assert!(list.bottom() <= screen.bottom());   // flipped above, it did not overflow
}

It never returns a rectangle outside bounds, so a dropdown at the bottom of the screen opens upward and one at the right edge shifts left.

Events go the other way

Drawing is back to front. Event handling is front to back. The widget on top must see the click first, or the thing underneath will react to a click it never received visually:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Page { modal: ModalState, menu: ContextMenuState, table: DataTableState }
impl Page {
fn event(&mut self, ev: &Event) -> Outcome {
    // topmost first, and stop as soon as one consumes
    let out = self.modal.handle(ev);
    if out.is_consumed() {
        return out;
    }
    let out = self.menu.handle(ev);
    if out.is_consumed() {
        return out;
    }
    self.table.handle(ev)
}
}
fn main() {}

This is the most common source of “why did my table scroll while a dialog was open”. The broadcast pattern from the events chapter is correct only for widgets that do not overlap.

A modal that is closed consumes nothing, so the early return costs one comparison per event when no dialog is open.

Escape and click-outside

Users expect Esc to close the topmost overlay and a click outside it to dismiss it. Widgets that own a popup handle both internally: Select closes its dropdown on Esc and on a click that lands outside the list. For app-owned overlays you write the rule, and the order above is what makes it work: the modal sees Esc first and consumes it, so the table never treats it as a filter reset.

When several overlays are open at once, Esc should close one layer per press. Handling them in topmost-first order with an early return gives that for free.

Cost

An overlay closure is one boxed allocation per popup per frame, drained immediately. A screen with a dropdown open allocates once more than the same screen with it closed. The queue is empty in the common case and is_empty() is what widgets check before doing any work.

Terminal capabilities

tuile resolves the terminal’s colour depth and Unicode level once, then applies them to every colour and every border, so a 256-colour terminal, NO_COLOR, or a font without box drawing all get something readable instead of whatever the palette happened to produce.

The default is deterministic truecolour

The library defaults to truecolour RGB and full Unicode. Nothing probes the environment unless you explicitly call term::probe_env() or runtime::run() (which calls it as part of terminal setup).

This is deliberate. A consumer rendering into a Buffer for a test or an image gets deterministic output. If capability detection happened implicitly, every colour assertion would read “default” when run in a CI agent shell that exports NO_COLOR=1.

Automatic detection

runtime::run() calls term::probe_env() as the first step of terminal setup. It reads:

  • NO_COLOR (any value) → attributes only, no colour
  • COLORTERM=truecolor or COLORTERM=24bit → 24-bit RGB
  • TERM ending in -256color → 256-colour palette
  • Otherwise → 16-colour ANSI palette

probe_env only decides colour; the Unicode level stays Full unless you set it, because no environment variable reports whether the user’s font has box drawing.

Explicit override

Call term::set_caps() to override detection:

extern crate tuile;
use tuile::prelude::*;
fn main() {

// Force a colour depth (for app settings or tests)
term::set_caps(TermCaps {
    color: ColorDepth::Ansi256,
    unicode: UnicodeLevel::Full,
});

// Or disable colour entirely
term::set_caps(TermCaps {
    color: ColorDepth::None,
    unicode: UnicodeLevel::Full,
});
}

How it works

Colour resolution

Every Rgb → Color conversion consults term::caps().color:

  • ColorDepth::TrueColor → Color::Rgb(r, g, b) (the default)
  • ColorDepth::Ansi256 → Color::Indexed(16..=255) (6×6×6 cube + 24 greys)
  • ColorDepth::Ansi16 → Color::Indexed(0..=15) (nearest of the 16 ANSI colours)
  • ColorDepth::None → Color::Reset (attributes only: bold, dim, italic, underline)

No widget code changes. Every widget calls Rgb::color() to resolve theme colours; that one hook covers the entire library.

Glyph fallback

With term::caps().unicode set to UnicodeLevel::Ascii, draw::Border swaps its box-drawing glyphs (┌─┐│└┘) for + - |. That is the case that breaks a whole screen at once: a font without box drawing turns every panel into mojibake. Widget glyphs outside the border tables (■, ●, ✓, the sparkline and big-text blocks) are still Unicode-only.

The NO_COLOR trap

Many CI environments and agent shells export NO_COLOR=1. A test that probes the environment and then asserts on a colour is asserting on Color::Reset — it passes while checking nothing. This is why probing is opt-in, and there are two ways to stay honest in a suite that does probe:

  1. Pin the depth in the test:

    #[test]
    fn widget_renders_red() {
        term::set_caps(TermCaps {
            color: ColorDepth::TrueColor,
            unicode: UnicodeLevel::Full,
        });
        // ... now colour assertions work
    }
  2. Assert on the resolved Color in the buffer, not on the source Rgb. The buffer holds what actually rendered, capability resolution included.

Performance

term::caps() is one relaxed atomic load: no environment access, no lock, no allocation. It sits on the per-cell style path, inside every Rgb::color() call.

The colour system

You give tuile up to ten colours. It derives about thirty from them. That derivation is the design system, and it is a port of Textual’s ColorSystem.

The point is that a widget never asks for “grey 40”. It asks for text_muted, and the theme decides what that means in a dark terminal versus a light one. Swap the palette and every widget follows, including widgets written after the palette was chosen.

The base palette

A ThemeSpec has one required colour and nine optional ones:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let spec = ThemeSpec::new("ocean", true, Rgb::hex(0x0178D4))   // name, dark, primary
    .secondary(Rgb::hex(0x004578))
    .accent(Rgb::hex(0xffa62b))
    .background(Rgb::hex(0x1e1e2e));
let theme = spec.resolve();
assert_eq!(theme.name, "ocean");
}
FieldRequiredDefault when omitted
nameyes
darkyes
primaryyes
secondarynofalls back to primary
warning, error, success, accentnofall back to primary
backgroundnonear-black for a dark theme, near-white for a light one
foregroundnothe inverse of the background
surfacenoderived from the background
panelnosurface blended 10% toward primary, lifted slightly in dark themes

A one-colour theme is legal and looks coherent, because everything unstated is derived from primary and the background. It is also monotonous: three or four well-chosen colours is the sweet spot.

What gets derived

Theme::resolve(&spec, primary_override) computes the full role set. Four mechanisms do all the work.

CIE-Lab lightness steps. Rgb::shade(n) moves L* by 15% per step, so shade(1) is one step lighter and shade(-2) two steps darker. Doing this in Lab rather than RGB keeps hue and saturation stable: darkening a blue in RGB drifts it toward black-grey, in Lab it stays blue.

Contrast-aware text. Rgb::text_on(alpha) picks white or black by the background’s brightness, then composites it at alpha. That is where the text ramp comes from:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let bg = Rgb::hex(0x1e1e2e);
let _text = bg.text_on(0.87);           // primary text
let _muted = bg.text_on(0.60);          // secondary text
let _disabled = bg.text_on(0.38);       // disabled text
}

Those three alphas are fixed in the library, matching Textual’s. A light theme gets dark text from the same code, with no branch in any widget.

Blending toward an accent. Interaction roles are the surface mixed with something: hover_bg is the surface 8% toward the contrast colour, selection_bg is the surface 50% toward primary, cursor_blurred_bg is 30% toward primary. Tinting rather than replacing is what keeps a selected row readable in every palette.

Semantic text colours. text_primary, text_success, text_error and friends are the contrast colour blended 66% toward the matching base colour. They are readable on the background, unlike the raw error red, which is chosen for fills.

Reading a theme

Theme is Copy, so hold it in a local for the frame:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer) {
let th = theme::current();
fill(buf, area, th.background);
put(buf, area.x, area.y, "Ready", area.width, st(th.text_success, th.background));
}
fn main() {}

st(fg, bg) builds a ratatui Style from two Rgb values. The theme roles table lists every field with what it is derived from and what it is for.

Variants

Six semantic variants map to base colours through th.variant(v):

extern crate tuile;
use tuile::prelude::*;
fn main() {
let th = theme::current();
assert_eq!(th.variant(Variant::Success), th.success);
assert_eq!(th.variant(Variant::Default), th.surface);
}

Default resolves to the surface colour, which is deliberate: a default button is a filled neutral shape, not a coloured one. Widgets that need a visible colour for Default (an outline button, whose surface-coloured border would be invisible) substitute a text colour instead.

Switching at runtime

extern crate tuile;
use tuile::prelude::*;
fn main() {
theme::set_by_name("nord");                   // false if the name is unknown
let names: Vec<&str> = theme::theme_names();  // every built-in
assert!(names.contains(&"nord"));

theme::set(Theme::resolve(&ThemeSpec::new("mine", true, Rgb::hex(0x7c3aed)), None));
theme::set_by_name("textual-dark");
}

theme::current() is the process-wide fallback every widget reads when no .theme(..) was passed. It is behind a lock, read once per widget per frame. Two globals exist in the whole library and this is one of them; the other is the animation epoch.

Contrast is derived, not checked

The system produces readable text for any reasonable palette because the text roles are computed from the background. It does not measure the result against a contrast standard such as WCAG. A palette with a mid-grey background and a mid-grey primary will produce legal but uncomfortable output, and nothing in the library will warn you. Look at the Themes page of the showcase with your palette loaded before shipping it.

Built-in palettes

Twelve palettes ship with the crate. Nine are dark, three are light.

NameModePrimaryNotes
textual-darkdark#0178D4The default. Textual’s own dark palette
textual-lightlight#004578Textual’s light palette
norddark#88C0D0Cool blue-grey, low contrast
gruvboxdark#85A598Warm, muted, high legibility
catppuccin-mochadark#F5C2E7Pastel on a soft dark background
catppuccin-lattelight#8839EFThe light member of the same family
draculadark#BD93F9Purple on near-black
tokyo-nightdark#BB9AF7Deep blue background, violet accents
monokaidark#AE81FFThe classic editor palette
flexokidark#205EA6Paper-inspired, ink-like text
solarized-lightlight#268bd2Solarized’s light half
rose-pinedark#c4a7e7Muted mauve and pine

Using one

extern crate tuile;
use tuile::prelude::*;
fn main() {
theme::set_by_name("nord");                  // returns false for an unknown name
theme::set_by_name("textual-dark");
}

Set it once at startup, before the first frame. Widgets read theme::current() at render time, so changing it mid-run takes effect on the next redraw with no invalidation step.

Getting a palette without installing it globally:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let gruvbox: Theme = theme::builtin("gruvbox").expect("gruvbox ships with the crate");
assert_eq!(gruvbox.name, "gruvbox");
}

theme::builtin(name) resolves the palette for you and returns the finished Theme. The unresolved specs are in theme::BUILTIN, which is what you want when a built-in palette is the starting point for a theme of your own.

Listing them

extern crate tuile;
use tuile::prelude::*;
fn main() {
for name in theme::theme_names() {
    println!("{name}");
}
}

This is what a theme picker in your app should iterate, rather than a hard-coded list that goes stale when the crate adds a palette. The showcase’s Ctrl-T cycle and its Themes page both use it.

Dark and light

spec.dark is a declaration, not a computation. It tells the resolver which direction to derive in: dark themes lift the panel colour slightly toward the contrast colour, light themes do not. The text ramp itself keys off the background’s brightness, so a light palette gets dark text automatically.

If you build a light theme and mark it dark = true, the text will still be readable because the ramp measures the background. The panel and boost surfaces will be subtly wrong.

Overriding one colour

Theme::resolve takes an optional primary override, which re-derives everything that depends on primary while keeping the rest of the palette:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let nord = theme::BUILTIN.iter().find(|s| s.name == "nord").unwrap();
let pink = Theme::resolve(nord, Some(Rgb::hex(0xff79c6)));
assert_ne!(pink.border, theme::builtin("nord").unwrap().border);
}

Borders, cursors, selections and the scrollbar all follow, because each is derived from primary. This is the hue slider on the showcase’s Themes page.

For anything more than the primary, build a spec of your own; the next chapter covers it.

Terminal caveats

Every palette is true colour (24-bit). In a terminal limited to 256 colours, ratatui’s backend maps each colour to the nearest palette entry, which flattens the small lightness steps the design system relies on: a hover row and an unhovered row can land on the same index. The library does not detect this and has no 256-colour palette of its own.

Terminals with a minimum-contrast setting (iTerm2 and some others) adjust foreground colours that sit too close to their background. That is why the library paints filled areas as background colours with space characters rather than as foreground block glyphs; a block glyph gets recoloured by that feature and a painted background does not.

Your own theme

A theme is a ThemeSpec you resolve. The minimum is a name, a mode and one colour:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let mine = ThemeSpec::new("acme", true, Rgb::hex(0x7c3aed)).resolve();
theme::set(mine);
theme::set_by_name("textual-dark");
}

Everything else is derived. That is usually too uniform for a real product, so add the colours that carry meaning in your domain and leave the rest:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let spec = ThemeSpec::new("acme", true, Rgb::hex(0x7c3aed))
    .secondary(Rgb::hex(0x2dd4bf))
    .accent(Rgb::hex(0xfbbf24))
    .success(Rgb::hex(0x22c55e))
    .warning(Rgb::hex(0xf59e0b))
    .error(Rgb::hex(0xef4444))
    .background(Rgb::hex(0x0f0f17))
    .surface(Rgb::hex(0x1a1a24));
theme::set(spec.resolve());
theme::set_by_name("textual-dark");
}

Every builder method is const, so a theme can be a constant:

extern crate tuile;
use tuile::prelude::*;
const ACME: ThemeSpec = ThemeSpec::new("acme", true, Rgb::hex(0x7c3aed))
    .accent(Rgb::hex(0xfbbf24));
fn main() { let _ = ACME.resolve(); }

Colour construction

extern crate tuile;
use tuile::prelude::*;
fn main() {
let a = Rgb::hex(0x7c3aed);
let b = Rgb(124, 58, 237);
assert_eq!(a, b);

let lighter = a.shade(1);        // +15% L* in CIE-Lab
let darker = a.shade(-2);        // -30%
let mixed = a.blend(b, 0.5);     // linear interpolation
let on_top = a.text_on(0.87);    // readable text for this background
let _ = (lighter, darker, mixed, on_top);
}

Rgb::hex takes the same 0xRRGGBB you would paste from a design tool. There is no CSS string parser, and no named colours.

Starting from a built-in

Copy a palette and change what you need:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let mut spec = *theme::BUILTIN.iter().find(|s| s.name == "gruvbox").expect("ships with the crate");
spec.name = "gruvbox-hot";
spec.error = Some(Rgb::hex(0xff0033));
theme::set(spec.resolve());
theme::set_by_name("textual-dark");
}

ThemeSpec is Copy with public fields, so this needs no builder call at all.

Per-widget themes

Every builder takes .theme(&Theme), which overrides the global for that widget:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, st: &mut ButtonState) {
let danger = ThemeSpec::new("danger", true, Rgb::hex(0xef4444)).resolve();
Button::new("Delete").theme(&danger).render(area, buf, st);
}
fn main() {}

Resolve the theme once and keep it in your app struct. Resolving it every frame repeats the whole Lab expansion for no benefit.

A per-widget theme is the right tool for a panel that must stand apart, a preview pane showing another palette, or a diff view with its own colours. For anything larger, set the global theme.

Letting users pick

Offer theme::theme_names() in a select and call set_by_name on change:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Prefs { picker: SelectState }
impl Prefs {
fn on_theme_picked(&mut self, name: &str) {
    if !theme::set_by_name(name) {
        // unknown name, keep the current theme
    }
}
}
fn main() {}

set_by_name returns false rather than panicking or falling back silently, so a stale name in a config file leaves the user with the previous theme instead of a surprise.

To persist a custom theme, store the spec’s ten colours, not the resolved theme. The resolution rules can change between versions; the palette you chose will not.

What you cannot theme

The derivation is fixed. You choose the base colours; you do not choose that text_muted is 60% alpha, or that hover is an 8% tint. Widgets read named roles, so changing a role means changing every widget that reads it.

If you need one role to differ, build a Theme by resolving a spec and then assigning the field directly. Theme is a plain struct with public fields:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let mut th = ThemeSpec::new("acme", true, Rgb::hex(0x7c3aed)).resolve();
th.hover_bg = Rgb::hex(0x2a2a3a);       // stronger hover than the 8% default
theme::set(th);
theme::set_by_name("textual-dark");
}

That is supported and occasionally the right answer. Be aware you are stepping outside the derivation, so the override will not follow when you change the base palette.

Controls

Buttons, toggles and value pickers. This is the family to read first: every widget here follows the builder-plus-state shape in its simplest form, so the patterns transfer to the rest of the library.

Button

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = ButtonState::new();

Button::new("Deploy")
    .variant(Variant::Primary)
    .focused(true)
    .now(now)
    .render(area, buf, &mut state);
}
fn main() {}

A button fires on Enter, Space, or a click that presses and releases inside it. All three return Outcome::Changed, which is the signal to run the action. A press that slides off the button before release returns Consumed, not Changed, so a cancelled click does nothing.

ButtonState holds only the hit rectangle and the instant of the last press, which drives a 120 ms flash. Pass .now(now) if you want that flash; without it the button still works and simply does not animate. state.animating(now) reports whether the flash is still running.

MethodEffect
variant(Variant)Semantic colour: Default, Primary, Secondary, Accent, Success, Warning, Error
style(ButtonStyle)Default (3D), Flat, Outline, Ghost
compact(bool)One row instead of three
icon(&str)A glyph before the label
min_width(u16)Pad short labels so a row of buttons lines up
full_width(bool)Fill the area’s width
enabled(bool)Disabled buttons ignore input and render muted

ButtonGroup::layout(area, n, gap) splits an area into n equal button rectangles, which saves writing the same Layout::horizontal every time.

Four styles exist because they carry different weight. Default is the raised look for the primary action on a screen. Flat is the same shape without the 3D edge. Outline is a border and a label, for secondary actions. Ghost is a label alone until hovered, for actions that should not compete for attention.

Checkbox

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = CheckboxState::new(CheckState::On);

Checkbox::new("Send crash reports")
    .style(CheckStyle::Check)
    .focused(true)
    .render(area, buf, &mut state);

if state.value == CheckState::On { /* ... */ }
}
fn main() {}

state.value is a CheckState, not a bool: Off, On or Indeterminate. The third value is for a parent checkbox summarising children that disagree. A plain checkbox never enters it on its own; call .tri_state(true) to let Space cycle through all three, or set it yourself with state.set_tri_state(..).

Six marker styles: Pill, Bracket ([x]), Box (☑), Circle (●), Check (✓), Square (■). .label_first(true) puts the marker on the right, which suits a settings list where the labels are the column being scanned.

Switch

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = SwitchState::new(true);

Switch::new()
    .label("Dark mode")
    .style(SwitchStyle::Pill)
    .now(now)
    .render(area, buf, &mut state);

let on: bool = state.on;
let _ = on;
}
fn main() {}

A switch is a boolean with a sliding thumb. state.on is the value; state.anim is the tween driving the thumb; state.animating(now) tells the runtime to keep redrawing while it slides.

Styles: Pill, Slim, Line, Round, Text (ON and OFF), Check (✓ and ✗). .duration(Duration) changes the slide time from its default; zero makes it snap, which is what a reduced-motion mode wants.

Use a switch when the change takes effect immediately, and a checkbox when it takes effect on submit. That is a convention rather than a rule the library enforces, but it is the one users read.

RadioGroup

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = RadioState::new(Some(0));

RadioGroup::new(vec!["Small".into(), "Medium".into(), "Large".into()])
    .title("Size")
    .style(RadioStyle::Dot)
    .focused(true)
    .render(area, buf, &mut state);

let chosen: Option<usize> = state.selected;
let _ = chosen;
}
fn main() {}

state.selected is the chosen index, None until something is picked, and state.cursor is where the keyboard cursor sits. They differ while the user is moving with the arrow keys before committing with Space or Enter. A click sets both.

.horizontal(true) lays the options in a row, .bordered(true) draws a frame, .title(..) labels it. Four marker styles: Dot, Bracket ((•)), Check, Arrow (❯).

CheckList

A multi-select list of checkboxes with a cursor, a scroll offset and its own scrollbar:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = CheckListState::default();

CheckList::new(vec!["Logs".into(), "Metrics".into(), "Traces".into()])
    .focused(true)
    .now(now)
    .render(area, buf, &mut state);
}
fn main() {}

The checked set is sized to the option count on the first render, so a state built with default() is safe to hand to a list of any length. The scrollbar appears only when the options do not fit.

Segmented

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = SegmentedState::new(1);

Segmented::new(vec!["Day".into(), "Week".into(), "Month".into()])
    .style(SegmentedStyle::Filled)
    .focused(true)
    .render(area, buf, &mut state);
}
fn main() {}

One row, one choice, all options visible. Styles: Filled, Outline, Underline, Text. Prefer it over a RadioGroup when there are two to four short options and vertical space is tight; prefer the radio group when the options need descriptions.

Slider and RangeSlider

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = SliderState::new(42.0, 0.0, 100.0, 1.0);

Slider::new()
    .label("Volume")
    .show_value(true)
    .ticks(true)
    .now(now)
    .render(area, buf, &mut state);

let v: f32 = state.value;
let _ = v;
}
fn main() {}

SliderState carries value, min, max and step as public fields, so clamping ranges live with the value rather than in the builder. state.set(v) clamps and snaps to the step, state.adjust(delta) nudges, and state.value_at(x) converts a screen column to a value, which is what dragging uses.

Arrow keys step, Home and End jump to the ends, a click on the track jumps to that value and a drag follows the pointer. .format(..) controls how the value is printed; .shape(FieldShape) adds a frame, which is off by default so a slider can sit inline in a settings row.

RangeSlider is the two-handle version over a RangeState, for a low and high bound.

Stepper

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = StepperState::new(1, 1, 10, 1);

Stepper::new().focused(true).render(area, buf, &mut state);

state.increment();
state.decrement();
}
fn main() {}

An integer with a minus and a plus button. It keeps a separate hit rectangle for each button, so a click lands on the right one. Use it for small bounded counts where a text field would invite invalid input.

Rating

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = RatingState::new(3);

Rating::new().max(5).allow_clear(true).focused(true).render(area, buf, &mut state);
}
fn main() {}

state.value is the committed rating and state.hover_value is the preview under the pointer, so hovering the fourth star previews four without committing. .allow_clear(true) lets a click on the current value reset it to zero.

Each star takes two cells. The star glyph is ambiguous-width, which means terminals disagree about whether it is one cell or two, and the widget reserves two so the row cannot skew. Budget max * 2 columns.

Choosing between them

You needUse
Run an actionButton
A setting that applies immediatelySwitch
A setting that applies on submitCheckbox
One of several, with room to explain eachRadioGroup
One of two to four short options, in one rowSegmented
Several of manyCheckList
A continuous valueSlider
A small bounded integerStepper
A one to five scoreRating

Text entry

Single-line input, multi-line textarea, dropdown select, filterable combobox, and multi-select with checkboxes.

Input

A single-line text field with cursor, horizontal scrolling, selection, history, and optional validation or autocomplete.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = InputState::new();
Input::new()
    .placeholder("Name")
    .focused(true)
    .render(area, buf, &mut state);

if state.take_submitted() {
    let value = state.value();
}
}
fn main() {}

State

InputState owns:

FieldTypePurpose
valueStringCurrent text content
cursorusizeGrapheme index of the cursor
scrollusizeHorizontal scroll offset
selectionOption<(usize, usize)>Selection anchor and cursor (if active)
submittedboolTrue when Enter was pressed; read via take_submitted()
errorOption<String>Validation error message (if validator is set)

Create state with InputState::new() or InputState::with_value("text"). Read the value with state.value(), set it with state.set_value("text"), or select all with state.select_all().

Keys

KeyAction
Left / RightMove cursor one grapheme
Ctrl-Left / Ctrl-RightMove to word boundary
Home / EndJump to line start or end
Ctrl-ASelect all
Shift-Left / Shift-RightExtend selection
BackspaceDelete character before cursor (or selection)
DeleteDelete character at cursor
Ctrl-BackspaceDelete word before cursor
Ctrl-UDelete from cursor to start
Ctrl-KDelete from cursor to end
Ctrl-WDelete word before cursor
Ctrl-XCut selection to clipboard
Ctrl-CCopy selection to clipboard
Ctrl-VPaste from clipboard
Ctrl-ZUndo (restores previous history entry)
TabAccept autocomplete suggestion (if suggester is set)
EnterSubmit (sets submitted flag)
Right at endAccept suggestion if cursor is at end of line

Mouse

Click positions the cursor. Drag selects text. The widget tracks double-click timing but full text selection on double-click depends on the render implementation.

Options

MethodEffect
placeholder(s)Placeholder text shown when empty
password(true)Mask input with dots
max_len(n)Limit input to n graphemes
restrict(r)Filter input: Digits, Integer, Number, Alpha, Alnum, Custom, or None
validator(fn)Validation function fn(&str) -> Result<(), String>; errors populate state.error
suggester(fn)Autocomplete function fn(&str) -> Option<String> returning suffix to append
prefix(s)Static prefix (e.g. $ for currency)
suffix(s)Static suffix (e.g. ms for time)
compact(true)Single-line compact mode without tall frame
shape(s)Frame shape (see FieldShape section)
tab_accepts(true)Let Tab accept suggestions (default: Tab is navigation)
show_error(true)Display validation error below the field
select_on_focus(true)Select all text when focused
align(a)Text alignment: Left, Center, Right

The restrict option filters typed characters. Digits allows only 0-9. Integer allows digits and leading -. Number allows digits, ., -, +, e, E. Alpha allows letters. Alnum allows letters and digits. Custom uses a custom filter function passed to state.custom_filter.

TextArea

A multi-line text editor with line numbers, syntax highlighting hook, undo/redo, and scrolling.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = TextAreaState::new();
TextArea::new()
    .line_numbers(true)
    .highlight_line(true)
    .focused(true)
    .render(area, buf, &mut state);

let text = state.text();
}
fn main() {}

State

TextAreaState owns:

FieldTypePurpose
linesVec<String>Lines of text content
cursor(usize, usize)Row and column (grapheme indices)
scroll_yusizeVertical scroll offset
scroll_xusizeHorizontal scroll offset
selectionOption<(usize, usize, usize, usize)>Selection rectangle (row0, col0, row1, col1)
vscroll_state / hscroll_stateScrollbarStateScrollbar positions

Create state with TextAreaState::new() or TextAreaState::with_text("multi\nline"). Read all text with state.text(), set it with state.set_text("text"), or insert at cursor with state.insert_str("text"). The undo and redo stacks store up to 100 snapshots each.

Keys

KeyAction
Up / DownMove cursor one line
Left / RightMove cursor one grapheme (wraps at line boundaries)
Ctrl-Left / Ctrl-RightMove to word boundary
Home / EndJump to line start or end
Ctrl-Home / Ctrl-EndJump to document start or end
PageUp / PageDownMove 10 lines
Ctrl-ASelect all
Ctrl-ZUndo
Ctrl-YRedo
Ctrl-DDuplicate current line
Alt-Up / Alt-DownMove current line up or down
Ctrl-XCut selection to clipboard
Ctrl-CCopy selection to clipboard
Ctrl-VPaste from clipboard
BackspaceDelete character before cursor (or selection)
DeleteDelete character at cursor
EnterInsert newline (with auto-indent)
TabInsert 4 spaces

Mouse

Click positions the cursor. Drag selects a rectangular region. Wheel scrolls vertically.

Options

MethodEffect
line_numbers(true)Show line numbers in left gutter
highlight_line(true)Highlight the current line
read_only(true)Disable editing
tab_size(n)Tab width in spaces (default 4)
max_lines(n)Limit to n lines
placeholder(s)Placeholder text shown when empty
shape(s)Frame shape (see FieldShape section)
highlighter(fn)Syntax highlighting function fn(&str) -> Vec<(usize, usize, Style)> returning (start, end, style) spans for a line
cursor(style)Cursor style: Block, Bar, Underline, or Outline
cursor_blink(true)Enable cursor blinking
cursor_when_unfocused(true)Show cursor even when not focused
show_position(true)Display Ln X, Col Y position indicator

The highlighter hook is called once per visible line with the line text. Return a list of (start_byte, end_byte, Style) tuples. The showcase page includes a simple Rust highlighter that matches keywords and strings.

Cursor styles

CursorStyle controls how the cursor is drawn:

VariantAppearance
BlockInverse cell (default)
BarThin ▏ bar in cursor color
UnderlineGlyph underlined, bold, accent foreground
OutlineCell background set to cursor_blurred_bg, foreground unchanged

Select

A dropdown with a fixed list of options. Click or press Space/Enter to open, arrow keys to navigate, Enter to choose.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = SelectState::new(&["Red", "Green", "Blue"]);
Select::new()
    .placeholder("Pick a color")
    .focused(true)
    .render(area, buf, &mut state);

if let Some(idx) = state.selected() {
    let label = state.selected_label().unwrap();
}
}
fn main() {}

State

SelectState owns:

FieldTypePurpose
optionsVec<SelectOption>List of selectable options
selectedOption<usize>Index of selected option
openboolTrue when dropdown is open
highlightusizeIndex of highlighted option in open dropdown
scrollusizeScroll offset in dropdown
dropdown_widthDropdownWidthWidth policy: Auto, Field, or Fixed(u16)

Create state with SelectState::new(&["A", "B", "C"]) or SelectState::with_options(vec). Read selection with state.selected() or state.selected_label(). Set selection with state.set_selected(Some(idx)) or clear with state.set_selected(None). Close the dropdown with state.close().

Each SelectOption has a label (displayed text), optional value (stored data), and disabled flag.

Keys

When closed:

KeyAction
Space / Enter / DownOpen dropdown

When open:

KeyAction
EscClose without selecting
EnterSelect highlighted option and close
Up / DownMove highlight (skips disabled options)
Home / EndJump to first or last option
PageUp / PageDownMove highlight by 5
Any letterTypeahead: jump to next option starting with typed prefix (resets after 800ms)

Mouse

Click the field to open or close. Click an option to select it. Wheel scrolls the dropdown list. Click outside closes the dropdown (the parent must handle this by checking state.dropdown_area and calling state.close()).

Options

MethodEffect
placeholder(s)Placeholder text when no option is selected
max_visible(n)Maximum visible options before scrolling (default 8)
allow_blank(true)Allow deselecting the current option
compact(true)Compact mode (single-line field without tall frame)
shape(s)Frame shape (see FieldShape section)

Set state.dropdown_width to control dropdown width. DropdownWidth::Auto fits the longest option. DropdownWidth::Field matches the field width. DropdownWidth::Fixed(w) uses a fixed width.

Combobox

An editable select: type to filter options, arrow keys to navigate, Enter to choose. The input buffer filters the list in real time.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = ComboboxState::new(&["Rust", "Python", "JavaScript"]);
Combobox::new()
    .placeholder("Type to filter")
    .focused(true)
    .render(area, buf, &mut state);

if let Some(idx) = state.selected() {
    let label = state.selected_label().unwrap();
}
}
fn main() {}

State

ComboboxState owns:

FieldTypePurpose
inputStringCurrent input buffer (filter query)
cursorusizeCursor position in input
optionsVec<SelectOption>All options
filteredVec<usize>Indices of options matching current input
selectedOption<usize>Index in options of selected option
highlightusizeIndex in filtered of highlighted option
openboolTrue when dropdown is open
scrollusizeScroll offset in dropdown

Create state with ComboboxState::new(&["A", "B"]). The filtered list is rebuilt on every keystroke, case-insensitive substring matching. Read selection with state.selected() or state.selected_label(). The input buffer is cleared when an option is selected.

Keys

KeyAction
DownOpen dropdown (if closed)
EscClose dropdown
EnterSelect highlighted option and close
Up / DownNavigate filtered options (opens dropdown if closed)
Home / EndJump to first or last filtered option
Left / RightMove cursor in input buffer
Backspace / DeleteEdit input buffer
Any letterAppend to filter and update filtered list

Mouse

Same as Select: click to open/close, click an option to select, wheel scrolls.

Options

MethodEffect
placeholder(s)Placeholder text when input is empty
max_visible(n)Maximum visible options before scrolling (default 8)
compact(true)Compact mode
shape(s)Frame shape (see FieldShape section)

MultiSelect

A select with checkboxes. Space toggles the highlighted option, Enter confirms and closes. The state tracks a set of selected indices.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = MultiSelectState::new(&["HTML", "CSS", "JS"]);
MultiSelect::new()
    .placeholder("Select multiple")
    .focused(true)
    .render(area, buf, &mut state);

let selected = state.selected_indices();
let summary = state.summary();
}
fn main() {}

State

MultiSelectState owns:

FieldTypePurpose
optionsVec<SelectOption>List of options
selectedVec<bool>One flag per option (true = selected)
openboolTrue when dropdown is open
highlightusizeIndex of highlighted option
scrollusizeScroll offset

Create state with MultiSelectState::new(&["A", "B"]). Read selections with state.selected_indices() (returns Vec<usize> of selected indices) or state.summary() (returns "2 selected").

Keys

When closed:

KeyAction
Space / Enter / DownOpen dropdown

When open:

KeyAction
EscClose (selections remain)
EnterClose (selections remain)
SpaceToggle highlighted option
Up / DownMove highlight
Home / EndJump to first or last option
PageUp / PageDownMove highlight by 5

Mouse

Click the field to open. Click a checkbox to toggle that option. Click outside closes (the parent must handle this).

Options

MethodEffect
placeholder(s)Placeholder text when no options are selected
max_visible(n)Maximum visible options before scrolling (default 8)
compact(true)Compact mode
shape(s)Frame shape (see FieldShape section)

FieldShape

The FieldShape enum controls the frame drawn around text fields. All five widgets in this chapter accept shape(s) as a builder option. The default is Tall(Edge::Full), a Textual-style frame with thick side bars and thin top and bottom lines.

VariantAppearanceUse when
Tall(edge)Thick side bars (edge controls thickness) plus thin ▔/▁ lines above and belowYou want a prominent, Textual-style field (default)
Bars(edge)Left and right bars only, no top or bottomYou want vertical separation without horizontal lines
Bar(edge)Left bar onlyYou want a minimal accent (omp-style prompts)
RuleA ─ rule above and below, no side barsYou want horizontal separation in a dense form
RoundRounded box ╭─╮ / ╰─╯You want a softer, friendlier appearance
PromptA ❯ prompt glyph before the first line, no frameYou want a shell-style prompt (single-line only)
BandFull-width band in field color with padding rows and muted › promptYou want Claude Code’s composer style
NoneNo frameYou want inline editing or custom framing

The edge parameter controls bar thickness for Tall, Bars, and Bar:

EdgeWidthGlyph
Hair1/8 cell▏ / ▕
Thin2/8 cell▎ / ▕ (default)
Half1/2 cell▌ / ▐
FullWhole cellBackground painted solid

For example, shape(FieldShape::Bar(Edge::Hair)) draws a thin left accent bar. shape(FieldShape::Prompt) removes the frame entirely and draws a ❯ prompt.

Choosing between Select, Combobox, and MultiSelect

Use Select when:

  • The list is short (under 20 options) and the user knows what they want.
  • Typing is slower than arrow keys (e.g. “United States” vs. typing “uni”).
  • The list is stable and well-known (countries, months, enum values).

Use Combobox when:

  • The list is long (50+ options) and typing is faster than scrolling.
  • The user may not know the exact label (search by substring).
  • The list is dynamic or fetched from a search API.

Use MultiSelect when:

  • The user needs to choose zero or more options.
  • The choices are independent (tags, permissions, features).
  • You want a compact summary like “3 selected” instead of showing all chosen items.

If the user must pick exactly one from a long list and knows what they want, Combobox is the right choice. If they may not remember the label, Combobox lets them filter. If they need to pick several, MultiSelect is the only option in this family.

Navigation

Widgets for moving between content: tab bars, lists, trees, menus, breadcrumbs, and paginators.

TabBar

A horizontal tab bar with five visual styles. All support keyboard navigation (Left/Right/Home/End), mouse clicks, and optional closable tabs. The Underline style animates a sliding highlight bar.

Tab styles

TabBar renders tabs in one of five styles. Pass .style(TabStyle::...) to pick one.

StyleDescription
UnderlineMinimal tabs with an animated underline below the active tab (default, 2 rows tall)
BoxedEach tab is a bordered box; active tab has no bottom border (3 rows tall)
PillsRounded pill buttons with solid fill on the active tab (1 row tall)
SegmentedHorizontal segmented control, tabs share borders (1 row tall)
MinimalPlain text tabs with subtle highlight on active, no borders (1 row tall)

Basic example

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let items = vec!["Home".into(), "Settings".into(), "Help".into()];
let mut state = TabBarState::new(0);
TabBar::new(items)
    .style(TabStyle::Underline)
    .focused(true)
    .render(area, buf, &mut state);
}
fn main() {}

The state holds active (the selected tab index). Call state.set_active(i) to switch tabs programmatically, or let the user navigate with arrow keys and clicks.

Animated underline

The Underline style slides a highlight bar from the old tab to the new one. Pass .now(Instant::now()) each frame to update the animation. The bar tweens both position and width when tabs differ in size.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::Instant;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let items = vec!["Home".into(), "Projects".into(), "Settings".into()];
let mut state = TabBarState::new(0);
TabBar::new(items)
    .style(TabStyle::Underline)
    .now(now)
    .render(area, buf, &mut state);
}
fn main() {}

The default duration is 150 ms. Without .now(), the underline still moves but jumps instantly.

TabItem options

Tabs accept TabItem instead of plain strings when you need icons, badges, or flags. Every &str automatically converts to a plain TabItem.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let items = vec![
    TabItem::new("All").icon("◈"),
    TabItem::new("Active").badge("3"),
    TabItem::new("Archive").disabled(true),
];
let mut state = TabBarState::new(0);
TabBar::new(items)
    .style(TabStyle::Pills)
    .render(area, buf, &mut state);
}
fn main() {}
MethodEffect
.icon(s)Icon string shown before the label
.badge(s)Badge string shown after the label in a colored pill
.disabled(true)Tab is visible but not selectable
.closable(true)Show an × button that removes the tab when clicked

Closable tabs

A closable tab shows an × button on hover. When clicked, the state records the index in closed but does not remove the item from your data. You must call take_closed() after event handling, remove the item yourself, and clamp the active index if needed.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn example() {
let mut tab_items = vec![
    "Files".into(),
    TabItem::new("Search").closable(true),
    TabItem::new("Git").closable(true),
];
let mut state = TabBarState::new(0);

// in event handler after handle_mouse
if let Some(i) = state.take_closed() {
    if i < tab_items.len() {
        tab_items.remove(i);
        let last = tab_items.len().saturating_sub(1);
        state.set_active(state.active.min(last));
    }
}
}
fn main() {}

The × hit box is recorded in state.close_hits. Clicking outside a closable tab still selects it; the × is the only region that closes.

Builder options

MethodEffect
.style(TabStyle)Visual style (default Underline)
.focused(bool)Enables keyboard navigation and focus ring
.now(Instant)Current time for underline animation
.theme(Theme)Custom theme (default from prelude)

TabbedContent

A helper that draws a TabBar and returns the inner content rect. Pair it with a match on state.active to render the selected panel.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let tabs = vec!["List".into(), "Tree".into(), "Grid".into()];
let mut state = TabBarState::new(0);

let content_rect = TabbedContent::new(tabs)
    .style(TabStyle::Underline)
    .bordered(true)
    .focused(true)
    .render(area, buf, &mut state);

match state.active {
    0 => { /* render list */ },
    1 => { /* render tree */ },
    2 => { /* render grid */ },
    _ => {}
}
}
fn main() {}

The .bordered(true) option draws a border around the content area. Without it, the content rect fills the remaining space.

ListView

A vertical scrolling list with cursor, multi-select, fuzzy filtering, details column, separators, and activation on Enter or double-click.

Basic list

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let entries = vec![
    ListEntry::new("Item 1"),
    ListEntry::new("Item 2"),
    ListEntry::new("Item 3"),
];
let mut state = ListViewState::new();
ListView::new(entries)
    .focused(true)
    .render(area, buf, &mut state);
}
fn main() {}

The state holds cursor (the highlighted row index). Arrow keys move the cursor, Enter calls activation (recorded in state.activated).

Details pane

Pass .details(ListDetail::Right) to show a right-aligned detail column. Each entry can have an optional detail string.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let entries = vec![
    ListEntry::new("config.toml").detail("1.2K"),
    ListEntry::new("main.rs").detail("4.5K"),
    ListEntry::new("lib.rs").detail("8.3K"),
];
let mut state = ListViewState::new();
ListView::new(entries)
    .details(ListDetail::Right)
    .focused(true)
    .render(area, buf, &mut state);
}
fn main() {}

Icons and variants

Entries support icons, disabled state, and visual variants (Default, Primary, Success, Warning, Error, Accent).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let entries = vec![
    ListEntry::new("Running").icon("●").variant(Variant::Success),
    ListEntry::new("Stopped").icon("○").variant(Variant::Error),
    ListEntry::new("Archived").disabled(true),
];
let mut state = ListViewState::new();
ListView::new(entries).render(area, buf, &mut state);
}
fn main() {}

Separators

A separator is a horizontal line between groups. Set .separator(true) on an entry to turn it into a divider. The label is shown as a section heading, or leave it empty for a plain line.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let entries = vec![
    ListEntry::new("Item 1"),
    ListEntry::new("Item 2"),
    ListEntry::new("Group B").separator(true),
    ListEntry::new("Item 3"),
    ListEntry::new("Item 4"),
];
let mut state = ListViewState::new();
ListView::new(entries).render(area, buf, &mut state);
}
fn main() {}

Multi-select

Pass .multi_select(true) to enable checkboxes. Space toggles the current item, Enter activates it. The state holds a selected set of indices.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let entries = vec![
    ListEntry::new("Option A"),
    ListEntry::new("Option B"),
    ListEntry::new("Option C"),
];
let mut state = ListViewState::new();
ListView::new(entries)
    .multi_select(true)
    .highlight(ListHighlight::Bar)
    .render(area, buf, &mut state);

// read state.selected (a BTreeSet<usize>)
}
fn main() {}

The ListHighlight enum controls the cursor appearance. Bar fills the entire row; Side draws a thin line on the left edge.

Fuzzy filtering

Pass .filter(pattern) to show only entries whose labels match. The filter uses fuzzy matching (characters in order, gaps allowed).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, pattern: &str) {
let entries = vec![
    ListEntry::new("config.toml"),
    ListEntry::new("Cargo.toml"),
    ListEntry::new("main.rs"),
];
let mut state = ListViewState::new();
ListView::new(entries)
    .filter(pattern)
    .render(area, buf, &mut state);
}
fn main() {}

The filter is applied every render. Filtered-out items are not counted in cursor navigation or selection.

State methods

MethodEffect
new()Create state with cursor at 0
take_activated()Return and clear the activated index (set by Enter or double-click)

The state also holds hits (the widget’s drawn rect), scroll (top visible row), and hover (mouse position).

Builder options

MethodEffect
.focused(bool)Enables keyboard navigation and focus ring
.border(Border)Border style (default Round)
.title(s)Title shown in the top border
.details(ListDetail)Detail column position (None, Right; default None)
.multi_select(bool)Show checkboxes (default false)
.highlight(ListHighlight)Cursor style (Bar, Side; default Bar)
.filter(s)Fuzzy filter pattern (default empty, shows all)
.theme(Theme)Custom theme

TreeView

A hierarchical tree with expand/collapse, tree guides, icons, and scrolling. Nodes are TreeNode structs with children.

Building a tree

Create nodes with TreeNode::new(label), nest them with .with_children(vec![...]), and call TreeNode::assign_ids(&mut roots) once to assign unique ids depth-first.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut roots = vec![
    TreeNode::new("src").with_children(vec![
        TreeNode::new("main.rs"),
        TreeNode::new("lib.rs"),
    ]),
    TreeNode::new("Cargo.toml"),
];
TreeNode::assign_ids(&mut roots);

let mut state = TreeViewState::new();
TreeView::new(roots).render(area, buf, &mut state);
}
fn main() {}

The assign_ids method is required before rendering. Without it, every node has id 0 and expand/collapse breaks.

Icons and details

Nodes accept optional icon and detail strings, shown to the left and right of the label.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut roots = vec![
    TreeNode::new("src").icon("").with_children(vec![
        TreeNode::new("main.rs").icon("").detail("2.4K"),
        TreeNode::new("lib.rs").icon("").detail("1.8K"),
    ]),
];
TreeNode::assign_ids(&mut roots);

let mut state = TreeViewState::new();
TreeView::new(roots).render(area, buf, &mut state);
}
fn main() {}

Expand and collapse

The state holds an expanded set of node ids. Call state.expand(id) and state.collapse(id) to change it, or let the user toggle with Enter or click on the expand marker (▸ collapsed, ▾ expanded).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo() {
let mut state = TreeViewState::new();
state.expand(TreeId(0));
state.expand(TreeId(3));
}
fn main() {}

Only expanded nodes show their children. The cursor moves through visible rows only.

Tree guides

The tree draws guides (vertical and corner lines) to show hierarchy. The style matches the border setting (Hair, Thin, Half, Full, Round).

State methods

MethodEffect
new()Create state with empty expanded set
expand(TreeId)Add node to expanded set
collapse(TreeId)Remove node from expanded set
is_expanded(TreeId)Check if node is expanded
take_activated()Return and clear the activated node id (set by Enter or double-click)

The state also holds cursor (visible row index), scroll, and hover.

Builder options

MethodEffect
.focused(bool)Enables keyboard navigation and focus ring
.border(Border)Border style (default Round)
.title(s)Title shown in the top border
.theme(Theme)Custom theme

A menu bar with dropdowns, submenus, keyboard navigation, and context menus. Both use the same item structure but different positioning.

A horizontal menu bar at the top of the window. Each menu is a MenuDef with a title and a list of items.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let menus = vec![
    MenuDef {
        title: "File".to_string(),
        items: vec![
            MenuItem::action(1, "New"),
            MenuItem::action(2, "Open"),
            MenuItem::separator(),
            MenuItem::action(3, "Quit").shortcut("^Q"),
        ],
    },
    MenuDef {
        title: "Edit".to_string(),
        items: vec![
            MenuItem::action(20, "Cut"),
            MenuItem::action(21, "Copy"),
            MenuItem::action(22, "Paste"),
        ],
    },
];

let mut state = MenuBarState::new();
MenuBar::new(menus)
    .focused(true)
    .render(area, buf, &mut state);

state.render_overlay(buf, buf.area);
}
fn main() {}

Call state.render_overlay(buf, bounds) after rendering the menu bar to draw the open dropdown on top of everything else.

Menus contain action items, separators, and submenus. An action has an id (any usize you choose), a label, and optional shortcut, disabled flag, and checkmark.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo() {
let item = MenuItem::action(10, "Save");
let item_with_shortcut = MenuItem::action(11, "Save As").shortcut("^S");
let checked = MenuItem::action(12, "Show sidebar").checked(true);
let disabled = MenuItem::action(13, "Export").disabled(true);
let separator = MenuItem::separator();
let submenu = MenuItem::submenu("Recent", vec![
    MenuItem::action(20, "file1.rs"),
    MenuItem::action(21, "file2.rs"),
]);
}
fn main() {}

Submenus open to the right when hovered or navigated to with Right arrow. They nest arbitrarily deep.

Shortcuts and checkmarks

Shortcuts are display-only strings shown right-aligned in the dropdown. They do not bind keys. Checkmarks show a ✓ to the left of the label when the item is in a checked state.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let menus = vec![MenuDef {
    title: "View".to_string(),
    items: vec![
        MenuItem::action(1, "Sidebar").shortcut("^B").checked(true),
        MenuItem::action(2, "Status bar").checked(false),
    ],
}];
let mut state = MenuBarState::new();
MenuBar::new(menus).render(area, buf, &mut state);
state.render_overlay(buf, buf.area);
}
fn main() {}

To toggle a checkmark, track the checked state yourself and rebuild the menu items each frame.

Opening and closing

Call state.open(index) to open a menu by index, or state.close() to close all. The user can open with F10, click, or Alt+letter (not implemented by the widget, handle in your event loop). Esc closes the menu.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo() {
let mut state = MenuBarState::new();
state.open(0);

if state.is_open() {
    state.close();
}
}
fn main() {}

Consuming actions

When an action item is clicked or activated with Enter, its id is recorded in state. Call take_action() to retrieve and clear it.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo() {
let mut state = MenuBarState::new();

if let Some(action_id) = state.take_action() {
    match action_id {
        1 => { /* handle New */ },
        2 => { /* handle Open */ },
        3 => { /* handle Quit */ },
        _ => {}
    }
}
}
fn main() {}

This is the same flow as closable tabs: the widget records the event, you take it and act.

ContextMenu

A floating menu that opens at a cursor position. Build it with ContextMenu::new(items) and call state.open_at(Position { x, y }) to show it.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let items = vec![
    MenuItem::action(1, "Open"),
    MenuItem::action(2, "Rename"),
    MenuItem::separator(),
    MenuItem::action(3, "Delete"),
];

let mut state = ContextMenuState::new();
ContextMenu::new(items)
    .theme(&Theme::default())
    .render(area, buf, &mut state);

state.render_overlay(buf, buf.area);

// open on right-click
#[allow(unused_variables)]
let mouse_x = 10u16;
#[allow(unused_variables)]
let mouse_y = 5u16;
state.open_at(Position { x: mouse_x, y: mouse_y });
}
fn main() {}

The context menu also uses take_action() to consume activated item ids. Call state.close() to dismiss it, or let the user click outside or press Esc.

When focused, F10 or click opens the first menu. Arrow keys navigate between menus and items. Right opens a submenu, Left closes it or moves to the previous menu. Enter activates an item, Esc closes all.

A horizontal breadcrumb trail with clickable segments. Middle segments collapse into ellipsis when space is tight.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let segments = vec![
    "Home".to_string(),
    "Projects".to_string(),
    "tuile".to_string(),
    "src".to_string(),
    "main.rs".to_string(),
];
let mut state = BreadcrumbsState::new();
Breadcrumbs::new(segments)
    .focused(false)
    .render(area, buf, &mut state);
}
fn main() {}

Collapsing

When the widget is too narrow to fit all segments, it hides middle segments and shows ... › instead. The first and last segments always remain visible. The collapsing logic kicks in when the total width exceeds the area.

Clicking

When a segment is clicked, its index is recorded in state. Call take_clicked() to retrieve it.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo() {
let mut state = BreadcrumbsState::new();

if let Some(index) = state.take_clicked() {
    // navigate to segments[index]
}
}
fn main() {}

The breadcrumbs widget does not navigate for you. You must handle the click and change your view or path state.

Builder options

MethodEffect
.focused(bool)Enables keyboard navigation (Home, End, Enter)
.theme(Theme)Custom theme

Paginator

A page selector with ellipsis for large page counts. Shows current page, first, last, and a window of nearby pages.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = PaginatorState::new();
Paginator::new(42)
    .window(2)
    .focused(false)
    .render(area, buf, &mut state);
}
fn main() {}

The first argument is the total number of pages. .window(n) controls how many pages to show on each side of the current page (default 1). The paginator always shows page 1, page total, and window pages around state.page.

Arrow keys, Home, End, and Page Up/Down move between pages when focused. Clicking a page number jumps to it. The state holds page (0-indexed).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo() {
let mut state = PaginatorState::new();
state.page = 5;
}
fn main() {}

The paginator does not load or display page content. It only tracks the current page number. You must render the content yourself based on state.page.

Builder options

MethodEffect
.window(usize)Number of pages shown around current (default 1)
.focused(bool)Enables keyboard navigation
.theme(Theme)Custom theme

Choosing between navigation widgets

Use TabBar for a small fixed set of views (3 to 8 tabs). Use TabbedContent when you want the tab bar and content area in one call. Use ListView for a dynamic list of items with optional filtering or multi-select. Use TreeView for hierarchical data like file trees or nested categories. Use MenuBar for commands grouped by category at the top of the window, and ContextMenu for right-click actions on a specific item. Use Breadcrumbs to show the current path and allow jumping to parent levels. Use Paginator for page-based navigation when you cannot fit all content on one screen.

All navigation widgets follow the same handle pattern: render with &mut state, handle events with state.handle_key(k) or state.handle_mouse(m), and read activation or clicks with take_activated() / take_action() / take_clicked() / take_closed().

Tables and data

Data presentation widgets: a sortable table with row or cell cursors, a simple key-value list, and large seven-segment digits.

DataTable

A full-featured data table with sortable columns, row or cell navigation, multi-select, filtering, zebra striping, column resize, scrollbars, and styling hooks. The header row is clickable and resizable.

Building columns and rows

Columns define layout and behaviour. Rows hold cells.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::{DataTable, DataTableState, TableColumn, TableRow, TableCell, TableCursor};
use tuile::ratatui_core::layout::{Constraint, Alignment};

fn demo(area: Rect, buf: &mut Buffer) {
let cols = vec![
    TableColumn::new("Name")
        .width(Constraint::Fill(1))
        .sortable(true),
    TableColumn::new("CPU")
        .width(Constraint::Length(8))
        .align(Alignment::Right)
        .sortable(true),
];

let rows = vec![
    TableRow::from(vec!["server-01", "45.2%"]),
    TableRow::from(vec!["server-02", "12.8%"]),
];

let mut state = DataTableState::new();
DataTable::new(cols, rows)
    .cursor(TableCursor::Row)
    .zebra(true)
    .render(area, buf, &mut state);
}
fn main() {}

TableColumn has five fields:

  • title (String): header text
  • width (Constraint): column width constraint; defaults to Fill(1)
  • align (Alignment): cell alignment; defaults to Left
  • sortable (bool): whether clicking the header or pressing s toggles sort; defaults to false
  • key (usize): unique identifier for column reordering (not used by DataTable itself)

TableRow::from(vec) accepts anything that converts to TableCell. Strings, &str, and f64 all work. An f64 cell displays as {:.1} and stores the numeric value as sort_key for numeric sorting.

TableCell has three fields:

  • text (String): displayed content
  • style (Option<Style>): overrides the default cell style if present
  • sort_key (Option<f64>): numeric sort value; when present, sorting compares numbers instead of text

Cursor modes

TableCursor has four variants:

ModeBehaviour
NoneNo cursor; the table is display-only
RowHighlights the entire row; arrow keys move up and down
CellHighlights a single cell; arrows move in all four directions
ColumnHighlights an entire column; left and right arrows move between columns

Row mode is the most common for selecting records. Cell mode is useful for heatmaps or when individual cell values matter. Column mode is rare.

Sorting

Clicking a sortable column header or pressing s when focused toggles sort order: first click sorts ascending, second descending, third clears the sort. The sort field in DataTableState holds Some((col_idx, ascending)) or None.

Sort logic:

  • If both cells have sort_key, compare those numbers.
  • Otherwise, compare text case-insensitively.
  • Missing cells sort last.

The f64 conversion automatically sets sort_key, so a column of floats sorts numerically. For other types (dates, durations, IDs), construct TableCell manually:

extern crate tuile;
use tuile::widgets::TableCell;
let uptime_cell = TableCell::new("23d 4h")
    .sort_key(23.0 * 24.0 + 4.0); // sort by hours
fn main() {}

Selection and activation

Multi-select is off by default. Enable it with .multi_select(true). Space toggles selection of the current row. state.selected is a BTreeSet<usize> of row indices (into the caller’s rows, not the sorted/filtered view).

Enter or double-clicking a row sets state.activated to Some(row_idx). Call state.take_activated() after handling an event to consume it (returns Option<usize>, clearing the field). This pattern avoids processing the same activation twice.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::{DataTable, DataTableState, TableColumn, TableRow, TableCursor};
use tuile::crossterm::event::{Event, KeyEvent};
fn demo(state: &mut DataTableState, ev: &Event) -> Outcome {
let out = state.handle(&ev);
if let Some(idx) = state.take_activated() {
    println!("User activated row {}", idx);
}
out
}
fn main() {}

The row index is into the original rows, not the sorted or filtered order, so it remains stable across sorts.

Filtering

state.set_filter(query) rebuilds the visible row list to include only rows where at least one cell contains the query (case-insensitive substring match). An empty query shows all rows. The showcase page binds / to enter filter mode, which calls set_filter on each keystroke.

Filtering and sorting compose: the table first filters, then sorts the surviving rows.

Zebra striping and row numbers

.zebra(true) alternates row background colours (based on the visible row index after filtering and sorting).

.show_row_numbers(true) prepends a column displaying 1-based row numbers. The numbers reflect the visible order, so they change when sorting or filtering.

Borders and spacing

.borders(TableBorders) controls separator lines:

VariantRendering
NoneNo lines (default)
HorizontalLines between rows
VerticalLines between columns
AllGrid

.column_gap(n) sets the spacing between columns (defaults to 2 cells). .padding(n) adds left and right padding inside each cell (defaults to 1).

Column resize

Clicking the boundary between two column headers starts a drag-resize. The mouse position during drag sets the width of the left column. Released widths are stored in state.width_overrides (a HashMap<usize, u16>) and persist across renders.

The cell_style hook

.cell_style(fn) lets the caller override cell colours based on value. The function receives (row_idx, col_idx, &TableCell, &Theme) and returns Option<Style>. Returning Some replaces the default style; None keeps it.

Example from the showcase (a heatmap blending primary and error colours based on cell value):

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::TableCell;
fn heatmap_style(_row: usize, _col: usize, cell: &TableCell, th: &Theme) -> Option<Style> {
    if let Some(v) = cell.sort_key {
        let intensity = ((v - 30.0) / 50.0).clamp(0.0, 1.0) as f32;
        let color = th.primary.blend(th.error, intensity);
        Some(st(color, th.surface))
    } else {
        None
    }
}
fn main() {}

The function must be a plain fn pointer (not a closure) because the widget stores it across frames.

State fields

DataTableState exposes the following public fields and methods:

Field / methodType / signatureMeaning
cursor_rowusizeCursor position in the sorted/filtered view (0-based)
cursor_colusizeColumn index (for Cell or Column cursor modes)
offset_yusizeVertical scroll offset in rows
offset_xusizeHorizontal scroll offset in cells
selectedBTreeSet<usize>Row indices (into original rows) that are selected
sortOption<(usize, bool)>Current sort (column index, ascending) or None
hover_rowOption<usize>Row index under the mouse in the visible view
filterStringCurrent filter query (case-insensitive substring)
visible_len()usizeNumber of rows after filtering
current_row()Option<usize>Original row index at cursor_row, or None if empty
take_activated()Option<usize>Consumes and returns the activated row index (Enter or double-click)
selected_rows()Vec<usize>Copy of selected row indices as a vector
set_filter(&str)()Updates the filter and marks the order dirty for rebuild
clear_selection()()Empties the selection set

The state rebuilds the internal order vector whenever sort or filter changes (lazy rebuild on next render). The caller never sees order directly; use current_row() to map visible positions to original indices.

Keys and mouse

The table responds to keyboard and mouse when focused. All bindings apply to Row and Cell cursor modes; Column mode only handles Left, Right, Enter, and s.

InputAction
Keyboard
Up / DownMove cursor row
Left / RightMove cursor column (Cell mode) or do nothing (Row mode)
HomeJump to first row
EndJump to last row
Page UpMove up 10 rows
Page DownMove down 10 rows
EnterSet activated to current row (consumed by take_activated())
SpaceToggle selection of current row (if multi_select is true)
sToggle sort on the current column (if sortable is true): ascending → descending → none
Mouse
Click headerToggle sort on that column (same as s)
Click header boundaryStart column resize drag
Drag (after boundary click)Resize the left column; width is stored in state.width_overrides
Click rowMove cursor to that row and set activated if it was already focused
Double-click rowSet activated (same as Enter)
Hover rowUpdate state.hover_row (does not change cursor or selection)
Scroll wheelScroll the table vertically (adjusts offset_y)
Scrollbar dragScroll to the dragged position

Mouse events do not require focus. Both the main table and a secondary table can respond to clicks simultaneously if the caller forwards events to both states (as the showcase does).

KeyValueList

A simple two-column widget for label-value pairs (definition lists, metadata, settings). Not interactive; it is a plain Widget, not StatefulWidget.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::KeyValueList;

fn demo(area: Rect, buf: &mut Buffer) {
let items = vec![
    ("Version".to_string(), "1.0.0".to_string()),
    ("Uptime".to_string(), "23d 4h 12m".to_string()),
    ("Memory".to_string(), "8.2 / 16.0 GB".to_string()),
];
KeyValueList::new(items).render(area, buf);
}
fn main() {}

Labels are bold and muted; values are normal text. The label column width is the longest label (capped at one third of the available width). .gap(n) sets the spacing between columns (defaults to 2).

If there are more items than rows in the area, the excess is clipped. Use a scrollable container or split the list if this is a problem.

Digits

Textual-style 3-row large digits for displaying numbers, time, or short character sequences. Each digit occupies 3 cells wide by 3 rows tall. The character set is 0123456789+-^x:ABCDEF$£€(). Colon (:) is 1 cell wide; all others are 3.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::Digits;
use tuile::ratatui_core::layout::Alignment;

fn demo(area: Rect, buf: &mut Buffer, theme: &Theme) {
Digits::new("12:34:56")
    .color(theme.primary)
    .align(Alignment::Center)
    .render(area, buf);
}
fn main() {}

Digits::width(text) returns the display width in cells (useful for layout). The widget requires at least 3 rows; if the area is shorter, nothing renders.

Options:

  • .color(Rgb): glyph foreground colour
  • .variant(Variant): alternative to .color; uses the theme’s variant colour
  • .align(Alignment): horizontal alignment (Left, Center, Right); defaults to Left
  • .bold(bool): render glyphs bold; defaults to false
  • .bg(Rgb): background colour; defaults to theme surface
  • .theme(&Theme): override the theme

The showcase uses Digits for a live clock (updated every second via animating) and a counter (incremented with Space). The clock is primary-coloured; the counter changes to accent when focused.

Charts

Sparklines, graphs, heatmaps, gauges, and activity grids for visualizing time-series and 2D data. Every chart widget is stateless (render only, no event handling) and autoscales its data to fit the available area.

SparkChart

A compact inline chart that shows the last N values from a slice. Width-limited: if there are more values than cells, only the most recent ones appear. Height scales automatically to the data range unless you set .min() and .max().

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let data = vec![10.0, 15.0, 22.0, 18.0, 30.0, 28.0, 35.0];
SparkChart::new(&data)
    .style(SparkStyle::Bars)
    .baseline(true)
    .show_last_value(true)
    .render(area, buf);
}
fn main() {}

NaN values in the slice are skipped (they do not occupy cells). An empty or all-NaN slice renders blank.

Styles

StyleAppearance
BarsBlock glyphs ▁▂▃▄▅▆▇█ (default)
LineBraille line connecting values
AreaBraille line with dimmed fill below
Fieldbtop-style dot field: every dot below the value is lit

.gradient(&[color1, color2, ...]) interpolates across the width (Bars, Line, Area) or by height per column (Field). .baseline(true) draws a thin baseline at the bottom. .mirrored(true) hangs the graph from the top edge (upload half of a mirrored network graph).

The widget needs at least 1 column and 1 row. Typical heights: 1 row for Bars/Field, 2 or more for Line/Area.

BarGraph

Grouped vertical or horizontal bars. Each BarGroup has a label and a vector of values (one per series).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let groups = vec![
    BarGroup {
        label: "Q1".into(),
        values: vec![30.0, 45.0, 25.0],
    },
    BarGroup {
        label: "Q2".into(),
        values: vec![35.0, 50.0, 30.0],
    },
];
BarGraph::new(&groups)
    .series_names(&["APAC", "EMEA", "AMER"])
    .show_values(true)
    .bar_width(4)
    .gap(1)
    .render(area, buf);
}
fn main() {}

Values scale to the tallest bar unless you set .max(value). .horizontal(true) draws bars left to right. .show_values(true) prints each bar’s value at its end. .axis(false) hides the baseline and tick marks. .colors(&[rgb1, rgb2, ...]) sets series colors (defaults cycle through theme variants).

A bar group with an empty values vector renders as blank space. NaN values are skipped within a group.

The widget needs at least 5 columns and 3 rows for vertical bars, or 5 rows and 10 columns for horizontal bars with labels.

LineGraph

Multi-series line chart with axes, grid, and legend. Each LineSeries has a name, points (x, y), optional color, and a LineStyle.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let series = vec![
    LineSeries {
        name: "CPU".into(),
        points: vec![(0.0, 20.0), (1.0, 35.0), (2.0, 50.0), (3.0, 65.0)],
        color: None,
        style: LineStyle::Line,
    },
    LineSeries {
        name: "Memory".into(),
        points: vec![(0.0, 10.0), (1.0, 25.0), (2.0, 40.0), (3.0, 55.0)],
        color: None,
        style: LineStyle::Area,
    },
];
LineGraph::new(&series)
    .grid(true)
    .legend(LegendPos::TopRight)
    .x_labels(5)
    .y_labels(5)
    .render(area, buf);
}
fn main() {}

Axes and grid are drawn in braille. .x_bounds(min, max) and .y_bounds(min, max) fix the range (defaults autoscale to the data). .label_fmt(|v| format!("{v:.1}")) customizes tick labels. .grid(true) draws a faint grid. .legend(LegendPos::Hidden) removes the legend. .title("Title") centers a title above the graph.

LineStyleRendering
LineBraille line (default)
AreaBraille line with dimmed fill below
PointsBraille dots at each point

An empty series or a series with fewer than two points renders blank. NaN coordinates are skipped (creating gaps in Line/Area styles).

The widget needs at least 12 columns and 6 rows for a readable chart with axes.

ScatterPlot

Thin wrapper over LineGraph that forces every series to LineStyle::Points. Same API as LineGraph but without .grid(), .legend(), or .title().

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let series = vec![
    LineSeries {
        name: "A".into(),
        points: vec![(0.0, 10.0), (1.0, 20.0), (2.0, 15.0)],
        color: None,
        style: LineStyle::Points,
    },
];
ScatterPlot::new(&series)
    .x_bounds(0.0, 3.0)
    .y_bounds(0.0, 30.0)
    .render(area, buf);
}
fn main() {}

Same scaling and NaN behavior as LineGraph. Minimum area: 8 columns and 4 rows.

Heatmap

2D grid of colored cells. Values are &[Vec<f64>] (rows, then columns within each row). Each cell is painted with a background color interpolated from the gradient stops.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let data = vec![
    vec![10.0, 20.0, 30.0],
    vec![15.0, 25.0, 35.0],
];
let gradient = [Rgb(50, 100, 200), Rgb(200, 100, 50)];
Heatmap::new(&data)
    .row_labels(&["Mon", "Tue"])
    .col_labels(&["A", "B", "C"])
    .gradient(&gradient)
    .show_values(true)
    .render(area, buf);
}
fn main() {}

.gradient(&[rgb1, rgb2, ...]) maps values from the data range to colors. .null_color(rgb) sets the color for NaN cells (default dark gray). .cell_width(w) controls cell width in columns (default 2). .show_values(true) prints each value centered in its cell. .legend(true) draws a gradient bar at the bottom.

NaN values render with the null color. Rows with different lengths are padded with NaN. An empty or all-NaN dataset renders blank.

Minimum area: (cols * cell_width) + (row_labels.len() as u16 * 2) columns, rows + 2 rows (for labels). Compact mode (cell_width 1) fits more data but values become unreadable.

ActivityGraph

GitHub-style contribution grid: 7 rows (days) and up to 52 columns (weeks). Values are &[u8] where each byte is a 0 to 4 intensity level for one day. Up to 364 days (52 weeks) are shown.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut days = vec![0u8; 364];
for (i, v) in days.iter_mut().enumerate() {
    *v = ((i * 7 + i / 7) % 5) as u8;
}
ActivityGraph::new(&days).render(area, buf);
}
fn main() {}

Each cell is 2 columns wide (painted background plus a space for separation). Weeks are columns, days of the week are rows (Monday at the top). .levels(&[rgb0, rgb1, rgb2, rgb3, rgb4]) sets the 5 colors (defaults to theme shades). Values outside 0..=4 are clamped.

An empty slice renders blank. Minimum area: 14 columns (7 weeks), 7 rows.

Meter

Horizontal gauge showing a 0.0 to 1.0 value with optional label, percent, and suffix.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
Meter::new()
    .value(0.73)
    .label("CPU:")
    .show_percent(true)
    .suffix("8 cores")
    .style(MeterStyle::Blocks)
    .render(area, buf);
}
fn main() {}
MethodEffect
.value(f32)0.0 to 1.0 (clamped)
.label(s)Left label
.suffix(s)Right label after the bar
.show_percent(bool)Print percent inside or after the bar
.thresholds(&[(f32, Variant)])Color by value ranges (e.g. green below 0.7, red above 0.9)
.color(rgb)Fixed bar color (overrides thresholds unless a gradient is set)
.gradient(&[rgb, ...])Interpolate across the bar (LED styles color each cell by position, solid styles by value)
.compact(bool)No label spacing (for tight layouts)

Meter styles

StyleAppearance
LineHorizontal line ━━━━╺━━ (default)
BlockSolid painted fill
Segments(n)n segments with gaps
Blocksbtop-style LED row: ■■■■□□ (empties dimmed)
DotsCompact LED row: ●●●◌◌

Thresholds take the highest matching (threshold_value, variant) pair. A gradient overrides both color and thresholds. Gradients on LED styles (Blocks, Dots) color each cell by its position along the bar, not by the meter’s value.

Minimum area: 10 columns for a readable bar, 1 row.

RadialGauge

Experimental semicircular gauge drawn with braille arcs. Shows a 0.0 to 1.0 value as a sweep from the left to right end of a half-circle.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
RadialGauge::new()
    .value(0.65)
    .label("Speed")
    .thickness(2)
    .render(area, buf);
}
fn main() {}

.thickness(u16) sets the arc width in braille dot rows (default 2). .label(s) prints centered text below the gauge.

This widget may be removed if the braille arc does not look good in all terminals. Minimum area: 12 columns, 6 rows.

BrailleCanvas

Low-level 2×4 dot rasterizer used internally by LineGraph and ScatterPlot. Each terminal cell holds 8 braille dots arranged in a 2-column by 4-row grid.

⠁ ⠈  (dots 0-3 left column, 4-7 right column, bottom to top)
⠂ ⠐
⠄ ⠠
⡀ ⢀

The braille unicode code is U+2800 plus a bit pattern where bit 0 is the top-left dot and bit 7 is the bottom-right. BrailleCanvas::new(width, height) creates a canvas where width and height are in braille dots (multiply terminal cell dimensions by 2 and 4). .set_dot(x, y, color) lights a dot. .line(x1, y1, x2, y2, color) draws a line. .render(area, buf) writes the result.

Most users do not call this directly. LineGraph and ScatterPlot use it to draw high-resolution plots.

Gradients

Two gradient helpers are available:

HelperUse
gradient_stops(&[rgb, ...])Builder method on widgets: interpolates colors across the data range or width
color_gradient(stops, t)Free function: samples a gradient at position t (0.0 to 1.0)

color_gradient linearly interpolates RGB components between stops. With two stops, it is a linear blend. With three or more, it finds the pair bracketing t and blends within that segment.

extern crate tuile;
use tuile::theme::{Rgb, gradient as color_gradient};
fn demo() {
let stops = [Rgb(0, 255, 0), Rgb(255, 255, 0), Rgb(255, 0, 0)];
let low = color_gradient(&stops, 0.0);
let mid = color_gradient(&stops, 0.5);
let high = color_gradient(&stops, 1.0);
}
fn main() {}

Widgets that accept .gradient(&[rgb, ...]) use this function internally to map data or position to color. One-stop gradients are solid (all values get that color). Zero-stop gradients fall back to the theme primary color.

When not to use a chart widget

Use Table or ListView when the data is tabular with row labels, or when you need selection and scrolling. Use BigText for large single numbers. Use Meter for a single gauge, not a bar chart with one bar. For custom shapes or plots that do not fit these widgets, draw directly into the buffer or compose a BrailleCanvas.

Feedback

Progress indicators, spinners, loading states, messages, and overlays for keeping users informed.

Progress bars

ProgressBar

An animated horizontal bar with optional percentage and ETA. The state owns the target value and a tween that smoothly animates toward it.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::{Duration, Instant};

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = ProgressState::default();
state.set(0.45, now, Duration::from_millis(300));

ProgressBar::new()
    .label("Downloading")
    .show_percentage(true)
    .show_eta(true)
    .variant(Variant::Primary)
    .render(area, buf, &mut state);
}
fn main() {}

The state tracks samples of (Instant, value) pairs and estimates time remaining. ETA appears after one second of progress and at least two samples. Call state.set(new_value, now, duration) each time the value changes; the tween interpolates over duration. Read state.value(now) for the current animated position.

Indeterminate mode shows a sweeping segment when the total is unknown:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::Instant;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = ProgressState::default();
state.tick_indeterminate(now, 0.5);

ProgressBar::new()
    .indeterminate()
    .label("Fetching")
    .render(area, buf, &mut state);
}
fn main() {}

Call tick_indeterminate(now, speed) each frame. The speed is cycles per second; 0.5 completes one sweep every two seconds.

MethodEffect
label(text)Left-aligned label
show_bar(bool)Show the filled bar (default true)
show_percentage(bool)Show percentage column (default true)
show_eta(bool)Show estimated time remaining (default true)
variant(Variant)Bar color (default Primary)
indeterminate()Sweeping segment instead of value-based fill
compact(bool)Single-row layout with no label row
width_hint(u16)Reserve width for ETA so the bar does not resize
background(Rgb)Custom background color

StepProgress

Dots for multi-step flows: ●●●○○ where filled dots mark completed steps.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
StepProgress::new(5).current(3).render(area, buf);
}
fn main() {}

No state needed; pass the current step (zero-indexed) at render. Customize the glyphs with filled(g) and empty(g).

Spinners

Spinner

Animated frame sequences from a catalog of 102 definitions. Every spinner in yaspin and cli-spinners plus tuile originals.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::spinners;
use std::time::Instant;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Spinner::new(&spinners::DOTS)
    .label("Loading")
    .now(now)
    .render(area, buf);
}
fn main() {}

The spinners module is a catalog of const SpinnerDef entries. Each definition holds a name, interval in milliseconds, and frame array. Pass .now(instant) to derive the phase from crate::anim::EPOCH, or .elapsed(secs) for an explicit phase. Tests use .elapsed() for deterministic frames.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::spinners;

fn example() {
let frame = spinners::DOTS.frame(1.5);
let width = spinners::DOTS.width();
}
fn main() {}

Call SpinnerDef::frame(elapsed) to read one frame at a given phase, or SpinnerDef::width() to reserve space for the widest frame.

Custom spinners:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::Instant;

const PULSE: SpinnerDef = SpinnerDef::new("pulse", 120, &["·", "•", "●", "•"]);

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Spinner::new(&PULSE).now(now).render(area, buf);
Spinner::frames(&["<", "^", ">", "v"], 150).now(now).render(area, buf);
}
fn main() {}

Popular catalog entries: DOTS, LINE, ARC, MOON, BOUNCING_BAR, CLOCK, SPARKLE, RING, WAVE, EQUALIZER, SCANNER, MATRIX. The full list lives in tuile::widgets::spinners::ALL.

MethodEffect
label(text)Right-aligned label after the spinner
color(Rgb)Custom foreground color
now(Instant)Current instant (phase from EPOCH)
elapsed(f32)Explicit phase in seconds

LoadingIndicator, Marquee, Blinker

LoadingIndicator: Five dots pulsing through a gradient. Defaults to theme primary; customize with colors(a, b) for gradient endpoints or dots(n) for count.

Marquee: Horizontally scrolling text. Set speed(cells_per_sec) and gap(cells) for spacing before repeat. Useful for status lines.

Blinker: Simple blinking glyph. The glyph is visible for the first half of each period(secs). Default period is 1.0 second, default glyph is ●.

All three take .now(Instant) or .elapsed(f32) for phase control.

Messages

Callout

Inline bordered notes for heads-up messages. Not dismissible by default; set dismissible(true) and the state tracks clicks.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::CalloutBorder;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = CalloutState::default();
Callout::new("Deployed", "Build 4821 is live in all regions.")
    .variant(Variant::Success)
    .border_style(CalloutBorder::Round)
    .dismissible(true)
    .render(area, buf, &mut state);
}
fn main() {}

Three border styles: LeftBar (thin left edge), Bar(Edge) (left edge with custom thickness), and Round (rounded corners). The variant colors the border and background tint.

When dismissible(true), clicking the callout sets state.dismissed to true and the widget stops rendering.

Overlays

Modal and CommandPalette are overlay widgets. Render them last in your frame so they appear above other content, and check the overlays concept page for stacking and focus rules: Overlays.

Dialogs with a dimmed backdrop. Three presets: confirm, alert, and prompt.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::Instant;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = ModalState::default();
state.open(now);

Modal::confirm("Delete file?", "This cannot be undone.")
    .render(area, buf, &mut state);

if let Some(button) = state.take_result() {
    match button {
        0 => { /* Cancel */ }
        1 => { /* Confirm */ }
        _ => {}
    }
}
}
fn main() {}

The result is the zero-indexed button that was pressed. take_result() clears the result so the same press is not read twice. Buttons are defined left to right in the buttons array.

Confirm has two buttons (Cancel at index 0, Confirm at 1). Alert has one (OK at 0). Prompt has a text field above the buttons; read state.input_text when the result is the OK button.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::Instant;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = ModalState::default();
state.open(now);

Modal::prompt("Enter name", "Name for the new project:")
    .render(area, buf, &mut state);

if let Some(1) = state.take_result() {
    let name = &state.input_text;
    // use the name
}
}
fn main() {}

Focus moves between the input field and buttons with Tab and arrow keys. Enter submits the default button; Escape closes with the cancel index. Clicking the backdrop also closes unless close_on_backdrop(false).

Custom dialogs use Modal::new(title).body(text).buttons(&[(label, variant)]). The cancel_index is returned when Escape is pressed or the backdrop is clicked. The default_button is focused on open and triggered by Enter.

MethodEffect
body(text)Message below the title
buttons(&[(label, variant)])Button labels and colors
width(u16)Dialog width in cells (clamped to 24 to 80)
kind(ModalKind)Dialog, Sheet, or Fullscreen
dim(f32)Backdrop opacity (0.0 to 1.0, default 0.6)
icon(text)Icon or emoji before the title
close_on_escape(bool)Whether Escape closes (default true)
close_on_backdrop(bool)Whether backdrop clicks close (default true)
cancel_index(usize)Button index for Escape/backdrop
default_button(usize)Button focused on open
with_input(bool)Show a text field (prompt mode)

CommandPalette

Fuzzy-matched command picker. Register items with set_items, open with state.open(), read the selection with take_selected().

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = CommandPaletteState::default();
let items = vec![
    PaletteItem::new("Open file").shortcut("Ctrl+O").group("File"),
    PaletteItem::new("Save").shortcut("Ctrl+S").group("File"),
    PaletteItem::new("Quit").shortcut("Ctrl+Q"),
];
state.set_items(&items);
state.open();

CommandPalette::new().render(area, buf, &mut state);

if let Some(idx) = state.take_selected() {
    match idx {
        0 => { /* open */ }
        1 => { /* save */ }
        2 => { /* quit */ }
        _ => {}
    }
}
}
fn main() {}

Fuzzy matching scores each item against the query. Up and Down move the highlight, Enter selects, Escape closes. The selected index is the position in the original items array. Items support optional hint, group, shortcut, and icon fields.

Tooltip

Tooltips appear after a hover delay. The state tracks the hovered widget and delay timing; render_overlay places the tooltip near the anchor.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::Instant;

fn demo(bounds: Rect, buf: &mut Buffer, now: Instant) {
let mut state = TooltipState::new();
let button_hit = HitBox { area: Rect::new(5, 5, 10, 1), hover: true, ..Default::default() };

state.track(button_hit.hover, button_hit.area, now);

Tooltip::new("Click to save changes")
    .delay(400)
    .max_width(30)
    .render_overlay(buf, bounds, &mut state, now);
}
fn main() {}

Call state.track(hover, widget_area, now) every frame with the hovered widget’s rect. The tooltip becomes visible after the delay (default 500 milliseconds). render_overlay positions it below the anchor if space permits, otherwise above.

Notifications

Toasts, notification centers, banners, and inline alerts for temporary and persistent messaging.

Toast system

Toasts are temporary overlays that slide in, show a message, and auto-dismiss. The toast system has three pieces: Toast (one message), Toaster (state that owns the queue and handles timing), and ToastStack (the widget that renders them).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::Instant;

fn demo() {
let mut toaster = Toaster::new();
toaster.push(Toast::new("Saved", "File written successfully").variant(Variant::Success));
toaster.info("Quick message");

// In your app loop:
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
let now = Instant::now();
toaster.tick(now);
ToastStack::new().now(now).render(area, &mut buf, &mut toaster);
}
fn main() {}

Lifecycle

A toast enters the queue when you call toaster.push(toast). The tick(now) call checks expiry and removes closed toasts. ToastStack renders the visible subset (newest first), applies the animation, and tracks hover state for pause-on-hover. Call tick once per frame with the current time, and render the stack over your main UI.

Creating toasts

Toast::new(title, message) takes two strings. The title is bold, the message is regular weight. Defaults: 5 second timeout, dismissible, no icon, Card style. Builder methods adjust these:

MethodEffect
variant(Variant)Changes color (Default, Primary, Success, Warning, Error, Accent). Default is Variant::Default.
timeout(Duration)Auto-dismiss after this duration. Default is 5 seconds.
no_timeout()Toast stays until manually dismissed.
dismissible(bool)Whether clicking the toast dismisses it. Default is true.
icon(impl Into<String>)Sets a custom icon glyph. If omitted, the variant picks a default (◔ for Default, ✓ for Success, ⚠ for Warning, ✗ for Error).
style(ToastStyle)Override the global style for this toast.
progress_value(f32)For Progress style, sets the fill percentage (0.0 to 1.0).
actions(&[&str])Adds inline action buttons (see Action style below).

The Toaster also has convenience methods that push pre-configured toasts:

extern crate tuile;
use tuile::prelude::*;
fn demo(toaster: &mut Toaster) {
toaster.info("Connection established");
toaster.success("File saved");
toaster.warning("Low disk space");
toaster.error("Network timeout");
}
fn main() {}

Each creates a toast with the named variant, a capitalized title, and your message.

ToastStyle

Ten styles, each with different visuals. The global style is set on the Toaster via .style(ToastStyle::X), or per-toast with toast.style(X).

StyleAppearance
CardRounded rectangle with shadow, icon on the left, title and message stacked. Default.
FlatFlat rectangle, no shadow, same layout as Card.
MinimalTransparent background, single-line text, no border.
PillHighly rounded ends, compact single-line layout.
OutlineTransparent background with a variant-colored border.
BannerFull-width bar (does not float in a corner), bold text.
GlassSemi-transparent fill, blurred effect (where terminals support it).
ProgressShows a horizontal progress bar under the message. Use .progress_value(f) to set fill. Does not auto-dismiss while progress is below 1.0.
ActionIncludes inline action buttons. Use .actions(&["Label", ...]) to add them.
GroupedStacks multiple toasts into a single card with a count badge when many arrive at once.

ToastPosition and ToastAnim

Toasts anchor to one corner or edge. Set the position on the Toaster:

extern crate tuile;
use tuile::widgets::{Toaster, ToastPosition};
fn demo() -> Toaster {
Toaster::new().position(ToastPosition::TopRight)
}
fn main() {}

Six positions: TopLeft, TopRight, BottomLeft, BottomRight, TopCenter, BottomCenter. Default is BottomRight. Center positions place toasts in a vertical stack at the top or bottom center of the screen.

Four animations:

AnimBehavior
SlideSlides in from the edge. Default.
FadeFades in opacity.
PopScales up from small to full size.
NoneAppears instantly, no animation.

Set the animation with .anim(ToastAnim::Slide). Animations run on entry and on close. The close animation is always a fade, regardless of the entry animation.

Timeouts and pause on hover

By default, toasts expire after their timeout (5 seconds) and start their close animation. Hovering over a toast does not pause the timer unless you enable it:

extern crate tuile;
use tuile::widgets::Toaster;
fn demo() -> Toaster {
Toaster::new().pause_on_hover(true)
}
fn main() {}

With pause on hover, the toast will not expire while the mouse is over it. The timer resumes when the mouse leaves.

Dismissing toasts

Click a dismissible toast to close it immediately, or call toaster.dismiss(id, now) from code. The toast plays its close animation and is removed after the fade completes (150ms by default). toaster.dismiss_all(now) starts the close animation for every toast.

The id comes from toast.id(), which returns a unique u64 assigned when the toast is created. Store the id when you push a toast if you need to dismiss it later:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::Instant;
fn demo(toaster: &mut Toaster) {
let toast = Toast::new("Processing", "Please wait").no_timeout();
let id = toast.id();
toaster.push(toast);

// Later:
toaster.dismiss(id, Instant::now());
}
fn main() {}

Action style and inline buttons

The Action style adds clickable buttons inside the toast. Use .actions(&["Undo", "Dismiss"]) to provide the labels. The user navigates between buttons with Tab, Right, and Left, and activates the focused button with Enter or a click. When an action is activated, handle_key or handle_mouse returns Outcome::Changed.

To determine which action was activated, iterate toaster.toasts and check the focused_action field on toasts that have actions. The toasts vec is public, but focused_action is a private field, so you read it through the toast after the event. Typically you dismiss the toast after handling its action.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::crossterm::event::{Event, KeyEvent};
use std::time::Instant;

fn handle_event(toaster: &mut Toaster, event: Event) {
let outcome = match event {
    Event::Key(k) => toaster.handle_key(k),
    Event::Mouse(m) => toaster.handle_mouse(m),
    _ => Outcome::Ignored,
};

if outcome == Outcome::Changed {
    // Check which toast's action was activated by inspecting toaster.toasts.
    // The actual focused_action field is private, so this is a simplified example.
    toaster.dismiss_all(Instant::now());
}
}
fn main() {}

Because focused_action is not exposed via a public accessor, you typically use the Action style when you know which toast has actions (for example, the most recently pushed undo toast) and dismiss it after any action.

Grouped stacking

The Grouped style condenses multiple toasts into a single card. When more than one toast with the same variant exists, the stack shows the newest toast’s message and a count badge. This prevents notification spam from filling the screen. Grouped toasts share the same timeout behavior as individual toasts.

Builder options

The Toaster itself has configuration methods:

MethodEffect
style(ToastStyle)Default style for all toasts. Default is Card.
position(ToastPosition)Where toasts anchor. Default is BottomRight.
anim(ToastAnim)Entry animation. Default is Slide.
max_visible(usize)Maximum toasts shown at once. Older toasts are hidden until newer ones close. Default is 3.
bar(Edge)Border thickness for toasts. Default is Edge::Thin.
pause_on_hover(bool)Whether hovering pauses the timeout. Default is false.
reduce_motion(bool)Disables animations. Default is false.

When not to use toasts

Toasts are for transient messages that do not require action or acknowledgment. Use an InlineAlert or Banner for errors that block progress, NotificationCenter for messages the user should review later, or a modal dialog for decisions that require a response.

NotificationCenter

A scrollable inbox for persistent notifications. Supports grouping by time or category, filtering by variant, marking as read, and dismissing individual items or the entire list.

State and entries

NotificationCenterState owns the notification list. Each Notification has a title, message, variant, time label, read status, source, and group. Push notifications with state.push(notification):

extern crate tuile;
use tuile::prelude::*;
use tuile::widgets::notify::{Notification, NotificationCenterState};
fn demo() {
let mut state = NotificationCenterState::new();
state.push(
    Notification::new("Deploy successful", "Production build deployed", "Today")
        .variant(Variant::Success)
        .time("2m ago")
);
state.push(
    Notification::new("High memory", "Container using 92% memory", "Today")
        .variant(Variant::Warning)
        .time("15m ago")
);
}
fn main() {}

The third argument to Notification::new is the group label (for example, “Today”, “Yesterday”, “Last week”). The notification center groups entries by this string and renders a divider between groups.

Reading and dismissing

state.unread_count() returns the number of unread notifications. state.mark_read(index) marks one as read. state.dismiss(index) removes a notification. state.dismiss_all() clears the entire list. Indexes refer to positions in the state.notifications vec.

The notification center handles keyboard and mouse input:

InputAction
Up, DownNavigate between notifications
EnterMark the selected notification as read
DeleteDismiss the selected notification
Mouse clickSelect and mark as read

The top bar has a “Clear all” button that calls dismiss_all().

Filtering

state.filter is an Option<Variant>. When set, only notifications matching that variant are shown. Set it to None to show all. The center renders a filter bar with buttons for each variant. Clicking a filter toggles it on and off.

Scrolling

The center scrolls to keep the selected item visible. Rendered height is determined by the area you pass to render. If the notification list exceeds the visible area, a scrollbar appears on the right.

A full-width announcement bar for important messages. Banners typically sit at the top of the screen and remain visible until dismissed.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::notify::{Banner, BannerState, BannerStyle};

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = BannerState::default();
Banner::new("Maintenance scheduled for tonight at 10 PM")
    .variant(Variant::Warning)
    .style(BannerStyle::Tinted)
    .action("Details")
    .render(area, buf, &mut state);
}
fn main() {}

BannerStyle

Three styles:

StyleAppearance
SolidSolid variant background, white text. High contrast.
TintedVariant color tinted 15% into the background with a left accent rail. Subtle.
OutlineTransparent background with a variant-colored border.

Options

MethodEffect
variant(Variant)Color. Default is Variant::Default.
style(BannerStyle)Visual style. Default is Solid.
action(impl Into<String>)Adds a clickable action button on the right.
closeable(bool)Whether the banner has a dismiss button. Default is true.

The BannerState tracks visibility and action clicks. It starts hidden. Call state.show() to reveal the banner. When the user dismisses it, state.visible() returns false. When the action button is clicked, handle_mouse returns Outcome::Changed and state.action_clicked() returns true for one frame.

InlineAlert

A stateless alert box for inline warnings, errors, or info messages. Two forms: framed (default) and compact.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::notify::InlineAlert;

fn demo(area: Rect, buf: &mut Buffer) {
InlineAlert::new("Invalid input", "Password must be at least 8 characters")
    .variant(Variant::Error)
    .render(area, buf);
}
fn main() {}

The framed style draws a rounded border with an icon on the left, title in bold, and message below. The compact style removes the border and draws a single line with icon, title, and message inline.

MethodEffect
variant(Variant)Color. Default is Variant::Default.
dismissible(bool)Adds a close button (no state, purely visual). Default is false.
compact(bool)Switches to compact single-line layout. Default is false.

Because InlineAlert is stateless, dismissing it is the app’s job. If you want interactive dismissal, wrap it in your own widget with state.

count_badge

A helper function that draws a small circular badge with a count. Used by the notification center to show unread counts and by grouped toasts to show the stack size.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::notify::count_badge;

fn demo(buf: &mut Buffer, theme: &Theme) {
count_badge(buf, 10, 2, 5, theme);
}
fn main() {}

The signature is count_badge(buf, x, y, count, theme). It draws a small circle at (x, y) with the count centered inside. Counts above 99 are rendered as “99+”. The badge uses the theme’s accent color.

Loading

Indeterminate loaders, skeleton placeholders, and dimming overlays for async work.

Loader

An animated progress indicator in one of 20 styles. Every loader takes .now(Instant) for real-time animation or .elapsed(secs) for deterministic headless rendering. Single-row styles are vertically centered in their area; multi-row scenes fill every row you give them.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Loader::new(LoaderStyle::Scanner).now(now).render(area, buf);
}
fn main() {}

Bars

Animated progress bars that use one row, centered vertically.

StyleAppearancelabelcolorcolor2speed
ScannerBright cell with fading tail sweeping back and forthnoyesnoyes
CometTail traveling one way and wrappingnoyesnoyes
SweepMaterial indeterminate: eased segment sliding acrossnoyesnoyes
FillDrainBar fills from left, then drains from leftnoyesnoyes
PulseWhole bar breathesnoyesnoyes
StripesBarber-pole stripes marching rightnoyesyesyes
RainbowHue-cycling rainbow flowing along the barnononoyes
SnakeThree dots chasing along a dotted tracknoyesyesyes
ChaseLED chase: one lit segment with dim trailnoyesnoyes
Blocks▰▰▰▱▱▱ filling and emptyingnoyesnoyes
WaveTraveling sine wave of eighth-blocksnoyesyesyes
BounceBall bouncing between bracketsnoyesnoyes
PingExpanding rings (((●)))noyesnoyes
HeartbeatScrolling ECG trace with glowing spikenoyesyesyes

All bar styles ignore label (the text is drawn separately to the left of the loader area). color defaults to $primary, color2 defaults to $accent. When given one row, they render normally. When given more than one row, the animation is vertically centered.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Loader::new(LoaderStyle::Sweep).color(Rgb(100, 150, 255)).speed(1.5).now(now).render(area, buf);
}
fn main() {}

Text

The label is the animated content. These styles use one row.

StyleAppearancelabelcolorcolor2speed
Ellipsislabel, label., label.., label...yesyesnoyes
ShimmerLabel with sweeping highlight bandyesyesyesyes
TypewriterLabel typed out with cursor, held, cleared, repeatedyesyesnoyes

For textual styles, the label is required (the default is an empty string, which renders nothing). color is the text color, color2 is the highlight color (Shimmer only). When given more than one row, the text is vertically centered.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Loader::new(LoaderStyle::Typewriter).label("Indexing workspace").now(now).render(area, buf);
}
fn main() {}

Scenes

Full-area animations that use every row you give them.

StyleAppearancelabelcolorcolor2speed
EqualizerBouncing spectrum barsnoyesyesyes
RainMatrix rainnoyesnoyes
RadarRadar sweep over a dot field with blipsnoyesyesyes

All scene styles ignore label. color is the main color, color2 is the accent color. These styles need at least 3 rows to be recognizable. When given only one row, they attempt to render but the result is a degraded horizontal slice of the animation (Rain becomes a flickering line, Radar becomes a scanning line, Equalizer becomes a flat bar).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Loader::new(LoaderStyle::Radar).color(Rgb(0, 200, 100)).color2(Rgb(255, 200, 0)).now(now).render(area, buf);
}
fn main() {}

Builder options

MethodEffect
label(s)Text left of the bar; for textual styles this is the animated text
color(rgb)Main color (default $primary)
color2(rgb)Secondary color for two-tone styles (default $accent)
speed(f)Playback rate; 1.0 is the designed speed
now(Instant)Animation phase from app clock
elapsed(f32)Explicit elapsed seconds for deterministic rendering
theme(&Theme)Override theme

Skeleton

Painted placeholder shapes with a diagonal shimmer band. Use them to reserve space while content loads.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Skeleton::new().shape(SkeletonShape::Card).now(now).render(area, buf);
}
fn main() {}

Shapes

ShapeLayout
TextOne bar per entry in .lines([widths]) (cells), a blank row between when there’s room
CardHero image block on top, then a title and two body lines
AvatarAvatar block with a name and handle beside it
ListStacked avatar rows: a list waiting for its items
TableHeader cells and rows of column cells (2-4 columns depending on width)
ChartBars of varying height along a baseline

The default shape is Text with line widths [60, 45, 50] cells. For Text, pass custom widths with .lines(&[80, 70, 85]). Other shapes ignore .lines() and lay out based on the area size.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Skeleton::new().shape(SkeletonShape::List).now(now).render(area, buf);
}
fn main() {}

The shimmer band sweeps diagonally across the skeleton. All shapes respect .now(Instant) or .elapsed(secs) for animation phase.

LoadingOverlay

Dim an area and center a Loader with an optional message over it. Draw this last, over the content it covers.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
LoadingOverlay::new(Loader::new(LoaderStyle::Sweep)).message("Fetching rows").now(now).render(area, buf);
}
fn main() {}

The overlay dims the existing buffer content by blending each cell’s foreground and background toward the theme background color. The dimming amount is controlled with .dim(f) where 0.0 leaves content untouched and 1.0 hides it completely (default 0.65).

The loader is centered in the area. For single-row loader styles, the .width(cells) option controls the loader width (default 24). Multi-row loaders occupy a fixed centered region.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
LoadingOverlay::new(Loader::new(LoaderStyle::Scanner).color(Rgb(100, 200, 255)))
    .message("Syncing")
    .dim(0.75)
    .width(32)
    .now(now)
    .render(area, buf);
}
fn main() {}

The message appears below the loader in muted text. When the area is too small (less than 5 rows or 20 columns), the overlay renders the dimming only, skipping the loader and message.

MethodEffect
message(s)Text below the loader
dim(f)Fade strength: 0.0 (untouched) to 1.0 (hidden); default 0.65
width(u16)Loader width for single-row styles; default 24
now(Instant)Animation phase from app clock
elapsed(f32)Explicit elapsed seconds
theme(&Theme)Override theme

Layout and chrome

Structural widgets: split panes, scroll views, scrollbars, panels, collapsible sections, and header/footer chrome.

SplitPane

SplitPane splits an area into two resizable regions with a draggable divider. Horizontal splits stack vertically, vertical splits sit side by side.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::SplitSize;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = SplitState::new(SplitSize::Ratio(0.3), 10, 10);
let (left, right) = SplitPane::new()
    .direction(Direction::Horizontal)
    .render_split(area, buf, &mut state);
}
fn main() {}

SplitState owns initial (the size spec at creation), pos (current first-pane size in cells), min_first, min_second, and the cached layout. render_split returns (first_rect, second_rect).

MethodEffect
direction(Direction)Horizontal (side by side) or Vertical (stacked)
divider(SplitDivider)Line (default), Thick, Dotted, Hidden

Keys and mouse: Drag the divider to resize. Double-click the divider to reset to initial. Call state.resize_by(delta) from your key handler to resize by delta cells. state.collapse_first(), state.collapse_second(), and state.restore() hide and show panes.

When not to use: Single fixed split with no interaction: use ratatui_core::layout directly. Three-way split: nest two SplitPane calls or use layout::stack.

ScrollView

ScrollView renders content into an offscreen buffer larger than the viewport, then blits the visible window. Wheel, arrow keys, PageUp/PageDown, Home/End scroll. Optional smooth tweened scrolling.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::draw::put;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = ScrollViewState::new();
ScrollView::new()
    .content_size(200, 100)
    .smooth(true)
    .render_with(area, buf, &mut state, |cbuf, carea| {
        put(cbuf, 0, 0, "Content...", carea.width, Style::new());
    });
}
fn main() {}

ScrollViewState tracks offset_x, offset_y, anim_y (the vertical scroll tween), content (width, height), viewport, and vbar/hbar (scrollbar states). Call state.scroll_to(x, y, now, dur) or state.scroll_by(dx, dy, now, dur) to scroll programmatically. A zero duration snaps; a non-zero duration animates.

MethodEffect
content_size(w, h)Offscreen buffer dimensions in cells
smooth(bool)Tween vertical scroll (default false)
scrollbars(ScrollBars)Auto (default), Always, Never

When you need it: Content larger than the viewport (a 200-line log in a 20-row area, a wide table). When you want smooth animated scrolling. When you need scrollbars.

When you do not: The content fits. You are already paginating in your data model (a TUI table with a virtual scroll). Rendering into a temporary buffer costs allocation; if you can clip with ratatui_core::layout instead, do.

Scrollbar

Scrollbar is the reference implementation of the tuile widget contract. It uses eighth-block glyphs for the thumb ends (Textual style), responds to wheel and drag, and updates state.offset.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = ScrollbarState::new();
let (total_rows, visible_rows) = (200, 10);
Scrollbar::vertical(total_rows, visible_rows)
    .offset(40)
    .render(Rect::new(39, 0, 1, 10), buf, &mut state);
}
fn main() {}

ScrollbarState tracks offset, content, viewport, axis, and drag state. state.handle_mouse(m) moves offset on wheel or drag and returns Outcome::Changed when it moved.

MethodEffect
vertical(content, viewport)Vertical scrollbar (axis defaults Vertical)
horizontal(content, viewport)Horizontal scrollbar
offset(usize)Current scroll position
hide_when_fits(bool)Default true: invisible when content <= viewport

The source shape: Scrollbar is simple enough to show in full. The widget is a builder holding content, viewport, offset, axis, hide_when_fits, and a theme. impl StatefulWidget for Scrollbar paints the track and thumb. ScrollbarState implements Interactive and turns mouse events into offset changes. This pattern repeats across tuile: builder, render, state, interactive.

Panel

Panel draws a bordered container with optional title, subtitle, padding, shadow, and background fill. Returns the inner content rect.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::draw::Border;

fn demo(area: Rect, buf: &mut Buffer) {
let inner = Panel::new()
    .title("Settings")
    .border(Border::Round)
    .shadow(true)
    .render(area, buf);
}
fn main() {}

Panel has no state. It is a Widget-like builder that returns a Rect instead of implementing Widget.

MethodEffect
title(&str)Top-left title
title_align(Alignment)Where the title sits
title_right(&str)A second title on the right of the top edge
subtitle(&str)Bottom-left subtitle
footer(&[(&str, &str)])Key and description pairs along the bottom edge
border(Border)Any Border style: Round, Thick, Double, Heavy, Tall, Panel, Wide, Inner, Outer, Dashed, Solid, Ascii, Hkey, Vkey, Blank, None
border_color(Rgb)Override the border colour
background(PanelBg)Transparent, Surface, Panel, Boost
padding(u16)Inner padding
shadow(bool)Drop shadow
badge(&str)A badge in the title row
variant(Variant)Title colour
focused(bool)Use the focused border colour

Panel::card() presets a surface background, a round border and a shadow. Panel::section() presets no border, for a title row with a rule instead of a box.

Placeholder

Placeholder is Textual’s placeholder block: cycles colours, shows dimensions and a label. Use it to sketch layouts.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
Placeholder::new()
    .index(0)
    .render(area, buf, "Sidebar");
}
fn main() {}

index picks a colour from a 22-colour palette. variant(PlaceholderVariant) overrides the display: Default (show name), Size (show WxH), Text (show custom text). The name is passed to render(), not a builder method.

Collapsible and Accordion

Collapsible is a header row that expands to reveal content. Click the header or call state.toggle() to open and close. The height animates.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::draw::put;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = CollapsibleState::new(false);
let used = Collapsible::new()
    .title("Details")
    .render_with(area, buf, &mut state, 10, |inner, buf| {
        put(buf, inner.x, inner.y, "Content", inner.width, Style::new());
    });
}
fn main() {}

CollapsibleState::new(open) sets the initial state. state.open is public. state.animating(now) is true while the height tween is active.

MethodEffect
title(&str)Header text
subtitle(&str)Secondary header text
header_style(CollapsibleHeader)Plain, Panel (bordered), Underline
markers(closed, open)The two glyphs shown before the title
border(Border)Frame around the whole widget
duration(Duration)Expand and collapse time
focused(bool)Focus styling

render_with takes content_height (the fully expanded height) and a closure that draws into the content area.

Accordion stacks multiple collapsibles. Set exclusive(true) to enforce single-open mode.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::draw::put;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = AccordionState::new(3, true);
Accordion::new()
    .titles(&["General", "Privacy", "Advanced"])
    .render_with(area, buf, &mut state, &[5, 5, 5], |idx, inner, buf| {
        put(buf, inner.x, inner.y, &format!("Section {}", idx), inner.width, Style::new());
    });
}
fn main() {}

AccordionState::new(count, exclusive) creates count collapsible sections. state.states is public (a Vec<CollapsibleState>). The render closure receives (index, area, buf). Pass heights as a slice of content heights per section.

AppHeader

Fixed-height application header with icon, title, subtitle, clock, and clickable action labels.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = AppHeaderState::default();
AppHeader::new()
    .title("Settings")
    .subtitle("Preferences")
    .icon("⊛")
    .clock(true)
    .actions(&["Help", "Quit"])
    .render(area, buf, &mut state);
if let Some(idx) = state.take_action() {
    // handle action click
}
if state.take_icon_click() {
    // handle icon click
}
}
fn main() {}

AppHeaderState tracks hover and pressed actions. state.take_action() returns Some(index) when an action is clicked. state.take_icon_click() returns true when the icon is clicked and clears the flag.

MethodEffect
title(&str)Main title (left, after icon)
subtitle(&str)Subtitle (left, below title if multi-row header)
icon(&str)Left icon (clickable)
clock(bool)Right-aligned HH:MM:SS clock
actions(&[&str])Right-aligned clickable labels (left of clock)

KeyFooter

Horizontal footer that renders key bindings as [key] description pairs. Truncates by priority when narrow.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = KeyFooterState::default();
KeyFooter::new()
    .bindings(&[("q", "Quit"), ("^S", "Save"), ("?", "Help")])
    .render(area, buf, &mut state);
if let Some(idx) = state.take_pressed() {
    // handle binding click
}
}
fn main() {}

FooterBinding::new(key, desc) creates a binding. Call .priority(n) to set sort order (lower renders first, higher survives truncation). Call .disabled() to grey out a binding.

state.take_pressed() returns Some(index) when a binding is clicked.

StatusLine

One-row status strip of separated segments with optional right-aligned text. Each segment has an optional icon, text, and colour.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::{StatusSegment, StatusSep};
use tuile::theme::Rgb;

fn demo(area: Rect, buf: &mut Buffer) {
StatusLine::new(&[
    StatusSegment::new("π"),
    StatusSegment::new("Opus 5").icon("◕").color(Rgb(255, 140, 0)),
    StatusSegment::new("/tmp").icon("⌂"),
    StatusSegment::new("4.0%/1M"),
])
.sep(StatusSep::Chevron)
.right("omp")
.render(area, buf);
}
fn main() {}
MethodEffect
sep(StatusSep)Chevron (›, default), Dot, Pipe, Arrow, Space
right(&str)Right-aligned text (colour defaults th.accent)
right_color(Rgb)Override right text colour
bg(Rgb)Background strip colour (default th.background)

StatusLine is stateless and implements Widget.

Steps

Horizontal or vertical step indicator for process flows. Shows step labels, status (pending, active, complete, error), and connecting lines.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = StepsState::default();
Steps::new(&["Start", "Build", "Deploy"])
    .active(1)
    .render(area, buf, &mut state);
}
fn main() {}
MethodEffect
active(usize)Current step index (drawn as active)
statuses(&[StepStatus])Per-step status: Pending, Active, Done, Error, Skipped
direction(Direction)Horizontal (default) or Vertical

StepsState is currently a placeholder (the widget has no interaction). Steps also provides a Timeline widget for vertical time-stamped entries.

Layout helpers

The tuile::layout module provides small helpers on top of ratatui_core::layout.

Centring:

extern crate tuile;
extern crate ratatui_core;
use tuile::layout::{center, center_h};
use tuile::ratatui_core::layout::Rect;

fn demo(area: Rect) {
let popup = center(area, 60, 20);
let banner = center_h(area, 80);
}
fn main() {}

center(area, w, h) returns a w×h rect centred in area (clamped to fit). center_h(area, w) centres horizontally only, full height.

Padding:

extern crate tuile;
extern crate ratatui_core;
use tuile::layout::{pad, pad_trbl};
use tuile::ratatui_core::layout::Rect;

fn demo(area: Rect) {
let inner = pad(area, 2, 1);
let inner2 = pad_trbl(area, 1, 2, 1, 2);
}
fn main() {}

pad(area, x, y) shrinks by x cells left/right and y cells top/bottom. pad_trbl(area, t, r, b, l) takes individual sides (CSS order).

Rows and columns:

extern crate tuile;
extern crate ratatui_core;
use tuile::layout::{stack, columns};
use tuile::ratatui_core::layout::Rect;

fn demo(area: Rect) {
let rows = stack(area, &[3, 1, 5], 1);
let cols = columns(area, 3, 2);
}
fn main() {}

stack(area, heights, gap) splits into fixed-height rows with gap between them; the last row takes the remainder. columns(area, n, gap) splits into n equal columns with gap between them.

Popup placement:

extern crate tuile;
extern crate ratatui_core;
use tuile::layout::popup_below;
use tuile::ratatui_core::layout::Rect;

fn demo(anchor: Rect, bounds: Rect) {
let popup = popup_below(anchor, 40, 10, bounds);
}
fn main() {}

popup_below(anchor, w, h, bounds) places a w×h popup below anchor, flipping above when it would overflow bounds, and sliding horizontally to stay inside.

Content

Widgets for displaying text, logs, dates, colors, and rich formatted content.

Text widgets

Label

Plain text with variant tinting, alignment, and wrapping.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
Label::new("Status OK").variant(Variant::Success).bold(true).render(area, buf);
}
fn main() {}
MethodEffect
variant(Variant)Tint with theme color
muted(bool), disabled(bool)Muted or disabled appearance
bold(bool), italic(bool), underline(bool)Text modifiers
align(Alignment)Left, Center, or Right (default Left)
wrap(bool)Wrap to area width (default false)
bg(Rgb)Background override
ellipsis(bool)Truncate with … (default true)

Label::height_for(width) returns wrapped height.

Rule

Horizontal or vertical separator with optional title.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
Rule::horizontal().title("Settings").render(area, buf);
}
fn main() {}

RuleStyle: Solid, Heavy, Double, Dashed, Dotted, Ascii, Blank. Methods: style(RuleStyle), title(&str), title_align(Alignment), color(Rgb).

Badge, Pill, KeyCap

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
Badge::new("v1.2").variant(Variant::Accent).render(area, buf);
}
fn main() {}

Badge has variant(Variant), style(BadgeStyle) (Filled, Outline, Soft), icon(&str). Badge::width(text) returns rendered width.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
Pill::new("Live").variant(Variant::Success).render(area, buf);
}
fn main() {}

Pill is a rounded badge using block glyphs.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
KeyCap::new("Ctrl").render(area, buf);
}
fn main() {}

KeyCap renders keyboard keys with inverted panel style.

Underlined clickable link with visited state.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = LinkState::default();
Link::new("Documentation").url("https://example.com").render(area, buf, &mut state);
}
fn main() {}

LinkState fields: url, visited, hit, clicked. Call state.take_clicked() to consume the event. Mouse and Enter activate.

StatCard

Dashboard card with large value, label, delta, and sparkline.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
StatCard::new("1,245", "Active Users")
    .delta(12.5, true)
    .trend(&[10.0, 12.0, 11.5, 13.0])
    .variant(Variant::Primary)
    .render(area, buf);
}
fn main() {}

Methods: delta(f64, bool), trend(&[f64]), variant(Variant), icon(&str), bordered(bool). StatCard::min_height() returns 3.

Markup

Rich console markup with Rich-style [tag] syntax. Markup::parse(input, theme) returns ratatui Text. Escape [ as [[.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::text::Markup;

fn demo(buf: &mut Buffer) {
let th = Theme::default();
let text = Markup::parse("[b]Bold[/b] and [success]green[/]", &th);
}
fn main() {}

Supported tags

TagEffect
[b], [i], [u], [s]Bold, italic, underline, strikethrough
[dim], [reverse]Dim, reverse video
[primary], [secondary], [accent]Theme colors
[success], [warning], [error], [muted]Semantic theme colors
[#rrggbb]RGB hex foreground (e.g. [#ff5500])
[on #rrggbb]RGB hex background
[/] or [/tag]Close tag

Tags nest and stack. Unknown tags render as literal text.

Markdown

CommonMark subset renderer with scrolling.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::markdown::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = MarkdownState::default();
let src = "# Heading\n\nThis is **bold** and *italic*.";
Markdown::new(src).render(area, buf, &mut state);
}
fn main() {}

MarkdownState handles scrolling. Arrow keys and Page Up/Down navigate.

Supported syntax

Block elements:

SyntaxRendered as
# Heading to #### HeadingHeadings (1-2 underlined)
```lang fenced blocksCode with language label
---, ***, ___Horizontal rule
> QuoteBlockquote with vertical bar
- item or * itemUnordered list (nested: two-space indent)
1. itemOrdered list
- [ ] or - [x]Task list (checked/unchecked)
| header | with | --- |Tables

Inline elements:

SyntaxEffect
**bold**Bold
*italic*Italic
code Inline code with background
~~strike~~Strikethrough
[text](url)Link (URL not shown, text underlined)

LogView

Scrolling log viewer with level badges, filtering, and auto-follow.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::log::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = LogViewState::new();
state.push(LogLevel::Info, "Server started on port 8080");
state.push(LogLevel::Warn, "High memory usage detected");
LogView::new().timestamps(true).render(area, buf, &mut state);
}
fn main() {}

LogViewState fields: lines, follow (default true), scroll, filter, filter_mode (Highlight or Only), max_lines.

Call state.push(level, text) to add entries. Levels: Trace, Debug, Info, Warn, Error, Success.

Keys: Up/Down scroll one line, Page Up/Down by viewport, Home/End toggle follow.

MethodEffect
timestamps(bool)Show HH:MM:SS column (default false)
level_column(bool)Show level badges (default true)
wrap(bool)Wrap long lines (default false)
max_lines(usize)Ring buffer size
border(Border), title(&str)Border and title

Calendar and DatePicker

Calendar

Month grid with navigation, selection, and marks.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::calendar::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = CalendarState::default();
Calendar::new().show_week_numbers(true).render(area, buf, &mut state);
}
fn main() {}

CalendarState fields: year, month, cursor_day, selected (Option<(i32, u32, u32)>), today. Arrow keys move cursor, Enter selects, mouse clicks select.

MethodEffect
week_start(Weekday)Sunday or Monday (default Sunday)
show_adjacent(bool)Days from adjacent months (default true)
show_week_numbers(bool)ISO week column (default false)
min(y, m, d), max(y, m, d)Restrict range
disabled(fn)Custom disable predicate
marks(&[(y, m, d, Variant)])Highlight dates

DatePicker

Date field with popup calendar.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::calendar::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = DatePickerState::default();
DatePicker::new().focused(true).render(area, buf, &mut state);
}
fn main() {}

DatePickerState fields: cal, open, hit. Space toggles popup, Escape closes. Selected date is in state.cal.selected. Call render_overlay(&self, state, buf, bounds) after page render for the popup.

Color widgets

Swatches

Row of selectable color chips with optional labels.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::color::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = SwatchesState::default();
let colors = vec![Rgb(255, 0, 0), Rgb(0, 255, 0), Rgb(0, 0, 255)];
let labels = ["Red", "Green", "Blue"];
Swatches::new(&colors).labels(&labels).render(area, buf, &mut state);
}
fn main() {}

SwatchesState fields: cursor, hit. Arrow keys navigate, Enter selects. Selected color is colors[state.cursor].

Methods: labels(&[&str]), cell_width(u16) (default 4), focused(bool), enabled(bool), bg(Rgb).

ColorPicker

HSL picker with hue strip and saturation/lightness grid.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::color::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = ColorPickerState::default();
ColorPicker::new().render(area, buf, &mut state);
}
fn main() {}

ColorPickerState fields: hue (0.0..360.0), sat, light (0.0..1.0), mode, hit. Arrow keys adjust. state.rgb() returns current Rgb.

GradientBar

Horizontal gradient with labels and marker.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::color::*;

fn demo(area: Rect, buf: &mut Buffer) {
let stops = vec![Rgb(0, 0, 255), Rgb(255, 0, 0)];
GradientBar::new(&stops).labels("Min", "Max").marker(0.5).render(area, buf);
}
fn main() {}

Gradient interpolates evenly across stops. Methods: labels(&str, &str), marker(f32) (0.0..1.0).

ThemePalette

Renders every role of a theme as labeled swatches.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::color::*;

fn demo(area: Rect, buf: &mut Buffer) {
ThemePalette::new().render(area, buf);
}
fn main() {}

Steps and Timeline

Steps

Horizontal or vertical step indicator.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::steps::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = StepsState::default();
Steps::new(&["Start", "Build", "Deploy"]).active(1).render(area, buf, &mut state);
}
fn main() {}

Methods: statuses(&[StepStatus]), active(usize), numbered(bool), vertical(bool), compact(bool).

StepStatus: Done, Active, Pending, Error, Skipped.

Timeline

Vertical timeline with time labels.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::steps::*;

fn demo(area: Rect, buf: &mut Buffer) {
let entries = vec![
    TimelineEntry {
        time: "10:00".into(),
        title: "Build started".into(),
        description: None,
        variant: Variant::Primary,
    },
];
Timeline::new(entries).render(area, buf);
}
fn main() {}

Methods: compact(bool), reverse(bool). Each TimelineEntry has time, title, description (Option<String>), variant.

Big text and menus

Big text rendered in box-drawing or pixel fonts, plus vertical menus with animated selection styles and a grouped settings list.

BigText

Renders text at 3 or 5 rows per line in one of four fonts. Use it for headings, splash screens and menu items.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
BigText::new("TUILE")
    .font(BigFont::Block5)
    .gradient(&[Rgb(230, 60, 60), Rgb(255, 160, 40)])
    .render(area, buf);
}
fn main() {}

Fonts

Four fonts available. Lowercase letters are uppercased. Unsupported characters fall back to ?.

Box3 (default): 3-row heavy box-drawing font. Solid strokes in every terminal font. Supports A-Z, 0-9, and .,:-_+!?/[]<>. Most letters are 3 cells wide. Example rendering:

┏━┓┳ ┳┏━┓
┣━┫┣━┫┃┫ 
┻ ┻┻ ┻┗━┛

Block5: 5-row figlet-style font with painted solid cells (background paint, not foreground glyphs). Supports A-Z, 0-9, and .,:-_+!/[]<>. Most letters are 5 cells wide, numbers 4 or 5 wide. Example rendering:

 ### 
#   #
#####
#   #
#   #

Half3: 3-row font using half-blocks ▀▄ for partial cells with painted solids. Same character set as Block5. Pixel fonts already carry a 1-cell gap after each glyph. Most letters are 5 cells wide plus gap. Example rendering:

 ▀▀▀ 
█▀▀█
█  █

Thin3: 3-row light box-drawing font (─│╭╮╰╯) for elegant headings. Same character set as Box3. Most letters are 3 cells wide. Example rendering:

╭─╮┬ ┬╭─╮
├─┤├─┤│ 
╯ ╯╯ ╯╰─╯

The pixel fonts (Block5, Half3) include a 1-cell trailing gap in their width calculation. Line fonts (Box3, Thin3) add a 1-cell gap between glyphs by default.

Builder options

MethodEffect
font(BigFont)Choose the font (default Box3)
color(Rgb)Flat color for the text
gradient(&[Rgb])Left-to-right color stops across the text
bg(Rgb)Background behind glyph strokes (default th.background); pass the panel color when drawing on a card
spacing(u16)Cells between glyphs (default: the font’s natural gap, 1 for line fonts, 0 for pixel fonts)
align(Alignment)Left, Center, or Right (default Left)
bold(bool)Apply bold modifier (default false)
theme(&Theme)Override the default theme

BigText::width_of(text, font, spacing) returns the cell width a string takes in a given font.

BigTitle

A title with optional subtitle and rule.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
BigTitle::new("WELCOME")
    .subtitle("Select an option to begin")
    .font(BigFont::Block5)
    .gradient(&[Rgb(100, 200, 255), Rgb(180, 100, 255)])
    .rule(true)
    .render(area, buf);
}
fn main() {}

The title is center-aligned. If a subtitle is set, it appears 2 rows below the title. If rule(true), a horizontal rule is drawn after the subtitle (or title if no subtitle).

MethodEffect
subtitle(&str)Optional subtitle below the title
font(BigFont)Choose the font (default Block5)
gradient(&[Rgb])Left-to-right color stops across the title
rule(bool)Draw a horizontal rule after the text (default false)
theme(&Theme)Override the default theme

height() returns the total rows the title occupies with its current settings.

BigMenu

A vertical menu of big-font items with many rendering styles.

Basic usage

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = BigMenuState::default();
BigMenu::new(&["START", "OPTIONS", "QUIT"])
    .style(BigMenuStyle::Arrows)
    .focused(true)
    .render(area, buf, &mut state);
if let Some(i) = state.take_activated() {
    // user pressed Enter or clicked item i
}
}
fn main() {}

The menu can be built from simple strings or from BigMenuItem structs that carry a description, icon, disabled flag, and hotkey.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = BigMenuState::default();
let items = [
    BigMenuItem::new("NEW GAME").icon("▸").hotkey('n'),
    BigMenuItem::new("LOAD").icon("◧").disabled(true),
    BigMenuItem::new("QUIT").icon("◆").hotkey('q'),
];
BigMenu::items(&items)
    .style(BigMenuStyle::Cards)
    .focused(true)
    .render(area, buf, &mut state);
}
fn main() {}

State

BigMenuState owns the selection cursor, mouse hit boxes, and animation tweens.

FieldPurpose
selectedIndex of the currently selected item
hitsMouse hit boxes (read-only, updated on render)
MethodPurpose
take_activated()Returns Some(index) when an item was chosen with Enter/Space/click since the last call
animating(now)True if an animation is active (for Underline style tween)

Keys and mouse

KeyAction
Up, kMove selection up
Down, jMove selection down
LeftMove selection up (wraps to last)
RightMove selection down (wraps to first)
HomeSelect first item
EndSelect last item
Enter, SpaceActivate selected item

Mouse hover changes the selection. Click activates the item. Wheel scrolls through items.

Styles

Ten rendering styles, each with different selection markers, animations, and layout.

Plain (default): Selected item in accent or gradient, others muted.

Arrows: Arrow markers ▸ and ◂ flanking the selected item with animated bounce.

Boxed: Round border frame around the selected item.

Underline: Underline under the selected item that tweens between items when selection changes.

Glow: Selected item bright with a horizontal gradient that sweeps across it, unselected items dimmed.

Shadow: Drop shadow (offset right and down), selected item raised in accent.

Bracket: Big bracket glyphs [ and ] around the selected item.

Horizontal: Items laid in one row separated by a gap. Left and Right navigate, Up and Down wrap.

Cards: Each item in its own card with label and description (requires BigMenuItem with description).

Retro: Pulse-blink selected item, > prefix cursor, all caps, monochrome.

Builder options

MethodEffect
style(BigMenuStyle)Choose the rendering style (default Plain)
gap(u16)Blank rows between items (default 1)
focused(bool)Draw focus indicator (default false)
align(Alignment)Left, Center, or Right (default Center)
color(Rgb)Color of the selected item (default th.accent)
selected_gradient(&[Rgb])Gradient across the selected item instead of flat color
font(BigFont)Choose the font (default Box3)
now(Instant)Pass the current time for animations
theme(&Theme)Override the default theme

height() returns the total rows the menu needs to show all items. width() returns the widest item including the style’s side chrome.

When an item is too wide for the available area, the menu first tries the label with letters touching (spacing 0), then truncates to the longest prefix that fits.

OptionList

A settings menu with grouped rows, in-place value cycling, and a group index for a sidebar. The shape of omp’s settings screen.

Structure

An OptionListState owns a Vec<OptionGroup>. Each group has a title and a Vec<OptionItem>. Each item has a stable key, a visible label, a value, and an optional hint.

Values can be Bool, Choice (one of N strings), Int (with min/max/step), Text (read-only, the app edits it elsewhere), or Action (a command). Left/Right, h/l and Space cycle the value at the cursor and report the key through take_changed; Enter reports it through take_activated instead, so the app can open a dropdown or a field for it. Text and Action never cycle.

Each row can carry trail cells - where the value came from, when a change takes effect - which are drawn as aligned columns after the value, and dim(true) for a row showing something the user did not choose, which mutes the value and the trail. value_rect(row) gives back the cell the value was drawn in, so an editor can be placed over it.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = OptionListState::new(vec![
    OptionGroup::new("Theme", vec![
        OptionItem::choice("theme", "Dark Theme", &["titanium", "nord", "dracula"], 0),
        OptionItem::bool("colorblind", "Color-Blind Mode", false)
            .trail(["default", "next session"])
            .dim(true),
    ]),
    OptionGroup::new("Display", vec![
        OptionItem::int("fps", "Frame rate", 60, 15, 120, 15),
    ]),
]);
OptionList::new().focused(true).render(area, buf, &mut state);
if let Some(key) = state.take_changed() {
    let theme = state.choice("theme");
}
if let Some(key) = state.take_activated() {
    // open an editor for `key`
}
}
fn main() {}

State methods

MethodPurpose
take_changed()Returns the key of the row whose value changed (or whose action fired) since the last call
get(&str)Returns a reference to the value for a key
get_mut(&str)Returns a mutable reference to the value for a key
bool(&str)Returns Some(bool) if the key is a Bool value
choice(&str)Returns the selected option text of a Choice row
int(&str)Returns Some(i64) if the key is an Int value
group_index()Index of the group the cursor is in (for a sidebar)
jump_to_group(usize)Move the cursor to the first row of a group

Keys and mouse

KeyAction
Up, kMove cursor up
Down, jMove cursor down
HomeJump to first row
EndJump to last row
Left, hCycle value backward (Bool toggles, Choice moves to previous option, Int decrements by step)
Right, lCycle value forward
EnterCycle value forward for Bool/Choice/Int, report changed for Text/Action

Mouse hover changes the cursor. Click on a row selects it and cycles its value forward. Wheel scrolls through rows.

The cursor is a flat index over all items in group order. Group headers are visible but not selectable.

Widget options

MethodEffect
focused(bool)Draw focus indicator (default false)
value_column(u16)Column where values start (default: widest label + 4)
cursor_glyph(&str)Glyph shown to the left of the cursor row (default ❯)
theme(&Theme)Override the default theme

The widget renders group headers in muted text, then each row as cursor label value hint. The hint (if present) appears after the value in muted text only when the cursor is on that row.

The scrollbar appears when the list is taller than the area. keep_visible keeps the cursor row visible.

The AI harness

Widgets for building a coding agent or chat UI in a terminal. They render the state you supply and handle navigation, expansion, and mouse interaction. None of them talk to a model or carry provider code.

Chat

Conversation display, streaming text, thinking indicators, approvals, and token gauges.

ChatView

A scrollable chat log with bubbles or full-width messages. Assistant messages have a role-coloured vertical bar on the left.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai::{ChatView, ChatState, ChatMessage, Role};

fn demo(area: Rect, buf: &mut Buffer) {
let mut state = ChatState::default();
state.push(ChatMessage::new(Role::User, "Hello!"));
state.push(ChatMessage::new(Role::Assistant, "Hi! How can I help?"));
ChatView::new().bubbles(true).render(area, buf, &mut state);
}
fn main() {}

The state owns messages: Vec<ChatMessage>, scroll: usize, and follow: bool. When follow is true, the view scrolls to the bottom after every push. Mouse clicks on thinking blocks toggle their collapsed flag. Keys: j/k or arrow keys scroll, g/G jump to top or bottom, Space/Shift+Space page, and clicking the “N new” pill when scrolled up re-follows.

MethodEffect
.bubbles(bool)Bubble layout (rounded inset messages) or full-width rows
.bar(Edge)Thickness of the left role bar on assistant messages (thin by default)
.compact(bool)Dense spacing between messages
.show_time(bool)Show timestamp on each message
.focused(bool)Focused state (brighter scrollbar)
.hover(bool)Track mouse hover row in state.hover_row
.max_width(u16)Maximum message width in bubbles mode
.now(Instant)Clock for thinking spinners and elapsed timers
.highlighter(Highlighter)Syntax highlighting for code blocks; ranges are grapheme offsets

ChatMessage

One message in the conversation. The role is User, Assistant, System, or Tool. Text is rendered with inline markdown: **bold**, `code`, and # headings. Bullets (- or * ) become • .

extern crate tuile;
use tuile::widgets::ai::{ChatMessage, Role, ChatBlock};
fn demo() {
let msg = ChatMessage::new(Role::User, "Add retry logic")
    .author("irvin")
    .time("14:02");
}
fn main() {}

For richer content use .block() or .with_blocks() with ChatBlock variants. When blocks are non-empty, the text field is ignored: a message with both text and blocks renders only the blocks.

ChatBlock

Content blocks inside a message.

VariantRenders as
Text(String)Plain text with inline markdown: **bold**, `code` in a different colour, # and ## as headings
Code { lang, text }A fenced code block with an optional language label
Thinking { text, secs, collapsed, streaming }An expandable thinking block with elapsed time. Clicking the header toggles collapsed in place
ToolCall { name, summary, status, duration_ms }An inline tool-call badge. status is Pending, Running, Done or Error; the duration is optional
Divider(String)A horizontal rule with a centred label, for timestamps or turn markers
Facts(Vec<(String, String)>)Aligned key/value pairs rendered with the KeyValueList layout. Keys are column-aligned within each block
Alert { level, title, text }An inline alert rendered with the InlineAlert look. level is a Variant (Default, Success, Warning, Error)
extern crate tuile;
use tuile::widgets::ai::{ChatMessage, Role, ChatBlock, ToolStatus};
fn demo() {
let msg = ChatMessage::new(Role::Assistant, "")
    .with_blocks(vec![
        ChatBlock::Text("Checking the source:".into()),
        ChatBlock::ToolCall {
            name: "read_file".into(),
            summary: "src/main.rs".into(),
            status: ToolStatus::Done,
            duration_ms: Some(42),
        },
        ChatBlock::Code {
            lang: Some("rust".into()),
            text: "fn main() { println!(\"ok\"); }".into(),
        },
    ]);
}
fn main() {}

Streaming

New text appears character by character. Call state.begin_stream(message, full_text, now) when the model starts a completion, then state.stream_tick(now, cps) each frame. The revealed prefix grows at cps characters per second and stops at word boundaries.

extern crate tuile;
extern crate ratatui_core;
use std::time::Instant;
use tuile::prelude::*;
use tuile::widgets::ai::{ChatView, ChatState, ChatMessage, Role};
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = ChatState::default();
let msg = ChatMessage::new(Role::Assistant, "");
state.begin_stream(msg, "The quick brown fox jumps over the lazy dog.".into(), now);
state.stream_tick(now, 80.0);
ChatView::new().now(now).render(area, buf, &mut state);
}
fn main() {}

StreamText

Standalone streaming text widget with a cursor. Used for previews or isolated displays.

extern crate tuile;
extern crate ratatui_core;
use std::time::Instant;
use tuile::prelude::*;
use tuile::widgets::ai::{StreamText, StreamCursor};
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
StreamText::new("The assistant is typing...")
    .now(now)
    .cps(60.0)
    .cursor(StreamCursor::Block)
    .render(area, buf);
}
fn main() {}

Cursor styles: Block (default ▌), Bar (▏), Underline, or None.

TypingIndicator

Three-dot travelling pulse animation.

extern crate tuile;
extern crate ratatui_core;
use std::time::Instant;
use tuile::prelude::*;
use tuile::widgets::ai::TypingIndicator;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
TypingIndicator::new()
    .label("Assistant")
    .now(now)
    .render(area, buf);
}
fn main() {}

Thinking

One-row thinking indicator with spinner, label, optional detail, and elapsed counter. The label has a sweeping shimmer band when .shimmer(true).

extern crate tuile;
extern crate ratatui_core;
use std::time::Instant;
use tuile::prelude::*;
use tuile::widgets::ai::Thinking;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Thinking::new("Planning")
    .detail("reading 3 files")
    .started(now)
    .now(now)
    .elapsed_label(true)
    .shimmer(true)
    .render(area, buf);
}
fn main() {}

Methods: .label(), .detail(), .spinner() (from tuile::widgets::spinner::spinners), .started(Instant), .elapsed(f32), .elapsed_label(bool), .shimmer(bool), .color(Rgb).

Approval

Interactive approval dialog with three choices: Once, Always, Deny. Three visual styles: Card (default), Inline, Banner.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai::{Approval, ApprovalState, ApprovalStyle};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = ApprovalState::default();
Approval::new("Allow write to README.md?")
    .command("write_file")
    .detail("The agent wants to update the installation instructions.")
    .style(ApprovalStyle::Card)
    .render(area, buf, &mut state);
}
fn main() {}

The state owns focus: usize and choice: Option<ApprovalChoice>. Keys: arrow keys or 1/2/3 to pick, Enter to confirm. Read state.choice after render to see if the user decided. Methods: .command(), .detail(), .style(), .danger(bool).

PromptComposer

Prompt input field with model pill, send/newline hints, attachments, and optional token count in a hint row below the text area.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai::{PromptComposer, ComposerState};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = ComposerState::default();
PromptComposer::new()
    .model("claude-sonnet-4")
    .tokens(1234)
    .render(area, buf, &mut state);
}
fn main() {}

The state owns editor: TextAreaState and submitted: Option<String>. Call state.take_submitted() to drain the submitted text after the user presses Enter.

Token count: By default, no token figure is shown. Pass .tokens(n) for an exact count from the caller’s tokenizer (drawn as 1234 tokens) or .estimate_tokens(chars_per_token) for an estimate (drawn as ~1234 tokens with tilde prefix). Showing an invented estimate next to a model that bills by tokens is a confident wrong number—only show a count when you have one.

Methods: .model(String), .placeholder(String), .shape(FieldShape), .attachments(&[String]), .focused(bool), .tokens(u32), .estimate_tokens(f32).

ContextGauge

Token usage bar with prompt, completion, limit, percentage, and optional cost.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai::{ContextGauge, TokenUsage};
fn demo(area: Rect, buf: &mut Buffer) {
let usage = TokenUsage { prompt: 12400, completion: 3200, limit: 200000 };
ContextGauge::new(usage)
    .cost_usd(0.0156)
    .render(area, buf);
}
fn main() {}

The bar is a Meter underneath, so .style(MeterStyle::Line) and .gradient(&[Rgb, ...]) work. Default style is MeterStyle::Block. Methods: .compact(bool), .label(String).

TokenHeat

Token probability heatmap: each token is coloured by its log probability. Higher probability is cooler (blue), lower is warmer (red).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai::TokenHeat;
fn demo(area: Rect, buf: &mut Buffer) {
let tokens = [
    ("The", 0.98),
    ("quick", 0.85),
    ("brown", 0.42),
];
TokenHeat::new(&tokens)
    .legend(true)
    .render(area, buf);
}
fn main() {}

Tools

Tool execution timeline, shell and code blocks, edit previews, change sets, and JSON trees.

ToolTimeline

Hierarchical tool execution log with live elapsed time, nested steps, and expandable output. Each step has a name, summary, status (Pending, Running, Done, Error), optional duration, and output lines. Depth controls nesting.

extern crate tuile;
extern crate ratatui_core;
use std::time::Instant;
use tuile::prelude::*;
use tuile::widgets::ai::{ToolStatus};
use tuile::widgets::ai_tools::{ToolTimeline, ToolTimelineState, ToolStep};
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = ToolTimelineState::default();
state.steps.push(
    ToolStep::new("read")
        .summary("Read src/main.rs")
        .status(ToolStatus::Done)
);
state.steps.push(
    ToolStep::new("grep")
        .summary("Search pattern")
        .status(ToolStatus::Running)
        .depth(1)
);
ToolTimeline::new()
    .now(now)
    .render(area, buf, &mut state);
}
fn main() {}

The state owns steps: Vec<ToolStep>, cursor: usize, scroll: usize. Keys: j/k navigate, Enter toggles output expansion, Space/Shift+Space page. Each ToolStep has .name(), .summary(), .status(), .started(Instant), .duration(Duration), .output(Vec<String>), .depth(u8), .expanded(bool).

ShellBlock

Shell command display with streaming output, working directory, exit code, and elapsed time. Output lines are (bool, String) where the bool is true for stdout, false for stderr.

extern crate tuile;
extern crate ratatui_core;
use std::time::Duration;
use tuile::prelude::*;
use tuile::widgets::ai_tools::ShellBlock;
fn demo(area: Rect, buf: &mut Buffer) {
let output = vec![
    (true, "Compiling tuile v0.1.0".into()),
    (true, "Finished dev [unoptimized] in 2.3s".into()),
];
ShellBlock::new()
    .command("cargo build")
    .cwd(Some("/home/user/project"))
    .output(&output)
    .exit_code(Some(0))
    .duration(Some(Duration::from_secs(2)))
    .render(area, buf);
}
fn main() {}

Methods: .running(bool), .collapsed(bool), .max_rows(u16), .lps(f32) (lines per second for streaming animation).

CodeBlock

Code display with language label, file path, line numbers, and optional syntax highlighting.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_tools::CodeBlock;
fn demo(area: Rect, buf: &mut Buffer) {
let code = "fn main() {\n    println!(\"Hello\");\n}";
CodeBlock::new()
    .lang("rust")
    .path("src/main.rs")
    .text(code)
    .line_numbers(true)
    .start_line(1)
    .render(area, buf);
}
fn main() {}

Methods: .wrap(bool), .caret(bool) (blinking caret on the last line for live editing previews), .highlighter(Highlighter) (syntax highlighting function; Highlighter is a type alias for fn(&str) -> Vec<(usize, usize, Style)> where ranges are half-open grapheme-cluster offsets).

EditPreview

Interactive edit preview with animated reveal and decision buttons (Accept, Reject, Edit). The lines are supplied as Vec<DiffLine> where each line has a kind (Add, Del, Ctx, Hunk) and text.

extern crate tuile;
extern crate ratatui_core;
use std::time::Instant;
use tuile::prelude::*;
use tuile::widgets::ai::{DiffLine, DiffKind};
use tuile::widgets::ai_tools::{EditPreview, EditPreviewState};
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = EditPreviewState::default();
let lines = vec![
    DiffLine { kind: DiffKind::Ctx, text: "fn main() {".into() },
    DiffLine { kind: DiffKind::Del, text: "-   println!(\"old\");".into() },
    DiffLine { kind: DiffKind::Add, text: "+   println!(\"new\");".into() },
    DiffLine { kind: DiffKind::Ctx, text: "}".into() },
];
EditPreview::new()
    .path("src/main.rs")
    .lines(&lines)
    .started(now)
    .now(now)
    .render(area, buf, &mut state);
}
fn main() {}

The state owns decision: Option<EditDecision>. Keys: a Accept, r Reject, e Edit. The diff reveals line by line over 0.8 seconds. Methods: .reveal_duration(f32).

ChangeSet

File change summary with stat bars. Each FileChange has a path, kind (Added, Modified, Deleted, Renamed), and added/removed line counts.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_tools::{ChangeSet, ChangeSetState, FileChange, ChangeKind};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = ChangeSetState::default();
state.files.push(
    FileChange::new("src/main.rs", ChangeKind::Modified)
        .added(12)
        .removed(3)
);
state.files.push(
    FileChange::new("src/lib.rs", ChangeKind::Added)
        .added(45)
);
ChangeSet::new().render(area, buf, &mut state);
}
fn main() {}

Keys: j/k navigate, Space/Shift+Space page. The state owns files: Vec<FileChange>, cursor: usize, scroll: usize.

JsonTree

Collapsible JSON tree viewer. Parse JSON text with Json::parse(text) then render it.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_tools::{JsonTree, JsonTreeState, Json};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = JsonTreeState::default();
let json = Json::parse(r#"{"status": "ok", "count": 42}"#).unwrap();
state.root = Some(json);
JsonTree::new().render(area, buf, &mut state);
}
fn main() {}

Keys: j/k navigate, Enter toggles expansion, h collapses, l expands. The state owns the tree and expansion map.

RetryNotice

Retry countdown banner shown during exponential backoff.

extern crate tuile;
extern crate ratatui_core;
use std::time::{Instant, Duration};
use tuile::prelude::*;
use tuile::widgets::ai_tools::RetryNotice;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
RetryNotice::new()
    .reason("Rate limit exceeded")
    .attempt(2, 3)
    .deadline(now + Duration::from_secs(5))
    .now(now)
    .render(area, buf);
}
fn main() {}

Methods: .compact(bool).

Agents

Agent trees, gantt lanes, token and cost meters, context maps, session lists, and model pickers.

AgentTree

Hierarchical agent tree with expansion, cursor, task labels, status, tokens, and elapsed time. Each AgentNode has a name, model, status (Idle, Running, Waiting, Done, Failed, Parked), task description, token count, elapsed duration, and children.

extern crate tuile;
extern crate ratatui_core;
use std::time::{Instant, Duration};
use tuile::prelude::*;
use tuile::widgets::ai_agents::{AgentTree, AgentTreeState, AgentNode, AgentStatus};
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let mut state = AgentTreeState::default();
let mut root = AgentNode::new("Main", "opus", AgentStatus::Running)
    .task("Refactor auth module")
    .tokens(12400)
    .elapsed(Duration::from_secs(45));
root.children.push(
    AgentNode::new("Scout", "haiku", AgentStatus::Done)
        .task("Find usages")
        .tokens(3200)
        .elapsed(Duration::from_secs(8))
);
state.root = Some(root);
AgentTree::new()
    .now(now)
    .render(area, buf, &mut state);
}
fn main() {}

Keys: j/k navigate, Enter toggles expansion, h collapses, l expands. The state owns root: Option<AgentNode>, cursor: usize, scroll: usize, and expansion map.

AgentLanes

Gantt chart showing agent activity over time. Each Lane has a name and spans; each LaneSpan has start, optional end, status, and label.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_agents::{AgentLanes, Lane, LaneSpan, AgentStatus};
fn demo(area: Rect, buf: &mut Buffer) {
let lanes = vec![
    Lane::new("Main").span(LaneSpan::new(0.0, Some(12.0), AgentStatus::Running, "task")),
    Lane::new("Scout").span(LaneSpan::new(2.0, Some(8.0), AgentStatus::Done, "search")),
];
AgentLanes::new(&lanes)
    .render(area, buf);
}
fn main() {}

Methods: .compact(bool).

TokenMeter

Stacked bar showing token breakdown: input, output, cache read, cache write. Includes a legend.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_agents::{TokenMeter, TokenBreakdown};
fn demo(area: Rect, buf: &mut Buffer) {
let breakdown = TokenBreakdown {
    input: 12000,
    output: 3200,
    cache_read: 8000,
    cache_write: 2000,
};
TokenMeter::new(breakdown).render(area, buf);
}
fn main() {}

CostMeter

Budget bar with current spend, total budget, and spend rate. The spend is passed as a direct value, not through state.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_agents::{CostMeter, CostMeterState};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = CostMeterState::default();
CostMeter::new()
    .spent(0.42)
    .budget(10.0)
    .rate_per_min(0.05)
    .render(area, buf, &mut state);
}
fn main() {}

Methods: .compact(bool).

ContextMap

Horizontal stacked bar showing context window segments by source. Each ContextSegment has a label, token count, and optional colour.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_agents::{ContextMap, ContextSegment};
fn demo(area: Rect, buf: &mut Buffer) {
let segments = vec![
    ContextSegment::new("system", 1200),
    ContextSegment::new("history", 8400),
    ContextSegment::new("tools", 2400),
];
ContextMap::new(&segments, 200000)
    .render(area, buf);
}
fn main() {}

CompactionBanner

Context compaction result banner showing before/after percentages, saved tokens, and optional summary.

extern crate tuile;
extern crate ratatui_core;
use std::time::Instant;
use tuile::prelude::*;
use tuile::widgets::ai_agents::CompactionBanner;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
CompactionBanner::new(85.0, 42.0, 86000)
    .summary("Removed 3 old turns")
    .started(now)
    .now(now)
    .render(area, buf);
}
fn main() {}

TurnStats

KPI cells showing input tokens, output tokens, cache hit rate, tool calls, duration, and cost. Each stat is optional.

extern crate tuile;
extern crate ratatui_core;
use std::time::Duration;
use tuile::prelude::*;
use tuile::widgets::ai_agents::TurnStats;
fn demo(area: Rect, buf: &mut Buffer) {
TurnStats::new()
    .input(12400)
    .output(3200)
    .cache_hit(0.68)
    .tool_calls(5)
    .duration(Duration::from_secs(8))
    .cost(0.042)
    .render(area, buf);
}
fn main() {}

RateGraph

Sparkline-style throughput rate graph.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_agents::RateGraph;
fn demo(area: Rect, buf: &mut Buffer) {
let values = vec![12.0, 18.0, 15.0, 22.0, 19.0];
RateGraph::new(&values)
    .label("tok/s")
    .max(30.0)
    .render(area, buf);
}
fn main() {}

SessionList

Scrollable session list with cursor, time, and a detail row of facts. Each SessionEntry has a title, a when string, an active flag, and facts: Vec<String>.

Facts are strings, not fixed fields, because a session’s vocabulary belongs to the harness: one counts messages and dollars, another counts steps and tokens on a branch. .messages(u32), .cost(f32) and .model(&str) are conveniences that push "42 msgs", "$1.20" and the model name; .fact(impl Into<String>) pushes anything else. They render in call order, separated by ·.

A fact that was never reported is simply never pushed, so a local model with no cost shows 7 msgs · qwen3 rather than $0.0000. Never call .cost(0.0) to mean “unknown”.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_agents::{SessionList, SessionListState, SessionEntry};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = SessionListState::default();
state.entries.push(
    SessionEntry::new("Refactor auth", "2h ago")
        .messages(24)
        .cost(0.42)
        .model("opus")
        .active(true)
);
SessionList::new().render(area, buf, &mut state);
}
fn main() {}

Keys: j/k navigate, Enter activates the selected session (read state.activated).

ModelPicker

Interactive model picker with capability badges, context window, and pricing. Each ModelInfo has an id, provider, capabilities (Vision, Tools, Reasoning, Fast), and two optional facts: context: Option<u32> and prices: Option<(f32, f32)> (per million input/output tokens).

Both are published per model, so a managed or local model that publishes neither simply takes less width in the row. Only call .context() and .prices() with numbers a provider actually stated.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_agents::{ModelPicker, ModelPickerState, ModelInfo, Capability};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = ModelPickerState::default();
state.models.push(
    ModelInfo::new("claude-opus-4", "anthropic")
        .context(200000)
        .prices(15.0, 75.0)
        .caps(vec![Capability::Vision, Capability::Tools])
);
ModelPicker::new().render(area, buf, &mut state);
}
fn main() {}

Keys: j/k navigate, Enter selects (read state.selected).

ElapsedTimer

Elapsed time display with a pulsing dot when running.

extern crate tuile;
extern crate ratatui_core;
use std::time::{Instant, Duration};
use tuile::prelude::*;
use tuile::widgets::ai_agents::ElapsedTimer;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
ElapsedTimer::new()
    .since(now - Duration::from_secs(83))
    .now(now)
    .running(true)
    .render(area, buf);
}
fn main() {}

Formatting helpers

fmt_duration(d) formats a duration as 850ms, 1.2s, 12.4s, 1m 03s, or 2h 05m. fmt_usd(v) formats dollars as $0.0042, $0.42, or $12.30.

Composer

Slash commands, mentions, attachments, mode badges, status lines, question cards, plan views, message queues, and suggestion chips.

SlashMenu

Popup slash command menu with fuzzy matching. Each SlashCommand has a name, description, optional args hint, and optional category.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_compose::{SlashMenu, SlashMenuState, SlashCommand};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = SlashMenuState::default();
state.query = "hel".into();
let commands = vec![
    SlashCommand::new("help", "Show help"),
    SlashCommand::new("clear", "Clear conversation"),
];
SlashMenu::new()
    .commands(&commands)
    .render(area, buf, &mut state);
}
fn main() {}

The state owns query: String, cursor: usize, and selected: Option<String>. Keys: j/k navigate, Enter selects. The widget writes ranked match indices and names each render; read selected when the user picks one. Methods: .max_rows(usize).

MentionPicker

Popup mention picker for files, directories, symbols, URLs, and agents. Each MentionItem has a label, kind, optional detail line, and recent flag.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_compose::{MentionPicker, MentionPickerState, MentionItem, MentionKind};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = MentionPickerState::default();
state.query = "main".into();
let items = vec![
    MentionItem::new("src/main.rs", MentionKind::File).recent(true),
    MentionItem::new("main()", MentionKind::Symbol).detail("function"),
];
MentionPicker::new()
    .items(&items)
    .render(area, buf, &mut state);
}
fn main() {}

Keys and state structure match SlashMenu. Methods: .max_rows(usize).

AttachmentChips

Horizontal row of removable attachment chips with hover and focus. Each Attachment has a name, kind (Image, File, Snippet, Url), and optional size label.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_compose::{AttachmentChips, AttachmentChipsState, Attachment, AttachmentKind};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = AttachmentChipsState::default();
let chips = vec![
    Attachment::new("screenshot.png", AttachmentKind::Image).size("42 KB"),
    Attachment::new("config.toml", AttachmentKind::File),
];
AttachmentChips::new()
    .attachments(&chips)
    .focused(true)
    .render(area, buf, &mut state);
}
fn main() {}

The state owns hover: Option<usize> and removed: Option<usize>. Mouse hover updates hover; clicking the × sets removed. Keys: left/right to focus, Backspace to remove.

ModeBadge

Animated mode badge with colour transition. Modes are Plan, Act, Ask, Auto.

extern crate tuile;
extern crate ratatui_core;
use std::time::Instant;
use tuile::prelude::*;
use tuile::widgets::ai_compose::{ModeBadge, HarnessMode};
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
ModeBadge::new()
    .mode(HarnessMode::Act)
    .now(now)
    .render(area, buf);
}
fn main() {}

Methods: .from(Option<(HarnessMode, Instant)>) for the transition animation.

HarnessStatus

Status line showing model, mode, and optional stats (tokens, cost).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_compose::{HarnessStatus, HarnessMode};
fn demo(area: Rect, buf: &mut Buffer) {
HarnessStatus::new()
    .model("opus")
    .mode(HarnessMode::Act)
    .tokens(15600)
    .cost(0.042)
    .render(area, buf);
}
fn main() {}

Methods: .branch(String), .dirty(bool), .context_pct(f32), .elapsed(Duration), .busy(bool), .queued(u32).

.context_pct takes a fraction, 0.0..=1.0, and renders it as a percentage beside a small bar. .context_pct and .cost are both unset by default and draw nothing at all when unset, so a provider that manages its own context window shows no percentage rather than 0%. .cost(0.0) is a reported zero and does render as $0.00.

QuestionCard

Interactive card presenting a question with single or multi-select options. Each QuestionOption has a label and description.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_compose::{QuestionCard, QuestionCardState, QuestionOption};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = QuestionCardState::default();
let options = vec![
    QuestionOption::new("Yes", "Proceed with the change"),
    QuestionOption::new("No", "Skip this step"),
];
QuestionCard::new()
    .question("Apply the migration?")
    .options(&options)
    .multi(false)
    .recommended(Some(0))
    .render(area, buf, &mut state);
}
fn main() {}

The state owns cursor: usize, selected: Vec<bool>, submitted: Option<Vec<usize>>, and cancelled: bool. Keys: j/k navigate, Space toggles, Enter submits. Read submitted or cancelled after render.

PlanView

Collapsible tree of plan phases and tasks with keyboard navigation. Each PlanPhase has a name, tasks, and collapsed flag; each PlanTask has text and state (Pending, InProgress, Done, Blocked, Dropped).

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_compose::{PlanView, PlanViewState, PlanPhase, PlanTask, TaskState};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = PlanViewState::default();
let phases = vec![
    PlanPhase::new("Setup").task("Install deps", TaskState::Done),
    PlanPhase::new("Implementation").task("Write tests", TaskState::InProgress),
];
PlanView::new()
    .phases(&phases)
    .render(area, buf, &mut state);
}
fn main() {}

Keys: j/k navigate, Enter toggles phase collapse. The state owns cursor: usize, scroll: usize, and collapsed: Vec<bool>.

MessageQueue

Scrollable list of queued messages with delete controls. Each QueuedMessage has text and a when label.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_compose::{MessageQueue, MessageQueueState, QueuedMessage};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = MessageQueueState::default();
let messages = vec![
    QueuedMessage::new("Fix the timeout", "2m ago"),
    QueuedMessage::new("Add tests", "5m ago"),
];
MessageQueue::new()
    .messages(&messages)
    .render(area, buf, &mut state);
}
fn main() {}

The state owns cursor: usize and removed: Option<usize>. Keys: j/k navigate, d or Delete removes the selected message.

Suggestions

Row of clickable suggestion chips. The cursor moves with arrow keys and Enter picks the selected text.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use tuile::widgets::ai_compose::{Suggestions, SuggestionsState};
fn demo(area: Rect, buf: &mut Buffer) {
let mut state = SuggestionsState::default();
let items = vec!["Fix typo", "Add docs", "Run tests"];
Suggestions::new()
    .items(&items)
    .render(area, buf, &mut state);
}
fn main() {}

The state owns cursor: usize and activated: Option<usize>. Keys: h/l or left/right navigate, Enter activates.

A settings form

A form is a focus ring, a column of widgets, and a submit path. This recipe builds one with validation, a dirty flag and a confirmation dialog. The finished version of this pattern is the Settings page of the showcase, in about 300 lines.

The shape

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)]
enum Field { Name, Email, Notify, Retention, Save }

struct Settings {
    name: InputState,
    email: InputState,
    notify: SwitchState,
    retention: SliderState,
    save: ButtonState,
    focus: Focus<Field>,
    dirty: bool,
}

impl Settings {
    fn new() -> Self {
        Settings {
            name: InputState::with_value("ada"),
            email: InputState::with_value("ada@example.com"),
            notify: SwitchState::new(true),
            retention: SliderState::new(30.0, 1.0, 365.0, 1.0),
            save: ButtonState::new(),
            focus: Focus::new([Field::Name, Field::Email, Field::Notify, Field::Retention, Field::Save]),
            dirty: false,
        }
    }
}
fn main() { let _ = Settings::new(); }

Load the current values into the states at construction. The states are the form: there is no separate model to keep in sync, and reading the form back is reading the states.

Laying it out

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Field { Name, Email, Notify, Retention, Save }
struct Settings { name: InputState, email: InputState, notify: SwitchState, retention: SliderState, save: ButtonState, focus: Focus<Field>, dirty: bool }
impl Settings {
fn draw(&mut self, area: Rect, buf: &mut Buffer, now: Instant) {
    let th = theme::current();
    let card = center(area, 56, 18);
    let inner = Border::Round.draw_titled(
        buf, card, th.border_blurred, th.background, "Settings", Alignment::Left,
    );

    let rows = stack(pad(inner, 2, 1), &[3, 3, 1, 3, 1], 1);

    // Input has no label of its own: draw one and give the field the rest of the row.
    let label = st(th.text_muted, th.background);
    let field = |r: Rect| Rect { x: r.x + 10, width: r.width.saturating_sub(10), ..r };

    put(buf, rows[0].x, rows[0].y, "Name", 10, label);
    Input::new()
        .placeholder("your name")
        .focused(self.focus.is(Field::Name))
        .now(now)
        .render(field(rows[0]), buf, &mut self.name);

    put(buf, rows[1].x, rows[1].y, "Email", 10, label);
    Input::new()
        .validator(|v| if v.contains('@') { Ok(()) } else { Err("must contain @".into()) })
        .focused(self.focus.is(Field::Email))
        .now(now)
        .render(field(rows[1]), buf, &mut self.email);

    Switch::new()
        .label("Email notifications")
        .focused(self.focus.is(Field::Notify))
        .now(now)
        .render(rows[2], buf, &mut self.notify);

    Slider::new()
        .label("Retention")
        .show_value(true)
        .format(|v| format!("{v:.0} days"))
        .focused(self.focus.is(Field::Retention))
        .now(now)
        .render(rows[3], buf, &mut self.retention);

    Button::new(if self.dirty { "Save changes" } else { "Saved" })
        .variant(if self.dirty { Variant::Primary } else { Variant::Default })
        .enabled(self.dirty)
        .focused(self.focus.is(Field::Save))
        .now(now)
        .render(Rect { width: 18, ..rows[4] }, buf, &mut self.save);
}
}
fn main() {}

stack(area, &heights, gap) is the layout helper for a column of fixed-height rows with a gap. It saves building a Layout with one Constraint per row, and the heights read as a list of row sizes.

The save button carries the dirty state in three places at once: its label, its variant and its enabled flag. Deriving all three from one bool is what keeps them consistent.

Events and the dirty flag

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Field { Name, Email, Notify, Retention, Save }
struct Settings { name: InputState, email: InputState, notify: SwitchState, retention: SliderState, save: ButtonState, focus: Focus<Field>, dirty: bool }
impl Settings {
fn event(&mut self, ev: &Event) -> Outcome {
    if let Event::Key(k) = ev
        && self.focus.handle_key(*k).is_consumed()
    {
        return Outcome::Consumed;
    }

    let out = match ev {
        Event::Key(_) => match self.focus.current() {
            Some(Field::Name) => self.name.handle(ev),
            Some(Field::Email) => self.email.handle(ev),
            Some(Field::Notify) => self.notify.handle(ev),
            Some(Field::Retention) => self.retention.handle(ev),
            Some(Field::Save) => self.save.handle(ev),
            None => Outcome::Ignored,
        },
        Event::Mouse(_) => {
            self.name.handle(ev)
                | self.email.handle(ev)
                | self.notify.handle(ev)
                | self.retention.handle(ev)
                | self.save.handle(ev)
        }
        _ => Outcome::Ignored,
    };

    if out.is_changed() {
        if self.focus.is(Field::Save) {
            self.submit();
        } else {
            self.dirty = true;
        }
    }
    out
}

fn submit(&mut self) {
    // read the states; they are the form
    let _name = self.name.value.clone();
    let _email = self.email.value.clone();
    let _notify = self.notify.on;
    let _days = self.retention.value as u32;
    self.dirty = false;
}
}
fn main() {}

One Changed handler covers every field. Which widget changed comes from the focus ring, so adding a field means adding an enum variant and a render call, not another branch here.

Validation

Input::validator takes a function from the current text to Result<(), String>. The widget runs it on every edit, stores the error in state.error and renders the field in the error colour with the message underneath.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, state: &mut InputState) {
Input::new()
    .validator(|v| {
        if v.is_empty() {
            Err("required".into())
        } else if !v.contains('@') {
            Err("must contain @".into())
        } else {
            Ok(())
        }
    })
    .render(area, buf, state);
}
fn main() {}

It is a fn pointer, not a closure that captures, so the rule cannot depend on another field’s value. For cross-field rules (a confirmation password, an end date after a start date) check in your submit path and surface the result yourself, for example with an InlineAlert above the button.

Gate submission on validity:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Settings { name: InputState, email: InputState }
impl Settings {
fn valid(&self) -> bool {
    self.name.error.is_none() && self.email.error.is_none()
}
}
fn main() {}

Confirming a destructive save

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Page { confirm: ModalState }
impl Page {
fn draw_confirm(&mut self, area: Rect, buf: &mut Buffer) {
    Modal::confirm("Apply changes?", "This restarts the agent.")
        .render(area, buf, &mut self.confirm);
}
}
fn main() {}

Render the modal after the form so it paints on top, and give it the event before the form so the form does not react while it is open. The overlays chapter covers both halves.

What to reach for

Field typeWidget
Free textInput
Long textTextArea
One of a fewRadioGroup or Segmented
One of manySelect or Combobox
Several of manyMultiSelect or CheckList
On or off, applied immediatelySwitch
On or off, applied on submitCheckbox
A bounded numberSlider or Stepper
A dateDatePicker

For a settings screen with sections and dozens of rows, OptionList is a better fit than a hand-built form: it renders label value rows, cycles bool, choice and int values in place, and exposes a group index a sidebar can follow. The Options page of the showcase is that pattern.

A dashboard

A dashboard is a grid of panels that each own one widget, refreshed from data your app already has. The interesting parts are the layout, deciding what animates, and keeping the redraw cost honest.

Splitting the screen

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn layout_demo(area: Rect) {
let [header, body, footer] = Layout::vertical([
    Constraint::Length(1),
    Constraint::Fill(1),
    Constraint::Length(1),
])
.areas(area);

let [left, right] = Layout::horizontal([Constraint::Percentage(60), Constraint::Fill(1)])
    .areas(body);

let cards = columns(left, 3, 1);        // three equal columns, one cell of gap
let _ = (header, footer, right, cards);
}
fn main() {}

ratatui’s Layout handles the main structure. tuile adds the helpers that are tedious to write with constraints: columns(area, n, gap), stack(area, &heights, gap), center(area, w, h) and pad(area, x, y). Use whichever is shorter at each point.

Stat cards

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer) {
let cards = columns(area, 3, 1);

StatCard::new("Requests", "12.4k").delta(8.2, true).variant(Variant::Success).render(cards[0], buf);
StatCard::new("Errors", "37").delta(12.0, false).variant(Variant::Error).render(cards[1], buf);
StatCard::new("p99", "412ms").render(cards[2], buf);
}
fn main() {}

Stat cards are stateless. Build them from your data each frame and pass no state at all.

A panel with a chart inside

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, samples: &[f64]) {
let th = theme::current();
let inner = Border::Panel.draw_titled(
    buf, area, th.border_blurred, th.background, "Throughput", Alignment::Left,
);
SparkChart::new(samples).style(SparkStyle::Area).render(inner, buf);
}
fn main() {}

Every border helper returns the rectangle inside the frame, so the panel and its content are two lines with no manual inset arithmetic.

Panel is the widget version with a title, an optional right title, footer keys and a badge. Use the bare Border when you only need a frame.

Choosing what animates

A dashboard that redraws at 60 fps forever is a dashboard that heats a laptop. animating should be true only while something is genuinely moving:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Dash { deploy: ProgressState, spinner_visible: bool, toasts: Toaster }
impl Dash {
fn animating(&self, now: Instant) -> bool {
    self.deploy.animating(now) || self.spinner_visible || self.toasts.animating(now)
}
}
fn main() {}

A progress bar tweens toward its new value and then stops, so it reports honestly. A spinner loops forever, so showing one permanently pins the frame rate; make its visibility a flag tied to real work.

Charts do not animate on their own. They redraw when your data changes, which on a monitoring screen is usually once a second. RunOptions { idle_redraw: Some(Duration::from_secs(1)), .. } is a reasonable setting for a screen like that, with animating false most of the time.

Feeding it data

tuile has no opinion about where data comes from. The usual shape is a worker thread and a channel, drained in update:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::sync::mpsc::Receiver;

struct Dash {
    rx: Receiver<f64>,
    series: Vec<f64>,
}

impl Dash {
    fn update(&mut self, _now: Instant) {
        while let Ok(sample) = self.rx.try_recv() {
            self.series.push(sample);
            if self.series.len() > 240 {
                self.series.remove(0);
            }
        }
    }
}
fn main() {}

try_recv in a loop, never a blocking recv: update runs on the draw thread and blocking there freezes the interface.

For a series with a fixed window, VecDeque with push_back and pop_front avoids the remove(0) shuffle. At 240 samples neither is measurable, and the chart widgets take any &[f64].

Keeping one widget interactive

Most dashboard panels are read-only, and usually one is not: a table of hosts, a log view, a list you can filter. Give that one widget the focus ring and leave the rest stateless.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Dash { hosts: DataTableState }
impl Dash {
fn event(&mut self, ev: &Event) -> Outcome {
    self.hosts.handle(ev)      // the only interactive panel
}
}
fn main() {}

When a second interactive panel arrives, add a Focus<PanelId> and route keys through it, exactly as in the form recipe. Mouse events can still go to everyone.

What the showcase does

The Dashboard page composes stat cards, a bar graph, a line graph, a data table, meters, a deploy button driving a progress bar, and an event log, all from one deterministic simulation. The Monitor page is the denser btop-style version with LED meters, dot-field graphs and a process tree. Both are single files under showcase/src/pages/.

Modal dialogs and overlays

A modal is a card over the current screen that owns the keyboard until closed. This recipe shows how to layer them correctly and use Modal::card for custom content.

The layer order

Modal dialogs render in four layers, from back to front:

  1. Screen — your page, buttons, fields, lists
  2. Backdrop — dimmed overlay covering the screen
  3. Card — centered card with border, title, and body
  4. Overlay queue — dropdowns and pickers rendered over the card

Keyboard priority

A modal outranks the focused field underneath. The rule:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Page { modal: ModalState, field: InputState }
impl Page {
fn handle_key(&mut self, ev: KeyEvent) -> Outcome {
    if self.modal.is_open() {
        return self.modal.handle_key(ev);
    }
    self.field.handle_key(ev)
}
}
fn main() {}

The modal consumes events first; the screen only sees them when the modal is closed.

A card the caller fills

Modal::card(title) gives you a card with a title and a body rect you fill. The body rect is published on ModalState::body after render:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Page { modal: ModalState, picker: ListViewState }
impl Page {
fn draw(&mut self, area: Rect, buf: &mut Buffer, now: Instant) {
    // … your page content draws first, so the card lands on top of it

    Modal::card("Pick an item")
        .width(50)
        .height(12)
        .now(now)
        .render(area, buf, &mut self.modal);

    // then fill the rect the card published
    if self.modal.is_open() {
        ListView::new(vec![
            ListEntry::new("Option A"),
            ListEntry::new("Option B"),
            ListEntry::new("Option C"),
        ])
        .title("options")
        .focused(true)
        .render(self.modal.body, buf, &mut self.picker);
    }
}
}
fn main() {}

The modal renders its backdrop, card, and title, then sets state.body to the rect you can draw into. Render your content into that rect — a list, a form, a data table, whatever the modal holds.

Overlays over the modal

A dropdown inside a card must paint after the card, or the card covers it. SelectState and ListViewState expose render_overlay for exactly that: draw the field into the card body, then drain the overlay at the end of the frame.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct ModalForm { modal: ModalState, select: SelectState }
impl ModalForm {
fn draw(&mut self, area: Rect, buf: &mut Buffer, now: Instant) {
    Modal::card("Settings")
        .width(60)
        .height(10)
        .now(now)
        .render(area, buf, &mut self.modal);

    if self.modal.is_open() {
        let th = theme::current();
        Select::new()
            .placeholder("Theme")
            .focused(true)
            .theme(&th)
            .render(self.modal.body, buf, &mut self.select);

        // last, so it lands over the card instead of under it
        self.select.render_overlay(buf, area, &th, 5);
    }
}
}
fn main() {}

With more than one deferred layer, collect them in a [layout::Overlay] and call overlay.draw(buf) once at the end of the frame; the queue keeps the z-order explicit instead of implicit in call order.

Opening and closing

extern crate tuile;
use tuile::prelude::*;
use std::time::Instant;
struct State { modal: ModalState }
impl State {
fn open_modal(&mut self, now: Instant) {
    self.modal.open(now);
}

fn handle_key(&mut self, ev: KeyEvent) -> Outcome {
    if self.modal.is_open() {
        let outcome = self.modal.handle_key(ev);
        // Esc closes the modal if configured (default: true)
        if !self.modal.is_open() {
            // Modal closed, react to it
        }
        return outcome;
    }
    // Route to screen widgets
    Outcome::Ignored
}
}
fn main() {}

Modal::card closes on Esc by default. To keep it open, use .close_on_escape(false). To close it programmatically, call state.close().

Example: ListView picker in a modal

# extern crate tuile;
# extern crate ratatui_core;
use tuile::prelude::*;

struct PickerModal {
    modal: ModalState,
    list: ListViewState,
    items: Vec<ListEntry>,
}

impl PickerModal {
    fn new() -> Self {
        Self {
            modal: ModalState::new(),
            list: ListViewState::new(),
            items: vec![
                ListEntry::new("Alice"),
                ListEntry::new("Bob"),
                ListEntry::new("Charlie"),
            ],
        }
    }
    
    fn draw(&mut self, area: Rect, buf: &mut Buffer, now: Instant) {
        // the busy screen underneath
        let th = theme::current();
        fill(buf, area, th.background);
        put(
            buf,
            2,
            1,
            "the screen below the modal",
            40,
            st(th.text, th.background),
        );

        Modal::card("Pick a name")
            .width(40)
            .height(10)
            .now(now)
            .render(area, buf, &mut self.modal);

        if self.modal.is_open() {
            ListView::new(self.items.clone())
                .title("names")
                .focused(true)
                .render(self.modal.body, buf, &mut self.list);
        }
    }

    fn handle_key(&mut self, ev: KeyEvent) -> Outcome {
        if !self.modal.is_open() {
            return Outcome::Ignored;
        }
        // the body owns navigation and picking; Esc still reaches the modal
        let picked = self.list.handle_key(ev);
        if picked.is_submitted() && self.list.take_activated().is_some() {
            self.modal.close();
            return Outcome::Submitted;
        }
        picked | self.modal.handle_key(ev)
    }
}
# fn main() {}

The modal owns the keyboard, the list gets events first (for navigation), then the modal (for Esc). When the user picks an item or presses Esc, the modal closes and the screen below regains control.

Writing your own widget

A widget that follows the same rules as the built-in ones looks native, works with the theme and the focus ring, and can be dropped into a page next to library widgets without special cases. The rules are in the widget contract; this recipe walks one widget from nothing to done.

We build a Pager: a row of dots showing which page of N you are on, clickable, with arrow key support.

1. The two types

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;

pub struct Pager {
    pages: usize,
    focused: bool,
    enabled: bool,
    theme: Option<Theme>,
}

#[derive(Clone, Debug, Default)]
pub struct PagerState {
    pub current: usize,
    pub hits: Vec<Rect>,
}
fn main() {}

The builder holds configuration and an optional theme. The state holds the value and the rectangles the last render produced. PagerState derives Clone + Debug + Default so a caller can store it, snapshot it in a test, and construct it with Default::default().

2. The builder

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
pub struct Pager { pages: usize, focused: bool, enabled: bool, theme: Option<Theme> }
impl Pager {
    pub fn new(pages: usize) -> Self {
        Pager { pages, focused: false, enabled: true, theme: None }
    }
    pub fn focused(mut self, v: bool) -> Self {
        self.focused = v;
        self
    }
    pub fn enabled(mut self, v: bool) -> Self {
        self.enabled = v;
        self
    }
    pub fn theme(mut self, th: &Theme) -> Self {
        self.theme = Some(*th);
        self
    }
}
fn main() {}

Take the theme by reference and copy it. Theme is Copy, and every built-in widget uses this exact signature, so a caller can swap your widget for a library one without changing the call.

3. Render

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
pub struct Pager { pages: usize, focused: bool, enabled: bool, theme: Option<Theme> }
#[derive(Clone, Debug, Default)] pub struct PagerState { pub current: usize, pub hits: Vec<Rect> }
impl Pager {
    pub fn render(self, area: Rect, buf: &mut Buffer, state: &mut PagerState) {
        let th = self.theme.unwrap_or_else(theme::current);
        state.hits.clear();
        if area.height == 0 || self.pages == 0 {
            return;                       // too small to draw: draw nothing, never panic
        }

        let fg = if !self.enabled {
            th.text_disabled
        } else if self.focused {
            th.text
        } else {
            th.text_muted
        };

        let y = area.y;
        for i in 0..self.pages {
            let x = area.x + i as u16 * 2;
            if x >= area.right() {
                break;                    // only what fits gets drawn, and gets a hit rect
            }
            let glyph = if i == state.current { "●" } else { "○" };
            let style = if i == state.current { st(th.primary, th.background) } else { st(fg, th.background) };
            put(buf, x, y, glyph, 1, style);
            state.hits.push(Rect::new(x, y, 1, 1));
        }
    }
}
fn main() {}

Four rules are visible here.

The theme falls back to theme::current() when the caller did not pass one, so the widget works with no configuration.

Hit rectangles are cleared and rebuilt every render, and only for what was actually drawn. A dot scrolled off the edge has no rectangle, so it cannot be clicked.

The small-area check returns instead of panicking. Every widget in the library is fuzzed from 60x16 upward, and “draws nothing” is a valid answer at any size.

put clips to the buffer. Writing through buf[(x, y)] does not, and a one-cell overrun panics.

4. Handle events

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Debug, Default)] pub struct PagerState { pub current: usize, pub hits: Vec<Rect> }
impl Interactive for PagerState {
    fn handle_key(&mut self, key: KeyEvent) -> Outcome {
        match key.code {
            KeyCode::Left if self.current > 0 => {
                self.current -= 1;
                Outcome::Changed
            }
            KeyCode::Right if self.current + 1 < self.hits.len() => {
                self.current += 1;
                Outcome::Changed
            }
            KeyCode::Home if self.current != 0 => {
                self.current = 0;
                Outcome::Changed
            }
            _ => Outcome::Ignored,
        }
    }

    fn handle_mouse(&mut self, m: MouseEvent) -> Outcome {
        if !is_left_down(&m) {
            return Outcome::Ignored;
        }
        for (i, r) in self.hits.iter().enumerate() {
            if mouse_in(*r, &m) {
                if self.current == i {
                    return Outcome::Consumed;      // clicked the current page: nothing changed
                }
                self.current = i;
                return Outcome::Changed;
            }
        }
        Outcome::Ignored
    }
}
fn main() {}

Implementing Interactive gives you handle(&Event) for free, so callers can forward raw events with one call.

Returning the right Outcome matters more than it looks. Ignored for a key you did not use lets the app offer it elsewhere, which is how Tab reaches the focus ring instead of being eaten. Consumed for a click that changed nothing still tells the app to redraw (the hover moved). Changed is reserved for a real value change.

5. Test the behaviour, not the wiring

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
pub struct Pager { pages: usize, focused: bool, enabled: bool, theme: Option<Theme> }
impl Pager {
    pub fn new(pages: usize) -> Self { Pager { pages, focused: false, enabled: true, theme: None } }
    pub fn render(self, area: Rect, buf: &mut Buffer, state: &mut PagerState) {
        state.hits.clear();
        for i in 0..self.pages {
            let x = area.x + i as u16 * 2;
            if x >= area.right() { break; }
            put(buf, x, area.y, if i == state.current { "●" } else { "○" }, 1, Style::default());
            state.hits.push(Rect::new(x, area.y, 1, 1));
        }
    }
}
#[derive(Clone, Debug, Default)] pub struct PagerState { pub current: usize, pub hits: Vec<Rect> }
fn main() {
// A click on the third dot selects page 2.
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
let mut state = PagerState::default();
Pager::new(5).render(buf.area, &mut buf, &mut state);

let third = state.hits[2];
assert_eq!(third.x, 4);

// Rendering into a one-cell area draws what fits and nothing more.
let mut tiny = Buffer::empty(Rect::new(0, 0, 1, 1));
let mut st2 = PagerState::default();
Pager::new(5).render(tiny.area, &mut tiny, &mut st2);
assert_eq!(st2.hits.len(), 1);
}

Assert what a consumer observes: which cell was painted, how many hit rectangles exist, what the state says after an event. A test that asserts a field was copied from the builder tests nothing.

The no-panic sweep is worth writing once per widget, because “never panics at any size” is a real contract here:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
pub struct Pager;
impl Pager { pub fn new(_: usize) -> Self { Pager } pub fn render(self, _: Rect, _: &mut Buffer, _: &mut u8) {} }
fn main() {
for w in [0u16, 1, 3, 40, 200] {
    for h in [0u16, 1, 2, 50] {
        let mut buf = Buffer::empty(Rect::new(0, 0, w.max(1), h.max(1)));
        let mut state = 0u8;
        Pager::new(8).render(Rect::new(0, 0, w, h), &mut buf, &mut state);
    }
}
}

6. Animation, if you need it

Take the frame instant through the builder and store the tween in the state:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
use std::time::Duration;
#[derive(Clone, Debug)] pub struct PagerState { pub current: usize, pub slide: Tween }
impl PagerState {
    pub fn go(&mut self, page: usize, now: Instant) {
        self.current = page;
        self.slide.go(page as f32, now, Duration::from_millis(150));
    }
    pub fn animating(&self, now: Instant) -> bool {
        self.slide.active(now)
    }
}
fn main() {}

Expose animating(now) so the app can or it into App::animating. Never call Instant::now() inside render: it makes output non-deterministic and breaks both tests and screenshots. The animation chapter explains the two clocks.

The reference implementation

tuile/src/widgets/scrollbar.rs is the widget the contract was written from. It is small, handles keyboard, wheel and drag, hides itself when the content fits, and has the doc comment and tests the contract asks for. Read it before writing anything non-trivial.

Custom spinners

The catalog has 102 spinners: the 90 from cli-spinners and yaspin, plus 12 written for this crate. Defining your own takes one line, because a spinner is a name, an interval and a list of frames.

Using one from the catalog

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Spinner::new(&spinners::DOTS).label("Loading").now(now).render(area, buf);
}
fn main() {}

Every entry is a pub const in spinners, screaming case: DOTS, LINE, ARC, BOUNCING_BAR, MOON, EARTH, CLOCK, and the originals SPARKLE, RING, WAVE, EQUALIZER, SCANNER, SHIMMER, DNA, MATRIX. spinners::ALL is the whole catalog as a slice, which is what the showcase’s filterable list iterates.

Look one up by name when it comes from a config file:

extern crate tuile;
use tuile::prelude::*;
fn main() {
let def: &SpinnerDef = SpinnerDef::by_name("dots").unwrap_or(&spinners::LINE);
assert_eq!(def.name, "dots");
}

Names are the lower-case cli-spinners names, so a config that says dots12 finds the same spinner it would in a Node tool.

Defining one

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
const PULSE: SpinnerDef = SpinnerDef::new("pulse", 120, &["·", "•", "●", "•"]);

fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Spinner::new(&PULSE).now(now).render(area, buf);
}
fn main() {}

SpinnerDef::new(name, interval_ms, frames) is const, so a spinner can be a constant with no allocation and no lazy init. The three fields are public if you prefer struct syntax.

Two rules for the frames. Every frame should be the same display width, or the spinner will shift the text next to it on each tick; def.width() reports the widest frame so you can check. And the glyphs must exist in the fonts your users have: braille dots are safe almost everywhere, emoji are not, and anything with the Unicode Emoji property may be drawn two cells wide by some terminals while unicode-width calls it one.

Frames from data

When the frames come from a config file or are computed, they are not 'static:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant, frames: &[&str]) {
Spinner::frames(frames, 90).now(now).render(area, buf);
}
fn main() {}

Spinner::frames(&[&str], interval_ms) borrows for the lifetime of the builder. Nothing is copied; the slice only has to outlive the render call.

Timing

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
Spinner::new(&spinners::DOTS).now(now).render(area, buf);        // phase from the shared epoch
Spinner::new(&spinners::DOTS).elapsed(1.25).render(area, buf);   // pinned, deterministic
}
fn main() {}

.now(instant) measures the phase from the process-wide epoch, so two spinners on screen with the same interval stay in step without any shared state. .elapsed(secs) pins the animation, which is what tests and the screenshot tool use.

The interval lives in the definition, not the builder. To run the same frames faster, define another constant; the spinner does not take a speed multiplier.

Colour and label

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer, now: Instant) {
let th = theme::current();
Spinner::new(&spinners::ARC)
    .color(th.accent)
    .label("Compiling")
    .now(now)
    .render(area, buf);
}
fn main() {}

Without .color(..) the spinner uses the theme’s primary. The label is drawn after the frame with one space between, and .width() on the builder reports the total cells needed, which is what you want when reserving space in a status line.

Regenerating the catalog

The catalog is generated, not hand-written:

cargo xtask spinners     # reads xtask/spinners.json, writes spinner/spinners.rs

Add an entry to xtask/spinners.json and re-run it to add a spinner to the shipped set. Editing spinners.rs by hand works until the next regeneration overwrites it.

Testing a terminal UI

A tuile widget renders into a Buffer, which is a plain grid of cells you can construct in a unit test. No terminal, no PTY, no snapshot framework. That covers most of what is worth testing; the rest is covered by driving the real binary headlessly and looking at a PNG.

Rendering into a buffer

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
let mut state = CheckboxState::new(CheckState::On);

Checkbox::new("Enabled").render(buf.area, &mut buf, &mut state);

let row: String = (0..20).map(|x| buf[(x, 0)].symbol().to_string()).collect();
assert!(row.contains("Enabled"));
}

Buffer::empty(rect) is the whole setup. Reading cells back gives you the symbol, the foreground, the background and the modifiers, so you can assert on colour as well as text.

Assert what a consumer observes

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
let mut state = CheckboxState::new(CheckState::Off);

let out = state.handle_key(KeyEvent::from(KeyCode::Char(' ')));

assert_eq!(out, Outcome::Changed);
assert_eq!(state.value, CheckState::On);

// A key the widget does not use must stay available to the app.
assert_eq!(state.handle_key(KeyEvent::from(KeyCode::Tab)), Outcome::Ignored);
}

The second assertion is the one that catches real bugs. A widget that consumes Tab breaks the focus ring of every app that embeds it, and nothing else in the test suite notices.

Useful things to assert: the value after an event, the Outcome, which cell got which colour, how many hit rectangles a render produced, and what happens at a size too small to draw in. Things not worth asserting: that a builder field reached the state, that a default was applied, that rendering “does not panic” for a widget whose contract says more than that.

Mouse events in tests

A state must render before it can be clicked, because that is when it learns its geometry:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
let mut state = ButtonState::new();

Button::new("Save").render(buf.area, &mut buf, &mut state);

let press = MouseEvent {
    kind: MouseEventKind::Down(MouseButton::Left),
    column: 3,
    row: 1,
    modifiers: KeyModifiers::NONE,
};
let release = MouseEvent { kind: MouseEventKind::Up(MouseButton::Left), ..press };

assert_eq!(state.handle_mouse(press), Outcome::Consumed);
assert_eq!(state.handle_mouse(release), Outcome::Submitted);
}

Press then release, because a click is both. A test that only presses is testing half the interaction.

Animated widgets

Pin the clock with .elapsed(secs) instead of .now(instant):

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));

Spinner::new(&spinners::DOTS).elapsed(0.0).render(buf.area, &mut buf);
let first = buf[(0, 0)].symbol().to_string();

let mut buf2 = Buffer::empty(Rect::new(0, 0, 10, 1));
Spinner::new(&spinners::DOTS).elapsed(0.5).render(buf2.area, &mut buf2);
let later = buf2[(0, 0)].symbol().to_string();

assert_ne!(first, later, "the spinner advanced between 0.0s and 0.5s");
}

With elapsed the same input always produces the same cells, so these tests do not flake. With now they would depend on when the suite happened to run.

For tweens, construct the state, call go(target, now, dur) with a fixed now, and sample value(now + d) at chosen offsets. Instant arithmetic is exact and no sleeping is needed.

Screenshots of the real binary

Unit tests cannot tell you that two panels overlap or that a border is one cell short. cargo xtask shot runs the actual binary in a private tmux server at an exact size, sends input, and rasterises the result:

cargo xtask shot -s 130x42 -o out.png -- ./target/release/showcase --page charts
cargo xtask shot -s 90x28 -k "Tab Tab Enter" -o out.png -- ./target/release/showcase --page inputs
cargo xtask shot -s 60x16 --text -- ./target/release/showcase --page tables
FlagEffect
-s COLSxROWSTerminal size, default 120x34
-k "Tab Enter"tmux key names sent in order (C-p, M-1, BTab, Escape)
--mouse "click 40 12"SGR mouse steps: click, down, up, drag, move, wheelup, wheeldown, with 0-based coordinates
--script 'Tab;shot a.png;Enter;wait 0.5;shot b.png'A sequence with several captures
--textPrint the captured text instead of writing a PNG
-o PATHWhere the PNG goes

--text is the mode to use in CI: the output is plain text, so a diff against a committed file is readable in a pull request, unlike a PNG diff.

Two things the script handles that a hand-rolled tmux capture will not. Agent and CI shells often export NO_COLOR=1 or TERM=dumb, which makes crossterm strip every colour; the script scrubs both. And Escape is always sent alone with a pause, because crossterm reads ESC immediately followed by another key as Alt+key.

Checking small sizes

Every widget in this library is expected to render without panicking from 60x16 up to 250x70. The cheap way to check your own screens is a loop over sizes with random input:

for size in 60x16 90x28 130x42 200x55; do
  cargo xtask shot -s $size --text -- ./target/release/myapp > /dev/null || echo "FAILED at $size"
done

The repository fuzzes every showcase page this way with random keys and mouse events. It is how the library’s no-panic claim is checked, and it has found real bugs that no unit test would have.

Widget index

This table lists every public widget in tuile. Widgets with no state type are plain render widgets that need no state tracking; the rest are stateful and accept a &mut State parameter.

Total: 116 widgets

WidgetState typeModuleChapter
AccordionAccordionStatecollapsibleLayout and chrome
ActivityGraphnonechartsCharts
AgentLanesnoneai_agentsThe AI harness
AgentTreeAgentTreeStateai_agentsThe AI harness
AppHeaderAppHeaderStateheaderLayout and chrome
ApprovalApprovalStateaiThe AI harness
AttachmentChipsAttachmentChipsStateai_composeThe AI harness
BadgenonetextContent
BannerBannerStatenotifyNotifications
BarGraphnonechartsCharts
BigMenuBigMenuStatebigtextBig text and menus
BigTextnonebigtextBig text and menus
BigTitlenonebigtextBig text and menus
BlinkernonespinnerLoading
BreadcrumbsBreadcrumbsStatebreadcrumbsNavigation
ButtonButtonStatebuttonControls
CalendarCalendarStatecalendarContent
CalloutCalloutStatetoastNotifications
CatalogCatalogStatecatalogTables and data
ChangeSetChangeSetStateai_toolsThe AI harness
ChatViewChatStateaiThe AI harness
CheckListCheckListStatetoggleControls
CheckboxCheckboxStatetoggleControls
CodeBlocknoneai_toolsThe AI harness
CollapsibleCollapsibleStatecollapsibleLayout and chrome
ColorPickerColorPickerStatecolorContent
ComboboxComboboxStateselectText entry
CommandPaletteCommandPaletteStatepaletteNavigation
CompactionBannernoneai_agentsThe AI harness
ContextGaugenoneaiThe AI harness
ContextMapnoneai_agentsThe AI harness
ContextMenuContextMenuStatemenuNavigation
CostMeterCostMeterStateai_agentsThe AI harness
DataTableDataTableStatetableTables and data
DatePickerDatePickerStatecalendarContent
DiffViewnoneaiThe AI harness
DigitsnonedigitsTables and data
EditPreviewEditPreviewStateai_toolsThe AI harness
ElapsedTimernoneai_agentsThe AI harness
GradientBarnonecolorContent
HarnessStatusnoneai_composeThe AI harness
HeatmapnonechartsCharts
InlineAlertnonenotifyNotifications
InputInputStateinputText entry
JsonTreeJsonTreeStateai_toolsThe AI harness
KeyCapnonetextContent
KeyFooterKeyFooterStatefooterLayout and chrome
LabelnonetextContent
LineGraphnonechartsCharts
LinkLinkStatetextContent
ListViewListViewStatelistNavigation
LoadingIndicatornonespinnerLoading
LoadingOverlaynoneloadingLoading
LoadernoneloadingLoading
LogViewLogViewStatelogContent
MarkdownMarkdownStatemarkdownContent
MarkupLabelnonetextContent
MarqueenonespinnerLoading
MenuBarMenuBarStatemenuNavigation
MessageQueueMessageQueueStateai_composeThe AI harness
MeternonechartsCharts
ModalModalStatemodalFeedback
ModeBadgenoneai_composeThe AI harness
ModelPickerModelPickerStateai_agentsThe AI harness
MultiSelectMultiSelectStateselectText entry
NotificationCenterNotificationCenterStatenotifyNotifications
OptionListOptionListStateoptionsFeedback
PaginatorPaginatorStatebreadcrumbsNavigation
PanelnonepanelLayout and chrome
PillnonetextContent
PlanViewPlanViewStateai_composeThe AI harness
ProgressBarProgressStateprogressFeedback
PromptComposerComposerStateaiThe AI harness
QuestionCardQuestionCardStateai_composeThe AI harness
RadialGaugenonechartsCharts
RadioGroupRadioStatetoggleControls
RangeSliderRangeStatesliderControls
RateGraphnoneai_agentsThe AI harness
RatingRatingStatesliderControls
RetryNoticenoneai_toolsThe AI harness
RulenonetextContent
ScatterPlotnonechartsCharts
ScrollbarScrollbarStatescrollbarLayout and chrome
ScrollViewScrollViewStatescrollLayout and chrome
SegmentedSegmentedStatetoggleControls
SelectSelectStateselectText entry
SessionListSessionListStateai_agentsThe AI harness
ShellBlocknoneai_toolsThe AI harness
SkeletonnoneloadingLoading
SlashMenuSlashMenuStateai_composeThe AI harness
SliderSliderStatesliderControls
SparkChartnonechartsCharts
SpinnernonespinnerLoading
SplitSplitStatesplitLayout and chrome
StatCardnonetextContent
StatusLinenonetextContent
StepProgressnoneprogressFeedback
StepperStepperStatesliderControls
StepsStepsStatestepsContent
StreamTextnoneaiThe AI harness
SuggestionsSuggestionsStateai_composeThe AI harness
SwatchesSwatchesStatecolorContent
SwitchSwitchStatetoggleControls
TabBarTabBarStatetabsNavigation
TextAreaTextAreaStatetextareaText entry
ThemePalettenonecolorContent
ThinkingnoneaiThe AI harness
ToastStackToastertoastNotifications
TokenHeatnoneaiThe AI harness
TokenMeternoneai_agentsThe AI harness
ToolCallnoneaiThe AI harness
ToolTimelineToolTimelineStateai_toolsThe AI harness
TooltipTooltipStatetooltipFeedback
TreeViewTreeViewStatetreeNavigation
TurnStatsnoneai_agentsThe AI harness
TypingIndicatornoneaiThe AI harness
MentionPickerMentionPickerStateai_composeThe AI harness

Theme roles

The tuile theme system resolves a small base palette into semantic colour roles for every surface, text, border and chrome element. Every role on Theme is a plain Rgb value you can read directly; most are derived by CIE-Lab lightness transformations or auto-contrast blending.

Base palette

Set directly by ThemeSpec:

RoleFieldDerived fromUsed for
Primary colourprimaryThemeSpec.primaryBorders, buttons, accents (Primary variant)
Secondary coloursecondaryThemeSpec.secondary or primarySecondary variant buttons, links
Warning colourwarningThemeSpec.warning or primaryWarning variant elements
Error colourerrorThemeSpec.error or secondaryError variant elements
Success coloursuccessThemeSpec.success or secondarySuccess variant elements
Accent colouraccentThemeSpec.accent or primaryAccent variant highlights
BackgroundbackgroundThemeSpec.background or #121212 (dark) / #efefef (light)Page background
ForegroundforegroundThemeSpec.foreground or background.inverse()Maximum contrast text reference

Surface roles

RoleFieldDerived fromUsed for
SurfacesurfaceThemeSpec.surface or #1e1e1e (dark) / #f5f5f5 (light)Default widget background
Panelpanelsurface.blend(primary, 0.1) + 4% contrast tint (dark)Containers, panels, cards
Boostboostsurface.blend(foreground, 0.04)Nested containers (Textual $boost)

Text roles

All text roles are derived for readability over the background. The auto N% notation means background.text_on(N), which blends the best-contrast text (white or black) at the given alpha.

RoleFieldDerived fromUsed for
Default texttextbackground.text_on(0.87)Primary body text (auto 87%)
Muted texttext_mutedbackground.text_on(0.60)Secondary labels, help text (auto 60%)
Disabled texttext_disabledbackground.text_on(0.38)Disabled controls (auto 38%)
Primary texttext_primaryforeground.blend(primary, 0.66)Tinted text for Primary variant
Secondary texttext_secondaryforeground.blend(secondary, 0.66)Tinted text for Secondary variant
Accent texttext_accentforeground.blend(accent, 0.66)Tinted text for Accent variant
Success texttext_successforeground.blend(success, 0.66)Tinted text for Success variant
Warning texttext_warningforeground.blend(warning, 0.66)Tinted text for Warning variant
Error texttext_errorforeground.blend(error, 0.66)Tinted text for Error variant

Border and cursor roles

RoleFieldDerived fromUsed for
Border (focused)borderprimaryFocused widget borders
Border (blurred)border_blurredsurface.darken(0.025)Unfocused borders (CIE-Lab −2.5% L*)
Cursor backgroundcursor_bgprimaryText cursor fill colour
Cursor foregroundcursor_fgprimary.text_on(0.9)Text under cursor (auto 90% on cursor_bg)
Cursor blurredcursor_blurred_bgsurface.blend(primary, 0.3)Unfocused cursor (30% primary over surface)

Interaction roles

RoleFieldDerived fromUsed for
Hover backgroundhover_bgsurface.blend(foreground, 0.08)Button/item hover state
Selection backgroundselection_bgsurface.blend(primary, 0.5)Selected text, list items
Link colourlinksecondary.blend(foreground, 0.2)Hyperlinks

Scrollbar roles

RoleFieldDerived fromUsed for
Scrollbar thumbscrollbarbackground.darken(0.15).blend(primary, 0.4)Scrollbar handle (40% primary on darkened bg)
Scrollbar hoverscrollbar_hoverbackground.darken(0.15).blend(primary, 0.6)Hovered scrollbar thumb (60% primary)
Scrollbar trackscrollbar_bgbackground.darken(0.15)Scrollbar track (CIE-Lab −15% L*)

Special widget roles

RoleFieldDerived fromUsed for
Toast backgroundtoast_bgpanel.lighten(0.15)Toast/notification background (CIE-Lab +15% L*)
Footer backgroundfooter_bgpanelKeyFooter background
Footer keyfooter_keyaccentKeyFooter key binding colour
Footer descriptionfooter_descpanel.text_on(0.87)KeyFooter description text (auto 87% on panel)
Markdown code backgroundmarkdown_code_bgsurface.blend(foreground, 0.06)Inline code blocks in Markdown

Helper methods

Theme::shade

pub fn shade(c: Rgb, n: i32) -> Rgb

Textual’s $color-lighten-N / $color-darken-N: shifts CIE-Lab L* by 15% per step. Positive n lightens, negative darkens. Matches Textual’s lightness transformations (e.g. shade(primary, 1) = +15% L*, shade(primary, -2) = −30% L*).

Rgb::text_on

pub fn text_on(self, alpha: f32) -> Rgb

Textual’s auto N%: blends the best-contrast text (white or black, chosen by self.contrast_text()) over this colour at the given alpha (0.0 = transparent, 1.0 = fully opaque). Use this to generate readable text over any background: bg.text_on(0.87) gives primary readable text, bg.text_on(0.60) gives muted secondary text.

Choosing the right role

What you are drawingRole to use
Text over the page backgroundtext, text_muted or text_disabled
Text over a widget surface or panelsurface.text_on(0.87) or panel.text_on(0.87)
Text inside a coloured button or badgevariant_colour.text_on(0.9)
Text tinted to carry meaningtext_primary, text_success, text_error and the rest of that set
A borderborder when focused, border_blurred when not
A hover stateblend hover_bg over the widget surface
A lighter or darker step of any colourTheme::shade(colour, steps), which matches Textual’s 15% L* step, rather than raw lighten()

Keyboard conventions

tuile follows Textual’s keyboard patterns across every widget. Learn them once and they work everywhere.

Global and focus movement

KeyAction
TabMove focus to the next widget in the focus ring
Shift-TabMove focus to the previous widget

Focus is managed by your app through Focus<Id>. Call focus.handle_key(key) to route Tab and Shift-Tab, or implement your own ring. The widgets themselves do not trap Tab.

Activation

KeyAction
EnterActivate (buttons, list rows, select options, dialogs, tabs)
SpaceActivate (buttons, list rows) or toggle (checkboxes, switches, table selection)

The helper is_activate(key) recognizes both. Buttons flash for 120 ms on activation. Lists and selects mark state.activated with the chosen index.

Lists and menus

KeyAction
Up / DownMove cursor up or down one row
j / kMove cursor down or up (vim-style, in ListView and OptionList)
HomeJump to the first row
EndJump to the last row
PageUpMove up ten rows (five rows in CommandPalette)
PageDownMove down ten rows (five rows in CommandPalette)
Type-aheadType letters to jump to the next matching entry

Type-ahead resets after a second of inactivity. In ListView it is fuzzy; in Select and CommandPalette it matches prefixes.

OptionList has a special value-cycling mode: Left/Right, h/l or Space adjusts the value at the cursor (cycles choices, toggles bools, steps numbers) and reports it through take_changed. Enter never cycles: it reports the row through take_activated, which is how an app opens a dropdown, a text field, or runs a command for the row the cursor is on. A Text or Action row has nothing to cycle and only ever activates.

Text fields

Single-line Input and multi-line TextArea share the same motion and editing keys.

KeyAction
Left / RightMove cursor one grapheme
Ctrl-Left / Ctrl-RightMove to previous or next word boundary
HomeMove to start of line (or start of document with Ctrl in TextArea)
EndMove to end of line (or end of document with Ctrl in TextArea)
Up / DownMove up or down one line (TextArea only)
PageUp / PageDownMove up or down ten lines (TextArea only)

Word boundaries follow the rule in core::word_boundary: skip non-alphanumerics, then the word itself. Both widgets use the same function so they cannot drift.

Editing

KeyAction
BackspaceDelete backward (by word with Ctrl in Input)
DeleteDelete forward
TabAccept suggestion (Input) or insert four spaces (TextArea)

Input shows a greyed-out suggestion from its suggester hook; Tab or Right at the end accepts it. TextArea has no autocomplete; Tab is indent.

Clipboard and undo

KeyAction
Ctrl-ASelect all
Ctrl-CCopy selection
Ctrl-XCut selection
Ctrl-VPaste
Ctrl-ZUndo
Ctrl-YRedo (TextArea only)

The clipboard is per-widget state, not the system clipboard. Input also supports emacs-style shortcuts: Ctrl-U deletes from cursor to start, Ctrl-K deletes to end, Ctrl-W deletes the word before the cursor, and Alt-B / Alt-F move by word.

TextArea adds Ctrl-D to duplicate the current line and Alt-Up / Alt-Down to move the line up or down.

KeyAction
EscapeClose the dropdown, dialog, or palette

Select and MultiSelect open on Enter, Space, or Down when closed. Modal closes on Escape only if close_on_escape is true (the default). CommandPalette always closes on Escape.

Click outside a dropdown or modal backdrop to dismiss (when close_on_backdrop is enabled).

Tables

KeyAction
ArrowsMove cursor (row or cell depending on TableCursor mode)
Home / EndJump to first or last row
PageUp / PageDownMove up or down ten rows
EnterActivate the current row (sets state.activated)
SpaceToggle selection for the current row
sToggle sort on the current column

Header clicks also toggle sort. Column boundaries can be dragged to resize.

Tabs

KeyAction
Left / RightSwitch to the previous or next tab
Home / EndJump to the first or last tab
1..9Jump to tab 1 through 9
Ctrl-W or DeleteClose the current tab (when closable)
Space or EnterRe-activate the current tab (returns Outcome::Changed)

Mouse wheel over the tab bar also cycles tabs.

Deviations

Most widgets follow the patterns above without change. Two exceptions:

  • CommandPalette uses Home and End to jump to the start and end of the query input, not the result list. Use PageUp to reach the top of the list.
  • Modal in prompt mode sends all typing to the text input until you Tab to the buttons. Up/Down do not navigate; Left/Right and Tab move between buttons.

Showcase app keys

The showcase gallery app itself uses:

KeyAction
] / [Next or previous page
Alt-1..9Jump to page 1 through 9
Ctrl-POpen command palette (pages, themes, actions)
Ctrl-TCycle to the next theme
Ctrl-BToggle sidebar
F1Show help overlay
F3Toggle reduce motion (disables animations)
Ctrl-C / Ctrl-QQuit
qQuit (when no widget consumed the key)
EscDismiss all toasts (when no modal or dropdown is open)

Inside pages, Tab and Shift-Tab move focus, arrows adjust focused widgets, and Enter/Space activate.

tuile widget contract

Every widget in tuile/src/widgets/ follows these rules so that apps can compose them without reading their source. Read tuile/src/{core,theme,anim,draw,layout}.rs and tuile/src/widgets/scrollbar.rs (the reference implementation) before writing one.

Shape

/// Builder: cheap value, consumed by `render`. Holds only configuration for THIS frame.
pub struct Switch { on: Option<bool>, focused: bool, enabled: bool, now: Option<Instant>, theme: Option<Theme>, /* … */ }

/// State: lives in the app between frames. Owns the value, hover/press tracking, tweens,
/// scroll offsets, cursor positions. Implements `Interactive`.
#[derive(Debug, Default, Clone)]
pub struct SwitchState { pub on: bool, pub hit: HitBox, anim: Tween, /* … */ }

impl Switch {
    pub fn new() -> Self;                       // or `new(required_args)`
    pub fn theme(self, th: &Theme) -> Self;     // ALWAYS present; default `theme::current()`
    pub fn focused(self, v: bool) -> Self;      // focus is owned by the app
    pub fn enabled(self, v: bool) -> Self;      // disabled look + ignore events
    pub fn now(self, now: Instant) -> Self;     // ONLY if the widget animates; never call Instant::now()
    /* configuration setters, one per option, returning Self */
}

impl StatefulWidget for Switch { type State = SwitchState; fn render(self, area, buf, state) }
// Stateless widgets (Label, Rule, Badge, Digits, charts without interaction) implement `Widget` instead.

impl SwitchState {
    pub fn new(/* initial value */) -> Self;
    pub fn animating(&self, now: Instant) -> bool;  // ONLY if the widget animates
    /* value accessors / mutators: `toggle()`, `set(v)`, `value()` … */
}
impl Interactive for SwitchState {
    fn handle_key(&mut self, key: KeyEvent) -> Outcome;    // the app calls this only when focused
    fn handle_mouse(&mut self, m: MouseEvent) -> Outcome;  // the app forwards EVERY mouse event
}

Rules:

  1. Render sets geometry. render must store its area (and any sub-rects used for hit testing) in the state via HitBox::set_area / plain Rect fields. Mouse handling uses only those cached rects. Never recompute layout in handle_mouse.
  2. Outcome semantics. Ignored = not mine. Consumed = redraw (hover/press/scroll/cursor move). Changed = the value the widget owns changed. Submitted = the value was committed (Enter in a field, a row activated, a dialog answered). An editing widget reports Changed per keystroke and Submitted once, so a screen that persists on commit does not persist on the first character typed. is_changed() is true for both, is_submitted() only for the commit; | keeps the strongest outcome, so Submitted sorts above Changed. Every widget states in its own docs what Changed means for it, and a widget that commits exposes the drain accessor (take_submitted() / take_activated()) next to it.
  3. Hover comes from HitBox in the state; focus and enabled come from the builder. Build Look { focused, hover: state.hit.hover, enabled } and style from it (th.focus_bg(), th.border vs th.border_blurred, th.hover_bg, th.text_disabled).
  4. Animation. Time comes in through .now(instant) on the builder and state.now(instant) (or the now argument) for event handlers that start tweens. Store Tweens in the state; expose animating(now). Respect .duration(Duration) / Duration::ZERO = instant. Looping animations (spinners, shimmers, marquees) take their phase from anim::since(now) (seconds since the process-wide anim::EPOCH) and offer .elapsed(f32) as the explicit, testable override; never ask the app for a start instant.
  5. Theme. let th = self.theme.unwrap_or_else(theme::current); inside render (Theme is Copy; builders store Option<Theme> and .theme(&Theme) does Some(*th)). Use semantic roles (th.surface, th.panel, th.primary, th.text_muted, th.cursor_bg, Variant → th.variant(v) / th.text_variant(v)). Never hard-code colours. Text fields (Input, TextArea, Select, Combobox, MultiSelect, PromptComposer) frame themselves through draw::FieldShape (.shape(..), default Tall(Edge::Full)); accent bars use draw::Edge (Hair/Thin/Half/Full). Never hard-code a side-bar glyph in a widget.
  6. Draw through crate::draw. fill, put, put_centered, Border::*.draw/draw_titled, hbar, blend_area, truncate, wrap. They clip to the buffer; you never index buf[(x, y)] without first checking buf.area.contains(..) (or use the helpers).
  7. Never panic on size, and never vanish silently. Any area (including 0×0 and 1×1) must render without panicking. Degrade first: hide parts, truncate text, drop a subtitle row. When a widget genuinely cannot draw, it implements core::MinSize and guards with if draw::refuse(buf, area, self.min_size(), th.text_disabled) { return; }, which paints a dim ⋯. A private if area.height < 2 { return; } is forbidden: the state still accepts every key while nothing paints, so the symptom a consumer sees is “my keystrokes are not arriving”, not “my widget is too small”. min_size() reads self, because style and FieldShape change the answer, and it is what layout should allocate from: Constraint::Length(w.min_size().1). The guard keeps whatever geometry reset the old early return did — a stale HitBox clicks a widget that is no longer on screen. Per-family numbers are tabled below.
  8. Keyboard. Follow Textual bindings: Enter/Space activate, arrows move, Home/End, PageUp/PageDown, Esc closes overlays, Tab is not handled by widgets (the app’s Focus ring does it) except inside multi-field widgets that document it. A widget that can coexist with a focused text field must let the caller narrow what it claims. A persistent nav bar wired ahead of the focused screen — the obvious wiring — used to swallow Enter, Space, every digit and caret movement app-wide, and because letters still arrived it read as a submit bug rather than a routing bug. Chrome widgets therefore take a key-group selector (TabBar/TabBarState::keys(TabKeys::ARROWS), default TabKeys::ALL = today’s behaviour) and return Ignored for anything outside it. List every key group you claim in the widget’s docs.
  9. Names. PascalCase builder, <Builder>State state, enums for options (TabStyle::Underline). Do not reuse ratatui widget names (Tabs, Table, List, Sparkline, Gauge, Chart, BarChart, Scrollbar, Paragraph, Block, ListItem, Row, Cell). Use TabBar, DataTable, ListView, SparkChart, Meter, LineGraph, BarGraph, TableColumn, TableRow, TreeNode, MenuItem, ListEntry…
  10. Docs. Every pub item gets a /// doc line. The module starts with //! explaining what it renders and a 3 to 6 line usage example.
  11. Tests. One #[cfg(test)] mod tests per module with behaviour tests that render into a Buffer::empty(Rect) and/or drive handle_key/handle_mouse and assert on state/cells. No snapshot dumps. Test the tricky logic (cursor math, scroll clamping, sort, wrap).
  12. Overlays. Widgets that pop something over other content (Select dropdown, Menu, Tooltip) render their base in render and expose render_overlay(&self/state, buf, bounds) (or take a &mut Overlay<'a>), positioned with layout::popup_below.
  13. No new dependencies. ratatui, unicode-width, unicode-segmentation only.
  14. Performance. No per-frame heap churn beyond small Strings; no Instant::now() inside render; precompute glyph tables as const.
  15. Solid = background paint, lines = box drawing or edge eighths. Paint filled areas with " " + bg (fill, hbar, █ entries in Border::glyphs), never with █/▐/▌ foreground glyphs: fonts leave seams between block glyphs, some fonts overshoot the cell vertically, and terminals with a minimum-contrast setting recolour any glyph whose fg is close to its bg (this turned every pill end and tall border into a stray bar). Accent markers and side rails use box drawing (┃, │), which tiles pixel-exact everywhere; the Textual-style Tall/Panel/Wide borders pair painted bars with a thin ▔/▁/▏/▕ line so the corners always meet. Half-blocks only for partial cells in animated bars. Wide/ambiguous glyphs (★, emoji) get two cells.
  16. Icons are width-1, non-emoji, and present in common monospace fonts. Terminals size glyphs with their own tables: anything with the Unicode Emoji property (▶ ◀ ▪ ▫ ⚠ ♥ ⚡ ☰ ⚙ ✦ ℹ …) is two cells for utf8proc-based terminals (tmux, foot, kitty) while unicode-width says one, so every cell after it on that row shifts and the whole row skews. Use glyphs that are 1 cell everywhere and exist in JetBrains Mono / DejaVu: ◈ ◉ ◔ ◌ ◆ ◇ ● ○ ■ □ ◧ ◫ ◊ ⊞ ⊡ ⊙ ⊛ ⊕ ⊗ ⌘ ⌂ ≡ ▸ ▹ ◂ ◃ ▴ ▾ ▲ ▼ • ◦ ✓ ✗ ✶ ¶ ⋮ ⋯ ❯. Check a candidate with unicode_width::UnicodeWidthStr::width and a cargo xtask shot screenshot before using it.
  17. Syntax highlighting. Highlighter (from core) returns styled ranges as (start, end, Style) where start..end are grapheme-cluster offsets (as walked by line.graphemes(true)), not byte or char indices. Use draw::put_highlighted to apply them. Ranges must be ascending and non-overlapping; the helper ignores malformed ranges rather than panicking. Text outside all ranges keeps the base style.
  18. Which side owns a setting. The state owns what must survive frames: scroll offsets, cursor positions, hit rects, tween clocks, queues, and anything an event handler reads before the next render (a key-claim selector; a toast queue’s corner, because the slide animation interpolates against it). The builder owns per-frame look. A setting that has to live on the state is still mirrored on the builder, writing through in render, so the first guess compiles: ToastStack::new().corner(..) and Toaster::corner both work. Exceptions — settings that are state-only with no builder mirror — are listed under “State-owned settings” below.

Minimum sizes

min_size() on the builder is authoritative — it reads the configuration, so a style or a FieldShape changes the answer. Allocate from it (Constraint::Length(w.min_size().1)) instead of guessing. Below it, render paints a dim ⋯ and returns; it never draws nothing.

chrome below is FieldShape::vertical_chrome(): 2 for the default Tall(Edge::Full), Rule, Round and Band; 0 for Bar, Bars, Prompt and None. .compact(true) on Input, TextArea and Select selects the zero-chrome shape, which is how a field fits in one row.

FamilyWidgetMinimum (w, h)
Text entryInput(7, 1 + chrome)
TextArea(4, 1 + chrome)
Select, Combobox, MultiSelect(8, 1 + chrome), compact (4, 1)
Slider, RangeSlider(10, 1 + chrome)
Stepper(15, 1) for a one-digit value; a wider value refuses visibly
Rating(2 × max, 1)
PromptComposer(8, 2 + chrome)
ControlsButton, Checkbox, RadioGroup, Segmented(3, 1)
Switchstyle-dependent
CheckList(5, 3)
OptionList(8, 1)
NavigationTabBar(8, 2) Underline, (8, 3) Boxed, else (8, 1)
TabbedContentthe bar’s minimum + 1 content row, + 2 each way when bordered
MenuBar, Breadcrumbs, Paginator, KeyFooter, Collapsible(8, 1)
AppHeader(8, 1), tall (8, 3)
Steps(8, 1) horizontal, one row per label vertical
Timeline(10, 1)
Accordion(8, titles)
ScrollView(1, 1)
SplitPane(3, 1)
Panel1 + chrome both ways
DataDataTablecontent (2, header + 1) plus 2 × padding both ways: (4, 4) by default
TreeView(3, 3) bordered (the default), (1, 1) without
KeyValueList(2, 1)
Catalogcolumns + header rows
Digits3 cells per digit × (_, 3)
BigMenu, BigTextone character in the configured font, plus that style’s chrome
LineGraph(10, 5)
ActivityGraph(10, 8)
Meter(4, 1)
RadialGauge(12, 6)
LogView, Markdown(5, 2)
ColorPicker(20, 10)
GradientBar(5, 1)
ThemePalette(15, 2)
ContentBadge, KeyValueList(2, 1)
Pill, KeyCap, Loader(3, 1)
StatusLine(4, 1)
StatCard(6, 2)
Skeleton(3, 1) for Chart (it degrades to one bar), else (3, 2)
AIStreamText(1, 1)
TokenHeat(2, 1)
Thinking, DiffView, ModeBadge(4, 1)
ContextGauge(4, 1) compact, (4, 2) full
ToolCall(4, 3)
ChatView(8, 2)
CostMeter, EditPreview(8, 2)
HarnessStatus(10, 1)
RateGraph(10, 2)
RetryNotice(10, 1) compact, (10, 3) card
AgentLanesname column + a 10-cell track, 2 rows
Approval(12, 1) inline, (12, 2) banner, (12, 5) card
TurnStats(12, 2)
CompactionBanner(20, 2)

Degrading beats refusing where a widget can still say something true: Skeleton’s chart shape draws a single bar under 2 rows rather than a marker, and reports 1 as its minimum height.

State-owned settings

Settings that live on the state because an event handler reads them between renders. Both of these are mirrored on the builder, so either side works:

State fieldBuilder mirrorWhy the state owns it
Toaster::cornerToastStack::cornerthe queue slides toasts toward it between frames
TabBarState::keysTabBar::keyshandle_key consults it before the next render

Showcase page contract (showcase/src/pages/<name>.rs)

#[derive(Default)] pub struct XxxPage { /* widget states, Focus<Id> */ }
impl Page for XxxPage {
    fn title(&self) -> &'static str;              // fixed, matches pages/mod.rs registry
    fn subtitle(&self) -> &'static str;           // one line
    fn icon(&self) -> &'static str;               // single narrow glyph (avoid emoji/wide)
    fn draw(&mut self, area: Rect, buf: &mut Buffer, ctx: &mut Ctx);
    fn event(&mut self, ev: &Event, ctx: &mut Ctx) -> Outcome;
    fn animating(&self, now: Instant) -> bool;
    fn bindings(&self) -> &'static [(&'static str, &'static str)];
}
  • The page gets the content rect; paint your own panels/cards inside it (ctx.theme.background is already filled). Use Border::Round/Panel cards with titles per widget family; group by rows with layout::stack/columns.
  • Keyboard: keep a Focus<Id> ring; route keys to the focused widget; return Outcome::Ignored for keys you do not handle so the shell can use [, ], q. Mouse: forward to every widget state (each one checks its own rect); on Hit::Press/Changed from a widget, move focus to it.
  • Show every option of each widget: variants, disabled, focused, sizes, styles. Make it interactive: clicking/typing must visibly change something. Use ctx.notify(msg, Variant) for actions (button pressed, row selected).
  • Use ctx.now for .now(..), ctx.dur(ms) for tween durations (respects reduce-motion), and ctx.elapsed() for looping animations.
  • Must render without panic from 60×16 up to 250×70. Verify with cargo xtask shot -s 60x16 --text -- ./target/debug/showcase --page <title>.

FAQ

Does tuile replace ratatui?

No. tuile is a widget library built on top of ratatui, not a replacement for it. It builds on ratatui-core, the crate upstream recommends for widget libraries, and re-exports it alongside crossterm so your Cargo.toml needs only tuile. Every widget renders into ratatui’s Buffer, the same type the ratatui facade gives you. Your own Rect math, raw text placement, and custom drawing code keep working exactly as before.

Do I need the App runtime?

No. The run(&mut app) runtime is a convenience: it sets up the terminal with mouse capture and panic-safe restore, then redraws at 60 fps only while app.animating() returns true. If you already have an event loop or want fine control over redrawing, skip the runtime and call .render(area, buf, &mut state) yourself from whatever loop you have. Every widget works in either mode.

How do I test animated widgets without waiting?

Pass a fixed Instant offset with .elapsed(secs) instead of .now(Instant::now()). The animation helpers work from an epoch you control, so tests can freeze time or skip ahead without sleeping. For example, .elapsed(0.5) renders the widget at exactly 500 ms into its tween, letting you snapshot the halfway frame instantly.

Why doesn’t my widget react to mouse clicks?

Widget states cache their hit rectangles during render, and handle_mouse uses those rects to resolve whether a click lands inside. If the state has never rendered, or if the widget moved since the last render and you reused the old area, the cached rect is stale or zero and every click will miss. Always render before you handle mouse events.

How do I use a different theme for one widget?

Pass .theme(&custom_theme) on the widget builder. Every widget falls back to theme::current(), but an explicit .theme() overrides it for just that one render. Build a Theme from a ThemeSpec with Theme::resolve(&spec, None), or tweak one role on an existing theme by cloning and editing the struct directly.

What is the minimum terminal size?

The showcase app enforces 60×16 and shows a message below that. Individual widgets do not panic at any size; they clip or collapse gracefully, but usability suffers under about 40 columns or 10 rows. If your app has a practical minimum, check frame.area() in draw and render a prompt when too small.

Does tuile support Windows?

tuile itself is platform-agnostic. It depends on crossterm, which supports Windows, macOS, and Linux. The runtime uses crossterm::terminal for setup and teardown, and the event loop reads crossterm::event::poll. The local clock helpers (local_hms, local_ymd) shell out to date +%z on Unix to read the timezone offset; on Windows that call will fail and the offset defaults to zero (UTC). Widgets and rendering work everywhere crossterm works.

How do I add my own widget?

The drawing and layout primitives are public and match the ones every built-in widget uses: draw::{Border, put, fill, hbar, wrap, truncate, st} and layout::{stack, columns, center, popup_below}. Read docs/src/reference/widget-contract.md for the checklist (builder plus state, Interactive, cached hit rects, no panics at any size) and look at tuile/src/widgets/scrollbar.rs as the reference implementation. The custom example shows a complete app with two custom widgets.

Why are my solid-color buttons or fills showing seams between cells?

The library paints backgrounds with cell.bg and a space character, not foreground block glyphs like █. Real terminals with minimum-contrast settings brighten foreground glyphs when fg is close to bg, turning solid runs of █ into visible bars, and fonts leave hairline seams between adjacent block characters. Background fills with " " render as true solid rectangles. If you are porting code that uses ▐ ▌ █ for solids, replace them with background color on a space.

What is the MSRV?

Rust 1.88. The library uses let-chains and inline-const patterns, which stabilized in 1.88. The Cargo.toml pins rust-version = "1.88" to enforce this at build time.

Is tuile affiliated with Textualize?

No. The design language is deliberate (Textual’s color system, widget appearance, and keyboard conventions), but the code is independent. tuile ports the parts of Textual that make building a TUI pleasant into plain Rust and ratatui, with no Python runtime, no CSS engine, and no messaging layer.

How stable is the API before 1.0?

The API is pre-1.0, so breaking changes may land between minor versions. The builder and state pattern is stable, and the core helpers (Outcome, Interactive, Focus, HitBox) are unlikely to change shape. Widget options and field names may be renamed or reorganized as patterns settle. Pin a git rev in Cargo.toml if you need stability, or track the main branch for fixes and new widgets.

Can I use async or tokio with tuile?

The run runtime is a synchronous loop over crossterm::event::poll. It does not use async or require an executor. If your app already runs on tokio, you can skip the runtime and drive the widgets yourself: spawn crossterm::event::EventStream on a task, send events to your app’s state, call .render() and flush the terminal from the main task. The widgets themselves are plain structs with no async dependencies.

Why is my text input showing the wrong characters?

Input and TextArea work in grapheme clusters (via unicode-segmentation), not bytes or chars. A single cursor position may correspond to multiple chars (for example an emoji with a skin-tone modifier). If you are indexing into state.value as bytes or chars, you will see misaligned insertions or mojibake. Use state.value.graphemes(true).collect::<Vec<_>>() or the provided cursor methods, which already handle grapheme boundaries correctly.