본문으로 건너뛰기
cccskills
무료GitHub에 공개

slt-migration

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로".

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

설치 방법 보기

포함된 파일(1)

  • SKILL.md30.8 KB

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

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

  1. 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.
  2. 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.

RemovedReplacement
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
LineGaugeOptsLineGauge<'_> 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.

AspectratatuiSLT
Loop ownershipYou own it: terminal.draw(|f| ...)slt::run(|ui| ...) owns it
LayoutLayout::default().constraints(...).split(area) → Vec<Rect>ui.row / ui.col / ui.bordered(...).col(...) (flexbox)
Widget APIBuild a widget value, then f.render_widget(widget, rect)Method on &mut Context: ui.text(...), ui.list(&mut state)
StateApp struct outside the closurePlain Rust variables outside the closure (same idiom)
Render modeRetained mental model — recompute every drawImmediate — describe every frame
Hit testingManual Rect mathResponse { clicked, right_clicked, hovered, focused, gained_focus, lost_focus, rect }
Setup / teardownenable_raw_mode, EnterAlternateScreen, panic hook by youAll handled by slt::run (incl. terminal-restoring panic hook)
Threading shared state&mut App parameter chainui.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/.

ratatuiSLT
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 widgetusually 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:

PatternSLT
Scrollable code/log with line numbersui.scrollable_with_gutter(&mut scroll, GutterOpts::line_numbers(total, vp_h), |ui, abs| { ui.text(lines[abs]); })
Search highlights in scrollableScrollState::set_highlights(&[HighlightRange::line(7), HighlightRange::span(15, 3)]) + state.highlight_next/previous
Two-pane resizable layoutui.split_pane(&mut SplitPaneState::new(0.5), |ui| ..., |ui| ...) (horizontal) or ui.vsplit_pane(...) (vertical)
Breadcrumb with click feedbacklet r = ui.breadcrumb(&segs).separator(" › ").show(); if let Some(i) = r.clicked_segment { ... }
Tooltipsif ui.button("X").on_hover(ui, "Save").clicked { ... }
Animated valuelet 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 focusui.scrollable(&mut scroll).col(...) — automatic since v0.25; opt out with scroll.follow_focus = false
Form field with geometrylet r = ui.form_field_response(&mut field); → FormFieldResponse { response, input, layout_rect }
Per-subtree theme overrideui.container().theme(custom_theme).col(|ui| ...)
Theme densityTheme::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:

ratatuiSLT
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 codeSLT
ListStateslt::ListState (ListState::new(items), set_items, set_filter, selected_item, selected_index, remove_selected, visible_indices)
TableStateslt::TableState (set_rows, toggle_sort, sort_by, set_filter, next_page, prev_page)
TabsStateslt::TabsState (new(vec!["Files", "Settings"]), selected_label)
ScrollbarState / manual offsetslt::ScrollState + ui.scrollable(&mut state).col(...) or ui.scrollable_with_gutter(...)
your own input: Stringslt::TextInputState::with_placeholder("…") (validators via add_validator)
your own textarea: Vec<String>slt::TextareaState::default().word_wrap(80)
your own selection setslt::SelectState, slt::RadioState, slt::MultiSelectState
tree viewslt::TreeState, slt::DirectoryTreeState::from_paths(...)
modal flagslt::Context::modal(|ui| ...) or modal_with(ModalOpts::new(), ...)
toast queueslt::ToastState::default() + ui.toast(&mut toasts), or ui.notify(msg, ToastLevel::Info) for the built-in queue
log/history viewslt::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

ratatuiSLT
KeyCode::Char('q') matchif ui.key('q') { ... }
KeyCode::Esc matchif 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)
EnableMouseCaptureNothing 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::ScrollUpif ui.scroll_up() { ... }
Manual hit testif ui.button("X").clicked { ... } (Response.clicked is a public field)
Focus eventsResponse.gained_focus / Response.lost_focus (v0.20)
paste eventif let Some(s) = ui.paste() { ... }
Sequence detectionif 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

ratatuiSLT
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 stylingui.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 BorderTypeSLT Border
BorderType::PlainBorder::Single
BorderType::RoundedBorder::Rounded
BorderType::DoubleBorder::Double
BorderType::ThickBorder::Thick
BorderType::QuadrantInside / QuadrantOutsideno direct equivalent — use a dashed style or custom draw
ratatui BordersSLT BorderSides
Borders::ALLdefault — ui.bordered(Border::Rounded) draws all 4
Borders::TOPBorderSides::top()
Borders::BOTTOMBorderSides::bottom()
Borders::LEFT / RIGHTBorderSides::left() / right()
Borders::TOP | Borders::BOTTOMBorderSides::horizontal()
Borders::LEFT | Borders::RIGHTBorderSides::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.

cursiveSLT
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.

textualSLT
class App(App) with compose()slt::run(|ui| { ... }) closure
reactive attributesplain Rust variables outside the closure
CSS-like stylingThemeBuilder + per-widget chains (.fg(Color::Red).bold())
Static("hello")ui.text("hello")
Button("Click") + on_button_pressedif ui.button("Click").clicked { ... } inline
Input(placeholder="...")ui.text_input(&mut TextInputState::with_placeholder("..."))
DataTableui.table(&mut TableState)
ScrollableContainerui.scrollable(&mut ScrollState).col(|ui| ...) or scrollable_with_gutter
Container(...)ui.bordered(...).col(...) or ui.container().col(...)
compose() yielding child widgetsthe closure body — order is layout order
Async event handlersslt::run_async (async feature) returns an owned AsyncRunHandle; use .sender() and .join() / .cancel_and_join()
CSS animationsslt::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

  1. 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.

  2. 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.

  3. 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.

  4. 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).

  5. 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/.

  6. 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.

  7. 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.

  8. Replace &App threading with provide/use_context for read-only shared state.

  9. 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

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

slt

무료

Build Rust TUI apps with SuperLightTUI v0.26 (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.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

subinium/SuperLightTUI1942026년 10월 18일 업데이트

subinium의 스킬 모두 보기

이 스킬의 문제 신고