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:
- A builder configures one frame. A state lives between frames and owns the value, the hover flag, the scroll offset and any running tween.
- Events go into a state and come back as an
Outcome:Ignored,ConsumedorChanged. - 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 to | Read |
|---|---|
| Add tuile to a project | Installation |
| Build something end to end | Your first app |
| See every widget running | Running the showcase |
| Understand the model | Builders and state |
| Change the colours | The colour system |
| Find the widget you need | Widget index |
| Write your own widget | Writing 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
| Rust | 1.88 or newer, edition 2024 |
| ratatui | 0.30 |
| Terminal | anything 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:
| Field | Default | Effect |
|---|---|---|
mouse | true | Enable mouse capture. Turn it off if you want the terminal’s own text selection. |
fps | 60 | Redraw rate while animating() is true. |
idle_redraw | Some(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.
What to read next
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
| Key | Action |
|---|---|
] [ | Next and previous page |
alt+1 to alt+9 | Jump to a page by number |
Ctrl-P | Command palette (fuzzy page search) |
Ctrl-T | Cycle to the next theme |
Ctrl-B | Toggle the sidebar |
F1 | Help overlay |
F3 | Toggle reduced motion |
Tab Shift-Tab | Move focus inside the page |
Esc | Dismiss all toasts |
q or Ctrl-C | Quit |
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
| Page | What it demonstrates |
|---|---|
| Welcome | The library’s own front page, big text and a card grid |
| Dashboard | Every family composed into one realistic ops screen |
| Monitor | A btop-style monitor: LED meters, dot-field graphs, mirrored network graph, process tree |
| Controls | Buttons, checkboxes, switches, radios, segmented controls, sliders, steppers, ratings |
| Inputs | Text fields, text areas, selects, comboboxes, multi-selects, all eight field shapes |
| Navigation | Tab bars in five styles, lists, trees, menus, breadcrumbs, paginator |
| Big menus | Four big fonts, ten menu styles, gradients |
| Tables | Sortable data table, cell navigation, key-value lists, big digits |
| Charts | Every chart type with live data |
| Feedback | Progress, spinners, modals, callouts, tooltips, the command palette |
| Notifications | Ten toast styles, six positions, the inbox, banners, inline alerts |
| Loading | Twenty loader styles, skeletons, the loading overlay |
| Spinners | The whole 102-entry catalog, filterable, with the one-liner for each |
| AI | A replayed agent turn: thinking, markdown, tool calls, streaming, approvals |
| AI Tools | Tool timeline, shell and code blocks, edit preview, change set, JSON tree |
| AI Agents | Agent tree and lanes, token and cost meters, context map, compaction, sessions, models |
| AI Composer | Slash commands, mentions, attachments, mode badge, status line, questions, plan, queue |
| Layout | Split panes, scroll views, panels, collapsibles, headers and footers |
| Content | Labels, badges, markup, markdown, logs, calendars, colour pickers, timelines |
| Settings | A complete preferences form in about 300 lines |
| Options | An omp-style settings screen: icon tabs, group sidebar, option list, live preview |
| Themes | All 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
| Method | Effect |
|---|---|
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 |
wrap | Public 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:
| Function | Returns |
|---|---|
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.
| Method | Effect |
|---|---|
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:
| Function | Returns |
|---|---|
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 colourCOLORTERM=truecolororCOLORTERM=24bit→ 24-bit RGBTERMending 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:
-
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 } -
Assert on the resolved
Colorin the buffer, not on the sourceRgb. 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");
}
| Field | Required | Default when omitted |
|---|---|---|
name | yes | |
dark | yes | |
primary | yes | |
secondary | no | falls back to primary |
warning, error, success, accent | no | fall back to primary |
background | no | near-black for a dark theme, near-white for a light one |
foreground | no | the inverse of the background |
surface | no | derived from the background |
panel | no | surface 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.
| Name | Mode | Primary | Notes |
|---|---|---|---|
textual-dark | dark | #0178D4 | The default. Textual’s own dark palette |
textual-light | light | #004578 | Textual’s light palette |
nord | dark | #88C0D0 | Cool blue-grey, low contrast |
gruvbox | dark | #85A598 | Warm, muted, high legibility |
catppuccin-mocha | dark | #F5C2E7 | Pastel on a soft dark background |
catppuccin-latte | light | #8839EF | The light member of the same family |
dracula | dark | #BD93F9 | Purple on near-black |
tokyo-night | dark | #BB9AF7 | Deep blue background, violet accents |
monokai | dark | #AE81FF | The classic editor palette |
flexoki | dark | #205EA6 | Paper-inspired, ink-like text |
solarized-light | light | #268bd2 | Solarized’s light half |
rose-pine | dark | #c4a7e7 | Muted 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.
| Method | Effect |
|---|---|
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 need | Use |
|---|---|
| Run an action | Button |
| A setting that applies immediately | Switch |
| A setting that applies on submit | Checkbox |
| One of several, with room to explain each | RadioGroup |
| One of two to four short options, in one row | Segmented |
| Several of many | CheckList |
| A continuous value | Slider |
| A small bounded integer | Stepper |
| A one to five score | Rating |
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:
| Field | Type | Purpose |
|---|---|---|
value | String | Current text content |
cursor | usize | Grapheme index of the cursor |
scroll | usize | Horizontal scroll offset |
selection | Option<(usize, usize)> | Selection anchor and cursor (if active) |
submitted | bool | True when Enter was pressed; read via take_submitted() |
error | Option<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
| Key | Action |
|---|---|
Left / Right | Move cursor one grapheme |
Ctrl-Left / Ctrl-Right | Move to word boundary |
Home / End | Jump to line start or end |
Ctrl-A | Select all |
Shift-Left / Shift-Right | Extend selection |
Backspace | Delete character before cursor (or selection) |
Delete | Delete character at cursor |
Ctrl-Backspace | Delete word before cursor |
Ctrl-U | Delete from cursor to start |
Ctrl-K | Delete from cursor to end |
Ctrl-W | Delete word before cursor |
Ctrl-X | Cut selection to clipboard |
Ctrl-C | Copy selection to clipboard |
Ctrl-V | Paste from clipboard |
Ctrl-Z | Undo (restores previous history entry) |
Tab | Accept autocomplete suggestion (if suggester is set) |
Enter | Submit (sets submitted flag) |
Right at end | Accept 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
| Method | Effect |
|---|---|
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:
| Field | Type | Purpose |
|---|---|---|
lines | Vec<String> | Lines of text content |
cursor | (usize, usize) | Row and column (grapheme indices) |
scroll_y | usize | Vertical scroll offset |
scroll_x | usize | Horizontal scroll offset |
selection | Option<(usize, usize, usize, usize)> | Selection rectangle (row0, col0, row1, col1) |
vscroll_state / hscroll_state | ScrollbarState | Scrollbar 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
| Key | Action |
|---|---|
Up / Down | Move cursor one line |
Left / Right | Move cursor one grapheme (wraps at line boundaries) |
Ctrl-Left / Ctrl-Right | Move to word boundary |
Home / End | Jump to line start or end |
Ctrl-Home / Ctrl-End | Jump to document start or end |
PageUp / PageDown | Move 10 lines |
Ctrl-A | Select all |
Ctrl-Z | Undo |
Ctrl-Y | Redo |
Ctrl-D | Duplicate current line |
Alt-Up / Alt-Down | Move current line up or down |
Ctrl-X | Cut selection to clipboard |
Ctrl-C | Copy selection to clipboard |
Ctrl-V | Paste from clipboard |
Backspace | Delete character before cursor (or selection) |
Delete | Delete character at cursor |
Enter | Insert newline (with auto-indent) |
Tab | Insert 4 spaces |
Mouse
Click positions the cursor. Drag selects a rectangular region. Wheel scrolls vertically.
Options
| Method | Effect |
|---|---|
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:
| Variant | Appearance |
|---|---|
Block | Inverse cell (default) |
Bar | Thin ▏ bar in cursor color |
Underline | Glyph underlined, bold, accent foreground |
Outline | Cell 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:
| Field | Type | Purpose |
|---|---|---|
options | Vec<SelectOption> | List of selectable options |
selected | Option<usize> | Index of selected option |
open | bool | True when dropdown is open |
highlight | usize | Index of highlighted option in open dropdown |
scroll | usize | Scroll offset in dropdown |
dropdown_width | DropdownWidth | Width 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:
| Key | Action |
|---|---|
Space / Enter / Down | Open dropdown |
When open:
| Key | Action |
|---|---|
Esc | Close without selecting |
Enter | Select highlighted option and close |
Up / Down | Move highlight (skips disabled options) |
Home / End | Jump to first or last option |
PageUp / PageDown | Move highlight by 5 |
| Any letter | Typeahead: 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
| Method | Effect |
|---|---|
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:
| Field | Type | Purpose |
|---|---|---|
input | String | Current input buffer (filter query) |
cursor | usize | Cursor position in input |
options | Vec<SelectOption> | All options |
filtered | Vec<usize> | Indices of options matching current input |
selected | Option<usize> | Index in options of selected option |
highlight | usize | Index in filtered of highlighted option |
open | bool | True when dropdown is open |
scroll | usize | Scroll 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
| Key | Action |
|---|---|
Down | Open dropdown (if closed) |
Esc | Close dropdown |
Enter | Select highlighted option and close |
Up / Down | Navigate filtered options (opens dropdown if closed) |
Home / End | Jump to first or last filtered option |
Left / Right | Move cursor in input buffer |
Backspace / Delete | Edit input buffer |
| Any letter | Append to filter and update filtered list |
Mouse
Same as Select: click to open/close, click an option to select, wheel scrolls.
Options
| Method | Effect |
|---|---|
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:
| Field | Type | Purpose |
|---|---|---|
options | Vec<SelectOption> | List of options |
selected | Vec<bool> | One flag per option (true = selected) |
open | bool | True when dropdown is open |
highlight | usize | Index of highlighted option |
scroll | usize | Scroll 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:
| Key | Action |
|---|---|
Space / Enter / Down | Open dropdown |
When open:
| Key | Action |
|---|---|
Esc | Close (selections remain) |
Enter | Close (selections remain) |
Space | Toggle highlighted option |
Up / Down | Move highlight |
Home / End | Jump to first or last option |
PageUp / PageDown | Move 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
| Method | Effect |
|---|---|
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.
| Variant | Appearance | Use when |
|---|---|---|
Tall(edge) | Thick side bars (edge controls thickness) plus thin ▔/▁ lines above and below | You want a prominent, Textual-style field (default) |
Bars(edge) | Left and right bars only, no top or bottom | You want vertical separation without horizontal lines |
Bar(edge) | Left bar only | You want a minimal accent (omp-style prompts) |
Rule | A ─ rule above and below, no side bars | You want horizontal separation in a dense form |
Round | Rounded box ╭─╮ / ╰─╯ | You want a softer, friendlier appearance |
Prompt | A ❯ prompt glyph before the first line, no frame | You want a shell-style prompt (single-line only) |
Band | Full-width band in field color with padding rows and muted › prompt | You want Claude Code’s composer style |
None | No frame | You want inline editing or custom framing |
The edge parameter controls bar thickness for Tall, Bars, and Bar:
| Edge | Width | Glyph |
|---|---|---|
Hair | 1/8 cell | ▏ / ▕ |
Thin | 2/8 cell | ▎ / ▕ (default) |
Half | 1/2 cell | ▌ / ▐ |
Full | Whole cell | Background 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.
| Style | Description |
|---|---|
Underline | Minimal tabs with an animated underline below the active tab (default, 2 rows tall) |
Boxed | Each tab is a bordered box; active tab has no bottom border (3 rows tall) |
Pills | Rounded pill buttons with solid fill on the active tab (1 row tall) |
Segmented | Horizontal segmented control, tabs share borders (1 row tall) |
Minimal | Plain 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() {}
| Method | Effect |
|---|---|
.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
| Method | Effect |
|---|---|
.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
| Method | Effect |
|---|---|
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
| Method | Effect |
|---|---|
.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
| Method | Effect |
|---|---|
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
| Method | Effect |
|---|---|
.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 |
MenuBar and ContextMenu
A menu bar with dropdowns, submenus, keyboard navigation, and context menus. Both use the same item structure but different positioning.
MenuBar
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.
MenuItem types
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.
MenuBar keyboard navigation
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.
Breadcrumbs
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
| Method | Effect |
|---|---|
.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.
Navigation
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
| Method | Effect |
|---|---|
.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 textwidth(Constraint): column width constraint; defaults toFill(1)align(Alignment): cell alignment; defaults to Leftsortable(bool): whether clicking the header or pressingstoggles sort; defaults to falsekey(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 contentstyle(Option<Style>): overrides the default cell style if presentsort_key(Option<f64>): numeric sort value; when present, sorting compares numbers instead of text
Cursor modes
TableCursor has four variants:
| Mode | Behaviour |
|---|---|
None | No cursor; the table is display-only |
Row | Highlights the entire row; arrow keys move up and down |
Cell | Highlights a single cell; arrows move in all four directions |
Column | Highlights 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
textcase-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:
| Variant | Rendering |
|---|---|
None | No lines (default) |
Horizontal | Lines between rows |
Vertical | Lines between columns |
All | Grid |
.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 / method | Type / signature | Meaning |
|---|---|---|
cursor_row | usize | Cursor position in the sorted/filtered view (0-based) |
cursor_col | usize | Column index (for Cell or Column cursor modes) |
offset_y | usize | Vertical scroll offset in rows |
offset_x | usize | Horizontal scroll offset in cells |
selected | BTreeSet<usize> | Row indices (into original rows) that are selected |
sort | Option<(usize, bool)> | Current sort (column index, ascending) or None |
hover_row | Option<usize> | Row index under the mouse in the visible view |
filter | String | Current filter query (case-insensitive substring) |
visible_len() | usize | Number 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.
| Input | Action |
|---|---|
| Keyboard | |
| Up / Down | Move cursor row |
| Left / Right | Move cursor column (Cell mode) or do nothing (Row mode) |
| Home | Jump to first row |
| End | Jump to last row |
| Page Up | Move up 10 rows |
| Page Down | Move down 10 rows |
| Enter | Set activated to current row (consumed by take_activated()) |
| Space | Toggle selection of current row (if multi_select is true) |
s | Toggle sort on the current column (if sortable is true): ascending → descending → none |
| Mouse | |
| Click header | Toggle sort on that column (same as s) |
| Click header boundary | Start column resize drag |
| Drag (after boundary click) | Resize the left column; width is stored in state.width_overrides |
| Click row | Move cursor to that row and set activated if it was already focused |
| Double-click row | Set activated (same as Enter) |
| Hover row | Update state.hover_row (does not change cursor or selection) |
| Scroll wheel | Scroll the table vertically (adjusts offset_y) |
| Scrollbar drag | Scroll 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
| Style | Appearance |
|---|---|
Bars | Block glyphs ▁▂▃▄▅▆▇█ (default) |
Line | Braille line connecting values |
Area | Braille line with dimmed fill below |
Field | btop-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.
| LineStyle | Rendering |
|---|---|
Line | Braille line (default) |
Area | Braille line with dimmed fill below |
Points | Braille 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() {}
| Method | Effect |
|---|---|
.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
| Style | Appearance |
|---|---|
Line | Horizontal line ━━━━╺━━ (default) |
Block | Solid painted fill |
Segments(n) | n segments with gaps |
Blocks | btop-style LED row: ■■■■□□ (empties dimmed) |
Dots | Compact 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:
| Helper | Use |
|---|---|
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.
| Method | Effect |
|---|---|
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.
| Method | Effect |
|---|---|
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.
Modal
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.
| Method | Effect |
|---|---|
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:
| Method | Effect |
|---|---|
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).
| Style | Appearance |
|---|---|
Card | Rounded rectangle with shadow, icon on the left, title and message stacked. Default. |
Flat | Flat rectangle, no shadow, same layout as Card. |
Minimal | Transparent background, single-line text, no border. |
Pill | Highly rounded ends, compact single-line layout. |
Outline | Transparent background with a variant-colored border. |
Banner | Full-width bar (does not float in a corner), bold text. |
Glass | Semi-transparent fill, blurred effect (where terminals support it). |
Progress | Shows a horizontal progress bar under the message. Use .progress_value(f) to set fill. Does not auto-dismiss while progress is below 1.0. |
Action | Includes inline action buttons. Use .actions(&["Label", ...]) to add them. |
Grouped | Stacks 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:
| Anim | Behavior |
|---|---|
Slide | Slides in from the edge. Default. |
Fade | Fades in opacity. |
Pop | Scales up from small to full size. |
None | Appears 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:
| Method | Effect |
|---|---|
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:
| Input | Action |
|---|---|
| Up, Down | Navigate between notifications |
| Enter | Mark the selected notification as read |
| Delete | Dismiss the selected notification |
| Mouse click | Select 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.
Banner
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:
| Style | Appearance |
|---|---|
Solid | Solid variant background, white text. High contrast. |
Tinted | Variant color tinted 15% into the background with a left accent rail. Subtle. |
Outline | Transparent background with a variant-colored border. |
Options
| Method | Effect |
|---|---|
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.
| Method | Effect |
|---|---|
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.
| Style | Appearance | label | color | color2 | speed |
|---|---|---|---|---|---|
Scanner | Bright cell with fading tail sweeping back and forth | no | yes | no | yes |
Comet | Tail traveling one way and wrapping | no | yes | no | yes |
Sweep | Material indeterminate: eased segment sliding across | no | yes | no | yes |
FillDrain | Bar fills from left, then drains from left | no | yes | no | yes |
Pulse | Whole bar breathes | no | yes | no | yes |
Stripes | Barber-pole stripes marching right | no | yes | yes | yes |
Rainbow | Hue-cycling rainbow flowing along the bar | no | no | no | yes |
Snake | Three dots chasing along a dotted track | no | yes | yes | yes |
Chase | LED chase: one lit segment with dim trail | no | yes | no | yes |
Blocks | ▰▰▰▱▱▱ filling and emptying | no | yes | no | yes |
Wave | Traveling sine wave of eighth-blocks | no | yes | yes | yes |
Bounce | Ball bouncing between brackets | no | yes | no | yes |
Ping | Expanding rings (((●))) | no | yes | no | yes |
Heartbeat | Scrolling ECG trace with glowing spike | no | yes | yes | yes |
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.
| Style | Appearance | label | color | color2 | speed |
|---|---|---|---|---|---|
Ellipsis | label, label., label.., label... | yes | yes | no | yes |
Shimmer | Label with sweeping highlight band | yes | yes | yes | yes |
Typewriter | Label typed out with cursor, held, cleared, repeated | yes | yes | no | yes |
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.
| Style | Appearance | label | color | color2 | speed |
|---|---|---|---|---|---|
Equalizer | Bouncing spectrum bars | no | yes | yes | yes |
Rain | Matrix rain | no | yes | no | yes |
Radar | Radar sweep over a dot field with blips | no | yes | yes | yes |
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
| Method | Effect |
|---|---|
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
| Shape | Layout |
|---|---|
Text | One bar per entry in .lines([widths]) (cells), a blank row between when there’s room |
Card | Hero image block on top, then a title and two body lines |
Avatar | Avatar block with a name and handle beside it |
List | Stacked avatar rows: a list waiting for its items |
Table | Header cells and rows of column cells (2-4 columns depending on width) |
Chart | Bars 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.
| Method | Effect |
|---|---|
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).
| Method | Effect |
|---|---|
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.
| Method | Effect |
|---|---|
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.
| Method | Effect |
|---|---|
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.
| Method | Effect |
|---|---|
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.
| Method | Effect |
|---|---|
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.
| Method | Effect |
|---|---|
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() {}
| Method | Effect |
|---|---|
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() {}
| Method | Effect |
|---|---|
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() {}
| Method | Effect |
|---|---|
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.
Link
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
| Tag | Effect |
|---|---|
[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:
| Syntax | Rendered as |
|---|---|
# Heading to #### Heading | Headings (1-2 underlined) |
```lang fenced blocks | Code with language label |
---, ***, ___ | Horizontal rule |
> Quote | Blockquote with vertical bar |
- item or * item | Unordered list (nested: two-space indent) |
1. item | Ordered list |
- [ ] or - [x] | Task list (checked/unchecked) |
| header | with | --- | | Tables |
Inline elements:
| Syntax | Effect |
|---|---|
**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.
| Method | Effect |
|---|---|
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.
| Method | Effect |
|---|---|
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
| Method | Effect |
|---|---|
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).
| Method | Effect |
|---|---|
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.
| Field | Purpose |
|---|---|
selected | Index of the currently selected item |
hits | Mouse hit boxes (read-only, updated on render) |
| Method | Purpose |
|---|---|
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
| Key | Action |
|---|---|
| Up, k | Move selection up |
| Down, j | Move selection down |
| Left | Move selection up (wraps to last) |
| Right | Move selection down (wraps to first) |
| Home | Select first item |
| End | Select last item |
| Enter, Space | Activate 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
| Method | Effect |
|---|---|
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
| Method | Purpose |
|---|---|
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
| Key | Action |
|---|---|
| Up, k | Move cursor up |
| Down, j | Move cursor down |
| Home | Jump to first row |
| End | Jump to last row |
| Left, h | Cycle value backward (Bool toggles, Choice moves to previous option, Int decrements by step) |
| Right, l | Cycle value forward |
| Enter | Cycle 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
| Method | Effect |
|---|---|
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.
| Method | Effect |
|---|---|
.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.
| Variant | Renders 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 type | Widget |
|---|---|
| Free text | Input |
| Long text | TextArea |
| One of a few | RadioGroup or Segmented |
| One of many | Select or Combobox |
| Several of many | MultiSelect or CheckList |
| On or off, applied immediately | Switch |
| On or off, applied on submit | Checkbox |
| A bounded number | Slider or Stepper |
| A date | DatePicker |
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:
- Screen — your page, buttons, fields, lists
- Backdrop — dimmed overlay covering the screen
- Card — centered card with border, title, and body
- 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
| Flag | Effect |
|---|---|
-s COLSxROWS | Terminal 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 |
--text | Print the captured text instead of writing a PNG |
-o PATH | Where 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
| Widget | State type | Module | Chapter |
|---|---|---|---|
| Accordion | AccordionState | collapsible | Layout and chrome |
| ActivityGraph | none | charts | Charts |
| AgentLanes | none | ai_agents | The AI harness |
| AgentTree | AgentTreeState | ai_agents | The AI harness |
| AppHeader | AppHeaderState | header | Layout and chrome |
| Approval | ApprovalState | ai | The AI harness |
| AttachmentChips | AttachmentChipsState | ai_compose | The AI harness |
| Badge | none | text | Content |
| Banner | BannerState | notify | Notifications |
| BarGraph | none | charts | Charts |
| BigMenu | BigMenuState | bigtext | Big text and menus |
| BigText | none | bigtext | Big text and menus |
| BigTitle | none | bigtext | Big text and menus |
| Blinker | none | spinner | Loading |
| Breadcrumbs | BreadcrumbsState | breadcrumbs | Navigation |
| Button | ButtonState | button | Controls |
| Calendar | CalendarState | calendar | Content |
| Callout | CalloutState | toast | Notifications |
| Catalog | CatalogState | catalog | Tables and data |
| ChangeSet | ChangeSetState | ai_tools | The AI harness |
| ChatView | ChatState | ai | The AI harness |
| CheckList | CheckListState | toggle | Controls |
| Checkbox | CheckboxState | toggle | Controls |
| CodeBlock | none | ai_tools | The AI harness |
| Collapsible | CollapsibleState | collapsible | Layout and chrome |
| ColorPicker | ColorPickerState | color | Content |
| Combobox | ComboboxState | select | Text entry |
| CommandPalette | CommandPaletteState | palette | Navigation |
| CompactionBanner | none | ai_agents | The AI harness |
| ContextGauge | none | ai | The AI harness |
| ContextMap | none | ai_agents | The AI harness |
| ContextMenu | ContextMenuState | menu | Navigation |
| CostMeter | CostMeterState | ai_agents | The AI harness |
| DataTable | DataTableState | table | Tables and data |
| DatePicker | DatePickerState | calendar | Content |
| DiffView | none | ai | The AI harness |
| Digits | none | digits | Tables and data |
| EditPreview | EditPreviewState | ai_tools | The AI harness |
| ElapsedTimer | none | ai_agents | The AI harness |
| GradientBar | none | color | Content |
| HarnessStatus | none | ai_compose | The AI harness |
| Heatmap | none | charts | Charts |
| InlineAlert | none | notify | Notifications |
| Input | InputState | input | Text entry |
| JsonTree | JsonTreeState | ai_tools | The AI harness |
| KeyCap | none | text | Content |
| KeyFooter | KeyFooterState | footer | Layout and chrome |
| Label | none | text | Content |
| LineGraph | none | charts | Charts |
| Link | LinkState | text | Content |
| ListView | ListViewState | list | Navigation |
| LoadingIndicator | none | spinner | Loading |
| LoadingOverlay | none | loading | Loading |
| Loader | none | loading | Loading |
| LogView | LogViewState | log | Content |
| Markdown | MarkdownState | markdown | Content |
| MarkupLabel | none | text | Content |
| Marquee | none | spinner | Loading |
| MenuBar | MenuBarState | menu | Navigation |
| MessageQueue | MessageQueueState | ai_compose | The AI harness |
| Meter | none | charts | Charts |
| Modal | ModalState | modal | Feedback |
| ModeBadge | none | ai_compose | The AI harness |
| ModelPicker | ModelPickerState | ai_agents | The AI harness |
| MultiSelect | MultiSelectState | select | Text entry |
| NotificationCenter | NotificationCenterState | notify | Notifications |
| OptionList | OptionListState | options | Feedback |
| Paginator | PaginatorState | breadcrumbs | Navigation |
| Panel | none | panel | Layout and chrome |
| Pill | none | text | Content |
| PlanView | PlanViewState | ai_compose | The AI harness |
| ProgressBar | ProgressState | progress | Feedback |
| PromptComposer | ComposerState | ai | The AI harness |
| QuestionCard | QuestionCardState | ai_compose | The AI harness |
| RadialGauge | none | charts | Charts |
| RadioGroup | RadioState | toggle | Controls |
| RangeSlider | RangeState | slider | Controls |
| RateGraph | none | ai_agents | The AI harness |
| Rating | RatingState | slider | Controls |
| RetryNotice | none | ai_tools | The AI harness |
| Rule | none | text | Content |
| ScatterPlot | none | charts | Charts |
| Scrollbar | ScrollbarState | scrollbar | Layout and chrome |
| ScrollView | ScrollViewState | scroll | Layout and chrome |
| Segmented | SegmentedState | toggle | Controls |
| Select | SelectState | select | Text entry |
| SessionList | SessionListState | ai_agents | The AI harness |
| ShellBlock | none | ai_tools | The AI harness |
| Skeleton | none | loading | Loading |
| SlashMenu | SlashMenuState | ai_compose | The AI harness |
| Slider | SliderState | slider | Controls |
| SparkChart | none | charts | Charts |
| Spinner | none | spinner | Loading |
| Split | SplitState | split | Layout and chrome |
| StatCard | none | text | Content |
| StatusLine | none | text | Content |
| StepProgress | none | progress | Feedback |
| Stepper | StepperState | slider | Controls |
| Steps | StepsState | steps | Content |
| StreamText | none | ai | The AI harness |
| Suggestions | SuggestionsState | ai_compose | The AI harness |
| Swatches | SwatchesState | color | Content |
| Switch | SwitchState | toggle | Controls |
| TabBar | TabBarState | tabs | Navigation |
| TextArea | TextAreaState | textarea | Text entry |
| ThemePalette | none | color | Content |
| Thinking | none | ai | The AI harness |
| ToastStack | Toaster | toast | Notifications |
| TokenHeat | none | ai | The AI harness |
| TokenMeter | none | ai_agents | The AI harness |
| ToolCall | none | ai | The AI harness |
| ToolTimeline | ToolTimelineState | ai_tools | The AI harness |
| Tooltip | TooltipState | tooltip | Feedback |
| TreeView | TreeViewState | tree | Navigation |
| TurnStats | none | ai_agents | The AI harness |
| TypingIndicator | none | ai | The AI harness |
| MentionPicker | MentionPickerState | ai_compose | The 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:
| Role | Field | Derived from | Used for |
|---|---|---|---|
| Primary colour | primary | ThemeSpec.primary | Borders, buttons, accents (Primary variant) |
| Secondary colour | secondary | ThemeSpec.secondary or primary | Secondary variant buttons, links |
| Warning colour | warning | ThemeSpec.warning or primary | Warning variant elements |
| Error colour | error | ThemeSpec.error or secondary | Error variant elements |
| Success colour | success | ThemeSpec.success or secondary | Success variant elements |
| Accent colour | accent | ThemeSpec.accent or primary | Accent variant highlights |
| Background | background | ThemeSpec.background or #121212 (dark) / #efefef (light) | Page background |
| Foreground | foreground | ThemeSpec.foreground or background.inverse() | Maximum contrast text reference |
Surface roles
| Role | Field | Derived from | Used for |
|---|---|---|---|
| Surface | surface | ThemeSpec.surface or #1e1e1e (dark) / #f5f5f5 (light) | Default widget background |
| Panel | panel | surface.blend(primary, 0.1) + 4% contrast tint (dark) | Containers, panels, cards |
| Boost | boost | surface.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.
| Role | Field | Derived from | Used for |
|---|---|---|---|
| Default text | text | background.text_on(0.87) | Primary body text (auto 87%) |
| Muted text | text_muted | background.text_on(0.60) | Secondary labels, help text (auto 60%) |
| Disabled text | text_disabled | background.text_on(0.38) | Disabled controls (auto 38%) |
| Primary text | text_primary | foreground.blend(primary, 0.66) | Tinted text for Primary variant |
| Secondary text | text_secondary | foreground.blend(secondary, 0.66) | Tinted text for Secondary variant |
| Accent text | text_accent | foreground.blend(accent, 0.66) | Tinted text for Accent variant |
| Success text | text_success | foreground.blend(success, 0.66) | Tinted text for Success variant |
| Warning text | text_warning | foreground.blend(warning, 0.66) | Tinted text for Warning variant |
| Error text | text_error | foreground.blend(error, 0.66) | Tinted text for Error variant |
Border and cursor roles
| Role | Field | Derived from | Used for |
|---|---|---|---|
| Border (focused) | border | primary | Focused widget borders |
| Border (blurred) | border_blurred | surface.darken(0.025) | Unfocused borders (CIE-Lab −2.5% L*) |
| Cursor background | cursor_bg | primary | Text cursor fill colour |
| Cursor foreground | cursor_fg | primary.text_on(0.9) | Text under cursor (auto 90% on cursor_bg) |
| Cursor blurred | cursor_blurred_bg | surface.blend(primary, 0.3) | Unfocused cursor (30% primary over surface) |
Interaction roles
| Role | Field | Derived from | Used for |
|---|---|---|---|
| Hover background | hover_bg | surface.blend(foreground, 0.08) | Button/item hover state |
| Selection background | selection_bg | surface.blend(primary, 0.5) | Selected text, list items |
| Link colour | link | secondary.blend(foreground, 0.2) | Hyperlinks |
Scrollbar roles
| Role | Field | Derived from | Used for |
|---|---|---|---|
| Scrollbar thumb | scrollbar | background.darken(0.15).blend(primary, 0.4) | Scrollbar handle (40% primary on darkened bg) |
| Scrollbar hover | scrollbar_hover | background.darken(0.15).blend(primary, 0.6) | Hovered scrollbar thumb (60% primary) |
| Scrollbar track | scrollbar_bg | background.darken(0.15) | Scrollbar track (CIE-Lab −15% L*) |
Special widget roles
| Role | Field | Derived from | Used for |
|---|---|---|---|
| Toast background | toast_bg | panel.lighten(0.15) | Toast/notification background (CIE-Lab +15% L*) |
| Footer background | footer_bg | panel | KeyFooter background |
| Footer key | footer_key | accent | KeyFooter key binding colour |
| Footer description | footer_desc | panel.text_on(0.87) | KeyFooter description text (auto 87% on panel) |
| Markdown code background | markdown_code_bg | surface.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 drawing | Role to use |
|---|---|
| Text over the page background | text, text_muted or text_disabled |
| Text over a widget surface or panel | surface.text_on(0.87) or panel.text_on(0.87) |
| Text inside a coloured button or badge | variant_colour.text_on(0.9) |
| Text tinted to carry meaning | text_primary, text_success, text_error and the rest of that set |
| A border | border when focused, border_blurred when not |
| A hover state | blend hover_bg over the widget surface |
| A lighter or darker step of any colour | Theme::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
| Key | Action |
|---|---|
| Tab | Move focus to the next widget in the focus ring |
| Shift-Tab | Move 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
| Key | Action |
|---|---|
| Enter | Activate (buttons, list rows, select options, dialogs, tabs) |
| Space | Activate (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
| Key | Action |
|---|---|
| Up / Down | Move cursor up or down one row |
| j / k | Move cursor down or up (vim-style, in ListView and OptionList) |
| Home | Jump to the first row |
| End | Jump to the last row |
| PageUp | Move up ten rows (five rows in CommandPalette) |
| PageDown | Move down ten rows (five rows in CommandPalette) |
| Type-ahead | Type 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.
Navigation
| Key | Action |
|---|---|
| Left / Right | Move cursor one grapheme |
| Ctrl-Left / Ctrl-Right | Move to previous or next word boundary |
| Home | Move to start of line (or start of document with Ctrl in TextArea) |
| End | Move to end of line (or end of document with Ctrl in TextArea) |
| Up / Down | Move up or down one line (TextArea only) |
| PageUp / PageDown | Move 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
| Key | Action |
|---|---|
| Backspace | Delete backward (by word with Ctrl in Input) |
| Delete | Delete forward |
| Tab | Accept 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
| Key | Action |
|---|---|
| Ctrl-A | Select all |
| Ctrl-C | Copy selection |
| Ctrl-X | Cut selection |
| Ctrl-V | Paste |
| Ctrl-Z | Undo |
| Ctrl-Y | Redo (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.
Dropdowns and overlays
| Key | Action |
|---|---|
| Escape | Close 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
| Key | Action |
|---|---|
| Arrows | Move cursor (row or cell depending on TableCursor mode) |
| Home / End | Jump to first or last row |
| PageUp / PageDown | Move up or down ten rows |
| Enter | Activate the current row (sets state.activated) |
| Space | Toggle selection for the current row |
| s | Toggle sort on the current column |
Header clicks also toggle sort. Column boundaries can be dragged to resize.
Tabs
| Key | Action |
|---|---|
| Left / Right | Switch to the previous or next tab |
| Home / End | Jump to the first or last tab |
| 1..9 | Jump to tab 1 through 9 |
| Ctrl-W or Delete | Close the current tab (when closable) |
| Space or Enter | Re-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:
CommandPaletteuses 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.Modalin 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:
| Key | Action |
|---|---|
| ] / [ | Next or previous page |
| Alt-1..9 | Jump to page 1 through 9 |
| Ctrl-P | Open command palette (pages, themes, actions) |
| Ctrl-T | Cycle to the next theme |
| Ctrl-B | Toggle sidebar |
| F1 | Show help overlay |
| F3 | Toggle reduce motion (disables animations) |
| Ctrl-C / Ctrl-Q | Quit |
| q | Quit (when no widget consumed the key) |
| Esc | Dismiss 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:
- Render sets geometry.
rendermust store itsarea(and any sub-rects used for hit testing) in the state viaHitBox::set_area/ plainRectfields. Mouse handling uses only those cached rects. Never recompute layout inhandle_mouse. - 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 reportsChangedper keystroke andSubmittedonce, 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, soSubmittedsorts aboveChanged. Every widget states in its own docs whatChangedmeans for it, and a widget that commits exposes the drain accessor (take_submitted()/take_activated()) next to it. - Hover comes from
HitBoxin the state; focus and enabled come from the builder. BuildLook { focused, hover: state.hit.hover, enabled }and style from it (th.focus_bg(),th.bordervsth.border_blurred,th.hover_bg,th.text_disabled). - Animation. Time comes in through
.now(instant)on the builder andstate.now(instant)(or thenowargument) for event handlers that start tweens. StoreTweens in the state; exposeanimating(now). Respect.duration(Duration)/Duration::ZERO= instant. Looping animations (spinners, shimmers, marquees) take their phase fromanim::since(now)(seconds since the process-wideanim::EPOCH) and offer.elapsed(f32)as the explicit, testable override; never ask the app for a start instant. - Theme.
let th = self.theme.unwrap_or_else(theme::current);insiderender(ThemeisCopy; builders storeOption<Theme>and.theme(&Theme)doesSome(*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 throughdraw::FieldShape(.shape(..), defaultTall(Edge::Full)); accent bars usedraw::Edge(Hair/Thin/Half/Full). Never hard-code a side-bar glyph in a widget. - Draw through
crate::draw.fill,put,put_centered,Border::*.draw/draw_titled,hbar,blend_area,truncate,wrap. They clip to the buffer; you never indexbuf[(x, y)]without first checkingbuf.area.contains(..)(or use the helpers). - 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 implementscore::MinSizeand guards withif draw::refuse(buf, area, self.min_size(), th.text_disabled) { return; }, which paints a dim⋯. A privateif 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()readsself, because style andFieldShapechange 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 staleHitBoxclicks a widget that is no longer on screen. Per-family numbers are tabled below. - Keyboard. Follow Textual bindings:
Enter/Spaceactivate, arrows move,Home/End,PageUp/PageDown,Esccloses overlays,Tabis not handled by widgets (the app’sFocusring 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), defaultTabKeys::ALL= today’s behaviour) and returnIgnoredfor anything outside it. List every key group you claim in the widget’s docs. - Names.
PascalCasebuilder,<Builder>Statestate, enums for options (TabStyle::Underline). Do not reuse ratatui widget names (Tabs,Table,List,Sparkline,Gauge,Chart,BarChart,Scrollbar,Paragraph,Block,ListItem,Row,Cell). UseTabBar,DataTable,ListView,SparkChart,Meter,LineGraph,BarGraph,TableColumn,TableRow,TreeNode,MenuItem,ListEntry… - Docs. Every
pubitem gets a///doc line. The module starts with//!explaining what it renders and a 3 to 6 line usage example. - Tests. One
#[cfg(test)] mod testsper module with behaviour tests that render into aBuffer::empty(Rect)and/or drivehandle_key/handle_mouseand assert on state/cells. No snapshot dumps. Test the tricky logic (cursor math, scroll clamping, sort, wrap). - Overlays. Widgets that pop something over other content (Select dropdown, Menu, Tooltip)
render their base in
renderand exposerender_overlay(&self/state, buf, bounds)(or take a&mut Overlay<'a>), positioned withlayout::popup_below. - No new dependencies.
ratatui,unicode-width,unicode-segmentationonly. - Performance. No per-frame heap churn beyond small
Strings; noInstant::now()inside render; precompute glyph tables asconst. - Solid = background paint, lines = box drawing or edge eighths. Paint filled areas with
" "+bg(fill,hbar,█entries inBorder::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 andtallborder into a stray bar). Accent markers and side rails use box drawing (┃,│), which tiles pixel-exact everywhere; the Textual-styleTall/Panel/Wideborders 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. - 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) whileunicode-widthsays 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 withunicode_width::UnicodeWidthStr::widthand acargo xtask shotscreenshot before using it. - Syntax highlighting.
Highlighter(fromcore) returns styled ranges as(start, end, Style)wherestart..endare grapheme-cluster offsets (as walked byline.graphemes(true)), not byte or char indices. Usedraw::put_highlightedto 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. - 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(..)andToaster::cornerboth 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.
| Family | Widget | Minimum (w, h) |
|---|---|---|
| Text entry | Input | (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) | |
| Controls | Button, Checkbox, RadioGroup, Segmented | (3, 1) |
Switch | style-dependent | |
CheckList | (5, 3) | |
OptionList | (8, 1) | |
| Navigation | TabBar | (8, 2) Underline, (8, 3) Boxed, else (8, 1) |
TabbedContent | the 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) | |
Panel | 1 + chrome both ways | |
| Data | DataTable | content (2, header + 1) plus 2 × padding both ways: (4, 4) by default |
TreeView | (3, 3) bordered (the default), (1, 1) without | |
KeyValueList | (2, 1) | |
Catalog | columns + header rows | |
Digits | 3 cells per digit × (_, 3) | |
BigMenu, BigText | one 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) | |
| Content | Badge, 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) | |
| AI | StreamText | (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 | |
AgentLanes | name 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 field | Builder mirror | Why the state owns it |
|---|---|---|
Toaster::corner | ToastStack::corner | the queue slides toasts toward it between frames |
TabBarState::keys | TabBar::keys | handle_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.backgroundis already filled). UseBorder::Round/Panelcards with titles per widget family; group by rows withlayout::stack/columns. - Keyboard: keep a
Focus<Id>ring; route keys to the focused widget; returnOutcome::Ignoredfor keys you do not handle so the shell can use[,],q. Mouse: forward to every widget state (each one checks its own rect); onHit::Press/Changedfrom 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.nowfor.now(..),ctx.dur(ms)for tween durations (respects reduce-motion), andctx.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.