Skip to content

Components

Component Trait

Every component implements the Component trait with three associated types:

rust
impl Component for MyApp {
    type Message = Msg;       // Events this component handles
    type Properties = Props;  // Input from parent (often `()`)
    type State = State;       // Local mutable state
}

Lifecycle Methods

MethodRequiredSignaturePurpose
create_stateYes(&self, &Props) -> StateInitialize state from properties
memo_keyNo(&self, &Props, &Context<Self>) -> Option<u64>Opt into retained subtree reuse
viewYes(&self, &Context<Self>) -> ElementReturn UI tree
updateYes(&mut self, Msg, &mut Context<Self>) -> UpdateHandle messages
initNo(&mut self, &mut Context<Self>) -> Option<Command>One-time setup on mount
on_keyNo(&mut self, KeyEvent, &mut Context<Self>) -> KeyUpdateHandle unhandled key events
on_props_changedNo(&mut self, &Props, &mut Context<Self>) -> UpdateReact to property changes
unmountNo(&mut self, &mut Context<Self>)Teardown before removal

State Flow

User Action → Event → Message → update() → State Change → Re-render
                  ↑___________________________|
  1. User interacts (click, keypress)
  2. Callback fires (ctx.link().callback(...))
  3. Message queued
  4. update() called - mutate state
  5. Return (needs_redraw: bool, command: Option<Command>)
  6. view() re-executed if dirty or memoization cannot retain the subtree
  7. Tree reconciled and rendered

The Update Return Type

Update is a named struct with a dirty flag, a refresh level, and an optional Command. Pick the smallest refresh that matches the state change:

ReturnUse when
Update::none()State changed only to mirror widget-owned runtime state, or nothing visual changed
Update::paint()Existing tree can be repainted without layout changes
Update::layout()Existing component subtree needs reconcile/layout, but view() does not need to rebuild expensive app data
Update::full()Your component's view() output changed and must be rebuilt
Update::with_command(cmd)State changed and background work should start

High-frequency widget callbacks such as ScrollView::on_viewport_change, on_scroll, drag updates, and cursor/selection sync should usually return Update::none() when they only store the reported offset or selection in parent state. Returning Update::full() from those paths can rebuild large trees on every wheel tick or drag frame.

rust
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
    match msg {
        Msg::Increment => {
            ctx.state.count += 1;
            Update::full()   // redraw, no background work
        }
        Msg::LoadData => {
            let id = ctx.props.user_id;
            Update::with_command(ctx.link().command(move |link| {
                // Runs on background thread
                let data = fetch_data(id);
                link.send(Msg::DataLoaded(data));
            }))
        }
        Msg::DataLoaded(data) => {
            ctx.state.data = data;
            Update::full()
        }
        Msg::NoOp => Update::none(),  // no redraw
    }
}

Context Methods

MethodPurpose
ctx.stateMutable access to component state
ctx.propsRead-only access to current properties
ctx.link()Build callbacks and commands
ctx.request_focus(key)Move focus to a keyed widget
ctx.show_devtools()Show the built-in DevTools panel on the next tick
ctx.hide_devtools()Hide the built-in DevTools panel on the next tick
ctx.toggle_devtools()Toggle the built-in DevTools panel on the next tick
ctx.has_focus_within_key(key)Check if focus is within a subtree
ctx.text_area_scrollbars(key)Read resolved vertical/horizontal scrollbar visibility for a keyed TextArea from the previous frame
ctx.has_focus_within_scope(id)Check focus within a scope
ctx.toast()Show toast notifications
ctx.clipboard()Programmatic clipboard access (copy/read)
ctx.quit()Exit the application
ctx.is_inline()Whether running in inline mode
ctx.effect_phase()Current renderer animation phase; capture it when starting one-shot phase-based effects
ctx.mouse_capture_enabled()Current mouse capture state
ctx.set_mouse_capture(bool)Change mouse capture at runtime
ctx.toggle_mouse_capture()Toggle mouse capture, returns new state
ctx.theme()Clone the active theme for this subtree
ctx.theme_extension::<T>()Clone a typed app-specific theme extension
ctx.host_terminal_colors()Read the runner-managed HostTerminalColors cache when live host colors are enabled
ctx.host_terminal_color_generation()Read the cache generation; increments when refreshed colors differ
ctx.request_host_terminal_color_refresh()Queue a safe runner-owned OSC 4/10/11 refresh on the UI thread
ctx.use_context::<T>()Read nearest ContextProvider<T> value for this subtree
ctx.append_transcript_lines(lines)Append styled lines to transcript history (inline only)
ctx.append_transcript_element(el)Append a rendered element to transcript history (inline only)
ctx.request_full_repaint()Next frame does a full reconcile + paint (use after the host terminal was used by another process; see External programs)
ctx.request_ui_snapshot_to(path)Queue a UI snapshot file write after the next paint (see Agent snapshots)
ctx.request_ui_snapshot_to_slot(slot)Queue in-memory UI snapshot delivery into UiSnapshotSlot after the next paint

ctx.effect_phase() is a snapshot, not a render subscription. Use it to store a start tick in component state during update() / init(), then build phase-based effects like VisualEffect::centered_burst_ripple(...) from that stored value.

Live host terminal colors are opt-in with App::live_host_terminal_colors(true). The runner probes once at startup, refreshes on terminal focus gained, and services ctx.request_host_terminal_color_refresh() while coordinating with its input reader. It never polls continuously. If the palette changed, the runner updates terminal_bg and increments ctx.host_terminal_color_generation(). Use ctx.host_terminal_colors() to rebuild app-specific system theme tokens; keep those tokens app-owned.

When the devtools feature is enabled, the built-in panel can be controlled from app code as well as the global keymap. This is useful for wiring DevTools to a button, command palette entry, startup action, or app-specific command:

rust
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
    match msg {
        Msg::OpenDevtools => ctx.show_devtools(),
        Msg::CloseDevtools => ctx.hide_devtools(),
        Msg::ToggleDevtools => ctx.toggle_devtools(),
    }
    Update::none()
}

Built-in DevTools panel layout is fixed; Context methods control visibility only.

To opt out of individual subsystems (logs, metrics) at app start time, see DevTools runtime configuration in the Quick Start.

Component Mounting

rust
fn main() -> tui_lipan::Result<()> {
    App::new()
        .mount(MyApp)        // Takes an instance, not a type
        .run()
}

// Dependency injection: pass data into the constructor
let app = MyApp::new(db_connection, config);
App::new().mount(app).run();

Properties vs State

PropertiesState
SourceParent / mountLocal to component
MutabilityImmutable (read via ctx.props)Mutable via ctx.state
LifetimePassed each renderPersisted across renders
Common useConfiguration, DIUser input, loaded data
rust
#[derive(Clone, PartialEq)]
struct Props { user_id: u64 }

#[derive(Default)]
struct State {
    user_name: String,
    is_loading: bool,
}

Note: Properties must implement Clone + PartialEq for reconciliation.

Commands (Async / Background Work)

Components are single-threaded. Use Command for background work:

rust
// Generic command: any closure
let cmd = ctx.link().command(move |link| {
    let result = blocking_call();
    link.send(Msg::Done(result));
});

// Keyed command: prevent stale work from piling up
let cmd = ctx.link().command_keyed(
    "search",                  // key (any &'static str)
    TaskPolicy::LatestOnly,    // coalescing policy
    move |link| {
        if link.is_cancelled() {
            return;
        }
        let results = do_search(&query);
        let _sent = link.send_if_not_cancelled(Msg::SearchDone(results));
    },
);

TaskPolicy Options

PolicyBehavior
QueueAllEnqueue every task. Native workers may run same-key tasks concurrently.
DropIfRunningIgnore new task while one with the same key is running; the active task is not cancelled.
LatestOnlyKeep only the newest pending task, cancel the active token, and cancel replaced pending tokens.

Cancellation is cooperative: a keyed LatestOnly task is not preempted. Poll link.is_cancelled() or clone link.cancellation_token() for long loops, and use link.send_if_not_cancelled(msg) to suppress stale results. link.send(msg) remains unconditional for cleanup/error messages that should report even after cancellation.

rust
use tui_lipan::TaskPolicy;

// Example: filter-as-you-type pattern
match msg {
    Msg::QueryChanged(q) => {
        let cmd = ctx.link().command_keyed("filter", TaskPolicy::LatestOnly, move |link| {
            let results = filter_items(&q);
            let _ = link.send_if_not_cancelled(Msg::FilterDone(results));
        });
        Update { dirty: false, command: Some(cmd) }
    }
}

Thread Safety

Commands use channels internally. The component itself never needs to be Send or Sync.

External interactive subprocesses

Spawning an editor or pager that needs the real terminal must not use Command::spawn / ctx.link().command(...) alone: use Command::new on the UI thread together with terminal_handoff, then request_full_repaint() if needed. See External programs.

Nested Components

Use child() to embed components within a view:

rust
use tui_lipan::child;

fn view(&self, ctx: &Context<Self>) -> Element {
    child(
        || MyChild,             // factory closure
        MyChildProps { x: 1 }, // properties
    )
}

Or use the rsx! macro with a component type:

rust
rsx! {
    // Widget types used directly as elements
    VStack {
        MyChildWidget { value: 42 }
    }
}

Parent → Child Communication (Props)

Parents pass data and callbacks to children via Properties:

rust
#[derive(Clone, PartialEq)]  // ← REQUIRED: Clone + PartialEq
struct SidebarProps {
    items: Vec<String>,
    selected: usize,
    on_select: Callback<usize>,   // Callback for child → parent
}

Child → Parent Communication (Callback Props)

Children notify parents by emitting callback props. Messages are scoped - a child cannot directly send messages to the parent's update loop:

rust
struct Sidebar;

#[derive(Clone)]
enum SidebarMsg {
    Selected(usize),
}

impl Component for Sidebar {
    type Message = SidebarMsg;
    type Properties = SidebarProps;
    type State = ();

    fn create_state(&self, _: &SidebarProps) -> () { () }

    fn view(&self, ctx: &Context<Self>) -> Element {
        List::new()
            .items(ctx.props.items.iter().map(|s| ListItem::new(s.clone())))
            .selected(ctx.props.selected)
            .on_select(ctx.link().callback(|e: ListEvent| SidebarMsg::Selected(e.index)))
            .into()
    }

    fn update(&mut self, msg: SidebarMsg, ctx: &mut Context<Self>) -> Update {
        match msg {
            SidebarMsg::Selected(idx) => {
                // Notify parent via callback prop:
                ctx.props.on_select.emit(idx);
                Update::none()  // Parent will re-render with new props
            }
        }
    }
}

// In parent view():
fn view(&self, ctx: &Context<Self>) -> Element {
    HStack::new()
        .child(child(
            || Sidebar,
            SidebarProps {
                items: ctx.state.items.clone(),
                selected: ctx.state.selected,
                on_select: ctx.link().callback(Msg::ItemSelected),
            },
        ))
        .child(Text::new("Detail panel").into())
        .into()
}

Key Rules for Nested Components

  1. Properties must implement Clone + PartialEq - required for reconciliation.
  2. Messages are scoped - each component has its own message queue.
  3. child() takes a factory closure - not just a type: child(|| MyComp, props).
  4. Communication is unidirectional: parent → child via props, child → parent via callback props.
  5. State is isolated - children don't access parent state.

Retained Subtree Reuse

Components can opt into retained subtree reuse by returning a stable key from memo_key():

rust
impl Component for MessageRow {
    type Message = Msg;
    type Properties = RowProps;
    type State = RowState;

    fn create_state(&self, props: &Self::Properties) -> Self::State {
        RowState::from(props)
    }

    fn memo_key(&self, props: &Self::Properties, _ctx: &Context<Self>) -> Option<u64> {
        Some(props.revision)
    }

    fn view(&self, ctx: &Context<Self>) -> Element {
        render_row(ctx.props)
    }

    fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
        handle_row_msg(msg, ctx)
    }
}

When memo_key() returns the same value, the runtime may reuse the component's previously expanded subtree and skip view(). Reuse is automatically invalidated when:

  • local state or props mark the component dirty
  • a nested child component under that subtree needs refresh
  • a Context value read during view() changes (theme(), theme_extension(), focus/hover queries, mouse_capture_enabled(), viewport(), breakpoint(), use_context::<T>())

Use memo_key() for expensive rows, panes, or tool outputs that are stable across unrelated parent updates. Keep the key focused on semantic content identity (revision, version, hash of derived props), not transient UI state that already lives in State.

Component State Keys

component_state_key preserves a component's local state even when its ancestor container structure changes (for example, wrapping a widget in an extra VStack or moving it between branches). It is declared on the element that mounts the component:

rust
fn view(&self, _ctx: &Context<Self>) -> Element {
    VStack::new()
        .child(
            child(|| Modal, modal_props)
                .component_state_key("modal")
        )
        .into()
}

Scoping and duplicate-key policy

State keys are scoped per parent component. Two components with the same component_state_key that are children of the same parent are considered duplicates. In that case the runtime uses last-writer-wins: the second component reuses (and overwrites props on) the same instance.

Debug builds log a warning when duplicate sibling keys are detected:

Duplicate component_state_key "modal" detected; last-writer-wins

Duplicates across different parent scopes (or unrelated branches) are fine. Because the key is global within the registry, a component in one branch can reuse the state of a previously-mounted component with the same key in another branch. This is useful for preserving form state when switching between tabs or conditional views.

Type mismatches

If a state key is reused but the component type does not match, the runtime falls back to creating a fresh instance rather than coercing the wrong type.

Snapshot / Visual Testing

TestBackend supports headless snapshot testing via capture_frame(). After a render() (or dispatch() / send_key() which implicitly re-render), call capture_frame() to get a CapturedFrame containing the full rendered buffer as crate-owned types - no ratatui types leak.

Plain-text snapshot with insta

rust
use tui_lipan::prelude::*;

struct MyWidget;

impl Component for MyWidget {
    type Message = ();
    type Properties = ();
    type State = ();

    fn create_state(&self, _: &()) -> () { () }
    fn update(&mut self, _: (), _: &mut Context<Self>) -> Update { Update::none() }

    fn view(&self, _ctx: &Context<Self>) -> Element {
        Frame::new()
            .title("Panel")
            .child(Text::new("hello"))
            .into()
    }
}

#[test]
fn snapshot_my_widget() {
    let mut backend = TestBackend::new(MyWidget);
    backend.set_viewport(Rect { x: 0, y: 0, w: 30, h: 5 });
    backend.render();

    let frame = backend.capture_frame();
    insta::assert_snapshot!(frame.plain_text());
}

plain_text() returns newline-joined rows with trailing spaces trimmed - the output is stable and deterministic across runs.

Per-cell style assertions

rust
let frame = backend.capture_frame();
let cell = frame.cell(0, 0);

assert_eq!(cell.symbol, "A");
assert_eq!(cell.fg, Color::Rgb(12, 34, 56));
assert_eq!(cell.bg, Color::Rgb(90, 80, 70));
assert!(cell.modifiers.bold);

Styled runs

styled_lines() groups each row into Vec<(String, Style)> runs by identical style, useful for asserting that specific text is rendered with a certain color:

rust
let runs = &frame.styled_lines()[0];
assert_eq!(runs[0].0, "error:");
assert_eq!(runs[0].1.fg, Some(Color::Red));

Cursor capture

When a focused input widget requests cursor placement, frame.cursor is populated:

rust
backend.focus_next();
backend.render();
let frame = backend.capture_frame();

let cursor = frame.cursor.expect("input should place cursor");
assert!(cursor.visible);
assert_eq!(cursor.y, 0);

Viewport resize

rust
backend.set_viewport(Rect { x: 0, y: 0, w: 40, h: 10 });
backend.render();
let frame = backend.capture_frame();
assert_eq!(frame.width, 40);
assert_eq!(frame.height, 10);

CapturedFrame API summary

MethodReturnsDescription
plain_text()StringFull frame as trimmed plain text, \n-separated
to_lines()Vec<String>Same as plain_text() but per-row
row(y)&[CapturedCell]All cells for row y
cell(x, y)&CapturedCellSingle cell at (x, y)
styled_lines()Vec<Vec<(String, Style)>>Rows grouped into style runs
to_fixed_grid()StringFull-width rows without trailing trim (layout-faithful)
to_ansi()StringANSI styled frame (full terminal repaint prelude)
to_ansi_diff(prev)StringIncremental ANSI update from a previous frame
to_png(&PngOptions)Vec<u8>PNG bytes with font-backed or bitmap rendering (ui-snapshot-png)
try_to_png(&PngOptions)Result<Vec<u8>>PNG bytes with encoder errors surfaced (ui-snapshot-png)

CapturedCell fields: symbol, fg, bg, underline_color, modifiers (CellModifiers with bool fields bold, dim, italic, underline, reverse, strikethrough).

Agent / design-review snapshots

TestBackend::capture_ui_snapshot() returns a UiSnapshot: rendered CapturedFrame plus semantic UiWidgetDesc entries (widget kind, keys, rects, focus/hover, selection, values). Use to_markdown() for agent-readable reports. Enable the ui-snapshot-json feature for to_json() / to_json_pretty(). Enable ui-snapshot-png for to_png() / to_png_default() or try_to_png() / try_to_png_default() when layout, color, focus chrome, and visual hierarchy matter; PNG complements markdown/JSON rather than replacing them.

The PNG renderer uses antialiased real-font text by default when a system font is available, with font8x8 bitmap rendering as the fallback. PngOptions is a crate-root import (not prelude) and can select PngTextRenderer::Auto, Font, or Bitmap; font_family / font_path let captures use system or Nerd Fonts. Force Bitmap for deterministic coarse cell output and fallback-style reviews.

rust
let mut backend = TestBackend::new(MyApp);
backend.set_viewport(Rect { x: 0, y: 0, w: 80, h: 24 });
backend.render();

let snapshot = backend.capture_ui_snapshot();
println!("{}", snapshot.to_markdown());

#[cfg(feature = "ui-snapshot-png")]
std::fs::write("/tmp/ui-snapshot.png", snapshot.to_png_default())?;

For design review captures, prefer fit-to-content margin helpers so flex space is visible without hand-tuning a viewport. The recommended default margin is (20, 8):

rust
let snapshot = backend.capture_ui_snapshot_with_margin(
    20,
    8,
    &UiSnapshotOptions::default(),
);

capture_frame_with_margin(20, 8) provides the same fit-to-content viewport behavior when you only need the rendered CapturedFrame.

Live apps: snapshot export is queued until after the next paint (not synchronous from update()). Each request replaces any earlier pending one. Requests schedule a repaint so idle apps still deliver. File routing follows the path extension: .md writes markdown, .json writes JSON with ui-snapshot-json, and .png writes the current viewport as PNG with ui-snapshot-png.

rust
// Store the slot in component state:
struct State {
    slot: UiSnapshotSlot,
}

// In update():
ctx.request_ui_snapshot_to("ui-snapshot.md");
ctx.request_ui_snapshot_to_slot(&ctx.state.slot);

// Later (next tick / handler):
if let Some(snap) = ctx.state.slot.take() {
    // use snap
}

See examples/ui_snapshot.rs.


Key Attribute (Reconciliation)

Assign stable keys to preserve state across re-renders and enable focus routing:

rust
rsx! {
    List { key: "file-list", ... }
    Input { key: format!("input-{}", id), ... }
}

Without a key, reconciliation uses position, which breaks when items are added/removed.

MIT OR Apache-2.0