tuika
A small composable terminal UI toolkit. tuika provides the layout,
overlay, focus, and component primitives that ratatui leaves to you, while
letting ratatui keep ownership of the cell buffer and its diff against the
terminal.
It is a published, self-contained crate that depends only on ratatui-core
(plus ratatui-crossterm for the terminal backend), crossterm, textwrap,
unicode-segmentation, and unicode-width, and is host-agnostic — it knows
nothing about the application embedding it. tuika renders none of ratatui's own
widgets, so it builds against ratatui-core directly rather than the ratatui
umbrella — keeping ratatui-widgets, ratatui-macros, and their transitive
weight out of its dependency tree. Your application still uses any ratatui
widget it likes (see Compatibility). (The optional async
feature adds Tokio for AsyncRunner; it is
off by default.)
Install
ratatui and crossterm are part of tuika's public interoperability
surface, so add ratatui to your own crate and pin a compatible minor version.
tuika depends on ratatui-core, which the ratatui umbrella re-exports, so
Cargo unifies the shared Buffer/Rect/Style types and your widgets compose
with tuika's surfaces (see Compatibility).
Model
- Views (
view::View) are rebuilt from application state every frame. This is cheap becauseratatuidiffs the resulting cell buffer, so there is no reconciler. - State that must survive across frames — scroll offset, selection index,
focus — lives in host-persisted
*Statestructs (theStatefulWidgetidiom), not in the view tree. - Live data (
Live/LiveView) is shared application state read at render time. Updates request a redraw from the runner; Tuika does not spawn data sources or reconcile a retained widget tree. - Layout is a flexbox subset (
layout):Dimension(Auto/Fixed/Percent/Flex),Align,Justify,Direction, over a direction-agnostic axis so rows and columns share one solver. - Overlays (
overlay) anchor a view over the base tree; the host (host) owns the alternate screen, translates crossterm input, and composites the frame. - Keymap (
keymap) resolves declarative key bindings to named commands: chords (ctrl+r) and multi-stroke sequences (g g) grouped into prioritized, mode-gatedLayers, dispatched from a translatedKeyand queryable for help/KeyHintssurfaces. Host-agnostic, so it unit-tests without a terminal. See the keymap guide. - Motion (
anim,components::{Spinner, ProgressBar, Loader},term::progress::TerminalProgress) animates from a host-supplied frame counter and can drive the terminal's own OSC 9;4 progress indicator.anim::Timelineadds a scheduler-free keyframe track (values eased over frame offsets, with looping/ping-pong) sampled purely from that counter. - Pixels (
framebuffer) — a mutable RGBAFrameBufferthe host draws into (set/blend/fill_rect/blit, a per-pixelshadeshader post-pass, andSpritespritesheet frames).FrameBufferViewpaints it into cells with half-blocks on any terminal, or handto_image_data()to the crisp graphics protocols.
Crate layout
Four places, so you can guess where something is:
| Path | Holds |
|---|---|
tuika:: |
the framework spine — View, Element, RenderCtx, layout, events, Theme, Surface, the host seam |
tuika::components |
every widget: Flex, Boxed, Text, Scroll, Markdown, Table, … |
tuika::term |
everything out-of-band: clipboard (OSC 52), hyperlink (OSC 8), progress (OSC 9;4), pointer (OSC 22), image, capabilities, palette (the terminal's own colors) |
tuika::prelude |
the spine and the components in one glob import |
Application code usually wants the prelude:
use *;
Everything else stays behind its module path on purpose — themes::by_name,
probe::RectProbe, width::str_cols, term::clipboard::write — so a short
path always means "you will use this constantly".
Components
See the component gallery for an animated demo of each component. Linked names below jump straight to their demo.
| Component | Purpose |
|---|---|
Text / Paragraph |
Styled lines / word-wrapped plain text |
Wrap |
Word-wraps pre-styled lines, preserving per-span styles |
Markdown (+ MarkdownState) |
CommonMark → styled lines; MarkdownState streams incrementally — see the markdown guide |
CodeBlock |
Themed, framed code block with a pluggable Highlighter and optional line-number gutter |
Diff |
Line diff (LCS), unified or side-by-side, with +/- gutters and line numbers |
AsciiFont |
Large "figlet-style" block-letter banner text |
QrCode (+ QrEcc) |
QR code (byte-mode v1–4 encoder) rendered with half-blocks |
Rule |
Horizontal separator: optional title + fill glyph to width |
Flex |
Flexbox container (the composition primitive) |
Responsive / Constrained |
Breakpoint selection and min/max measurement |
Boxed |
Border + padding + title, focus-aware |
Scene / Dialog |
Owned root + anchored overlays; modal composition |
Spacer |
Flexible filler |
Scroll (+ ScrollState) |
Vertical scroll viewport + scrollbar over lines |
ItemScroll |
The same viewport over laid-out items (panels, tables, nested layouts) |
Viewport |
Two-dimensional clipping/panning over any child view |
Form / FormField (+ FormState) |
Responsive labeled controls and validation |
DrawView / CanvasView |
Closure-based custom cell drawing |
SelectList (+ SelectState) |
Selectable list |
Slider (+ SliderState) |
One-row value picker over a numeric range |
TextInput (+ TextInputState) |
Multi-line composer: soft-wrap, placeholder, highlighted ranges, @// tokens |
StatusBar |
One-row left/right status segments |
Tabs / KeyHints |
Host-state tab navigation and command hints |
TabSelect (+ TabSelectState) |
Value-selecting segmented control |
Toasts / ToastList |
Transient notification stack with frame-driven expiry |
Console (+ ConsoleLog) |
Captured stdout/log ring buffer + tailing overlay view |
Spinner |
Frame-cycled activity glyph |
ProgressBar |
Determinate (sub-cell) / indeterminate bar |
Loader |
Spinner + message + hint row |
Example
Layout reads top-down with the view! DSL:
use *;
let theme = default;
let root = view! ;
// In a `terminal.draw(|f| ...)` closure:
paint;
Owned scenes, dialogs, and forms
Scene owns a root Element and ordered SceneOverlays. Each layer retains
its OverlaySpec, so it resolves against the current terminal size inside
rendering; callers do not retain borrowed views or pre-resolved Rects.
Dialog composes Boxed, Flex, and optional KeyHints into a centered modal
with size clamps, clear/dim behavior, and an optional focus-owner id:
use *;
let scene = new.dialog;
scene.sync_focus;
paint_scene;
Form lays out arbitrary control Elements beside responsive labels, stacking
on narrow terminals. Help and validation rows are built in; FormState owns
only focus traversal and submit/cancel outcomes, while values and cursor state
stay in existing host-owned TextInputState, SelectState, or application
models.
Arbitrary-child viewports and drawing
Viewport clips and pans any child view in both axes. The host supplies the
full content Size and mirrors offsets through the same ScrollState used by
line-oriented Scroll. It renders only the visible source window, so a large
logical canvas does not allocate a full off-screen buffer.
DrawView (also named CanvasView) turns a closure receiving (Rect, &mut Surface, &RenderCtx) into a normal view. The surface is already clipped,
making it suitable for terminal grids, charts, emulators, and incremental
migrations. Import it explicitly from tuika::view; custom canvases stay
outside the application prelude.
Run cargo run --example primitives for one composition using Scene,
Dialog, Form, Viewport, and DrawView.
Builder syntax (alternative)
view! expands to plain builder calls, so the same tree can be written without
the macro:
use *;
let root = column
.gap
.fixed
.fixed
.grow;
Markdown and syntax highlighting
Markdown renders CommonMark to styled lines, word-wrapping prose while drawing
code and tables verbatim. MarkdownState is its streaming form: fed deltas as a
message arrives, it re-parses only the in-flight tail and caches everything
before the last stable block boundary, so long transcripts don't re-tokenize and
settled code blocks aren't re-highlighted every frame.
Highlighting is a seam, not a dependency: tuika owns the presentation of code
(framing, background, language label, wrapping) via CodeBlock, and takes token
colors from any Highlighter you supply — keeping the toolkit free of grammar
crates. The companion crate
tuika-codeformatters ships a
ready-made tree-sitter Highlighter.
Fenced blocks can replace their source with a different, width-aware
presentation through FencedBlockRenderer. The
tuika-mermaid companion uses that seam with mmdflux:
a mermaid fence becomes a Unicode cell diagram inside the surrounding
Markdown, with no browser, SVG, or image protocol. Unsupported or invalid input
falls back to the ordinary code block.
use *;
use MermaidRenderer;
let mermaid = new;
let document = new
.block_renderer;
# let _ = document;
Run the complete integration demo with
cargo run -p tuika-mermaid --example mermaid_markdown.
Images use the same host-extension pattern: supply an ImageResolver and
 renders as real pixels (see Images).
Theming
Every component styles itself from a Theme passed through the render context —
no color is hard-coded, so swapping the theme handed to paint restyles the
whole tree at once. A Theme is a plain Copy struct of colors, and tuika
bundles a few standard palettes as full const Theme structures in the
themes module —
reachable directly, by constructor, or by name:
use themes;
let a = GRUVBOX_DARK; // the struct
let b = gruvbox_dark; // named constructor
let c = by_name.unwrap; // config / --theme
See the theme gallery for a screenshot of each bundled
palette, or themes::PRESETS to enumerate them for a picker.
An app can also inherit the palette the user already configured in their
terminal, rather than bringing its own — either implicitly with themes::TERMINAL
(ANSI slots, no I/O) or by asking the terminal for its actual colors and deriving
a full theme from the reply. It is opt-in: tuika never probes unless a host asks
it to. The query lives with the other out-of-band escapes, in term::palette. See
inheriting the terminal's colors.
Where a Theme is the color tokens, a StyleSheet is the rules — a mapping
from a semantic role (heading, link, inline code, a panel's border and fill, …)
onto the style it draws with. Override a role in one place and every element with
that role restyles at once; markdown parts and bare URLs are role-driven too. See
the styling guide.
Runnable examples
Each enters the alternate screen; press q (or esc) to quit.
| Example | Command | Shows |
|---|---|---|
gallery |
cargo run --example gallery |
motion components + native OSC 9;4 progress |
markdown |
cargo run --example markdown |
streaming MarkdownState + highlighted CodeBlock, following the stream until you scroll back |
select |
cargo run --example select |
SelectState + SelectList (stateful-widget idiom) |
overlay |
cargo run --example overlay |
OverlaySpec centered dialog + input routing |
primitives |
cargo run --example primitives |
owned dialog scene + form + arbitrary-child viewport |
ratatui_dashboard |
cargo run --example ratatui_dashboard |
mixed Ratatui widgets + responsive live data |
async_dashboard |
cargo run --example async_dashboard --features async |
AsyncRunner polling on a Tokio runtime, no shared state |
mouse |
cargo run --example mouse |
drag-to-select + highlight + OSC 52 copy, clickable buttons |
image |
cargo run --example image |
Image over reserved cells (Kitty/iTerm2/Sixel), alt-text fallback |
inherit |
cargo run --example inherit |
adopting the terminal's own palette — probe, derive, and the no-I/O fallback |
codex |
cargo run --example codex |
a whole coding-agent TUI — demo (a UI replica, see below): streaming transcript, composer, @// pickers, approval prompt |
Each of the single-topic examples above quits on q/esc. codex
is the composite one — those keys are text there, so it quits with ⌃C — and it
prints canned frames without a terminal at all with
cargo run --example codex -- --dump.
It is a replica of the OpenAI Codex CLI's interface, not the Codex CLI: an example built on tuika, unaffiliated with and unendorsed by OpenAI, with no model, network, or shell behind it — a turn is a scripted sequence revealed frame by frame. See the showcase entry.
Declarative DSL (view!)
view! is optional sugar over the builders — it expands to the exact same
Flex/Boxed/element(...) calls, so there is no runtime cost and nothing new
in the model. It just makes nested layout read top-down:
let root = crateview! ;
Grammar (each keyword consumes exactly one node):
-
col(attrs) { … }/row(attrs) { … }— flex containers. Attrs (all optional):gap,padding,align,justify,background. -
boxed(attrs) { child }— bordered container. Attrs:title,border,padding,background. -
text(expr),spacer()— leaves. -
grow(n) { node }/fixed(n) { node }— set a child's main-axis size (default auto). -
node(expr)— splice anyimpl View. This is the escape hatch, and how a component from another crate participates in the DSL:use CustomView; crateview! ;
node(...) accepts any type that already implements Tuika's View; it does
not make a Ratatui Widget implement View. Use RatatuiView for Ratatui
widgets. The tuika-gallery demo is built entirely with view!.
Ratatui interoperability
Tuika deliberately does not duplicate Ratatui's widget catalog. Wrap existing
widgets in RatatuiView; they render into an isolated buffer and only the
assigned clip is composited into the frame:
use ;
use *;
let values = vec!;
let chart = sized;
The closure form supports widgets that borrow captured data. Stateful widgets
can capture host-owned synchronized state and call StatefulWidget::render
inside the same closure. Surface::render_ratatui is the lower-level escape
hatch for custom views that need several widgets. Neither API exposes the
frame's mutable buffer.
Responsive and live views
Responsive chooses complete compact/wide view trees from the current width;
this supports row-to-column reflow and intentionally omitted secondary
content. Constrained supplies min/max intrinsic measurements to flex layout.
Live<T> is shared application data with a narrow read/update API. LiveView
derives a fresh view from its current value each frame. Connect it to
Runner::redraw_handle() when background producers should invalidate the
screen. Producers retain ownership of their threads, tasks, retries, and
lifecycle.
Terminal lifecycle and runner
TerminalSession is the complete RAII guard: it owns raw mode, enhanced
keyboard reporting, alternate screen, mouse capture, and cursor visibility,
including rollback after partial initialization. Enhanced reporting preserves
non-character modifiers, so Shift+Enter reaches TextInputState as a
different chord from Enter; iTerm2 and tmux get their required protocol
variants, while Windows uses the modifier state already carried by its native
console events. It preserves raw mode and any keyboard-reporting stack entries
the caller had already enabled. AltScreen remains available for hosts that
intentionally own raw mode, keyboard modes, and cursor visibility themselves.
Runner is an optional synchronous event loop for dashboards and small tools.
It owns TerminalSession, frame scheduling, Crossterm event translation, and
data-driven redraw checks.
AsyncRunner (behind features = ["async"]) is the same loop for applications
that already have a Tokio runtime — anything doing network or disk I/O. It ties
TerminalSession, paint, and translate_event to crossterm's async
EventStream and a tick timer in one tokio::select!, so the host keeps a
single event loop instead of bolting spawn_blocking + a shared Live +
Notify + a stop flag onto the synchronous Runner to feed it. The loop threads
one owned state value through a view closure (&state → the frame) and an
async update closure (&mut state on each Signal — a tick or an event —
and it may .await):
use ControlFlow;
use Duration;
use *;
let runner = new;
let mut stats = default;
runner.run.await?;
Enabling async adds Tokio (timer + select!) and crossterm's event-stream
feature; it stays off by default so sync-only hosts pull in no runtime. The
async_dashboard example is the runnable
counterpart to ratatui_dashboard with no shared state at all.
Native terminal progress
term::progress::TerminalProgress emits the OSC 9;4 sequence, which drives the
terminal's own progress indicator — a bar across the top of the window in
Ghostty, the taskbar in Windows Terminal / ConEmu, and similar in
WezTerm / Konsole / mintty. It is out-of-band (no cursor movement, no cells),
so it works in both the inline and full-screen renderers; terminals that don't
understand it ignore the sequence. A host typically shows it (indeterminate)
while long work runs and clears it when idle.
Images
Image paints real pixels — an avatar, a chart, a rendered diagram — over the
cells it reserves, using whichever terminal graphics protocol
ImageSupport::detect() finds: Kitty (Kitty, Ghostty, WezTerm, Konsole),
iTerm2, or Sixel (foot, xterm +sixel, mlterm, contour). Terminals with
none show the alt text, so the same view tree renders everywhere.
Decoding stays in the host — a heavy dependency, kept out like the highlighter
seam — so you hand in raw RGBA via ImageData::from_rgba and tuika owns the
protocol encoding (base64, PNG, and Sixel encoders are inline, so no image-codec
dependency). A graphics escape paints at the cursor, unlike the cursor-neutral
OSC sequences, so emission is split from layout: Image reserves cells and
records its placement into an ImageLayer, then the host calls
ImageLayer::emit after terminal.draw() to paint the pixels over them.
use *;
use ;
let data = from_rgba.unwrap;
let layer = new;
let _image = new // 20×10 cells on screen
.support
.in_layer
.alt; // shown where graphics aren't supported
Markdown  renders too, in both the one-shot Markdown view and the
streaming MarkdownState: attach a host ImageResolver (URL → ImageData, the
same seam as the highlighter) and resolved images become real pixels — a
link-styled placeholder for the rest, never a dropped URL.
To check support across every terminal feature in one place — graphics,
hyperlinks, clipboard, progress, truecolor — use Capabilities:
Capabilities::from_env() is an instant advisory guess, and
Capabilities::query(timeout) adds a Device Attributes probe that confirms Sixel
(the one protocol the environment can't reliably reveal).
Mouse, selection, and clipboard
Enabling mouse capture (which AltScreen / TerminalSession do) means the
terminal stops doing its own click-drag text selection and hands every drag to
the app instead. The mouse module rebuilds those affordances over the grid
you already rendered:
- Text selection.
SelectionStateturns a left-buttonDown → Drag → Upgesture into aSelectionRange(a plain click selects nothing; a new press clears the old selection).selected_text(buffer, area, range)reads the text back out of the renderedratatui::Buffer— linear/stream selection like a terminal's own, wide glyphs intact — andmouse::paint_selection(buffer, area, range, style)paints it in. - Clicks and regions.
HitMap<T>maps screen rects to values (a button, a link, a row); the last-pushed match wins, so children/overlays registered after their parents take precedence.ClickTrackerturns a same-cellDown/Upinto aClickand lets an intervening drag cancel it. - Clipboard.
clipboard::write(out, text)copies via OSC 52 (clipboard::osc52is the pure encoder) — no platform clipboard library, works over SSH. Same tmux caveat as OSC 8: needsallow-passthrough on.
The enriched event model carries what selection and clicks need: MouseKind is
Down/Up/Drag(MouseButton), Moved, and ScrollUp/Down/Left/Right, and every
Mouse reports shift/ctrl/alt. Shift-drag is deliberately left to the
terminal — most emulators use it to bypass app mouse capture for a native
selection — so a host should act on plain() left-drags.
Touch arrives as mouse events: terminal emulators translate a tap to a
Down+Up and a swipe to scroll or a drag, so touch flows through this same
path — there is no separate touch event to handle.
See the terminal features guide for these terminal-integration capabilities — OSC 8 hyperlinks, mouse selection and clicks, OSC 52 clipboard, OSC 9;4 progress, and Kitty/iTerm2/Sixel images — plus
Capabilitiesdetection, with demos and runnable examples.
Testing your UI
Rendering is deterministic, so UI built on tuika can be tested without a real
terminal or TestBackend setup. The testing
module draws a View into an in-memory ratatui Buffer and reads it back:
render(view, width, height, &theme) -> Buffer— draw once at a fixed size.grid(&buffer) -> String— the buffer as a plain glyph grid, ready for a snapshot assertion.render_sizes(view, sizes, &theme) -> Vec<Buffer>— the same view across a set of sizes, for resize and degenerate-size sweeps.
use ;
use Theme;
let buffer = render;
assert!;
Used in
- yolop — a terminal coding agent whose experimental full-screen renderer is built on tuika.
- LLMSim — an LLM traffic simulator whose live stats dashboard is a tuika screen.
See the showcases for a recording of each. Building something on tuika? Open a PR adding it here.
Compatibility
- Minimum supported Rust version: 1.88, declared as
rust-versionand checked in CI. - Tuika 0.x follows Cargo semver: minor releases may make deliberate breaking API changes; patch releases do not.
- Ratatui and Crossterm are part of Tuika's public interoperability surface.
Tuika builds against
ratatui-core(andratatui-crossterm) directly, not theratatuiumbrella; the umbrella re-exports that sameratatui-core, so a matchingratatuiminor line in your application resolves to one sharedratatui-coreand Cargo deduplicates the core types. Widgets such asBlock/Paragraph/Tablelive inratatui-widgets(pulled in by yourratatuidependency, not by tuika) and compose throughSurface::render_ratatuiandRatatuiView, whose seam is a rawratatui-coreBuffer.
Extending
tuika is extended from your own crate — no fork, no registration step, no trait the built-ins get that yours don't:
- Custom components. Implement
Viewon your own type and splice it anywhere withnode(your_view), or hand it to any container — they accept anyimpl View. The built-in components are on equal footing with yours; nothing special-cases them. - Existing Ratatui widgets. Wrap one in
RatatuiViewrather than reimplementing it — see Ratatui interoperability.
The view! DSL reaches your components through the same
node(...) escape hatch, so they compose exactly like the built-ins.
Contributing
Issues and pull requests are welcome at
everruns/tuika. See
CONTRIBUTING.md for the local checks (cargo fmt --check,
cargo clippy --all-targets --all-features -- -D warnings,
cargo test --all-features) and the commit and review conventions.
The separately published companion crates live in this repository:
tuika-codeformatterssupplies the tree-sitterHighlighter.tuika-mermaidrenders Mermaid fences as Unicode terminal diagrams through mmdflux.
Both keep their heavier parsers and grammars out of tuika core.
License
MIT — see LICENSE.