# SLT Cookbook
Copy-paste recipes for common TUI apps. Each recipe is also shipped as a standalone runnable example at `examples/cookbook_<name>.rs`, so you can run and tweak it directly.
Recipes assume familiarity with the core mental model from [QUICK_START.md](QUICK_START.md). If a widget surprises you, cross-check [WIDGETS.md](WIDGETS.md) for the full API table and [PATTERNS.md](PATTERNS.md) for composition advice.
## Index
- [Login Form with Validation](#login-form-with-validation) — `cargo run --example cookbook_login`
- [Data Table with Search and Sort](#data-table-with-search-and-sort) — `cargo run --example cookbook_table`
- [Modal Confirmation with Toast](#modal-confirmation-with-toast) — `cargo run --example cookbook_modal_toast`
- [Real-time Dashboard with Charts](#real-time-dashboard-with-charts) — `cargo run --example cookbook_dashboard`
- [File Picker with Preview](#file-picker-with-preview) — `cargo run --example cookbook_file_picker`
- [Components with Shared State](#components-with-shared-state) — `provide` / `use_context` (v0.19.0)
- [Common pitfalls](#common-pitfalls)
## Prerequisites
Add SLT to a fresh project:
```toml
# Cargo.toml
[dependencies]
superlighttui = "0.19"
```
Every recipe follows the same outer shape:
```rust
fn main() -> std::io::Result<()> {
// 1. Create state once, outside the closure.
let mut state = /* ... */;
// 2. Your closure runs each frame.
slt::run(|ui: &mut slt::Context| {
// Global shortcuts first.
if ui.key('q') || ui.key_code(slt::KeyCode::Esc) {
ui.quit();
}
// Describe your UI.
ui.text("hello");
})
}
```
The closure is the whole app. Plain Rust variables hold state; `slt::Context` mutates on interaction. No framework object, no stores.
---
## Login Form with Validation
### What it shows
- Two `TextInputState` fields with `add_validator(...)` chains that run every frame.
- Password masking via `TextInputState::masked`.
- Automatic Tab / Shift+Tab focus cycling — no manual focus handling required.
- Submit button that is only enabled when both fields pass validation.
### Full code
```rust
use slt::{AlertLevel, Border, Color, Context, KeyCode, TextInputState};
fn main() -> std::io::Result<()> {
let mut email = TextInputState::with_placeholder("you@example.com");
email.add_validator(|v| {
if v.is_empty() {
Err("Email is required".into())
} else if !v.contains('@') || !v.contains('.') {
Err("Invalid email format".into())
} else {
Ok(())
}
});
let mut password = TextInputState::with_placeholder("At least 8 characters");
password.masked = true;
password.add_validator(|v| {
if v.len() < 8 {
Err("Password must be at least 8 characters".into())
} else {
Ok(())
}
});
let mut submitted: Option<String> = None;
slt::run(|ui: &mut Context| {
if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) {
ui.quit();
}
// Run validators every frame; cheap because inputs are tiny.
email.run_validators();
password.run_validators();
let valid = email.errors().is_empty() && password.errors().is_empty();
let _ = ui
.bordered(Border::Rounded)
.title("Sign In")
.pad(2)
.gap(1)
.col(|ui| {
ui.text("Welcome back").bold().fg(Color::Cyan);
ui.text("Use Tab / Shift+Tab to move between fields.").dim();
ui.separator();
ui.text("Email").bold();
let _ = ui.text_input(&mut email);
for err in email.errors() {
ui.text(format!(" {err}")).fg(Color::Red);
}
ui.text("Password").bold();
let _ = ui.text_input(&mut password);
for err in password.errors() {
ui.text(format!(" {err}")).fg(Color::Red);
}
ui.separator();
let _ = ui.row(|ui| {
// Disable the button when invalid by swapping the label.
let label = if valid { "Submit" } else { "Submit (fix errors)" };
if ui.button(label).clicked && valid {
submitted = Some(email.value.clone());
}
ui.spacer();
if let Some(ref user) = submitted {
let _ = ui.alert(
&format!("Signed in as {user}"),
AlertLevel::Success,
);
}
});
});
})
}
```
### Key patterns
- `add_validator(|v| -> Result<(), String>)` is additive — call it once per rule. `run_validators()` populates `errors()`, which is the source of truth for the UI.
- Never guard `ui.text_input(...)` behind an `if`. Call it every frame; otherwise focus and key consumption state drifts.
- Focus cycling is handled by SLT. Register widgets in the order you want them traversed.
- The submit button stays rendered even when invalid — this keeps hook order stable. We gate the effect (`submitted = ...`) instead.
---
## Data Table with Search and Sort
### What it shows
- `TableState` with built-in search, pagination, and column sort.
- A `TextInputState` driving the filter each frame.
- Header-click sort (`toggle_sort`) via keyboard shortcuts `H` / `L`.
- Zebra rows + row-size footer with live row count.
### Full code
```rust
use slt::{Border, Color, Context, KeyCode, TableState, TextInputState};
fn main() -> std::io::Result<()> {
let mut table = TableState::new(
vec!["Name", "Role", "Stars"],
vec![
vec!["slt", "TUI library", "500"],
vec!["ratatui", "TUI library", "12000"],
vec!["bubbletea", "TUI framework", "30000"],
vec!["ink", "React for CLI", "28000"],
vec!["textual", "Python TUI", "26000"],
vec!["cursive", "Rust TUI", "4200"],
vec!["termion", "terminal lib", "1900"],
vec!["notcurses", "rendering lib", "2300"],
vec!["blessed", "JS TUI", "10700"],
vec!["prompt-kit","form lib", "890"],
],
);
table.page_size = 5;
table.zebra = true;
let mut filter = TextInputState::with_placeholder("Filter across all columns...");
slt::run(|ui: &mut Context| {
if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) {
ui.quit();
}
// Column-sort shortcuts (1..=3).
for i in 1..=3u8 {
if ui.key((b'0' + i) as char) {
table.toggle_sort((i - 1) as usize);
}
}
if ui.key_code(KeyCode::Right) {
table.next_page();
}
if ui.key_code(KeyCode::Left) {
table.prev_page();
}
let _ = ui
.bordered(Border::Rounded)
.title("Projects")
.pad(1)
.gap(1)
.col(|ui| {
ui.text("Press 1/2/3 to sort, Left/Right to page").dim();
let _ = ui.text_input(&mut filter);
// Pipe the text input straight into the table filter each frame.
table.set_filter(&filter.value);
let _ = ui.table(&mut table);
let _ = ui.row(|ui| {
ui.text(format!(
"Showing {}/{} rows",
table.visible_indices().len(),
table.rows.len(),
))
.dim();
ui.spacer();
if let Some(col) = table.sort_column {
let arrow = if table.sort_ascending { "asc" } else { "desc" };
ui.text(format!("Sort: {} {arrow}", table.headers[col]))
.fg(Color::Cyan);
}
ui.spacer();
ui.text(format!(
"page {}/{}",
table.page + 1,
table.total_pages()
))
.dim();
});
if let Some(row) = table.selected_row() {
ui.separator();
ui.text(format!("Selected: {}", row.join(" | "))).bold();
}
});
})
}
```
### Key patterns
- `TableState::new` accepts headers and string rows. `Stars` contains numbers inside strings — SLT's sort detects numeric columns automatically by trying `str::parse::<f64>()`.
- `set_filter` runs a cached, lowercase, token-AND search across all cells. Calling it with the same value is a no-op.
- `toggle_sort(col)` flips asc/desc on the second press; `sort_by(col)` always sets ascending.
- `page_size` of 0 disables pagination. When set, `visible_indices()` is still the full filtered set; SLT slices it internally for the current page.
---
## Modal Confirmation with Toast
### What it shows
- A destructive "Delete" action guarded by `ui.modal(...)`.
- `ToastState` for transient notifications with severity levels.
- `raw_key_code(KeyCode::Esc)` to close overlays regardless of modal state.
- Split logic: the button in the main UI opens the modal; the modal itself handles Confirm/Cancel.
### Full code
```rust
use slt::{Border, ButtonVariant, Color, Context, KeyCode, ToastState};
fn main() -> std::io::Result<()> {
let mut items: Vec<String> = (1..=6).map(|i| format!("Document {i}.txt")).collect();
let mut selected: usize = 0;
let mut show_modal = false;
let mut toasts = ToastState::new();
slt::run(|ui: &mut Context| {
let tick = ui.tick();
if !show_modal && ui.key('q') {
ui.quit();
}
if ui.key_code(KeyCode::Up) && selected > 0 {
selected -= 1;
}
if ui.key_code(KeyCode::Down) && selected + 1 < items.len() {
selected += 1;
}
let _ = ui
.bordered(Border::Rounded)
.title("Files")
.pad(1)
.gap(1)
.col(|ui| {
ui.text("Select a file, then press Delete.").dim();
for (i, name) in items.iter().enumerate() {
let prefix = if i == selected { "> " } else { " " };
let color = if i == selected { Color::Cyan } else { Color::White };
ui.text(format!("{prefix}{name}")).fg(color);
}
ui.separator();
let _ = ui.row(|ui| {
if ui.button("Refresh").clicked {
toasts.info("List refreshed", tick);
}
ui.spacer();
if ui.button_with("Delete", ButtonVariant::Danger).clicked
&& !items.is_empty()
{
show_modal = true;
}
});
});
if show_modal {
// Esc closes the modal no matter what is below it.
if ui.raw_key_code(KeyCode::Esc) {
show_modal = false;
}
let _ = ui.modal(|ui| {
let _ = ui
.bordered(Border::Double)
.title("Confirm delete")
.pad(2)
.gap(1)
.col(|ui| {
let target = items
.get(selected)
.cloned()
.unwrap_or_else(|| "<nothing>".into());
ui.text("You are about to delete:").bold();
ui.text(format!(" {target}")).fg(Color::Yellow);
ui.text("This cannot be undone.").dim();
let _ = ui.row(|ui| {
if ui.button("Cancel").clicked {
show_modal = false;
toasts.info("Canceled", tick);
}
ui.spacer();
if ui
.button_with("Delete", ButtonVariant::Danger)
.clicked
{
let name = items.remove(selected);
if selected >= items.len() && selected > 0 {
selected -= 1;
}
toasts.success(format!("Deleted {name}"), tick);
show_modal = false;
}
});
});
});
}
// Toast rendering must happen every frame so `cleanup()` can expire old messages.
ui.toast(&mut toasts);
})
}
```
### Key patterns
- `ui.modal(|ui| { ... })` dims the background and traps focus inside the closure. The UI behind it still renders, but interaction routes to the modal.
- `ui.raw_key_code(...)` bypasses the modal's input consumption — ideal for global keys like Esc. Plain `ui.key_code(...)` inside the modal is for modal-local keys.
- `ToastState::info/success/warning/error` take `(message, tick)`. `ui.toast(&mut toasts)` both renders and cleans up expired entries — always call it unconditionally every frame.
- Gate global shortcuts (like `q`) behind `!show_modal` so they don't fire while the modal is open.
---
## Real-time Dashboard with Charts
### What it shows
- Simulated per-tick metrics feeding a `sparkline` and `bar_chart`.
- `ui.chart(|c| ...)` multi-series builder with a `line` and `area` overlay.
- `stat_trend` and `stat_colored` for KPI tiles.
- A ring buffer of recent values — the classic "rolling window" pattern for TUIs.
> Dashboards often want hover-driven highlight or drag-to-pan. Both use previous-frame layout data, so the first frame after layout change can look odd. See [DEBUGGING.md](DEBUGGING.md) for why — it is not a bug, it is immediate mode's trade-off.
### Full code
```rust
use slt::{Border, Color, Context, KeyCode, SpinnerState, Trend};
fn main() -> std::io::Result<()> {
let spinner = SpinnerState::dots();
let mut cpu_hist: Vec<f64> = vec![0.0; 60];
let mut req_hist: Vec<f64> = vec![0.0; 60];
slt::run(|ui: &mut Context| {
if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) {
ui.quit();
}
let tick = ui.tick() as f64;
let cpu = 45.0 + 30.0 * (tick * 0.08).sin() + 10.0 * (tick * 0.3).cos();
let reqs = (120.0 + 80.0 * (tick * 0.05).sin()).max(0.0);
// Ring-buffer update: drop oldest, append newest.
cpu_hist.rotate_left(1);
if let Some(last) = cpu_hist.last_mut() {
*last = cpu;
}
req_hist.rotate_left(1);
if let Some(last) = req_hist.last_mut() {
*last = reqs;
}
let _ = ui
.bordered(Border::Rounded)
.title("Live Dashboard")
.pad(1)
.gap(1)
.grow(1)
.col(|ui| {
let _ = ui.row(|ui| {
ui.spinner(&spinner);
ui.text(" LIVE").bold().fg(Color::Green);
ui.spacer();
ui.text(format!("tick {tick:.0}")).dim();
});
let _ = ui.row(|ui| {
let _ = ui.bordered(Border::Single).pad(1).grow(1).col(|ui| {
let _ = ui.stat_trend("Requests / min", &format!("{reqs:.0}"), Trend::Up);
});
let _ = ui.bordered(Border::Single).pad(1).grow(1).col(|ui| {
let color = if cpu > 80.0 { Color::Red } else { Color::Cyan };
let _ = ui.stat_colored("CPU", &format!("{cpu:.1}%"), color);
});
let _ = ui.bordered(Border::Single).pad(1).grow(1).col(|ui| {
let _ = ui.stat_colored("P99", "42ms", Color::Yellow);
});
});
let _ = ui
.bordered(Border::Single)
.title("CPU (last 60 ticks)")
.pad(1)
.col(|ui| {
let _ = ui.sparkline(&cpu_hist, 60);
});
let _ = ui
.bordered(Border::Single)
.title("Requests / min")
.pad(1)
.col(|ui| {
let _ = ui.chart(
|c| {
c.area(&req_hist).label("rpm").color(Color::Cyan);
c.grid(true);
},
60,
10,
);
});
let top_routes: Vec<(&str, f64)> = vec![
("/api/users", 420.0),
("/api/items", 312.0),
("/auth", 280.0),
("/health", 95.0),
("/metrics", 60.0),
];
let _ = ui
.bordered(Border::Single)
.title("Top Routes")
.pad(1)
.col(|ui| {
let _ = ui.bar_chart(&top_routes, 30);
});
let _ = ui.help(&[("Ctrl+Q", "quit"), ("Esc", "quit")]);
});
})
}
```
### Key patterns
- Store history in a fixed-size `Vec<f64>` and rotate it. This is cheaper than `VecDeque` for tiny buffers and keeps data contiguous for `sparkline(&history, width)`.
- `ui.chart(|c| { ... }, width, height)` takes an explicit size. For grow-to-fit behavior, read `ui.width()` / `ui.height()` and compute manually.
- `stat_trend` pairs a value with an up/down arrow via `Trend::Up` / `Trend::Down`. `stat_colored` lets you encode severity.
- Spinners animate off the frame tick automatically — just create one `SpinnerState::dots()` at startup and hand the reference in each frame.
---
## File Picker with Preview
### What it shows
- `FilePickerState` on the left, text preview on the right in a two-pane layout.
- Extension filtering (`.extensions(&["rs", "toml", "md"])`).
- Safe file reading with a size cap — avoids locking the UI on multi-MB files.
- `notify` toasts for error reporting without growing the main UI.
### Full code
```rust
use slt::{Border, Color, Context, FilePickerState, KeyCode, ScrollState, ToastState};
use std::path::PathBuf;
const PREVIEW_MAX_BYTES: u64 = 64 * 1024; // 64 KiB cap
fn main() -> std::io::Result<()> {
let mut picker = FilePickerState::new(
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
)
.extensions(&["rs", "toml", "md", "txt", "json"])
.show_hidden(false);
let mut preview: Option<(PathBuf, String)> = None;
let mut preview_scroll = ScrollState::new();
let mut toasts = ToastState::new();
slt::run(|ui: &mut Context| {
let tick = ui.tick();
if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) {
ui.quit();
}
let _ = ui
.bordered(Border::Rounded)
.title("File Picker")
.pad(1)
.gap(1)
.grow(1)
.col(|ui| {
ui.text(format!("Dir: {}", picker.current_dir.display()))
.dim()
.wrap();
ui.text("Enter opens, Backspace goes up, filter: .rs .toml .md .txt .json")
.dim();
ui.separator();
let _ = ui.row(|ui| {
// Left pane: picker.
let _ = ui
.bordered(Border::Single)
.title("Browse")
.pad(1)
.grow(1)
.col(|ui| {
if ui.file_picker(&mut picker).changed {
if let Some(path) = picker.selected().cloned() {
match read_preview(&path) {
Ok(content) => {
preview = Some((path.clone(), content));
preview_scroll.offset = 0;
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?");
toasts.success(format!("Loaded {name}"), tick);
}
Err(err) => {
toasts.error(err, tick);
}
}
}
}
});
// Right pane: preview.
let _ = ui
.bordered(Border::Single)
.title("Preview")
.pad(1)
.grow(2)
.col(|ui| match &preview {
None => {
ui.text("Select a file to preview.").dim();
}
Some((path, content)) => {
ui.text(path.display().to_string()).fg(Color::Cyan).wrap();
ui.separator();
let _ = ui.scrollable(&mut preview_scroll).grow(1).col(|ui| {
for line in content.lines() {
ui.text(line);
}
});
}
});
});
});
ui.toast(&mut toasts);
})
}
fn read_preview(path: &std::path::Path) -> Result<String, String> {
let metadata = std::fs::metadata(path)
.map_err(|e| format!("metadata: {e}"))?;
if metadata.len() > PREVIEW_MAX_BYTES {
return Err(format!(
"File too large ({} bytes, limit {} bytes)",
metadata.len(),
PREVIEW_MAX_BYTES
));
}
std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))
}
```
### Key patterns
- `FilePickerState` owns the directory listing. `.extensions(&[...])` filters non-directory entries; directories are always shown so you can navigate in.
- `picker.selected()` returns `Option<&PathBuf>` only when the user picks a file with Enter — directories set `current_dir` instead and emit `changed = true` but no selection.
- Always gate `std::fs::read_to_string` behind a size check. A runaway file read blocks the thread and the UI stops responding.
- Pair a `ScrollState` with every long content region. Reset `offset = 0` when you swap the content — otherwise the previous file's scroll position leaks into the new file.
---
## Components with Shared State
### What it shows
- `ui.provide(value, |ui| ...)` to publish shared app context once at the root.
- `ui.use_context::<T>()` (panics if missing) and `ui.try_use_context::<T>()` (returns `Option<&T>`) to read it back from any nested render fn.
- Replaces threading `&theme`, `&tick`, `&user` parameters through every signature.
The canonical real-world refactor is `examples/demo_website.rs`, which moved from per-call argument threading to one `provide` at the top.
### When to reach for it
- Many nested render helpers all want the same read-only value (theme, tick, current user).
- You catch yourself adding the same parameter to every render fn signature.
Reserve explicit parameters for **writes** (`&mut MyDocState`, `&mut ToastState`). `provide` / `use_context` is a *read*-side ergonomics tool, not a state container.
### Full code
```rust
use slt::{Border, Color, Context, KeyCode, Theme};
// `provide` boxes the value as `dyn Any`, requiring `T: 'static`. `Theme` is
// `Copy`, so deref-copy from `ui.theme()`. `&'static str` works for string
// literals; switch to `String` for runtime values.
struct AppCtx {
theme: Theme,
tick: u64,
user: &'static str,
}
fn main() -> std::io::Result<()> {
slt::run(|ui: &mut Context| {
if ui.key('q') || ui.key_code(KeyCode::Esc) {
ui.quit();
}
let ctx = AppCtx {
theme: *ui.theme(),
tick: ui.tick(),
user: "subin",
};
ui.provide(ctx, |ui| {
let _ = ui
.bordered(Border::Rounded)
.title("Shared context demo")
.pad(1)
.gap(1)
.col(|ui| {
render_header(ui);
render_card(ui);
});
});
})
}
fn render_header(ui: &mut Context) {
let ctx = ui.use_context::<AppCtx>();
ui.text(format!("hi, {}", ctx.user)).bold().fg(Color::Cyan);
ui.text(format!("tick {}", ctx.tick)).dim();
}
fn render_card(ui: &mut Context) {
// Optional read — never panics if no provider is on the stack.
if let Some(ctx) = ui.try_use_context::<AppCtx>() {
ui.text(format!("theme bg: {:?}", ctx.theme.bg));
} else {
ui.text("no app context").dim();
}
}
```
### Key patterns
- `provide` is scoped to the closure body. Once that body returns, the value pops off the context stack.
- `use_context::<T>()` finds the nearest provided `T` by type. Two providers of the same type stack — the inner one wins inside its body.
- Use `try_use_context` in helpers that should also work outside the provider (e.g. unit tests, isolated demos).
---
## Common pitfalls
### `RichLogState::new()` silently truncates at 10000 entries
`RichLogState::new()` is bounded at 10000 entries (v0.19.2). Older entries are dropped to keep memory predictable. If you are running a long-lived dashboard or log viewer and entries seem to "disappear" from the top, that is the cap, not a bug.
For unbounded accumulation use `RichLogState::new_unbounded()` and accept the memory cost, or add your own retention policy on top of the bounded variant.
```rust
// Bounded — drops oldest after 10k entries.
let mut log = slt::RichLogState::new();
// Unbounded — grows until the process dies. Use only when you control the input rate.
let mut log = slt::RichLogState::new_unbounded();
```
---
## Where to go next
- [PATTERNS.md](PATTERNS.md) — when these recipes are not enough and you need structural advice (render helpers, screens, hooks vs app state).
- [THEMING.md](THEMING.md) — all recipes honor `ui.theme()`; swap `Theme::dark()` to `Theme::tokyo_night()` and see everything recolor.
- [TESTING.md](TESTING.md) — each recipe's logic can be exercised headlessly with `TestBackend::new(w, h).render(|ui| { ... })`.
- [ANIMATION.md](ANIMATION.md) — tween the dashboard tiles or stagger-fade the file picker list.
- [EXAMPLES.md](EXAMPLES.md) — full index of runnable examples, including the `cookbook_*` entries paired with this page.