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, or cursive, or Cargo.toml lists them.
- The user is comparing libraries and wants concrete mappings.
- A Python
textual project 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::run turns the mouse on by itself (MouseMode::Drag locally, MouseMode::Click over SSH/mosh), so Response.clicked / scroll_delta and drags work without configuration. If the original app reacted to plain pointer moves (hover), port to slt::run_with(RunConfig::default().mouse_mode(MouseMode::Hover), |ui| ...), or hovered only changes on clicks and drags. If the original never enabled capture and users rely on native terminal selection, use MouseMode::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 focused text_input / textarea edits with, in any call order, so port the dispatch as plain shortcut checks. Use ui.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 theme
slt::run_inline(rows, \|ui\| ...) — render below the prompt, no alt screen
slt::run_static(&mut output, dynamic_rows, \|ui\| ...) — inline TUI below append-only scrollback; output is a StaticOutput (output.println(..) before the run, ui.static_log(..) inside it)
slt::run_async::<Message>(\|ui, messages\| ...) — tokio integration (async feature)
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, plus LightRed, LightGreen, etc.
- 256-color:
Color::Indexed(N) ↔ Color::Indexed(N)
- 24-bit:
Color::Rgb(r, g, b) ↔ Color::Rgb(r, g, b)
Color::Reset in both
Differences:
- Gray/white names are shifted by one ANSI slot. ratatui
Color::Gray (index 7) is SLT Color::White, ratatui Color::White (index 15) is SLT Color::LightWhite, and Color::DarkGray (index 8) is the same in both. The ratatui feature's From impls apply this mapping (src/ratatui_compat.rs); a literal Color::White left 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 (returns ThemeBuilder)
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
Widget trait." — drop ratatui's. Either rewrite as fn render_my_widget(ui: &mut Context, data: &MyData), or implement slt::Widget (different shape — see Custom widgets section).
- "My App has a
draw(&mut self, frame: &mut Frame) method." — convert to fn render(ui: &mut Context, app: &mut App) and call from slt::run(\|ui\| render(ui, &mut app)).
- "ratatui
ListState lives across frames." — same in SLT. let mut list = ListState::new(items); outside the closure; pass &mut list to ui.list(&mut list) each frame. Read the selection with list.selected_item() (Option<&str>) or list.selected_index() (Option<usize>, the data index into items to pass to remove_item / item); the selected field 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(). Raw ui.events() is for advanced cases (key release, paste handling, custom modifier matching). For modal-aware globals use ui.raw_key_code() / ui.raw_key_mod().
- "I have heavy custom layout math (
.split() arithmetic on Rect)." — try ui.row / ui.col + .fill / .h / .w / .h_pct / .w_pct / .align / .justify first. Flexbox handles 95% of cases. Drop to ui.container().draw(\|buf, rect\| { ... }) only when flexbox can't express it. The draw closure must be 'static (deferred execution).
- "
Constraint::Percentage(50) is everywhere." — .w_pct(50) (row child) or .h_pct(50) (col child). Both take u8.
- "I use
Layout::default().margin(1).split(area)." — .p(1) on the parent container.
- "I check
Response.rect immediately." — SLT layout runs after the closure. Frame 1 returns zero Rect. Guard with if ui.tick() > 0 { ... }.
- "
Borders::ALL." — SLT default. ui.bordered(Border::Rounded) draws all 4 sides. Subset via .border_sides(BorderSides::horizontal()) (top + bottom) or vertical() (left + right).
- "I want
Color::Gray." — SLT has no Gray; ratatui's Gray is ANSI 7, which is SLT Color::White. Port ratatui White to Color::LightWhite (ANSI 15). For a theme-independent mid-gray use palette::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::run installs one on first call.
- "I'm threading
&App through every render fn." — replace read-only sharing with ui.provide(ctx, \|ui\| ...) + ui.use_context::<AppCtx>(). Keep &mut for writes.
- "My ratatui Gauge has no label." — use
ui.gauge(0.75) (no label) or ui.progress(0.75) (a ProgressBar builder that derefs to Response; 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
q in a search box quits." — the shortcut uses raw_key / raw_events(), which ignore text focus. Use ui.key('q') or ui.shortcut(..); they skip keys a focused input edits with.
Migration workflow
-
Inventory ratatui widgets used.
grep -rn "render_widget\|f\.render_widget" src/
grep -rn "Block::\|Paragraph::\|List::\|Table::\|Tabs::\|Gauge::\|BarChart::\|Chart::\|Sparkline::" src/
Map each to an SLT method via the tables above.
-
Convert the run loop. Replace Terminal::new setup + draw loop + disable_raw_mode teardown with one of slt::run, slt::run_with, slt::run_inline, or slt::run_async. If the original relied on hover (MouseEventKind::Moved), use run_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 App struct now feeds into one SLT closure.
-
Replace layout splitters. Each Layout::default().constraints(...).split(area) becomes nested ui.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 grep src/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 to ui.key(), ui.key_code(), ui.key_mod(), ui.mouse_down(), ui.scroll_up(). Drop manual hit-testing in favor of Response.clicked / right_clicked / hovered / gained_focus / lost_focus.
-
Replace &App threading with provide/use_context for read-only shared state.
-
Run cargo check and fix one widget at a time. Add tests with slt::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
Canvas braille drawing primitive. SLT has ui.canvas(width, height, \|cv\| { cv.line(...); cv.circle(...); }) but the API takes a CanvasContext closure, not a value-typed widget. Custom point/line drawing logic needs a small rewrite. See Context::canvas in src/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 into if state.show_modal { ui.modal(\|ui\| ...) }.
- textual's CSS/TCSS hot reload. SLT does not hot-reload layout or arbitrary style code. The
theme-watch feature can reload TOML themes through ThemeWatcher; use cargo watch -x run for 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 through cargo 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