slt-migration
Skill199 starsChanged 15 days agoMITView on GitHub
Migrate Rust TUIs from ratatui (or cursive, Python textual) to SuperLightTUI v0.26. Use when porting an existing TUI codebase to SLT, or when the user asks "how do I do X from ratatui in SLT". Korean triggers "ratatui 마이그레이션", "SLT로 포팅", "이걸 SLT로".
We split this skill into the rules it gives an agent
8 rules. About 0 are common, 2 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 10
Targets SLT v0.26.0: Every API name below must be verified against src/lib.rs and src/context/widgets/.
- Very specific: line 20
If starting fresh (no existing TUI), use the slt skill instead.
- Distinctive: line 29
If you see these in any third-party doc, blog post, or AI training data — they are GONE in v0.20.
- Very specific: line 104
Every SLT method below is verified against src/context/widgets/.
- Very specific: line 350
Use via .border_sides(...): ui.bordered(Border::Single).border_sides(BorderSides::horizontal()).col(...) (top + bottom).
- Very specific: line 441
Run
cargo checkand fix one widget at a time: Add tests with slt::TestBackend::new(80, 24).render(\|ui\| ...) once a section compiles. - Very specific: line 443
After everything compiles, run the full quality gate (project AGENTS.md): cargo fmt -- --check, cargo check --all-features, cargo clippy …
- Distinctive: line 455
If a feature genuinely doesn't map, tell the user — don't fake it.
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
SLT Migration Skill (from ratatui / cursive / textual)
This skill complements .agents/skills/slt/SKILL.md (authoring). Use this one to port an existing codebase. After migration switch to the slt skill for day-to-day work.
Targets SLT v0.26.0. Every API name below must be verified against src/lib.rs and src/context/widgets/. The v0.20 API consistency pass removed several v0.19 names; the historical table below remains relevant.
When to use
Trigger when:
- The user says "migrate from ratatui", "port from cursive", "ratatui equivalent in SLT", or "rewrite this TUI in SLT".
- A file in scope imports
ratatui,tui, orcursive, orCargo.tomllists them. - The user is comparing libraries and wants concrete mappings.
- A Python
textualproject is being rewritten in Rust.
If starting fresh (no existing TUI), use the slt skill instead.
Two rules that break naive ports
- The mouse is on by default; hover is opt-in. ratatui apps enable capture with
EnableMouseCapture(all motion). Since v0.26,slt::runturns the mouse on by itself (MouseMode::Draglocally,MouseMode::Clickover SSH/mosh), soResponse.clicked/scroll_deltaand drags work without configuration. If the original app reacted to plain pointer moves (hover), port toslt::run_with(RunConfig::default().mouse_mode(MouseMode::Hover), |ui| ...), orhoveredonly changes on clicks and drags. If the original never enabled capture and users rely on native terminal selection, useMouseMode::Off. - Shortcuts vs focused inputs. A ratatui
match key.code { Char('q') => quit, .. }dispatched before the focused input sees the key. In SLT,ui.key('q')/ui.shortcut(KeyCode::Char('q'), KeyModifiers::NONE)skip the keys a focusedtext_input/textareaedits with, in any call order, so port the dispatch as plain shortcut checks. Useui.raw_key(c)only when a key must fire even while typing.
v0.20 removed APIs (do NOT migrate to these)
If you see these in any third-party doc, blog post, or AI training data — they are GONE in v0.20. Use the replacement.
| Removed | Replacement |
gauge_w(r, w) | ui.gauge(r).width(w) |
gauge_colored(r, c) | ui.gauge(r).color(c) |
line_gauge_with(r, opts) | ui.line_gauge(r).<chain> |
breadcrumb_sep(b, s) | ui.breadcrumb(b).separator(s) |
breadcrumb_response(b) / breadcrumb_response_with(b, s) | ui.breadcrumb(b).show() -> BreadcrumbResponse |
LineGaugeOpts | LineGauge<'_> builder (chained) |
HighlightRange::single(i) | HighlightRange::line(i) |
label_owned(s) | label(s) (accepts impl Into<String>) |
Mental model translation
Compact side-by-side — read once before writing any code.
| Aspect | ratatui | SLT |
| Loop ownership | You own it: terminal.draw(|f| ...) | slt::run(|ui| ...) owns it |
| Layout | Layout::default().constraints(...).split(area) → Vec<Rect> | ui.row / ui.col / ui.bordered(...).col(...) (flexbox) |
| Widget API | Build a widget value, then f.render_widget(widget, rect) | Method on &mut Context: ui.text(...), ui.list(&mut state) |
| State | App struct outside the closure | Plain Rust variables outside the closure (same idiom) |
| Render mode | Retained mental model — recompute every draw | Immediate — describe every frame |
| Hit testing | Manual Rect math | Response { clicked, right_clicked, hovered, focused, gained_focus, lost_focus, rect } |
| Setup / teardown | enable_raw_mode, EnterAlternateScreen, panic hook by you | All handled by slt::run (incl. terminal-restoring panic hook) |
| Threading shared state | &mut App parameter chain | ui.provide(value, |ui| ...) + ui.use_context::<T>() |
cursive: callback-based — siv.add_global_callback, layered views, owns its loop. SLT has no callbacks; check inputs and branch in the closure.
textual (Python): retained App+Widget classes, CSS-like styling, compose() yields widgets, on_* event handlers. SLT replaces all of that with one closure and chained method calls.
ratatui → SLT mapping
Run loop
ratatui (typical):
<!-- slt-check-skip: ratatui code shown for comparison; not SLT -->let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
terminal::enable_raw_mode()?;
crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
loop {
terminal.draw(|f| ui(f, &mut app))?;
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Char('q') { break; }
// dispatch to app
}
}
crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal::disable_raw_mode()?;SLT:
<!-- slt-check: {"name":"migration_run_loop","features":["crossterm"],"context":"module","setup":"#[derive(Default)] struct App { saves: u32 } fn render(ui: &mut Context, app: &mut App) { if ui.button(\"Save\").clicked { app.saves += 1; } }"} -->fn main() -> std::io::Result<()> {
let mut app = App::default();
// Clicks, wheel and drags are on by default. ratatui's EnableMouseCapture
// also reported every pointer move; MouseMode::Hover is the equivalent.
slt::run_with(slt::RunConfig::default().mouse_mode(slt::MouseMode::Hover), |ui| {
if ui.key('q') || ui.key_code(KeyCode::Esc) { ui.quit(); }
render(ui, &mut app);
})
}slt::run enters alternate screen, enables raw mode, installs a panic hook restoring terminal state, and tears down on exit. Variants:
slt::run_with(RunConfig::default().mouse_mode(MouseMode::Hover).theme(Theme::dark()), \|ui\| ...)— hover reporting, custom themeslt::run_inline(rows, \|ui\| ...)— render below the prompt, no alt screenslt::run_static(&mut output, dynamic_rows, \|ui\| ...)— inline TUI below append-only scrollback;outputis aStaticOutput(output.println(..)before the run,ui.static_log(..)inside it)slt::run_async::<Message>(\|ui, messages\| ...)— tokio integration (asyncfeature)
Widget mapping (top ratatui widgets → SLT)
Every SLT method below is verified against src/context/widgets/.
| ratatui | SLT |
Block::default().borders(Borders::ALL).title("X") | ui.bordered(Border::Rounded).title("X").col(|ui| ...) |
Block::default().borders(Borders::ALL).border_type(BorderType::Rounded) | ui.bordered(Border::Rounded).col(...) |
Block::default().borders(Borders::TOP | Borders::BOTTOM) | ui.bordered(Border::Single).border_sides(BorderSides::horizontal()).col(...) |
Paragraph::new("text") | ui.text("text") |
Paragraph::new("text").wrap(Wrap { trim: true }) | ui.text("text").wrap() |
Paragraph::new("text").alignment(Alignment::Center) | ui.text("text").text_center() |
List::new(items).highlight_style(...) | ui.list(&mut ListState) — items via ListState::set_items(...) |
Table::new(rows).widths(&[...]) | ui.table(&mut TableState) — rows via TableState::set_rows(...), auto column widths |
Tabs::new(titles).select(idx) | ui.tabs(&mut TabsState) — labels via TabsState::new(vec!["Files", "Settings"]) |
Gauge::default().percent(75).label("75%") | ui.gauge(0.75).label("75%").width(24).color(Color::Green) (v0.20 builder) |
Gauge::default().percent(75) (no label) | ui.gauge(0.75) or ui.progress(0.75) |
LineGauge::default().ratio(0.6).label("Memory") | ui.line_gauge(0.6).label("Memory").filled('━').empty('─').width(48) |
BarChart::default().data(&[("a", 1), ("b", 2)]) | ui.bar_chart(&[("a", 1.0), ("b", 2.0)], max_width) (values are f64) |
Chart::new(datasets) | ui.chart(|c| { c.line(&data).color(Color::Cyan).label("X"); }, width, height) (closure first; data: &[(f64, f64)]) |
Sparkline::default().data(&[1, 2, 3]) | ui.sparkline(&[1.0, 2.0, 3.0], width) |
Span::styled("x", Style::default().fg(Color::Red)) | ui.text("x").fg(Color::Red) |
Line::from(vec![span1, span2]) | ui.row(|ui| { ui.text("a"); ui.text("b"); }) or ui.line(|ui| ...) |
Clear widget | usually unneeded — render at top-level or use ui.modal(|ui| ...) for overlays |
SLT-only widgets (no ratatui equivalent — use these on the migration target)
These are SLT additions; if your ratatui app hand-rolled them, replace with the built-in:
| Pattern | SLT |
| Scrollable code/log with line numbers | ui.scrollable_with_gutter(&mut scroll, GutterOpts::line_numbers(total, vp_h), |ui, abs| { ui.text(lines[abs]); }) |
| Search highlights in scrollable | ScrollState::set_highlights(&[HighlightRange::line(7), HighlightRange::span(15, 3)]) + state.highlight_next/previous |
| Two-pane resizable layout | ui.split_pane(&mut SplitPaneState::new(0.5), |ui| ..., |ui| ...) (horizontal) or ui.vsplit_pane(...) (vertical) |
| Breadcrumb with click feedback | let r = ui.breadcrumb(&segs).separator(" › ").show(); if let Some(i) = r.clicked_segment { ... } |
| Tooltips | if ui.button("X").on_hover(ui, "Save").clicked { ... } |
| Animated value | let alpha = ui.animate_value("fade", target, 30); (eased, 1-line) |
| Modal with focus trap (WCAG) | ui.modal(|ui| ...) (traps by default since 0.26); ui.modal_with(slt::ModalOpts::new().anchor(..), |ui| ...) to pin it |
| Scroll that follows Tab focus | ui.scrollable(&mut scroll).col(...) — automatic since v0.25; opt out with scroll.follow_focus = false |
| Form field with geometry | let r = ui.form_field_response(&mut field); → FormFieldResponse { response, input, layout_rect } |
| Per-subtree theme override | ui.container().theme(custom_theme).col(|ui| ...) |
| Theme density | Theme::compact() / comfortable() / spacious() instead of manual spacing |
Custom widgets — Layer 3
ratatui custom Widget trait impls don't translate directly. Two options:
Option A — function (preferred for one-off widgets):
<!-- slt-check: {"name":"migration_widget_fn","features":[],"context":"module","setup":"struct MyData { title: String }"} -->fn render_my_widget(ui: &mut Context, data: &MyData) { ui.text(&data.title).bold(); }Option B — implement SLT's Widget trait (for reusable libraries):
<!-- slt-check: {"name":"migration_widget_trait","features":[],"context":"module"} -->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()
}
}
fn render(ui: &mut slt::Context) {
let mut label = Label { text: "hello" };
ui.widget(&mut label);
}Note: SLT's Widget trait is different from ratatui's. ratatui's Widget::render(self, area, buf) is a stateless paint into a Buffer. SLT's Widget::ui(&mut self, ui) is an immediate-mode call that returns a Response.
Incremental path — keep ratatui widgets (ratatui feature, v0.26+)
When a rewrite is too big to do at once, or a third-party ratatui widget has no SLT equivalent, enable superlighttui = { version = "0.26", features = ["ratatui"] } and render the ratatui widget inside an SLT container. The feature depends only on ratatui-core 0.1 (ratatui 0.30), not on a ratatui backend, and is not part of full.
<!-- slt-check-skip: needs the external ratatui-widgets crate and captured app values -->use ratatui_widgets::{block::Block, paragraph::Paragraph}; // or `ratatui::widgets`
ui.container().h(5).ratatui_widget(Paragraph::new(text.clone()).block(Block::bordered()));
ui.container().grow(1).ratatui(move |area, buf| sparkline.render(area, buf));The closure is deferred ('static): move owned data in. It is render-only — input, focus and StatefulWidget state stay in SLT code. slt::Color / Modifiers / Style convert to and from the ratatui types with From; see src/ratatui_compat.rs for the lossy cases (blink variants, HIDDEN, OVERLINE). Port these regions to native SLT widgets later, one at a time.
Layout mapping
ratatui:
<!-- slt-check-skip: ratatui code shown for comparison; not SLT -->let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
.split(area);
f.render_widget(header, chunks[0]);
f.render_widget(body, chunks[1]);
f.render_widget(footer, chunks[2]);SLT:
<!-- slt-check: {"name":"migration_layout","features":[],"context":"ui"} -->ui.col(|ui| {
ui.container().h(3).col(|ui| { ui.text("header"); });
ui.container().fill().col(|ui| { ui.text("body"); }); // fills remaining (fill() == grow(1))
ui.container().h(1).col(|ui| { ui.text("footer"); });
});Constraint translation:
| ratatui | SLT |
Constraint::Length(3) | .h(3) (col) or .w(3) (row) |
Constraint::Min(0) | .fill() (v0.20) or .grow(1) |
Constraint::Min(n) | .min_h(n).fill() (col) or .min_w(n).fill() (row) |
Constraint::Max(n) | .max_h(n) or .max_w(n) |
Constraint::Percentage(50) | .h_pct(50) or .w_pct(50) |
Constraint::Ratio(1, 3) | .h_ratio(1, 3) (col) or .w_ratio(1, 3) (row), directly on ContainerBuilder; also on Constraints |
.margin(1) on Layout | .p(1) on parent container |
.spacing(1) between chunks | .gap(1) on parent row / col |
Constraints value type also exposes the v0.20 WidthSpec set: Constraints::default().w_pct(50), .w_ratio(1, 3), .w_minmax(10, 30) — see examples/widthspec.rs. Containers take the ratio directly too: ui.container().w_ratio(1, 3).col(..) (and h_ratio), like ratatui's Constraint::Ratio(1, 3).
State mapping (re-exported via slt::*)
use slt::prelude::*; imports the common ones below plus Context, run / run_with, the style types and KeyCode in one line (no crossterm re-export, so it never shadows your own crossterm dependency during a port).
| ratatui / your code | SLT |
ListState | slt::ListState (ListState::new(items), set_items, set_filter, selected_item, selected_index, remove_selected, visible_indices) |
TableState | slt::TableState (set_rows, toggle_sort, sort_by, set_filter, next_page, prev_page) |
TabsState | slt::TabsState (new(vec!["Files", "Settings"]), selected_label) |
ScrollbarState / manual offset | slt::ScrollState + ui.scrollable(&mut state).col(...) or ui.scrollable_with_gutter(...) |
your own input: String | slt::TextInputState::with_placeholder("…") (validators via add_validator) |
your own textarea: Vec<String> | slt::TextareaState::default().word_wrap(80) |
| your own selection set | slt::SelectState, slt::RadioState, slt::MultiSelectState |
| tree view | slt::TreeState, slt::DirectoryTreeState::from_paths(...) |
| modal flag | slt::Context::modal(|ui| ...) or modal_with(ModalOpts::new(), ...) |
| toast queue | slt::ToastState::default() + ui.toast(&mut toasts), or ui.notify(msg, ToastLevel::Info) for the built-in queue |
| log/history view | slt::RichLogState::new() (capped at 10000) or RichLogState::new_unbounded() |
Threading state (CRITICAL for ratatui apps)
ratatui apps typically thread &mut App (or &App) through every render fn:
<!-- slt-check-skip: ratatui code shown for comparison; not SLT -->fn render(f: &mut Frame, app: &mut App) {
render_header(f, app);
render_body(f, app);
render_footer(f, app);
}In SLT v0.19+ replace read-only sharing with provide/use_context:
<!-- slt-check: {"name":"migration_provide_context","features":["crossterm"],"context":"module","setup":"#[derive(Clone, Default)] struct Settings; #[derive(Default)] struct App { settings: Settings, doc: TextareaState } fn render_body(ui: &mut Context, doc: &mut TextareaState) { let _ = ui.textarea(doc, 5); } fn render_footer(ui: &mut Context) { ui.text(\"q quit\"); }"} -->struct AppCtx { theme: slt::Theme, tick: u64, settings: Settings }
fn main() -> std::io::Result<()> {
let mut app = App::default();
slt::run(|ui| {
let ctx = AppCtx { theme: *ui.theme(), tick: ui.tick(), settings: app.settings.clone() };
ui.provide(ctx, |ui| {
render_header(ui);
render_body(ui, &mut app.doc); // writes still pass &mut explicitly
render_footer(ui);
});
})
}
fn render_header(ui: &mut slt::Context) {
let tick = ui.use_context::<AppCtx>().tick; // copy out before the next `ui` call
ui.text(format!("tick {tick}"));
}Reserve explicit &mut parameters for writes (&mut MyDocState).
Event mapping
| ratatui | SLT |
KeyCode::Char('q') match | if ui.key('q') { ... } |
KeyCode::Esc match | if ui.key_code(KeyCode::Esc) { ... } |
KeyModifiers::CONTROL + Char('c') | if ui.key_mod('c', KeyModifiers::CONTROL) { ... } (Ctrl-C is also auto-handled by slt::run) |
EnableMouseCapture | Nothing for clicks, wheel and drags (on by default); RunConfig::default().mouse_mode(MouseMode::Hover) for MouseEventKind::Moved / hover |
MouseEventKind::Down(MouseButton::Left) | if let Some((x, y)) = ui.mouse_down() { ... } or Response.clicked |
MouseEventKind::Down(MouseButton::Right) | Response.right_clicked (v0.20) |
MouseEventKind::ScrollUp | if ui.scroll_up() { ... } |
| Manual hit test | if ui.button("X").clicked { ... } (Response.clicked is a public field) |
| Focus events | Response.gained_focus / Response.lost_focus (v0.20) |
paste event | if let Some(s) = ui.paste() { ... } |
| Sequence detection | if ui.key_chord("gg") { ... } |
Modal-aware: ui.key(), ui.key_code(), ui.key_mod() are filtered when a modal is open, and skip the keys a focused text field edits with. For global shortcuts that bypass both, use ui.raw_key() / ui.raw_key_code() / ui.raw_key_mod().
Consume: ui.consume_key(c) / ui.consume_key_code(code) mark events handled so child widgets don't re-process. They never claim keys a focused text field edits with.
Style mapping
| ratatui | SLT |
Style::default().fg(Color::Red) | Style::new().fg(Color::Red) |
Style::default().add_modifier(Modifier::BOLD) | Style::new().bold() |
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) | Style::new().fg(Color::Red).bold() |
Style::default().bg(Color::Blue) | Style::new().bg(Color::Blue) |
Per-text styling: Span::styled("x", style) | Chain on the call: ui.text("x").fg(Color::Red).bold() |
Modifier::DIM / ITALIC / UNDERLINED / REVERSED / CROSSED_OUT | .dim() / .italic() / .underline() / .reversed() / .strikethrough() |
| Conditional styling | ui.text("x").with_if(is_error, |t| { t.bold().fg(Color::Red); }) (v0.19+) |
Style is Copy in both libraries — no clone needed.
Color mapping
Both libraries have:
- 16 named colors:
Red,Green,Blue,Yellow,Cyan,Magenta,Black,White, plusLightRed,LightGreen, etc. - 256-color:
Color::Indexed(N)↔Color::Indexed(N) - 24-bit:
Color::Rgb(r, g, b)↔Color::Rgb(r, g, b) Color::Resetin both
Differences:
- Gray/white names are shifted by one ANSI slot. ratatui
Color::Gray(index 7) is SLTColor::White, ratatuiColor::White(index 15) is SLTColor::LightWhite, andColor::DarkGray(index 8) is the same in both. Theratatuifeature'sFromimpls apply this mapping (src/ratatui_compat.rs); a literalColor::Whiteleft in ported code renders one shade darker than before. - For semantic colors prefer
slt::palette::tailwind::*(BLUE.c500,RED.c700) — same 11-shade scale across 22 palettes, identical to Tailwind CSS.
Border type mapping
ratatui BorderType | SLT Border |
BorderType::Plain | Border::Single |
BorderType::Rounded | Border::Rounded |
BorderType::Double | Border::Double |
BorderType::Thick | Border::Thick |
BorderType::QuadrantInside / QuadrantOutside | no direct equivalent — use a dashed style or custom draw |
ratatui Borders | SLT BorderSides |
Borders::ALL | default — ui.bordered(Border::Rounded) draws all 4 |
Borders::TOP | BorderSides::top() |
Borders::BOTTOM | BorderSides::bottom() |
Borders::LEFT / RIGHT | BorderSides::left() / right() |
Borders::TOP | Borders::BOTTOM | BorderSides::horizontal() |
Borders::LEFT | Borders::RIGHT | BorderSides::vertical() |
Use via .border_sides(...): ui.bordered(Border::Single).border_sides(BorderSides::horizontal()).col(...) (top + bottom).
Theme
ratatui has no built-in theme. If you have ad-hoc Color::* constants, replace with slt::Theme and ui.color(ThemeColor::Primary) so themes can swap. Built-ins:
Theme::dark()/light()(base)Theme::compact()/comfortable()/spacious()(density variants of dark)Theme::dracula()/nord()/tokyo_night()/gruvbox_dark()/one_dark()/one_light()/catppuccin()/solarized_dark()/solarized_light()Theme::builder_from(Theme::nord())— extend a preset (returnsThemeBuilder)ContainerBuilder::theme(custom)— per-subtree override
cursive → SLT mapping
cursive is callback-driven. SLT replaces both pattern and event loop with the imperative closure model.
| cursive | SLT |
Cursive::default().run() | slt::run(|ui| { ... }) |
siv.add_global_callback(Key::Esc, |s| s.quit()) | if ui.key_code(KeyCode::Esc) { ui.quit(); } |
TextView::new("hello") | ui.text("hello") |
EditView::new() | ui.text_input(&mut TextInputState) |
SelectView::new().item("a", 0).item("b", 1) | ui.select(&mut SelectState) |
Dialog::around(view).button("OK", |s| ...) | ui.modal(|ui| { ui.text(...); if ui.button("OK").clicked { ... } }) |
LinearLayout::vertical() | ui.col(|ui| ...) |
LinearLayout::horizontal() | ui.row(|ui| ...) |
siv.add_layer(view) | render at top-level; for overlays use ui.modal(...) / ui.overlay_at(anchor, |ui| ...) |
Cursive::set_user_data(state) | plain Rust variable outside the closure, captured by reference |
Mental shift: cursive callbacks fire on input. SLT's closure runs every frame. State updates are visible immediately.
textual (Python) → SLT mapping
textual is class-based with reactive state and CSS. SLT is functional with plain variables.
| textual | SLT |
class App(App) with compose() | slt::run(|ui| { ... }) closure |
reactive attributes | plain Rust variables outside the closure |
| CSS-like styling | ThemeBuilder + per-widget chains (.fg(Color::Red).bold()) |
Static("hello") | ui.text("hello") |
Button("Click") + on_button_pressed | if ui.button("Click").clicked { ... } inline |
Input(placeholder="...") | ui.text_input(&mut TextInputState::with_placeholder("...")) |
DataTable | ui.table(&mut TableState) |
ScrollableContainer | ui.scrollable(&mut ScrollState).col(|ui| ...) or scrollable_with_gutter |
Container(...) | ui.bordered(...).col(...) or ui.container().col(...) |
compose() yielding child widgets | the closure body — order is layout order |
| Async event handlers | slt::run_async (async feature) returns an owned AsyncRunHandle; use .sender() and .join() / .cancel_and_join() |
| CSS animations | slt::Tween / slt::Spring / slt::Keyframes / ui.animate_value("id", target, ticks) |
Common migration pitfalls
- "I have a struct that implements
Widgettrait." — drop ratatui's. Either rewrite asfn render_my_widget(ui: &mut Context, data: &MyData), or implementslt::Widget(different shape — see Custom widgets section). - "My App has a
draw(&mut self, frame: &mut Frame)method." — convert tofn render(ui: &mut Context, app: &mut App)and call fromslt::run(\|ui\| render(ui, &mut app)). - "ratatui
ListStatelives across frames." — same in SLT.let mut list = ListState::new(items);outside the closure; pass&mut listtoui.list(&mut list)each frame. Read the selection withlist.selected_item()(Option<&str>) orlist.selected_index()(Option<usize>, the data index intoitemsto pass toremove_item/item); theselectedfield is deprecated since 0.26 because it holds the filtered-view position, not the data index. - "I want raw crossterm events." — prefer
ui.key()/ui.key_code()/ui.key_mod()/ui.mouse_down()/ui.scroll_up(). Rawui.events()is for advanced cases (key release, paste handling, custom modifier matching). For modal-aware globals useui.raw_key_code()/ui.raw_key_mod(). - "I have heavy custom layout math (
.split()arithmetic onRect)." — tryui.row/ui.col+.fill / .h / .w / .h_pct / .w_pct / .align / .justifyfirst. Flexbox handles 95% of cases. Drop toui.container().draw(\|buf, rect\| { ... })only when flexbox can't express it. Thedrawclosure must be'static(deferred execution). - "
Constraint::Percentage(50)is everywhere." —.w_pct(50)(row child) or.h_pct(50)(col child). Both takeu8. - "I use
Layout::default().margin(1).split(area)." —.p(1)on the parent container. - "I check
Response.rectimmediately." — SLT layout runs after the closure. Frame 1 returns zeroRect. Guard withif ui.tick() > 0 { ... }. - "
Borders::ALL." — SLT default.ui.bordered(Border::Rounded)draws all 4 sides. Subset via.border_sides(BorderSides::horizontal())(top + bottom) orvertical()(left + right). - "I want
Color::Gray." — SLT has noGray; ratatui'sGrayis ANSI 7, which is SLTColor::White. Port ratatuiWhitetoColor::LightWhite(ANSI 15). For a theme-independent mid-gray usepalette::tailwind::SLATE.c500. - "My ratatui app calls
terminal.clear()between frames." — don't. SLT diffs the buffer and only emits changed cells. Manual clear breaks the diff and causes flicker. - "My panic hook restores raw mode." — drop it.
slt::runinstalls one on first call. - "I'm threading
&Appthrough every render fn." — replace read-only sharing withui.provide(ctx, \|ui\| ...)+ui.use_context::<AppCtx>(). Keep&mutfor writes. - "My ratatui Gauge has no label." — use
ui.gauge(0.75)(no label) orui.progress(0.75)(aProgressBarbuilder that derefs toResponse; chain.color(c)). - "Hover does nothing." — the default mouse mode reports clicks, wheel and drags only:
slt::run_with(RunConfig::default().mouse_mode(MouseMode::Hover), ...). If clicks do nothing either, look for.mouse(false)/MouseMode::Off. - "Typing
qin a search box quits." — the shortcut usesraw_key/raw_events(), which ignore text focus. Useui.key('q')orui.shortcut(..); they skip keys a focused input edits with.
Migration workflow
- Inventory ratatui widgets used.
Map each to an SLT method via the tables above.grep -rn "render_widget\|f\.render_widget" src/ grep -rn "Block::\|Paragraph::\|List::\|Table::\|Tabs::\|Gauge::\|BarChart::\|Chart::\|Sparkline::" src/ - Convert the run loop. Replace
Terminal::newsetup + draw loop +disable_raw_modeteardown with one ofslt::run,slt::run_with,slt::run_inline, orslt::run_async. If the original relied on hover (MouseEventKind::Moved), userun_with(RunConfig::default().mouse_mode(MouseMode::Hover), ...); clicks and wheel need nothing. - Move state out of the draw closure. Most ratatui apps already do this. Keep the same shape — your
Appstruct now feeds into one SLT closure. - Replace layout splitters. Each
Layout::default().constraints(...).split(area)becomes nestedui.row/ui.col+.fill / .h / .w / .h_pct / .w_pct / .align / .justify..gap(n)instead of.spacing(n),.p(n)instead of.margin(n). - Replace each
f.render_widget(...)with the SLT method. Convert widget by widget. Verify any uncertain method via the mapping table or grepsrc/context/widgets/. - Adopt current builders. Where the old code hand-rolled gauges, breadcrumbs, scrollable-with-line-numbers, or split panes, use the current builders/opts directly. They handle hit-testing and accessibility.
- Replace event handling. Convert raw
crossterm::event::read()matches toui.key(),ui.key_code(),ui.key_mod(),ui.mouse_down(),ui.scroll_up(). Drop manual hit-testing in favor ofResponse.clicked/right_clicked/hovered/gained_focus/lost_focus. - Replace
&Appthreading withprovide/use_contextfor read-only shared state. - Run
cargo checkand fix one widget at a time. Add tests withslt::TestBackend::new(80, 24).render(\|ui\| ...)once a section compiles.
After everything compiles, run the full quality gate (project AGENTS.md):
cargo fmt -- --check, cargo check --all-features, cargo clippy --all-features -- -D warnings, cargo test --all-features, cargo check --examples --all-features.
Things SLT doesn't have a direct equivalent for
Be honest with the user — these need workarounds:
- ratatui
Canvasbraille drawing primitive. SLT hasui.canvas(width, height, \|cv\| { cv.line(...); cv.circle(...); })but the API takes aCanvasContextclosure, not a value-typed widget. Custom point/line drawing logic needs a small rewrite. SeeContext::canvasinsrc/context/widgets/viz/canvas.rs. - ratatui
Wrap { trim: true }exact semantics. SLT wraps via container width and.wrap()on text; trim-leading-whitespace behavior isn't identical. Test wrap-heavy text manually. - cursive's deep view layering / multiple modal stacks. SLT supports
ui.modal(...)/ui.modal_with(...)/ui.overlay_at(anchor, \|ui\| ...)but not arbitrary nested view managers. Most uses fold intoif state.show_modal { ui.modal(\|ui\| ...) }. - textual's CSS/TCSS hot reload. SLT does not hot-reload layout or arbitrary style code. The
theme-watchfeature can reload TOML themes throughThemeWatcher; usecargo watch -x runfor code and layout iteration.
If a feature genuinely doesn't map, tell the user — don't fake it.
References
.agents/skills/slt/SKILL.md— SLT authoring skill (use after migration is done)..agents/skills/slt/REFERENCES.md— feature flags, current surface, doc pointers.examples/gauge.rs,examples/breadcrumb_response.rs,examples/gutter_highlights.rs,examples/split_pane.rs, etc. — single-feature demos, compiled and run throughcargo run --example widgets_tour.tests/hooks_focus.rs,tests/widgets.rs,tests/example_snapshots/— regression tests showing canonical TestBackend + sequence patterns.src/lib.rs— authoritative public re-exports.src/context/widgets/— authoritative widget signatures.- ratatui repo: https://github.com/ratatui-org/ratatui
- cursive repo: https://github.com/gyscos/cursive
- textual repo: https://github.com/Textualize/textual
In this repository5 other files it gives its agents.
- AGENTS.mdAGENTS.md
- docs/llms.txtllms.txt
- slt-migrationSkill
- sltSkill
- sltSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

