name: slt
description: Build Rust TUI apps with SuperLightTUI v0.20 (immediate-mode terminal UI). Use this skill when the user asks to create, modify, or debug terminal UI code in this repo, or asks "how do I X in SLT / TUI / terminal", or types Korean triggers like "터미널 UI", "TUI 만들어줘", "SLT로", "ratatui 대신". Read REFERENCES.md for feature flags and doc pointers; grep src/context/ and src/widgets/ before inventing any API.
SuperLightTUI (SLT) Authoring Skill — v0.20
Mental model
SLT is immediate-mode. Your app is one closure: slt::run(|ui: &mut Context| { ... }). The closure runs every frame. State lives in plain Rust variables outside the closure — no App trait, no Model/View/Update, no retained tree. SLT handles flexbox layout, ANSI diff, and stdout flush.
Response.rect reflects the previous frame because layout runs after the closure returns. Frame 1 returns a zero Rect. Guard measurement-dependent logic with if ui.tick() > 0 { ... }. See docs/PREVIOUS_FRAME_GUIDE.md.
For larger apps write components as functions: fn render_card(ui: &mut Context, data: &Card). Share read-mostly state with ui.provide(value, |ui| ...) + ui.use_context::<T>() instead of threading &theme through every helper.
The 5 API rules (predictability anchors)
These are non-negotiable in v0.20+. When generating new code, every public widget must match all 5.
Builder for optional config. Methods on
Contextreturn a builder when ≥1 option exists. Builders chain&mut self -> &mut Self, render onDrop, expose.show()to capture a*Response.ui.gauge(0.6).label("60%").width(24).color(Color::Cyan); // good let r = ui.breadcrumb(&segs).separator(" › ").show(); // capture responseRemoved in v0.20:
gauge_w,gauge_colored,line_gauge_with,breadcrumb_sep,LineGaugeOpts,HighlightRange::single,label_owned. Do not write these — AI training data may suggest them.Floats are
f64. Public surface never takes/returnsf32.0.5isf64natively, soui.gauge(0.5)just works.≤3 positional args. When 4+ args appear, use an opts struct (
<Widget>Opts) or a builder.GutterOpts::line_numbers(total, viewport) // Opts struct ui.scrollable_with_gutter(&mut scroll, opts, |ui, abs| { ... });Stateful widgets take
&mut <Widget>State. Never&mut String,&mut Vec<T>,&mut usize. Trivial-value exceptions:slider(&mut f64),checkbox(&mut bool),toggle(&mut bool).ui.text_input(&mut TextInputState); // not &mut String ui.tabs(&mut TabsState); // not &mut usizeResponses. Single-rect widgets return
Response. Compound widgets return<Widget>Response: Deref<Target = Response>with#[must_use]. Never tuples.if ui.button("Save").clicked { ... } // Response if let Some(i) = ui.breadcrumb(&segs).show().clicked_segment { ... } // BreadcrumbResponseReturn-type pattern. Methods on
Contextreturn one of two types — picking the wrong one is the most common AI-generated compile error.&mut Self— chainable mutators of the last rendered element. Use for:text,link,styled,separator,timer_display, and the style chain (bold,dim,italic,fg,bg,wrap,truncate,align,text_center,m,mx,w,h,grow,spacer,with_if,with).Response— interaction result of an independently-rendered widget. Use for every stateful interactive widget (button,checkbox,toggle,table,tabs,select,radio,multi_select,text_input,list,tree,file_picker,slider,calendar,command_palette,rich_log).- Container helpers split:
col/row/modal→Response.line/line_wrap/screen→&mut Self(these continue an inline-text chain).
ui.button("Save").bold(); // ❌ Response has no .bold() — compile error if ui.button("Save").clicked { … } // ✅ Response field ui.text("Saved").bold().fg(green); // ✅ &mut Self chain on display element
Naming categories (NAMING.md micro tier)
Method names encode their category. When picking a name, match the category shape of nearby methods.
| Category | Shape | Examples |
|---|---|---|
| Verbs (actions, side effects) | <verb> or <verb>_<object> |
quit, notify, register_focusable, focus_by_name, consume_indices, set_ratio |
| Nouns (getters, no side effects) | <noun> or <noun>_<modifier> |
theme, width, events, focused_name, state.cursor. Never get_X. |
| Adjectives (Layer 2 builder modifiers) | short, ≤2 syllables | bordered, bg, fg, p, m, w, h, gap, grow, fill, bold, dim |
| Constructors | Type::default() / Type::new(args) / Type::with_X(arg) |
TextInputState::default(), SplitPaneState::new(0.5), TextInputState::with_placeholder("…") |
Allowed universal abbreviations: bg fg id idx len min max pos pct w h x y r g b a.
Forbidden: ctx btn lbl dbg cfg req res srv db in public API. Closure params over &mut Context are always ui, never ctx.
Layer model (5 layers, predictability anchor)
| Layer | Object | Examples |
|---|---|---|
| 1 — Context | &mut Context (the ui parameter) |
ui.text(...), ui.button(...), ui.row(...), ui.theme(), ui.use_state(...) |
| 2 — ContainerBuilder | returned by ui.container(), ui.bordered(...) |
chained adjectives: `.p(2).bg(c).gap(1).col( |
| 3 — Widget | impl Widget for MyType { type Response; fn ui(...) } |
custom widget extension point |
| 4 — State | pub struct <Widget>State in src/widgets/*.rs |
TextInputState, TableState, ScrollState |
| 5 — Response | Response or <Widget>Response: Deref<Response> |
Response { clicked, hovered, changed, focused, rect, right_clicked, gained_focus, lost_focus } |
When a method belongs to two layers (ui.bordered(B) shortcut vs ui.container().border(B) explicit), prefer the explicit form in skill output.
Sibling widget shapes (memorize)
When unsure about a widget signature, find its family and match.
// Stateful interactive widgets — &mut <Widget>State, returns Response
ui.list(&mut ListState); ui.tabs(&mut TabsState);
ui.table(&mut TableState); ui.tree(&mut TreeState);
ui.select(&mut SelectState); ui.radio(&mut RadioState);
ui.multi_select(&mut MultiSelectState);
ui.file_picker(&mut FilePickerState); ui.calendar(&mut CalendarState);
ui.text_input(&mut TextInputState); ui.textarea(&mut TextareaState, rows); // textarea takes rows
ui.rich_log(&mut RichLogState); ui.command_palette(&mut CommandPaletteState);
ui.toast(&mut ToastState); ui.spinner(&SpinnerState); // spinner is &, not &mut
// Trivial-value siblings — exception to Rule 4
ui.button("Save"); // Response
ui.checkbox("Done", &mut done); // Response
ui.toggle("Enabled", &mut on); // Response
ui.slider("Vol", &mut value, 0.0..=100.0); // Response
// Builder widgets — chain optional config, render on Drop
ui.gauge(0.6).label("60%").width(24).color(Color::Cyan);
ui.line_gauge(0.6).filled('━').empty('─').width(24).label("60%");
let r = ui.breadcrumb(&segs).separator(" › ").color(Color::Cyan).show();
// Compound responses — Deref<Response> + extra fields
let r = ui.gauge(cpu).label("CPU").show(); // GaugeResponse { ratio: f64 }
let r = ui.breadcrumb(&segs).show(); // BreadcrumbResponse { clicked_segment: Option<usize> }
let r = ui.split_pane(&mut split, l, r); // SplitPaneResponse { ratio: f64 }
let r = ui.scrollable_with_gutter(&mut scroll, opts, body); // GutterResponse { current_highlight: Option<usize> }
Hook ordering — three variants
Hooks must be called in the same order every frame unless they are id-keyed.
| Hook | Key | Safe in if/match? |
Use when |
|---|---|---|---|
ui.use_state(|| init) |
call order | No | Top-level state, no conditional placement |
ui.use_state_named::<T>("id") |
&'static str |
Yes | Conditional/branching state with compile-time id |
ui.use_state_named_with("id", || init) |
&'static str |
Yes | Same, with explicit init fn |
ui.use_state_keyed("id-{i}", || init) |
runtime String |
Yes | Per-row state in a list (key from data) |
ui.use_state_keyed_default("id-{i}") |
runtime String |
Yes | Same, T: Default shortcut |
ui.use_memo(&deps, |d| compute(d)) |
call order + deps | No | Cached compute, deps change → recompute |
ui.use_effect(|d| { ... }, &deps) |
call order + deps | No | Side effect on deps change |
// WRONG — order-based hook in conditional drifts call order between frames
if expanded { let count = ui.use_state(|| 0); }
// RIGHT — id-keyed variant is safe inside conditionals
if expanded { let count = ui.use_state_named::<i32>("sidebar.count"); }
// Per-list-item runtime keys
for i in 0..items.len() {
let count = ui.use_state_keyed_default::<i32>(format!("counter-{i}"));
}
Context injection (provide / use_context)
Stop threading &theme, &tick, &mut toasts through every render fn. provide injects a typed value scoped to a closure; nested code reads it back with use_context.
struct AppCtx { theme: slt::Theme, tick: u64, user: &'static str }
slt::run(|ui| {
let ctx = AppCtx { theme: *ui.theme(), tick: ui.tick(), user: "subin" };
ui.provide(ctx, |ui| {
render_header(ui);
render_card(ui);
});
});
fn render_card(ui: &mut slt::Context) {
let ctx = ui.use_context::<AppCtx>(); // panics if missing
// let maybe = ui.try_use_context::<AppCtx>(); // returns Option<&T>
ui.text(format!("hi {} (tick {})", ctx.user, ctx.tick));
}
Reserve explicit parameters for writes (&mut MyDocState). Bound is T: 'static — use &'static str for literals, String for runtime values.
Conditional styling (with_if / with)
with_if(cond, modifier) and with(modifier) collapse conditional branches into a single chain. Available on text and ContainerBuilder. Beware: text uses &mut self -> &mut Self, ContainerBuilder uses consuming Self -> Self.
// text — closure receives &mut Self
ui.text("Status").with_if(is_error, |t| { t.bold().fg(Color::Red); });
// ContainerBuilder — closure receives Self by value
ui.container().with_if(is_focused, |c| c.bg(theme.surface_hover)).col(|ui| ...);
Custom widget pattern (Layer 3)
When to use which pattern:
- Function (
fn render_card(ui: &mut Context, data: &CardData)): 90% of cases. Use for screens, sections, reusable layouts. Cheaper to write, no trait bounds, easier to test. Built-in widgets follow this shape internally (impl Contextdirect methods). impl Widget: when the component (a) has its own state struct that the caller owns, and (b) you wantui.widget(&mut w)ergonomics matching built-ins. Required for third-party crates that export widgets through trait-bound APIs.
struct Label<'a> { text: &'a str }
impl<'a> slt::Widget for Label<'a> {
type Response = slt::Response;
fn ui(&mut self, ui: &mut slt::Context) -> Self::Response {
ui.register_focusable();
ui.text(self.text).bold();
slt::Response::default()
}
}
ui.add(Label { text: "hello" }); // or call .ui(ui) directly
For mouse hit-testing use ui.interaction(rect). For keyboard use register_focusable() + available_key_presses().
Authoring workflow
- Confirm the goal — what app? Data table? Dashboard? Form? Game?
- Check
examples/for the closest pattern (see Reference Examples below). Start from that file if it fits. - Grep
src/context/widgets_*andsrc/widgets/*for the actual signature before writingui.foo(...). Do NOT invent APIs. - Keep
Cargo.tomlfeatures minimal — see REFERENCES.md. - Run the quality gate before saying "done".
Quality gate (mandatory before saying "done")
Core — every commit:
cargo fmt -- --check
cargo check --all-features
cargo clippy --all-features -- -D warnings
cargo test --all-features
cargo check --examples --all-features
Extended — before PR or release:
typos
cargo check -p superlighttui --no-default-features
cargo check -p slt-wasm --target wasm32-unknown-unknown
cargo hack check -p superlighttui --each-feature --no-dev-deps
cargo audit
cargo deny check
Release workflow (mandatory — do not skip any step)
CLAUDE.md has the full 8-step checklist. Short version:
- Local PRE-CI (Core + Extended both green)
- Bump
Cargo.toml, updateCHANGELOG.md - Branch
release/vX.Y.Z, single atomic commit, push gh pr create, wait for CI green- Merge (squash), pull main
- Tag, push tag, wait for
release.ymlgreen - Verify
gh release view, crates.io, docs.rs - Only now announce
Red flags that mean STOP: "Probably fine", "Just a docs change", "CI will catch it", "I'll tag now and fix later". Run the gate locally first.
Common pitfalls (AI-generated SLT code)
- Inventing method names. Always grep
src/context/andsrc/widgets/first. - Stale removed APIs.
gauge_w,gauge_colored,line_gauge_with,breadcrumb_sep,LineGaugeOpts,HighlightRange::single,label_ownedare GONE in v0.20. Use the builder forms. Response.recton frame 1. ZeroRect. Guard withui.tick() > 0.use_state()insideif/match/for. Useuse_state_named(&'static strid) oruse_state_keyed(runtimeString).- Forgetting
.show()on builders that return a response. Drop renders and discards the response. Capture withlet r = ui.gauge(...).show();. 'staticonContainerBuilder::draw()closure. Raw draw is deferred; the closure must be'static.- Mixing crossterm raw events with
ui.*helpers. Preferui.key(),ui.key_code(),ui.key_mod(). For modal-aware shortcuts useui.raw_key_*. - Hard-coding
Color::Rgb(...)instead ofui.theme()— themes can't swap. RichLogState::new()for unbounded. New caps at 10000; useRichLogState::new_unbounded()if you really want unlimited.- First-frame hover/click tests. Render once to warm the prev-frame hit map, then send the event in a second
tb.render(...)call. - Binding only Ctrl-C as quit. macOS terminals intercept Ctrl-C as Copy. Always pair
q,Esc, andCtrl-Q. unsafeblocks.#![forbid(unsafe_code)]. Hard compile error.- Printing to stdout/stderr from a widget. A library must not write to stdout. Lints catch this.
Reference examples (skill should reference these by file:line)
| Domain | Reference file | Key shape |
|---|---|---|
| Hello / minimal | examples/hello.rs (21 lines) |
slt::run, bordered.title.col, quit triple |
| Counter (state in closure) | examples/counter.rs |
move-closure state pattern |
| Inline mode | examples/inline.rs (23 lines) |
run_inline(rows, ...) |
| Tabbed tour | examples/cookbook_tour.rs |
TabsState + child pub fn render(ui, &mut DemoState) |
| Form / validators | examples/cookbook_login.rs |
TextInputState::with_placeholder, masked password, validation |
| Searchable+sortable table | examples/cookbook_table.rs |
TableState::set_filter, toggle_sort, consume_key |
| Modal + Toast | examples/cookbook_modal_toast.rs |
ButtonVariant::Danger, raw_key_code(Esc) for modal-aware quit |
| Modal focus trap | examples/v020_modal_trap.rs |
ModalOptions { tab_trap: true } |
| File picker | examples/cookbook_file_picker.rs |
FilePickerState::selected_file() |
| Dashboard (chart+sparkline) | examples/cookbook_dashboard.rs |
ui.chart(|c| c.line(...).color()), rolling VecDeque<f64> |
| Animation primitives | examples/anim.rs |
Tween/Spring/Keyframes/Sequence/Stagger |
use_state_keyed |
examples/v020_use_state_keyed.rs |
per-row counter via format!("counter-{i}") |
use_effect |
examples/v020_use_effect.rs |
three dep shapes (&(), &i32, &bool) |
provide/use_context |
tests/context_provider.rs (100 lines), examples/demo_website.rs:139-152 |
injection + try_use_context |
| Theme subtree | examples/v020_theme_subtree.rs |
container().theme(theme) per-subtree override |
| Theme density | examples/v020_spacing_scale.rs |
Theme::compact() / comfortable() / spacious() |
| Gauge builder | examples/v020_gauge.rs:104-107 |
ui.gauge(value).label(...).width(24) |
| Line gauge | examples/v020_gauge.rs:74-92 |
ui.line_gauge(0.45).filled('#').empty('.').width(24) |
| Breadcrumb response | examples/v020_breadcrumb_response.rs:72-77 |
ui.breadcrumb(&segs).separator(" › ").show() |
| Scrollable + gutter | examples/v020_gutter_highlights.rs:150-165 |
GutterOpts::line_numbers, HighlightRange::line |
| Split pane | examples/v020_split_pane.rs |
split_pane, vsplit_pane, drag handle |
| WidthSpec variants | examples/v020_widthspec.rs |
Constraints::default().w_pct(50), .w_ratio(1,3), .w_minmax(10,30) |
| Named focus | examples/v020_named_focus.rs:187 |
register_focusable_named + focus_by_name |
| Keymap help overlay | examples/v020_keymap_help.rs |
WidgetKeyHelp, publish_keymap, keymap_help_overlay |
| DX shortcuts | examples/v020_dx_shortcuts.rs |
on_hover, animate_bool, fill(), Rect::center_in |
| Static log / scrollback | examples/v020_static_log.rs |
slt::run_static, ui.static_log(...) |
| Async demo | examples/async_demo.rs |
slt::run_async with tokio |
| All-in-one showcase | examples/v020_showcase.rs (277 lines) |
every major v0.20 feature |
| Test utilities | tests/v020_test_utils_demo.rs |
record_frames, sequence().tick().key().type_string() |
Testing pattern (headless)
use slt::{TestBackend, EventBuilder};
#[test]
fn my_widget_renders() {
let mut tb = TestBackend::new(80, 24);
tb.render(|ui| { ui.text("hello"); });
tb.assert_contains("hello");
}
// Multi-frame with events (warm prev-frame hit map first)
#[test]
fn click_triggers() {
let mut tb = TestBackend::new(40, 10);
tb.render(|ui| { ui.button("Save"); }); // warm frame
tb.run_with_events(vec![EventBuilder::mouse_down(2, 0)], // event frame
|ui| { if ui.button("Save").clicked { /* assert */ } });
}
// Sequence builder (most readable)
tb.sequence().tick(5).key(KeyCode::Tab, KeyModifiers::NONE).type_string("hello", &mut state.value).run();
See tests/v020_test_utils_demo.rs for record_frames, assert_not_contains, assert_style_at.
File layout cheat sheet
| Area | Primary files |
|---|---|
| Public API | src/lib.rs (re-exports) |
| Run loop / backend | src/terminal.rs, src/lib.rs (run, run_with, run_inline, run_async, run_static, frame, frame_owned) |
| Context core | src/context/{core,runtime,container,helpers,state}.rs |
| Widget impls | src/context/widgets_display/*, widgets_interactive/*, widgets_input/*, widgets_viz.rs |
| Layer 4 state types | src/widgets/*.rs |
| Compound responses | src/widgets/responses.rs (BreadcrumbResponse, GaugeResponse, SplitPaneResponse, GutterResponse) |
| Layout kernels | src/layout/{tree,flexbox,collect,render,command}.rs |
| Style / theme | src/style/{color,theme}.rs, src/style.rs |
| Animation | src/anim.rs |
| Charts | src/chart.rs, src/chart/*.rs, src/context/widgets_viz.rs |
| Testing helpers | src/test_utils.rs |
| Skill references | REFERENCES.md (feature flags, doc pointers) |
Korean conventions
- "ㄱㄱ" = "go go" → proceed immediately, no clarifying questions
- "켜줘" / "열어줘" → open the file in Cursor (NOT
catto terminal) - "고쳐줘" / "수정해줘" → fix with minimal change, run quality gate
- "리뷰해줘" → audit only, do not modify code unless explicitly asked