tuika 0.2.0

A composable terminal UI toolkit — flexbox layout, overlays, focus, and safe ratatui interoperability.
Documentation

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, crossterm, textwrap, and unicode-width. It knows nothing about yolop; yolop is one production consumer.

Model

  • Views (view::View) are rebuilt from application state every frame. This is cheap because ratatui diffs the resulting cell buffer, so there is no reconciler.
  • State that must survive across frames — scroll offset, selection index, focus — lives in host-persisted *State structs (the StatefulWidget idiom), 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.
  • Motion (anim, components::{Spinner, ProgressBar, Loader}, native::TerminalProgress) animates from a host-supplied frame counter and can drive the terminal's own OSC 9;4 progress indicator.

Components

Component Purpose
Text / Paragraph Styled lines / word-wrapped plain text
Wrap Word-wraps pre-styled lines, preserving per-span styles
Flex Flexbox container (the composition primitive)
Responsive / Constrained Breakpoint selection and min/max measurement
Boxed Border + padding + title, focus-aware
Spacer Flexible filler
Scroll (+ ScrollState) Vertical scroll viewport + scrollbar
SelectList (+ SelectState) Selectable list
StatusBar One-row left/right status segments
Tabs / KeyHints Host-state tab navigation and command hints
Spinner Frame-cycled activity glyph
ProgressBar Determinate (sub-cell) / indeterminate bar
Loader Spinner + message + hint row

Example

use tuika::{Flex, ProgressBar, Spinner, Text, Theme, element, paint};

let theme = Theme::default();
let root = Flex::column()
    .gap(1)
    .fixed(1, element(Spinner::new(frame)))
    .fixed(1, element(ProgressBar::determinate(0.6).percent(true)))
    .grow(1, element(Text::raw("body")));

// In a `terminal.draw(|f| ...)` closure:
paint(f.buffer_mut(), f.area(), &theme, root.as_ref(), &[]);

Runnable examples

Each enters the alternate screen; press q (or esc) to quit.

Example Command Shows
gallery cargo run -p tuika --example gallery motion components + native OSC 9;4 progress
select cargo run -p tuika --example select SelectState + SelectList (stateful-widget idiom)
overlay cargo run -p tuika --example overlay OverlaySpec centered dialog + input routing
ratatui_dashboard cargo run -p tuika --example ratatui_dashboard mixed Ratatui widgets + responsive live data

(Embedded in yolop, the gallery is also reachable as yolop tuika-gallery.)

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 = crate::view! {
    col(gap = 1, padding = tuika::Padding::all(1)) {
        boxed(title = " body ") { text("hello") }
        grow(1) { spacer() }
        node(status_bar)          // any expression that is `impl View`
    }
};

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 any impl View. This is the escape hatch, and how a component from another crate participates in the DSL:

    use other_crate::CustomView;
    crate::view! { col { node(CustomView::new(&data)) } };
    

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 ratatui::widgets::{Sparkline, Widget};
use tuika::{RatatuiView, Size};

let values = vec![1, 4, 2, 8];
let chart = RatatuiView::sized(Size::new(20, 4), move |area, buffer| {
    Sparkline::default().data(&values).render(area, buffer);
});

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, alternate screen, mouse capture, and cursor visibility, including rollback after partial initialization. It preserves raw mode when the caller had already enabled it. AltScreen remains available for hosts that intentionally own raw mode 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. Async applications can keep their existing loop and call paint directly.

Native terminal progress

native::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. yolop shows it (indeterminate) while a turn runs and clears it when idle.

Testing

Layout and rendering are tested hermetically by rendering into an in-memory ratatui Buffer and reading cells back — no real terminal:

  • Unit tests (src/tuika/tests.rs) — layout math, component rendering, interactive state (scroll/select/focus), compositor, easing, OSC encoder, and palette (every themed cell pinned to its Theme slot).
  • Property tests (src/tuika/proptests.rs, proptest) — solver and overlay invariants for any input (children stay in bounds, flex fills exactly).
  • Golden snapshots (src/tuika/snapshots.rs) — whole screens diffed against checked-in glyph grids; refresh with UPDATE_SNAPSHOTS=1.
  • Resize / degenerate sizes — a size sweep from 0×0 up asserts no panic and no out-of-clip writes.
  • PTY smoke (tests/tuika_pty.rs) — drives the real binary under a pseudo-terminal and asserts the terminal-facing protocol: alternate-screen enter/leave, OSC 9;4 progress, resize survival, clean exit.

Downstream crates can use tuika::testing::{render, render_sizes, grid} for buffer assertions, resize sweeps, and stable glyph snapshots without a real terminal or TestBackend setup.

Compatibility

  • Minimum supported Rust version: 1.88, declared as rust-version and 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 tracks compatible minor lines deliberately; applications should use matching versions so Cargo can deduplicate them.

Manual terminal matrix

The automated tests cover the protocol a terminal receives; they cannot verify how a specific emulator actually paints it. This is a checklist to run before a release, not a record of verified results — tick a box only after confirming it yourself. Run cargo run -- tuika-gallery in each terminal and check alt-screen enter/exit, Braille/wide glyphs, truecolor, mouse-wheel scroll, and — with YOLOP_HYPERLINKS=1 — that the footer URL is a clickable OSC 8 link:

  • Ghostty
  • iTerm2
  • WezTerm
  • Kitty
  • Windows Terminal
  • Konsole
  • tmux (truecolor needs Tc/RGB in terminal-overrides)

Native OSC 9;4 progress support is a fixed property of each terminal (not something to re-verify per release). Terminals that render it: Ghostty (bar at top of window), Windows Terminal and ConEmu (taskbar), WezTerm, Konsole, mintty. Others (e.g. iTerm2, Kitty) silently ignore the unknown OSC, so emitting it is safe everywhere — the in-terminal UI is unaffected. See the OSC 9;4 references linked from the motion-module PR.

OSC 8 hyperlinks (HyperlinkBackend) wrap http(s) URL runs so a supporting terminal makes them clickable: Ghostty, iTerm2, WezTerm, Kitty, Konsole, recent GNOME Terminal / VTE. Others ignore the escape and render the URL as plain (usually still auto-linkified) text, so emitting it is safe everywhere. Unlike OSC 9;4, this one is worth re-checking, because it writes styled spans straight to the terminal: confirm the link is clickable and that surrounding text, colors, and wrapping are undamaged. In yolop it is opt-in (YOLOP_HYPERLINKS=1), default-off until this matrix is walked — that is what the checkbox above verifies.

Extending

Add a component by implementing view::View in a new module under components/. There is no registration step — containers accept any boxed View.