| 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 Context return a builder when ≥1 option exists. Builders chain &mut self -> &mut Self, render on Drop, expose .show() to capture a *Response.
ui.gauge(0.6).label("60%").width(24).color(Color::Cyan);
let r = ui.breadcrumb(&segs).separator(" › ").show();
Removed 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/returns f32. 0.5 is f64 natively, so ui.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)
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);
ui.tabs(&mut TabsState);
-
Responses. Single-rect widgets return Response. Compound widgets return <Widget>Response: Deref<Target = Response> with #[must_use]. Never tuples.
if ui.button("Save").clicked { ... }
if let Some(i) = ui.breadcrumb(&segs).show().clicked_segment { ... }
-
Return-type pattern. Methods on Context return 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();
if ui.button("Save").clicked { … }
ui.text("Saved").bold().fg(green);
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.
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);
ui.rich_log(&mut RichLogState); ui.command_palette(&mut CommandPaletteState);
ui.toast(&mut ToastState); ui.spinner(&SpinnerState);
ui.button("Save");
ui.checkbox("Done", &mut done);
ui.toggle("Enabled", &mut on);
ui.slider("Vol", &mut value, 0.0..=100.0);
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();
let r = ui.gauge(cpu).label("CPU").show();
let r = ui.breadcrumb(&segs).show();
let r = ui.split_pane(&mut split, l, r);
let r = ui.scrollable_with_gutter(&mut scroll, opts, body);
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 |
if expanded { let count = ui.use_state(|| 0); }
if expanded { let count = ui.use_state_named::<i32>("sidebar.count"); }
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>();
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.
ui.text("Status").with_if(is_error, |t| { t.bold().fg(Color::Red); });
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 Context direct methods).
impl Widget: when the component (a) has its own state struct that the caller owns, and (b) you want ui.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" });
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_* and src/widgets/* for the actual signature before writing ui.foo(...). Do NOT invent APIs.
- Keep
Cargo.toml features 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)
AGENTS.md has the full 8-step checklist. Short version:
- Local PRE-CI (Core + Extended both green)
- Bump
Cargo.toml, update CHANGELOG.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.yml green
- 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/ and src/widgets/ first.
- Stale removed APIs.
gauge_w, gauge_colored, line_gauge_with, breadcrumb_sep, LineGaugeOpts, HighlightRange::single, label_owned are GONE in v0.20. Use the builder forms.
Response.rect on frame 1. Zero Rect. Guard with ui.tick() > 0.
use_state() inside if/match/for. Use use_state_named (&'static str id) or use_state_keyed (runtime String).
- Forgetting
.show() on builders that return a response. Drop renders and discards the response. Capture with let r = ui.gauge(...).show();.
'static on ContainerBuilder::draw() closure. Raw draw is deferred; the closure must be 'static.
- Mixing crossterm raw events with
ui.* helpers. Prefer ui.key(), ui.key_code(), ui.key_mod(). For modal-aware shortcuts use ui.raw_key_*.
- Hard-coding
Color::Rgb(...) instead of ui.theme() — themes can't swap.
RichLogState::new() for unbounded. New caps at 10000; use RichLogState::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, and Ctrl-Q.
unsafe blocks. #![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");
}
#[test]
fn click_triggers() {
let mut tb = TestBackend::new(40, 10);
tb.render(|ui| { ui.button("Save"); });
tb.run_with_events(vec![EventBuilder::mouse_down(2, 0)],
|ui| { if ui.button("Save").clicked { } });
}
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
cat to terminal)
- "고쳐줘" / "수정해줘" → fix with minimal change, run quality gate
- "리뷰해줘" → audit only, do not modify code unless explicitly asked