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_window_focus_changedNo, root only(&mut self, bool, &mut Context<Self>) -> UpdateReact to host terminal/window focus transitions
on_props_changedNo(&mut self, &Props, &mut Context<Self>) -> UpdateReact to property changes
unmountNo(&mut self, &mut Context<Self>)Teardown before removal

on_window_focus_changed runs only on the mounted root when the host reports an actual focus transition. It is not widget focus: use widget .on_focus / .on_blur callbacks and the focus APIs for keyboard routing. It is also separate from a child Terminal requesting CSI ?1004 focus reporting; the runner continues to send those sequences only to that terminal.

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()Repaint the existing realized tree without rerunning component views or layout
Update::layout()Rerun the emitting component scope's view(), then reconcile and lay out that subtree
Update::layout_with_command(cmd)Same component-scoped refresh while also starting background work
Update::full()Rebuild from the root because state affects other scopes or global composition
Update::with_command(cmd)Same root-wide refresh while also starting background work
Update::command_only(cmd)Start background work without marking a component dirty

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.

See Performance for production patterns around update scope, scrolling, memoization, and bounded work.

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, including before mount or inside an excluded scope
ctx.blur()Clear current and retained focus identity (Auto restores its default target on render)
ctx.focus_next() / ctx.focus_prev()Move through the focus ring explicitly, including under Manual
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.devtools_visible()Read the current runner-synchronized DevTools panel visibility
ctx.set_devtools_metrics(factory)Lazily replace the ordered label/value rows in the DevTools App tab without scheduling a frame; an empty iterator clears them
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.command_chord_pending()Whether an app command chord is currently pending completion (e.g., after a leader prefix key). Entering or leaving pending always dirties a frame so chrome like a PREFIX badge updates even when the completing/mismatch key is forwarded to a focused terminal with no paint of its own.
ctx.command_chord_pending_since()When the pending chord started, or None when none is pending
ctx.command_chord_revealed()Whether the pending chord has been held for at least App::command_chord_reveal_delay; the signal for a which-key panel
ctx.set_command_chord_reveal_delay(d)Retime the reveal delay at runtime (config reload)
ctx.last_mouse()Last pointer in terminal content coordinates, or None until a mouse event has been seen. Updated even when motion is forwarded to a tracking terminal.
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.suspend_to_shell()Stop the app to the shell like ctrl+z, releasing and restoring the terminal around the stop (see External programs)
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 host-color 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. Use App::system_theme() for a framework-wide theme derived from the host palette, or App::live_host_terminal_colors(true) when app code needs extra host-derived tokens. The runner probes OSC 4/10/11 once at startup, refreshes on terminal focus gained, and services ctx.request_host_terminal_color_refresh() while coordinating with its input reader. On Unix fullscreen surfaces it additionally enables DEC private mode 2031; compatible terminals then send exact dark/light palette-change notifications, which trigger an immediate typed OSC 10/11 refresh. The runtime cache retains the startup probe's resolved RGB ANSI slots because Termina does not yet expose OSC 4 responses; it never substitutes unresolved indexed colors into app-owned theme tokens. A changed refresh schedules a complete repaint without presenting a cleared intermediate frame. Inline, non-Unix, non-live, and unsupported terminals retain startup, focus-gained, and manual OSC 4/10/11 refresh behavior. The runner never polls continuously. Use ctx.host_terminal_colors() for app-specific tokens beyond the framework theme; 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()
}

ctx.devtools_visible() reports the applied panel state, including closes from Esc or the global toggle. A queued show/hide/toggle request is reflected after the runner applies it on the next tick. It always returns false without the devtools feature.

Apps can also publish a small ordered set of structured metrics. Each call replaces the previous set, so call it from view() or update() with the current snapshot:

rust
ctx.set_devtools_metrics(|| [
    DevToolsMetric::new("Panes", pane_count.to_string()),
    DevToolsMetric::new("Queue", queue_depth.to_string()),
]);

// Clear the App tab:
ctx.set_devtools_metrics(std::iter::empty);

The closure runs immediately when devtools is enabled; the panel later reads stored values only and never calls back into the host app while rendering. Publishing does not schedule a frame: values published from view() are read by the DevTools extra root later in that same frame. Calls made elsewhere are stored until a later frame rebuilds the panel; the host update is responsible for requesting that frame. The tab sizes to its content within the viewport and becomes vertically scrollable when rows do not fit. Without the devtools feature, the closure is not invoked, so formatting and allocation are skipped while the same source keeps compiling.

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

Deferring chord chrome

A panel that lists what a pending chord can do next — a which-key panel — should not flash on every chord the user completes from muscle memory. App::command_chord_reveal_delay sets how long a chord must be held before ctx.command_chord_revealed() reports it:

rust
App::new().command_chord_reveal_delay(Duration::from_millis(350))
rust
fn view(&self, ctx: &Context<Self>) -> Element {
    let mut root = ZStack::new().child(self.workspace(ctx));
    if ctx.command_chord_revealed() {
        root = root.child(which_key_panel(ctx));
    }
    root.into()
}

The runtime schedules the frame at which the delay elapses, so the view needs no timer: a chord completed or cancelled first simply never reveals. Keep using ctx.command_chord_pending() for chrome that must react on the first keystroke — a mode badge, or suppressing a caret that would otherwise suggest the next key goes to the focused widget. The default delay is zero, which makes the two identical.

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::command_only(cmd)
    }
}

Delayed work: debounces, retries, and ticks

Never thread::sleep inside a command. Tasks run on a fixed pool of 2-8 workers, so a sleeping task occupies one for the whole delay — two recurring timers are enough to park the pool on a low-core machine and stall every other background task behind them.

Use Command::after, which waits on a shared timer thread and reaches the pool only once due:

rust
use std::time::Duration;

// Debounce: coalesce a resize storm into one flush.
Command::after(Duration::from_millis(16), |link: CommandLink<Msg>| {
    link.send(Msg::FlushResizes);
})

Re-arming from the handler gives a recurring tick that costs no thread between firings:

rust
Msg::Tick => {
    refresh(ctx);
    Update::with_command(Command::after(Duration::from_secs(1), |link: CommandLink<Msg>| {
        link.send(Msg::Tick);
    }))
}

When you already hold a CommandLink and only need to deliver a message later, send_after is the direct form. It is dropped if the command is cancelled first:

rust
link.send_after(Duration::from_millis(800), Msg::Deadline);

Delay first, then work: the closure body still runs on the pool, so a delayed fetch or filesystem sweep is fine inside Command::after — only the waiting moves off the pool.

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

Snapshot capture, headless PNGs, recordings, the live control channel, design sketches, and visual regression baselines live in docs/testing.md.

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.

MPL-2.0