From efaa20a766766529cee2d740bb1abdd7baba622a Mon Sep 17 00:00:00 2001 From: Subin An Date: Wed, 29 Apr 2026 08:45:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20v0.20.1=20=E2=80=94=20col=5Fgap=20depre?= =?UTF-8?q?cation=20+=20perf-alloc=20isolation=20+=20status=20doc=20gaps?= =?UTF-8?q?=20+=20skill=20v0.20=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation + deprecation + flaky-harness root-cause fix patch. Lands the audit items the v0.20.0 CHANGELOG flagged as "patch-safe doc-only gaps", an API discoverability fix for the `col_gap` / `row_gap` name collision, a skill-pack refresh covering the v0.20 surface, and a source-level fix for the parallel-test contamination that previously needed a `--test-threads=1` CI workaround. Highlights: * Deprecate `Context::col_gap` / `Context::row_gap`. The two-arg form collides with `ContainerBuilder::col_gap` / `row_gap`, which set the row-finalize / column-finalize main-axis gap (Tailwind `gap-x` / `gap-y` axis convention) and so mean the opposite thing. Migrate to `ui.container().gap(n).col(f)` / `.row(f)` — same output, no collision. * 25 demo + integration files (22 examples + 3 tests) migrated off the deprecated form so AI training data and downstream copy-paste land on the unambiguous shape from the start. * `tests/v020_perf_alloc.rs` cross-test contamination root-caused. The pre-fix `measure_lock` mutex protected only the measurement critical section, so non-measuring sibling tests still ran in parallel and leaked alloc into the global counter. Fix: every `#[test]` now grabs the file-wide mutex via `enter_perf_test()` on its first line, and `measure_allocs` switches to `try_lock`. The `--test-threads=1` workaround in `.github/workflows/ci.yml` is removed in the same commit. * `Buffer::recompute_line_hashes` / `row_clean` / `row_hash` gated on `#[cfg(any(feature = "crossterm", test))]` so they no longer trip `dead_code` under `--no-default-features`. The methods exist solely for the `flush_buffer_diff` fast-path, which already lives behind `feature = "crossterm"`. * Status-family rustdoc fill (`badge`, `badge_colored`, `key_hint`, `stat`, `stat_colored`, `stat_trend`, `empty_state`, `empty_state_action`) plus `vsplit_pane` example block. Closes the v0.20.0 patch-safe doc audit. * SKILL.md additions: Rule 6 (return-type pattern: `&mut Self` vs `Response`) and Custom widget pattern decision guide (function form vs `impl Widget`). * `slt` / `slt-migration` skill packs synced from v0.19.2 → v0.20 with removed-API table, hook-ordering decision table, Korean triggers. * README cleanup: dropped version-specific "v0.20 Demo Catalog" in favor of pointing at `docs/EXAMPLES.md`, generalized the launcher snippet so the top-level README stays timeless across minor releases. * `cargo update -p rand` 0.9.2 → 0.9.4 to clear RUSTSEC-2026-0097 (unsound transitive dep via the proptest dev-dep). No public runtime behavior changes — deprecated paths still call into the same `push_container` code, so existing apps keep building with a deprecation warning until v0.21+. The full Pre-CI Core + Extended Gate runs clean with the default parallel test runner; no environment-specific workarounds remain. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/slt-migration/SKILL.md | 493 ++++++++++++++------------ .claude/skills/slt/REFERENCES.md | 171 +++++++-- .claude/skills/slt/SKILL.md | 344 ++++++++++++++---- .github/workflows/ci.yml | 13 +- CHANGELOG.md | 28 ++ Cargo.lock | 8 +- Cargo.toml | 2 +- README.md | 46 +-- crates/slt-wasm/Cargo.toml | 4 +- examples/anim.rs | 2 +- examples/canvas_tour.rs | 10 +- examples/cookbook_dashboard.rs | 6 +- examples/cookbook_modal_toast.rs | 4 +- examples/cookbook_table.rs | 2 +- examples/cookbook_tour.rs | 2 +- examples/counter.rs | 2 +- examples/demo.rs | 46 +-- examples/demo_design_system.rs | 16 +- examples/demo_game.rs | 6 +- examples/demo_ime.rs | 2 +- examples/showcase_tour.rs | 2 +- examples/system_tour.rs | 2 +- examples/text_tour.rs | 2 +- examples/v020_gauge.rs | 8 +- examples/v020_modal_trap.rs | 4 +- examples/v020_named_focus.rs | 8 +- examples/v020_spacing_scale.rs | 2 +- examples/v020_theme_subtree.rs | 2 +- examples/v020_tour.rs | 2 +- examples/v020_use_state_keyed.rs | 2 +- examples/v020_widthspec.rs | 2 +- src/buffer.rs | 8 + src/context/widgets_display/layout.rs | 19 + src/context/widgets_display/split.rs | 17 +- src/context/widgets_display/status.rs | 100 ++++++ tests/v020_interaction_regression.rs | 4 +- tests/v020_perf_alloc.rs | 63 +++- tests/v020_theme_modal_demos.rs | 6 +- tests/v020_widthspec_demo.rs | 10 +- 39 files changed, 993 insertions(+), 477 deletions(-) diff --git a/.claude/skills/slt-migration/SKILL.md b/.claude/skills/slt-migration/SKILL.md index d0b5d2f..7fde885 100644 --- a/.claude/skills/slt-migration/SKILL.md +++ b/.claude/skills/slt-migration/SKILL.md @@ -1,43 +1,55 @@ --- name: slt-migration -description: Migrate Rust TUIs from ratatui (or cursive, Python textual) to SuperLightTUI. Use when porting an existing TUI codebase to SLT, or when the user asks "how do I do X from ratatui in SLT". +description: Migrate Rust TUIs from ratatui (or cursive, Python textual) to SuperLightTUI v0.20. 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로". --- -# SLT Migration Skill (from ratatui / cursive / textual) +# SLT Migration Skill (from ratatui / cursive / textual) — v0.20 -This skill complements `.claude/skills/slt/SKILL.md` (authoring). Use this one -to *port* an existing codebase. After migration, switch to the `slt` skill for -day-to-day authoring. +This skill complements `.claude/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.19.2. Every API name below has been verified against `src/lib.rs` -and `src/context/widgets_*.rs`. If a future SLT version moves something, the -`slt` skill's "grep before writing" rule still applies. +**Targets SLT v0.20.0** (`Cargo.toml: version = "0.20.0"`). Every API name below has been verified against `src/lib.rs` and `src/context/widgets_*.rs`. The v0.20 API consistency pass removed several v0.19 names — the "v0.20 removed APIs" table below lists every one. Do not migrate to a removed name. ## When to use -Trigger when any of the following are true: +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`, `cursive`, or has a `Cargo.toml` listing them. +- 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 the user is starting fresh (no existing TUI), use the `slt` skill instead. +If starting fresh (no existing TUI), use the `slt` skill instead. + +## 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).` | +| `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`) | ## Mental model translation -Compact side-by-side. Read once before writing any code. +Compact side-by-side — read once before writing any code. | Aspect | ratatui | SLT | |---|---|---| -| Loop ownership | You own the loop, `terminal.draw(\|f\| ...)` | `slt::run(\|ui\| ...)` owns it | -| Layout | `Layout::default().constraints(...).split(area)` returns `Vec` | `ui.row \| ui.col \| ui.bordered(...).col(...)` (flexbox) | -| Widget API | Build a widget value, then `f.render_widget(widget, rect)` | Method call on `&mut Context`: `ui.text(...) / ui.list(&mut state) / ui.table(&mut state)` | +| Loop ownership | You own it: `terminal.draw(\|f\| ...)` | `slt::run(\|ui\| ...)` owns it | +| Layout | `Layout::default().constraints(...).split(area)` → `Vec` | `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) | -| Mode | Retained — recompute every draw | Immediate — describe every frame | -| Hit testing | None built in (you do math on `Rect`) | `Response { clicked, hovered, focused, rect }` returned from each widget | -| Setup / teardown | You do `enable_raw_mode`, `EnterAlternateScreen`, panic hook | `slt::run` handles all of it (including a panic hook restoring terminal state) | +| 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::()` | -**cursive**: callback-based — `siv.add_global_callback`, views added to layers, runs its own loop. SLT has no callbacks; check inputs and branch in the closure. +**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. @@ -54,57 +66,95 @@ 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 + // dispatch to app } } crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; terminal::disable_raw_mode()?; ``` -SLT equivalent: +SLT v0.20: ```rust fn main() -> std::io::Result<()> { let mut app = App::default(); slt::run(|ui| { - if ui.key('q') { ui.quit(); } + if ui.key('q') || ui.key_code(KeyCode::Esc) { ui.quit(); } render(ui, &mut app); }) } ``` -`slt::run` enters the alternate screen, enables raw mode, installs a panic hook -that restores terminal state, and tears everything down on exit. Use -`slt::run_with(RunConfig::default().mouse(true), ...)` for mouse capture, or -`slt::run_inline(height, ...)` to render below the prompt without alternate -screen. +`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(true).theme(Theme::dark()), \|ui\| ...)` — mouse capture, custom theme +- `slt::run_inline(rows, \|ui\| ...)` — render below the prompt, no alt screen +- `slt::run_static(\|ui\| ...)` — append-only scrollback (use with `ui.static_log(...)`) +- `slt::run_async::(\|ui, messages\| ...)` — tokio integration (`async` feature) -### Widget mapping (top ratatui widgets) +### Widget mapping (top ratatui widgets → v0.20 SLT) -Every SLT method below has been confirmed to exist in `src/context/widgets_*.rs`. +Every SLT method below is verified against `src/context/widgets_*.rs`. -| ratatui | SLT | +| ratatui | SLT v0.20 | |---|---| -| `Block::default().borders(Borders::ALL).title("X")` | `ui.bordered(Border::Rounded).title("X").col(\|ui\| { ... })` | +| `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(...)` | +| `Block::default().borders(Borders::TOP \| Borders::BOTTOM)` | `ui.bordered(Border::Single).border_sides(BorderSides::vertical()).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 state)` — items live in `ListState` (`state.items`, `state.selected`) | -| `Table::new(rows).widths(&[...])` | `ui.table(&mut state)` — rows live in `TableState` (auto column widths) | -| `Tabs::new(titles).select(idx)` | `ui.tabs(&mut state)` — labels live in `TabsState` | -| `Gauge::default().percent(75)` | `ui.progress_bar(0.75, width)` (ratio in `[0.0, 1.0]`, width in cells) | +| `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(["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(...)` — see `src/context/widgets_viz.rs` line 1342 for full signature, or use `ChartBuilder` | -| `Sparkline::default().data(&[1, 2, 3])` | `ui.sparkline(&[1.0, 2.0, 3.0], width)` (values are `f64`, width required) | +| `Chart::new(datasets)` | `ui.chart(width, height, \|c\| { c.line(&data).color(c).label("X"); })` | +| `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 (clear background) | `ui.bordered(...).bg(Color::...).col(...)` or just rely on overlay/modal | +| `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 | + +### v0.20-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 | v0.20 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_with(ModalOptions { tab_trap: true }, \|ui\| ...)` (plain `ui.modal` keeps Esc-friendly behavior) | +| 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): +```rust +fn render_my_widget(ui: &mut Context, data: &MyData) { /* method calls */ } +``` + +**Option B — implement SLT's `Widget` trait** (for reusable libraries): +```rust +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" }); +``` -Widgets ratatui has but SLT doesn't ship as a built-in: -- ratatui `Canvas` for braille drawing — use `ui.canvas(...)` (different API; takes a closure that gets a `CanvasContext`). -- ratatui `Scrollbar` — use `ui.scrollable(&mut scroll).col(...)` then `ui.scrollbar(&scroll)` for the indicator. -- Custom `Widget` trait impls — these don't translate. Rewrite as a function: `fn render_my_widget(ui: &mut Context, data: &MyData)`. +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`. ### Layout mapping @@ -114,16 +164,16 @@ let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)]) .split(area); -f.render_widget(header_widget, chunks[0]); -f.render_widget(body_widget, chunks[1]); -f.render_widget(footer_widget, chunks[2]); +f.render_widget(header, chunks[0]); +f.render_widget(body, chunks[1]); +f.render_widget(footer, chunks[2]); ``` SLT: ```rust ui.col(|ui| { ui.container().h(3).col(|ui| { /* header */ }); - ui.container().grow(1).col(|ui| { /* body — fills remaining */ }); + ui.container().fill().col(|ui| { /* body — fills remaining (v0.20 fill() == grow(1)) */ }); ui.container().h(1).col(|ui| { /* footer */ }); }); ``` @@ -133,51 +183,84 @@ Constraint translation: | ratatui | SLT | |---|---| | `Constraint::Length(3)` | `.h(3)` (col) or `.w(3)` (row) | -| `Constraint::Min(0)` | `.grow(1)` | -| `Constraint::Min(n)` | `.min_h(n).grow(1)` (col) or `.min_w(n).grow(1)` (row) | -| `Constraint::Max(n)` | `.max_h(n)` (col) or `.max_w(n)` (row) | -| `Constraint::Percentage(50)` | `.h_pct(50)` (col) or `.w_pct(50)` (row) — takes `u8` | -| `Constraint::Ratio(1, 3)` | `.grow(1)` on each child (use equal grow weights), or `.w_pct(33)` | -| `.margin(1)` on Layout | `.pad(1)` on the parent container, or `.m(1)` on individual children | -| `.spacing(1)` between chunks | `.gap(1)` on the parent `row`/`col` | +| `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)` or `.w_ratio(1, 3)` | +| `.margin(1)` on Layout | `.p(1)` on parent container | +| `.spacing(1)` between chunks | `.gap(1)` on parent `row` / `col` | -**Important**: SLT does NOT have a `.shrink()` builder. To prevent a child from -expanding, just leave `.grow` unset (default 0) and set explicit `.h`/`.w`. -For pure flexbox, only `.grow(u16)` is exposed. +`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/v020_widthspec.rs`. -### State mapping +### State mapping (re-exported via `slt::*`) -| ratatui | SLT (re-exported via `slt::*`) | +| ratatui / your code | SLT | |---|---| -| `ListState` | `slt::ListState` (`state.items: Vec`, `state.selected: usize`) | -| `TableState` | `slt::TableState` (`state.headers`, `state.rows`, `state.selected`, plus `set_filter`, `toggle_sort`, `next_page`) | -| `TabsState` | `slt::TabsState` (`state.labels`, `state.selected`) | -| `ScrollbarState` / manual offset | `slt::ScrollState` (`state.offset`, paired with `ui.scrollable(&mut state).col(...)`) | -| your own `input: String` | `slt::TextInputState` (`state.value`, `state.errors()`, `add_validator`) | -| your own `textarea: Vec` | `slt::TextareaState` (multi-line) | +| `ListState` | `slt::ListState` (`set_items`, `set_filter`, `selected_item`, `visible_indices`) | +| `TableState` | `slt::TableState` (`set_rows`, `toggle_sort`, `sort_by`, `set_filter`, `next_page`, `prev_page`) | +| `TabsState` | `slt::TabsState` (`new(["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` | `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(ModalOptions{tab_trap:true}, ...)` | +| toast queue | `slt::ToastState::default()` + `ui.notify(level, msg)` | +| 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: -All state types are re-exported at crate root. Confirmed lines 146–153 of `src/lib.rs`. +```rust +fn render(f: &mut Frame, app: &mut App) { + render_header(f, app); + render_body(f, app); + render_footer(f, app); +} +``` -### Event mapping +In SLT v0.19+ replace **read-only** sharing with `provide`/`use_context`: -ratatui reads crossterm events directly. SLT exposes a higher-level event query -API that handles edge-detect, focus routing, and key consumption: +```rust +struct AppCtx { theme: slt::Theme, tick: u64, settings: Settings } + +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 ctx = ui.use_context::(); + ui.text(format!("tick {}", ctx.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`) | -| `MouseEventKind::Down(MouseButton::Left)` | `if let Some((x, y)) = ui.mouse_down() { ... }` (or `ui.mouse_down_button(MouseButton::Left)`) | -| Manual hit test: `if click in rect { ... }` | `if ui.button("X").clicked { ... }` (`Response.clicked` is a public field) | +| `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 | `for s in ui.pastes() { ... }` | +| Sequence detection | `if ui.key_seq("gg") { ... }` | -Key consumption: `ui.consume_key(c)` and `ui.consume_key_code(code)` mark the -event as handled so child widgets don't see it. Useful when you want global -shortcuts to take precedence over widget input. +**Modal-aware**: `ui.key()`, `ui.key_code()`, `ui.key_mod()` are filtered when a modal is open. For global shortcuts that bypass modals, use `ui.raw_key_code()` / `ui.raw_key_mod()`. -For the rare case you need raw events, use `ui.events()` to iterate. Prefer the -helpers — they handle modal stacking, focus, and previous-frame state correctly. +**Consume**: `ui.consume_key(c)` / `ui.consume_key_code(code)` mark events handled so child widgets don't re-process. Useful for global shortcuts taking precedence over text input. ### Style mapping @@ -188,211 +271,155 @@ helpers — they handle modal stacking, focus, and previous-frame state correctl | `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` | `.dim()` | -| `Modifier::ITALIC` | `.italic()` | -| `Modifier::UNDERLINED` | `.underline()` | -| `Modifier::REVERSED` | `.reversed()` | -| `Modifier::CROSSED_OUT` | `.strikethrough()` | +| `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 need to clone. +`Style` is `Copy` in both libraries — no clone needed. ### Color mapping -Colors are nearly identical. Both libraries have: -- 16 named colors: `Color::Red`, `Color::Green`, `Color::Blue`, `Color::Yellow`, `Color::Cyan`, `Color::Magenta`, `Color::Black`, `Color::White`, plus `LightRed`, `LightGreen`, etc. -- 256-color palette: ratatui `Color::Indexed(N)` ↔ SLT `Color::Indexed(N)`. -- 24-bit: ratatui `Color::Rgb(r, g, b)` ↔ SLT `Color::Rgb(r, g, b)`. -- Reset: `Color::Reset` in both. +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: -- ratatui has `Color::Gray` and `Color::DarkGray` — SLT only has `Color::DarkGray`. Use `Color::Indexed(8)` or `Color::Rgb(128, 128, 128)` for mid-gray. -- SLT's bright white is `Color::LightWhite` (ratatui calls it `White` since their `White` is 7 and `Gray` is the not-bright version). Audit any color-dependent comparisons during port. +- ratatui has `Color::Gray` and `Color::DarkGray` — SLT only has `Color::DarkGray`. Use `Color::Indexed(8)` (ANSI bright black) or `Color::Rgb(128, 128, 128)` for mid-gray. +- 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::vertical()` | +| `Borders::LEFT \| Borders::RIGHT` | `BorderSides::horizontal()` | + +Use via `.border_sides(...)`: `ui.bordered(Border::Single).border_sides(BorderSides::vertical()).col(...)`. ### Theme -ratatui has no built-in theme. If you have ad-hoc color constants, replace them -with `slt::Theme` and call `ui.color(ThemeColor::Primary)` etc. so the same -code respects light/dark mode and theme swaps. See `slt::ThemeBuilder` (a -`const fn` since v0.19.2 — themes can be defined at compile time). +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. v0.20 additions: +- `Theme::dark()` / `light()` (base) +- `Theme::compact()` / `comfortable()` / `spacious()` (density variants of dark) +- `Theme::dracula()` / `nord()` / `tokyo_night()` / `gruvbox_dark()` / `one_dark()` / `catppuccin()` / `solarized_dark()` / `solarized_light()` +- `ThemeBuilder::builder_from(Theme::nord())` — extend a preset +- `ContainerBuilder::theme(custom)` — per-subtree override ## cursive → SLT mapping -cursive is callback-driven and runs its own event loop. SLT replaces both -patterns with the imperative closure model. +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 state)` (state is `TextInputState`) | -| `SelectView::new().item("a", 0).item("b", 1)` | `ui.select(&mut state)` (state is `SelectState`) | +| `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().child(...).child(...)` | `ui.col(\|ui\| { ... })` | -| `LinearLayout::horizontal()` | `ui.row(\|ui\| { ... })` | -| `siv.add_layer(view)` | Render at top-level in the closure (no layering needed unless using `ui.modal` / `ui.overlay`) | -| `Cursive::set_user_data(state)` | Plain Rust variable outside the closure, captured by reference | +| `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 | -The biggest mental shift: cursive callbacks fire on user input. SLT's closure -runs every frame. State updates are visible immediately because you re-render -each frame. +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 is class-based with reactive state and CSS. SLT is functional with plain variables. | textual | SLT | |---|---| -| `class App(App)` with `compose()` yielding widgets | A `slt::run(\|ui\| { ... })` closure with imperative method calls | -| `reactive` attributes | Plain Rust variables (`let mut count: i32 = 0;`) outside the closure | +| `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 in the closure | +| `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::new(...))` | -| `ScrollableContainer` | `ui.scrollable(&mut ScrollState::new()).col(\|ui\| ...)` | +| `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 the layout order | -| Async event handlers | Either keep them async-side (read state in the SLT closure), or use `slt::run_async` (requires `async` feature, returns a `tokio::sync::mpsc::Sender`) | -| CSS animations | `slt::Tween` / `slt::Spring` / `slt::Keyframes` (see `slt::anim::*`) | +| `compose()` yielding child widgets | the closure body — order is layout order | +| Async event handlers | `slt::run_async` (`async` feature) returns a `tokio::sync::mpsc::Sender` | +| 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 the trait. SLT widgets are method calls, not types. Rewrite as - `fn render_my_widget(ui: &mut Context, data: &MyData)` and call it - from inside the `slt::run` closure. - -- **"My App has a `draw(&mut self, frame: &mut Frame)` method."** - Convert to `fn render(ui: &mut Context, app: &mut App)` and call from - the closure: `slt::run(|ui| render(ui, &mut app))`. Same data flow, - different parameter shape. State stays outside the closure. - -- **"ratatui `ListState` lives across frames."** - Same in SLT — `slt::ListState` lives outside the closure. Pass `&mut state` - to `ui.list(&mut state)` each frame. Up/Down arrow handling is built in. - -- **"I want raw crossterm events directly."** - Prefer `ui.key()`, `ui.key_code()`, `ui.key_mod()`, `ui.mouse_down()`, - `ui.mouse_pos()`, `ui.scroll_up()/down()`. Raw `ui.events()` is for advanced - cases (key release detection, paste handling beyond the helpers, custom - modifier matching). - -- **"I have heavy custom layout math (`.split()` arithmetic on `Rect`)."** - Try `ui.row` / `ui.col` + `.grow(n)` / `.h(n)` / `.w(n)` / `.h_pct(50)` / - `.align(...)` / `.justify(...)` first. Flexbox handles 95% of cases. - Drop down to `ui.container().draw(|buf, rect| { ... })` only when - flexbox can't express it. The `draw` closure must be `'static` (deferred - execution). - -- **"ratatui `Style` is `Copy`."** - Same in SLT — `Style` derives `Copy`, no `.clone()` needed. - -- **"`Constraint::Percentage(50)` is everywhere in my layout."** - Map to `.w_pct(50)` (row child) or `.h_pct(50)` (col child). Both take `u8`. - There is no `.width_pct` / `.height_pct` — those names don't exist. - -- **"I use `Layout::default().margin(1).split(area)`."** - Use `.pad(1)` on the parent container. `pad` adds inside-the-border padding, - which is what `Layout::margin` effectively does in ratatui. - -- **"I check `Response.rect` immediately."** - SLT layout runs *after* the closure. On frame 1, `Response.rect` is a - zero `Rect`. Guard with `if ui.tick() > 0 { ... }` for measurement-dependent - logic. (See `docs/PREVIOUS_FRAME_GUIDE.md`.) - -- **"`Borders::ALL`."** - SLT does not have a `Borders::ALL` constant. `ui.bordered(Border::Rounded)` - draws all four sides by default. To draw a subset, pass a - `BorderSides` via `.border_sides(BorderSides::horizontal())` etc. - -- **"I want `Color::Gray`."** - Doesn't exist in SLT. Use `Color::Indexed(8)` (ANSI dim gray) or - `Color::Rgb(128, 128, 128)`. - -- **"My ratatui app calls `terminal.clear()` between frames."** - Don't. SLT diffs the buffer each frame and only emits changed cells. - Manually clearing breaks the diff and causes flicker. - -- **"My panic hook restores raw mode."** - Drop it. `slt::run` installs a panic hook on first call that disables - raw mode and prints a clean panic header. +- **"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();` outside the closure; pass `&mut list` to `ui.list(&mut list)` each frame. +- **"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::vertical())`. +- **"I want `Color::Gray`."** — doesn't exist. Use `Color::Indexed(8)` or `Color::Rgb(128, 128, 128)`. Or pull from `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::()`. Keep `&mut` for writes. +- **"My ratatui Gauge has no label."** — use `ui.gauge(0.75)` (no label) or `ui.progress(0.75)` (display widget, returns `&mut Self`). ## Migration workflow -1. **Inventory ratatui widgets used.** From the project root: +1. **Inventory ratatui widgets used.** ```sh 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 table above. - -2. **Convert the run loop.** Replace `Terminal::new` setup + draw loop + - `disable_raw_mode` teardown with one of: - - `slt::run(|ui| { ... })` — full-screen alt-screen mode (most apps). - - `slt::run_with(RunConfig::default().mouse(true).theme(Theme::dark()), |ui| { ... })` — when you need mouse, custom theme, etc. - - `slt::run_inline(height, |ui| { ... })` — render below the prompt (CLI tools, no alt screen). - - `slt::run_async::(|ui, messages| { ... })` — tokio integration (requires `async` feature). - -3. **Move state out of the draw closure.** ratatui apps usually already do this - (`App` struct outside, `terminal.draw(|f| ui(f, &mut app))` inside). Keep - the same shape — your `App` struct now feeds into a single SLT closure. - -4. **Replace layout splitters.** Convert each - `Layout::default().constraints(...).split(area)` to nested `ui.row` / - `ui.col` + `.grow / .h / .w / .h_pct / .w_pct / .align / .justify`. - Use `.gap(n)` instead of `.spacing(n)`, `.pad(n)` instead of `.margin(n)`. - -5. **Replace each `f.render_widget(...)` with the SLT method.** Convert - widget by widget in the order they're rendered. Use the widget mapping - table; verify any uncertain method against `src/context/widgets_*.rs` - (or use the `slt` skill's grep workflow). - -6. **Replace event handling.** Convert raw `crossterm::event::read()` - matches to `ui.key()`, `ui.key_code()`, `ui.key_mod()`, `ui.mouse_down()`, - `ui.scroll_up/down()`. Drop your manual hit-testing in favour of - `Response.clicked` returned from each widget. - -7. **Run `cargo check` and fix one widget at a time.** Add tests with - `slt::TestBackend::new(80, 24).render(|ui| { ... })` plus - `tb.assert_contains("text")` once a section compiles. See - `docs/TESTING.md` for event injection and multi-frame scenarios. - -After everything compiles, run the full quality gate from project `CLAUDE.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 0.19.2 doesn't have a direct equivalent for + 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`. + +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 v0.20 mapping table or grep `src/context/widgets_*.rs`. + +6. **Adopt v0.20 builders.** Where the old code hand-rolled gauges, breadcrumbs, scrollable-with-line-numbers, or split panes, use the v0.20 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 `CLAUDE.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 v0.20 doesn't have a direct equivalent for Be honest with the user — these need workarounds: -- **ratatui `Canvas` braille drawing primitive.** SLT has `ui.canvas(...)` but - the API takes a `CanvasContext` closure, not a value-typed widget. Custom - point/line drawing logic needs a small rewrite. See - `src/context/widgets_viz.rs` line 1289. -- **ratatui `Wrap { trim: true }` exact semantics.** SLT wraps via container - width and `.wrap()` on text; the trim-leading-whitespace behaviour isn't - identical. Test wrap-heavy text manually. -- **ratatui custom `Widget` trait impls.** No equivalent — wrap as a function - taking `&mut Context`. -- **cursive's deep view layering / multiple modal stacks.** SLT supports - `ui.modal(...)` and `ui.overlay(...)` but not arbitrary nested view - managers. Most uses fold into a single `if state.show_modal { ui.modal(...) }`. -- **textual's CSS hot reload.** Themes are Rust values (no hot reload). For - fast iteration use `cargo watch -x run`. +- **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 `src/context/widgets_viz.rs:1289`. +- **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 hot reload.** Themes are Rust values (no hot reload). Use `cargo watch -x run` for fast iteration. If a feature genuinely doesn't map, tell the user — don't fake it. ## References - `.claude/skills/slt/SKILL.md` — SLT authoring skill (use after migration is done). -- `docs/COMPLETE_REFERENCE.md` — full SLT API in one file. -- `docs/COOKBOOK.md` — 5+ working SLT app recipes (login, data table, modal+toast, dashboard, file picker). -- `docs/PATTERNS.md` — component composition (`provide` / `use_context` / `use_state_named` / `with_if`). -- `docs/STATE_APIS.md` — every public `*State` struct's methods listed. -- `docs/PREVIOUS_FRAME_GUIDE.md` — when `Response.rect` is meaningful (frame 2+, not frame 1). +- `.claude/skills/slt/REFERENCES.md` — feature flags, v0.20 surface, doc pointers. +- `examples/v020_*.rs` — runnable v0.20 demos (gauge, breadcrumb, scrollable_with_gutter, split_pane, etc.). +- `tests/v020_*.rs` — regression tests showing canonical TestBackend + sequence patterns. +- `src/lib.rs` — authoritative public re-exports. +- `src/context/widgets_*.rs` — authoritative widget signatures. - ratatui repo: - cursive repo: - textual repo: diff --git a/.claude/skills/slt/REFERENCES.md b/.claude/skills/slt/REFERENCES.md index 2e33ee2..1ededc3 100644 --- a/.claude/skills/slt/REFERENCES.md +++ b/.claude/skills/slt/REFERENCES.md @@ -1,51 +1,146 @@ -# SLT References +# SLT References — v0.20 -Load this only when the user asks about specific feature flags, or when building something that requires a non-default feature. +Load this when the user asks about feature flags, version-specific APIs, or doc locations. ## Feature matrix (from `Cargo.toml`) | Feature | Default? | Purpose | APIs / capabilities enabled | |---|---|---|---| -| `crossterm` | yes | Terminal backend | `slt::run`, `slt::run_inline`, `detect_color_scheme`, `read_clipboard` | -| `async` | no | Tokio channel-based messaging | `slt::run_async` | +| `crossterm` | yes | Terminal backend | `slt::run`, `slt::run_with`, `slt::run_inline`, `slt::run_static`, `detect_color_scheme`, `read_clipboard` | +| `async` | no | Tokio integration | `slt::run_async` (channel-based message pump) | | `serde` | no | Serialize/Deserialize | derives on `Style`, `Color`, `Theme`, layout types | -| `image` | no | Image loading helpers | `ui.image(...)` halfblock/kitty/sixel | -| `qrcode` | no | QR code widget | `ui.qr_code(...)` | -| `syntax-rust` | no | Rust syntax highlighting | `ui.code_block(code, "rust")` | -| `syntax-python` | no | Python syntax highlighting | `ui.code_block(code, "python")` | -| `syntax-javascript` / `syntax-typescript` | no | JS/TS highlighting | same | -| `syntax-go` / `syntax-bash` / `syntax-json` / `syntax-toml` / `syntax-c` / `syntax-cpp` / `syntax-java` / `syntax-ruby` / `syntax-css` / `syntax-html` / `syntax-yaml` | no | Other languages | same | -| `syntax` | no | All `syntax-*` combined | all languages | -| `kitty-compress` | no | zlib-compressed kitty protocol | larger images with smaller payloads | -| `full` | no | Everything: crossterm+async+serde+image+qrcode+kitty-compress | use for development / demos only | - -## Doc pointers - -- `docs/COMPLETE_REFERENCE.md` — full API, single-file, ~1530 lines (LLM-optimized) -- `docs/COOKBOOK.md` — 5 copy-paste app recipes -- `docs/STATE_APIS.md` — every public `*State` struct with methods (note: `RichLogState::new()` is bounded at 10000 entries since v0.19.2; use `RichLogState::new_unbounded()` for unlimited) -- `docs/PREVIOUS_FRAME_GUIDE.md` — frame timing, when `Response.rect` is valid -- `docs/PATTERNS.md` — reusable patterns including `provide` / `use_context` / `use_state_named` / `with_if` (v0.19.0+ component DX) -- `docs/EXAMPLES.md` — annotated table of every example; start here when looking for a runnable reference -- `docs/ARCHITECTURE.md` — render pipeline (commands → build_tree → flexbox → collect → render → flush) -- `docs/THEMING.md` — `Theme` presets, `ThemeColor` semantic tokens, contrast helpers (`ThemeBuilder` is `const fn` since v0.19.2; themes can be defined at compile time) -- `docs/TESTING.md` — `TestBackend`, `EventBuilder` (incl. v0.19.1 `mouse_up` / `drag` / `key_release` / `focus_gained` / `focus_lost`), snapshot patterns -- `docs/AI_GUIDE.md` — concise AI-oriented overview -- `docs/BACKENDS.md` — `Backend`, `AppState`, `frame()` low-level paths; sixel auto-detection uses an exact-match list (`mlterm` / `foot` / `yaft` / `xterm-256color-sixel`) plus the `"sixel"` substring catch-all and `SLT_FORCE_SIXEL=1` opt-in -- `docs/DEBUGGING.md` — F12 layout overlay, common debug flags -- `docs/ANIMATION.md` — `Tween` / `Spring` / `Keyframes` / `Sequence` / `Stagger` (`Stagger::is_all_done()` reports completion across all items, distinct from `is_done()`) -- `src/lib.rs` — authoritative public re-exports -- `examples/` — 32 runnable examples (highlights: `demo_cjk` CJK / wide-char rendering, `demo_website` `provide` / `use_context` composition, `demo_dashboard` full layout) - -## Release / deployment reference - -See `CLAUDE.md` at project root for the full 8-step release checklist. One-line summary: - -Local PRE-CI → branch `release/vX.Y.Z` → commit → push → PR → wait CI → merge (squash) → pull main → tag → push tag → wait release workflow → verify `gh release view` + crates.io + docs.rs → announce. +| `image` | no | Image loading helpers | `ui.image(...)` halfblock/kitty/sixel decode paths | +| `qrcode` | no | QR widget | `ui.qr_code(...)` | +| `syntax-rust` / `syntax-python` / `syntax-javascript` / `syntax-typescript` / `syntax-go` / `syntax-bash` / `syntax-json` / `syntax-toml` / `syntax-c` / `syntax-cpp` / `syntax-java` / `syntax-ruby` / `syntax-css` / `syntax-html` / `syntax-yaml` | no | Per-language tree-sitter grammars | `ui.code_block(code, "rust")` etc. | +| `syntax` | no | All `syntax-*` combined | every language above | +| `kitty-compress` | no | zlib-compressed kitty protocol | larger images, smaller payloads | +| `full` | no | crossterm+async+serde+image+qrcode+kitty-compress | development / demos only | + +## v0.20 API surface (highlights AI agents miss) + +These shipped in v0.20 and are NOT in older docs. Every entry verified against `src/`. + +### Builders (Rule 1) +- `ui.gauge(ratio: f64) -> Gauge<'_>` — `.label(s).width(n).color(c).show() -> GaugeResponse`. `src/context/widgets_display/gauge.rs:37` +- `ui.line_gauge(ratio: f64) -> LineGauge<'_>` — `.label(s).filled(c).empty(c).width(n).show()`. `gauge.rs:55` +- `ui.breadcrumb(&[&str]) -> Breadcrumb<'_>` — `.separator(s).color(c).show() -> BreadcrumbResponse`. `widgets_display/status.rs:200` + +### Opts struct (Rule 3) +- `ui.scrollable_with_gutter(&mut ScrollState, GutterOpts, body)` — `widgets_display/gutter.rs:115` +- `GutterOpts::line_numbers(total, viewport)` — 90% case smart constructor +- `GutterOpts::new(total, viewport, |i| label)` — custom labels +- `HighlightRange::line(i)` / `::span(start, len)` — replaces v0.19 `::single` + +### Split panes +- `ui.split_pane(&mut SplitPaneState, left, right) -> SplitPaneResponse` — horizontal split, draggable +- `ui.vsplit_pane(&mut SplitPaneState, top, bottom) -> SplitPaneResponse` — vertical split +- `SplitPaneState::new(0.5)`, `.with_min_ratio(0.2)` + +### Hooks (v0.19+ + v0.20 additions) +- `ui.use_state(\|\| init)` — order-based, `src/context/runtime.rs:632` +- `ui.use_state_named::("id")` — `&'static str`, `T: Default`, `runtime.rs:690` +- `ui.use_state_named_with("id", \|\| init)` — `&'static str` + init fn, `runtime.rs:671` +- `ui.use_state_keyed("id-{i}", \|\| init)` — runtime `String`, `runtime.rs:978` +- `ui.use_state_keyed_default::("id-{i}")` — runtime `String` + `T: Default`, `runtime.rs:1002` +- `ui.use_memo(&deps, \|d\| compute(d))` — cached compute, `runtime.rs:832` +- `ui.use_effect(\|d\| { ... }, &deps)` — side effect on deps change, `runtime.rs:1039` +- `ui.provide(value, \|ui\| ...)` — typed scoped injection, `runtime.rs:780` +- `ui.use_context::()` — panics if missing, `runtime.rs:807` +- `ui.try_use_context::()` — `Option<&T>`, `runtime.rs:818` + +### Animation shorthand (v0.20) +- `ui.animate_value("id", target, duration_ticks) -> f64` — `runtime.rs:743` +- `ui.animate_bool("id", value) -> f64` — eased 0..1 toggle, `runtime.rs:716` + +### Response signal fields (v0.20) +`Response { clicked, right_clicked, hovered, changed, focused, gained_focus, lost_focus, rect }`. `right_clicked`, `gained_focus`, `lost_focus` are new in v0.20. Older docs may show only the v0.19 set. +- `Response::on_hover(ui, "tooltip text")` — chained tooltip (`src/context/state.rs:221`) +- `Response::on_hover_ui(ui, |ui| { ... })` — chained tooltip with custom body + +### Theme additions (v0.20) +- `Theme::compact()` / `Theme::comfortable()` / `Theme::spacious()` — density variants of dark +- `Theme::with_spacing(spacing)` — override Spacing on any theme +- `ContainerBuilder::theme(theme)` — per-subtree theme override (no ambient state) +- `ThemeBuilder` / `ThemeBuilder::builder_from(base)` — `const fn` since v0.19.2 + +### Layout / DX (v0.20) +- `ContainerBuilder::fill()` — equivalent to `.grow(1)`, more readable +- `Rect::center_in(outer)`, `center_horizontally_in`, `center_vertically_in` — `src/rect.rs` +- `Context::modal_with(ModalOptions { tab_trap: true }, \|ui\| ...)` — focus-trapped modal +- `Context::static_log(...)` / `take_static_log()` — for `slt::run_static` scrollback + +### Keymap (v0.20) +- `WidgetKeyHelp` trait — implement on a state type to publish keybindings +- `Context::publish_keymap(...)` / `published_keymaps()` — collect + render help +- `Context::keymap_help_overlay()` — auto-rendered overlay +- `RunConfig::handle_ctrl_c(bool)` — opt-out of auto Ctrl-C quit + +### Testing (v0.20) +- `TestBackend::record_frames()` + `tb.frames()[i]` — frame history +- `tb.sequence().tick(n).key(...).type_string(s, &mut value).run()` +- `Buffer::snapshot_format()` — readable snapshot dump +- `tb.assert_not_contains(s)`, `assert_line_not_contains`, `assert_empty_line(y)`, `assert_style_at(x, y, style)` + +## v0.20 removed APIs (do NOT use) + +These were either v0.19 preview or pre-1.0 mistakes consolidated by the v0.20 API consistency pass. AI training data may suggest them — don't. + +| Removed | Replacement | +|---|---| +| `gauge_w(r, w)` | `gauge(r).width(w)` | +| `gauge_colored(r, c)` | `gauge(r).color(c)` | +| `line_gauge_with(r, opts)` | `line_gauge(r).` | +| `breadcrumb_sep(b, s)` | `breadcrumb(b).separator(s)` | +| `breadcrumb_response(b)` / `breadcrumb_response_with(b, s)` | `breadcrumb(b).show()` (returns `BreadcrumbResponse`) | +| `LineGaugeOpts` | `LineGauge<'_>` builder | +| `HighlightRange::single(i)` | `HighlightRange::line(i)` | +| `label_owned(s)` | `label(s)` (accepts `impl Into`) | + +## Doc pointers (cross-check before relying on) + +These docs exist but several lag behind v0.20. When in doubt, grep source. + +- `docs/COMPLETE_REFERENCE.md` — **partially stale** (header still says v0.19.2; missing gauge/line_gauge/scrollable_with_gutter/split_pane/Response signal fields/v0.19 hooks). Treat as supplementary, not authoritative. +- `docs/WIDGETS.md` — **partially stale** (breadcrumb 4-variant API, missing v0.20 builders). +- `docs/STATE_APIS.md` — current for state methods. Note `ScrollState::progress() -> f32` is a documented Rule 2 violation pending fix. +- `docs/PREVIOUS_FRAME_GUIDE.md` — current. Read for `Response.rect` timing. +- `docs/PATTERNS.md` — current. `provide`/`use_context`/`use_state_named`/`with_if` covered. +- `docs/QUICK_START.md` — current. +- `docs/COOKBOOK.md` — current. (Cargo dep line still says `superlighttui = "0.19"` — bump to `0.20` mentally.) +- `docs/THEMING.md` — current. `ThemeBuilder` `const fn` since v0.19.2. +- `docs/TESTING.md` — covers v0.19.1 EventBuilder additions. Read alongside `tests/v020_test_utils_demo.rs` for v0.20 helpers. +- `docs/ANIMATION.md` — current. +- `docs/AI_GUIDE.md` — concise overview, current. +- `docs/BACKENDS.md` — `Backend`, `AppState`, `frame()` low-level paths. +- `docs/ARCHITECTURE.md` — render pipeline. +- `docs/DEBUGGING.md` — F12 layout overlay, debug flags. +- `docs/MIGRATION.md` — **partially stale** for v0.19 → v0.20 (frames v0.20 as "next minor", but it shipped). Use this skill's "v0.20 removed APIs" table. +- `docs/llms.txt` — **partially stale** (v0.19.2 highlight list). +- `docs/RUSTDOC_GUIDE.md` — current. Doctest standards. +- `docs/NAMING.md` — current. The micro-tier rubric this skill encodes. +- `docs/API_DESIGN.md` — current. The 5 rules this skill encodes. +- `docs/DESIGN_PRINCIPLES.md` — current. North-star + audit matrix. + +**Authoritative when docs disagree**: source code (`src/lib.rs` re-exports, `src/context/widgets_*.rs` signatures), `examples/v020_*.rs` runnable demos, `tests/v020_*.rs` regression tests. + +## Examples (`examples/` — 59 files, 16 Cargo-listed) + +The skill's `SKILL.md` lists the canonical reference example for each domain. Highlights: +- **v0.20 surface**: `v020_showcase.rs` (kitchen sink), `v020_tour.rs` (tabbed), `v020_dx_shortcuts.rs` (on_hover/animate_bool/fill) +- **Cookbook recipes**: `cookbook_login.rs`, `cookbook_table.rs`, `cookbook_modal_toast.rs`, `cookbook_dashboard.rs`, `cookbook_file_picker.rs`, `cookbook_tour.rs` +- **Composition**: `demo_website.rs` (provide/use_context), `tests/context_provider.rs` (8 unit tests) +- **CJK / wide chars**: `demo_cjk.rs` +- **All charts**: `demo_infoviz.rs` + +## Release / deployment + +`CLAUDE.md` at project root has the full 8-step checklist. Short: + +Local PRE-CI → branch `release/vX.Y.Z` → atomic commit → push → PR → wait CI → merge (squash) → pull main → tag → push tag → wait `release.yml` → verify `gh release view` + crates.io + docs.rs → announce. ## MSRV -Rust 1.81. Verify MSRV check with `cargo check --features async,serde` on a 1.81 toolchain. +Rust 1.81. Verify with `cargo check --features async,serde` on a 1.81 toolchain (CI's MSRV job runs this exact command). ## Supported targets diff --git a/.claude/skills/slt/SKILL.md b/.claude/skills/slt/SKILL.md index c7c4b3c..99e161f 100644 --- a/.claude/skills/slt/SKILL.md +++ b/.claude/skills/slt/SKILL.md @@ -1,40 +1,212 @@ --- name: slt -description: Build Rust TUI apps with SuperLightTUI (immediate-mode terminal UI library). 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". First read docs/COMPLETE_REFERENCE.md for the full API, docs/COOKBOOK.md for app recipes, docs/STATE_APIS.md for state type methods, docs/PREVIOUS_FRAME_GUIDE.md for frame-timing questions. +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 +# SuperLightTUI (SLT) Authoring Skill — v0.20 -## Mental model (read every session) +## Mental model -SuperLightTUI is an immediate-mode TUI library. Your app is a single closure: -`slt::run(|ui: &mut Context| { ... })`. That closure runs once per frame. -State lives in normal Rust variables outside the closure — there is no `App` trait, -no `Model/View/Update`, no retained component tree. The library handles layout -(flexbox), rendering to a back buffer, ANSI diffing against the previous frame, -and stdout flush. +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. -Interaction uses *previous-frame* hit rects — when the closure runs for frame N, -layout for N hasn't happened yet, so `Response.rect` reflects frame N-1. On frame 1 -it's a zero `Rect`. For measurement-dependent logic, guard with -`if ui.tick() > 0 { /* use rect */ }`. See `docs/PREVIOUS_FRAME_GUIDE.md`. +`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 via `ui.provide(value, |ui| ...)` + `ui.use_context::()` -(avoids threading `&theme` / `&tick` / `&mut toasts` through every helper fn). -Component-local state lives in `ui.use_state_named(id)` — the id-keyed variant -is safe inside conditionals, unlike order-based `use_state`. Conditional styling: -`.with_if(cond, modifier)` on text and `ContainerBuilder`. See `docs/PATTERNS.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::()` 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. + +1. **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`. + ```rust + ui.gauge(0.6).label("60%").width(24).color(Color::Cyan); // good + let r = ui.breadcrumb(&segs).separator(" › ").show(); // capture response + ``` + **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. + +2. **Floats are `f64`.** Public surface never takes/returns `f32`. `0.5` is `f64` natively, so `ui.gauge(0.5)` just works. + +3. **≤3 positional args.** When 4+ args appear, use an **opts struct** (`Opts`) or a builder. + ```rust + GutterOpts::line_numbers(total, viewport) // Opts struct + ui.scrollable_with_gutter(&mut scroll, opts, |ui, abs| { ... }); + ``` + +4. **Stateful widgets take `&mut State`.** Never `&mut String`, `&mut Vec`, `&mut usize`. Trivial-value exceptions: `slider(&mut f64)`, `checkbox(&mut bool)`, `toggle(&mut bool)`. + ```rust + ui.text_input(&mut TextInputState); // not &mut String + ui.tabs(&mut TabsState); // not &mut usize + ``` + +5. **Responses.** Single-rect widgets return `Response`. Compound widgets return `Response: Deref` with `#[must_use]`. Never tuples. + ```rust + if ui.button("Save").clicked { ... } // Response + if let Some(i) = ui.breadcrumb(&segs).show().clicked_segment { ... } // BreadcrumbResponse + ``` + +6. **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). + + ```rust + ui.button("Save").bold(); // ❌ Response has no .bold() — compile error + if ui.button("Save").clicked { … } // ✅ Response field + ui.text("Saved").bold().fg(green); // ✅ &mut Self chain on display element + ``` + +## Naming categories (NAMING.md micro tier) + +Method names encode their category. When picking a name, match the category shape of nearby methods. + +| Category | Shape | Examples | +|---|---|---| +| **Verbs** (actions, side effects) | `` or `_` | `quit`, `notify`, `register_focusable`, `focus_by_name`, `consume_indices`, `set_ratio` | +| **Nouns** (getters, no side effects) | `` or `_` | `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(|ui| ...)` | +| 3 — Widget | `impl Widget for MyType { type Response; fn ui(...) }` | custom widget extension point | +| 4 — State | `pub struct State` in `src/widgets/*.rs` | `TextInputState`, `TableState`, `ScrollState` | +| 5 — Response | `Response` or `Response: Deref` | `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. + +```rust +// Stateful interactive widgets — &mut State, returns Response +ui.list(&mut ListState); ui.tabs(&mut TabsState); +ui.table(&mut TableState); ui.tree(&mut TreeState); +ui.select(&mut SelectState); ui.radio(&mut RadioState); +ui.multi_select(&mut MultiSelectState); +ui.file_picker(&mut FilePickerState); ui.calendar(&mut CalendarState); +ui.text_input(&mut TextInputState); ui.textarea(&mut TextareaState, rows); // textarea takes rows +ui.rich_log(&mut RichLogState); ui.command_palette(&mut CommandPaletteState); +ui.toast(&mut ToastState); ui.spinner(&SpinnerState); // spinner is &, not &mut + +// Trivial-value siblings — exception to Rule 4 +ui.button("Save"); // Response +ui.checkbox("Done", &mut done); // Response +ui.toggle("Enabled", &mut on); // Response +ui.slider("Vol", &mut value, 0.0..=100.0); // Response + +// Builder widgets — chain optional config, render on Drop +ui.gauge(0.6).label("60%").width(24).color(Color::Cyan); +ui.line_gauge(0.6).filled('━').empty('─').width(24).label("60%"); +let r = ui.breadcrumb(&segs).separator(" › ").color(Color::Cyan).show(); + +// Compound responses — Deref + extra fields +let r = ui.gauge(cpu).label("CPU").show(); // GaugeResponse { ratio: f64 } +let r = ui.breadcrumb(&segs).show(); // BreadcrumbResponse { clicked_segment: Option } +let r = ui.split_pane(&mut split, l, r); // SplitPaneResponse { ratio: f64 } +let r = ui.scrollable_with_gutter(&mut scroll, opts, body); // GutterResponse { current_highlight: Option } +``` + +## 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::("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 | + +```rust +// WRONG — order-based hook in conditional drifts call order between frames +if expanded { let count = ui.use_state(|| 0); } + +// RIGHT — id-keyed variant is safe inside conditionals +if expanded { let count = ui.use_state_named::("sidebar.count"); } + +// Per-list-item runtime keys +for i in 0..items.len() { + let count = ui.use_state_keyed_default::(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`. + +```rust +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::(); // panics if missing + // let maybe = ui.try_use_context::(); // returns Option<&T> + ui.text(format!("hi {} (tick {})", ctx.user, ctx.tick)); +} +``` + +Reserve explicit parameters for **writes** (`&mut MyDocState`). Bound is `T: 'static` — use `&'static str` for literals, `String` for runtime values. + +## Conditional styling (`with_if` / `with`) + +`with_if(cond, modifier)` and `with(modifier)` collapse conditional branches into a single chain. Available on text and `ContainerBuilder`. Beware: text uses `&mut self -> &mut Self`, ContainerBuilder uses consuming `Self -> Self`. + +```rust +// text — closure receives &mut Self +ui.text("Status").with_if(is_error, |t| { t.bold().fg(Color::Red); }); + +// ContainerBuilder — closure receives Self by value +ui.container().with_if(is_focused, |c| c.bg(theme.surface_hover)).col(|ui| ...); +``` + +## Custom widget pattern (Layer 3) + +**When to use which pattern**: +- *Function* (`fn render_card(ui: &mut Context, data: &CardData)`): 90% of cases. Use for screens, sections, reusable layouts. Cheaper to write, no trait bounds, easier to test. Built-in widgets follow this shape internally (`impl 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. + +```rust +struct Label<'a> { text: &'a str } + +impl<'a> slt::Widget for Label<'a> { + type Response = slt::Response; + fn ui(&mut self, ui: &mut slt::Context) -> Self::Response { + ui.register_focusable(); + ui.text(self.text).bold(); + slt::Response::default() + } +} + +ui.add(Label { text: "hello" }); // or call .ui(ui) directly +``` + +For mouse hit-testing use `ui.interaction(rect)`. For keyboard use `register_focusable()` + `available_key_presses()`. ## Authoring workflow -1. Confirm the goal. What app is the user building? Data table? Dashboard? Form? Game? -2. Check `docs/COOKBOOK.md` for a matching recipe (login / data table / modal+toast / dashboard / file picker). Start from that if it fits. -3. Otherwise, read `docs/COMPLETE_REFERENCE.md` (single condensed file) and grep `src/lib.rs` for the needed re-exports. -4. For state types, read `docs/STATE_APIS.md` — every public `*State` struct listed with methods. -5. Stick to the small core grammar: `ui.text / row / col / bordered / button / text_input / table / list / modal / toast / chart / canvas / tabs / select / tree / spinner`. For component composition (v0.19.0+): `ui.provide(...)` / `ui.use_context::()` / `ui.use_state_named(id)` / `.with_if(cond, modifier)`. -6. Before writing `ui.foo(...)`, grep `src/context/` to confirm the method exists. Do NOT invent APIs. -7. Run the quality gate (below) before saying "done". +1. Confirm the goal — what app? Data table? Dashboard? Form? Game? +2. Check `examples/` for the closest pattern (see Reference Examples below). Start from that file if it fits. +3. Grep `src/context/widgets_*` and `src/widgets/*` for the actual signature before writing `ui.foo(...)`. **Do NOT invent APIs.** +4. Keep `Cargo.toml` features minimal — see REFERENCES.md. +5. Run the quality gate before saying "done". ## Quality gate (mandatory before saying "done") @@ -59,9 +231,9 @@ cargo deny check ## Release workflow (mandatory — do not skip any step) -The project-level `CLAUDE.md` has the full 8-step checklist. The short version: +`CLAUDE.md` has the full 8-step checklist. Short version: -1. Local PRE-CI (Core + Extended gates both green) +1. Local PRE-CI (Core + Extended both green) 2. Bump `Cargo.toml`, update `CHANGELOG.md` 3. Branch `release/vX.Y.Z`, single atomic commit, push 4. `gh pr create`, **wait** for CI green @@ -70,35 +242,57 @@ The project-level `CLAUDE.md` has the full 8-step checklist. The short version: 7. Verify `gh release view`, crates.io, docs.rs 8. Only now announce -Red flags that mean STOP: -- "Probably fine" — run the gate -- "Just a docs change" — still run `cargo check --examples` -- "CI will catch it" — no, locals first -- "I'll tag now and fix later" — no broken tags +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/` before writing a `ui.*` call. -- **Using `Response.rect` on frame 1.** Zero Rect. Guard with `ui.tick() > 0` (see `docs/PREVIOUS_FRAME_GUIDE.md`). -- **`.unwrap()` in library paths.** `#![warn(clippy::unwrap_used)]` is on. Use `?` or explicit match. -- **`unsafe` blocks.** `#![forbid(unsafe_code)]` is on at crate root. Hard compile error. -- **Forgetting `'static` on `ContainerBuilder::draw()` closure.** Raw draw is deferred. -- **Mixing crossterm raw events with `ui.*` helpers.** Prefer `ui.key()`, `ui.key_code()`, `ui.key_mod()`. Raw events are for advanced cases only. -- **`use_state()` inside `if` / `match` / `for`.** Order-based hooks misbehave when call order changes between frames. Use `ui.use_state_named(id)` (id-keyed) for state inside conditionals. -- **Threading `&theme`, `&tick`, `&mut state` through every render fn.** v0.19.0+ has `ui.provide(value, |ui| ...)` + `ui.use_context::()` for cross-scope reads. Reserve explicit params for *writes*. -- **Hard-coding `Color::Rgb(...)` in widget code.** Pull from `ui.theme()` (`primary`, `text`, `border`, `selected_bg`, etc.) so themes can swap. v0.19.2 made `ThemeBuilder` `const fn` — themes can be defined at compile time. -- **`RichLogState::new()` for unbounded logs.** v0.19.2 capped `new()` at 10000 entries. Use `RichLogState::new_unbounded()` if you really want unlimited accumulation (tail-style log viewers). -- **Animating without `slt::Tween` / `slt::Spring`.** Don't reinvent — use the animation primitives. -- **Printing to stdout/stderr from a widget.** `#![warn(clippy::print_stdout)]` / `print_stderr`. A library must not write to stdout. - -## Reading order when stuck - -1. `docs/COMPLETE_REFERENCE.md` — condensed everything, start here. -2. `docs/COOKBOOK.md` — 5 full app recipes. -3. `docs/PATTERNS.md` — component composition (`provide` / `use_context` / `use_state_named` / `with_if`) and state-ownership idioms. -4. `src/lib.rs` — authoritative public re-exports. -5. `examples/` — 32 runnable examples; find the closest pattern. `demo_cjk` for CJK / wide-char rendering, `demo_website` for the canonical `provide` / `use_context` example. -6. If still stuck: ask the user. Korean conventions to honor: "ㄱㄱ" = proceed immediately, "켜줘" = open the file in Cursor (not `cat` to terminal). +- **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` | +| 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) @@ -108,25 +302,45 @@ use slt::{TestBackend, EventBuilder}; #[test] fn my_widget_renders() { let mut tb = TestBackend::new(80, 24); - tb.render(|ui| { - ui.text("hello"); - }); - assert!(tb.line(0).contains("hello")); + tb.render(|ui| { ui.text("hello"); }); + tb.assert_contains("hello"); +} + +// Multi-frame with events (warm prev-frame hit map first) +#[test] +fn click_triggers() { + let mut tb = TestBackend::new(40, 10); + tb.render(|ui| { ui.button("Save"); }); // warm frame + tb.run_with_events(vec![EventBuilder::mouse_down(2, 0)], // event frame + |ui| { if ui.button("Save").clicked { /* assert */ } }); } + +// Sequence builder (most readable) +tb.sequence().tick(5).key(KeyCode::Tab, KeyModifiers::NONE).type_string("hello", &mut state.value).run(); ``` -See `docs/TESTING.md` for event injection, multi-frame scenarios, and snapshot patterns. +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 / terminal backend | `src/terminal.rs`, `src/lib.rs` (`run`, `run_inline`, `run_async`) | -| Context / widget methods | `src/context/runtime.rs`, `src/context/widgets_*` | -| State types | `src/widgets/*.rs` | -| Layout | `src/layout/` (tree, flexbox, collect, render) | -| Style / theme | `src/style/` | +| 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/` | +| 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80ca9d3..cbb8fa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,12 +38,13 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - # `--test-threads=1` is required because `tests/v020_perf_alloc.rs` - # uses a global allocation counter; CI's higher parallelism than a - # local laptop causes other parallel tests to contaminate the count - # and trip its single-thread budget. Single-threaded execution adds - # ~10s but keeps the perf-budget asserts meaningful. - - run: cargo test --all-features -- --test-threads=1 + # `tests/v020_perf_alloc.rs` serialises every `#[test]` in the binary + # via the file-wide `measure_lock` mutex (see + # `tests/v020_perf_alloc.rs::enter_perf_test`), so the parallel test + # runner is safe — the previous `--test-threads=1` workaround was + # removed in v0.20.1 (#240) once the global-allocator-counter + # cross-test contamination was fixed at the source. + - run: cargo test --all-features clippy: name: Clippy diff --git a/CHANGELOG.md b/CHANGELOG.md index 9524476..0f1b687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [0.20.1] - 2026-04-29 + +### Deprecated + +- **`deprecated(layout)` — `Context::col_gap` and `Context::row_gap`** — The two-arg shorthand collides with `ContainerBuilder::col_gap` / `ContainerBuilder::row_gap`, which set the *row-finalize* / *column-finalize* main-axis gap (Tailwind `gap-x` / `gap-y` axis convention) and so mean the opposite thing despite the same name. Use `ui.container().gap(n).col(f)` / `.row(f)` instead — same output, no name collision. AI-generated code that hits the old form continues to compile with a deprecation warning until the planned v0.21+ removal. + +### Docs + +- **`docs(skills)` — `.claude/skills/slt/{SKILL,REFERENCES}.md` and `.claude/skills/slt-migration/SKILL.md` synced to v0.20** — Mental-model section trimmed, 5 API rules and 5 Layer model formalized, v0.20 removed-API table inlined (`gauge_w`, `gauge_colored`, `line_gauge_with`, `breadcrumb_sep`, `LineGaugeOpts`, `HighlightRange::single`, `label_owned`), Korean trigger phrases added, hook-ordering decision table for `use_state` / `use_state_named` / `use_state_keyed`, per-domain reference-example index. Migration skill version banner bumped from v0.19.2 → v0.20.0. +- **`docs(skills)` — Rule 6 (return-type pattern) added to `SKILL.md`** — `Context` methods return `&mut Self` for chainable display mutators (`text`, `link`, `styled`, style chain) or `Response` for interaction results (every stateful interactive widget plus `col` / `row` / `modal`); `line` / `line_wrap` / `screen` continue an inline-text chain so they return `&mut Self`. The split is the most common AI-generated compile error — chaining `.bold()` on `ui.button(...)` etc. — and now has a single discoverable rule. +- **`docs(skills)` — Custom widget pattern decision guide** — Function form (`fn render_card(ui, &data)`) covers the 90% case; `impl Widget` is for caller-owned state types and third-party crates exporting widgets through trait-bound APIs. Closes the "when do I pick which" gap that previously left readers to guess from the trait example alone. +- **`docs(rustdoc)` — `# Example` blocks added to the status family** — `badge`, `badge_colored`, `key_hint`, `stat`, `stat_colored`, `stat_trend`, `empty_state`, `empty_state_action` (`src/context/widgets_display/status.rs`) each carry a runnable `no_run` example. Closes the patch-safe doc-only audit gap flagged in v0.20.0. +- **`docs(rustdoc)` — `# Example` block added to `vsplit_pane`** — `src/context/widgets_display/split.rs:66` — paired with the existing `split_pane` example so both orientations are equally discoverable. +- **`docs(readme)` — Removed version-specific "v0.20 Demo Catalog"** — The 20-row table of `v020_*` demos with issue numbers belongs in `docs/EXAMPLES.md` (already linked from the same page) rather than the top-level README, which should stay timeless across minor releases. Replaced with a tighter "Demo Launcher" subsection pointing readers at the per-release catalog. Same applies to the inline `# All v0.20 demos at once` comment in the launcher snippet — generalized to "full feature-tour spread". + +### Refactor + +- **`refactor(examples)` / `refactor(tests)` — 25 demo and integration files migrated off `Context::col_gap` / `Context::row_gap`** to the explicit `ui.container().gap(n).col(f)` / `.row(f)` form. 22 example files (counter, demo, demo_design_system, cookbook_*, system_tour, v020_tour, v020_use_state_keyed, v020_modal_trap, v020_theme_subtree, v020_spacing_scale, anim, etc.) and 3 integration tests (`v020_interaction_regression`, `v020_theme_modal_demos`, `v020_widthspec_demo`). Output is byte-identical (same finalize path, same gap value) — the change is purely about removing the deprecated form so AI training data and downstream copy-paste land on the unambiguous shape from the start. + +### Fixed + +- **`fix(tests)` — `tests/v020_perf_alloc.rs` cross-test allocator contamination** — Every `#[test]` in the file now grabs the file-wide `measure_lock` mutex via the new `enter_perf_test()` helper on its first line. The pre-fix `measure_allocs` lock protected only the measurement critical section, so non-measuring sibling tests still ran concurrently and their `String::from(...)` / `Vec::new()` calls leaked into the global `ALLOC_COUNT`. Pattern manifested as flaky `framestate_reuse_steady_state` / `kitty_placement_flush` / `use_state_keyed_*` budget breaches whose noise scaled with macOS thread-cache timing. Root cause now fixed in source — the `--test-threads=1` workaround in `.github/workflows/ci.yml` (line 41-46) was removed in the same change. +- **`fix(buffer)` — `dead_code` warning under `--no-default-features`** — `Buffer::recompute_line_hashes`, `Buffer::row_clean`, `Buffer::row_hash` (`src/buffer.rs:475-540`) are gated on `#[cfg(any(feature = "crossterm", test))]`. The methods exist solely to support the per-row hash fast-path inside `flush_buffer_diff` (added in #171), which is itself behind the `crossterm` feature. Without the gate they showed as `dead_code` whenever the crate was built with `--no-default-features`, tripping `cargo check -p superlighttui --no-default-features` on a clean tree. No public-API change — methods stay `pub(crate)`. + +### CI + +- **`ci(test)` — Removed `--test-threads=1` from the Test job** — `.github/workflows/ci.yml` now runs `cargo test --all-features` with the default parallel runner. Justified by the `tests/v020_perf_alloc.rs` source fix above; CI run time drops by ~10 s on the typical SLT testsuite size. + ## [0.20.0] - 2026-04-28 ### Added diff --git a/Cargo.lock b/Cargo.lock index 6db77cc..4ba22f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -764,9 +764,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -1024,7 +1024,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "slt-wasm" -version = "0.20.0" +version = "0.20.1" dependencies = [ "js-sys", "superlighttui", @@ -1052,7 +1052,7 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "superlighttui" -version = "0.20.0" +version = "0.20.1" dependencies = [ "compact_str", "criterion", diff --git a/Cargo.toml b/Cargo.toml index f17d98b..2d1d792 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "crates/slt-wasm"] [package] name = "superlighttui" -version = "0.20.0" +version = "0.20.1" edition = "2021" description = "Super Light TUI - A lightweight, ergonomic terminal UI library" license = "MIT" diff --git a/README.md b/README.md index 3d90456..2dbb2fd 100644 --- a/README.md +++ b/README.md @@ -275,46 +275,18 @@ For composition advice, see [Patterns Guide]. | `inline` | `cargo run --example inline` | Inline rendering below a normal prompt | | `async_demo` | `cargo run --example async_demo --features async` | Background messages | -The full categorized index lives in [Examples Guide]. - -### v0.20 Demo Catalog - -Run any v020 demo directly: - -| Demo | Issue | Showcases | -|---|---|---| -| `v020_showcase` | (integration) | All v0.20 features on one screen | -| `v020_regression_panel` | (integration) | v0.19 + v0.20 cumulative regression check | -| `v020_dx_shortcuts` | #209/#210/#220/#221 | on_hover, animate_bool, fill, center_in | -| `v020_use_state_keyed` | #215 | Dynamic-string-keyed state | -| `v020_use_effect` | #216 | Dependency-tracked effects | -| `v020_named_focus` | #217 | register_focusable_named, focus_by_name | -| `v020_theme_subtree` | #226 | Per-subtree theme override | -| `v020_modal_trap` | #225 | Modal tab_trap focus containment | -| `v020_spacing_scale` | #227 | compact / comfortable / spacious presets | -| `v020_split_pane` | #223 | split_pane / vsplit_pane with drag handle | -| `v020_gauge` | #224 | gauge / line_gauge builder APIs | -| `v020_gutter_highlights` | #235 | scrollable_with_gutter, GutterOpts | -| `v020_breadcrumb_response` | #213 | Builder breadcrumb API | -| `v020_progress_response` | #212 | progress / spinner returning Response | -| `v020_static_log` | #233 | ui.static_log() append-only scrollback | -| `v020_keymap_help` | #236 | WidgetKeyHelp + auto help overlay | -| `v020_ctrl_c_passthrough` | #238 | RunConfig::handle_ctrl_c opt-out | -| `v020_widthspec` | #237 | WidthSpec / HeightSpec sampler | -| `v020_perf_audit` | #204/205/206/228 | Allocation + timing report (stdout) | -| `v020_test_utils` | #229–232 | record_frames / sequence / snapshot_format / negative asserts (stdout) | - -Or use the launcher script: +The full categorized index — including per-release feature tours and showcase demos — lives in [Examples Guide]. -```bash -# Interactive picker -./scripts/ghostty_demos.sh +### Demo Launcher -# All v0.20 demos at once (each in its own Ghostty window) -./scripts/ghostty_demos.sh --features +`scripts/ghostty_demos.sh` opens demos in fresh Ghostty windows, which is +useful for skimming the full set side by side. See [Examples Guide] for the +list of demos at each release. -# Just the integration demos -./scripts/ghostty_demos.sh --showcase +```bash +./scripts/ghostty_demos.sh # interactive picker +./scripts/ghostty_demos.sh --features # full feature-tour spread +./scripts/ghostty_demos.sh --showcase # integration showcases only ``` ## Custom Widgets And Backends diff --git a/crates/slt-wasm/Cargo.toml b/crates/slt-wasm/Cargo.toml index e3a9876..d9e260d 100644 --- a/crates/slt-wasm/Cargo.toml +++ b/crates/slt-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slt-wasm" -version = "0.20.0" +version = "0.20.1" edition = "2021" description = "WASM/browser backend for SuperLightTUI" license = "MIT" @@ -9,7 +9,7 @@ homepage = "https://github.com/subinium/SuperLightTUI" documentation = "https://docs.rs/slt-wasm" [dependencies] -superlighttui = { version = "0.20.0", path = "../..", default-features = false } +superlighttui = { version = "0.20.1", path = "../..", default-features = false } wasm-bindgen = "0.2" web-sys = { version = "0.3", features = [ "CssStyleDeclaration", diff --git a/examples/anim.rs b/examples/anim.rs index c24bedf..a30d0f9 100644 --- a/examples/anim.rs +++ b/examples/anim.rs @@ -170,7 +170,7 @@ fn main() -> std::io::Result<()> { if cb_tween.is_done() && !cb_fired { cb_fired = true; } - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { if cb_fired { ui.text("on_complete fired!").fg(Color::Green); } diff --git a/examples/canvas_tour.rs b/examples/canvas_tour.rs index def809f..8a7c83c 100644 --- a/examples/canvas_tour.rs +++ b/examples/canvas_tour.rs @@ -972,7 +972,7 @@ fn render_intro(ui: &mut Context) { } fn row_pair(ui: &mut Context, label: &str, desc: &str) { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("{label:<10}")).bold().fg(Color::Cyan); ui.text(desc).dim(); }); @@ -1431,7 +1431,7 @@ fn render_tetris_screen(ui: &mut Context, game: &TetrisGame, theme: Theme) { .w(game_w) .ml(left) .col(|ui| { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Single) .border_style(Style::new().fg(theme.border)) @@ -1509,7 +1509,7 @@ fn render_snake_screen(ui: &mut Context, game: &SnakeGame, theme: Theme) { .w(game_w) .ml(left) .col(|ui| { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Single) .border_style(Style::new().fg(theme.border)) @@ -1561,7 +1561,7 @@ fn render_minesweeper_screen(ui: &mut Context, game: &MinesweeperGame, theme: Th .w(game_w) .ml(left) .col(|ui| { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Single) .border_style(Style::new().fg(theme.border)) @@ -1973,7 +1973,7 @@ fn render_anim(ui: &mut Context, state: &mut AnimState) { if state.cb_tween.is_done() && !state.cb_fired { state.cb_fired = true; } - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { if state.cb_fired { ui.text("on_complete fired!").fg(Color::Green); } diff --git a/examples/cookbook_dashboard.rs b/examples/cookbook_dashboard.rs index 997137b..a2d1349 100644 --- a/examples/cookbook_dashboard.rs +++ b/examples/cookbook_dashboard.rs @@ -107,7 +107,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { .gap(1) .grow(1) .col(|ui| { - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { let _ = ui.bordered(Border::Single).p(1).grow(1).col(|ui| { let _ = ui.stat_colored("CPU", &format!("{:.1}%", m.cpu), Color::Cyan); }); @@ -130,11 +130,11 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { ); let spark_w = ui.width().saturating_sub(14).max(20); - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { ui.text("Memory").dim(); let _ = ui.sparkline(&mem_slice, spark_w); }); - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { ui.text("Req/s ").dim(); let _ = ui.sparkline(&req_slice, spark_w); }); diff --git a/examples/cookbook_modal_toast.rs b/examples/cookbook_modal_toast.rs index 0d0e66c..33a1762 100644 --- a/examples/cookbook_modal_toast.rs +++ b/examples/cookbook_modal_toast.rs @@ -67,7 +67,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { ui.text("Destructive actions need a confirmation modal.") .dim(); - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { ui.text(format!("Items remaining: {}", state.items_left)) .bold() .fg(Color::Cyan); @@ -92,7 +92,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { .gap(1) .col(|ui| { ui.text("Delete this item? This cannot be undone.").bold(); - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { if ui.button_with("Yes", ButtonVariant::Danger).clicked { state.items_left = state.items_left.saturating_sub(1); state.toasts.success("Deleted", tick); diff --git a/examples/cookbook_table.rs b/examples/cookbook_table.rs index 8f1579a..1967a54 100644 --- a/examples/cookbook_table.rs +++ b/examples/cookbook_table.rs @@ -70,7 +70,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { .gap(1) .grow(1) .col(|ui| { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text("Search:").dim(); let resp = ui.text_input(&mut state.search); if resp.changed { diff --git a/examples/cookbook_tour.rs b/examples/cookbook_tour.rs index 5d94b83..f950b96 100644 --- a/examples/cookbook_tour.rs +++ b/examples/cookbook_tour.rs @@ -184,7 +184,7 @@ fn render_intro(ui: &mut Context) { /// One label/description row for the intro recipe list. fn row_pair(ui: &mut Context, label: &str, desc: &str) { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("{label:<12}")).bold().fg(Color::Cyan); ui.text(desc).dim(); }); diff --git a/examples/counter.rs b/examples/counter.rs index 5613b7b..fece26b 100644 --- a/examples/counter.rs +++ b/examples/counter.rs @@ -19,7 +19,7 @@ fn main() -> std::io::Result<()> { .gap(1) .col(|ui| { ui.text("SLT Counter").bold().fg(Color::Cyan); - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { ui.text("Count:"); let color = if count >= 0 { Color::Green } else { Color::Red }; ui.text(format!("{count}")).bold().fg(color); diff --git a/examples/demo.rs b/examples/demo.rs index 5216774..627e932 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -651,7 +651,7 @@ fn render_page_tabs(ui: &mut Context, page_tabs: &mut TabsState) { for (row_idx, labels) in page_tabs.labels.chunks(split_at).enumerate() { let row_start = row_idx * split_at; - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { for (offset, label) in labels.iter().enumerate() { let tab_idx = row_start + offset; let clicked = if page_tabs.selected == tab_idx { @@ -1615,7 +1615,7 @@ fn render_v080( const ACCENT: slt::ContainerStyle = slt::ContainerStyle::new().bg(Color::Rgb(255, 107, 107)); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui.container().apply(&CARD).grow(1).col(|ui| { ui.text("Base card").bold(); ui.text("ContainerStyle::new().border(..).p(1)").dim(); @@ -1633,7 +1633,7 @@ fn render_v080( } section(ui, "ERROR BOUNDARY"); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .container() .grow(1) @@ -1664,7 +1664,7 @@ fn render_v080( section(ui, "DARK MODE"); card(ui, |ui| { - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { let _ = ui .container() .bg(Color::Rgb(240, 240, 240)) @@ -1688,7 +1688,7 @@ fn render_v080( section(ui, "RESPONSIVE LAYOUT"); card(ui, |ui| { ui.text(format!("Breakpoint: {:?}", ui.breakpoint())).dim(); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .container() .w(20) @@ -1759,7 +1759,7 @@ fn render_v080( let idx = *idx_state.get(ui); let (_name, ref custom) = presets[idx % presets.len()]; - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { for (i, (label, _)) in presets.iter().enumerate() { if i == idx { ui.text(format!("● {label}")).bold().fg(custom.primary); @@ -1769,7 +1769,7 @@ fn render_v080( } ui.text(" → applies to entire app").dim(); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text("■ Primary").fg(custom.primary); ui.text("■ Secondary").fg(custom.secondary); ui.text("■ Accent").fg(custom.accent); @@ -1795,7 +1795,7 @@ fn render_v080( let progress = val / 100.0; let _ = ui.progress(progress); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("Value: {:.0}", val)); if *v8_anim_done { ui.text("✓ on_complete fired!").fg(theme.success).bold(); @@ -1812,7 +1812,7 @@ fn render_v080( }); section(ui, "GROUP HOVER"); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { for name in &["Card A", "Card B", "Card C"] { let _ = ui .group(name) @@ -1833,7 +1833,7 @@ fn render_v080( let count_val = *counter.get(ui); let doubled = *ui.use_memo(&count_val, |c| c * 2); let tripled = *ui.use_memo(&count_val, |c| c * 3); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("Count: {count_val}")); ui.text(format!("×2 = {doubled}")).fg(theme.primary); ui.text(format!("×3 = {tripled}")).fg(theme.success); @@ -2184,7 +2184,7 @@ fn render_v014( rich_log.max_entries = Some(100); } - let _ = ui.col_gap(1, |ui| { + let _ = ui.container().gap(1).col(|ui| { let _ = ui .bordered(slt::Border::Rounded) .title("Gradient Text") @@ -2254,8 +2254,8 @@ fn render_v0141(ui: &mut Context) { .dim(); ui.text(""); - let _ = ui.col_gap(1, |ui| { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).col(|ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(slt::Border::Rounded) .title("Rust") @@ -2275,7 +2275,7 @@ fn render_v0141(ui: &mut Context) { }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(slt::Border::Rounded) .title("TypeScript") @@ -2295,7 +2295,7 @@ fn render_v0141(ui: &mut Context) { }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(slt::Border::Rounded) .title("C++") @@ -2315,7 +2315,7 @@ fn render_v0141(ui: &mut Context) { }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(slt::Border::Rounded) .title("Bash") @@ -2335,7 +2335,7 @@ fn render_v0141(ui: &mut Context) { }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(slt::Border::Rounded) .title("JSON") @@ -2364,7 +2364,7 @@ fn render_v0141(ui: &mut Context) { }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(slt::Border::Rounded) .title("HTML") @@ -2698,7 +2698,7 @@ fn render_v0152( .dim(); ui.text(""); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Rounded) .title("Table in Markdown") @@ -2745,7 +2745,7 @@ fn render_v0152( .dim(); ui.text(""); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Rounded) .title("Links") @@ -2785,7 +2785,7 @@ fn render_v0152( let focus_idx = ui.focus_index(); let focus_cnt = ui.focus_count(); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Rounded) .title("Focus State") @@ -2833,7 +2833,7 @@ fn render_v0152( // ── Markdown complex cases ────────────────────────────────────── let _ = ui.divider_text("Markdown — Complex Cases (v0.15.3+)"); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Rounded) .title("Wrapping + Links") @@ -2861,7 +2861,7 @@ fn render_v0152( }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Rounded) .title("Table with formatting") diff --git a/examples/demo_design_system.rs b/examples/demo_design_system.rs index 4adc9f0..79e14e1 100644 --- a/examples/demo_design_system.rs +++ b/examples/demo_design_system.rs @@ -113,7 +113,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { } // ── Header ─────────────────────────────────────────────────── - let _ = ui.col_gap(sp.xs(), |ui| { + let _ = ui.container().gap(sp.xs()).col(|ui| { ui.text("Design System Demo (v0.17)").bold().fg(primary); ui.text(format!( "Theme: {} | Left/Right cycle themes | t: toggle theme view | q: quit", @@ -125,11 +125,11 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { if state.show_themes { // ── Theme browser ──────────────────────────────────────── - let _ = ui.col_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).col(|ui| { ui.text("All Theme Presets").bold(); for (i, (name, t)) in state.themes.iter().enumerate() { let marker = if i == state.theme_idx { "> " } else { " " }; - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("{}{}", marker, name)).fg(t.primary).bold(); for (label, color) in [ ("pri", t.primary), @@ -149,10 +149,10 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { }); } else { // ── Showcase ───────────────────────────────────────────── - let _ = ui.col_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).col(|ui| { // Row 1: Style extends + ThemeColor ui.text("Style Extends + ThemeColor").bold(); - let _ = ui.row_gap(sp.xs(), |ui| { + let _ = ui.container().gap(sp.xs()).row(|ui| { let _ = ui.container().apply(&CARD).grow(1).col(|ui| { ui.text("CARD (base)").fg(surface_text); ui.text("theme_bg: Surface").fg(text_dim); @@ -173,7 +173,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { // Row 2: Spacing tokens ui.text("Spacing Tokens").bold(); - let _ = ui.row_gap(sp.xs(), |ui| { + let _ = ui.container().gap(sp.xs()).row(|ui| { let scale = Spacing::new(1); for (name, val) in [ ("xs", scale.xs()), @@ -190,7 +190,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { // Row 3: WidgetTheme + interactive widgets ui.text("WidgetTheme (buttons have cyan accent)").bold(); - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { let _ = ui.container().apply(&CARD).grow(1).col(|ui| { if ui.button("Increment").clicked { state.counter += 1; @@ -220,7 +220,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { ("Success", theme.success), ("Surface", theme.surface), ]; - let _ = ui.row_gap(sp.xs(), |ui| { + let _ = ui.container().gap(sp.xs()).row(|ui| { for (label, bg_color) in test_bgs { let fg = Color::contrast_fg(bg_color); let ratio = Color::contrast_ratio(fg, bg_color); diff --git a/examples/demo_game.rs b/examples/demo_game.rs index 51403fb..c0da699 100644 --- a/examples/demo_game.rs +++ b/examples/demo_game.rs @@ -828,7 +828,7 @@ fn render_tetris_screen(ui: &mut Context, game: &TetrisGame, theme: Theme) { .w(game_w) .ml(left) .col(|ui| { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Single) .border_style(Style::new().fg(theme.border)) @@ -906,7 +906,7 @@ fn render_snake_screen(ui: &mut Context, game: &SnakeGame, theme: Theme) { .w(game_w) .ml(left) .col(|ui| { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Single) .border_style(Style::new().fg(theme.border)) @@ -958,7 +958,7 @@ fn render_minesweeper_screen(ui: &mut Context, game: &MinesweeperGame, theme: Th .w(game_w) .ml(left) .col(|ui| { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { let _ = ui .bordered(Border::Single) .border_style(Style::new().fg(theme.border)) diff --git a/examples/demo_ime.rs b/examples/demo_ime.rs index 920e7d6..ae65d34 100644 --- a/examples/demo_ime.rs +++ b/examples/demo_ime.rs @@ -61,7 +61,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { .dim(); ui.separator(); - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { let _ = ui.container().grow(1).gap(1).col(|ui| { ui.text("Name").bold(); let _ = ui.text_input(&mut state.name); diff --git a/examples/showcase_tour.rs b/examples/showcase_tour.rs index b7fe7dd..a55baa5 100644 --- a/examples/showcase_tour.rs +++ b/examples/showcase_tour.rs @@ -212,7 +212,7 @@ fn render_intro(ui: &mut Context) { /// One label/description row for the intro example list. fn row_pair(ui: &mut Context, label: &str, desc: &str) { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("{label:<12}")).bold().fg(Color::Cyan); ui.text(desc).dim(); }); diff --git a/examples/system_tour.rs b/examples/system_tour.rs index 6e9106b..4253b01 100644 --- a/examples/system_tour.rs +++ b/examples/system_tour.rs @@ -163,7 +163,7 @@ fn render_intro(ui: &mut Context) { /// One label/description row for the intro feature list. fn row_pair(ui: &mut Context, label: &str, desc: &str) { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("{label:<9}")).bold().fg(Color::Cyan); ui.text(desc).dim(); }); diff --git a/examples/text_tour.rs b/examples/text_tour.rs index 2ae9fd8..4bae35f 100644 --- a/examples/text_tour.rs +++ b/examples/text_tour.rs @@ -181,7 +181,7 @@ fn render_intro(ui: &mut Context) { /// One label/description row for the intro feature list. fn row_pair(ui: &mut Context, label: &str, desc: &str) { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("{label:<8}")).bold().fg(Color::Cyan); ui.text(desc).dim(); }); diff --git a/examples/v020_gauge.rs b/examples/v020_gauge.rs index 60a2abe..53dce05 100644 --- a/examples/v020_gauge.rs +++ b/examples/v020_gauge.rs @@ -71,11 +71,11 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { ui.text("Single-line gauge with custom characters:") .fg(Color::Cyan); - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { ui.text("Default "); ui.line_gauge(0.6).label("60%").width(24); }); - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { ui.text("Hash/dot "); ui.line_gauge(0.45) .filled('#') @@ -83,7 +83,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { .width(24) .label("45%"); }); - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { ui.text("Block "); ui.line_gauge(0.85) .filled('█') @@ -99,7 +99,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { /// Render a single labelled `gauge` row with an auto-formatted percentage. fn metric_row(ui: &mut Context, label: &str, value: f64) { let sp = ui.spacing(); - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { ui.text(label); ui.gauge(value) .label(format!("{:.0}%", value * 100.0)) diff --git a/examples/v020_modal_trap.rs b/examples/v020_modal_trap.rs index 8ef2d98..dd56327 100644 --- a/examples/v020_modal_trap.rs +++ b/examples/v020_modal_trap.rs @@ -69,7 +69,7 @@ fn body(ui: &mut Context, state: &mut State) { // Three throwaway background buttons. They exist so the user can // see that focus genuinely escapes the modal scope when no trap // is active — and is held captive once the modal opens. - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { let _ = ui.button("First bg button"); let _ = ui.button("Second bg button"); let _ = ui.button("Third bg button"); @@ -97,7 +97,7 @@ fn body(ui: &mut Context, state: &mut State) { .gap(sp.xs()) .col(|ui| { ui.text("Press Tab — focus stays inside the modal.").bold(); - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { if ui.button_with("Yes", ButtonVariant::Primary).clicked { state.answered = Some(true); state.show_modal = false; diff --git a/examples/v020_named_focus.rs b/examples/v020_named_focus.rs index d09d3c2..7c5c4eb 100644 --- a/examples/v020_named_focus.rs +++ b/examples/v020_named_focus.rs @@ -125,7 +125,7 @@ fn render_input_rows(ui: &mut Context, state: &mut DemoState) { // `row_gap(...)` Response also captures clicks on the bare // "Name:"/"Email:"/"City:" label cells outside the input box, // routing them through the same `focus_by_name` call. - let name_row = ui.row_gap(row_gap, |ui| { + let name_row = ui.container().gap(row_gap).row(|ui| { ui.text("Name: "); let _ = ui.register_focusable_named("name"); let r = ui.container().fill().col(|ui| { @@ -139,7 +139,7 @@ fn render_input_rows(ui: &mut Context, state: &mut DemoState) { let _ = ui.focus_by_name("name"); } - let email_row = ui.row_gap(row_gap, |ui| { + let email_row = ui.container().gap(row_gap).row(|ui| { ui.text("Email:"); let _ = ui.register_focusable_named("email"); let r = ui.container().fill().col(|ui| { @@ -153,7 +153,7 @@ fn render_input_rows(ui: &mut Context, state: &mut DemoState) { let _ = ui.focus_by_name("email"); } - let city_row = ui.row_gap(row_gap, |ui| { + let city_row = ui.container().gap(row_gap).row(|ui| { ui.text("City: "); let _ = ui.register_focusable_named("city"); let r = ui.container().fill().col(|ui| { @@ -172,7 +172,7 @@ fn render_input_rows(ui: &mut Context, state: &mut DemoState) { /// Render three focus buttons. Each button targets a name; clicking it /// asks the focus system to jump on the next frame. fn render_focus_buttons(ui: &mut Context, gap: u32) { - let _ = ui.row_gap(gap, |ui| { + let _ = ui.container().gap(gap).row(|ui| { if ui.button("Focus name").clicked { let _ = ui.focus_by_name("name"); } diff --git a/examples/v020_spacing_scale.rs b/examples/v020_spacing_scale.rs index f78e5f9..c52696e 100644 --- a/examples/v020_spacing_scale.rs +++ b/examples/v020_spacing_scale.rs @@ -36,7 +36,7 @@ fn body(ui: &mut Context) { ui.text("compact = base 1, comfortable = base 2, spacious = base 3.") .dim(); - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { panel(ui, "compact", Theme::compact()); panel(ui, "comfortable", Theme::comfortable()); panel(ui, "spacious", Theme::spacious()); diff --git a/examples/v020_theme_subtree.rs b/examples/v020_theme_subtree.rs index 3a15a8a..11a5cfe 100644 --- a/examples/v020_theme_subtree.rs +++ b/examples/v020_theme_subtree.rs @@ -66,7 +66,7 @@ pub fn render(ui: &mut Context) { ui.text("Outer scope keeps its parent theme — nothing leaks across panels.") .dim(); - let _ = ui.row_gap(panel_gap, |ui| { + let _ = ui.container().gap(panel_gap).row(|ui| { for (label, theme) in theme_bench() { panel(ui, label, theme); } diff --git a/examples/v020_tour.rs b/examples/v020_tour.rs index 4458318..cfe4ffa 100644 --- a/examples/v020_tour.rs +++ b/examples/v020_tour.rs @@ -208,7 +208,7 @@ fn render_intro(ui: &mut Context) { /// One label/description row for the intro feature list. fn row_pair(ui: &mut Context, label: &str, desc: &str) { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("{label:<8}")).bold().fg(Color::Cyan); ui.text(desc).dim(); }); diff --git a/examples/v020_use_state_keyed.rs b/examples/v020_use_state_keyed.rs index 3ee4097..ca6f79f 100644 --- a/examples/v020_use_state_keyed.rs +++ b/examples/v020_use_state_keyed.rs @@ -108,7 +108,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) { } let row_gap = ui.spacing().xs(); - let _ = ui.row_gap(row_gap, |ui| { + let _ = ui.container().gap(row_gap).row(|ui| { let value = *counter.get(ui); let prefix = if i == selected { "▶" } else { " " }; let label = format!("{prefix} item {i:>2} count = {value:>4}"); diff --git a/examples/v020_widthspec.rs b/examples/v020_widthspec.rs index 4b56dd7..24a8d5a 100644 --- a/examples/v020_widthspec.rs +++ b/examples/v020_widthspec.rs @@ -119,7 +119,7 @@ pub fn render(ui: &mut Context) { // box starts at the same x — readers can compare resolved widths visually // without measuring against a moving baseline. fn row(ui: &mut Context, label: &str, content: F) { - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text(format!("{label: bool { if y < self.area.y { return false; @@ -533,6 +540,7 @@ impl Buffer { /// hash for clean rows is used as a short-circuit signal, so callers /// must check `row_clean` first. #[inline] + #[cfg(any(feature = "crossterm", test))] pub(crate) fn row_hash(&self, y: u32) -> Option { if y < self.area.y { return None; diff --git a/src/context/widgets_display/layout.rs b/src/context/widgets_display/layout.rs index eb1d9e4..f8cd01d 100644 --- a/src/context/widgets_display/layout.rs +++ b/src/context/widgets_display/layout.rs @@ -296,6 +296,15 @@ impl Context { /// Create a vertical (column) container with a gap between children. /// /// `gap` is the number of blank rows inserted between each child. + /// + /// **Deprecated since 0.20.1**: the name collides with + /// [`ContainerBuilder::col_gap`], which sets the *row-finalize* main-axis + /// gap (Tailwind `gap-x` axis convention) and so means the opposite thing. + /// Use `ui.container().gap(n).col(f)` instead — same output, no collision. + #[deprecated( + since = "0.20.1", + note = "Use `ui.container().gap(n).col(f)` instead — same output, no name collision with `ContainerBuilder::col_gap`." + )] pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response { self.push_container(Direction::Column, gap, f) } @@ -323,6 +332,16 @@ impl Context { /// Create a horizontal (row) container with a gap between children. /// /// `gap` is the number of blank columns inserted between each child. + /// + /// **Deprecated since 0.20.1**: the name collides with + /// [`ContainerBuilder::row_gap`], which sets the *column-finalize* + /// main-axis gap (Tailwind `gap-y` axis convention) and so means the + /// opposite thing. Use `ui.container().gap(n).row(f)` instead — same + /// output, no collision. + #[deprecated( + since = "0.20.1", + note = "Use `ui.container().gap(n).row(f)` instead — same output, no name collision with `ContainerBuilder::row_gap`." + )] pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response { self.push_container(Direction::Row, gap, f) } diff --git a/src/context/widgets_display/split.rs b/src/context/widgets_display/split.rs index 8ea17c2..42d65b8 100644 --- a/src/context/widgets_display/split.rs +++ b/src/context/widgets_display/split.rs @@ -62,7 +62,22 @@ impl Context { /// Vertical split container with a draggable handle. /// /// Mirrors [`Self::split_pane`] but stacks the panes vertically with a - /// 1-row horizontal divider (`─`) between them. + /// 1-row horizontal divider (`─`) between them. The handle is focusable; + /// arrow keys (`Up`/`Down`) adjust the ratio by 5% per press. + /// + /// # Example + /// + /// ```no_run + /// # use slt::SplitPaneState; + /// # let mut split = SplitPaneState::new(0.5); + /// # slt::run(|ui: &mut slt::Context| { + /// ui.vsplit_pane( + /// &mut split, + /// |ui| { ui.text("top pane"); }, + /// |ui| { ui.text("bottom pane"); }, + /// ); + /// # }); + /// ``` pub fn vsplit_pane( &mut self, state: &mut SplitPaneState, diff --git a/src/context/widgets_display/status.rs b/src/context/widgets_display/status.rs index cfe2340..0730c78 100644 --- a/src/context/widgets_display/status.rs +++ b/src/context/widgets_display/status.rs @@ -296,12 +296,31 @@ impl Context { } /// Render a badge with the theme's primary color. + /// + /// # Example + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// ui.badge("NEW"); + /// # }); + /// ``` pub fn badge(&mut self, label: &str) -> Response { let theme = self.theme; self.badge_colored(label, theme.primary) } /// Render a badge with a custom background color. + /// + /// Foreground is auto-selected for contrast via [`Color::contrast_fg`]. + /// + /// # Example + /// + /// ```no_run + /// # use slt::Color; + /// # slt::run(|ui: &mut slt::Context| { + /// ui.badge_colored("ALPHA", Color::Magenta); + /// # }); + /// ``` pub fn badge_colored(&mut self, label: &str, color: Color) -> Response { let fg = Color::contrast_fg(color); let mut label_text = String::with_capacity(label.len() + 2); @@ -314,6 +333,17 @@ impl Context { } /// Render a keyboard shortcut hint with reversed styling. + /// + /// # Example + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// ui.line(|ui| { + /// ui.text("Quit: "); + /// ui.key_hint("Ctrl+Q"); + /// }); + /// # }); + /// ``` pub fn key_hint(&mut self, key: &str) -> Response { let theme = self.theme; let mut key_text = String::with_capacity(key.len() + 2); @@ -326,6 +356,20 @@ impl Context { } /// Render a label-value stat pair. + /// + /// Renders as a column: a dim label above a bold value. Pair multiple + /// stats in a [`row`](Self::row) for a compact dashboard strip. + /// + /// # Example + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// ui.row(|ui| { + /// ui.stat("Users", "1.2k"); + /// ui.stat("Revenue", "$8,420"); + /// }); + /// # }); + /// ``` pub fn stat(&mut self, label: &str, value: &str) -> Response { let _ = self.col(|ui| { ui.text(label).dim(); @@ -336,6 +380,15 @@ impl Context { } /// Render a stat pair with a custom value color. + /// + /// # Example + /// + /// ```no_run + /// # use slt::Color; + /// # slt::run(|ui: &mut slt::Context| { + /// ui.stat_colored("Errors", "0", Color::Green); + /// # }); + /// ``` pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) -> Response { let _ = self.col(|ui| { ui.text(label).dim(); @@ -346,6 +399,22 @@ impl Context { } /// Render a stat pair with an up/down trend arrow. + /// + /// The arrow color follows the theme: `success` for [`Trend::Up`], + /// `error` for [`Trend::Down`]. + /// + /// [`Trend::Up`]: crate::widgets::Trend::Up + /// [`Trend::Down`]: crate::widgets::Trend::Down + /// + /// # Example + /// + /// ```no_run + /// # use slt::widgets::Trend; + /// # slt::run(|ui: &mut slt::Context| { + /// ui.stat_trend("MRR", "$24.5k", Trend::Up); + /// ui.stat_trend("Churn", "1.8%", Trend::Down); + /// # }); + /// ``` pub fn stat_trend( &mut self, label: &str, @@ -372,6 +441,20 @@ impl Context { } /// Render a centered empty-state placeholder. + /// + /// Title is rendered prominently; description is dimmed below. Both are + /// centered horizontally and vertically inside the available space. + /// + /// # Example + /// + /// ```no_run + /// # let items: Vec<&str> = vec![]; + /// # slt::run(|ui: &mut slt::Context| { + /// if items.is_empty() { + /// ui.empty_state("No items yet", "Press 'a' to add one"); + /// } + /// # }); + /// ``` pub fn empty_state(&mut self, title: &str, description: &str) -> Response { let _ = self.container().center().col(|ui| { ui.text(title).align(Align::Center); @@ -382,6 +465,23 @@ impl Context { } /// Render a centered empty-state placeholder with an action button. + /// + /// Returns a [`Response`] whose `clicked` field is `true` on the frame + /// the action button is activated. + /// + /// # Example + /// + /// ```no_run + /// # let items: Vec<&str> = vec![]; + /// # slt::run(|ui: &mut slt::Context| { + /// if items.is_empty() { + /// let r = ui.empty_state_action("No items yet", "Get started", "Add first item"); + /// if r.clicked { + /// // open create flow + /// } + /// } + /// # }); + /// ``` pub fn empty_state_action( &mut self, title: &str, diff --git a/tests/v020_interaction_regression.rs b/tests/v020_interaction_regression.rs index f22c75e..6dea5d1 100644 --- a/tests/v020_interaction_regression.rs +++ b/tests/v020_interaction_regression.rs @@ -225,7 +225,7 @@ fn render_modal_body(ui: &mut Context, state: &mut modal_trap_demo::State) { .grow(1) .col(|ui| { ui.text("test body").dim(); - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { let _ = ui.button("First bg button"); let _ = ui.button("Second bg button"); let _ = ui.button("Third bg button"); @@ -246,7 +246,7 @@ fn render_modal_body(ui: &mut Context, state: &mut modal_trap_demo::State) { .gap(sp.xs()) .col(|ui| { ui.text("Press Tab — focus stays inside the modal.").bold(); - let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().gap(sp.sm()).row(|ui| { if ui.button_with("Yes", ButtonVariant::Primary).clicked { state.answered = Some(true); state.show_modal = false; diff --git a/tests/v020_perf_alloc.rs b/tests/v020_perf_alloc.rs index 79d7483..b8c5726 100644 --- a/tests/v020_perf_alloc.rs +++ b/tests/v020_perf_alloc.rs @@ -2,17 +2,27 @@ //! //! Each test wraps a hot path (frame render, wrap_segments, kitty placement //! flush, dim_buffer modal) in a counting global allocator and asserts the -//! allocation count drops to a near-zero steady-state. The counter is global -//! and these tests run in sequence on a single thread (the test runner uses -//! 1 thread for #[ignore] suites by default; we don't `#[ignore]` here, but -//! the counter is per-allocation and noisy results from parallel tests are -//! filtered by measuring deltas before/after a specific operation). +//! allocation count drops to a near-zero steady-state. //! -//! NOTE: Cargo's test runner runs each `#[test]` in parallel by default. To -//! avoid cross-test contamination on the global counter, every test takes a -//! scoped `delta = ALLOC_COUNT.swap(...)` snapshot inside a critical section -//! gated by `MEASURING_LOCK`. Only one test "measures" at a time; the others -//! run without measuring. +//! # Cross-test isolation (root-cause fix, v0.20.1 #240) +//! +//! Cargo's test runner runs `#[test]` functions in parallel by default. The +//! `MEASURING` flag and `ALLOC_COUNT` counter are global, so any other test +//! thread that allocates while a measurement is in flight pollutes the count. +//! +//! Pre-fix the file relied on a `measure_lock` mutex held only inside +//! `measure_allocs()`, which protected nothing — non-measuring siblings still +//! ran concurrently and their `String::from(...)` / `Vec::new()` calls leaked +//! into the counter, producing noisy `1599 / 1937 / 65 / 145` budget breaches +//! whose pattern depended purely on macOS thread-cache timing. +//! +//! Fix: every `#[test]` in this file now grabs `measure_lock` at the top of +//! the function body. That serialises the *whole* file at the test-function +//! granularity (one binary = one mutex), so `cargo test --all-features` is +//! reliable even without `--test-threads=1`. `measure_allocs()` itself uses +//! `try_lock` since the caller already holds the guard — the only purpose of +//! the inner attempt is to belt-and-braces against future tests that forget +//! to call `enter_perf_test()` at the top. #![allow(clippy::unwrap_used)] @@ -41,15 +51,30 @@ unsafe impl GlobalAlloc for CountingAllocator { #[global_allocator] static GLOBAL: CountingAllocator = CountingAllocator; -/// Serializes the `MEASURING` toggle so concurrent tests don't pollute each -/// other's allocation deltas. +/// Serialises every `#[test]` in this file. Held by `enter_perf_test()` for +/// the lifetime of each test function so the parallel test runner cannot +/// interleave allocator activity from a sibling test into a measurement. fn measure_lock() -> &'static Mutex<()> { static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } +/// Acquire the file-wide test mutex. Every `#[test]` body must call this on +/// its first line and bind the returned guard to a `_guard` local — the +/// guard's `Drop` releases the mutex when the test function exits, so the +/// next test in line gets a clean global allocator state. +#[must_use = "binding the guard to `_guard` keeps the file-wide test serialisation alive"] +fn enter_perf_test() -> std::sync::MutexGuard<'static, ()> { + measure_lock().lock().unwrap() +} + fn measure_allocs(label: &'static str, f: impl FnOnce() -> R) -> (R, usize) { - let _guard = measure_lock().lock().unwrap(); + // The caller (each `#[test]` fn) already holds `measure_lock` via + // `enter_perf_test()`, so a recursive `lock()` would deadlock. `try_lock` + // is a belt-and-braces guard: if a future test forgets the file-wide + // `_guard`, the failed try_lock at least prevents two threads from + // toggling `MEASURING` at once. + let _maybe_guard = measure_lock().try_lock(); ALLOC_COUNT.store(0, Ordering::Relaxed); MEASURING.store(true, Ordering::Relaxed); let r = f(); @@ -65,6 +90,7 @@ fn measure_allocs(label: &'static str, f: impl FnOnce() -> R) -> (R, usize) { #[test] fn framestate_reuse_steady_state_alloc_count_low() { + let _guard = enter_perf_test(); use slt::TestBackend; let mut tb = TestBackend::new(80, 24); @@ -114,6 +140,7 @@ fn framestate_reuse_steady_state_alloc_count_low() { #[test] fn wrap_segments_alloc_count_low_via_bench_helper() { + let _guard = enter_perf_test(); // Build static segment fixtures once — keep all allocations of the // fixture out of the measured region so we only count what // `wrap_segments` itself drives. @@ -162,6 +189,7 @@ fn wrap_segments_alloc_count_low_via_bench_helper() { #[test] fn kitty_placement_flush_first_flush_one_arc_clone() { + let _guard = enter_perf_test(); // Each rgba Arc gets exactly +1 strong ref (the stored `prev_placements` // copy). The pre-fix code added an extra +1 per Arc per flush via the // `let adjusted: Vec = ... .iter().map(|p| p.clone())` @@ -184,6 +212,7 @@ fn kitty_placement_flush_first_flush_one_arc_clone() { #[test] fn kitty_placement_flush_steady_state_no_arc_growth() { + let _guard = enter_perf_test(); // After the first flush, repeated identical flushes must not bump any // Arc strong count — the fast-path returns early and the new in-place // rebuild only swaps the existing prev_placements entries. @@ -210,6 +239,7 @@ fn kitty_placement_flush_steady_state_no_arc_growth() { #[test] fn kitty_placement_flush_alloc_count_low() { + let _guard = enter_perf_test(); // Steady-state flushes must allocate near-zero. Pre-fix code allocated // a `Vec` per flush (+ a per-element `Arc::clone` // bookkeeping). Post-fix: only `Vec` sink growth on bytes written. @@ -241,6 +271,7 @@ fn kitty_placement_flush_alloc_count_low() { #[test] fn dim_buffer_modal_perimeter_not_area() { + let _guard = enter_perf_test(); // Direct call to the public bench helper that exposes modal-aware dim. use slt::buffer::Buffer; use slt::rect::Rect; @@ -288,6 +319,7 @@ fn dim_buffer_modal_perimeter_not_area() { #[test] fn dim_buffer_modal_full_screen_falls_back_correctly() { + let _guard = enter_perf_test(); use slt::buffer::Buffer; use slt::rect::Rect; @@ -312,6 +344,7 @@ fn dim_buffer_modal_full_screen_falls_back_correctly() { #[test] fn dim_buffer_modal_zero_size_falls_back_to_full() { + let _guard = enter_perf_test(); use slt::buffer::Buffer; use slt::rect::Rect; @@ -343,6 +376,7 @@ fn dim_buffer_modal_zero_size_falls_back_to_full() { #[test] fn use_state_keyed_allocates_one_string_per_call() { + let _guard = enter_perf_test(); // Differential test: compare a frame with N cache-hit calls to a // baseline frame with the same shape but no extra calls. The delta // is the marginal allocation cost of the N `use_state_keyed` calls. @@ -412,6 +446,7 @@ fn use_state_keyed_allocates_one_string_per_call() { #[test] fn use_state_keyed_cache_hit_scales_one_per_call() { + let _guard = enter_perf_test(); // Differential test: compare a small-N frame to a large-N frame on a // warmed TestBackend. The marginal-cost model (large - small) cancels // render-internal one-off allocations that don't scale with call count. @@ -489,6 +524,7 @@ fn use_state_keyed_cache_hit_scales_one_per_call() { /// must re-emit placements at the new offset. #[test] fn kitty_flush_resize_reemits() { + let _guard = enter_perf_test(); let mut fx = slt::__bench_new_kitty_fixture(3); let mut sink: Vec = Vec::new(); @@ -552,6 +588,7 @@ fn kitty_flush_resize_reemits() { /// frame would trip if the rollback failed to restore the stack. #[test] fn framestate_reuse_buffers_restored_on_error_boundary_panic() { + let _guard = enter_perf_test(); use slt::TestBackend; // ── Frame 1: normal render to populate FrameState reuse buffers. ──── diff --git a/tests/v020_theme_modal_demos.rs b/tests/v020_theme_modal_demos.rs index e877552..8a9bd8a 100644 --- a/tests/v020_theme_modal_demos.rs +++ b/tests/v020_theme_modal_demos.rs @@ -21,7 +21,7 @@ fn render_theme_subtree(ui: &mut Context) { .grow(1) .col(|ui| { ui.text("Each panel uses container().theme(...).").dim(); - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { panel(ui, "Dark", Theme::dark()); panel(ui, "Light", Theme::light()); }); @@ -100,7 +100,7 @@ fn render_spacing_scale(ui: &mut Context) { .p(1) .grow(1) .col(|ui| { - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { density_panel(ui, "compact", Theme::compact()); density_panel(ui, "comfortable", Theme::comfortable()); density_panel(ui, "spacious", Theme::spacious()); @@ -138,7 +138,7 @@ fn demo_spacing_scale_compact_visually_denser_than_spacious() { // padding difference is observable. let mut tb = TestBackend::new(80, 16); tb.render(|ui| { - let _ = ui.row_gap(2, |ui| { + let _ = ui.container().gap(2).row(|ui| { density_panel(ui, "compact", Theme::compact()); density_panel(ui, "spacious", Theme::spacious()); }); diff --git a/tests/v020_widthspec_demo.rs b/tests/v020_widthspec_demo.rs index 7f9c9a4..ab31584 100644 --- a/tests/v020_widthspec_demo.rs +++ b/tests/v020_widthspec_demo.rs @@ -20,7 +20,7 @@ fn widthspec_demo_renders_all_five_variants() { .fg(Color::Cyan) .bold(); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text("Fixed(20)"); let _ = ui .bordered(Border::Single) @@ -30,7 +30,7 @@ fn widthspec_demo_renders_all_five_variants() { }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text("Pct(50)"); let _ = ui .bordered(Border::Single) @@ -40,7 +40,7 @@ fn widthspec_demo_renders_all_five_variants() { }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text("Ratio(1,3)"); let _ = ui .bordered(Border::Single) @@ -50,7 +50,7 @@ fn widthspec_demo_renders_all_five_variants() { }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text("MinMax(10,30)"); let _ = ui .bordered(Border::Single) @@ -60,7 +60,7 @@ fn widthspec_demo_renders_all_five_variants() { }); }); - let _ = ui.row_gap(1, |ui| { + let _ = ui.container().gap(1).row(|ui| { ui.text("Auto"); let _ = ui.bordered(Border::Single).col(|ui| { ui.text("AutoVar").fg(Color::White);