Tutorial: Build a Complete App
This tutorial walks through building a realistic multi-panel application step by step. By the end you'll understand components, state, messages, nested components, focus routing, async commands, overlays, and toasts - everything needed to build production TUI apps.
Step 1: Skeleton - Single Component
Start with the minimal Component skeleton:
use tui_lipan::prelude::*;
struct App;
#[derive(Default)]
struct State {
items: Vec<String>,
selected: usize,
detail: String,
}
#[derive(Clone)]
enum Msg {
Select(usize),
ItemsLoaded(Vec<String>),
}
impl Component for App {
type Message = Msg;
type Properties = ();
type State = State;
fn create_state(&self, _: &()) -> State { State::default() }
fn view(&self, ctx: &Context<Self>) -> Element {
Text::new("Hello").into()
}
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
Update::full()
}
}
fn main() -> tui_lipan::Result<()> {
tui_lipan::App::new().title("My App").mount(App).run()
}Key points:
Stateholds all mutable data. Use#[derive(Default)].Msgenumerates every event. Use#[derive(Clone)].create_stateinitializes state from properties.viewreturns the UI tree. Called on every re-render.updatehandles messages. Returns(needs_redraw, optional_command).
Step 2: Layout - Panels with Focus
Build a master-detail layout with focus-aware borders:
fn view(&self, ctx: &Context<Self>) -> Element {
let sidebar_focused = ctx.has_focus_within_key("sidebar");
let detail_focused = ctx.has_focus_within_key("detail");
HStack::new()
.gap(0)
.child(
Frame::new()
.title("Items")
.border(true)
.border_style(if sidebar_focused { BorderStyle::Thick } else { BorderStyle::Rounded })
.width(Length::Px(30))
.child(
List::new()
.key("sidebar")
.items(ctx.state.items.iter().map(|s| ListItem::new(s.clone())))
.selected(ctx.state.selected)
.scrollbar(true)
.selection_full_width(true)
.on_select(ctx.link().callback(|e: ListEvent| Msg::Select(e.index)))
.on_activate(ctx.link().callback(|e: ListEvent| Msg::Activate(e.index)))
),
)
.child(
Frame::new()
.title("Detail")
.border(true)
.border_style(if detail_focused { BorderStyle::Thick } else { BorderStyle::Rounded })
.padding(1)
.child(
Text::new(ctx.state.detail.clone()).key("detail")
),
)
.into()
}Key points:
.key("sidebar")assigns a stable identity for focus routing and reconciliation.ctx.has_focus_within_key("sidebar")queries whether focus is within that subtree.BorderStyle::ThickvsBorderStyle::Roundedgives visual focus cues.ListEvent { index }is the payload foron_selectandon_activate.
Note: We use
Framehere because we need borders and titles to visually distinguish panels. If you don't need visual chrome (border, title, status), useVStack/HStackdirectly - they are lighter containers with the same layout behavior. Seepatterns.mdfor container selection guidance.
Step 3: Handle Messages
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
match msg {
Msg::Select(idx) => {
ctx.state.selected = idx;
ctx.state.detail = format!("Selected: {}", ctx.state.items[idx]);
Update::full()
}
Msg::Activate(idx) => {
ctx.state.detail = format!("Activated: {}", ctx.state.items[idx]);
ctx.request_focus("detail"); // Move focus to detail panel
Update::full()
}
Msg::ItemsLoaded(items) => {
ctx.state.items = items;
ctx.state.selected = 0;
ctx.toast().push(Toast::new("Items loaded!"));
Update::full()
}
}
}Key points:
- Return
Update::full()to redraw without spawning background work. - Return
Update::none()when no visual change occurred. ctx.request_focus("detail")moves focus to a keyed widget on next render.ctx.toast().push(Toast::new(...))shows a toast notification.
Step 4: Async Data Loading
Load data on startup via init() and Command:
fn init(&mut self, ctx: &mut Context<Self>) -> Option<Command> {
Some(ctx.link().command(move |link| {
// Runs on a background thread - safe to block here
let items: Vec<String> = (1..=50)
.map(|i| format!("Item {i}"))
.collect();
std::thread::sleep(std::time::Duration::from_millis(500));
link.send(Msg::ItemsLoaded(items));
}))
}Key points:
init()runs once when the component mounts.ctx.link().command(...)spawns a background task.link.send(Msg::...)sends a message back to the component's update loop.- The component itself never needs
SendorSync.
Step 5: Key Event Handling
Add global keyboard shortcuts via on_key:
fn on_key(&mut self, key: KeyEvent, ctx: &mut Context<Self>) -> KeyUpdate {
match key.code {
KeyCode::Char('q') if key.mods.ctrl => {
ctx.quit();
KeyUpdate::handled(Update::full())
}
KeyCode::Char('1') if key.mods.ctrl => {
ctx.request_focus("sidebar");
KeyUpdate::handled(Update::none())
}
KeyCode::Char('2') if key.mods.ctrl => {
ctx.request_focus("detail");
KeyUpdate::handled(Update::none())
}
KeyCode::Char('r') if key.mods.ctrl => {
// Reload data
let cmd = ctx.link().command(move |link| {
let items: Vec<String> = (1..=50).map(|i| format!("Item {i}")).collect();
link.send(Msg::ItemsLoaded(items));
});
KeyUpdate::handled(Update::with_command(cmd))
}
_ => KeyUpdate::unhandled(Update::none())
}
}Key points:
KeyUpdate::handled(update)stops the key from bubbling further.KeyUpdate::unhandled(update)lets the key continue to parent components.key.mods.ctrl,key.mods.alt,key.mods.shiftare boolean flags.- You can return a
Commandfromon_keytoo (via theUpdatetuple).
Step 6: Conditional Overlays (Modal)
Show a confirmation dialog via state flag:
#[derive(Default)]
struct State {
// ... existing fields ...
show_confirm: bool,
pending_delete: Option<usize>,
}
#[derive(Clone)]
enum Msg {
// ... existing variants ...
RequestDelete(usize),
ConfirmDelete,
CancelDelete,
}
fn view(&self, ctx: &Context<Self>) -> Element {
rsx! {
VStack {
// ... main content ...
if ctx.state.show_confirm {
Modal {
title: "Confirm Delete",
VStack {
gap: 1,
padding: 1,
Text { content: "Are you sure?" }
HStack {
gap: 1,
Button {
label: "Cancel",
on_click: ctx.link().callback(|_| Msg::CancelDelete),
}
Button {
label: "Delete",
style: Style::new().fg(Color::Red),
on_click: ctx.link().callback(|_| Msg::ConfirmDelete),
}
}
}
}
}
}
}
}Step 7: Keyed Task Coalescing (Search)
For filter-as-you-type, use command_keyed with TaskPolicy::LatestOnly:
Msg::QueryChanged(q) => {
ctx.state.query = q.clone();
let cmd = ctx.link().command_keyed(
"search",
TaskPolicy::LatestOnly, // Drop stale searches
move |link| {
let results = expensive_search(&q);
link.send(Msg::SearchDone(results));
},
);
Update::with_command(cmd)
}| Policy | Behavior |
|---|---|
QueueAll | Run every task sequentially |
DropIfRunning | Ignore new task while one with same key is running |
LatestOnly | Keep only the newest pending task; drop older ones |
Step 8: Nested Components
For complex apps, split into nested components with child():
use tui_lipan::child;
// Parent passes data via Properties, gets results via callbacks
#[derive(Clone, PartialEq)] // Properties MUST be Clone + PartialEq
struct SidebarProps {
items: Vec<String>,
selected: usize,
on_select: Callback<usize>,
}
struct Sidebar;
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) => {
ctx.props.on_select.emit(idx); // Notify parent
Update::none() // Parent will redraw with new props
}
}
}
}
// In parent's 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::Select),
},
))
.child(/* detail panel */)
.into()
}Key points:
- Properties must implement
Clone + PartialEqfor reconciliation. - Communication is parent → child via props, child → parent via callback props.
- Children don't directly access parent state. Messages are scoped.
child(|| Sidebar, props)takes a factory closure, not just a type.
Complete App Configuration
fn main() -> tui_lipan::Result<()> {
tui_lipan::App::new()
.title("My App") // Chrome frame title
.theme(Theme::one_dark()) // Theme preset
.mouse(true) // Mouse capture (default: true)
.toast_placement(ToastPlacement::BottomEnd)
.contrast_policy(ContrastPolicy::Wcag) // WCAG-fix low-contrast text
.mount(App)
.run()
}Available themes: default(), one_dark(), dracula(), nord(), gruvbox(), catppuccin(), tokyo_night(), solarized_dark(), monokai().