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 dirty: bool and command: Option<Command>:

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.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.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.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)

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.

You can also disable individual devtools subsystems at app start time via DevToolsConfig:

rust
App::new()
    .devtools_config(DevToolsConfig {
        logs: true,    // ingest debug_log! lines
        metrics: true, // collect per-frame stats
    })

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| {
        let results = do_search(&query);
        link.send(Msg::SearchDone(results));
    },
);

TaskPolicy Options

PolicyBehavior
QueueAllRun every task sequentially
DropIfRunningIgnore new task while one with the same key is running
LatestOnlyKeep only the newest pending task; drop older pending ones
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);
            link.send(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

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


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