Components
Component Trait
Every component implements the Component trait with three associated types:
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
| Method | Required | Signature | Purpose |
|---|---|---|---|
create_state | Yes | (&self, &Props) -> State | Initialize state from properties |
memo_key | No | (&self, &Props, &Context<Self>) -> Option<u64> | Opt into retained subtree reuse |
view | Yes | (&self, &Context<Self>) -> Element | Return UI tree |
update | Yes | (&mut self, Msg, &mut Context<Self>) -> Update | Handle messages |
init | No | (&mut self, &mut Context<Self>) -> Option<Command> | One-time setup on mount |
on_key | No | (&mut self, KeyEvent, &mut Context<Self>) -> KeyUpdate | Handle unhandled key events |
on_props_changed | No | (&mut self, &Props, &mut Context<Self>) -> Update | React to property changes |
unmount | No | (&mut self, &mut Context<Self>) | Teardown before removal |
State Flow
User Action → Event → Message → update() → State Change → Re-render
↑___________________________|- User interacts (click, keypress)
- Callback fires (
ctx.link().callback(...)) - Message queued
update()called - mutate state- Return
(needs_redraw: bool, command: Option<Command>) view()re-executed if dirty or memoization cannot retain the subtree- Tree reconciled and rendered
The Update Return Type
Update is a named struct with dirty: bool and command: Option<Command>:
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
| Method | Purpose |
|---|---|
ctx.state | Mutable access to component state |
ctx.props | Read-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:
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:
App::new()
.devtools_config(DevToolsConfig {
logs: true, // ingest debug_log! lines
metrics: true, // collect per-frame stats
})Component Mounting
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
| Properties | State | |
|---|---|---|
| Source | Parent / mount | Local to component |
| Mutability | Immutable (read via ctx.props) | Mutable via ctx.state |
| Lifetime | Passed each render | Persisted across renders |
| Common use | Configuration, DI | User input, loaded data |
#[derive(Clone, PartialEq)]
struct Props { user_id: u64 }
#[derive(Default)]
struct State {
user_name: String,
is_loading: bool,
}Note: Properties must implement
Clone + PartialEqfor reconciliation.
Commands (Async / Background Work)
Components are single-threaded. Use Command for background work:
// 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
| Policy | Behavior |
|---|---|
QueueAll | Run every task sequentially |
DropIfRunning | Ignore new task while one with the same key is running |
LatestOnly | Keep only the newest pending task; drop older pending ones |
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:
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:
rsx! {
// Widget types used directly as elements
VStack {
MyChildWidget { value: 42 }
}
}Parent → Child Communication (Props)
Parents pass data and callbacks to children via Properties:
#[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:
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
- Properties must implement
Clone + PartialEq- required for reconciliation. - Messages are scoped - each component has its own message queue.
child()takes a factory closure - not just a type:child(|| MyComp, props).- Communication is unidirectional: parent → child via props, child → parent via callback props.
- 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():
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
Contextvalue read duringview()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:
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-winsDuplicates 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
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
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:
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:
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
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
| Method | Returns | Description |
|---|---|---|
plain_text() | String | Full 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) | &CapturedCell | Single 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:
rsx! {
List { key: "file-list", ... }
Input { key: format!("input-{}", id), ... }
}Without a key, reconciliation uses position, which breaks when items are added/removed.