Quick Start
Introduction
tui-lipan is an opinionated, component-based TUI framework for Rust, inspired by React and Elm.
Key characteristics:
- Declarative UI - builder API +
ui!macro (with full autocomplete), plus optionalrsx!. - Component model - properties, local state, and message-based updates.
- Flexbox-like layout - sensible defaults, no raw coordinate math.
- Rich widget set - Frames, Tabs, Lists, Inputs, Tables, Modals, and more.
Import Map
// Recommended: start here for typical app-author code
use tui_lipan::prelude::*;The prelude is intentionally curated for app authors. It re-exports the common component/runtime types, styling primitives, macros, and a broad set of user-facing widgets and widget event types. For framework internals or unusual helpers, prefer explicit imports from tui_lipan.
Representative prelude::* re-exports:
| Symbol | Category |
|---|---|
App, AppRunner, ContrastPolicy, TextAreaNewlineBinding | App runner |
Component, Context, Update, Command, Breakpoint, KeyUpdate, TaskPolicy | Component trait |
Element, IntoElement, Key | Tree primitives |
Callback, CommandLink, KeyHandler, Link | Messaging |
KeyCode, KeyEvent, KeyMods, MouseEvent, MouseMoveEvent | Events |
KeyBinding, KeyBindings | Common keybinding types |
Style, Color, Length, Padding, Align, Justify, BorderStyle, BorderEdges, CaretPalette, CaretShape | Styling |
RichText, Span, Edge, Rect, Size, ScrollbarConfig, ScrollbarVariant | Layout & text types |
Theme, ColorGradient, GradientDirection, GradientRange, VisualEffect, RippleRadius, RetroPreset | Themes & effects |
ClipboardConfig, PasteShiftInsertBehavior | Clipboard config |
TextEditor, TextInput, TextEditEvent, TextEditKind | Text editing |
word_forward_start, word_end, line_start_at, first_nonblank_in_line, … (text_motion module) | Vim-style word/line text motion helpers |
OverlayId, OverlayScope, ToastHandle, ToastPlacement | Overlays |
App, CommandEntry, CommandRegistry | App commands |
FrameworkAction, FrameworkKeymap, KeyDispatchPolicy, TerminalKeyPolicy, UserKeymapPolicy, CommandConflictPolicy, ChordMismatchPolicy | Layered key dispatch |
child, mockup!, rsx!, ui! | Macros & helpers |
VStack, HStack, ZStack, Canvas, Frame, Button, Text, Input, List, Tabs, Table, Modal, TextArea, Tree, DocumentView, FileTree, Animated, AsciiCanvas | Common and advanced widgets |
The prelude no longer re-exports broad internal modules like core, utils, or widgets::* wholesale.
Extra imports not in prelude::*:
// Clipboard image support (requires feature "image" or "clipboard-images")
use tui_lipan::{ImageContent, ImageFormat, ClipboardProvider, ClipboardError};
// Lower-level framework or specialized APIs
use tui_lipan::NodeId;Feature Flags
[dependencies]
tui-lipan = { version = "*", features = ["image", "big-text"] }| Feature | Default | What it enables |
|---|---|---|
clipboard | Yes | System clipboard via arboard (X11/Wayland/macOS/Windows) |
devtools | No | In-app DevTools overlay (F12 by default, rebindable) with frame stats and debug log console; controllable from Context and configurable via DevToolsConfig |
ui-snapshot-json | No | JSON export for UiSnapshot::to_json() (markdown export is always available) |
ui-snapshot-png | No | Font-backed PNG export for UiSnapshot::to_png() / to_png_default() and CapturedFrame::to_png() |
clipboard-images | No | Image clipboard read/write (without Image rendering widget) |
big-text | No | Large ASCII/pixel text via FIGlet and pixel fonts - BigText |
diff-view | No | Side-by-side/unified diff viewer - DiffView |
image | No | Protocol-aware image rendering (Kitty, iTerm2, Sixel, halfblocks) with PNG/JPEG/GIF/WebP codecs - includes clipboard-images |
image-full-formats | No | Restores the broad image crate default codec set for image, clipboard-images, or ui-snapshot-png builds |
hints-regex | No | regex-lite scanner for string-configured custom text hints; the dependency-free HintScanner trait and URL/path/Git scanners are always available |
markdown | No | Markdown formatter for DocumentView + markdown preview example |
qr-code | No | Scannable QR symbols rendered as terminal cells - QrCode |
profiling-tracing | No | tracing spans/events around render loop and DocumentView formatting/reconcile hot paths |
syntax-syntect | No | Lightweight syntax highlighting in TextArea, DocumentView, and DiffView via syntect; WASM uses pure-Rust fancy-regex |
syntax-extra | No | Opt-in bat-curated syntax set with broad grammar coverage; adds about 0.6 MiB and includes syntax-syntect |
terminal | No | Embedded PTY / terminal viewport - Terminal, ManagedTerminal |
terminal-images | No | Kitty graphics in terminal panes: the program running in a pane can draw images, whatever the host terminal supports; includes terminal and image |
terminal-serde | No | Serde derives for terminal snapshot leaf style/mouse types used by external, versioned snapshot transports; includes terminal |
theme-reload | No | Live reload of TOML theme files without restarting the app - see Styling |
web | No | Browser/WASM backend - see Web / WASM Backend |
HintScanner is the primary custom-hint interface and does not require a feature. Enable hints-regex only when config needs patterns as strings. An app that already depends on a different regex-lite version may compile a second copy until its config layer also uses the framework scanner.
Profiling with tracing
Enable instrumentation:
tui-lipan = { version = "*", features = ["markdown", "profiling-tracing"] }Then install any standard tracing subscriber in your app binary (for example tracing-subscriber, tracing-tracy, or OpenTelemetry exporters). tui-lipan emits spans/events for frame loop, draw, and DocumentView formatting/reconcile hot paths. See Performance for update-scope guidance, DevTools diagnostics, and repeatable benchmarks.
To disable clipboard (e.g. for minimal no-system-dep builds):
tui-lipan = { version = "*", default-features = false }For smaller shipping binaries, build app artifacts with the size-optimized profile:
cargo build --profile release-size --no-default-featuresUse the normal release profile when runtime throughput is more important than artifact size.
Examples requiring specific features:
| Example | Required feature |
|---|---|
big_text, figlet_editor | big-text |
diff_hub | diff-view |
qr_code | qr-code |
image, image_modes, messenger | image |
markdown_hub | markdown |
markdown_editor_sync | markdown, syntax-syntect |
yazi | syntax-extra |
terminal_filetree_devtools | terminal |
terminal_images | terminal-images |
devtools | devtools |
theme_hot_reload | theme-reload |
With devtools enabled, the built-in panel docks to the bottom of the viewport with its Stats / Logs / App tab strip pinned to the row above its bottom border, so tabs stay put while the body changes height. Stats and App both size to their content within the viewport, never below a shared 48-column floor; Logs always fills the width. The App tab scrolls vertically when its rows are capped. Use Context (show_devtools, hide_devtools, toggle_devtools) for visibility - the panel claims no keys of its own beyond ctrl+c on a selected Logs row.
DevTools runtime configuration
When the devtools feature is enabled, you can opt out of individual subsystems at app start time:
use tui_lipan::prelude::*;
App::new()
.devtools_config(DevToolsConfig {
logs: true, // ingest debug_log! lines into the DevTools log panel
metrics: true, // collect per-frame stats (FPS, reconcile/draw times, node count)
show_framework_logs: false, // hide tui-lipan's own internal log lines by default
})
.mount(MyApp)
.run()logs: falseremoves thedebug_log!→ devtools sink path entirely (the macro still respectsTUI_LIPAN_DEBUG=1env logging).metrics: falseskips frame timing and tree-size collection; the panel will show "No frame metrics yet".show_framework_logs: falsestarts the Logs tab with tui-lipan's own framework noise (key events, dirty tracking, etc.) hidden, leaving only your app'sdebug_log!lines. Toggle it live with the tui-lipan button in the Logs tab.logs,metrics, andshow_framework_logsall default totrue, sofeatures = ["devtools"]behaves exactly as before.
In the Logs tab you can also copy the selected row to the clipboard with Ctrl+C, or by activating a row (double-click / Enter).
Subsystem cost — what each toggle controls:
| Subsystem | When true (default) | When to turn off |
|---|---|---|
logs | Every debug_log! allocates a String and pushes a DevLogEntry onto a bounded ring buffer (small). | Hot loops calling debug_log! thousands of times per frame, or to drop the formatting cost when the panel is never opened. |
metrics | Per-frame timing samples + node-tree size snapshot collected while the DevTools panel is visible; small fixed-size ring buffer. | Profiling renders against a release build where you don't want sampling overhead. |
Note: debug_log! works in --release builds when the devtools feature is enabled — there is no debug_assertions gate. Lines are emitted to the panel regardless of profile, so guard hot paths yourself if you need zero overhead in release.
Minimal Example
use tui_lipan::prelude::*;
struct Counter;
#[derive(Default)]
struct State {
count: i32,
}
#[derive(Clone)]
enum Msg {
Increment,
Decrement,
}
impl Component for Counter {
type Message = Msg;
type Properties = ();
type State = State;
fn create_state(&self, _props: &Self::Properties) -> Self::State {
State::default()
}
fn view(&self, ctx: &Context<Self>) -> Element {
rsx! {
VStack {
gap: 1,
padding: 2,
Text { content: format!("Count: {}", ctx.state.count) }
HStack {
gap: 1,
Button { label: "-", on_click: ctx.link().callback(|_| Msg::Decrement) }
Button { label: "+", on_click: ctx.link().callback(|_| Msg::Increment) }
}
}
}
}
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
match msg {
Msg::Increment => ctx.state.count += 1,
Msg::Decrement => ctx.state.count -= 1,
}
Update::full() // (needs_redraw, optional_command)
}
}
fn main() -> tui_lipan::Result<()> {
App::new()
.title("Counter")
.mount(Counter)
.run()
}Fast Prototyping with mockup!
Skip all Component boilerplate for layout previews:
use tui_lipan::prelude::*;
fn main() -> tui_lipan::Result<()> {
mockup!("Dashboard Preview", {
HStack::new()
.gap(1)
.child(
Frame::new()
.header_left("Sidebar")
.border(true)
.width(Length::Px(30))
.child(List::new().items([
ListItem::new("Dashboard"),
ListItem::new("Settings"),
ListItem::new("Logs"),
]).selected(0)),
)
.child(
Frame::new()
.header_left("Content")
.border(true)
.padding(1)
.child(Text::new("Hello from mockup!")),
)
})
}Key behaviors:
- Press
Escorqto quit. - The body expression is auto-wrapped in
.into()- return any widget builder directly. - Interactive widgets (List, Tabs, Inputs) still respond to focus and mouse.
- The closure uses
movecapture, so local data is accessible.
Using Mockup adapter directly:
App::new()
.title("My Layout")
.mount(Mockup::new(|| {
Frame::new().header_left("Panel").border(true)
.child(Text::new("World")).into() // closure must return Element
}))
.run()Mockup → App Workflow
Extract views as plain functions reusable in both mockups and real components:
fn sidebar(items: &[&str], selected: usize) -> Element {
Frame::new().header_left("Nav").border(true)
.width(Length::Px(28))
.child(List::new().items(items.iter().map(|s| ListItem::new(*s))).selected(selected))
.into()
}
// Step 1: preview with mockup
fn main() -> tui_lipan::Result<()> {
let items = vec!["Home", "Settings", "Logs"];
mockup!("Preview", { sidebar(&items, 0) })
}
// Step 2: reuse in real component - zero rewrite
fn view(&self, ctx: &Context<Self>) -> Element {
sidebar(&ctx.state.nav_items, ctx.state.selected)
}App Configuration
App::new()
.title("My App") // Optional outer chrome frame
.theme(Theme::one_dark()) // Optional theme override
.system_theme() // Optional: derive theme from host terminal colors
.inline_ephemeral(8) // Optional: inline mode (8 terminal rows)
.mouse(true) // Mouse capture (default: true in fullscreen, false in inline)
.scroll_wheel_multiplier(3) // Optional: lines per wheel tick (default: 1)
.toast_placement(ToastPlacement::BottomEnd)
.keymap_path("/path/to/keymap.conf") // see docs/keybindings.md
.global_quit(None) // disable Ctrl-Q quit without a keymap file
.framework_keymap(
FrameworkKeymap::default().unbind(FrameworkAction::Quit),
)
.user_keymap_policy(UserKeymapPolicy::Disabled) // ignore env/default user keymaps
.key_dispatch_policy(KeyDispatchPolicy::AppCommandsFirst)
.terminal_key_policy(TerminalKeyPolicy::AppCommandsThenTerminal)
.command_conflict_policy(CommandConflictPolicy::HighestPriority)
.chord_mismatch_policy(ChordMismatchPolicy::ForwardPrefixAndCurrent)
.clipboard_config(ClipboardConfig { .. })
.contrast_policy(ContrastPolicy::Wcag)
.terminal_bg(query_host_colors().map(|c| c.bg)) // enables Opacity through Color::Reset
.live_host_terminal_colors(true) // opt-in runner-managed live host palette refresh
.mount(Root)
.exit_view(|_component, ctx| {
Text::new(format!("Final count: {}", ctx.state.count)).into()
})
.run()ScrollView::scroll_wheel_multiplier(...), TextArea::scroll_wheel_multiplier(...), and DocumentView::scroll_wheel_multiplier(...) override the app-wide wheel multiplier for a specific widget.
terminal_bg/ live host colors:ColorTransform::Opacityblends foreground colors toward the resolved cell background. When the cell background isColor::Reset(terminal default) there is no RGB to blend toward, so opacity has no effect. Calling.terminal_bg(query_host_colors().map(|c| c.bg))beforerun()provides the terminal's actual default background color and enables correct opacity blending for static apps. Use.system_theme()to opt into a framework-wide theme derived from live host colors, or.live_host_terminal_colors(true)when app code wants to readctx.host_terminal_colors()and build its own tokens. The runner probes once at startup, refreshes on terminal focus gained, servicesctx.request_host_terminal_color_refresh(), and never polls continuously. Opted-in Unix fullscreen apps also enable DEC private mode 2031: compatible terminals send exact palette-change notifications that trigger an immediate OSC 10/11 refresh and, when resolved foreground or background colors changed, a complete repaint. Those refreshes retain the startup probe's resolved ANSI slots because Termina does not yet expose OSC 4 responses. Inline, non-Unix, and unsupported terminals retain startup, focus-gained, and manual OSC 4/10/11 refresh behavior. Refreshed host backgrounds updateterminal_bgautomatically. Omitting both leaves opacity unchanged on reset-background cells.
exit_view: Attach this onAppRunner<C>after.mount(...)when you want a final one-shot element rendered to stdout after the TUI exits. The callback runs before unmount, so component state is still available inctx.state. This is useful for persisting a session summary or logo in terminal scrollback.
Layered key dispatch policy builders are always available (no extra feature flag). See keybindings.md for precedence rules, command shortcut(...) vs keybinding_hint(...), and focus.md / widgets/terminal.md for dispatch order tables.
Development Workflow
- Define State -
struct State { ... }with#[derive(Default)] - Define Messages -
enum Msg { ... }with#[derive(Clone)] - Implement Component -
create_state,update,view - Run -
App::new().mount(Root).run()
Debugging
Debug logging
Enable debug output with environment variables:
TUI_LIPAN_DEBUG=1 cargo run # Print to stderr
TUI_LIPAN_DEBUG_FILE=/tmp/tui.log cargo run # Also append to fileHeadless snapshots
Capture what an app looks like without a terminal, and without editing its source. TUI_LIPAN_SNAPSHOT makes run() render one frame off-screen, write it, and exit:
TUI_LIPAN_SNAPSHOT=/tmp/app.png cargo run --features ui-snapshot-png
TUI_LIPAN_SNAPSHOT=/tmp/app.md TUI_LIPAN_SNAPSHOT_VIEWPORT=140x40 cargo runCompanion variables: TUI_LIPAN_SNAPSHOT_VIEWPORT (WIDTHxHEIGHT, default 100x30), TUI_LIPAN_SNAPSHOT_VIEWPORTS (comma-separated list, writes suffixed files), TUI_LIPAN_SNAPSHOT_FRAMES, TUI_LIPAN_SNAPSHOT_FOCUS, TUI_LIPAN_SNAPSHOT_KEYS, TUI_LIPAN_SNAPSHOT_ADVANCE_MS (virtual-clock advance for time-gated UI), and TUI_LIPAN_SNAPSHOT_DIAGNOSTIC=1.
TUI_LIPAN_SNAPSHOT_KEYS scripts input so states behind a keystroke can be captured without writing code:
TUI_LIPAN_SNAPSHOT=/tmp/modal.png TUI_LIPAN_SNAPSHOT_KEYS="tab,enter" cargo runSee docs/testing.md for the full table, the Sketch API, and visual regression baselines.
Terminal recordings
TUI_LIPAN_RECORD plays a key script and writes an asciinema cast - a text recording that scrubs and plays in a browser, typically smaller than one PNG frame of the same app. It needs no feature flag:
TUI_LIPAN_RECORD=/tmp/demo.cast TUI_LIPAN_RECORD_KEYS="tab,enter" cargo runCompanion variables: TUI_LIPAN_RECORD_VIEWPORT, _FPS, _KEY_DELAY_MS, _SETTLE_MS, and _FRAMES. The Recording builder is the in-code equivalent.
TUI_LIPAN_RECORD_FRAMES=<dir> additionally writes one truecolor PNG per frame (needs ui-snapshot-png) and prints a ready-to-run ffmpeg command for encoding them to MP4. See docs/components.md for a size and quality comparison of .cast, GIF, and MP4.
Use the debug_log! macro in your own code to emit messages through the same channel:
use tui_lipan::debug_log;
debug_log!("Current state: {:?}", ctx.state);Layout snapshot diagnostics
When content vanishes in tests or mockups, first check the viewport and sizing:
TestBackendstarts at an 80x24 viewport unless you callset_viewport(...); use a fixed viewport for reproducible snapshots.Mockuprenders at the live terminal size, so a layout can change when the terminal is narrow or short.VStack,HStack, andFramedefault toLength::Flex(1)on both axes; fixed headers, footers, and side bars usually needLength::Px(...).- Capture with
UiSnapshotOptions::diagnostic()to include zero-area nodes, spacers, and dividers; markdown snapshots flag zero-size widgets aszero-area.
Mouse event diagnostics
The tui_lipan::debug module exposes counters for diagnosing mouse event throughput:
use tui_lipan::debug;
let count = debug::mouse_events_processed(); // Total mouse events since start
debug::reset_mouse_events(); // Reset counter to zero