diff --git a/.claude/skills/slt-migration/SKILL.md b/.claude/skills/slt-migration/SKILL.md new file mode 100644 index 0000000..d0b5d2f --- /dev/null +++ b/.claude/skills/slt-migration/SKILL.md @@ -0,0 +1,398 @@ +--- +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". +--- + +# SLT Migration Skill (from ratatui / cursive / textual) + +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. + +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. + +## When to use + +Trigger when any of the following are true: +- 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. +- 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. + +## Mental model translation + +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)` | +| 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) | + +**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. + +**textual** (Python): retained App+Widget classes, CSS-like styling, `compose()` yields widgets, `on_*` event handlers. SLT replaces all of that with one closure and chained method calls. + +## ratatui → SLT mapping + +### Run loop + +ratatui (typical): +```rust +let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; +terminal::enable_raw_mode()?; +crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; +loop { + terminal.draw(|f| ui(f, &mut app))?; + if let Event::Key(key) = event::read()? { + if key.code == KeyCode::Char('q') { break; } + // ... dispatch to app + } +} +crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; +terminal::disable_raw_mode()?; +``` + +SLT equivalent: +```rust +fn main() -> std::io::Result<()> { + let mut app = App::default(); + slt::run(|ui| { + if ui.key('q') { 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. + +### Widget mapping (top ratatui widgets) + +Every SLT method below has been confirmed to exist in `src/context/widgets_*.rs`. + +| ratatui | SLT | +|---|---| +| `Block::default().borders(Borders::ALL).title("X")` | `ui.bordered(Border::Rounded).title("X").col(\|ui\| { ... })` | +| `Block::default().borders(Borders::ALL).border_type(BorderType::Rounded)` | `ui.bordered(Border::Rounded).col(...)` | +| `Block::default().borders(Borders::TOP \| Borders::BOTTOM)` | `ui.bordered(Border::Single).border_sides(BorderSides::horizontal()).col(...)` | +| `Paragraph::new("text")` | `ui.text("text")` | +| `Paragraph::new("text").wrap(Wrap { trim: true })` | `ui.text("text").wrap()` | +| `Paragraph::new("text").alignment(Alignment::Center)` | `ui.text("text").text_center()` | +| `List::new(items).highlight_style(...)` | `ui.list(&mut 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) | +| `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) | +| `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 | + +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)`. + +### Layout mapping + +ratatui: +```rust +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]); +``` + +SLT: +```rust +ui.col(|ui| { + ui.container().h(3).col(|ui| { /* header */ }); + ui.container().grow(1).col(|ui| { /* body — fills remaining */ }); + ui.container().h(1).col(|ui| { /* footer */ }); +}); +``` + +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` | + +**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. + +### State mapping + +| ratatui | SLT (re-exported via `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) | + +All state types are re-exported at crate root. Confirmed lines 146–153 of `src/lib.rs`. + +### Event mapping + +ratatui reads crossterm events directly. SLT exposes a higher-level event query +API that handles edge-detect, focus routing, and key consumption: + +| 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::ScrollUp` | `if ui.scroll_up() { ... }` | + +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. + +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. + +### Style mapping + +| ratatui | SLT | +|---|---| +| `Style::default().fg(Color::Red)` | `Style::new().fg(Color::Red)` | +| `Style::default().add_modifier(Modifier::BOLD)` | `Style::new().bold()` | +| `Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)` | `Style::new().fg(Color::Red).bold()` | +| `Style::default().bg(Color::Blue)` | `Style::new().bg(Color::Blue)` | +| Per-text styling: `Span::styled("x", style)` | Chain on the call: `ui.text("x").fg(Color::Red).bold()` | +| `Modifier::DIM` | `.dim()` | +| `Modifier::ITALIC` | `.italic()` | +| `Modifier::UNDERLINED` | `.underline()` | +| `Modifier::REVERSED` | `.reversed()` | +| `Modifier::CROSSED_OUT` | `.strikethrough()` | + +`Style` is `Copy` in both libraries — no need to clone. + +### 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. + +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. + +### 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). + +## cursive → SLT mapping + +cursive is callback-driven and runs its own event loop. SLT replaces both +patterns 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`) | +| `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 | + +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. + +## textual (Python) → SLT mapping + +textual is class-based with reactive state and CSS. SLT is functional with +plain variables. + +| textual | SLT | +|---|---| +| `class App(App)` with `compose()` yielding widgets | A `slt::run(\|ui\| { ... })` closure with imperative method calls | +| `reactive` attributes | Plain Rust variables (`let mut count: i32 = 0;`) 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 | +| `Input(placeholder="...")` | `ui.text_input(&mut TextInputState::with_placeholder("..."))` | +| `DataTable` | `ui.table(&mut TableState::new(...))` | +| `ScrollableContainer` | `ui.scrollable(&mut ScrollState::new()).col(\|ui\| ...)` | +| `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::*`) | + +## 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. + +## Migration workflow + +1. **Inventory ratatui widgets used.** From the project root: + ```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 + +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`. + +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). +- ratatui repo: +- cursive repo: +- textual repo: diff --git a/.claude/skills/slt/REFERENCES.md b/.claude/skills/slt/REFERENCES.md index 173b2c2..2e33ee2 100644 --- a/.claude/skills/slt/REFERENCES.md +++ b/.claude/skills/slt/REFERENCES.md @@ -21,19 +21,21 @@ Load this only when the user asks about specific feature flags, or when building ## Doc pointers -- `docs/COMPLETE_REFERENCE.md` — full API, single-file, ~1500 lines (LLM-optimized) +- `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 +- `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 +- `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 -- `docs/TESTING.md` — `TestBackend`, `EventBuilder`, snapshot patterns +- `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 +- `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/` — 25+ runnable examples +- `examples/` — 32 runnable examples (highlights: `demo_cjk` CJK / wide-char rendering, `demo_website` `provide` / `use_context` composition, `demo_dashboard` full layout) ## Release / deployment reference diff --git a/.claude/skills/slt/SKILL.md b/.claude/skills/slt/SKILL.md index 0877b39..c7c4b3c 100644 --- a/.claude/skills/slt/SKILL.md +++ b/.claude/skills/slt/SKILL.md @@ -19,13 +19,20 @@ layout for N hasn't happened yet, so `Response.rect` reflects frame N-1. On fram it's a zero `Rect`. For measurement-dependent logic, guard with `if ui.tick() > 0 { /* use rect */ }`. 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`. + ## 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 / toast / spinner`. +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". @@ -77,6 +84,10 @@ Red flags that mean STOP: - **`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. @@ -84,9 +95,10 @@ Red flags that mean STOP: 1. `docs/COMPLETE_REFERENCE.md` — condensed everything, start here. 2. `docs/COOKBOOK.md` — 5 full app recipes. -3. `src/lib.rs` — authoritative public re-exports. -4. `examples/` — 25+ runnable examples; find the closest pattern. -5. If still stuck: ask the user. Korean conventions to honor: "ㄱㄱ" = proceed immediately, "켜줘" = open the file in Cursor (not `cat` to terminal). +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). ## Testing pattern (headless) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1025781..3afb018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,67 @@ ## [Unreleased] +## [0.19.3] — 2026-04-27 + +Patch release covering 11 v0.19.x patch-safe issues plus 6 cross-cutting +extensions framing SLT in terms of broader UI library patterns (CSS / Flutter / +React Native positioning, performance budget, migration guidance, visual +snapshot regression infrastructure). + +### Added + +- **`feat(layout)` — `Anchor` enum + `overlay_at` / `modal_at`** (#200) — 9-cell positioning (`TopLeft`, `TopCenter`, …, `BottomRight`). Maps to CSS `place-self`, Flutter `Align(alignment:)`, React Native `position: absolute`. +- **`feat(layout)` — `overlay_at_offset(anchor, dx, dy, …)` / `modal_at_offset(…)`** — CSS `inset`-style offset on top of 9-cell anchor. Sign convention: positive `(dx, dy)` always inset toward viewport center. Mapping documented in `docs/POSITIONING.md`. +- **`feat(layout)` — `DebugLayer::{All, TopMost, BaseOnly}`** (#201) + `Context::set_debug_layer` / `debug_layer()` — F12 overlay scoped to a single layer. `All` is the default → no API call needed for the reported case. +- **`feat(api)` — `min_h` / `max_h` breakpoint variants** (#147) — `xs_min_h` / `sm_min_h` / `md_min_h` / `lg_min_h` / `xl_min_h` / `min_h_at` and same for `max_h`. Symmetric with `min_w` / `max_w` breakpoint coverage. +- **`feat(skill)` — `.claude/skills/slt-migration/SKILL.md`** (398 lines) — Migration skill mapping `ratatui` / `cursive` / `textual` → SLT with grep-verified API references. +- **`feat(test)` — `tests/visual_snapshots.rs` + 5 baselines** — Visual snapshot regression infrastructure using `insta`. Catches layout drift, border render bugs, theme color shifts, CJK width issues. Baselines: `demo`, `demo_dashboard`, `demo_cjk`, `demo_infoviz`, `demo_overlay_anchor`. +- **`docs(positioning)` — `docs/POSITIONING.md`** (286 lines) — CSS `place-self` / Flutter `Align`+`Positioned` / React Native `position: absolute` ↔ SLT `Anchor` mapping with migration recipes. +- **`docs(performance)` — `docs/PERFORMANCE.md`** (336 lines) — 60 fps frame budget, allocation budget, 6 optimization patterns, comparison vs React / Flutter / UIKit / ratatui, regression detection workflow. +- **`docs(migration)` — `docs/MIGRATION.md`** (234 lines) — v0.19 → v0.20 migration guide with deprecation table + sed-based codemod + comparison vs React / Vue / Angular / Flutter migration tooling. +- **`example` — `examples/demo_overlay_anchor.rs`** — 9 anchor positions + 4 inset corners using `overlay_at_offset`. + +### Fixes + +- **`fix(layout)` overlay `align(End)/justify(End)` rendered at center** (#200 part 2) — root cause in `src/layout/flexbox.rs`: overlay sizing block hard-coded shrink-and-center, starving any inner `grow`. New `any_grow` heuristic expands the wrapper to full area when a child has `grow > 0`; legacy behavior preserved otherwise. +- **`fix(layout)` `container.grow(1).draw(|buf, rect|)` inside overlay didn't render** (#200 part 3) — same root cause: 0×0 wrapper rect was being skipped. Same fix resolves both bugs together. +- **`fix(layout)` F12 debug overlay skipped `node.overlays`** (#201 part A) — `render_debug_overlay` now walks both `node.children` and `node.overlays`, matching `count_leaf_widgets`. Default-on; no API call needed. + +### Perf + +- **`perf(container)` integer `isqrt` for `filled_circle`** (#146) — Newton's method replaces `f64::sqrt()` round-trip. MSRV 1.81 blocks `u64::isqrt` (1.84+); migrate when MSRV bumps. +- **`perf(layout)` `LayoutNode` size 432 → 320 bytes (~26 % reduction)** (#153) — 6 text-only fields extracted into `Box`. Spacer / Container / RawDraw nodes (the majority) now pay 8 bytes (`Option>`) instead of ~120 bytes of always-`None` fields. `const _ASSERT_LAYOUT_NODE_SIZE` regression guard at `tree.rs:3-15`. +- **`perf(layout)` `commands` Vec capacity reused via `FrameState.commands_buf`** (#150) — eliminates per-frame Vec allocation in `Context::new`. +- **`perf(layout)` `FrameData` Vec capacity reused via `&mut FrameData`** (#155) — `collect_all` signature changed to `(&LayoutNode, &mut FrameData)`. 8 Vec allocations per frame eliminated. +- **`perf(layout)` `wrap_segments` `line_segs` capacity hint** (#157) — `Vec::with_capacity(segments.len().min(16))` reduces early growth churn on text wrap path. +- **`perf(layout)` viewport bound check before bottom border corner** (#162) — gates `set_char` on `bottom_i < viewport_bottom`. No functional change (OOB writes were silently skipped); saves up to 2 `set_char` per scrolled border frame. + +### Refactor + +- **`refactor(api)` deprecate long-form aliases** (#148) — `pad()` / `min_width()` / `max_width()` / `min_height()` / `max_height()` are now `#[deprecated(since = "0.20.0")]`. Use short forms: `p()` / `min_w()` / `max_w()` / `min_h()` / `max_h()`. Internal callers updated. Migration guide in `docs/MIGRATION.md`. +- **`refactor(debug)` F12 per-layer color tagging** — Base = green family, Overlay = red, Modal = blue. Status bar adds breakdown: `14 widgets (8 base, 5 overlay, 1 modal)`. Inspired by Chrome DevTools / React DevTools / Flutter Inspector layer color conventions. +- **`refactor(layout)` `group_name: Option>` confirmed in `LayoutNode`** (#152) — already shipped earlier in v0.19.x; collect-side conversion is now a pointer bump (atomic increment) rather than heap alloc. + +### Docs + +- **`docs(skill)` SLT skill (`.claude/skills/slt/`) v0.19.x sync** — v0.19.0 component DX (`provide` / `use_context` / `use_state_named` / `with_if`), `RichLogState` bounded default, `ThemeBuilder` `const fn`, `EventBuilder` v0.19.1 chain wrappers. +- **`docs(audit)` 17 docs files audited and synced to v0.19.x** — BLOCKING: `AppCtx` `'static` lifetime fix in AI_GUIDE / COOKBOOK examples (owned `Theme` via `*ui.theme()` Copy pattern, mirroring `examples/demo_website.rs`). HIGH: 16 leaked GitHub issue refs scrubbed from prose. MEDIUM: `COMPLETE_REFERENCE` version banner update, `WIDGETS` separator path corrected, `EXAMPLES` `demo_cjk` row added with audit prose cleanup, `llms.txt` `try_get` signature corrected and `demo_website` / `demo_cjk` added to examples list. +- **`docs(testing)` visual snapshot regression workflow** — section in `docs/TESTING.md` covering `cargo test --test visual_snapshots`, `cargo insta review` flow, scope of detection. +- **`docs(debugging)` F12 layer-color reading guide** — section in `docs/DEBUGGING.md` describing color tagging and status-line breakdown. + +### Tests + +- **7 new regression tests** for #200 (overlay anchor + 2 bug fixes) and #201 (F12 walks overlays, layer color distinction, count breakdown). +- **5 visual snapshot baselines** committed in `tests/snapshots/visual__*.snap`. + +### Asset cleanup + +- Removed orphan assets (~6.1 MB): `assets/tui-builders-demo.gif`, `assets/demo_tetris.png`, `examples/demo_wiki.rs` (depended on private `assets/blackpink/`, broke external builds). + +### Notes + +This release closes the v0.19.x perf / refactor backlog (11 patch-safe issues) plus pairs with the external reporter promise on #200 / #201. Breaking-change issues (#98, #134, #149, #161, #184, #192, #193) remain deferred to v0.20.0. #102 (textarea undo / redo) remains blocked on `cargo-semver-checks` private-field rule; #171 (line-hash flush skip) remains blocked on bench gate. 3-pass review (5 author agents → 10 independent reviewers → 5 post-fix reviewers) per group; full Core + Extended Gate green at tag. + ## [0.19.2] — 2026-04-27 Patch release covering 34 v0.19.x issues plus two long-standing visual regressions diff --git a/Cargo.lock b/Cargo.lock index afbc44a..77fb4cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1052,7 +1052,7 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "superlighttui" -version = "0.19.2" +version = "0.19.3" dependencies = [ "compact_str", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 2fecb50..f303570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "crates/slt-wasm"] [package] name = "superlighttui" -version = "0.19.2" +version = "0.19.3" edition = "2021" description = "Super Light TUI - A lightweight, ergonomic terminal UI library" license = "MIT" @@ -142,10 +142,6 @@ path = "examples/demo_website.rs" name = "demo_infoviz" path = "examples/demo_infoviz.rs" -[[example]] -name = "demo_wiki" -path = "examples/demo_wiki.rs" - [[example]] name = "cookbook_dashboard" path = "examples/cookbook_dashboard.rs" @@ -182,6 +178,10 @@ path = "examples/demo_kitty_image.rs" name = "demo_cjk" path = "examples/demo_cjk.rs" +[[example]] +name = "demo_overlay_anchor" +path = "examples/demo_overlay_anchor.rs" + [[bench]] name = "benchmarks" harness = false diff --git a/_typos.toml b/_typos.toml index e5ec2bc..3c5f893 100644 --- a/_typos.toml +++ b/_typos.toml @@ -11,4 +11,12 @@ "flate2" = "flate2" [files] -extend-exclude = ["CHANGELOG.md", "Cargo.lock", "AUDIT-REPORT.md", "docs/README.*.md"] +extend-exclude = [ + "CHANGELOG.md", + "Cargo.lock", + "AUDIT-REPORT.md", + "docs/README.*.md", + # Visual regression snapshots can contain truncated text the typos + # checker misreads as misspellings (e.g. "anc" cut from "anchor"). + "tests/snapshots/visual__*.snap", +] diff --git a/assets/demo_tetris.png b/assets/demo_tetris.png deleted file mode 100644 index 1756969..0000000 Binary files a/assets/demo_tetris.png and /dev/null differ diff --git a/assets/tui-builders-demo.gif b/assets/tui-builders-demo.gif deleted file mode 100644 index 12242ce..0000000 Binary files a/assets/tui-builders-demo.gif and /dev/null differ diff --git a/docs/AI_GUIDE.md b/docs/AI_GUIDE.md index f5a1cb1..12bebc7 100644 --- a/docs/AI_GUIDE.md +++ b/docs/AI_GUIDE.md @@ -49,6 +49,22 @@ Pass `WidgetColors::new().fg(color).bg(color)` as the last argument. See `docs/T `use_state()` and `use_memo()` must be called in the same order every frame. Never put them inside conditionals. +Exception (v0.19.0): `ui.use_state_named(id)` is the id-keyed variant and IS safe inside `if`/`match` branches because it keys by the supplied `&'static str` instead of call order. Reach for it when you genuinely need a hook in a conditional path. + +```rust +// Wrong: order-based hook inside a conditional drifts the call order. +if expanded { + let count = ui.use_state(|| 0); // BAD — frame N has it, frame N+1 may not. +} + +// Right: id-keyed variant is safe inside conditionals. +if expanded { + let count = ui.use_state_named::("sidebar.count"); // OK +} +``` + +The original `use_state` / `use_memo` order rule still holds — only the id-keyed `*_named` variants opt out of it. + ### "How do I handle keyboard shortcuts?" Use `key(c)`, `key_code(code)`, or `key_mod(c, mods)`. For modal-aware shortcuts use the regular versions. For global shortcuts that bypass modals, use `raw_key_code()` or `raw_key_mod()`. For key sequence detection use `key_seq("gg")`. @@ -85,6 +101,40 @@ For the frame timeline and prev-frame rect rules, see [Previous Frame Guide](PRE - Use `palette::tailwind` colors instead of hardcoding RGB values. - Check `docs/FEATURES.md` before using feature-gated APIs. +### Context injection: stop threading shared state through every render fn + +When a value is *read* by many nested render functions (theme, current tick, current user, toast bus), do not thread `&theme`, `&tick`, `&mut toasts` parameters through every signature. Use `ui.provide(value, |ui| ...)` once near the root, then have nested code read it back with `ui.use_context::()` (panics if missing) or `ui.try_use_context::()` (returns `Option<&T>`). Added in v0.19.0. + +```rust +// `provide` boxes the value as `dyn Any`, so the type must satisfy `T: 'static`. +// `Theme` is `Copy`, so deref-copy from `ui.theme()`. Use `&'static str` for +// string literals; switch to `String` if the value comes from runtime input. +struct AppCtx { + theme: slt::Theme, + tick: u64, + user: &'static str, +} + +slt::run(|ui: &mut slt::Context| { + 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::(); + ui.text(format!("hi {} (tick {})", ctx.user, ctx.tick)); +} +``` + +Reserve explicit parameters for **writes** (e.g. `&mut MyDocState`) — those should still be passed in, not read out of the context bag. See [PATTERNS.md](PATTERNS.md) for the full pattern. + +### Conditional styling without re-chaining + +`with_if(cond, modifier)` and `with(modifier)` (v0.19.0) let you fold conditional styling into a single fluent chain on text and `ContainerBuilder`. Replace `if cond { t.bold(); t.fg(Color::Red); }` style branching with `ui.text(...).with_if(is_error, |t| t.bold().fg(Color::Red))`. Cleaner diffs, no broken chains. + ## Internal widget rules for agents When editing SLT built-in widgets inside `src/context/*`, prefer the internal interaction helpers instead of hand-rolling event scans again. diff --git a/docs/ANIMATION.md b/docs/ANIMATION.md index 815648f..66cd043 100644 --- a/docs/ANIMATION.md +++ b/docs/ANIMATION.md @@ -72,7 +72,7 @@ slt::run(|ui: &mut Context| { Key methods: - `Spring::new(initial, stiffness, damping)` — constructor - `stiffness`: acceleration per unit displacement (`0.1`..`0.5`) - - `damping`: velocity decay per tick, `< 1.0` (`0.8`..`0.95`) + - `damping`: per-tick velocity multiplier, must satisfy `0.0 < damping < 1.0` (`0.8`..`0.95`). Both bounds are enforced via `debug_assert!` in `Spring::new` (v0.19.1); release builds do not panic but values outside this range conserve or amplify energy and never settle. - `.on_settle(fn)` — callback when settled (builder) - `.set_target(value)` — change the goal position (interactive use) - `.tick()` — advance simulation by one frame (call once per frame) @@ -81,6 +81,8 @@ Key methods: Spring does not use `reset(tick)`. Call `.tick()` every frame and `.set_target()` to change direction. +> **Damping note**: This `damping` is *not* the standard ODE damping ratio ζ — it is a velocity multiplier applied each tick (`velocity *= damping` after the spring force). A value of `1.0` would conserve energy (eternal oscillation); `> 1.0` would amplify it. The recommended `0.80..=0.95` range covers fast-settle to slow-bouncy UI feel. + ### Keyframes Multi-stop timeline animation, like CSS `@keyframes`. Each segment between stops can use its own easing. @@ -103,9 +105,9 @@ let brightness = kf.value(ui.tick()); Key methods: - `Keyframes::new(duration_ticks)` — constructor -- `.stop(position, value)` — add a stop at normalized position `[0.0, 1.0]` +- `.stop(position, value)` — add a stop at normalized position `[0.0, 1.0]`. Stops are kept sorted by `position` after every call, so the order in which you append them does not matter — a `.stop(0.7, 100.0)` after `.stop(1.0, 40.0)` lands in the right slot. Code that relied on insertion order to identify segments will see the sorted-by-time order instead. - `.easing(fn)` — default easing for all segments -- `.segment_easing(index, fn)` — override easing for segment `index` (0 = first-to-second stop) +- `.segment_easing(index, fn)` — override easing for segment `index` (0 = first-to-second stop). Out-of-range indices are silently ignored in release builds (preserving the panic-free guarantee for runtime code) and trigger a `debug_assert!` panic in debug builds (v0.19.1) so builder-order mistakes — calling `segment_easing(2, ...)` before three stops have been added — surface during development. - `.loop_mode(mode)` — set loop behavior - `.on_complete(fn)` — callback when done - `.reset(tick)` — start/restart @@ -171,6 +173,7 @@ Key methods: - `.reset(tick)` — start/restart - `.value(tick, item_index) -> f64` — sample value for a specific item - `.is_done() -> bool` — true if the most recently sampled item finished +- `.is_all_done(tick, item_count) -> bool` — true once **every** item has finished, computed from pure tick arithmetic (v0.19.1). Use this when you need a single completion signal that does not depend on which item happened to be sampled last. With `LoopMode::Repeat` / `PingPong` it only reports `true` for the first cycle (loops re-enter after completion). ## Easing functions @@ -347,7 +350,7 @@ ui.text("->").ml(pos); | Builder | `.easing(fn)`, `.delay(ticks)`, `.items(count)`, `.loop_mode(mode)`, `.on_complete(fn)` | | Control | `.reset(tick)` | | Sample | `.value(tick, item_index) -> f64` | -| Done | `.is_done() -> bool` | +| Done | `.is_done() -> bool` (last-sampled item), `.is_all_done(tick, item_count) -> bool` (every item) | ## Related docs diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 3c806c6..281279e 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -66,6 +66,16 @@ The built-in terminal backends are intentionally boring: This matters because SLT is trying to be easy at the API layer without becoming sloppy underneath. +### Buffered stdout (v0.19.1) + +Both `Terminal` and `InlineTerminal` wrap stdout in `BufWriter::with_capacity(65536, _)`. Every queued ANSI sequence — cursor moves, style deltas, raw sequences, Kitty placements — accumulates in a 64 KiB buffer and is committed with a single `flush()` per frame. This collapses what was previously dozens to thousands of individual `write` syscalls per frame into one, which materially reduces overhead for high-frequency rendering (charts, animations, image-heavy frames). + +The contract for custom backends is unchanged: `Backend::flush()` is still called once per frame and may itself defer or batch writes however it likes. + +### `kitty_keyboard` honored in inline mode (v0.19.1) + +`InlineTerminal::new` now reads `RunConfig::kitty_keyboard` and propagates it through `TerminalSessionGuard`. Earlier versions hardcoded inline-mode kitty keyboard support to `false` regardless of config; both full-screen and inline runs now agree on the flag. + ## `RunConfig` in practice `RunConfig` is the runtime policy object for all built-in loops. @@ -238,6 +248,27 @@ What still remains: That is what makes SLT usable as a rendering core instead of only a terminal runtime. +## Sixel auto-detection (v0.19.1) + +`ui.sixel_image(...)` is gated by a runtime check that asks "does this terminal speak sixel?" before emitting any sixel sequence. Unsupported terminals see the reserved cell area but no garbled escape output. + +Detection logic, in order: + +1. `SLT_FORCE_SIXEL=1` (also `true` / `yes` / `on`) — explicit override, returns true unconditionally. Use this for patched-xterm-with-sixel, embedded targets, or testing. +2. Exact-match `TERM` against the known-good list: `mlterm`, `foot`, `yaft`, `xterm-256color-sixel`. +3. Substring match: `TERM` contains `sixel` (catches custom builds and forks). +4. `TERM_PROGRAM` is `foot` or `mlterm`. + +Pre-v0.19.1 used `term.contains("xterm")`, which fired on the default `xterm-256color` `TERM` value used by macOS Terminal.app, VS Code's integrated terminal, and most SSH clients — none of which actually parse sixel. Output appeared as raw escape junk in the scrollback. The new exact-match list fixes the false positive while still covering the terminals that do support the protocol. + +If you ship an app for end users on unknown terminals, prefer the half-block (`ui.image`) or Kitty (`ui.kitty_image`) path; sixel is a niche protocol and even the "supported" list above varies in fidelity. + +## `image()` is one RawDraw command (v0.19.1) + +`ui.image(&half_block_image)` previously emitted one `Command::RawDraw` per cell plus one `String` allocation per cell — for a modest 40×20 half-block image, that worked out to ~841 commands and ~800 transient `String` allocations per frame. The implementation now wraps the whole image in a single `container().draw(closure)` call: one `RawDraw`, one closure capture, the inner double-loop runs against the buffer directly with no per-cell allocation. + +This matters most for animated image content (frame-by-frame video, sprite scrolling), where the per-frame allocation pressure is what was previously dominating CPU. The widget API (`ui.image(&half)`) is unchanged — the optimization is purely internal. + ## Related APIs - `docs/FEATURES.md` - feature-gated runtime behavior diff --git a/docs/COMPETITIVE_ANALYSIS.md b/docs/COMPETITIVE_ANALYSIS.md index 668ed1a..7f65040 100644 --- a/docs/COMPETITIVE_ANALYSIS.md +++ b/docs/COMPETITIVE_ANALYSIS.md @@ -1,6 +1,6 @@ # SLT Competitive Analysis & Roadmap -**Date**: 2026-04-07 (v0.17.1) +**Date**: 2026-04-27 (v0.19.2) **Scope**: Feature-level comparison against ratatui, Textual, Ink, Bubbletea + prioritized development roadmap --- @@ -78,7 +78,7 @@ Widget counts are not perfectly apples-to-apples across frameworks, so this docu | Feature | SLT | Ratatui | Textual | Ink | Bubbletea | |---|---|---|---|---|---| | State model | Closure + hooks | Manual struct | Reactive attrs | React hooks | Elm MVU | -| Hooks | ✅ use_state, use_memo | ❌ | ❌ | ✅ Full React | ❌ | +| Hooks | ✅ use_state, use_memo, use_state_named (id-keyed; safe in conditionals), provide / use_context (scoped state injection) | ❌ | ❌ | ✅ Full React | ❌ | | Event bubbling | ❌ | ❌ | ✅ | ✅ | ❌ | | Error boundary | ✅ | ❌ | ❌ | ✅ | ❌ | | Custom widgets | ✅ Widget trait | ✅ Widget trait | ✅ Class inheritance | ✅ React components | ✅ Model interface | @@ -129,7 +129,7 @@ Widget counts are not perfectly apples-to-apples across frameworks, so this docu --- -## Completed Roadmap (v0.14.0–v0.14.1) +## Completed Roadmap (v0.14.0 – v0.19.2) ### v0.14.0 — Ecosystem Foundation @@ -169,6 +169,44 @@ Widget counts are not perfectly apples-to-apples across frameworks, so this docu | — | Terminal session hardening | ✅ | | — | Interaction allocator unification | ✅ | +### v0.18.x — Performance + Hardening + +| ID | Feature | Status | +|---|---|---| +| #62 | Flush coalescing — single ANSI emit per frame | ✅ | +| #64 | Command enum boxing — shrinks the per-frame command stream | ✅ | +| #67 | Flexbox U32Stack scratch — no per-frame allocations in layout | ✅ | +| — | NO_COLOR env var support | ✅ | +| — | `scroll_col` + `draw_with` helpers | ✅ | +| — | `try_get` for safer state access | ✅ | + +### v0.19.0 — Component DX + +| ID | Feature | Status | +|---|---|---| +| — | `provide` / `use_context::()` — scoped state injection (no parameter threading) | ✅ | +| — | `use_state_named(id)` — id-keyed local state, safe inside conditionals | ✅ | +| — | `with_if(cond, modifier)` / `with(modifier)` — fluent conditional styling on text and ContainerBuilder | ✅ | + +### v0.19.1 — Output + Image Perf + +| ID | Feature | Status | +|---|---|---| +| — | BufWriter stdout (1 flush/frame) | ✅ | +| — | `image()` reduced to 1 RawDraw/frame (was 841) | ✅ | +| — | Sixel exact-match detection | ✅ | +| #131 | EventBuilder chain wrappers (`mouse_up`, `drag`, `key_release`, `focus_gained`, `focus_lost`) | ✅ | +| #160 | CJK title clamp regression coverage | ✅ | + +### v0.19.2 — Theme + Status DX + +| ID | Feature | Status | +|---|---|---| +| #108 | `mx` / `my` margin shorthands | ✅ | +| — | `ThemeBuilder` const fn — compile-time theme construction | ✅ | +| #182 | `breadcrumb_response` / `breadcrumb_response_with` returning `(Response, Option)` | ✅ | +| — | `RichLogState` bounded default (no unbounded growth) | ✅ | + --- ## Remaining Roadmap diff --git a/docs/COMPLETE_REFERENCE.md b/docs/COMPLETE_REFERENCE.md index b735f27..2715e35 100644 --- a/docs/COMPLETE_REFERENCE.md +++ b/docs/COMPLETE_REFERENCE.md @@ -1,6 +1,6 @@ # SuperLightTUI — Complete Reference (LLM-optimized) -> Version: 0.18.1. This document condenses the full SLT API and common patterns into one file. An LLM agent should be able to load this alone and generate any normal SLT app without further reads. +> Version: 0.19.2. This document condenses the full SLT API and common patterns into one file. An LLM agent should be able to load this alone and generate any normal SLT app without further reads. --- @@ -59,6 +59,7 @@ Rules you will use every file: | `slt::run_async(|ui, msgs: &mut Vec| { ... })` | Requires `async` feature. Returns `tokio::sync::mpsc::Sender` for pushing messages to the UI. | | `slt::run_async_with(config, f)` | Async + config. | | `slt::frame(&mut backend, &mut state, &config, &events, &mut f)` | Low-level per-frame driver for custom backends. Returns `Ok(true)` to keep going, `Ok(false)` when quit. | +| `slt::frame_owned(&mut backend, &mut state, &config, events, &mut f)` | Same as `frame()` but takes `Vec` by value (zero-copy when callers already own a vector — no slice→Vec clone). | `RunConfig` (builder, all setters consume `self`): @@ -70,6 +71,7 @@ slt::RunConfig::default() .theme(Theme::dracula()) .color_depth(ColorDepth::TrueColor) .max_fps(60) + .no_fps_cap() // disable the FPS limit (sets max_fps = None) .scroll_speed(3) .title("My App") .widget_theme(WidgetTheme::new().button(WidgetColors::new().accent(Color::Cyan))) @@ -96,7 +98,7 @@ slt::RunConfig::default() | `AppState` | Opaque session state passed to `frame()`. | | `RunConfig` | Per-run configuration. | | `TestBackend` | Headless backend for tests: `TestBackend::new(w, h).render(|ui| ...)`, then `assert_contains`/`line`/`to_string_trimmed`. | -| `EventBuilder` | `EventBuilder::new().key('a').key_code(KeyCode::Enter).click(5, 2).scroll_up(x, y).paste("txt").resize(w, h).build()`. | +| `EventBuilder` | `EventBuilder::new().key('a').key_code(KeyCode::Enter).click(5, 2).mouse_up(x, y).drag(x, y).key_release('c').focus_gained().focus_lost().scroll_up(x, y).paste("txt").resize(w, h).build()`. | ### Style and layout | Type | Notes | @@ -104,7 +106,7 @@ slt::RunConfig::default() | `Style` | `Style::new().fg(c).bg(c).bold().italic().dim().underline().reversed().strikethrough()`. | | `Color` | `Color::Rgb(r, g, b)`, `Color::Indexed(u8)`, `Color::Named` (all 17 ANSI: `Black`, `Red`, `Green`, `Yellow`, `Blue`, `Magenta`, `Cyan`, `White`, `DarkGray`, `LightRed`, `LightGreen`, `LightYellow`, `LightBlue`, `LightMagenta`, `LightCyan`, `LightWhite`), `Color::Reset`. Helpers: `.blend(o, a)`, `.lighten(f)`, `.darken(f)`, `.luminance()`, `.contrast_ratio(a, b)`, `.meets_contrast_aa(a, b)`, `.contrast_fg(bg)`, `.downsampled(depth)`. | | `ColorDepth` | `TrueColor`, `EightBit`, `Basic`. `ColorDepth::detect()` auto-detects from `$COLORTERM`/`$TERM`. | -| `Modifiers` | Bitflags: `BOLD`, `DIM`, `ITALIC`, `UNDERLINE`, `REVERSED`, `STRIKETHROUGH`. | +| `Modifiers` | Bitflags: `BOLD`, `DIM`, `ITALIC`, `UNDERLINE`, `REVERSED`, `STRIKETHROUGH`. Methods: `.contains(o)`, `.is_empty()`, `.remove(o)` (clears bits in-place). | | `Border` | `None`, `Single`, `Rounded`, `Double`, `Heavy`, `Thick`, `Dashed`, `Dotted`, `Ascii`. | | `BorderSides` | bitflags: `TOP`, `RIGHT`, `BOTTOM`, `LEFT`, or combos. | | `Padding` | `{ top, right, bottom, left: u32 }`. | @@ -122,8 +124,8 @@ slt::RunConfig::default() ### Theming | Type | Notes | |---|---| -| `Theme` | 17-field struct. `#[non_exhaustive]`. Use presets or `Theme::builder()`. Presets: `Theme::dark()` (default), `light()`, `dracula()`, `catppuccin()`, `nord()`, `solarized_dark()`, `solarized_light()`, `tokyo_night()`, `gruvbox_dark()`, `one_dark()`. | -| `ThemeBuilder` | `.primary(c).secondary(c).accent(c).text(c).text_dim(c).border(c).bg(c).success(c).warning(c).error(c).selected_bg(c).selected_fg(c).surface(c).surface_hover(c).surface_text(c).is_dark(b).spacing(sp).build()`. | +| `Theme` | 17-field struct. `#[non_exhaustive]`. Use presets or `Theme::builder()`. Presets: `Theme::dark()` (default), `light()`, `dracula()`, `catppuccin()`, `nord()`, `solarized_dark()`, `solarized_light()`, `tokyo_night()`, `gruvbox_dark()`, `one_dark()`. Builder entry points: `Theme::builder()` (empty), `Theme::builder_from(base: Theme) -> ThemeBuilder` (pre-fill from any theme), `Theme::light_builder() -> ThemeBuilder` (shortcut for `builder_from(Theme::light())`). | +| `ThemeBuilder` | `.primary(c).secondary(c).accent(c).text(c).text_dim(c).border(c).bg(c).success(c).warning(c).error(c).selected_bg(c).selected_fg(c).surface(c).surface_hover(c).surface_text(c).is_dark(b).spacing(sp).build()`. All setters and `build()` are `const fn` — themes can be defined in `const` context. | | `ThemeColor` | Semantic tokens: `Primary`, `Secondary`, `Accent`, `Text`, `TextDim`, `Border`, `Bg`, `Success`, `Warning`, `Error`, `SelectedBg`, `SelectedFg`, `Surface`, `SurfaceHover`, `SurfaceText`, `Info`, `Link`, `FocusRing`, `Custom(Color)`. Resolve via `ui.color(ThemeColor::X)` or `ui.theme().resolve(t)`. | | `Spacing` | `{ base: u32 }`. Methods: `none()`, `xs()`, `sm()`, `md()`, `lg()`, `xl()`, `xxl()`. `Spacing::new(2)` doubles everything. `ui.spacing()` returns the current one. | | `WidgetColors` | `WidgetColors::new().fg(c).bg(c).border(c).accent(c).theme_fg(t).theme_bg(t).theme_border(t).theme_accent(t)`. Pass to `_colored` variants. | @@ -140,7 +142,7 @@ slt::RunConfig::default() | `MouseEvent` | `{ kind: MouseKind, x: u32, y: u32, modifiers: KeyModifiers, pixel_x: Option, pixel_y: Option }`. | | `MouseKind` | `Down(MouseButton)`, `Up(MouseButton)`, `Drag(MouseButton)`, `Moved`, `ScrollUp`, `ScrollDown`, `ScrollLeft`, `ScrollRight`. | | `MouseButton` | `Left`, `Right`, `Middle`. | -| `KeyMap` | Declarative keymap used to drive `help_from_keymap(&km)`. Register via `.bind(char, desc)`, `.bind_code(KeyCode, desc)`, `.bind_mod(char, mods, desc)`, `.bind_hidden(char, desc)`. Input matching is still your responsibility via `ui.key(...)`. | +| `KeyMap` | Declarative keymap used to drive `help_from_keymap(&km)`. Register via `.bind(char, desc)`, `.bind_code(KeyCode, desc)`, `.bind_mod(char, mods, desc)`, `.bind_code_mod(KeyCode, mods, desc)` (any KeyCode + modifiers, e.g. Ctrl+Enter), `.bind_hidden(char, desc)`. Input matching is still your responsibility via `ui.key(...)`. | | `Binding` | Entry in a `KeyMap` (`{ key, modifiers, display, description, visible }`). | ### Animation @@ -150,7 +152,7 @@ slt::RunConfig::default() | `Spring` | `Spring::new(initial, stiffness, damping)`. `.set_target(v)`, `.tick()`, `.value() -> f64`. | | `Keyframes` | Timeline with stops. `Keyframes::new(duration).at(0.0, 0.0).at(0.5, 100.0).at(1.0, 0.0).loop_mode(LoopMode::Loop)`. | | `Sequence` | Chained tween segments in order. | -| `Stagger` | `Stagger::new(from, to, duration).delay(ticks).items(count)`. `.value(tick, item_index)`. | +| `Stagger` | `Stagger::new(from, to, duration).delay(ticks).items(count)`. `.value(tick, item_index)`. `.is_done()` reports completion of the last sampled item; `.is_all_done(tick, item_count) -> bool` reports completion of every item in the batch. | | `LoopMode` | `Once`, `Loop`, `PingPong`. | | Easings (under `slt::anim::*`) | `ease_linear`, `ease_in_quad`, `ease_out_quad`, `ease_in_out_quad`, `ease_in_cubic`, `ease_out_cubic`, `ease_in_out_cubic`, `ease_out_elastic`, `ease_out_bounce`. | @@ -254,6 +256,8 @@ Legend: `Response = { clicked, hovered, changed, focused, rect }`. `&mut Self` m | `ui.confirm(question, &mut result)` | Yes/No dialog; `result` set on click. | | `ui.breadcrumb(&[&str]) -> Option` | Clickable breadcrumb; returns clicked segment. | | `ui.breadcrumb_with(&[&str], separator) -> Option` | Custom separator. | +| `ui.breadcrumb_response(&[&str]) -> (Response, Option)` | Same as `breadcrumb()` but also exposes the row `Response` (hover/focus/rect). | +| `ui.breadcrumb_response_with(&[&str], separator) -> (Response, Option)` | Custom separator + `Response`. The plain `breadcrumb()` / `breadcrumb_with()` are wrappers that drop the `Response`. | | `ui.help(&[(&str, &str)])` | Key/description help bar. `ui.help_colored(bindings, key_color, text_color)`. | | `ui.help_from_keymap(&keymap)` | From a `KeyMap`. | @@ -317,7 +321,8 @@ Finalization: | `ui.button(label)` | — | Click button. Variants: `button_colored(label, &colors)`, `button_with(label, ButtonVariant)` (`Default`, `Primary`, `Danger`, `Outline`). | | `ui.checkbox(label, &mut bool)` / `checkbox_colored` | — | Checkbox toggle. | | `ui.toggle(label, &mut bool)` / `toggle_colored` | — | Toggle switch. | -| `ui.slider(label, &mut f64, range)` | — | Horizontal slider, `range: RangeInclusive`. | +| `ui.slider(label, &mut f64, range)` | — | Horizontal slider, `range: RangeInclusive`. Default step is `span / 20`. | +| `ui.slider_with_step(label, &mut f64, range, step)` | — | Slider with explicit step size — use when the default step is too coarse/fine (integers need `1.0`, fine controls `0.1`). | | `ui.text_input(&mut state)` / `text_input_colored` | `TextInputState` | Single-line input. | | `ui.textarea(&mut state, visible_rows)` | `TextareaState` | Multi-line editor. | | `ui.select(&mut state)` / `select_colored` | `SelectState` | Dropdown. | diff --git a/docs/COOKBOOK.md b/docs/COOKBOOK.md index 6141f7a..923bde7 100644 --- a/docs/COOKBOOK.md +++ b/docs/COOKBOOK.md @@ -11,6 +11,8 @@ Recipes assume familiarity with the core mental model from [QUICK_START.md](QUIC - [Modal Confirmation with Toast](#modal-confirmation-with-toast) — `cargo run --example cookbook_modal_toast` - [Real-time Dashboard with Charts](#real-time-dashboard-with-charts) — `cargo run --example cookbook_dashboard` - [File Picker with Preview](#file-picker-with-preview) — `cargo run --example cookbook_file_picker` +- [Components with Shared State](#components-with-shared-state) — `provide` / `use_context` (v0.19.0) +- [Common pitfalls](#common-pitfalls) ## Prerequisites @@ -19,7 +21,7 @@ Add SLT to a fresh project: ```toml # Cargo.toml [dependencies] -superlighttui = "0.18" +superlighttui = "0.19" ``` Every recipe follows the same outer shape: @@ -614,6 +616,105 @@ fn read_preview(path: &std::path::Path) -> Result { --- +## Components with Shared State + +### What it shows + +- `ui.provide(value, |ui| ...)` to publish shared app context once at the root. +- `ui.use_context::()` (panics if missing) and `ui.try_use_context::()` (returns `Option<&T>`) to read it back from any nested render fn. +- Replaces threading `&theme`, `&tick`, `&user` parameters through every signature. + +The canonical real-world refactor is `examples/demo_website.rs`, which moved from per-call argument threading to one `provide` at the top. + +### When to reach for it + +- Many nested render helpers all want the same read-only value (theme, tick, current user). +- You catch yourself adding the same parameter to every render fn signature. + +Reserve explicit parameters for **writes** (`&mut MyDocState`, `&mut ToastState`). `provide` / `use_context` is a *read*-side ergonomics tool, not a state container. + +### Full code + +```rust +use slt::{Border, Color, Context, KeyCode, Theme}; + +// `provide` boxes the value as `dyn Any`, requiring `T: 'static`. `Theme` is +// `Copy`, so deref-copy from `ui.theme()`. `&'static str` works for string +// literals; switch to `String` for runtime values. +struct AppCtx { + theme: Theme, + tick: u64, + user: &'static str, +} + +fn main() -> std::io::Result<()> { + slt::run(|ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + + let ctx = AppCtx { + theme: *ui.theme(), + tick: ui.tick(), + user: "subin", + }; + + ui.provide(ctx, |ui| { + let _ = ui + .bordered(Border::Rounded) + .title("Shared context demo") + .pad(1) + .gap(1) + .col(|ui| { + render_header(ui); + render_card(ui); + }); + }); + }) +} + +fn render_header(ui: &mut Context) { + let ctx = ui.use_context::(); + ui.text(format!("hi, {}", ctx.user)).bold().fg(Color::Cyan); + ui.text(format!("tick {}", ctx.tick)).dim(); +} + +fn render_card(ui: &mut Context) { + // Optional read — never panics if no provider is on the stack. + if let Some(ctx) = ui.try_use_context::() { + ui.text(format!("theme bg: {:?}", ctx.theme.bg)); + } else { + ui.text("no app context").dim(); + } +} +``` + +### Key patterns + +- `provide` is scoped to the closure body. Once that body returns, the value pops off the context stack. +- `use_context::()` finds the nearest provided `T` by type. Two providers of the same type stack — the inner one wins inside its body. +- Use `try_use_context` in helpers that should also work outside the provider (e.g. unit tests, isolated demos). + +--- + +## Common pitfalls + +### `RichLogState::new()` silently truncates at 10000 entries + +`RichLogState::new()` is bounded at 10000 entries (v0.19.2). Older entries are dropped to keep memory predictable. If you are running a long-lived dashboard or log viewer and entries seem to "disappear" from the top, that is the cap, not a bug. + +For unbounded accumulation use `RichLogState::new_unbounded()` and accept the memory cost, or add your own retention policy on top of the bounded variant. + +```rust +// Bounded — drops oldest after 10k entries. +let mut log = slt::RichLogState::new(); + +// Unbounded — grows until the process dies. Use only when you control the input rate. +let mut log = slt::RichLogState::new_unbounded(); +``` + +--- + ## Where to go next - [PATTERNS.md](PATTERNS.md) — when these recipes are not enough and you need structural advice (render helpers, screens, hooks vs app state). diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index 9a1e112..dc477bb 100644 --- a/docs/DEBUGGING.md +++ b/docs/DEBUGGING.md @@ -21,12 +21,50 @@ Use it when you need to inspect: - container bounds - nesting depth -- widget count +- widget count (broken down by layer) - frame timing / FPS - current terminal dimensions This is the fastest way to debug clipping, spacing, and invisible layout issues. +### Reading the colored outlines + +Each layer family is tinted with a distinct hue, matching the convention used by +Chrome DevTools' layout overlay, the React DevTools component highlighter, and the +Flutter Inspector's widget tree boundaries. Within each family the color lightens +with depth so nested containers stay visually separable. + +| Color family | Layer | Triggered by | +|--------------|-------|--------------| +| Green | **Base** | `ui.col`, `ui.row`, `ui.container().*`, every non-overlay widget | +| Red | **Overlay** | `ui.overlay`, `ui.overlay_at`, `ui.overlay_at_offset`, `ui.tooltip` | +| Blue | **Modal** | `ui.modal`, `ui.modal_at`, `ui.modal_at_offset` | + +Tooltips currently share the Overlay (red) family because they ride the same +non-modal overlay plumbing — the layout tree does not yet carry a separate +tooltip tag. If you need to tell them apart visually, give the tooltip +container a unique title or background while debugging. + +### Reading the status line + +The status bar at the bottom of the screen shows a per-layer breakdown when +more than one layer family is populated: + +``` +[SLT Debug] 120x40 | 14 widgets (8 base, 5 overlay, 1 modal) | 1.7ms | 60fps +``` + +- **`14 widgets`** — total leaf widgets the renderer drew this frame. +- **`(8 base, 5 overlay, 1 modal)`** — only present when at least two layer + families have widgets, so a base-only scene keeps the short status line. +- **`1.7ms`** — last frame time. +- **`60fps`** — exponential moving average frame rate. + +Use `Context::set_debug_layer(DebugLayer::TopMost)` to outline only the active +modal/overlay (helpful when an overlay is fighting the base layout for space) +or `DebugLayer::BaseOnly` to keep the legacy pre-fix behavior of skipping +overlays entirely. + ## Common failure modes ### 1. Hover or click seems "off" @@ -58,6 +96,8 @@ if show_sidebar { } ``` +If you genuinely need a hook inside an `if` or `match` arm, use the id-keyed variant `ui.use_state_named::(id)` (v0.19.0). It keys by the supplied `&'static str` instead of call order, so it is safe inside conditional branches. The original order-based `use_state` rule still applies — only `*_named` variants opt out of it. + ### 3. `Response.rect` is empty `Response.rect` is meaningful only after the widget has participated in layout. diff --git a/docs/DESIGN_PRINCIPLES.md b/docs/DESIGN_PRINCIPLES.md index c97d280..89bc089 100644 --- a/docs/DESIGN_PRINCIPLES.md +++ b/docs/DESIGN_PRINCIPLES.md @@ -133,8 +133,9 @@ Breakpoints: Xs (<40), Sm (40-79), Md (80-119), Lg (120-159), Xl (>=160). ### Hook Rules (same as React) - `use_state()` and `use_memo()` must be called in the **same order** every frame -- Never call hooks inside conditionals or loops +- Never call order-based hooks inside conditionals or loops - Hook type mismatches panic with a descriptive message — this is a programmer error +- v0.19.0 added id-keyed variants (`use_state_named`, `use_state_named_with`) that key by `&'static str` and are explicitly safe inside conditional branches — use them when conditional placement is genuinely required --- diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 5e97cbb..b8d6e9b 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -19,8 +19,7 @@ Use this index to find the smallest example that matches what you want to build. | `demo` | `cargo run --example demo` | `qrcode`, `syntax` (optional) | Broad widget overview | | `demo_cli` | `cargo run --example demo_cli` | — | CLI-style layout | | `demo_table` | `cargo run --example demo_table` | — | Table widget focus | -| `demo_wiki` | `cargo run --example demo_wiki` | — | Text-heavy layout and markdown | -| `demo_website` | `cargo run --example demo_website` | — | Website-style terminal layout | +| `demo_website` | `cargo run --example demo_website` | — | Canonical `provide` / `use_context` example. Root closure calls `ui.provide(AppState { theme, tick }, |ui| ...)`; nested `render_*` fns read shared state via `ui.use_context::()` instead of receiving it as parameters (v0.19.0 component DX) | | `demo_design_system` | `cargo run --example demo_design_system` | — | Design tokens, ThemeColor, extends, WidgetTheme | | `demo_pretext` | `cargo run --example demo_pretext` | — | Pretext-inspired text reflow | @@ -41,6 +40,7 @@ Use this index to find the smallest example that matches what you want to build. | `demo_kitty_image` | `cargo run --example demo_kitty_image` | — | Kitty graphics protocol | | `demo_fire` | `cargo run --release --example demo_fire` | — | Half-block visual effect | | `demo_ime` | `cargo run --example demo_ime` | — | IME and CJK input | +| `demo_cjk` | `cargo run --example demo_cjk` | — | CJK (Chinese / Japanese / Korean) wide-character demo — title truncation, mixed Korean / Chinese / Japanese body wrap, narrow-clamp title boxes (12-cell width), CJK form fields, mouse support (group hover, click counters, mouse coords). | | `demo_key_test` | `cargo run --example demo_key_test` | — | Inspect key events | | `debug_selection` | `cargo run --example debug_selection` | — | Selection overlay debugging | diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..24f6bfa --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,234 @@ +# SLT Migration Guide + +A version-by-version upgrade guide for SuperLightTUI users. Pre-1.0, minor versions +(`0.X.0`) may contain breaking changes; patch versions (`0.X.Y`) must not. + +For the full per-release notes, see `CHANGELOG.md`. For composition recipes that use +current APIs, see `docs/PATTERNS.md` and `docs/COOKBOOK.md`. + +--- + +## 1. Quick reference: v0.19 → v0.20 + +The next minor release contains breaking changes from issues #98, #102, #134, #149, #161, +#184, #192, and #193. Most are caught by the compiler — a few are runtime/visual. + +| Change | Severity | Migration | +|---|---|---| +| `FilePickerState::selected()` → `selected_file()` (#98) | Rename — Cargo compile-error | `s/\.selected()/\.selected_file()/g` on `FilePickerState` calls | +| `ContainerBuilder::scroll_offset()` `pub` → `pub(crate)` (#149) | Compile error if used externally | Use `ScrollState` + `scrollable()` widget instead | +| `scrollbar()` return type `()` → `Response` (#184) | `unused_must_use` warning if ignored | `let _ = ui.scrollbar(&state);` to discard | +| `virtual_list` cursor stays in viewport (#192) | Visual change, no compile error | If you depended on the old cursor=bottom-row behavior, set `state.viewport_offset = state.selected.saturating_sub(visible_height - 1)` manually | +| `calendar` `h`/`l` = day ±1 (was month) (#193) | Keybinding behavior change | Document for users; `[`/`]` is the new month nav | +| `TextareaState` struct-literal init broken (#102) | Compile error if you used struct literal | Use `TextareaState::new()` constructor (private fields added for undo/redo) | +| `screen_hook_map` keys: `String` → `&'static str` (#134) | Compile error if you wrote dynamic `String` | Use string literals; for runtime keys, `Box::leak(s.into_boxed_str())` | +| `flex-shrink` global behavior change (#161) | Pixel-exact positions change for overflowing rows/columns | Re-screenshot snapshot tests; only affects layouts that previously overflowed and shrank | + +### How to upgrade + +1. Bump `superlighttui` in `Cargo.toml` to `0.20.0` +2. `cargo build` — fix compile errors with the table above +3. Re-run any TUI snapshot tests; eyeball overflow layouts and `virtual_list` cursor behavior +4. If your app exposes its own keybindings docs, update them for `calendar` + +### Concrete examples + +**#98 — `FilePickerState`** + +```rust +// Before (v0.19.x) +if let Some(path) = state.selected() { + open_file(path); +} + +// After (v0.20) +if let Some(path) = state.selected_file() { + open_file(path); +} +``` + +The struct field `state.selected_file: Option` already exists in v0.19.x +(`src/widgets/collections.rs` line ~102) — only the accessor method is renamed. +Direct field access (`&state.selected_file`) was always available and keeps working. + +**#184 — `scrollbar()` return type** + +```rust +// Before (v0.19.x): scrollbar returns () +ui.scrollbar(&scroll_state); + +// After (v0.20): scrollbar returns Response, must be used or discarded +let _ = ui.scrollbar(&scroll_state); +// or, if you want to react to interaction: +if ui.scrollbar(&scroll_state).clicked { /* ... */ } +``` + +**#192 — `virtual_list` cursor** + +The new behavior: cursor stays inside the visible viewport instead of pinning to the +bottom row. If your app relied on the old behavior to surface "newest item at the +cursor" (e.g. a streaming log), opt back in by setting `viewport_offset` manually: + +```rust +let visible_height = list_rect.height as usize; +state.viewport_offset = state.selected.saturating_sub(visible_height - 1); +``` + +**#134 — `screen_hook_map` keys** + +```rust +// Before (v0.19.x): HashMap +ui.screen_hook_map.insert(format!("screen_{i}"), (start, count)); + +// After (v0.20): HashMap<&'static str, _> +ui.screen_hook_map.insert("screen_static", (start, count)); + +// For runtime-generated keys, leak into 'static: +let key: &'static str = Box::leak(format!("screen_{i}").into_boxed_str()); +ui.screen_hook_map.insert(key, (start, count)); +``` + +Most callers use static names — the `Box::leak` workaround is for the rare dynamic case. + +--- + +## 2. v0.19.x → v0.19.3 (deprecations only — no code break) + +These compiled fine in v0.19.x but now produce deprecation warnings. They will be +**removed in v0.20**: + +| Old | New | +|---|---| +| `pad(n)` | `p(n)` | +| `min_width(n)` | `min_w(n)` | +| `max_width(n)` | `max_w(n)` | +| `min_height(n)` | `min_h(n)` | +| `max_height(n)` | `max_h(n)` | + +Source-of-truth: `src/context/container.rs` lines ~1050–1069 carry the +`#[deprecated(since = "0.20.0", ...)]` attributes; `pad` is at ~835. + +### Codemod (sed-based) + +```bash +# Run from your project root. Backs up files before modifying. +find src/ -name "*.rs" -exec sed -i.bak -E ' + s/\.pad\(/\.p(/g; + s/\.min_width\(/\.min_w(/g; + s/\.max_width\(/\.max_w(/g; + s/\.min_height\(/\.min_h(/g; + s/\.max_height\(/\.max_h(/g; +' {} \; +``` + +**Warning**: this is a regex codemod, not AST-aware. False positives possible if your +project has unrelated `.pad()` / `.min_width()` etc. methods on non-`ContainerBuilder` +types (e.g. a custom `MyRect::pad`). Review the diff before committing: + +```bash +git diff +# Looks good? Drop the backups: +find src/ -name "*.rs.bak" -delete +``` + +For comparison, React migrations use `npx jscodeshift` (AST-aware). The Rust ecosystem +doesn't have an equivalent for SLT yet — see section 5. + +--- + +## 3. v0.18.x → v0.19.0 (additive, no break) + +v0.19.0 added component DX APIs in `src/context/runtime.rs` (`provide`, `use_context`, +`use_state_named`, `with_if`). All existing code keeps compiling. Adopt incrementally: + +| Old pattern | New pattern (v0.19.0+) | +|---|---| +| Threading `&theme, &tick, &mut state` parameters through every render fn | `ui.provide(value, \|ui\| ...)` once, then `ui.use_context::()` in nested fns | +| `use_state` inside `if`/`match` (panics on hook-order mismatch) | `use_state_named(id)` (id-keyed, safe in conditionals) | +| `if cond { t.bold(); t.fg(Color::Red); }` | `t.with_if(cond, \|t\| t.bold().fg(Color::Red))` | + +See `docs/PATTERNS.md` for the full set of composition patterns. + +--- + +## 4. v0.17.x → v0.18.x + +Major architecture refactor — most user-facing APIs unchanged but internal modules split: + +- `src/context.rs` → facade (was monolithic) +- `src/widgets.rs` → facade +- `src/layout.rs` → facade + +If you had `pub(crate)` references to specific module paths from inside the SLT crate +(forks, or downstream crates that depended on undocumented internals), they may have +moved. Most external users see no break. + +Key APIs added in v0.18: + +- `NO_COLOR` env var support — automatically disables ANSI colors when set +- `scroll_col` helper — vertical scroll for column layouts +- `draw_with(rect, |buf| { ... })` — escape hatch for raw buffer access +- `try_get(rect, x, y) -> Option<&Cell>` — bounds-safe cell access in raw-draw + +v0.18.1 added doc improvements and safety hardening; v0.18.2 added flush coalescing, +the `Command` box pattern, and a flexbox scratch buffer (perf only). + +--- + +## 5. Comparison: how other UI libraries handle migrations + +| Framework | Tooling | Convention | +|---|---|---| +| **React** | `npx jscodeshift` AST codemods + RFC | Major every 2–3 years; deprecation warnings ≥1 minor before removal | +| **Vue** | `vue-codemod` AST tool + composition API guide | Major: 3.x → vue-next migration build | +| **Angular** | `ng update` schematic CLI (auto-migration) | Strict semver, deprecation warnings, automatic migration | +| **Flutter** | `dart fix --apply` | Annotated deprecations migrate via `dart fix` | +| **SLT** | sed-based codemods (no AST tool yet) + this guide + deprecation warnings | Strict semver, deprecation warnings ≥1 minor before removal in v0.x | + +SLT does **not** yet have an AST-aware codemod tool. PRs welcome — `cargo-slt-migrate` +would be a high-impact contribution. Until then, the sed snippets in section 2 plus +`cargo build` errors are the migration toolchain. + +--- + +## 6. Pre-1.0 versioning policy + +- **Pre-1.0 (current)**: minor versions (`0.X.0`) MAY contain breaking changes; patch + versions (`0.X.Y`) MUST NOT. +- **Post-1.0 (TBD)**: standard semver — major for breaking, minor for features, patch + for fixes. +- CI runs `cargo-semver-checks` on every PR to verify patch releases stay non-breaking. + Minor releases are allowed to fail this check; major/minor breakage is documented in + the release notes and reflected in this file. + +Deprecation policy: every API removal in a minor release is preceded by at least one +minor release that emits `#[deprecated(since = "X.Y.Z", note = "...")]` warnings. So if +you keep `cargo build` clean of deprecation warnings before each minor bump, you should +not hit surprise removals. + +--- + +## 7. Reporting migration issues + +If you find a migration step that doesn't work, errors out, or is missing from this +guide: + +- File an issue: +- Tag with `migration` label +- Include: + - SLT version you're upgrading **from** and **to** + - Minimal code snippet that reproduces the failure + - Full error message (compile error, panic, or visual diff screenshot) + +PRs that add migration entries here are welcome — keep entries to the same format as +the tables above. + +--- + +## 8. Cross-references + +- `CHANGELOG.md` — full per-release notes with every commit +- `docs/COOKBOOK.md` — recipes using current APIs +- `docs/PATTERNS.md` — composition patterns (`provide` / `use_context` / `with_if`) +- `docs/COMPLETE_REFERENCE.md` — full API surface +- `docs/STATE_APIS.md` — state-type method reference diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..bb542a3 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,336 @@ +# Performance + +A performance guide for SLT — frame budget, allocation budget, optimization +patterns, and how to detect regressions. If you've used the React profiler, +the Flutter timeline, or browser DevTools' performance panel, the model here +will feel familiar: SLT is an immediate-mode renderer with a per-frame +pipeline you can measure, profile, and optimize. + +## 1. Frame budget (target: 60 FPS) + +At 60 FPS, each frame has a ~16.6 ms budget. SLT's per-frame pipeline, +broken down by phase: + +| Phase | Target | Source | +|---|---|---| +| Closure execution (your app code) | < 2 ms | user-controlled | +| `build_tree` (commands → `LayoutNode`) | < 0.5 ms | `src/layout/tree.rs` | +| `compute` (flexbox layout) | < 1 ms | `src/layout/flexbox.rs` | +| `collect_all` (single DFS) | < 0.3 ms | `src/layout/collect.rs` | +| `render` (`LayoutNode` → `Buffer`) | < 1 ms | `src/layout/render.rs` | +| `flush_buffer_diff` (`Buffer` → ANSI bytes → stdout) | < 2 ms | `src/terminal.rs` | +| **Total framework overhead** | **< 5 ms** | | + +The remaining ~11 ms is yours: terminal I/O, async work, and slack for the +OS scheduler. The pipeline runs in `slt::frame()` (`src/lib.rs:1180–1290`) +which is called once per tick by `run_with` / `run_inline_with` / +`run_static_with`. + +> **TODO: measure.** The numbers above are targets. To produce the +> "measured" column for your hardware, run `cargo bench --bench benchmarks` +> (see [§3](#3-measuring-performance)) and record the actual figures for +> `full_render_120x40`, `layout_nested_rows_cols`, and `buffer_diff_200x50`. +> Do not publish a benchmark number you have not measured locally. + +## 2. Allocation budget + +The steady-state render path targets zero unnecessary heap allocations. +What we reuse, and where: + +| Per-frame allocation | Status | Issue / version | +|---|---|---| +| `commands` `Vec` | reused via `FrameState.commands_buf` | #143 / v0.19.1 | +| `FrameData` (8 collection `Vec`s) | reused via `&mut FrameData` in `collect_all` | #155 / source | +| `flexbox` row/column scratch | inline `U32Stack { [u32; 16] }` | #67 / v0.18.2 | +| Group name strings | `Arc` (atomic ref-count, no heap) | #139, #145 / v0.19.1 | +| `Style` commands | `Style` is `Copy` (no heap) | always | +| `Color`, `Rect` | `Copy` (no heap) | always | +| `Buffer` cells | pre-allocated `Vec`, only resized on terminal resize | always | +| `consume_activation_keys` queue | `SmallVec<[usize; 8]>` inline | #135 / v0.19.1 | +| `separator()` repeat string | `OnceLock`-cached static | #177 / v0.19.2 | +| `set_string_inner` private helper | dedup'd from public variants | #169 / v0.19.1 | + +`Command::BeginContainer` and `Command::BeginScrollable` were boxed in +v0.18.2 (#64) so the `Command` enum stays ≤ 128 bytes — small `Command`s +(text, style change) don't pay for the fat container variants on every +push. + +**Target**: no unnecessary heap allocations on the steady-state render +path. New widget contributions should justify any frame-rate-path +allocation in the PR description; reviewers should push back on +`String::from`, `format!`, `Vec::new` inside the `frame()` body unless the +allocation is one-shot or amortized. + +> **Working tree note**: `FrameState.commands_buf` and `FrameState.frame_data` +> exist in the v0.19.2 source tree (`src/lib.rs:600` / `:603`) and are wired +> into `frame()` at `:1187` and `:1195`. The CHANGELOG records #155 and #157 +> as "Deferred to v0.19.3" because they were reverted during release triage +> and are scheduled to re-land. Treat the deferred-list items as in-flight +> until v0.19.3 ships. + +## 3. Measuring performance + +### `cargo bench` + +```bash +cargo bench --bench benchmarks +``` + +The benchmark suite is defined in `benches/benchmarks.rs` and uses +`criterion`. Current benches: + +- `buffer_set_string_200x50` — hot path of the render phase +- `buffer_diff_200x50` — flush-phase input +- `layout_col_10_texts` — minimal column layout +- `layout_nested_rows_cols` — 5×4 nested rows-in-column +- `full_render_120x40` — small dashboard with header + progress +- `widget_list_100_items`, `widget_list_sizes`, `widget_table_50_rows`, + `widget_tabs_5`, `widget_checkbox_10`, `widget_select_10_items`, + `widget_progress_10` + +Compare results before and after a change with criterion's built-in +baseline: + +```bash +cargo bench --bench benchmarks -- --save-baseline before +# ... make a change ... +cargo bench --bench benchmarks -- --baseline before +``` + +### Frame timing in your app + +`AppState` exposes the smoothed FPS estimate and a debug toggle: + +```rust +// AppState API (src/lib.rs:251, :256) +let fps = state.fps(); // exponential moving average +state.set_debug(true); // same as pressing F12 +``` + +When the debug overlay is active (toggled by F12 at runtime, or via +`AppState::set_debug(true)` programmatically), the `render_debug_overlay` +pass (`src/layout/render.rs:24`) draws layout outlines on top of the +frame. The overlay layer is configurable via +`DiagnosticsState.debug_layer: DebugLayer` — `All` (default), `TopMost`, +or `BaseOnly` (issue #201 in `src/lib.rs:571–587`). + +There is no `RunConfig::show_fps()` builder method. To put an FPS readout +on screen, render `state.fps()` yourself in your UI closure, or rely on +the F12 overlay during development. + +### Custom instrumentation + +For deeper analysis, wrap a frame call: + +```rust +use std::time::Instant; +let start = Instant::now(); +let _keep_going = slt::frame(&mut backend, &mut state, &config, &events, &mut f)?; +println!("frame took {:?}", start.elapsed()); +``` + +For phase-level breakdown, splice timestamps inside `frame()` itself +(`src/lib.rs:1180–1290`) and capture them under a feature flag. Don't +ship phase timers in release binaries — they show up in the steady-state +budget. + +## 4. Optimization patterns (lessons from v0.18.x–v0.19.2) + +### Pattern 1: Reuse allocations across frames + +Bad — every frame allocates: + +```rust +let mut buf = Vec::new(); +collect_into(&mut buf); +``` + +Good — long-lived state, take/clear/refill: + +```rust +struct FrameState { commands_buf: Vec } + +// per frame, in the renderer: +let mut buf = std::mem::take(&mut state.commands_buf); +buf.clear(); +collect_into(&mut buf); +state.commands_buf = buf; // capacity preserved for next frame +``` + +This is the pattern used for `commands_buf` (#143), `FrameData` (#155), +and `RichLogState` history. `mem::take` + `clear` keeps the +`Vec`'s capacity from the previous high-water mark, so steady-state +frames don't reallocate. + +### Pattern 2: Inline small collections + +For collections that are almost always ≤ N items, use +`SmallVec<[T; N]>` or fixed-size arrays. SLT examples: + +- `consume_activation_keys` (`src/context/runtime.rs:440`) typically + pushes 0–2 indices per frame → `SmallVec<[usize; 8]>` keeps the common + case allocation-free (#135). +- `flexbox::U32Stack` (`src/layout/flexbox.rs:23`) is a `[u32; 16]` + inline buffer with a heap-`Vec` overflow path (#67). Child-counts ≤ 16 + pay zero allocations per `layout_row` / `layout_column` call. + +### Pattern 3: Flatten heap structures + +Bad — pointer chasing, double indirection: + +```rust +let plot: Vec> = vec![vec![' '; w]; h]; +``` + +Good — flat `Vec` with stride math: + +```rust +let plot: Vec = vec![' '; w * h]; +let cell = plot[y * w + x]; +``` + +Used in chart plot buffers (`#117` / v0.19.2) and command buffers. Flat +storage is also more cache-friendly: a 200×60 chart fits in a single +allocation instead of 60 row pointers + 60 row buffers. + +### Pattern 4: `Copy` types over `Clone` + +`Style`, `Color`, `Rect`, `Modifiers`, `Border`, `Padding`, `Margin`, and +`Theme` are all `Copy`. Avoid `.clone()` on a `Copy` type — it compiles +but signals confusion about the cost model. Reviewers should call this +out. + +```rust +let s = Style::new().bold().fg(Color::Cyan); // Copy +let s2 = s; // free (memcpy of 16 bytes) +``` + +### Pattern 5: Buffer cell hot path + +`Buffer::set_string` is the most-called write API on the render path. +Variants: + +- `set_string_inner` (`src/buffer.rs:335`) — private, single insertion + point, dedup'd from `set_string` and `set_string_with_url` (#169). +- `set_string` (`src/buffer.rs:316`) — no hyperlink, calls `_inner` with + `link: None`. +- `set_string_with_url` (`src/buffer.rs:325`) — OSC 8 hyperlink path, + calls `_inner` with `link: Some(&url)`. URL validation goes through + `is_valid_osc8_url` (#168), which doesn't allocate when validation + fails. + +Image rendering went through the same flatten in v0.19.1: `image()` +emitted 841 commands per frame for a 40×20 image (`#174`); the fix +collapses the per-pixel `Command::Text` rows into a single +`container().draw(...)` raw-draw region, dropping it to one command and +saving 800 `String` allocations per frame. + +### Pattern 6: Cache derivation results across frames + +When a derived value depends on stable inputs, store it on the state +type and invalidate on mutation rather than recomputing per frame: + +- `CommandPaletteState::filtered_indices` (#101) — fuzzy-match score is + computed once per query change, not twice per render. +- `TableState` column widths (#195) — `recompute_widths` short-circuits + when neither items nor filter changed. +- `ListState` lowercase-cache (#96) — set by `set_filter`; avoids + per-keystroke `to_lowercase()` over the whole item set. + +For your own derived values, use `ui.use_memo(deps, |d| compute(d))` +(`src/context/runtime.rs:651`) — the hook stores `(deps, value)` and +recomputes only on `PartialEq` deps change. + +## 5. Compared to other UI frameworks + +| Framework | Render model | Per-frame allocations | Profiler | +|---|---|---|---| +| **SLT (TUI)** | Immediate-mode, `Buffer` diff vs prev frame | Target 0 (steady state) | F12 overlay + `cargo bench` | +| **React** | Virtual DOM diff, retained components | Many (props, vnodes, fibers) | React DevTools Profiler | +| **Flutter** | Retained widget tree, RenderObject layout | Few (per-build only) | Flutter DevTools Timeline | +| **iOS UIKit** | Retained view hierarchy, Auto Layout solver | Few (constraint solver only) | Instruments | +| **ratatui** | Immediate-mode, full re-render every frame | Many (widget value types) | manual `Instant::elapsed` | + +SLT is closest to ratatui in render model — both rebuild the widget +tree every frame and diff the resulting `Buffer` against the previous +one. The difference is alloc-reuse: SLT recycles `commands`, +`FrameData`, flexbox scratch, and group names across frames, where most +ratatui apps allocate fresh widget value types each `Frame::render`. +For typical TUIs, both are limited by terminal flush bandwidth (one +syscall per ANSI command was ~10× the framework cost until #172 +introduced 64 KiB `BufWriter`). + +## 6. Detecting regressions + +### `cargo bench` snapshot + +Run before and after each PR that touches `src/layout/`, `src/buffer.rs`, +`src/terminal.rs`, or any high-traffic widget. Threshold: > 5% +regression on `full_render_120x40` or `buffer_diff_200x50` requires a +PR-description justification and a reviewer ack. + +### Visual snapshot regression + +`TestBackend` produces deterministic 1-frame outputs. The repo uses +`insta` for committed snapshot baselines — see `tests/snapshots.rs` and +the `tests/snapshots/` directory (10 widgets covered as of v0.19.2: +list, table, tabs, calendar, button, progress, separator, bordered_col, +row_layout, table_zebra). Add a new `insta::assert_snapshot!` for any +widget whose visual output you change; review the `.snap` diff in the PR. + +### Allocation tracking (manual) + +Wrap a benchmark with `dhat-rs` or run under `heaptrack` for actual +heap-profiling. Not in CI yet — case-by-case for performance-critical +PRs. + +```rust +// Cargo.toml dev-dependency: dhat = "0.3" +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +fn main() { + let _profiler = dhat::Profiler::new_heap(); + // run a render loop +} +``` + +The `dhat-heap.json` output opens in +[dh_view](https://nnethercote.github.io/dh_view/dh_view.html). + +## 7. Anti-patterns to avoid + +- **Calling widgets inside a `for` loop with thousands of items** — use + `ui.virtual_list(&mut state, visible_height, |ui, idx| {...})` + (`src/context/widgets_interactive/rich_markdown.rs:151`) instead of + `ui.list(&mut state)`. `virtual_list` only renders rows in the visible + window; a 100k-item list pays for the visible 50 rows, not all 100k. +- **Heavy derivation on every frame** — cache results in + `ui.use_memo(deps, |d| ...)` (`src/context/runtime.rs:651`). The + closure runs only when `deps` changes by `PartialEq`. +- **`.clone()` on `Style`** — `Style` is `Copy`. Drop the `.clone()`. + Same for `Color`, `Rect`, `Border`, `Padding`, `Margin`, `Theme`. +- **String concatenation in hot paths** — `format!()` in a per-frame + callback allocates every frame. Prefer `&str` and `Style::with_*` + chains; only allocate when you must, and prefer a one-shot allocation + cached in `use_memo` or on your state type. +- **`Vec::new()` inside the frame closure** — same problem. Move the + buffer to long-lived state, take/clear/refill (Pattern 1). +- **Per-cell glyph allocations** — never `'│'.to_string()` per cell. + Use `const TRACK: &str = "│"` and `set_string` (#164, #179). +- **Forgotten `#[inline]` on tiny helpers in flexbox** — Rust usually + inlines correctly, but if you're adding a function called millions + of times per frame and profiling shows a cost, try `#[inline]` and + re-bench. Don't preemptively annotate everything. +- **Ignoring `cargo bench` regressions** — a 5–10% slowdown per PR + compounds across a release. The `criterion` baseline workflow exists; + use it. + +## 8. Cross-references + +- `benches/benchmarks.rs` — criterion baselines +- `tests/snapshots.rs` and `tests/snapshots/` — `insta` visual baselines +- `docs/ARCHITECTURE.md` — render pipeline overview +- `docs/DEBUGGING.md` — F12 overlay usage and layout-debug walkthrough +- `docs/PATTERNS.md` — component patterns including `use_memo` +- `CHANGELOG.md` — issue numbers cited above (#67, #135, #143, #155, #169, …) diff --git a/docs/POSITIONING.md b/docs/POSITIONING.md new file mode 100644 index 0000000..2942c0f --- /dev/null +++ b/docs/POSITIONING.md @@ -0,0 +1,286 @@ +# Positioning in SLT — A Migration Guide for Web & Mobile Devs + +If you're coming from CSS, Flutter, or React Native, this guide maps SLT's `Anchor` +and overlay APIs to concepts you already know. SLT is an **immediate-mode** TUI +library, so positioning is a function call, not a styled element — but the mental +model is identical to `position: absolute` + `place-self`. + +--- + +## 1. Quick Reference + +| Intent | CSS | Flutter | React Native | SLT | +|---|---|---|---|---| +| Center on screen | `place-self: center; position: absolute` | `Center(child:)` | `position: absolute; top/left/right/bottom: 0; alignItems/justifyContent: 'center'` | `ui.overlay_at(Anchor::Center, \|ui\| ...)` | +| Bottom-right corner | `place-self: end end` | `Align(alignment: Alignment.bottomRight)` | `position: absolute; bottom: 0; right: 0` | `ui.overlay_at(Anchor::BottomRight, \|ui\| ...)` | +| Bottom-right with inset | `position: absolute; bottom: 16px; right: 16px` | `Positioned(bottom: 16, right: 16)` | `position: absolute; bottom: 16; right: 16` | `ui.overlay_at(Anchor::BottomRight, \|ui\| { ui.container().mr(2).mb(1).col(\|ui\| ...) })` | +| Top notification banner | `position: fixed; top: 0; left: 0; right: 0` | `Align(alignment: Alignment.topCenter)` | `position: absolute; top: 0; left: 0; right: 0` | `ui.overlay_at(Anchor::TopCenter, \|ui\| ...)` | +| Modal centered with backdrop | `` w/ backdrop | `showDialog()` | `` | `ui.modal_at(Anchor::Center, \|ui\| ...)` | +| Tooltip near hovered widget | `position: absolute; top: cursorY; left: cursorX` | computed via offset | computed via offset | `ui.tooltip("...")` (uses previous-frame `Response.rect`) | + +> **Units note**: SLT offsets are in **terminal cells**, not pixels. +> 1 cell ≈ 8 px wide × 16 px tall (font dependent). Use small numbers (1–3 cells). + +--- + +## 2. The `Anchor` 9-Cell Compass + +`Anchor` exposes 9 fixed positions: + +``` +TopLeft TopCenter TopRight +CenterLeft Center CenterRight +BottomLeft BottomCenter BottomRight +``` + +### Mapping to CSS `place-self` + +| SLT | CSS `place-self` | Flutter `Alignment` | RN `justifyContent` × `alignItems` | +|---|---|---|---| +| `Anchor::TopLeft` | `start start` | `Alignment.topLeft` | `flex-start` × `flex-start` | +| `Anchor::TopCenter` | `start center` | `Alignment.topCenter` | `flex-start` × `center` | +| `Anchor::TopRight` | `start end` | `Alignment.topRight` | `flex-start` × `flex-end` | +| `Anchor::CenterLeft` | `center start` | `Alignment.centerLeft` | `center` × `flex-start` | +| `Anchor::Center` | `center center` | `Alignment.center` | `center` × `center` | +| `Anchor::CenterRight` | `center end` | `Alignment.centerRight` | `center` × `flex-end` | +| `Anchor::BottomLeft` | `end start` | `Alignment.bottomLeft` | `flex-end` × `flex-start` | +| `Anchor::BottomCenter` | `end center` | `Alignment.bottomCenter` | `flex-end` × `center` | +| `Anchor::BottomRight` | `end end` | `Alignment.bottomRight` | `flex-end` × `flex-end` | + +Internally, `overlay_at` wraps your closure in a full-screen flex column with +`grow(1)`, then applies the matching `align`/`justify` pair. There's no magic — +it's the same flexbox you know. + +--- + +## 3. Offsets — the SLT analog of `inset` / `top` / `right` + +`overlay_at` pins to a corner with **zero inset**. To inset content (e.g. "16 px +from the right edge"), nest a `container()` with margin helpers (`ml`, `mt`, `mr`, `mb`) +inside the closure. + +``` +Without offset (Anchor::BottomRight): With mr(2).mb(1) inside: + +┌──────────────────────────────────┐ ┌──────────────────────────────────┐ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ [BADGE] │ │ │ +└──────────────────────────────────┘ │ [BADGE] │ + │ │ + └──────────────────────────────────┘ +``` + +Pattern: + +```rust +ui.overlay_at(Anchor::BottomRight, |ui| { + ui.container() + .mr(2) // 2 cells from right edge + .mb(1) // 1 cell from bottom edge + .col(|ui| { + render_badge(ui); + }); +}); +``` + +### Sign convention + +Margins are non-negative (`u32`). They always **inset toward the center**, +regardless of which corner you anchored to: + +| Anchor | `mr(2)` shifts | `mb(1)` shifts | +|---|---|---| +| `BottomRight` | left | up | +| `TopRight` | left | (no effect — already at top) | +| `BottomLeft` | (no effect — already at left) | up | + +Rule of thumb: only the margin sides that face the **opposite** edge of your anchor +have visible effect. For `BottomRight`, use `mr` and `mb`. For `TopLeft`, use +`ml` and `mt`. + +--- + +## 4. When NOT to use overlay_at + +Most layout should be flow-based (`ui.row` / `ui.col`), not overlays. Overlays +exist for content that floats independently of the document flow. + +| Use case | Right tool | Why | +|---|---|---| +| Toolbar at top of app | `ui.col(\|ui\| { toolbar(ui); main(ui); })` | Part of layout flow | +| Sidebar on the left | `ui.row(\|ui\| { sidebar(ui); main(ui); })` | Part of layout flow | +| Floating "Save" badge that doesn't push content | `ui.overlay_at(Anchor::BottomRight, ...)` | Floats above flow | +| Modal that dims background | `ui.modal_at(Anchor::Center, ...)` | Floats + dims | +| Tooltip near a hovered widget | `ui.tooltip("...")` | Anchored to widget rect | +| Toast notifications | `ui.overlay_at(Anchor::TopRight, ...)` | Floats above flow | + +**Heuristic**: if removing the element would change where surrounding content sits, +it belongs in flow (`row`/`col`). If removing it leaves the rest of the layout +unchanged, it's an overlay. + +--- + +## 5. Migrating from CSS / Flutter / React Native + +### From CSS + +```css +.badge { + position: absolute; + bottom: 16px; + right: 16px; +} +``` + +```rust +ui.overlay_at(Anchor::BottomRight, |ui| { + ui.container().mr(2).mb(1).col(|ui| { + render_badge(ui); + }); +}); +``` + +### From Flutter + +```dart +Stack( + children: [ + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.only(bottom: 16, right: 16), + child: Badge(), + ), + ), + ], +) +``` + +```rust +ui.overlay_at(Anchor::BottomRight, |ui| { + ui.container().mr(2).mb(1).col(|ui| { + render_badge(ui); + }); +}); +``` + +### From React Native + +```jsx + + + +``` + +```rust +ui.overlay_at(Anchor::BottomRight, |ui| { + ui.container().mr(2).mb(1).col(|ui| { + render_badge(ui); + }); +}); +``` + +### Modal with backdrop (CSS / Flutter / RN → SLT) + +```css +/* CSS */ +.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); } +.modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); } +``` + +```dart +// Flutter +showDialog(context: context, builder: (_) => AlertDialog(...)); +``` + +```jsx +// React Native +... +``` + +```rust +// SLT — modal_at adds the dimmed backdrop automatically +ui.modal_at(Anchor::Center, |ui| { + ui.text("Are you sure?"); + if ui.button("OK").clicked { confirm = true; } +}); +``` + +--- + +## 6. Common Pitfalls + +- **"My overlay collapses to top-left even though content has `align(End)`."** + Use the typed entry points: `overlay_at` / `modal_at`. They internally apply + `grow(1)` to the wrapper so flexbox has slack to push against. Hand-rolling + with `align`/`justify` on a non-growing container will collapse. + +- **"My margin doesn't shift the badge."** Margins only have visible effect on + sides facing the **opposite** of your anchor. `Anchor::TopLeft` + `mr(2)` does + nothing. Use `ml` and `mt` instead. + +- **"Offsets are in pixels in my CSS but my badge is way off."** Terminal cells + ≠ CSS px. 1 cell ≈ 8 px wide × 16 px tall. CSS `16px` ≈ 2 cells. Use small + numbers (1–3) not 16. + +- **"Multiple overlays — which one wins?"** Declaration order. Earlier + `overlay_at` calls render below later ones. Render the most important overlay + (e.g. modal) **last** so it stacks on top. + +- **"Tooltip doesn't show."** `tooltip()` requires the previous widget to be + hovered AND have a non-zero rect from the previous frame. Make sure the + preceding widget call returned a `Response` and is interactive. + +--- + +## 7. Cross-References + +- **API reference**: `Anchor`, `overlay_at`, `modal_at` re-exported from the + crate root — see `src/lib.rs` re-exports +- **Implementation**: `src/context/widgets_display/layout.rs` (`Anchor` enum, + `anchor_to_align_justify`, `overlay_at`, `modal_at`) +- **Container margin helpers**: `ml`, `mt`, `mr`, `mb`, `mx`, `my`, `margin` in + `src/context/container.rs` +- **Runnable demo**: `cargo run --example demo_overlay_anchor` — renders all 9 + anchors at once +- **Cookbook**: `docs/COOKBOOK.md` (Modal Confirmation with Toast, Real-time Dashboard) +- **Reference**: `docs/COMPLETE_REFERENCE.md` — full API surface + +--- + +## 8. TL;DR + +```rust +use slt::{Anchor, Context}; + +slt::run(|ui: &mut Context| { + // Flow content + ui.col(|ui| { + ui.text("Main content here"); + }); + + // Floating badge in the bottom-right with a 2x1 cell inset + ui.overlay_at(Anchor::BottomRight, |ui| { + ui.container().mr(2).mb(1).col(|ui| { + ui.text("v0.19.2").dim(); + }); + }); + + // Centered modal with backdrop + if show_dialog { + ui.modal_at(Anchor::Center, |ui| { + ui.text("Confirm?"); + if ui.button("OK").clicked { show_dialog = false; } + }); + } +}); +``` + +That's the whole positioning system. Nine anchors, two entry points +(`overlay_at` / `modal_at`), and standard margin helpers for fine-tuning insets. diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index bef6563..64ef33a 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -104,7 +104,8 @@ This is the core contract across the library. ## 7. Next docs - `docs/WIDGETS.md` - categorized widget map -- `docs/PATTERNS.md` - common composition patterns +- `docs/PATTERNS.md` - common composition patterns, including component composition (`provide` / `use_context`) +- `docs/FEATURES.md` - feature flags (`async`, `serde`, `image`, `qrcode`, `syntax-*`) - `docs/EXAMPLES.md` - runnable examples by use case - `docs/ARCHITECTURE.md` - internal module map and frame lifecycle - `docs/DESIGN_PRINCIPLES.md` - why the API is shaped this way diff --git a/docs/STATE_APIS.md b/docs/STATE_APIS.md index d1d6214..a149f9f 100644 --- a/docs/STATE_APIS.md +++ b/docs/STATE_APIS.md @@ -57,6 +57,14 @@ Single-line text input state. Pass `&mut TextInputState` to | `set_suggestions(&mut self, suggestions: Vec)` | Replace suggestions and reset popup state. | | `matched_suggestions(&self) -> Vec<&str>` | Suggestions whose prefix matches `value` (case-insensitive). | +> **`Clone` note**: `TextInputState::clone()` produces a copy *without* registered +> validators. Validators are stored as `Box`, which is not `Clone`, so +> the cloned state starts with an empty validator list. This is intentional — +> re-register validators on the clone if you need them. Inherent fields +> (`value`, `cursor`, `placeholder`, `max_length`, `validation_error`, +> `masked`, `suggestions`, `suggestion_index`, `show_suggestions`) are copied +> as expected. + ### Minimal example ```rust @@ -846,12 +854,22 @@ Multi-style append-only log. |-------|------|---------| | `entries` | `Vec` | Accumulated log rows. | | `auto_scroll` | `bool` | Follow the tail when new rows arrive. | -| `max_entries` | `Option` | Optional ring-buffer cap. | +| `max_entries` | `Option` | Optional ring-buffer cap (`None` = unbounded). | ### Constructors -- `RichLogState::new() -> Self` -- `RichLogState::default() -> Self` +- `RichLogState::new() -> Self` — bounded at `RichLogState::DEFAULT_MAX_ENTRIES` + (10,000 entries). Older entries are dropped from the front when the cap is + exceeded. **Recommended default for long-running apps** to prevent unbounded + memory growth. +- `RichLogState::new_unbounded() -> Self` — opt-in unbounded log + (`max_entries = None`). Use only when the host explicitly bounds growth + elsewhere (e.g. you call `clear()` periodically or cap entries upstream). +- `RichLogState::default() -> Self` — same as `new()` (bounded at 10,000). + +### Associated constants + +- `RichLogState::DEFAULT_MAX_ENTRIES: usize = 10_000` ### Methods diff --git a/docs/TESTING.md b/docs/TESTING.md index b2289bc..3d8aa04 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -57,6 +57,41 @@ tb.run_with_events(events, |ui| { Use `EventBuilder` when the widget logic depends on keyboard, mouse, paste, or resize events. +### Mouse and key chain wrappers (v0.19.1+) + +`EventBuilder` ships convenience wrappers for events that previously required +constructing raw `Event` values. The most useful ones to know: + +| Method | Emits | Use for | +|--------|-------|---------| +| `.click(x, y)` | mouse down + up at `(x, y)` | most click tests | +| `.mouse_up(x, y)` | mouse up only | testing release-only handlers, drag end | +| `.drag(x, y)` | mouse drag at `(x, y)` (button held) | scrubbing sliders, resizing splits | +| `.key_release(c)` | key release for `c` | matched press/release pairs (e.g. modifier holds) | +| `.focus_gained()` | terminal `FocusGained` event | windows/tabs gaining focus | +| `.focus_lost()` | terminal `FocusLost` event | pause-on-blur, autosave | + +Click vs drag is the test gap most authors miss — a `click` is two events +in the same cell, a `drag` is movement while a button is held: + +```rust +// Click: down then up in the same cell +let events = EventBuilder::new() + .click(10, 4) + .build(); + +// Drag: emit drag events while moving across cells +let events = EventBuilder::new() + .drag(10, 4) + .drag(11, 4) + .drag(12, 4) + .mouse_up(12, 4) + .build(); +``` + +Use `mouse_up` when you want to assert that a handler only fires on release +(common for "press-and-hold to drag, release to commit" patterns). + ## `render()` vs `run_with_events()` vs `render_with_events()` | Method | Use when | @@ -142,7 +177,7 @@ Add `insta` to your own project's dev-dependencies: ```toml [dev-dependencies] -superlighttui = "0.18" +superlighttui = "0.19" insta = "1" ``` @@ -174,6 +209,68 @@ with focused `assert_contains` / `assert_line` for narrow invariants. Prefer small snapshots — one widget or one panel — over full-screen dumps that churn on every theme or layout tweak. +## Visual snapshot regression tests + +`tests/visual_snapshots.rs` renders one frame of each demo example into a +`TestBackend` and stores the buffer output as a plain-text snapshot under +`tests/snapshots/visual__.snap`. The goal is to catch the kinds of +visual regressions that raw assertions miss — top-border title overflow, +flexbox grow drift, theme color shifts, CJK width handling at the right +edge — by failing CI when the rendered output changes unexpectedly. + +### How to run + +```bash +cargo test --test visual_snapshots +``` + +The first run on a clean checkout passes against the committed baselines. +A failing test prints a side-by-side diff of expected vs actual buffer. + +### Updating baselines after intentional changes + +When you deliberately change visual output (a widget restyling, a layout +tweak, a new badge), the snapshots will fail. Review and accept the new +baseline: + +```bash +cargo insta review # interactive +cargo insta accept # accept all pending +``` + +Commit the updated `tests/snapshots/visual__*.snap` files alongside the +code change so reviewers can see the visual diff in the PR. + +### What it catches + +- Layout drift (flexbox grow/shrink/gap regressions) +- Border rendering bugs (wrong corners, missing edges, **title overflow**) +- Theme color shifts that flip glyph attributes +- CJK / wide-char width handling at the right edge +- Wrap and truncation at small terminal sizes + +### What it does NOT catch + +- Interactive state transitions (focus, hover, click) — use `EventBuilder` + with assertion-based tests instead +- Animation and frame timing — use parity / property tests +- Sixel / kitty image output — not represented in plain-text buffer +- Multi-frame state changes (only frame 1 is captured) + +### Implementation + +Each example file (`examples/demo*.rs`) exposes a `pub fn render(ui: &mut Context)` +entry point that builds fresh state and runs one rendering pass. The +example's own `main` keeps using `slt::run` (or `slt::run_with`) so the +interactive demo still works; the snapshot test imports the example via +Rust's `#[path = "../examples/demo.rs"]` attribute and calls `render` +directly. + +Demos with rich internal state (`demo.rs`, `demo_dashboard.rs`, +`demo_infoviz.rs`, `demo_cjk.rs`) use a `render_frame(ui, &mut state)` +helper for runtime, and a thin `render(ui)` wrapper that builds default +state and forwards. Frame-1 snapshots only need that wrapper. + ## Testing custom widgets For custom widgets: diff --git a/docs/THEMING.md b/docs/THEMING.md index d8e58d4..3a9483c 100644 --- a/docs/THEMING.md +++ b/docs/THEMING.md @@ -49,6 +49,45 @@ let theme = Theme::builder() .build(); ``` +### Builder entry points + +| Constructor | Pre-filled defaults | When to use | +|-------------|---------------------|-------------| +| `Theme::builder()` | `Theme::dark()` | Build a dark theme from scratch | +| `Theme::light_builder()` | `Theme::light()` | Build a light theme — keeps light bg/text/border defaults instead of dark ones (v0.19.2) | +| `Theme::builder_from(base)` | All fields of `base` | Derive a variant from any preset, override only the fields you want to change (v0.19.2) | + +```rust +use slt::{Color, Theme}; + +// Nord with a custom primary — keeps Nord's frost/snow palette everywhere else. +let custom_nord = Theme::builder_from(Theme::nord()) + .primary(Color::Rgb(255, 0, 0)) + .build(); +assert_eq!(custom_nord.bg, Theme::nord().bg); + +// Light theme variant without re-specifying every light-mode field. +let my_light = Theme::light_builder() + .primary(Color::Rgb(0, 100, 200)) + .build(); +assert!(!my_light.is_dark); +``` + +### `const fn` ThemeBuilder (v0.19.2) + +Every `ThemeBuilder` setter is `const fn`, including `builder()`, `builder_from()`, `light_builder()`, `build()`, and all field setters (`primary`, `secondary`, `accent`, `text`, `text_dim`, `border`, `bg`, `success`, `warning`, `error`, `selected_bg`, `selected_fg`, `surface`, `surface_hover`, `surface_text`, `is_dark`, `spacing`). You can define themes at compile time: + +```rust +use slt::{Color, Theme}; + +const MY_THEME: Theme = Theme::builder() + .primary(Color::Rgb(0, 0, 0)) + .accent(Color::Cyan) + .build(); +``` + +Compile-time themes incur no runtime construction cost and let you embed branded palettes as `static` data alongside other UI constants. + ### All 17 theme fields | Field | Purpose | @@ -141,6 +180,8 @@ let fg = ui.theme().contrast_text_on(bg_color); let overlay = ui.theme().overlay(color, 0.5); ``` +> **Note (v0.19.1)**: `Color::contrast_fg` and `Theme::contrast_text_on` use the WCAG 2.1 relative luminance threshold of `0.179`, not the previous `0.5`. Mid-tone backgrounds — Dracula purple (`Rgb(189, 147, 249)`, luminance ≈ 0.385), Solarized base1, Catppuccin lavender — now route to white text instead of black, matching WCAG AA contrast guidance. If you depended on the old midpoint behavior for stylistic reasons, override per-callsite with `WidgetColors` instead of relying on the default. + ## Runtime Theme Switching Change themes and dark mode on the fly inside your render closure: @@ -359,13 +400,15 @@ use slt::Color; let bg = Color::Rgb(30, 30, 46); // Perceived brightness (0.0 = darkest, 1.0 = brightest) -let lum = bg.luminance(); // ~0.03 +let lum = bg.luminance(); // ~0.013 (sRGB-linearized, BT.709 weights) // Automatic readable foreground for a background color -// Returns white for dark backgrounds, black for light ones +// Returns white if luminance > 0.179 (WCAG threshold), black otherwise let fg = Color::contrast_fg(bg); // Rgb(255, 255, 255) ``` +> **Note (v0.19.1)**: `Color::luminance` now applies the sRGB inverse transfer function (gamma decoding) to each channel before applying BT.709 weights `0.2126·R + 0.7152·G + 0.0722·B`. This matches the WCAG 2.1 relative luminance definition. Numerical results differ from pre-v0.19.1 — most notably, mid-tone colors land at lower luminance values than the old naive linear average produced. If your code compares `luminance()` against a hardcoded threshold, re-check it against the new scale. + ### Downsampling for terminal compatibility ```rust diff --git a/docs/WIDGETS.md b/docs/WIDGETS.md index 75b46b9..3a31daf 100644 --- a/docs/WIDGETS.md +++ b/docs/WIDGETS.md @@ -36,6 +36,7 @@ Every widget method on `Context` follows one of these return patterns: | `Response` | Interactive widget with click/hover/changed/focused/rect | `button`, `list`, `table`, `tabs`, `col`, `row`, `alert`, charts | | `ContainerBuilder<'a>` | Fluent builder — finalize with `.col()`, `.row()`, or `.draw()` | `container`, `scrollable`, `bordered`, `group` | | `Option` | Index of selected segment, or `None` | `breadcrumb` | +| `(Response, Option)` | Row `Response` plus clicked segment index | `breadcrumb_response`, `breadcrumb_response_with` | | `()` | Fire-and-forget side-effect | `tooltip`, `scrollbar`, `notify`, `screen` | **`Response` fields:** @@ -97,7 +98,7 @@ All return `Response` unless noted. | `definition_list(items)` | Key-value list from `&[(&str, &str)]` | | `accordion(title, open, f)` | Collapsible section. `open: &mut bool` | | `confirm(question, result)` | Yes/No dialog. `result: &mut bool`, `Response.clicked` on answer | -| `breadcrumb(segments)` | Clickable breadcrumb trail. Returns `Option`. Variant: `breadcrumb_with(segments, sep)` | +| `breadcrumb(segments)` | Clickable breadcrumb trail. Returns `Option`. Variants: `breadcrumb_with(segments, sep)`; `breadcrumb_response(segments)` and `breadcrumb_response_with(segments, sep)` return `(Response, Option)` so you can read hover/focus/rect alongside the clicked index. | | `help(bindings)` | Keybinding help bar from `&[(&str, &str)]`. Variant: `help_colored(bindings, key_color, text_color)` | | `help_from_keymap(keymap)` | Help bar from a `KeyMap` struct | @@ -153,7 +154,8 @@ All return `Response`. Most have a `_colored(&WidgetColors)` variant. | `button(label)` | — | Click button. Variants: `button_colored(label, colors)`, `button_with(label, variant)` where `ButtonVariant`: `Default`, `Primary`, `Danger`, `Outline` | | `checkbox(label, checked)` | `&mut bool` | Checkbox toggle. Variant: `checkbox_colored(...)` | | `toggle(label, on)` | `&mut bool` | Toggle switch. Variant: `toggle_colored(...)` | -| `slider(label, value, range)` | `&mut f64`, `RangeInclusive` | Horizontal slider | +| `slider(label, value, range)` | `&mut f64`, `RangeInclusive` | Horizontal slider (default step = `span / 20`) | +| `slider_with_step(label, value, range, step)` | `&mut f64`, `RangeInclusive`, `f64` | Slider with explicit step size — use for integer counters (`1.0`) or fine controls (`0.1`) | | `text_input(state)` | `TextInputState` | Single-line text input. Variant: `text_input_colored(...)` | | `textarea(state, visible_rows)` | `TextareaState` | Multi-line text editor | | `select(state)` | `SelectState` | Dropdown select. Variant: `select_colored(...)` | @@ -246,7 +248,7 @@ All return `Response`. | `kitty_image(rgba, pw, ph, cols, rows)` | — | Kitty graphics protocol image | | `kitty_image_fit(rgba, sw, sh, cols)` | — | Auto-fit Kitty image to column width | | `sixel_image(rgba, pw, ph, cols, rows)` | — | Sixel protocol image (requires `crossterm`) | -| `rich_log(state)` | `RichLogState` | Scrollable styled log viewer | +| `rich_log(state)` | `RichLogState` | Scrollable styled log viewer. `RichLogState::new()` is bounded at `RichLogState::DEFAULT_MAX_ENTRIES` (10,000 entries) — older entries drop from the front. Use `RichLogState::new_unbounded()` only when growth is bounded elsewhere. | --- @@ -353,7 +355,7 @@ These are not widgets but essential `Context` methods for state, input, and envi | `streaming_text` | `StreamingTextState` | `.push(text)`, `.content`, `.streaming` | | `streaming_markdown` | `StreamingMarkdownState` | `.push(text)`, `.streaming` | | `tool_approval` | `ToolApprovalState` | `.tool_name`, `.description`, `.action: ApprovalAction` (`Pending`, `Approved`, `Rejected`) | -| `rich_log` | `RichLogState` | `.push(text, style)`, `.entries`, `.auto_scroll` | +| `rich_log` | `RichLogState` | `.push(text, style)`, `.entries`, `.auto_scroll`, `.max_entries: Option`. `::new()` caps at 10,000; `::new_unbounded()` opts out (use only when growth is bounded elsewhere). | | `treemap` | `TreemapItem` | `.label: String`, `.value: f64`, `.color: Color`, `::new(label, value, color)` | --- @@ -464,7 +466,7 @@ Received in `ui.canvas(w, h, |cv| { ... })`. Coordinates are in pixel space (col | `src/context/widgets_display/text.rs` | `text`, `styled`, `link`, `spacer`, `timer_display`, `help_from_keymap` | | `src/context/widgets_display/status.rs` | `alert`, `badge`, `stat`, `breadcrumb`, `accordion`, `code_block`, `divider_text`, `definition_list`, `empty_state`, `confirm`, `key_hint` | | `src/context/widgets_display/rich_output.rs` | `big_text`, `image`, `kitty_image`, `sixel_image`, `streaming_text`, `streaming_markdown`, `tool_approval`, `context_bar` | -| `src/context/widgets_display/layout.rs` | `col`, `row`, `line`, `line_wrap`, `modal`, `overlay`, `tooltip`, `group`, `container`, `scrollable`, `scrollbar`, `bordered`, `screen`, `form`, `form_field`, `form_submit` | +| `src/context/widgets_display/layout.rs` | `col`, `row`, `line`, `line_wrap`, `modal`, `overlay`, `tooltip`, `group`, `container`, `scrollable`, `scrollbar`, `bordered`, `screen`, `form`, `form_field`, `form_submit`, `separator`, `separator_colored` | | `src/context/widgets_input/text_input.rs` | `text_input`, `text_input_colored` | | `src/context/widgets_input/textarea_progress.rs` | `textarea`, `progress`, `progress_bar` | | `src/context/widgets_input/feedback.rs` | `spinner`, `toast`, `slider`, `notify` | diff --git a/docs/llms.txt b/docs/llms.txt index 88ebfc9..94a1137 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -9,7 +9,7 @@ - [AI guide](AI_GUIDE.md): common questions agents get wrong, implementation rules - [Widgets](WIDGETS.md): categorized API catalog (50+ widgets, state types, runtime methods) - [Patterns](PATTERNS.md): composition patterns, state ownership, screens, forms -- [Examples guide](EXAMPLES.md): 25+ runnable examples grouped by product shape +- [Examples guide](EXAMPLES.md): 32 runnable examples grouped by product shape - [Architecture](ARCHITECTURE.md): module map, frame lifecycle, layout pipeline - [Design principles](DESIGN_PRINCIPLES.md): API philosophy, immediate-mode rationale @@ -25,6 +25,26 @@ - [Crate root re-exports](../src/lib.rs): authoritative public API - [Context runtime methods](../src/context/runtime.rs): use_state, use_memo, focus, interaction, error_boundary +### Component / state APIs (v0.19.0) + +- `ui.provide(value, |ui| { ... })` — context injection, scoped to the closure body +- `ui.use_context::()` — read provided context (panics if no `T` is in scope) +- `ui.try_use_context::()` — read provided context, returns `Option<&T>` (safe form) +- `ui.use_state_named(id)` / `ui.use_state_named_with(id, init)` — id-keyed local state, safe inside conditionals (does not depend on call order) +- `.with_if(cond, modifier)` / `.with(modifier)` — fluent conditional styling on text and `ContainerBuilder` + +### v0.18.x highlights + +- `NO_COLOR` env var support (auto-disables color output when set) +- `scroll_col` — vertical scroll container helper +- `draw_with(rect, |buf| { ... })` — typed raw-draw entry point +- `Buffer::try_get(x, y) -> Option<&Cell>` — fallible buffer cell accessor for `draw(|buf, rect| ...)` raw-draw closures + +### v0.19.2 widget helpers + +- `breadcrumb_response(segments)` / `breadcrumb_response_with(segments, ...)` — returns `(Response, Option)` so callers can detect which segment was clicked +- `mx(n)` / `my(n)` — horizontal / vertical margin shorthands + ## Specialized topics - [Theming](THEMING.md): 10 preset themes, ThemeColor semantic tokens, WidgetColors overrides, Tailwind palette @@ -42,3 +62,5 @@ - [examples/demo_dashboard.rs](../examples/demo_dashboard.rs): dashboard composition - [examples/demo_cli.rs](../examples/demo_cli.rs): CLI-style layout - [examples/demo_infoviz.rs](../examples/demo_infoviz.rs): charts and visualizations +- [examples/demo_website.rs](../examples/demo_website.rs): canonical `provide` / `use_context` composition (v0.19.0) +- [examples/demo_cjk.rs](../examples/demo_cjk.rs): CJK / wide-character rendering, title clamp, mouse hover diff --git a/examples/demo.rs b/examples/demo.rs index e5c454d..d80f13b 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -8,6 +8,26 @@ use slt::{ }; fn main() -> std::io::Result<()> { + slt::run_with( + RunConfig::default().mouse(true).kitty_keyboard(true), + render, + ) +} + +/// Render one frame of the widget showcase demo. +/// +/// All widget state is constructed inside this function so it can be +/// driven by both the runtime event loop in `main` and by visual snapshot +/// tests in `tests/visual_snapshots.rs` without external setup. +/// +/// Note: because state is rebuilt on every call, the runtime example +/// resets per-widget state (selections, scroll positions, form input) +/// at every frame. The frame-1 snapshot test does not care, and the +/// trade-off keeps this entry point small enough for visual coverage +/// without a 60-field state struct. Migrate to `Context::use_state` +/// hooks when richer interactive persistence is needed. +#[allow(unused_assignments)] +pub fn render(ui: &mut Context) { let mut page_tabs = TabsState::new(vec![ "Core Widgets", "Data Viz", @@ -236,220 +256,214 @@ fn main() -> std::io::Result<()> { let mut v152_focus_b = TextInputState::with_placeholder("Input B (focusable #1)"); let mut v152_search = TextInputState::with_placeholder("Search fills remaining space..."); - slt::run_with( - RunConfig::default().mouse(true).kitty_keyboard(true), - |ui: &mut Context| { - let tick = ui.tick(); + { + let tick = ui.tick(); - if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { - ui.quit(); - } - if ui.key_mod('t', slt::KeyModifiers::CONTROL) { - theme_idx = (theme_idx + 1) % themes.len(); - toasts.info(format!("Theme: {}", theme_names[theme_idx]), tick); - } - if ui.key_mod('h', slt::KeyModifiers::CONTROL) { - progress = (progress - 0.05).max(0.0); - } - if ui.key_mod('l', slt::KeyModifiers::CONTROL) { - progress = (progress + 0.05).min(1.0); - } - if ui.key_mod('m', slt::KeyModifiers::CONTROL) { - show_modal = !show_modal; - } - if ui.key_mod('o', slt::KeyModifiers::CONTROL) { - show_overlay = !show_overlay; - } - if ui.key_mod('p', slt::KeyModifiers::CONTROL) { - palette.open = !palette.open; - } - if ui.key_mod('g', slt::KeyModifiers::CONTROL) { - scroll.offset = 0; - } - for i in 1..=9u8 { - if ui.key_mod((b'0' + i) as char, slt::KeyModifiers::CONTROL) { - page_tabs.selected = (i - 1) as usize; - } + if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + if ui.key_mod('t', slt::KeyModifiers::CONTROL) { + theme_idx = (theme_idx + 1) % themes.len(); + toasts.info(format!("Theme: {}", theme_names[theme_idx]), tick); + } + if ui.key_mod('h', slt::KeyModifiers::CONTROL) { + progress = (progress - 0.05).max(0.0); + } + if ui.key_mod('l', slt::KeyModifiers::CONTROL) { + progress = (progress + 0.05).min(1.0); + } + if ui.key_mod('m', slt::KeyModifiers::CONTROL) { + show_modal = !show_modal; + } + if ui.key_mod('o', slt::KeyModifiers::CONTROL) { + show_overlay = !show_overlay; + } + if ui.key_mod('p', slt::KeyModifiers::CONTROL) { + palette.open = !palette.open; + } + if ui.key_mod('g', slt::KeyModifiers::CONTROL) { + scroll.offset = 0; + } + for i in 1..=9u8 { + if ui.key_mod((b'0' + i) as char, slt::KeyModifiers::CONTROL) { + page_tabs.selected = (i - 1) as usize; } + } - ui.set_theme(themes[theme_idx]()); - ui.set_dark_mode(v8_dark_mode); + ui.set_theme(themes[theme_idx]()); + ui.set_dark_mode(v8_dark_mode); - let theme = *ui.theme(); - let _ = ui - .container() - .border(Border::Rounded) - .p(1) - .grow(1) - .col(|ui| { - let _ = ui.row(|ui| { - ui.text("SuperLightTUI").bold().fg(theme.primary); - ui.text(" widget showcase").fg(theme.text); - ui.spacer(); - ui.text(theme_names[theme_idx]).fg(theme.text_dim); - }); - ui.text("All widgets follow active theme tokens.") - .fg(theme.text_dim); - ui.separator(); + let theme = *ui.theme(); + let _ = ui + .container() + .border(Border::Rounded) + .p(1) + .grow(1) + .col(|ui| { + let _ = ui.row(|ui| { + ui.text("SuperLightTUI").bold().fg(theme.primary); + ui.text(" widget showcase").fg(theme.text); + ui.spacer(); + ui.text(theme_names[theme_idx]).fg(theme.text_dim); + }); + ui.text("All widgets follow active theme tokens.") + .fg(theme.text_dim); + ui.separator(); - render_page_tabs(ui, &mut page_tabs); - ui.separator(); + render_page_tabs(ui, &mut page_tabs); + ui.separator(); - let _ = ui - .scrollable(&mut scroll) - .grow(1) - .col(|ui| match page_tabs.selected { - 0 => render_core( - ui, - &mut section_tabs, - &mut input, - &mut textarea, - &mut dark_mode, - &mut notifications, - &mut autosave, - &mut vim_mode, - &mut saves, - ), - 1 => render_dataviz(ui), - 2 => render_layout( - ui, - &mut list, - &mut table, - &mut table_filter, - &mut show_overlay, - ), - 3 => render_forms(ui, &mut form, &mut password), - 4 => render_ime( - ui, - &mut ime_name, - &mut ime_search, - &mut ime_message, - &ime_items, - ), - 5 => render_feedback(ui, &spinner, progress), - 6 => render_advanced( - ui, - &mut select, - &mut radio, - &mut multi, - &mut tree, - &mut vlist, - ), - 7 => render_v070( - ui, - &mut v7_scroll, - &mut v7_stream, - &mut v7_tool, - &mut v7_stream_tick, - ), - 8 => render_v080( - ui, - &mut list_with_filter, - &mut list_filter_input, - &mut v8_dark_mode, - &mut v8_tween, - &mut v8_anim_done, - tick, - ), - 9 => render_v094( - ui, - &mut accordion_general, - &mut accordion_advanced, - &mut alert_visible, - ), - 10 => render_v011( - ui, - &mut v11_button_clicks, - &mut v11_volume, - &mut v11_brightness, - &mut v11_confirm_delete, - &mut v11_autocomplete, - &mut v11_validated, - &mut v11_file_picker, - &v11_keymap, - ), - 11 => render_v01210(ui), - 12 => render_v013( - ui, - &mut v13_show_modal, - &mut v13_modal_message, - &mut v13_palette, - &mut v13_palette_last, - &mut v13_debug_input, - &mut v13_list_a, - &mut v13_list_b, - ), - 13 => render_v0132( - ui, - &mut v132_zebra_table, - &mut v132_calendar, - &mut v132_screens, - &mut v132_fuzzy_palette, - &mut v132_fuzzy_last, - ), - 14 => render_v014(ui, tick, &mut rich_log, &mut dir_tree), - 15 => render_v0141(ui), - 16 => render_v0152( - ui, - &mut v152_focus_a, - &mut v152_focus_b, - &mut v152_search, - ), - _ => {} - }); + let _ = ui + .scrollable(&mut scroll) + .grow(1) + .col(|ui| match page_tabs.selected { + 0 => render_core( + ui, + &mut section_tabs, + &mut input, + &mut textarea, + &mut dark_mode, + &mut notifications, + &mut autosave, + &mut vim_mode, + &mut saves, + ), + 1 => render_dataviz(ui), + 2 => render_layout( + ui, + &mut list, + &mut table, + &mut table_filter, + &mut show_overlay, + ), + 3 => render_forms(ui, &mut form, &mut password), + 4 => render_ime( + ui, + &mut ime_name, + &mut ime_search, + &mut ime_message, + &ime_items, + ), + 5 => render_feedback(ui, &spinner, progress), + 6 => render_advanced( + ui, + &mut select, + &mut radio, + &mut multi, + &mut tree, + &mut vlist, + ), + 7 => render_v070( + ui, + &mut v7_scroll, + &mut v7_stream, + &mut v7_tool, + &mut v7_stream_tick, + ), + 8 => render_v080( + ui, + &mut list_with_filter, + &mut list_filter_input, + &mut v8_dark_mode, + &mut v8_tween, + &mut v8_anim_done, + tick, + ), + 9 => render_v094( + ui, + &mut accordion_general, + &mut accordion_advanced, + &mut alert_visible, + ), + 10 => render_v011( + ui, + &mut v11_button_clicks, + &mut v11_volume, + &mut v11_brightness, + &mut v11_confirm_delete, + &mut v11_autocomplete, + &mut v11_validated, + &mut v11_file_picker, + &v11_keymap, + ), + 11 => render_v01210(ui), + 12 => render_v013( + ui, + &mut v13_show_modal, + &mut v13_modal_message, + &mut v13_palette, + &mut v13_palette_last, + &mut v13_debug_input, + &mut v13_list_a, + &mut v13_list_b, + ), + 13 => render_v0132( + ui, + &mut v132_zebra_table, + &mut v132_calendar, + &mut v132_screens, + &mut v132_fuzzy_palette, + &mut v132_fuzzy_last, + ), + 14 => render_v014(ui, tick, &mut rich_log, &mut dir_tree), + 15 => render_v0141(ui), + 16 => { + render_v0152(ui, &mut v152_focus_a, &mut v152_focus_b, &mut v152_search) + } + _ => {} + }); - ui.separator(); - let _ = ui.help(&[ - ("^Q/Esc", "quit"), - ("^T", "theme"), - ("^M", "modal"), - ("^O", "overlay"), - ("^H/^L", "progress"), - ("^P", "palette"), - ("^1-9", "tab"), - ("^G", "top"), - ("Tab", "focus"), - ("F12", "debug"), - ]); - }); + ui.separator(); + let _ = ui.help(&[ + ("^Q/Esc", "quit"), + ("^T", "theme"), + ("^M", "modal"), + ("^O", "overlay"), + ("^H/^L", "progress"), + ("^P", "palette"), + ("^1-9", "tab"), + ("^G", "top"), + ("Tab", "focus"), + ("F12", "debug"), + ]); + }); - if show_modal { - let _ = ui.modal(|ui| { - let theme = *ui.theme(); - let _ = ui - .container() - .bg(theme.surface) - .border(Border::Rounded) - .p(2) - .col(|ui| { - ui.text("Modal Demo").bold().fg(theme.primary); - ui.text("This modal stays in the demo.") - .fg(theme.surface_text); - ui.text("Press m or click close.").fg(theme.surface_text); - if ui.button("Close").clicked { - show_modal = false; - } - }); - }); - } + if show_modal { + let _ = ui.modal(|ui| { + let theme = *ui.theme(); + let _ = ui + .container() + .bg(theme.surface) + .border(Border::Rounded) + .p(2) + .col(|ui| { + ui.text("Modal Demo").bold().fg(theme.primary); + ui.text("This modal stays in the demo.") + .fg(theme.surface_text); + ui.text("Press m or click close.").fg(theme.surface_text); + if ui.button("Close").clicked { + show_modal = false; + } + }); + }); + } - ui.toast(&mut toasts); + ui.toast(&mut toasts); - let _cp = ui.command_palette(&mut palette); - if let Some(idx) = palette.last_selected { - match idx { - 0 => { - theme_idx = (theme_idx + 1) % themes.len(); - toasts.info(format!("Theme: {}", theme_names[theme_idx]), tick); - } - 1 => show_modal = !show_modal, - 2 => show_overlay = !show_overlay, - 3 => ui.quit(), - _ => {} + let _cp = ui.command_palette(&mut palette); + if let Some(idx) = palette.last_selected { + match idx { + 0 => { + theme_idx = (theme_idx + 1) % themes.len(); + toasts.info(format!("Theme: {}", theme_names[theme_idx]), tick); } + 1 => show_modal = !show_modal, + 2 => show_overlay = !show_overlay, + 3 => ui.quit(), + _ => {} } - }, - ) + } + } } fn render_page_tabs(ui: &mut Context, page_tabs: &mut TabsState) { diff --git a/examples/demo_cjk.rs b/examples/demo_cjk.rs index e0fce55..61dee9d 100644 --- a/examples/demo_cjk.rs +++ b/examples/demo_cjk.rs @@ -18,159 +18,182 @@ fn main() -> std::io::Result<()> { let mut tag_input = TextInputState::with_placeholder("태그"); slt::run(|ui: &mut Context| { - // Per-frame snapshots of mouse position and focus state, captured up - // front so the closing status bar can read them without disturbing - // the layout pass. - let mouse_pos = ui.mouse_pos(); - let focus_index = ui.focus_index(); - let focus_count = ui.focus_count(); - - // Per-card click counters and "last clicked" label persist across - // frames via `use_state`. Order is fixed at the top of the closure - // so the hook cursor stays stable. - let counts_state = ui.use_state(|| [0u32; 4]); - let last_clicked_state = ui.use_state(|| Option::::None); - - let _ = ui - .bordered(Border::Rounded) - .title("한글 / 中文 / 日本語 demo") - .p(1) - .grow(1) - .col(|ui| { - ui.text("CJK 위젯 데모 — Ctrl+Q to quit · 카드를 클릭해 보세요") - .bold() - .fg(Color::Cyan); - ui.separator(); - - let _ = ui.row(|ui| { - let _ = ui - .bordered(Border::Rounded) - .title("짧은 제목") - .p(1) - .grow(1) - .col(|ui| { - ui.text("한국어 본문이 박스 안에 잘 들어가는지 확인합니다.") - .wrap(); - ui.text("中文 段落 — 测试中文换行与右边界裁剪。").wrap(); - ui.text("日本語 — 折り返しと右端の境界を確認します。") - .wrap(); - }); + render_frame(ui, &mut name_input, &mut tag_input); + }) +} - let _ = ui - .bordered(Border::Rounded) - .title("Mixed 한·中·日 title overflow test") - .p(1) - .grow(1) - .col(|ui| { - ui.text("Long titles must clip without breaking the right border (┐)."); - ui.text("긴 제목은 오른쪽 테두리를 침범하지 않아야 합니다.") - .wrap(); - }); - }); - - ui.separator(); - let _ = ui.row(|ui| { - // Group name encodes the focus role so `is_group_focused` - // (used by `text_input` internally) stays unique even if - // the title text were to change later. - let _ = ui - .group("input-name") - .border(Border::Rounded) - .title("입력") - .p(1) - .grow(1) - .col(|ui| { - ui.text("이름:").dim(); - let _ = ui.text_input(&mut name_input); - ui.text("태그:").dim(); - let _ = ui.text_input(&mut tag_input); - ui.text("Tab으로 포커스 이동, 또는 아래 [포커스] 버튼") - .dim(); - }); +/// Render one frame with fresh, default state — used by visual snapshot tests +/// in `tests/visual_snapshots.rs`. The runtime example uses [`render_frame`] +/// directly so the text-input contents persist across frames. +pub fn render(ui: &mut Context) { + let mut name_input = TextInputState::with_placeholder("이름을 입력하세요"); + let mut tag_input = TextInputState::with_placeholder("태그"); + render_frame(ui, &mut name_input, &mut tag_input); +} + +/// Render one frame of the CJK demo into the supplied context. +/// +/// Most state (per-card counts, last-clicked label) is stored via `use_state` +/// hooks on the context, so this is safe to call repeatedly in the runtime +/// loop. The two `TextInputState` values for the form fields are passed by +/// `&mut` because they are not yet hooked. +pub fn render_frame( + ui: &mut Context, + name_input: &mut TextInputState, + tag_input: &mut TextInputState, +) { + // Per-frame snapshots of mouse position and focus state, captured up + // front so the closing status bar can read them without disturbing + // the layout pass. + let mouse_pos = ui.mouse_pos(); + let focus_index = ui.focus_index(); + let focus_count = ui.focus_count(); + + // Per-card click counters and "last clicked" label persist across + // frames via `use_state`. Order is fixed at the top of the closure + // so the hook cursor stays stable. + let counts_state = ui.use_state(|| [0u32; 4]); + let last_clicked_state = ui.use_state(|| Option::::None); + + let _ = ui + .bordered(Border::Rounded) + .title("한글 / 中文 / 日本語 demo") + .p(1) + .grow(1) + .col(|ui| { + ui.text("CJK 위젯 데모 — Ctrl+Q to quit · 카드를 클릭해 보세요") + .bold() + .fg(Color::Cyan); + ui.separator(); + + let _ = ui.row(|ui| { + let _ = ui + .bordered(Border::Rounded) + .title("짧은 제목") + .p(1) + .grow(1) + .col(|ui| { + ui.text("한국어 본문이 박스 안에 잘 들어가는지 확인합니다.") + .wrap(); + ui.text("中文 段落 — 测试中文换行与右边界裁剪。").wrap(); + ui.text("日本語 — 折り返しと右端の境界を確認します。") + .wrap(); + }); - let _ = ui - .bordered(Border::Rounded) - .title("결과") - .p(1) - .grow(1) + let _ = ui + .bordered(Border::Rounded) + .title("Mixed 한·中·日 title overflow test") + .p(1) + .grow(1) + .col(|ui| { + ui.text("Long titles must clip without breaking the right border (┐)."); + ui.text("긴 제목은 오른쪽 테두리를 침범하지 않아야 합니다.") + .wrap(); + }); + }); + + ui.separator(); + let _ = ui.row(|ui| { + // Group name encodes the focus role so `is_group_focused` + // (used by `text_input` internally) stays unique even if + // the title text were to change later. + let _ = ui + .group("input-name") + .border(Border::Rounded) + .title("입력") + .p(1) + .grow(1) + .col(|ui| { + ui.text("이름:").dim(); + let _ = ui.text_input(name_input); + ui.text("태그:").dim(); + let _ = ui.text_input(tag_input); + ui.text("Tab으로 포커스 이동, 또는 아래 [포커스] 버튼") + .dim(); + }); + + let _ = ui + .bordered(Border::Rounded) + .title("결과") + .p(1) + .grow(1) + .col(|ui| { + ui.text(format!("이름 = {}", name_input.value)).bold(); + ui.text(format!("태그 = {}", tag_input.value)).bold(); + let total: u32 = counts_state.get(ui).iter().sum(); + ui.text(format!("카드 클릭 합계 = {total}")) + .fg(Color::Green); + }); + }); + + ui.separator(); + ui.text("Truncation table — 각 박스는 너비 12, 제목이 잘려야 정상 (hover/click)") + .dim(); + let _ = ui.row(|ui| { + // Read counts up front so the click handler below can set + // the next value without holding an immutable borrow on + // `ui` while we also read `is_group_hovered`. + let counts_now = *counts_state.get(ui); + let mut clicked: Option = None; + + for (idx, title) in CARD_TITLES.iter().enumerate() { + // Stable per-card group name. The `card-` prefix keeps + // it from colliding with future groups; the index is + // the load-bearing part for `is_group_hovered`. + let group_name = format!("card-{idx}"); + let count = counts_now[idx]; + let resp = ui + .group(&group_name) + .border(Border::Single) + .group_hover_border_style(Style::new().fg(Color::Yellow)) + .min_w(12) + .max_w(12) + .p(0) + .title(*title) .col(|ui| { - ui.text(format!("이름 = {}", name_input.value)).bold(); - ui.text(format!("태그 = {}", tag_input.value)).bold(); - let total: u32 = counts_state.get(ui).iter().sum(); - ui.text(format!("카드 클릭 합계 = {total}")) - .fg(Color::Green); + if ui.is_group_hovered(&group_name) { + ui.text(format!("hits {count}")).fg(Color::Yellow); + } else { + ui.text(format!("hits {count}")).dim(); + } }); - }); + if resp.clicked { + clicked = Some(idx); + } + } - ui.separator(); - ui.text("Truncation table — 각 박스는 너비 12, 제목이 잘려야 정상 (hover/click)") + if let Some(idx) = clicked { + let counts = counts_state.get_mut(ui); + counts[idx] = counts[idx].saturating_add(1); + *last_clicked_state.get_mut(ui) = Some(CARD_TITLES[idx].to_string()); + } + }); + + ui.separator(); + let _ = ui.row(|ui| { + let last_label = last_clicked_state + .get(ui) + .clone() + .unwrap_or_else(|| "(none)".to_string()); + let mouse_label = match mouse_pos { + Some((x, y)) => format!("mouse=({x},{y})"), + None => "mouse=(—)".to_string(), + }; + ui.text(format!("{mouse_label} · last clicked = {last_label}")) .dim(); - let _ = ui.row(|ui| { - // Read counts up front so the click handler below can set - // the next value without holding an immutable borrow on - // `ui` while we also read `is_group_hovered`. - let counts_now = *counts_state.get(ui); - let mut clicked: Option = None; - - for (idx, title) in CARD_TITLES.iter().enumerate() { - // Stable per-card group name. The `card-` prefix keeps - // it from colliding with future groups; the index is - // the load-bearing part for `is_group_hovered`. - let group_name = format!("card-{idx}"); - let count = counts_now[idx]; - let resp = ui - .group(&group_name) - .border(Border::Single) - .group_hover_border_style(Style::new().fg(Color::Yellow)) - .min_w(12) - .max_w(12) - .p(0) - .title(*title) - .col(|ui| { - if ui.is_group_hovered(&group_name) { - ui.text(format!("hits {count}")).fg(Color::Yellow); - } else { - ui.text(format!("hits {count}")).dim(); - } - }); - if resp.clicked { - clicked = Some(idx); - } - } + ui.text(format!("focus={focus_index}/{focus_count}")).dim(); - if let Some(idx) = clicked { - let counts = counts_state.get_mut(ui); - counts[idx] = counts[idx].saturating_add(1); - *last_clicked_state.get_mut(ui) = Some(CARD_TITLES[idx].to_string()); - } - }); - - ui.separator(); - let _ = ui.row(|ui| { - let last_label = last_clicked_state - .get(ui) - .clone() - .unwrap_or_else(|| "(none)".to_string()); - let mouse_label = match mouse_pos { - Some((x, y)) => format!("mouse=({x},{y})"), - None => "mouse=(—)".to_string(), - }; - ui.text(format!("{mouse_label} · last clicked = {last_label}")) - .dim(); - ui.text(format!("focus={focus_index}/{focus_count}")).dim(); - - if ui.button("초기화 / Reset").clicked { - *counts_state.get_mut(ui) = [0; 4]; - *last_clicked_state.get_mut(ui) = None; - name_input.value.clear(); - tag_input.value.clear(); - name_input.cursor = 0; - tag_input.cursor = 0; - } - if ui.button("포커스 / Focus next").clicked && focus_count > 0 { - ui.set_focus_index((focus_index + 1) % focus_count); - } - }); + if ui.button("초기화 / Reset").clicked { + *counts_state.get_mut(ui) = [0; 4]; + *last_clicked_state.get_mut(ui) = None; + name_input.value.clear(); + tag_input.value.clear(); + name_input.cursor = 0; + tag_input.cursor = 0; + } + if ui.button("포커스 / Focus next").clicked && focus_count > 0 { + ui.set_focus_index((focus_index + 1) % focus_count); + } }); - }) + }); } diff --git a/examples/demo_dashboard.rs b/examples/demo_dashboard.rs index 5cd4f1d..a1b2b28 100644 --- a/examples/demo_dashboard.rs +++ b/examples/demo_dashboard.rs @@ -16,7 +16,200 @@ struct Metrics { fn main() -> std::io::Result<()> { let spinner = SpinnerState::dots(); let mut log_scroll = ScrollState::new(); - let mut proc_table = TableState::new( + let mut proc_table = make_proc_table(); + let mut dark_mode = true; + let mut toasts = ToastState::new(); + let logs = make_logs(); + + slt::run_with(slt::RunConfig::default().mouse(true), |ui: &mut Context| { + render_frame( + ui, + &spinner, + &mut log_scroll, + &mut proc_table, + &mut dark_mode, + &mut toasts, + &logs, + ); + }) +} + +/// Render one frame with fresh, default state — used by visual snapshot tests +/// in `tests/visual_snapshots.rs`. The runtime example uses [`render_frame`] +/// directly so widget state can persist across frames. +pub fn render(ui: &mut Context) { + let spinner = SpinnerState::dots(); + let mut log_scroll = ScrollState::new(); + let mut proc_table = make_proc_table(); + let mut dark_mode = true; + let mut toasts = ToastState::new(); + let logs = make_logs(); + render_frame( + ui, + &spinner, + &mut log_scroll, + &mut proc_table, + &mut dark_mode, + &mut toasts, + &logs, + ); +} + +/// Render one frame of the dashboard demo into the supplied context. +/// +/// Exposed so both `main` (which keeps state across frames) and the +/// visual snapshot test (which builds fresh state and renders once) can +/// share the same rendering logic. +pub fn render_frame( + ui: &mut Context, + spinner: &SpinnerState, + log_scroll: &mut ScrollState, + proc_table: &mut TableState, + dark_mode: &mut bool, + toasts: &mut ToastState, + logs: &[(&'static str, &'static str, &'static str)], +) { + if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(slt::KeyCode::Esc) { + ui.quit(); + } + if ui.key_mod('t', slt::KeyModifiers::CONTROL) { + *dark_mode = !*dark_mode; + } + ui.set_theme(if *dark_mode { + Theme::dark() + } else { + Theme::light() + }); + + let tick = ui.tick(); + let metrics = sim_metrics(tick); + + let _ = ui + .bordered(Border::Rounded) + .title("System Dashboard") + .p(1) + .grow(1) + .col(|ui| { + let _ = ui.row(|ui| { + ui.spinner(spinner); + ui.text(" LIVE").bold().fg(Color::Green); + ui.spacer(); + ui.text(format!( + "Uptime: {}d {}h {}m", + metrics.uptime_secs / 86400, + (metrics.uptime_secs % 86400) / 3600, + (metrics.uptime_secs % 3600) / 60, + )) + .dim(); + }); + let _ = ui.divider_text("System Metrics"); + let _ = ui.row(|ui| { + metric_card(ui, "CPU", metrics.cpu, "%", Color::Cyan); + metric_card(ui, "Memory", metrics.mem, "%", Color::Yellow); + metric_card( + ui, + "Disk", + metrics.disk, + "%", + if metrics.disk > 80.0 { + Color::Red + } else { + Color::Green + }, + ); + metric_card(ui, "Net In", metrics.net_in, "MB/s", Color::Blue); + metric_card(ui, "Net Out", metrics.net_out, "MB/s", Color::Magenta); + }); + + let _ = ui.divider_text("Key Metrics"); + let _ = ui.row(|ui| { + let _ = ui.bordered(Border::Rounded).p(1).grow(1).col(|ui| { + let _ = ui.stat_trend("Requests", &format!("{}", metrics.requests), Trend::Up); + }); + let _ = ui.bordered(Border::Rounded).p(1).grow(1).col(|ui| { + let _ = ui.stat_colored( + "Errors", + &format!("{}", metrics.errors), + if metrics.errors > 5 { + Color::Red + } else { + Color::Green + }, + ); + }); + let _ = ui.bordered(Border::Rounded).p(1).grow(1).col(|ui| { + let _ = ui.stat_colored("P99", "45ms", Color::Yellow); + }); + let _ = ui.bordered(Border::Rounded).p(1).grow(1).col(|ui| { + let _ = ui.stat_colored("Threads", "24", Color::Blue); + }); + }); + + let _ = ui.container().grow(1).row(|ui| { + // process table + let _ = ui + .bordered(Border::Rounded) + .title("Processes") + .p(1) + .grow(1) + .col(|ui| { + let _ = ui.table(proc_table); + ui.separator(); + let _ = ui.row(|ui| { + if ui.button("Kill").clicked { + let row = proc_table.selected; + if let Some(name) = proc_table.rows.get(row).and_then(|r| r.get(1)) + { + toasts.warning(format!("Killed: {name}"), tick); + } + } + if ui.button("Restart").clicked { + let row = proc_table.selected; + if let Some(name) = proc_table.rows.get(row).and_then(|r| r.get(1)) + { + toasts.success(format!("Restarted: {name}"), tick); + } + } + }); + }); + + // log stream + let _ = ui + .bordered(Border::Rounded) + .title("Logs") + .p(1) + .grow(1) + .col(|ui| { + let _ = ui.scrollable(log_scroll).grow(1).col(|ui| { + for &(time, level, msg) in logs { + let color = match level { + "ERROR" => Color::Red, + "WARN" => Color::Yellow, + _ => Color::Indexed(245), + }; + ui.styled( + format!("{time} [{level:5}] {msg}"), + Style::new().fg(color), + ); + } + }); + }); + }); + + ui.toast(toasts); + + let _ = ui.divider_text("Controls"); + let _ = ui.help(&[ + ("Ctrl+Q", "quit"), + ("Ctrl+T", "theme"), + ("Tab", "focus"), + ("j/k", "select"), + ]); + }); +} + +fn make_proc_table() -> TableState { + TableState::new( vec!["PID", "Name", "CPU%", "Mem%", "Status"], vec![ vec!["1", "systemd", "0.1", "0.3", "running"], @@ -28,11 +221,11 @@ fn main() -> std::io::Result<()> { vec!["789", "go-api", "3.8", "2.9", "running"], vec!["834", "cron", "0.0", "0.1", "sleeping"], ], - ); - let mut dark_mode = true; - let mut toasts = ToastState::new(); + ) +} - let logs = vec![ +fn make_logs() -> Vec<(&'static str, &'static str, &'static str)> { + vec![ ("12:04:01", "INFO", "Request GET /api/users 200 (12ms)"), ("12:04:03", "INFO", "Request POST /api/auth 200 (45ms)"), ("12:04:05", "WARN", "High memory usage: 82.4%"), @@ -61,150 +254,7 @@ fn main() -> std::io::Result<()> { ), ("12:04:42", "ERROR", "Failed to send email: SMTP timeout"), ("12:04:45", "INFO", "Worker process recycled (PID 501)"), - ]; - - slt::run_with(slt::RunConfig::default().mouse(true), |ui: &mut Context| { - if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(slt::KeyCode::Esc) { - ui.quit(); - } - if ui.key_mod('t', slt::KeyModifiers::CONTROL) { - dark_mode = !dark_mode; - } - ui.set_theme(if dark_mode { - Theme::dark() - } else { - Theme::light() - }); - - let tick = ui.tick(); - let metrics = sim_metrics(tick); - - let _ = ui - .bordered(Border::Rounded) - .title("System Dashboard") - .p(1) - .grow(1) - .col(|ui| { - let _ = ui.row(|ui| { - ui.spinner(&spinner); - ui.text(" LIVE").bold().fg(Color::Green); - ui.spacer(); - ui.text(format!( - "Uptime: {}d {}h {}m", - metrics.uptime_secs / 86400, - (metrics.uptime_secs % 86400) / 3600, - (metrics.uptime_secs % 3600) / 60, - )) - .dim(); - }); - let _ = ui.divider_text("System Metrics"); - let _ = ui.row(|ui| { - metric_card(ui, "CPU", metrics.cpu, "%", Color::Cyan); - metric_card(ui, "Memory", metrics.mem, "%", Color::Yellow); - metric_card( - ui, - "Disk", - metrics.disk, - "%", - if metrics.disk > 80.0 { - Color::Red - } else { - Color::Green - }, - ); - metric_card(ui, "Net In", metrics.net_in, "MB/s", Color::Blue); - metric_card(ui, "Net Out", metrics.net_out, "MB/s", Color::Magenta); - }); - - let _ = ui.divider_text("Key Metrics"); - let _ = ui.row(|ui| { - let _ = ui.bordered(Border::Rounded).p(1).grow(1).col(|ui| { - let _ = - ui.stat_trend("Requests", &format!("{}", metrics.requests), Trend::Up); - }); - let _ = ui.bordered(Border::Rounded).p(1).grow(1).col(|ui| { - let _ = ui.stat_colored( - "Errors", - &format!("{}", metrics.errors), - if metrics.errors > 5 { - Color::Red - } else { - Color::Green - }, - ); - }); - let _ = ui.bordered(Border::Rounded).p(1).grow(1).col(|ui| { - let _ = ui.stat_colored("P99", "45ms", Color::Yellow); - }); - let _ = ui.bordered(Border::Rounded).p(1).grow(1).col(|ui| { - let _ = ui.stat_colored("Threads", "24", Color::Blue); - }); - }); - - let _ = ui.container().grow(1).row(|ui| { - // process table - let _ = ui - .bordered(Border::Rounded) - .title("Processes") - .p(1) - .grow(1) - .col(|ui| { - let _ = ui.table(&mut proc_table); - ui.separator(); - let _ = ui.row(|ui| { - if ui.button("Kill").clicked { - let row = proc_table.selected; - if let Some(name) = - proc_table.rows.get(row).and_then(|r| r.get(1)) - { - toasts.warning(format!("Killed: {name}"), tick); - } - } - if ui.button("Restart").clicked { - let row = proc_table.selected; - if let Some(name) = - proc_table.rows.get(row).and_then(|r| r.get(1)) - { - toasts.success(format!("Restarted: {name}"), tick); - } - } - }); - }); - - // log stream - let _ = ui - .bordered(Border::Rounded) - .title("Logs") - .p(1) - .grow(1) - .col(|ui| { - let _ = ui.scrollable(&mut log_scroll).grow(1).col(|ui| { - for &(time, level, msg) in &logs { - let color = match level { - "ERROR" => Color::Red, - "WARN" => Color::Yellow, - _ => Color::Indexed(245), - }; - ui.styled( - format!("{time} [{level:5}] {msg}"), - Style::new().fg(color), - ); - } - }); - }); - }); - - ui.toast(&mut toasts); - - let _ = ui.divider_text("Controls"); - let _ = ui.help(&[ - ("Ctrl+Q", "quit"), - ("Ctrl+T", "theme"), - ("Tab", "focus"), - ("j/k", "select"), - ]); - }); - }) + ] } fn metric_card(ui: &mut Context, label: &str, value: f64, unit: &str, color: Color) { diff --git a/examples/demo_infoviz.rs b/examples/demo_infoviz.rs index 630645a..971a556 100644 --- a/examples/demo_infoviz.rs +++ b/examples/demo_infoviz.rs @@ -4,6 +4,45 @@ use slt::{ }; fn main() -> std::io::Result<()> { + let mut tabs = TabsState::new(vec![ + "Overview", + "Lines", + "Scatter", + "Bars", + "Heatmap", + "Financial", + "Treemap", + "Canvas", + ]); + slt::run(|ui: &mut Context| { + render_frame(ui, &mut tabs); + }) +} + +/// Render one frame with fresh, default state — used by visual snapshot tests +/// in `tests/visual_snapshots.rs`. The runtime example uses [`render_frame`] +/// directly so the selected tab persists across frames. +pub fn render(ui: &mut Context) { + let mut tabs = TabsState::new(vec![ + "Overview", + "Lines", + "Scatter", + "Bars", + "Heatmap", + "Financial", + "Treemap", + "Canvas", + ]); + render_frame(ui, &mut tabs); +} + +/// Render one frame of the infoviz demo. +/// +/// All chart data is constructed inside this function so it can be called +/// from both the runtime event loop in `main` and from visual snapshot +/// tests in `tests/visual_snapshots.rs` without external setup. `tabs` +/// is passed in so its selected index can persist across frames at runtime. +pub fn render_frame(ui: &mut Context, tabs: &mut TabsState) { // --- Shared data --- let cpu_data: Vec<(f64, f64)> = vec![ (0.0, 32.0), @@ -251,22 +290,10 @@ fn main() -> std::io::Result<()> { ), ]; - let mut tabs = TabsState::new(vec![ - "Overview", - "Lines", - "Scatter", - "Bars", - "Heatmap", - "Financial", - "Treemap", - "Canvas", - ]); - - slt::run(|ui: &mut Context| { - if ui.key('q') || ui.key_code(slt::KeyCode::Esc) { - ui.quit(); - } - + if ui.key('q') || ui.key_code(slt::KeyCode::Esc) { + ui.quit(); + } + { let tw = ui.width() as u32; let th = ui.height() as u32; let grid_dim = slt::Style::new().fg(Color::Indexed(237)); @@ -276,7 +303,7 @@ fn main() -> std::io::Result<()> { .title("SLT Infoviz") .grow(1) .col(|ui| { - let _ = ui.tabs(&mut tabs); + let _ = ui.tabs(tabs); match tabs.selected { // ── Tab 0: Overview ────────────────────────────────── @@ -1134,5 +1161,5 @@ fn main() -> std::io::Result<()> { let _ = ui.help(&[("q", "quit"), ("\u{2190}/\u{2192}", "tab"), ("Esc", "quit")]); }); - }) + } } diff --git a/examples/demo_overlay_anchor.rs b/examples/demo_overlay_anchor.rs new file mode 100644 index 0000000..60963a1 --- /dev/null +++ b/examples/demo_overlay_anchor.rs @@ -0,0 +1,90 @@ +//! Demonstrates `overlay_at(Anchor::*)` and `overlay_at_offset(Anchor::*, dx, dy)` — +//! pin floating content to any of the 9 compass positions, with an optional +//! cell-offset inset toward the viewport center. +//! +//! Each badge sits in its own overlay with the corresponding [`Anchor`]. +//! The base layer renders a centered title behind them; the overlays float +//! on top without dimming (use [`modal_at`] for a dimmed variant). +//! +//! - Outer flush badges (`TL`..`BR`) call [`Context::overlay_at`] — the +//! widget sits flush against the screen edge / corner. +//! - Inset badges (`tl*`, `tr*`, `bl*`, `br*`) call +//! [`Context::overlay_at_offset`] with `(dx=2, dy=1)` — 2 cells +//! horizontally and 1 row vertically inset toward the center, the SLT +//! analog of CSS `place-self: end end; bottom: 1px; right: 2px;`. +//! +//! Press `q` or `Esc` to quit. + +use slt::{Anchor, Border, Color, Context, KeyCode}; + +/// Render one frame of the overlay-anchor demo. +/// +/// Exposed as a free function so that visual snapshot tests in +/// `tests/visual_snapshots.rs` can drive the same rendering logic +/// through a `TestBackend` without going through `slt::run`. +pub fn render(ui: &mut Context) { + if ui.key('q') || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + + // Base layer — visible behind every overlay. + let _ = ui + .bordered(Border::Rounded) + .title("overlay_at + overlay_at_offset demo") + .p(2) + .grow(1) + .col(|ui| { + ui.text("Press q to quit.").dim(); + ui.spacer(); + ui.text("Outer flush badges = overlay_at(anchor)") + .fg(Color::Cyan) + .bold(); + ui.text("Inner inset badges = overlay_at_offset(anchor, 2, 1)") + .fg(Color::Magenta) + .bold(); + ui.text("CSS analog: place-self + top/right/bottom/left inset.") + .dim(); + ui.spacer(); + }); + + // Flush badges (no inset). Each lives in its own overlay, so flexbox + // doesn't compete with the base layer. + for (anchor, label, color) in ANCHORS { + let _ = ui.overlay_at(*anchor, |ui| { + ui.text(*label).fg(Color::Black).bg(*color); + }); + } + + // Inset badges — same anchors, shifted (dx=2, dy=1) toward the + // viewport center. Compare visually with the flush corners above. + for (anchor, label) in INSET_BADGES { + let _ = ui.overlay_at_offset(*anchor, 2, 1, |ui| { + ui.text(*label).fg(Color::White).bg(Color::Magenta); + }); + } +} + +fn main() -> std::io::Result<()> { + slt::run(render) +} + +const ANCHORS: &[(Anchor, &str, Color)] = &[ + (Anchor::TopLeft, " TL ", Color::Cyan), + (Anchor::TopCenter, " TC ", Color::Cyan), + (Anchor::TopRight, " TR ", Color::Cyan), + (Anchor::CenterLeft, " ML ", Color::Yellow), + (Anchor::Center, " CC ", Color::Magenta), + (Anchor::CenterRight, " MR ", Color::Yellow), + (Anchor::BottomLeft, " BL ", Color::Green), + (Anchor::BottomCenter, " BC ", Color::Green), + (Anchor::BottomRight, " BR ", Color::Green), +]; + +// Same compass corners, but inset by (2 cols, 1 row) toward the center — +// the typical "16px inset corner badge" pattern from CSS layouts. +const INSET_BADGES: &[(Anchor, &str)] = &[ + (Anchor::TopLeft, " tl* "), + (Anchor::TopRight, " tr* "), + (Anchor::BottomLeft, " bl* "), + (Anchor::BottomRight, " br* "), +]; diff --git a/examples/demo_wiki.rs b/examples/demo_wiki.rs deleted file mode 100644 index 5ef7c6a..0000000 --- a/examples/demo_wiki.rs +++ /dev/null @@ -1,279 +0,0 @@ -use slt::{Border, Color, Context, KeyCode, RunConfig, Style, TabsState, Theme}; - -struct MemberProfile { - tab: &'static str, - name: &'static str, - born: &'static str, - position: &'static str, - nationality: &'static str, - note: &'static str, - image_path: &'static str, - placeholder_color: [u8; 3], -} - -struct MemberImage { - rgba: Vec, - width: u32, - height: u32, -} - -const MEMBERS: [MemberProfile; 4] = [ - MemberProfile { - tab: "Jisoo", - name: "Kim Jisoo (김지수)", - born: "1995-01-03", - position: "Lead Vocal", - nationality: "South Korea", - note: "Eldest member, actress in Snowdrop", - image_path: "assets/blackpink/jisoo.jpg", - placeholder_color: [255, 105, 180], - }, - MemberProfile { - tab: "Jennie", - name: "Jennie Kim (제니)", - born: "1996-01-16", - position: "Main Rapper & Lead Vocal", - nationality: "South Korea", - note: "Solo debut 2018, 6 years trainee", - image_path: "assets/blackpink/jennie.jpg", - placeholder_color: [220, 20, 60], - }, - MemberProfile { - tab: "Rosé", - name: "Park Chaeyoung (로제/박채영)", - born: "1997-02-11", - position: "Main Vocal & Lead Dancer", - nationality: "South Korea/New Zealand", - note: "Born in NZ, raised in Australia", - image_path: "assets/blackpink/rose.jpg", - placeholder_color: [255, 127, 80], - }, - MemberProfile { - tab: "Lisa", - name: "Lalisa Manobal (리사)", - born: "1997-03-27", - position: "Main Dancer & Lead Rapper", - nationality: "Thailand", - note: "Most followed K-pop idol on Instagram", - image_path: "assets/blackpink/lisa.jpg", - placeholder_color: [148, 103, 189], - }, -]; - -fn main() -> std::io::Result<()> { - let mut tabs = TabsState::new(vec![ - "Jisoo", - "Jennie", - "Rosé", - "Lisa", - "Group", - "Discography", - ]); - let member_images: Vec = MEMBERS.iter().map(load_member_image).collect(); - - slt::run_with( - RunConfig::default().mouse(true).theme(Theme::dark()), - move |ui: &mut Context| { - let quit_key = ui.key('q'); - let esc_key = ui.key_code(KeyCode::Esc); - if quit_key || esc_key { - ui.quit(); - } - - let _ = ui.container().bg(Color::Rgb(28, 30, 34)).p(1).row(|ui| { - ui.text("BLACKPINK").bold().fg(Color::Rgb(255, 105, 180)); - ui.text(" 블랙핑크").fg(Color::White); - ui.spacer(); - ui.text("나무위키 스타일").fg(Color::Rgb(126, 211, 33)); - }); - - let _ = ui.tabs(&mut tabs); - - let selected = tabs.selected; - let imgs = &member_images; - match selected { - 0..=3 => render_member(ui, selected, imgs), - 4 => render_group(ui), - _ => render_discography(ui), - } - - let _ = ui.help(&[("← →", "tab"), ("q", "quit")]); - }, - ) -} - -fn render_member(ui: &mut Context, idx: usize, member_images: &[MemberImage]) { - let p = &MEMBERS[idx]; - let img = &member_images[idx]; - - let _ = ui.bordered(Border::Single).p(1).row(|ui| { - let _ = ui - .bordered(Border::Single) - .title(format!("{} Photo", p.tab)) - .col(|ui| { - let _ = ui.kitty_image_fit(&img.rgba, img.width, img.height, 30); - }); - - let _ = ui - .bordered(Border::Single) - .title("Profile Info") - .grow(1) - .p(1) - .col(|ui| { - info_row(ui, "Name", p.name); - info_row(ui, "Born", p.born); - info_row(ui, "Position", p.position); - info_row(ui, "Nationality", p.nationality); - info_row(ui, "Note", p.note); - }); - }); -} - -fn render_group(ui: &mut Context) { - let _ = ui.bordered(Border::Single).title("Group").p(1).col(|ui| { - info_row(ui, "Debut", "2016-08-08"); - info_row(ui, "Agency", "YG Entertainment"); - info_row(ui, "Fandom", "BLINK"); - info_row(ui, "Members", "Jisoo, Jennie, Rosé, Lisa"); - ui.separator(); - ui.text("Achievements").bold().fg(Color::Rgb(255, 105, 180)); - ui.text("• First K-pop girl group at Coachella (2019)"); - ui.text("• Highest-charting female K-pop group on Billboard Hot 100"); - ui.text("• Most-subscribed music group on YouTube"); - }); -} - -fn render_discography(ui: &mut Context) { - let _ = ui - .bordered(Border::Single) - .title("Discography") - .p(1) - .col(|ui| { - album_row(ui, "SQUARE ONE", "2016", "Digital Single"); - album_row(ui, "SQUARE UP", "2018", "Mini Album"); - album_row(ui, "THE ALBUM", "2020", "Studio Album"); - album_row(ui, "BORN PINK", "2022", "Studio Album"); - album_row(ui, "DEADLINE", "2026", "Mini Album"); - }); -} - -fn album_row(ui: &mut Context, title: &str, year: &str, kind: &str) { - ui.line(|ui| { - ui.text(format!("{year} ")).fg(Color::Indexed(245)); - ui.text(title).bold().fg(Color::Rgb(255, 105, 180)); - ui.text(format!(" ({kind})")).fg(Color::Indexed(245)); - }); -} - -fn info_row(ui: &mut Context, key: &str, value: &str) { - ui.line(|ui| { - ui.styled( - format!("{key}: "), - Style::new().fg(Color::Indexed(250)).bold(), - ); - ui.text(value); - }); -} - -fn load_member_image(member: &MemberProfile) -> MemberImage { - if let Some(img) = try_load_image_file(member.image_path) { - return img; - } - MemberImage { - rgba: gen_gradient(200, 300, member.placeholder_color), - width: 200, - height: 300, - } -} - -fn try_load_image_file(path: &str) -> Option { - let path = std::path::Path::new(path); - if !path.exists() { - return None; - } - let data = std::fs::read(path).ok()?; - let (w, h, rgb) = jpeg_decoder(&data)?; - let mut rgba = Vec::with_capacity(w * h * 4); - for chunk in rgb.chunks(3) { - rgba.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); - } - Some(MemberImage { - rgba, - width: w as u32, - height: h as u32, - }) -} - -fn jpeg_decoder(data: &[u8]) -> Option<(usize, usize, Vec)> { - if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 { - return None; - } - // Minimal approach: use image crate if available, otherwise use stb-style decode - // For now, shell out to convert via sips (macOS) or ffmpeg - let tmp_in = "/tmp/slt_wiki_input.jpg"; - let tmp_out = "/tmp/slt_wiki_output.bmp"; - std::fs::write(tmp_in, data).ok()?; - - let status = std::process::Command::new("sips") - .args(["-s", "format", "bmp", tmp_in, "--out", tmp_out]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .ok()?; - - if !status.success() { - return None; - } - - read_bmp_rgb(tmp_out) -} - -fn read_bmp_rgb(path: &str) -> Option<(usize, usize, Vec)> { - let data = std::fs::read(path).ok()?; - if data.len() < 54 || data[0] != b'B' || data[1] != b'M' { - return None; - } - let offset = u32::from_le_bytes([data[10], data[11], data[12], data[13]]) as usize; - let w = i32::from_le_bytes([data[18], data[19], data[20], data[21]]) as usize; - let h_raw = i32::from_le_bytes([data[22], data[23], data[24], data[25]]); - let h = h_raw.unsigned_abs() as usize; - let bpp = u16::from_le_bytes([data[28], data[29]]) as usize; - - if bpp != 24 && bpp != 32 { - return None; - } - - let bytes_per_pixel = bpp / 8; - let row_size = (w * bytes_per_pixel).div_ceil(4) * 4; - let mut rgb = Vec::with_capacity(w * h * 3); - let bottom_up = h_raw > 0; - - for row in 0..h { - let src_row = if bottom_up { h - 1 - row } else { row }; - let row_start = offset + src_row * row_size; - for col in 0..w { - let px = row_start + col * bytes_per_pixel; - if px + 2 >= data.len() { - rgb.extend_from_slice(&[0, 0, 0]); - continue; - } - rgb.extend_from_slice(&[data[px + 2], data[px + 1], data[px]]); - } - } - - Some((w, h, rgb)) -} - -fn gen_gradient(w: u32, h: u32, base: [u8; 3]) -> Vec { - let mut rgba = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for _x in 0..w { - let t = y as f32 / h as f32; - let r = (base[0] as f32 * (1.0 - t * 0.5)).clamp(0.0, 255.0) as u8; - let g = (base[1] as f32 * (1.0 - t * 0.3)).clamp(0.0, 255.0) as u8; - let b = (base[2] as f32 * (1.0 - t * 0.5)).clamp(0.0, 255.0) as u8; - rgba.extend_from_slice(&[r, g, b, 255]); - } - } - rgba -} diff --git a/src/context.rs b/src/context.rs index 86d2d6e..3341c84 100644 --- a/src/context.rs +++ b/src/context.rs @@ -37,6 +37,7 @@ mod widgets_display; mod widgets_input; mod widgets_interactive; mod widgets_viz; +pub use widgets_display::Anchor; pub use widgets_viz::TreemapItem; mod state; diff --git a/src/context/container.rs b/src/context/container.rs index 98fd9e6..5040780 100644 --- a/src/context/container.rs +++ b/src/context/container.rs @@ -13,7 +13,7 @@ use super::*; /// use slt::{Border, Color}; /// ui.container() /// .border(Border::Rounded) -/// .pad(1) +/// .p(1) /// .grow(1) /// .col(|ui| { /// ui.text("inside a bordered, padded, growing column"); @@ -825,17 +825,18 @@ impl<'a> ContainerBuilder<'a> { // ── padding (Tailwind: p, px, py, pt, pr, pb, pl) ─────────────── - /// Set uniform padding on all sides. Alias for [`pad`](Self::pad). - pub fn p(self, value: u32) -> Self { - self.pad(value) - } - /// Set uniform padding on all sides. - pub fn pad(mut self, value: u32) -> Self { + pub fn p(mut self, value: u32) -> Self { self.padding = Padding::all(value); self } + /// Set uniform padding on all sides. Deprecated alias for [`p`](Self::p). + #[deprecated(since = "0.20.0", note = "Use `p()` instead")] + pub fn pad(self, value: u32) -> Self { + self.p(value) + } + /// Set horizontal padding (left and right). pub fn px(mut self, value: u32) -> Self { self.padding.left = value; @@ -1017,34 +1018,56 @@ impl<'a> ContainerBuilder<'a> { self } + define_breakpoint_methods!( + base = min_h, + arg = value: u32, + xs = xs_min_h => ["Minimum height applied only at Xs breakpoint (< 40 cols)."], + sm = sm_min_h => ["Minimum height applied only at Sm breakpoint (40-79 cols)."], + md = md_min_h => ["Minimum height applied only at Md breakpoint (80-119 cols)."], + lg = lg_min_h => ["Minimum height applied only at Lg breakpoint (120-159 cols)."], + xl = xl_min_h => ["Minimum height applied only at Xl breakpoint (>= 160 cols)."], + at = min_h_at => ["Minimum height applied only at the given breakpoint."] + ); + /// Set the maximum height constraint. Shorthand for [`max_height`](Self::max_height). pub fn max_h(mut self, value: u32) -> Self { self.constraints.max_height = Some(value); self } - /// Set the minimum width constraint in cells. - pub fn min_width(mut self, value: u32) -> Self { - self.constraints.min_width = Some(value); - self + define_breakpoint_methods!( + base = max_h, + arg = value: u32, + xs = xs_max_h => ["Maximum height applied only at Xs breakpoint (< 40 cols)."], + sm = sm_max_h => ["Maximum height applied only at Sm breakpoint (40-79 cols)."], + md = md_max_h => ["Maximum height applied only at Md breakpoint (80-119 cols)."], + lg = lg_max_h => ["Maximum height applied only at Lg breakpoint (120-159 cols)."], + xl = xl_max_h => ["Maximum height applied only at Xl breakpoint (>= 160 cols)."], + at = max_h_at => ["Maximum height applied only at the given breakpoint."] + ); + + /// Set the minimum width constraint in cells. Deprecated alias for [`min_w`](Self::min_w). + #[deprecated(since = "0.20.0", note = "Use `min_w()` instead")] + pub fn min_width(self, value: u32) -> Self { + self.min_w(value) } - /// Set the maximum width constraint in cells. - pub fn max_width(mut self, value: u32) -> Self { - self.constraints.max_width = Some(value); - self + /// Set the maximum width constraint in cells. Deprecated alias for [`max_w`](Self::max_w). + #[deprecated(since = "0.20.0", note = "Use `max_w()` instead")] + pub fn max_width(self, value: u32) -> Self { + self.max_w(value) } - /// Set the minimum height constraint in rows. - pub fn min_height(mut self, value: u32) -> Self { - self.constraints.min_height = Some(value); - self + /// Set the minimum height constraint in rows. Deprecated alias for [`min_h`](Self::min_h). + #[deprecated(since = "0.20.0", note = "Use `min_h()` instead")] + pub fn min_height(self, value: u32) -> Self { + self.min_h(value) } - /// Set the maximum height constraint in rows. - pub fn max_height(mut self, value: u32) -> Self { - self.constraints.max_height = Some(value); - self + /// Set the maximum height constraint in rows. Deprecated alias for [`max_h`](Self::max_h). + #[deprecated(since = "0.20.0", note = "Use `max_h()` instead")] + pub fn max_height(self, value: u32) -> Self { + self.max_h(value) } /// Set width as a percentage (1-100) of the parent container. @@ -1212,7 +1235,7 @@ impl<'a> ContainerBuilder<'a> { /// use slt::Border; /// let highlighted = true; /// ui.container() - /// .pad(1) + /// .p(1) /// .with_if(highlighted, |c| c.border(Border::Single).title("Active")) /// .col(|ui| { /// ui.text("body"); @@ -1238,7 +1261,7 @@ impl<'a> ContainerBuilder<'a> { /// # slt::run(|ui: &mut slt::Context| { /// use slt::Border; /// ui.container() - /// .with(|c| c.border(Border::Rounded).pad(1)) + /// .with(|c| c.border(Border::Rounded).p(1)) /// .col(|ui| { /// ui.text("body"); /// }); diff --git a/src/context/core.rs b/src/context/core.rs index b9679dc..0c332fe 100644 --- a/src/context/core.rs +++ b/src/context/core.rs @@ -41,6 +41,10 @@ pub struct Context { pub(crate) prev_modal_active: bool, pub(crate) clipboard_text: Option, pub(crate) debug: bool, + /// Issue #201: which layers the F12 debug overlay should outline. Read + /// from `state.diagnostics.debug_layer` at frame start and written back + /// at frame end so [`Context::set_debug_layer`] persists across frames. + pub(crate) debug_layer: crate::DebugLayer, pub(crate) theme: Theme, pub(crate) is_real_terminal: bool, pub(crate) deferred_draws: Vec>, diff --git a/src/context/runtime.rs b/src/context/runtime.rs index 36160a5..89bc8eb 100644 --- a/src/context/runtime.rs +++ b/src/context/runtime.rs @@ -43,8 +43,15 @@ impl Context { } } + // Reuse `commands_buf` capacity from the previous frame (issue #150). + // `mem::take` swaps an empty Vec into `state.commands_buf`; we then + // clear (no-op since len==0) and reuse the reclaimed allocation. After + // `build_tree` consumes commands, the empty Vec is returned to + // `state.commands_buf` for the next frame in `run_frame_kernel`. + let mut commands = std::mem::take(&mut state.commands_buf); + commands.clear(); let mut ctx = Self { - commands: Vec::new(), + commands, events, consumed, should_quit: false, @@ -69,6 +76,7 @@ impl Context { prev_modal_active: focus.prev_modal_active, clipboard_text: None, debug: diagnostics.debug_mode, + debug_layer: diagnostics.debug_layer, theme, is_real_terminal: false, deferred_draws: Vec::new(), diff --git a/src/context/widgets_display.rs b/src/context/widgets_display.rs index 3701a06..f551e8d 100644 --- a/src/context/widgets_display.rs +++ b/src/context/widgets_display.rs @@ -5,6 +5,8 @@ mod rich_output; mod status; mod text; +pub use layout::Anchor; + #[cfg(test)] mod line_wrap_tests; #[cfg(test)] diff --git a/src/context/widgets_display/layout.rs b/src/context/widgets_display/layout.rs index 76db309..0acfba2 100644 --- a/src/context/widgets_display/layout.rs +++ b/src/context/widgets_display/layout.rs @@ -7,6 +7,153 @@ fn sep_line() -> &'static str { SEP_LINE.get_or_init(|| "─".repeat(200)) } +/// Compass-rose anchor for [`Context::overlay_at`] / [`Context::modal_at`]. +/// +/// Each variant maps to a (cross-axis [`Align`], main-axis [`Justify`]) pair +/// that pins overlay content to the requested screen position. The `_at` +/// helpers expand to a full-screen wrapper (so flexbox has slack to push +/// against), then place the user's content per the selected anchor. +/// +/// ```no_run +/// # use slt::Anchor; +/// # slt::run(|ui: &mut slt::Context| { +/// ui.overlay_at(Anchor::BottomRight, |ui| { +/// ui.text("v0.19.3").dim(); +/// }); +/// # }); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Anchor { + /// Top-left corner. + TopLeft, + /// Top edge, horizontally centered. + TopCenter, + /// Top-right corner. + TopRight, + /// Left edge, vertically centered. + CenterLeft, + /// Screen center. + Center, + /// Right edge, vertically centered. + CenterRight, + /// Bottom-left corner. + BottomLeft, + /// Bottom edge, horizontally centered. + BottomCenter, + /// Bottom-right corner. + BottomRight, +} + +/// Map [`Anchor`] to the wrapper column's (cross-axis align, main-axis justify). +/// +/// The inner column is `Direction::Column`, so: +/// - `Justify` controls the vertical (main-axis) position. +/// - `Align` controls the horizontal (cross-axis) position. +fn anchor_to_align_justify(anchor: Anchor) -> (Align, Justify) { + match anchor { + Anchor::TopLeft => (Align::Start, Justify::Start), + Anchor::TopCenter => (Align::Center, Justify::Start), + Anchor::TopRight => (Align::End, Justify::Start), + Anchor::CenterLeft => (Align::Start, Justify::Center), + Anchor::Center => (Align::Center, Justify::Center), + Anchor::CenterRight => (Align::End, Justify::Center), + Anchor::BottomLeft => (Align::Start, Justify::End), + Anchor::BottomCenter => (Align::Center, Justify::End), + Anchor::BottomRight => (Align::End, Justify::End), + } +} + +/// Resolve `(dx, dy)` to a [`Margin`] for the outer grow-1 anchor column, +/// given an [`Anchor`]. +/// +/// Sign convention: **positive `dx` / `dy` inset toward the viewport center** +/// (mirrors the CSS `inset` shorthand intuition). The margin shrinks the +/// column's slack on the side adjacent to the anchored edge, so subsequent +/// flexbox `align`/`justify` push the user's content inward by `(dx, dy)`: +/// - `BottomRight` + `(dx=2, dy=1)` → `mr=2, mb=1` (push 2 left, 1 up) +/// - `TopLeft` + `(dx=2, dy=1)` → `ml=2, mt=1` (push 2 right, 1 down) +/// - `Center` + `(dx=2, dy=1)` → `ml=2, mt=1` (shift 2 right, 1 down) +/// - `Center` + `(dx=-2, dy=-1)` → `mr=2, mb=1` (shift 2 left, 1 up) +/// +/// Negative values for corner / edge anchors would push the content +/// off-screen (no opposite-side slack to consume), so they are clamped to 0; +/// see [`Context::overlay_at_offset`] for the documented contract. +fn anchor_offset_to_margin(anchor: Anchor, dx: i32, dy: i32) -> Margin { + let mut margin = Margin::default(); + + // Horizontal axis: positive dx insets toward center. + let h_anchor = match anchor { + Anchor::TopLeft | Anchor::CenterLeft | Anchor::BottomLeft => HSide::Left, + Anchor::TopRight | Anchor::CenterRight | Anchor::BottomRight => HSide::Right, + Anchor::TopCenter | Anchor::Center | Anchor::BottomCenter => HSide::Center, + }; + match h_anchor { + HSide::Left => { + // Anchored to left edge: positive dx pushes right via ml. + // Negative dx would push left (offscreen) — no slack on the + // opposite side, and `u32` margin can't represent negatives, + // so we clamp to 0. See `Context::overlay_at_offset` doc. + if dx > 0 { + margin.left = dx as u32; + } + } + HSide::Right => { + // Anchored to right edge: positive dx pushes left via mr. + if dx > 0 { + margin.right = dx as u32; + } + } + HSide::Center => { + // Centered: positive dx shifts right (ml), negative shifts left (mr). + if dx > 0 { + margin.left = dx as u32; + } else if dx < 0 { + margin.right = dx.unsigned_abs(); + } + } + } + + // Vertical axis: positive dy insets toward center. + let v_anchor = match anchor { + Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => VSide::Top, + Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => VSide::Bottom, + Anchor::CenterLeft | Anchor::Center | Anchor::CenterRight => VSide::Center, + }; + match v_anchor { + VSide::Top => { + if dy > 0 { + margin.top = dy as u32; + } + } + VSide::Bottom => { + if dy > 0 { + margin.bottom = dy as u32; + } + } + VSide::Center => { + if dy > 0 { + margin.top = dy as u32; + } else if dy < 0 { + margin.bottom = dy.unsigned_abs(); + } + } + } + + margin +} + +enum HSide { + Left, + Right, + Center, +} + +enum VSide { + Top, + Bottom, + Center, +} + impl Context { /// Render a horizontal divider line. /// @@ -322,6 +469,140 @@ impl Context { self.response_for(interaction_id) } + /// Render floating content anchored to one of the 9 compass positions. + /// + /// Wraps [`overlay`](Self::overlay) with a full-area column that pins the + /// content to the requested anchor via flexbox `align`/`justify`. The + /// inner column gets `grow(1)` so the wrapper consumes the screen, giving + /// `align`/`justify` room to push the content to the corner. + /// + /// ```no_run + /// # use slt::Anchor; + /// # slt::run(|ui: &mut slt::Context| { + /// ui.overlay_at(Anchor::TopRight, |ui| { + /// ui.text("0:42").bold(); + /// }); + /// # }); + /// ``` + pub fn overlay_at(&mut self, anchor: Anchor, f: impl FnOnce(&mut Context)) -> Response { + self.overlay(|ui| { + let (align, justify) = anchor_to_align_justify(anchor); + let _ = ui.container().grow(1).align(align).justify(justify).col(f); + }) + } + + /// Render a modal overlay anchored to one of the 9 compass positions. + /// + /// Like [`modal`](Self::modal) but pinned to a corner / edge / center via + /// the same anchor wrapping as [`overlay_at`](Self::overlay_at). + pub fn modal_at(&mut self, anchor: Anchor, f: impl FnOnce(&mut Context)) -> Response { + self.modal(|ui| { + let (align, justify) = anchor_to_align_justify(anchor); + let _ = ui.container().grow(1).align(align).justify(justify).col(f); + }) + } + + /// Render `f` at `anchor` with cell offset `(dx, dy)` from the anchored edge. + /// + /// This is the SLT analog of CSS `position: absolute; top/right/bottom/left`, + /// or Flutter's `Positioned(top:, right:, ...)`. The 9-cell [`Anchor`] + /// chooses which edge to anchor to; `(dx, dy)` insets toward the center. + /// + /// # Sign convention + /// Positive `dx` / `dy` always inset toward the viewport center. So + /// `overlay_at_offset(Anchor::BottomRight, 2, 1, ...)` places the widget + /// 2 cells left and 1 cell up from the bottom-right corner. + /// + /// For [`Anchor::Center`] (and other centered axes) negative values shift + /// in the opposite direction — `(dx=-2, dy=-1)` shifts 2 cells left and 1 + /// cell up. For corner / edge anchors, negative values would push the + /// content off-screen, so they are clamped to 0; use a different anchor + /// instead of negative offsets to escape an edge. + /// + /// # CSS analogy + /// ```text + /// CSS: place-self: end end; bottom: 1px; right: 2px; + /// SLT: overlay_at_offset(Anchor::BottomRight, 2, 1, |ui| { ... }) + /// ``` + /// + /// # Example + /// + /// ```no_run + /// # use slt::Anchor; + /// # slt::run(|ui: &mut slt::Context| { + /// // Inset corner badge — 2 cells from the right, 1 row from the bottom. + /// ui.overlay_at_offset(Anchor::BottomRight, 2, 1, |ui| { + /// ui.text("v0.19.3").dim(); + /// }); + /// # }); + /// ``` + pub fn overlay_at_offset( + &mut self, + anchor: Anchor, + dx: i32, + dy: i32, + f: impl FnOnce(&mut Context), + ) -> Response { + self.overlay(|ui| { + let (align, justify) = anchor_to_align_justify(anchor); + let margin = anchor_offset_to_margin(anchor, dx, dy); + // Apply margin on the outer (grow=1) column so flexbox's parent + // (the synthetic overlay root) shrinks the column's area before + // align/justify pick a position. This avoids a wrapper container + // around `f`, which would expose a flexbox limitation where + // `Align::End` shifts the immediate child's `pos` but does not + // propagate the shift down to grandchildren. + let _ = ui + .container() + .grow(1) + .align(align) + .justify(justify) + .margin(margin) + .col(f); + }) + } + + /// Modal variant of [`overlay_at_offset`](Self::overlay_at_offset). + /// + /// Like [`modal_at`](Self::modal_at) but with a `(dx, dy)` cell inset + /// from the anchored edge. Positive values inset toward the center — + /// see [`overlay_at_offset`](Self::overlay_at_offset) for the full sign + /// convention. + /// + /// # Example + /// + /// ```no_run + /// # use slt::{Anchor, Border}; + /// # slt::run(|ui: &mut slt::Context| { + /// ui.modal_at_offset(Anchor::TopRight, 2, 1, |ui| { + /// ui.bordered(Border::Rounded).p(1).col(|ui| { + /// ui.text("Saved!"); + /// }); + /// }); + /// # }); + /// ``` + pub fn modal_at_offset( + &mut self, + anchor: Anchor, + dx: i32, + dy: i32, + f: impl FnOnce(&mut Context), + ) -> Response { + self.modal(|ui| { + let (align, justify) = anchor_to_align_justify(anchor); + let margin = anchor_offset_to_margin(anchor, dx, dy); + // See `overlay_at_offset` for why margin lives on the outer + // grow-1 column rather than a wrapper around `f`. + let _ = ui + .container() + .grow(1) + .align(align) + .justify(justify) + .margin(margin) + .col(f); + }) + } + /// Render a hover tooltip for the previously rendered interactive widget. /// /// Call this right after a widget or container response: diff --git a/src/context/widgets_interactive/events.rs b/src/context/widgets_interactive/events.rs index b34f1a9..ec10dd7 100644 --- a/src/context/widgets_interactive/events.rs +++ b/src/context/widgets_interactive/events.rs @@ -613,4 +613,24 @@ impl Context { pub fn debug_enabled(&self) -> bool { self.debug } + + /// Return which layers the F12 debug overlay outlines (issue #201). + /// + /// Default is [`DebugLayer::All`], which outlines the base tree plus any + /// active overlays/modals. See [`set_debug_layer`](Self::set_debug_layer) + /// to narrow the outline to a specific layer. + pub fn debug_layer(&self) -> crate::DebugLayer { + self.debug_layer + } + + /// Choose which layers the F12 debug overlay outlines (issue #201). + /// + /// Persists across frames. The default ([`DebugLayer::All`]) matches the + /// reporter's expectation that F12 reflects everything the renderer is + /// drawing. Use [`DebugLayer::TopMost`] to focus on the active modal / + /// overlay only, or [`DebugLayer::BaseOnly`] to keep the legacy behavior + /// of skipping overlays. + pub fn set_debug_layer(&mut self, layer: crate::DebugLayer) { + self.debug_layer = layer; + } } diff --git a/src/layout.rs b/src/layout.rs index 373762d..5b7cd44 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -15,7 +15,7 @@ mod flexbox; mod render; mod tree; -pub(crate) use collect::collect_all; +pub(crate) use collect::{collect_all, FrameData}; pub use command::Direction; pub(crate) use command::{BeginContainerArgs, BeginScrollableArgs, Command}; pub(crate) use flexbox::compute; diff --git a/src/layout/collect.rs b/src/layout/collect.rs index b660b5b..feca3a5 100644 --- a/src/layout/collect.rs +++ b/src/layout/collect.rs @@ -14,6 +14,23 @@ pub(crate) struct FrameData { pub raw_draw_rects: Vec, } +impl FrameData { + /// Reset all collection vectors to `len = 0` while keeping their + /// allocated capacities (issue #155). The next frame's `collect_all` + /// call writes into these slots, so the per-frame allocation churn of + /// 8 fresh `Vec::new()`s is amortized to zero after warm-up. + pub(crate) fn clear(&mut self) { + self.scroll_infos.clear(); + self.scroll_rects.clear(); + self.hit_areas.clear(); + self.group_rects.clear(); + self.content_areas.clear(); + self.focus_rects.clear(); + self.focus_groups.clear(); + self.raw_draw_rects.clear(); + } +} + /// Information about a raw-draw node's visible screen rect. pub(crate) struct RawDrawRect { pub draw_id: usize, @@ -29,8 +46,12 @@ pub(crate) struct RawDrawRect { /// /// Replaces the 7 individual `collect_*` functions that each traversed the /// tree independently, reducing per-frame traversals from 7x to 1x. -pub(crate) fn collect_all(node: &LayoutNode) -> FrameData { - let mut data = FrameData::default(); +/// +/// As of issue #155 the caller owns the `FrameData` allocation: we clear +/// (preserving capacity) and write into it directly, so steady-state frames +/// pay zero allocation churn for the 8 collection vectors. +pub(crate) fn collect_all(node: &LayoutNode, data: &mut FrameData) { + data.clear(); if node.is_scrollable { let viewport_h = node.size.1.saturating_sub(node.frame_vertical()); @@ -64,14 +85,12 @@ pub(crate) fn collect_all(node: &LayoutNode) -> FrameData { 0 }; for child in &node.children { - collect_all_inner(child, &mut data, child_offset, None, None, 1); + collect_all_inner(child, data, child_offset, None, None, 1); } for overlay in &node.overlays { - collect_all_inner(&overlay.node, &mut data, 0, None, None, 1); + collect_all_inner(&overlay.node, data, 0, None, None, 1); } - - data } fn collect_all_inner( diff --git a/src/layout/flexbox.rs b/src/layout/flexbox.rs index a0321dd..68665a2 100644 --- a/src/layout/flexbox.rs +++ b/src/layout/flexbox.rs @@ -146,10 +146,12 @@ fn compute_body(node: &mut LayoutNode, area: Rect, depth: usize) { if matches!(node.kind, NodeKind::Text) && node.wrap { let lines = node.ensure_wrapped_for_width(area.width); node.size = (area.width, lines); - } else { - node.cached_wrap_width = None; - node.cached_wrapped = None; - node.cached_wrapped_segments = None; + } else if let Some(td) = node.text_data.as_deref_mut() { + // Only text nodes carry the wrap cache. Non-text variants have + // `text_data = None` and have nothing to invalidate here. + td.cached_wrap_width = None; + td.cached_wrapped = None; + td.cached_wrapped_segments = None; } match node.kind { @@ -235,12 +237,34 @@ fn compute_body(node: &mut LayoutNode, area: Rect, depth: usize) { } for overlay in &mut node.overlays { - let width = overlay.node.min_width().min(area.width); - let height = overlay.node.min_height_for_width(width).min(area.height); - let x = area.x.saturating_add(area.width.saturating_sub(width) / 2); - let y = area - .y - .saturating_add(area.height.saturating_sub(height) / 2); + // Issue #200 / #201: When the user's overlay content uses `.grow(>=1)` + // (or sets explicit `.w(area_w).h(area_h)` via constraints producing a + // child whose min size already equals the area), the wrapper must + // take the full area so flexbox `justify` / `align` inside the inner + // container has space to push against. Previously the wrapper was + // always shrunk to content min-size and centered, so: + // - `overlay(|ui| ui.container().grow(1).align(End).justify(End)…)` + // never reached the bottom-right (no slack to push into). + // - `overlay(|ui| ui.container().grow(1).draw(…))` rendered nothing + // (raw_draw with no constraints had min-size 0×0, so the wrapper + // became 0×0 and `lib.rs` skipped the empty-rect callback). + // + // Heuristic: if any direct child requests grow, fill the full area. + // Otherwise preserve the historic shrink-and-center behavior so plain + // `overlay(|ui| ui.bordered(...).p(1).col(...))` still floats in the + // middle. + let any_grow = overlay.node.children.iter().any(|c| c.grow > 0); + let (x, y, width, height) = if any_grow { + (area.x, area.y, area.width, area.height) + } else { + let width = overlay.node.min_width().min(area.width); + let height = overlay.node.min_height_for_width(width).min(area.height); + let x = area.x.saturating_add(area.width.saturating_sub(width) / 2); + let y = area + .y + .saturating_add(area.height.saturating_sub(height) / 2); + (x, y, width, height) + }; compute_inner(&mut overlay.node, Rect::new(x, y, width, height), depth + 1); } } diff --git a/src/layout/render.rs b/src/layout/render.rs index 7ffe9a0..f844ea8 100644 --- a/src/layout/render.rs +++ b/src/layout/render.rs @@ -21,14 +21,76 @@ fn dim_entire_buffer(buf: &mut Buffer) { } } +/// Layer category used to tint F12 debug outlines. +/// +/// Inspired by Chrome DevTools layout overlay, React DevTools component +/// highlighter, and the Flutter Inspector — each layer family gets its own +/// hue so a glance at the screen tells you which container is part of the +/// base tree, an overlay, or a modal. Within each family the depth still +/// varies the brightness so nested containers remain distinguishable. +#[derive(Debug, Clone, Copy)] +enum LayerTint { + /// Base tree (root + children) — green family ("default, healthy"). + Base, + /// Floating overlay — red family ("attention"). Tooltips share this + /// tint because they ride the same `overlay()` plumbing as + /// [`Context::overlay`] (no separate variant tag yet). + Overlay, + /// Modal dialog — blue family ("deliberate, dimmed background"). + Modal, +} + +/// Per-layer widget breakdown for the debug status bar. +/// +/// Returned by [`count_leaf_widgets_layered`]. The legacy `total` accessor +/// preserves the v0.19.3 "N widgets" status line so existing snapshot tests +/// stay green; the per-layer fields surface in the new +/// "(N base, M overlay, K modal)" suffix. +#[derive(Debug, Clone, Copy, Default)] +struct LayerCounts { + base: u32, + overlay: u32, + modal: u32, +} + +impl LayerCounts { + fn total(self) -> u32 { + self.base + .saturating_add(self.overlay) + .saturating_add(self.modal) + } +} + pub(crate) fn render_debug_overlay( node: &LayoutNode, buf: &mut Buffer, frame_time_us: u64, fps: f32, + layer: crate::DebugLayer, ) { - for child in &node.children { - render_debug_overlay_inner(child, buf, 0, 0); + // Issue #201 Part A: previously this only walked `node.children`, so any + // active overlay/modal was invisible to the F12 outline pass even though + // the underlying renderer DID draw it. Walk overlays too unless the user + // explicitly opted into a narrower layer via [`Context::set_debug_layer`]. + let walk_base = !matches!(layer, crate::DebugLayer::TopMost) || node.overlays.is_empty(); + let walk_overlays = !matches!(layer, crate::DebugLayer::BaseOnly); + if walk_base { + for child in &node.children { + render_debug_overlay_inner(child, buf, 0, 0, LayerTint::Base); + } + } + if walk_overlays { + for overlay in &node.overlays { + // Distinguish modal from non-modal overlays — `OverlayLayer.modal` + // is the only tag the layout tree carries today (tooltips fall + // under the non-modal `Overlay` bucket). + let tint = if overlay.modal { + LayerTint::Modal + } else { + LayerTint::Overlay + }; + render_debug_overlay_inner(&overlay.node, buf, 0, 0, tint); + } } render_debug_status_bar(node, buf, frame_time_us, fps); } @@ -38,17 +100,41 @@ fn render_debug_status_bar(node: &LayoutNode, buf: &mut Buffer, frame_time_us: u return; } - let widgets: u32 = node.children.iter().map(count_leaf_widgets).sum(); + // Issue #201 Part C: include overlay widgets in the status-bar count so + // the displayed total matches what the renderer actually drew. The + // recursive `count_leaf_widgets` already walks overlays at every nested + // level — only the root sum was missing the overlay branch. + let counts = count_leaf_widgets_layered(node); + let widgets = counts.total(); let width = buf.area.width; let height = buf.area.height; let y = buf.area.bottom() - 1; let style = Style::new().fg(Color::Black).bg(Color::Yellow).bold(); + // Per-layer breakdown only renders the layers that actually have + // widgets, so a base-only scene keeps the original short status line. + let mut breakdown_parts: Vec = Vec::with_capacity(3); + if counts.base > 0 { + breakdown_parts.push(format!("{} base", counts.base)); + } + if counts.overlay > 0 { + breakdown_parts.push(format!("{} overlay", counts.overlay)); + } + if counts.modal > 0 { + breakdown_parts.push(format!("{} modal", counts.modal)); + } + let breakdown = if breakdown_parts.len() > 1 { + format!(" ({})", breakdown_parts.join(", ")) + } else { + String::new() + }; + let status = format!( - "[SLT Debug] {}x{} | {} widgets | {:.1}ms | {:.0}fps", + "[SLT Debug] {}x{} | {} widgets{} | {:.1}ms | {:.0}fps", width, height, widgets, + breakdown, frame_time_us as f64 / 1_000.0, fps.max(0.0) ); @@ -58,6 +144,32 @@ fn render_debug_status_bar(node: &LayoutNode, buf: &mut Buffer, frame_time_us: u buf.set_string(buf.area.x, y, &status, style); } +/// Count leaf widgets per layer category, walking nested overlays. +/// +/// The base count comes from `node.children`. Overlays are split by their +/// `modal` flag — tooltips ride the non-modal path (see [`LayerTint`]). +/// Nested overlays inside an overlay's subtree inherit the outer overlay's +/// bucket: the goal is "how many widgets did each top-level layer +/// contribute," matching what the user sees on screen. +fn count_leaf_widgets_layered(node: &LayoutNode) -> LayerCounts { + let base: u32 = node.children.iter().map(count_leaf_widgets).sum(); + let mut overlay: u32 = 0; + let mut modal: u32 = 0; + for layer in &node.overlays { + let n = count_leaf_widgets(&layer.node); + if layer.modal { + modal = modal.saturating_add(n); + } else { + overlay = overlay.saturating_add(n); + } + } + LayerCounts { + base, + overlay, + modal, + } +} + fn count_leaf_widgets(node: &LayoutNode) -> u32 { let mut total = if node.children.is_empty() { match node.kind { @@ -75,7 +187,13 @@ fn count_leaf_widgets(node: &LayoutNode) -> u32 { total } -fn render_debug_overlay_inner(node: &LayoutNode, buf: &mut Buffer, depth: u32, y_offset: u32) { +fn render_debug_overlay_inner( + node: &LayoutNode, + buf: &mut Buffer, + depth: u32, + y_offset: u32, + tint: LayerTint, +) { let child_offset = if node.is_scrollable { y_offset.saturating_add(node.scroll_offset) } else { @@ -85,7 +203,7 @@ fn render_debug_overlay_inner(node: &LayoutNode, buf: &mut Buffer, depth: u32, y if let NodeKind::Container(_) = node.kind { let sy = screen_y(node.pos.1, y_offset); if sy + node.size.1 as i64 > 0 { - let color = debug_color_for_depth(depth); + let color = debug_color_for_depth(tint, depth); let style = Style::new().fg(color); let clamped_y = sy.max(0) as u32; draw_debug_border(node.pos.0, clamped_y, node.size.0, node.size.1, buf, style); @@ -95,28 +213,43 @@ fn render_debug_overlay_inner(node: &LayoutNode, buf: &mut Buffer, depth: u32, y } } + // Nested overlays inherit the outer layer's tint — a modal that opens an + // inner non-modal overlay still reads as part of the modal stack to the + // human eye, which is what we want. if node.is_scrollable { if let Some(area) = visible_area(node, y_offset) { let inner = inner_area(node, area); buf.push_clip(inner); for child in &node.children { - render_debug_overlay_inner(child, buf, depth.saturating_add(1), child_offset); + render_debug_overlay_inner(child, buf, depth.saturating_add(1), child_offset, tint); } buf.pop_clip(); } } else { for child in &node.children { - render_debug_overlay_inner(child, buf, depth.saturating_add(1), child_offset); + render_debug_overlay_inner(child, buf, depth.saturating_add(1), child_offset, tint); } } } -fn debug_color_for_depth(depth: u32) -> Color { +/// Pick an outline color from the layer family + depth. +/// +/// Each [`LayerTint`] gets a distinct base hue so layers stay visually +/// separable; depth then pulls the color toward white to keep nested +/// containers distinguishable inside the same family. Two depth bands +/// (`<= 1` = base, `<= 3` = lighter, `> 3` = lightest) give enough +/// gradation for typical 5–15-deep TUI trees without overshooting into +/// pure white where the hue would be lost. +fn debug_color_for_depth(tint: LayerTint, depth: u32) -> Color { + let base = match tint { + LayerTint::Base => Color::Rgb(64, 200, 64), + LayerTint::Overlay => Color::Rgb(220, 80, 80), + LayerTint::Modal => Color::Rgb(80, 140, 220), + }; match depth { - 0 => Color::Cyan, - 1 => Color::Yellow, - 2 => Color::Magenta, - _ => Color::Red, + 0..=1 => base, + 2..=3 => base.lighten(0.25), + _ => base.lighten(0.5), } } @@ -209,10 +342,17 @@ fn render_inner( match node.kind { NodeKind::Text => { - if let Some(ref segs) = node.segments { + // For Text nodes the constructors guarantee `text_data = Some`. + // Read-only access through `text_data()` keeps us off the borrow + // checker's bad side and lets the segments / content branches + // share the same payload reference. + let Some(td) = node.text_data() else { + return; + }; + if let Some(ref segs) = td.segments { if node.wrap { let fallback; - let wrapped = if let Some(cached) = &node.cached_wrapped_segments { + let wrapped = if let Some(cached) = &td.cached_wrapped_segments { cached.as_slice() } else { fallback = wrap_segments(segs, node.size.0); @@ -247,14 +387,14 @@ fn render_inner( x += UnicodeWidthStr::width(text.as_str()) as u32; } } - } else if let Some(ref text) = node.content { + } else if let Some(ref text) = td.content { let mut style = node.style; if style.bg.is_none() { style.bg = parent_bg; } if node.wrap { let fallback; - let lines = if let Some(cached) = &node.cached_wrapped { + let lines = if let Some(cached) = &td.cached_wrapped { cached.as_slice() } else { fallback = wrap_lines(text, node.size.0); @@ -316,7 +456,7 @@ fn render_inner( 0 }; let draw_x = node.pos.0.saturating_add(x_offset); - if let Some(cursor_offset) = node.cursor_offset { + if let Some(cursor_offset) = td.cursor_offset { let cursor_x = text .chars() .take(cursor_offset) @@ -464,25 +604,33 @@ fn render_container_border( } } - let y = bottom_i as u32; - let bl = match (sides.bottom, sides.left) { - (true, true) => Some(chars.bl), - (true, false) => Some(chars.h), - (false, true) => Some(chars.v), - (false, false) => None, - }; - if let Some(ch) = bl { - buf.set_char(x, y, ch, style); - } + // Issue #162: skip the bottom corner writes entirely when `bottom_i` is + // already off-screen. `buf.set_char` silently drops out-of-bounds writes, + // so this is a perf-only guard — saves two redundant `set_char` calls per + // scrolled container border per frame. `viewport_bottom` is exclusive + // (matches `render_inner`'s convention). + let viewport_bottom = i64::from(buf.area.y).saturating_add(i64::from(buf.area.height)); + if bottom_i < viewport_bottom { + let y = bottom_i as u32; + let bl = match (sides.bottom, sides.left) { + (true, true) => Some(chars.bl), + (true, false) => Some(chars.h), + (false, true) => Some(chars.v), + (false, false) => None, + }; + if let Some(ch) = bl { + buf.set_char(x, y, ch, style); + } - let br = match (sides.bottom, sides.right) { - (true, true) => Some(chars.br), - (true, false) => Some(chars.h), - (false, true) => Some(chars.v), - (false, false) => None, - }; - if let Some(ch) = br { - buf.set_char(right, y, ch, style); + let br = match (sides.bottom, sides.right) { + (true, true) => Some(chars.br), + (true, false) => Some(chars.h), + (false, true) => Some(chars.v), + (false, false) => None, + }; + if let Some(ch) = br { + buf.set_char(right, y, ch, style); + } } if sides.top && top_i >= 0 { diff --git a/src/layout/tests.rs b/src/layout/tests.rs index dca4726..d9da75f 100644 --- a/src/layout/tests.rs +++ b/src/layout/tests.rs @@ -356,7 +356,8 @@ fn collect_focus_rects_from_markers() { let area = crate::rect::Rect::new(0, 0, 40, 10); compute(&mut tree, area); - let fd = collect_all(&tree); + let mut fd = FrameData::default(); + collect_all(&tree, &mut fd); assert_eq!(fd.focus_rects.len(), 2); assert_eq!(fd.focus_rects[0].0, 0); assert_eq!(fd.focus_rects[1].0, 1); @@ -406,7 +407,8 @@ fn focus_marker_tags_container() { let area = crate::rect::Rect::new(0, 0, 40, 10); compute(&mut tree, area); - let fd = collect_all(&tree); + let mut fd = FrameData::default(); + collect_all(&tree, &mut fd); assert_eq!(fd.focus_rects.len(), 1); assert_eq!(fd.focus_rects[0].0, 0); assert!(fd.focus_rects[0].1.width >= 8); @@ -426,13 +428,21 @@ fn wrapped_text_cache_reused_for_same_width() { ); let height_a = node.min_height_for_width(6); - let first_ptr = node.cached_wrapped.as_ref().map(Vec::as_ptr).unwrap(); + let first_ptr = node + .text_data() + .and_then(|t| t.cached_wrapped.as_ref()) + .map(Vec::as_ptr) + .unwrap(); let height_b = node.min_height_for_width(6); - let second_ptr = node.cached_wrapped.as_ref().map(Vec::as_ptr).unwrap(); + let second_ptr = node + .text_data() + .and_then(|t| t.cached_wrapped.as_ref()) + .map(Vec::as_ptr) + .unwrap(); assert_eq!(height_a, height_b); assert_eq!(first_ptr, second_ptr); - assert_eq!(node.cached_wrap_width, Some(6)); + assert_eq!(node.text_data().and_then(|t| t.cached_wrap_width), Some(6)); } #[test] @@ -465,7 +475,9 @@ fn collect_all_clips_raw_draw_to_scroll_viewport() { scroll.children.push(raw); root.children.push(scroll); - let rects: Vec<_> = collect_all(&root) + let mut fd = FrameData::default(); + collect_all(&root, &mut fd); + let rects: Vec<_> = fd .raw_draw_rects .into_iter() .map(|r| (r.draw_id, r.rect, r.top_clip_rows, r.original_height)) @@ -532,7 +544,8 @@ fn group_names_share_arc_across_focus_descendants() { let mut tree = build_tree(commands); let area = crate::rect::Rect::new(0, 0, 80, (N_GROUPS * FOCUSES_PER_GROUP) as u32 + 4); compute(&mut tree, area); - let fd = collect_all(&tree); + let mut fd = FrameData::default(); + collect_all(&tree, &mut fd); // All N_GROUPS group rects present with the expected names. assert_eq!( @@ -859,7 +872,8 @@ fn collect_all_keeps_scroll_invariant_with_nested_scrollables() { root.children.push(outer); root.children.push(sibling); - let fd = collect_all(&root); + let mut fd = FrameData::default(); + collect_all(&root, &mut fd); assert_eq!( fd.scroll_infos.len(), @@ -902,8 +916,9 @@ fn raw_draw_constructor_matches_inline_literal_shape() { assert_eq!(node.focus_id, Some(11)); assert_eq!(node.interaction_id, Some(13)); // Defaults — none of these should be populated by the constructor. - assert!(node.content.is_none()); - assert!(node.cursor_offset.is_none()); + // Non-text nodes have no `text_data`; the text-only fields are + // unreachable from this variant (issue #153 split them off). + assert!(node.text_data().is_none()); assert_eq!(node.align, Align::Start); assert!(node.align_self.is_none()); assert_eq!(node.justify, Justify::Start); @@ -917,10 +932,8 @@ fn raw_draw_constructor_matches_inline_literal_shape() { assert!(!node.is_scrollable); assert_eq!(node.scroll_offset, 0); assert_eq!(node.content_height, 0); - assert!(node.cached_wrap_width.is_none()); - assert!(node.cached_wrapped.is_none()); - assert!(node.segments.is_none()); - assert!(node.cached_wrapped_segments.is_none()); + // The wrap caches and segments are inside `text_data`, which is + // `None` for `RawDraw` nodes. assert!(node.link_url.is_none()); assert!(node.group_name.is_none()); assert!(node.overlays.is_empty()); @@ -970,7 +983,8 @@ fn collect_all_panics_at_depth_guard() { } } populate_sizes(&mut node); - let _ = collect_all(&node); + let mut fd = FrameData::default(); + collect_all(&node, &mut fd); } #[test] @@ -992,3 +1006,254 @@ fn render_panics_at_depth_guard() { let mut buf = crate::buffer::Buffer::empty(crate::rect::Rect::new(0, 0, 80, 24)); super::render::render(&node, &mut buf); } + +#[test] +fn collect_all_reuses_buffer_without_leaking_prior_frame_data() { + // Regression for issue #155: `collect_all(&tree, &mut fd)` must call + // `fd.clear()` before populating, so a recycled `FrameData` does not + // carry data from a previous frame's tree. The capacity is reused; the + // contents are not. + use crate::style::{Constraints, Margin}; + + // Frame A: tree with two focusables. + let mut tree_a = LayoutNode::container(Direction::Column, default_container_config()); + let mut focus_a0 = LayoutNode::container(Direction::Column, default_container_config()); + focus_a0.focus_id = Some(0); + focus_a0.pos = (0, 0); + focus_a0.size = (10, 1); + tree_a.children.push(focus_a0); + let mut focus_a1 = LayoutNode::container(Direction::Column, default_container_config()); + focus_a1.focus_id = Some(1); + focus_a1.pos = (0, 1); + focus_a1.size = (10, 1); + tree_a.children.push(focus_a1); + + let mut fd = FrameData::default(); + collect_all(&tree_a, &mut fd); + assert_eq!(fd.focus_rects.len(), 2, "frame A: two focus rects"); + + // Frame B: a different tree with a single raw_draw (no focuses, no + // groups). After collect_all, the recycled `fd` must reflect *only* + // tree B — not a mix of A and B. + let mut tree_b = LayoutNode::container(Direction::Column, default_container_config()); + let mut raw = + LayoutNode::raw_draw(42, Constraints::default(), 0, Margin::default(), None, None); + raw.pos = (0, 0); + raw.size = (4, 2); + tree_b.children.push(raw); + + collect_all(&tree_b, &mut fd); + assert_eq!( + fd.focus_rects.len(), + 0, + "frame B: focus_rects must be cleared before refill" + ); + assert_eq!( + fd.raw_draw_rects.len(), + 1, + "frame B: one raw_draw rect must be present" + ); + assert_eq!(fd.raw_draw_rects[0].draw_id, 42); +} + +#[test] +fn f12_debug_overlay_outlines_overlay_layer() { + // Regression for issue #201 Part A: `render_debug_overlay` previously + // walked only `node.children`, leaving any active overlay/modal invisible + // to the F12 outline pass. Build a root with one base child + one + // overlay child, and verify the overlay's bounds receive border chars. + + let mut root = LayoutNode::container(Direction::Column, default_container_config()); + + // Base layer: a container at (0,0) sized 40×5. + let mut base = LayoutNode::container(Direction::Column, default_container_config()); + base.pos = (0, 0); + base.size = (40, 5); + root.children.push(base); + + // Overlay layer: a container at (10,10) sized 20×4. + let mut overlay_node = LayoutNode::container(Direction::Column, default_container_config()); + overlay_node.pos = (10, 10); + overlay_node.size = (20, 4); + root.overlays.push(super::tree::OverlayLayer { + node: overlay_node, + modal: false, + }); + + let mut buf = crate::buffer::Buffer::empty(crate::rect::Rect::new(0, 0, 40, 20)); + super::render::render_debug_overlay(&root, &mut buf, 0, 60.0, crate::DebugLayer::All); + + // The overlay container's top-RIGHT corner should now carry the outline + // corner char ('┐'). The top-left position is overwritten by the depth + // label '0', so we sample the right corner instead. Pre-fix the entire + // overlay rect was untouched. + let cell_top_right = buf.get(10 + 20 - 1, 10); + assert_eq!( + cell_top_right.symbol, "┐", + "F12 overlay outline must hit overlay's top-right corner; got {:?}", + cell_top_right.symbol + ); + // Bottom-right corner of overlay. + let cell_bottom_right = buf.get(10 + 20 - 1, 10 + 4 - 1); + assert_eq!( + cell_bottom_right.symbol, "┘", + "F12 overlay outline must hit overlay's bottom-right corner; got {:?}", + cell_bottom_right.symbol + ); +} + +#[test] +fn count_leaf_widgets_matches_outline_count_with_overlays() { + // Regression for issue #201 Part C: the status-bar widget count must + // include overlay nodes so the displayed total reflects what the renderer + // actually drew. + let mut root = LayoutNode::container(Direction::Column, default_container_config()); + + // 2 base widgets. + root.children.push(LayoutNode::text( + "a".to_string(), + Style::new(), + 0, + Align::Start, + (None, false, false), + Margin::default(), + Constraints::default(), + )); + root.children.push(LayoutNode::text( + "b".to_string(), + Style::new(), + 0, + Align::Start, + (None, false, false), + Margin::default(), + Constraints::default(), + )); + + // 1 overlay widget. + let mut overlay_root = LayoutNode::container(Direction::Column, default_container_config()); + overlay_root.children.push(LayoutNode::text( + "overlay".to_string(), + Style::new(), + 0, + Align::Start, + (None, false, false), + Margin::default(), + Constraints::default(), + )); + root.overlays.push(super::tree::OverlayLayer { + node: overlay_root, + modal: false, + }); + + // Render the debug status bar at the bottom. The widget count is the + // first integer following "| ". + let mut buf = crate::buffer::Buffer::empty(crate::rect::Rect::new(0, 0, 80, 5)); + super::render::render_debug_overlay(&root, &mut buf, 0, 60.0, crate::DebugLayer::All); + + let mut bottom = String::new(); + for x in 0..80 { + bottom.push_str(&buf.get(x, 4).symbol); + } + // Expected: 2 base widgets + 1 overlay widget = 3 total. + assert!( + bottom.contains("3 widgets"), + "status bar widget count must include overlay nodes; got {bottom:?}" + ); + // Per-layer breakdown is appended in parens whenever more than one + // layer family is non-empty (matches the doc update in DEBUGGING.md). + assert!( + bottom.contains("2 base") && bottom.contains("1 overlay"), + "status bar must include per-layer breakdown when multiple layers \ + are populated; got {bottom:?}" + ); +} + +#[test] +fn f12_debug_overlay_distinguishes_layers_by_color() { + // Regression for the #201 follow-up: each layer family (base / overlay / + // modal) must paint its outlines in a distinct hue so the F12 view stays + // legible when several layers are stacked. We sample a known cell on + // each layer's border ring and check that the foreground colors differ + // meaningfully (different `Color::Rgb` channels — testing equality is + // sufficient because `debug_color_for_depth` returns concrete RGBs). + let mut root = LayoutNode::container(Direction::Column, default_container_config()); + + // Base container: (2, 2) sized 10x4. Bottom-right corner at (11, 5). + let mut base = LayoutNode::container(Direction::Column, default_container_config()); + base.pos = (2, 2); + base.size = (10, 4); + root.children.push(base); + + // Non-modal overlay: (15, 2) sized 10x4. Bottom-right corner at (24, 5). + let mut overlay_node = LayoutNode::container(Direction::Column, default_container_config()); + overlay_node.pos = (15, 2); + overlay_node.size = (10, 4); + root.overlays.push(super::tree::OverlayLayer { + node: overlay_node, + modal: false, + }); + + // Modal overlay: (28, 2) sized 10x4. Bottom-right corner at (37, 5). + let mut modal_node = LayoutNode::container(Direction::Column, default_container_config()); + modal_node.pos = (28, 2); + modal_node.size = (10, 4); + root.overlays.push(super::tree::OverlayLayer { + node: modal_node, + modal: true, + }); + + let mut buf = crate::buffer::Buffer::empty(crate::rect::Rect::new(0, 0, 60, 8)); + super::render::render_debug_overlay(&root, &mut buf, 0, 60.0, crate::DebugLayer::All); + + // Sample the bottom-right corner '┘' of each container — that cell is + // never overwritten by the depth label (which sits at the top-left). + let base_fg = buf.get(11, 5).style.fg; + let overlay_fg = buf.get(24, 5).style.fg; + let modal_fg = buf.get(37, 5).style.fg; + + assert!( + base_fg.is_some() && overlay_fg.is_some() && modal_fg.is_some(), + "all three layer outlines must carry a foreground color; \ + got base={base_fg:?} overlay={overlay_fg:?} modal={modal_fg:?}" + ); + assert_ne!( + base_fg, overlay_fg, + "base and overlay outlines must be different colors" + ); + assert_ne!( + base_fg, modal_fg, + "base and modal outlines must be different colors" + ); + assert_ne!( + overlay_fg, modal_fg, + "overlay and modal outlines must be different colors" + ); + + // Sanity-check the hue families: green-dominant for Base, red-dominant + // for Overlay, blue-dominant for Modal. Catches future palette drift + // that keeps colors distinct but loses the convention. + if let Some(crate::style::Color::Rgb(r, g, b)) = base_fg { + assert!( + g > r && g > b, + "Base outline should be green-dominant; got rgb({r},{g},{b})" + ); + } else { + panic!("Base outline must be Color::Rgb; got {base_fg:?}"); + } + if let Some(crate::style::Color::Rgb(r, g, b)) = overlay_fg { + assert!( + r > g && r > b, + "Overlay outline should be red-dominant; got rgb({r},{g},{b})" + ); + } else { + panic!("Overlay outline must be Color::Rgb; got {overlay_fg:?}"); + } + if let Some(crate::style::Color::Rgb(r, g, b)) = modal_fg { + assert!( + b > r && b > g, + "Modal outline should be blue-dominant; got rgb({r},{g},{b})" + ); + } else { + panic!("Modal outline must be Color::Rgb; got {modal_fg:?}"); + } +} diff --git a/src/layout/tree.rs b/src/layout/tree.rs index 8679ac9..723108a 100644 --- a/src/layout/tree.rs +++ b/src/layout/tree.rs @@ -1,5 +1,19 @@ use super::*; +/// Regression guard for the size of [`LayoutNode`] (issue #153). +/// +/// A frame may build hundreds of layout nodes, and `LayoutNode` is moved / +/// recursed over throughout the layout pipeline. The text-only fields +/// (`content`, `cursor_offset`, `cached_*`, `segments`) are split into +/// [`TextNodeData`] behind a `Box`, so non-text variants (`Spacer`, +/// `Container`, `RawDraw`) — which are the vast majority of nodes — pay +/// only the 8-byte `Option>` rather than ~120 bytes of +/// always-`None` fields inline. Pre-split the struct measured 432 bytes; +/// post-split it should be substantially smaller. If a future field +/// addition pushes this past the bound, either box the new field or audit +/// whether the addition needs to live on `LayoutNode` at all. +const _ASSERT_LAYOUT_NODE_SIZE: () = assert!(std::mem::size_of::() <= 320); + #[derive(Debug, Clone)] pub(crate) struct OverlayLayer { pub(crate) node: LayoutNode, @@ -14,11 +28,32 @@ pub(crate) enum NodeKind { RawDraw(usize), } +/// Text-only data for [`NodeKind::Text`] nodes (issue #153). +/// +/// All six fields are unused by `Spacer`, `Container`, and `RawDraw` +/// nodes, so we hide them behind a `Box` on `LayoutNode` to keep the +/// hot non-text paths small. Boxing is cheap because text nodes already +/// own at least one heap allocation (`content` or `segments`), so the +/// extra indirection costs one more allocation per text node in exchange +/// for ~120 bytes saved on every non-text node — a clear win when most +/// nodes are containers. +#[derive(Debug, Clone, Default)] +pub(crate) struct TextNodeData { + pub(crate) content: Option, + pub(crate) cursor_offset: Option, + pub(crate) cached_wrap_width: Option, + pub(crate) cached_wrapped: Option>, + pub(crate) segments: Option>, + pub(crate) cached_wrapped_segments: Option>>, +} + #[derive(Debug, Clone)] pub(crate) struct LayoutNode { pub(crate) kind: NodeKind, - pub(crate) content: Option, - pub(crate) cursor_offset: Option, + /// Text-only payload. `Some` only for `NodeKind::Text` nodes; always + /// `None` for `Spacer`, `Container`, and `RawDraw`. See + /// [`TextNodeData`] for the rationale behind boxing. + pub(crate) text_data: Option>, pub(crate) style: Style, pub(crate) grow: u16, pub(crate) align: Align, @@ -41,10 +76,6 @@ pub(crate) struct LayoutNode { pub(crate) is_scrollable: bool, pub(crate) scroll_offset: u32, pub(crate) content_height: u32, - pub(crate) cached_wrap_width: Option, - pub(crate) cached_wrapped: Option>, - pub(crate) segments: Option>, - pub(crate) cached_wrapped_segments: Option>>, pub(crate) focus_id: Option, pub(crate) interaction_id: Option, pub(crate) link_url: Option, @@ -75,6 +106,27 @@ pub(crate) struct ContainerConfig { } impl LayoutNode { + /// Get a shared reference to the text-only payload. + /// + /// Returns `None` for non-text variants. Use this everywhere the + /// caller only reads text fields (e.g. `render_inner`). + #[inline] + pub(crate) fn text_data(&self) -> Option<&TextNodeData> { + self.text_data.as_deref() + } + + /// Get a mutable reference to the text-only payload. + /// + /// Panics if the node is not a `NodeKind::Text` node — callers are + /// expected to check `kind` first or operate on a node they know to + /// be text-shaped (e.g. inside `ensure_wrapped_for_width`). + #[inline] + pub(crate) fn text_data_mut(&mut self) -> &mut TextNodeData { + self.text_data + .as_deref_mut() + .expect("text_data_mut called on non-text node") + } + pub(crate) fn text( content: String, style: Style, @@ -88,8 +140,11 @@ impl LayoutNode { let width = UnicodeWidthStr::width(content.as_str()) as u32; Self { kind: NodeKind::Text, - content: Some(content), - cursor_offset, + text_data: Some(Box::new(TextNodeData { + content: Some(content), + cursor_offset, + ..Default::default() + })), style, grow, align, @@ -112,10 +167,6 @@ impl LayoutNode { is_scrollable: false, scroll_offset: 0, content_height: 0, - cached_wrap_width: None, - cached_wrapped: None, - segments: None, - cached_wrapped_segments: None, focus_id: None, interaction_id: None, link_url: None, @@ -137,8 +188,10 @@ impl LayoutNode { .sum(); Self { kind: NodeKind::Text, - content: None, - cursor_offset: None, + text_data: Some(Box::new(TextNodeData { + segments: Some(segments), + ..Default::default() + })), style: Style::new(), grow: 0, align, @@ -161,10 +214,6 @@ impl LayoutNode { is_scrollable: false, scroll_offset: 0, content_height: 0, - cached_wrap_width: None, - cached_wrapped: None, - segments: Some(segments), - cached_wrapped_segments: None, focus_id: None, interaction_id: None, link_url: None, @@ -176,8 +225,7 @@ impl LayoutNode { pub(crate) fn container(direction: Direction, config: ContainerConfig) -> Self { Self { kind: NodeKind::Container(direction), - content: None, - cursor_offset: None, + text_data: None, style: Style::new(), grow: config.grow, align: config.align, @@ -200,10 +248,6 @@ impl LayoutNode { is_scrollable: false, scroll_offset: 0, content_height: 0, - cached_wrap_width: None, - cached_wrapped: None, - segments: None, - cached_wrapped_segments: None, focus_id: None, interaction_id: None, link_url: None, @@ -230,8 +274,7 @@ impl LayoutNode { ) -> Self { Self { kind: NodeKind::RawDraw(draw_id), - content: None, - cursor_offset: None, + text_data: None, style: Style::new(), grow, align: Align::Start, @@ -257,10 +300,6 @@ impl LayoutNode { is_scrollable: false, scroll_offset: 0, content_height: 0, - cached_wrap_width: None, - cached_wrapped: None, - segments: None, - cached_wrapped_segments: None, focus_id, interaction_id, link_url: None, @@ -272,8 +311,7 @@ impl LayoutNode { pub(crate) fn spacer(grow: u16) -> Self { Self { kind: NodeKind::Spacer, - content: None, - cursor_offset: None, + text_data: None, style: Style::new(), grow, align: Align::Start, @@ -296,10 +334,6 @@ impl LayoutNode { is_scrollable: false, scroll_offset: 0, content_height: 0, - cached_wrap_width: None, - cached_wrapped: None, - segments: None, - cached_wrapped_segments: None, focus_id: None, interaction_id: None, link_url: None, @@ -415,29 +449,34 @@ impl LayoutNode { } pub(crate) fn ensure_wrapped_for_width(&mut self, available_width: u32) -> u32 { - if self.cached_wrap_width == Some(available_width) { - if let Some(ref segs) = self.cached_wrapped_segments { + // `ensure_wrapped_for_width` is only called for `NodeKind::Text` nodes + // (gated by `compute_body` and `min_height_for_width`), so `text_data` + // is guaranteed to be `Some`. Unwrap once at the top to avoid threading + // mutable borrows across multiple field reads/writes below. + let td = self.text_data_mut(); + if td.cached_wrap_width == Some(available_width) { + if let Some(ref segs) = td.cached_wrapped_segments { return segs.len().max(1) as u32; } - if let Some(ref lines) = self.cached_wrapped { + if let Some(ref lines) = td.cached_wrapped { return lines.len().max(1) as u32; } } - if let Some(ref segs) = self.segments { + if let Some(ref segs) = td.segments { let wrapped = wrap_segments(segs, available_width); let line_count = wrapped.len().max(1) as u32; - self.cached_wrap_width = Some(available_width); - self.cached_wrapped_segments = Some(wrapped); - self.cached_wrapped = None; + td.cached_wrap_width = Some(available_width); + td.cached_wrapped_segments = Some(wrapped); + td.cached_wrapped = None; line_count } else { - let text = self.content.as_deref().unwrap_or(""); + let text = td.content.as_deref().unwrap_or(""); let lines = wrap_lines(text, available_width); let line_count = lines.len().max(1) as u32; - self.cached_wrap_width = Some(available_width); - self.cached_wrapped = Some(lines); - self.cached_wrapped_segments = None; + td.cached_wrap_width = Some(available_width); + td.cached_wrapped = Some(lines); + td.cached_wrapped_segments = None; line_count } } @@ -723,7 +762,11 @@ pub(crate) fn wrap_segments( } } - let mut line_segs: Vec<(String, Style)> = Vec::new(); + // Capacity hint: most lines hold a small handful of style runs, so + // pre-size the scratch buffer to avoid the early Vec growth churn. + // The 16-cap clamp keeps over-allocation bounded when the input has + // a long segment list that wraps into many short lines (issue #157). + let mut line_segs: Vec<(String, Style)> = Vec::with_capacity(segments.len().min(16)); let mut line_width: u32 = 0; // Snapshot of the most recent space boundary on the current line: // (line_segs.len(), last seg's byte-length, line_width, space_seg_idx, space_byte_off). diff --git a/src/lib.rs b/src/lib.rs index 1422e75..35a7d34 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -127,7 +127,7 @@ pub use cell::Cell; // `GraphType`, `Axis`) live under `slt::chart::*`. pub use chart::{Candle, ChartBuilder, ChartConfig, Dataset, LegendPosition, Marker}; pub use context::{ - Bar, BarChartConfig, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context, + Anchor, Bar, BarChartConfig, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context, Response, State, TreemapItem, Widget, }; pub use event::{ @@ -564,9 +564,28 @@ pub(crate) struct DiagnosticsState { pub tick: u64, pub notification_queue: Vec<(String, ToastLevel, u64)>, pub debug_mode: bool, + pub debug_layer: DebugLayer, pub fps_ema: f32, } +/// Which layers the F12 debug overlay should outline (issue #201). +/// +/// `All` (the default) outlines both the base layer and any active +/// overlays/modals — matching the user's expectation for "show everything +/// the renderer is producing this frame." `TopMost` only outlines the +/// topmost overlay (or the base if no overlay is active), and `BaseOnly` +/// keeps the legacy pre-fix behavior of skipping overlays entirely. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DebugLayer { + /// Outline base + all overlays (default — matches reporter expectation). + #[default] + All, + /// Outline only the topmost overlay, or the base if none. + TopMost, + /// Outline only the base layer (legacy behavior). + BaseOnly, +} + #[derive(Default)] pub(crate) struct FrameState { pub hook_states: Vec>, @@ -575,6 +594,13 @@ pub(crate) struct FrameState { pub focus: FocusState, pub layout_feedback: LayoutFeedbackState, pub diagnostics: DiagnosticsState, + /// Recycled command Vec (issue #150). `Context::new` swaps this into the + /// new context (capacity preserved, len reset to 0). After `build_tree` + /// drains the commands, the now-empty Vec is reclaimed back here. + pub commands_buf: Vec, + /// Recycled per-frame layout collection scratch (issue #155). Same + /// pattern as `commands_buf`: clear before use, restore after. + pub frame_data: crate::layout::FrameData, #[cfg(feature = "crossterm")] pub selection: terminal::SelectionState, } @@ -1099,6 +1125,7 @@ pub(crate) fn run_frame_kernel( state.named_states = ctx.named_states; state.screen_hook_map = ctx.screen_hook_map; state.diagnostics.notification_queue = ctx.rollback.notification_queue; + state.diagnostics.debug_layer = ctx.debug_layer; #[cfg(feature = "crossterm")] let clipboard_text = ctx.clipboard_text.take(); #[cfg(feature = "crossterm")] @@ -1150,24 +1177,38 @@ pub(crate) fn run_frame_kernel( state.focus.focus_index = ctx.focus_index; state.focus.prev_focus_count = ctx.rollback.focus_count; - let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands)); + // Issue #150: `state.commands_buf` is swapped into `ctx.commands` on + // entry (see `Context::new`), so the per-frame `Vec::new()` allocation + // for the command list is amortized to one allocation across the + // session. Build_tree consumes the Vec by-value here — the empty placeholder + // returns to `state.commands_buf` via the `Default` shell from `mem::take`, + // and full capacity reclamation will land when build_tree's signature is + // refactored to drain (tracked separately; tree.rs is owned by another agent). + let commands = std::mem::take(&mut ctx.commands); + let mut tree = layout::build_tree(commands); let area = crate::rect::Rect::new(0, 0, w, h); layout::compute(&mut tree, area); - let fd = layout::collect_all(&tree); + + // Issue #155: reuse `state.frame_data` across frames. `collect_all` calls + // `fd.clear()` first so the Vecs reset to len=0 with capacity preserved + // from the prior frame, then refills them. + let mut fd = std::mem::take(&mut state.frame_data); + layout::collect_all(&tree, &mut fd); debug_assert_eq!( fd.scroll_infos.len(), fd.scroll_rects.len(), "scroll feedback vectors must stay aligned" ); - state.layout_feedback.prev_scroll_infos = fd.scroll_infos; - state.layout_feedback.prev_scroll_rects = fd.scroll_rects; - state.layout_feedback.prev_hit_map = fd.hit_areas; - state.layout_feedback.prev_group_rects = fd.group_rects; - state.layout_feedback.prev_content_map = fd.content_areas; - state.layout_feedback.prev_focus_rects = fd.focus_rects; - state.layout_feedback.prev_focus_groups = fd.focus_groups; + let raw_rects = std::mem::take(&mut fd.raw_draw_rects); + state.layout_feedback.prev_scroll_infos = std::mem::take(&mut fd.scroll_infos); + state.layout_feedback.prev_scroll_rects = std::mem::take(&mut fd.scroll_rects); + state.layout_feedback.prev_hit_map = std::mem::take(&mut fd.hit_areas); + state.layout_feedback.prev_group_rects = std::mem::take(&mut fd.group_rects); + state.layout_feedback.prev_content_map = std::mem::take(&mut fd.content_areas); + state.layout_feedback.prev_focus_rects = std::mem::take(&mut fd.focus_rects); + state.layout_feedback.prev_focus_groups = std::mem::take(&mut fd.focus_groups); + state.frame_data = fd; layout::render(&tree, buffer); - let raw_rects = fd.raw_draw_rects; // RAII guard ensuring the kitty clip frame is popped even if a raw-draw // callback panics — prevents stale scroll-clip state leaking into the // next region or subsequent frames. @@ -1209,6 +1250,8 @@ pub(crate) fn run_frame_kernel( state.named_states = ctx.named_states; state.screen_hook_map = ctx.screen_hook_map; state.diagnostics.notification_queue = ctx.rollback.notification_queue; + // Issue #201: persist any in-frame `set_debug_layer` change. + state.diagnostics.debug_layer = ctx.debug_layer; let frame_time = frame_start.elapsed(); let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64; @@ -1224,7 +1267,13 @@ pub(crate) fn run_frame_kernel( (state.diagnostics.fps_ema * 0.9) + (inst_fps * 0.1) }; if state.diagnostics.debug_mode { - layout::render_debug_overlay(&tree, buffer, frame_time_us, state.diagnostics.fps_ema); + layout::render_debug_overlay( + &tree, + buffer, + frame_time_us, + state.diagnostics.fps_ema, + state.diagnostics.debug_layer, + ); } FrameKernelResult { diff --git a/tests/render_check.rs b/tests/render_check.rs index 7ea523d..90bacc4 100644 --- a/tests/render_check.rs +++ b/tests/render_check.rs @@ -114,3 +114,171 @@ fn render_overlay() { println!("Overlay output:\n{}", output); tb.assert_contains("Overlay text"); } + +/// Regression for issue #200 Part 2: `align(End)`/`justify(End)` inside an +/// overlay must reach the bottom-right corner. Pre-fix the overlay wrapper +/// shrunk to content min-size and centered, so `.grow(1)` had no slack and +/// the inner `align/justify End` had nothing to push against. +#[test] +fn overlay_align_end_justify_end_renders_at_corner() { + let mut tb = TestBackend::new(40, 10); + tb.render(|ui| { + ui.text("base"); + ui.overlay(|ui| { + ui.container() + .grow(1) + .align(Align::End) + .justify(Justify::End) + .col(|ui| { + ui.text("HELLO"); + }); + }); + }); + let output = tb.to_string_trimmed(); + println!("AlignEnd/JustifyEnd overlay output:\n{}", output); + let bottom = tb.line(9); + assert!( + bottom.ends_with("HELLO"), + "expected bottom row to end with 'HELLO', got {bottom:?}" + ); +} + +/// Regression for issue #200 Part 3: a `container.grow(1).draw(|buf, rect|)` +/// raw-draw inside an overlay must actually render. Pre-fix the overlay +/// wrapper shrunk to content min-size — for a constraint-less raw-draw that +/// is 0×0, so the run loop skipped the empty-rect callback entirely. +#[test] +fn overlay_raw_draw_writes_to_buffer() { + let mut tb = TestBackend::new(40, 10); + tb.render(|ui| { + ui.text("base"); + ui.overlay(|ui| { + ui.container().grow(1).draw(move |buf, rect| { + buf.set_string(rect.x, rect.y, "HELLO", Style::new().fg(Color::Red)); + }); + }); + }); + let output = tb.to_string_trimmed(); + println!("Overlay raw-draw output:\n{}", output); + tb.assert_contains("HELLO"); +} + +/// Regression for `overlay_at_offset(Anchor::*, dx, dy, ...)` — positive +/// `dx`/`dy` must inset the rendered content toward the viewport center, +/// matching the CSS `inset` shorthand convention. +/// +/// On a 40x10 buffer with `Anchor::BottomRight` and `(dx=2, dy=1)`, the +/// glyph "X" must land at column `40 - 1 - 2 = 37` and row `10 - 1 - 1 = 8`. +#[test] +fn overlay_at_offset_insets_from_corner() { + use slt::Anchor; + + let mut tb = TestBackend::new(40, 10); + tb.render(|ui| { + ui.overlay_at_offset(Anchor::BottomRight, 2, 1, |ui| { + ui.text("X"); + }); + }); + let output = tb.to_string_trimmed(); + println!("overlay_at_offset BottomRight (2,1):\n{}", output); + + // Row 8 (the inset row) should end with "X" at column 37 — i.e. the + // trimmed line is 38 chars long (cols 0..=37) and the last char is 'X'. + let row8 = tb.line(8); + assert_eq!( + row8.len(), + 38, + "row 8 should end at col 37 (40 - 1 - dx 2), got len {} ({:?})", + row8.len(), + row8 + ); + assert!( + row8.ends_with('X'), + "Anchor::BottomRight + (2,1) must place 'X' at col 37, got {row8:?}" + ); + + // Row 9 (the bottom edge) and row 10 (oob) must NOT contain 'X' — the + // inset pushed the glyph upward and leftward. + assert!( + !tb.line(9).contains('X'), + "row 9 must be empty after inset, got {:?}", + tb.line(9) + ); + + // Row 7 must be empty too — the glyph is on row 8 only. + assert!( + !tb.line(7).contains('X'), + "row 7 must be empty (glyph is on row 8), got {:?}", + tb.line(7) + ); +} + +/// Top-left mirror of `overlay_at_offset_insets_from_corner` — verifies the +/// sign convention is consistent across opposite anchors. +#[test] +fn overlay_at_offset_insets_from_top_left() { + use slt::Anchor; + + let mut tb = TestBackend::new(40, 10); + tb.render(|ui| { + ui.overlay_at_offset(Anchor::TopLeft, 2, 1, |ui| { + ui.text("X"); + }); + }); + let output = tb.to_string_trimmed(); + println!("overlay_at_offset TopLeft (2,1):\n{}", output); + + // Row 1 (top + dy 1) should start with two spaces then 'X'. + let row1 = tb.line(1); + assert!( + row1.starts_with(" X"), + "Anchor::TopLeft + (2,1) must place 'X' at col 2 row 1, got {row1:?}" + ); + assert!( + !tb.line(0).contains('X'), + "row 0 must be empty after dy=1 inset, got {:?}", + tb.line(0) + ); +} + +/// Regression for issue #200 Part 1: `overlay_at(Anchor::*)` must place +/// content at the requested compass position by composing align+justify +/// inside the wrapper. Verified for two corners + center. +#[test] +fn overlay_at_anchor_positions() { + use slt::Anchor; + + let mut tb = TestBackend::new(40, 10); + tb.render(|ui| { + ui.overlay_at(Anchor::TopLeft, |ui| { + ui.text("TL"); + }); + ui.overlay_at(Anchor::BottomRight, |ui| { + ui.text("BR"); + }); + ui.overlay_at(Anchor::Center, |ui| { + ui.text("CC"); + }); + }); + let output = tb.to_string_trimmed(); + println!("overlay_at output:\n{}", output); + // Top-left: row 0 starts with "TL". + let top = tb.line(0); + assert!( + top.starts_with("TL"), + "Anchor::TopLeft must place 'TL' at the start of row 0, got {top:?}" + ); + // Bottom-right: row 9 ends with "BR". + let bottom = tb.line(9); + assert!( + bottom.ends_with("BR"), + "Anchor::BottomRight must place 'BR' at the end of row 9, got {bottom:?}" + ); + // Center: 'CC' should be near the middle of the buffer (rows 4 or 5). + let center_row_a = tb.line(4); + let center_row_b = tb.line(5); + assert!( + center_row_a.contains("CC") || center_row_b.contains("CC"), + "Anchor::Center must place 'CC' near the buffer center (row 4 or 5), got rows {center_row_a:?} / {center_row_b:?}" + ); +} diff --git a/tests/snapshots/visual__demo.snap b/tests/snapshots/visual__demo.snap new file mode 100644 index 0000000..cbcef28 --- /dev/null +++ b/tests/snapshots/visual__demo.snap @@ -0,0 +1,27 @@ +--- +source: tests/visual_snapshots.rs +--- +╭──────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ SuperLightTUI widget showcase Dark │ +│ All widgets follow active theme tokens. │ +│ ──────────────────────────────────────────────────────────────────────────── │ +│ [ Core Widgets ] [ Data Viz ] [ Layout ] [ Forms ] [ IME/CJK ] [ Feedback ] │ +│ [ v0.9.4 ] [ v0.11.0 ] [ v0.12.10 ] [ v0.13 ] [ v0.13.2 ] [ v0.14.0 ] [ v0.1 │ +│ ──────────────────────────────────────────────────────────────────────────── │ +│ CORE WIDGETS │ +│ ╭──────────────────────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│ │ Tabs │ │ +│ │ Use Left/Right when focused. │ │ +│ │ [ Primary ] [ Secondary ] [ Accent ] │ │ +│ │ Selected:Primary │ │ +│ │ │ │ +│ ╰──────────────────────────────────────────────────────────────────────────╯ │ +│ ╭───────────────────────╮╭───────────────────────╮╭────────────────────────╮ │ +│ │ ││ ││ │ │ +│ │ Input ││ Controls ││ Buttons ▼ │ +│ ──────────────────────────────────────────────────────────────────────────── │ +│ ^Q/Esc quit · ^T theme · ^M modal · ^O overlay · ^H/^L progress │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/tests/snapshots/visual__demo_cjk.snap b/tests/snapshots/visual__demo_cjk.snap new file mode 100644 index 0000000..e7e4e58 --- /dev/null +++ b/tests/snapshots/visual__demo_cjk.snap @@ -0,0 +1,27 @@ +--- +source: tests/visual_snapshots.rs +--- +╭─한글 / 中文 / 日本語 demo────────────────────────────────────────────────────╮ +│ │ +│ CJK 위젯 데모 — Ctrl+Q to quit · 카드를 클릭해 보세요 │ +│ ──────────────────────────────────────────────────────────────────────────── │ +│ ╭─짧은 제목──────────────────────────╮╭─Mixed 한·中·日 title overflow test─╮ │ +│ │ ││ │ │ +│ │ 한국어 본문이 박스 안에 잘 ││ Long titles must clip without brea │ │ +│ │ 들어가는지 확인합니다. ││ 긴 제목은 오른쪽 테두리를 침범하지 │ │ +│ │ 中文 段落 — ││ 않아야 합니다. │ │ +│ │ ││ │ │ +│ ╰────────────────────────────────────╯╰────────────────────────────────────╯ │ +│ ──────────────────────────────────────────────────────────────────────────── │ +│ ╭─입력───────────────────────────────╮╭─결과───────────────────────────────╮ │ +│ │ ││ │ │ +│ │ 이름: ││ 이름 = │ │ +│ │ ╭────────────────────────────────╮ ││ 태그 = │ │ +│ │ │ ▎이름을 입력하세요 │ ││ 카드 클릭 합계 = 0 │ │ +│ │ ╰────────────────────────────────╯ ││ │ │ +│ │ 태그: ││ │ │ +│ │ ╭────────────────────────────────╮ ││ │ │ +│ │ │ ▎태그 │ ││ │ │ +│ │ ╰────────────────────────────────╯ ││ │ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/tests/snapshots/visual__demo_dashboard.snap b/tests/snapshots/visual__demo_dashboard.snap new file mode 100644 index 0000000..6352618 --- /dev/null +++ b/tests/snapshots/visual__demo_dashboard.snap @@ -0,0 +1,43 @@ +--- +source: tests/visual_snapshots.rs +--- +╭─System Dashboard─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ ⠋ LIVE Uptime: 4d 0h 0m │ +│ ──────────────────────────────────────────────────── System Metrics ──────────────────────────────────────────────── │ +│ ┌─────────────────────┐┌─────────────────────┐┌─────────────────────┐┌─────────────────────┐┌──────────────────────┐ │ +│ │ ││ ││ ││ ││ │ │ +│ │ CPU ││ Memory ││ Disk ││ Net In ││ Net Out │ │ +│ │ 45.0% ││ 62.0% ││ 73.0% ││ 12.0MB/s ││ 7.0MB/s │ │ +│ │ ████░░░░░░ ││ ██████░░░░ ││ ███████░░░ ││ █░░░░░░░░░ ││ ░░░░░░░░░░ │ │ +│ │ ││ ││ ││ ││ │ │ +│ └─────────────────────┘└─────────────────────┘└─────────────────────┘└─────────────────────┘└──────────────────────┘ │ +│ ───────────────────────────────────────────────────── Key Metrics ────────────────────────────────────────────────── │ +│ ╭───────────────────────────╮╭───────────────────────────╮╭───────────────────────────╮╭───────────────────────────╮ │ +│ │ ││ ││ ││ │ │ +│ │ Requests ││ Errors ││ P99 ││ Threads │ │ +│ │ 1847293 ↑ ││ 0 ││ 45ms ││ 24 │ │ +│ │ ││ ││ ││ │ │ +│ ╰───────────────────────────╯╰───────────────────────────╯╰───────────────────────────╯╰───────────────────────────╯ │ +│ ╭─Processes──────────────────────────────────────────────╮╭─Logs───────────────────────────────────────────────────╮ │ +│ │ ││ │ │ +│ │ PID │ Name │ CPU% │ Mem% │ Status ││ 12:04:01 [INFO ] Request GET /api/users 200 (12ms) │ │ +│ │ ────┼──────────┼──────┼──────┼───────── ││ 12:04:03 [INFO ] Request POST /api/auth 200 (45ms) │ │ +│ │ 1 │ systemd │ 0.1 │ 0.3 │ running ││ 12:04:05 [WARN ] High memory usage: 82.4% │ │ +│ │ 142 │ nginx │ 2.4 │ 1.2 │ running ││ 12:04:07 [INFO ] Request GET /api/items 200 (8ms) │ │ +│ │ 389 │ postgres │ 8.7 │ 12.4 │ running ││ 12:04:08 [ERROR] Connection timeout: db-replica-2 │ │ +│ │ 412 │ redis │ 1.1 │ 3.8 │ running ││ 12:04:10 [INFO ] Request GET /health 200 (1ms) │ │ +│ │ 501 │ node │ 15.3 │ 8.2 │ running ││ 12:04:12 [INFO ] Cache hit ratio: 94.2% │ │ +│ │ 623 │ python3 │ 4.2 │ 6.1 │ running ││ 12:04:15 [WARN ] Slow query: SELECT * FROM orders (320 │ │ +│ │ 789 │ go-api │ 3.8 │ 2.9 │ running ││ 12:04:18 [INFO ] Request DELETE /api/sessions 204 (3ms │ │ +│ │ 834 │ cron │ 0.0 │ 0.1 │ sleeping ││ 12:04:20 [INFO ] SSL cert renewal: 23 days remaining │ │ +│ │ ────────────────────────────────────────────────────── ││ 12:04:22 [INFO ] Request GET /api/dashboard 200 (18ms) │ │ +│ │ [ Kill ][ Restart ] ││ 12:04:25 [ERROR] Rate limit exceeded: 203.0.113.42 │ │ +│ │ ││ 12:04:28 [INFO ] Backup completed: 2.4GB (42s) │ │ +│ │ ││ 12:04:30 [INFO ] Request PATCH /api/users/5 200 (22ms▼ │ │ +│ │ ││ │ │ +│ ╰────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────╯ │ +│ ─────────────────────────────────────────────────────── Controls ─────────────────────────────────────────────────── │ +│ Ctrl+Q quit · Ctrl+T theme · Tab focus · j/k select │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/tests/snapshots/visual__demo_infoviz.snap b/tests/snapshots/visual__demo_infoviz.snap new file mode 100644 index 0000000..5752481 --- /dev/null +++ b/tests/snapshots/visual__demo_infoviz.snap @@ -0,0 +1,43 @@ +--- +source: tests/visual_snapshots.rs +--- +╭─SLT Infoviz──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│[ Overview ] [ Lines ] [ Scatter ] [ Bars ] [ Heatmap ] [ Financial ] [ Treemap ] [ Canvas ] │ +│┌─Multi-Series──────────────┐┌─P&L───────────────────────┐┌─Area───────────────────────┐┌─Direction──────────────────┐│ +││100┤ ··············· ─ CPU││ 60┤ ··············· ─ P&L││60┤ ··········⢀·⡇ █ Growth ││80┤ ·············· ─ Price ││ +││ │ · · · · · · · • Mem││ │ · · · · · · · · ││ │ · · · ·⢸⣶⡇ ││ │ · · · · · ·⢀· ││ +││ │ · · · · · · · ││ │ · · · · · · · · ││ │ · · · ⢸⣾⣿⡇ ││ │ · · · · · ·⡸· ││ +││ │ · · · · · · · ││ │ · · · · · · · · ││ │ · · · ⣸⣿⣿⡇ ││70┤ ··········⡄·⡇· ││ +││ │ · · · · · ⢠⠳⡀· ││ │ · · · · · · · ⢀ ││50┤ ········⣦⣿⣿⣿⡇ ││ │ · · · · ⢀⠟⣤⠃· ││ +││ 80┤ ···········⡜·⠑· ││ 40┤ ··············⡎ ││ │ · · · ⣿⣿⣿⣿⡇ ││ │ · · · · ⢸ ⠘ · ││ +││ │ · · · · ·⢠⠃• · ││ │ · · · · · · ·⡸· ││ │ · · ·⣾⣿⣿⣿⣿⡇ ││ │ · · · ·⣆⡸ · · ││ +││ │ · · · ·⡰⢀⡜ · · ││ │ · · · · · · ⢠⠃· ││ │ · · ·⣿⣿⣿⣿⣿⡇ ││60┤ ·······⢠⠋⠇···· ││ +││ │ · · · ⢰⠁•⠃ · · ││ │ · · · · · · ⢸ · ││40┤ ·····⢰⣼⣿⣿⣿⣿⣿⡇ ││ │ · · ·⢀ ⢸ · · · ││ +││ │ · · · ⢀ ⠈ · · ││ │ · · · ⡷⡀· · ⡎ · ││ │ · · ⢸⣿⣿⣿⣿⣿⣿⡇ ││ │ · · ·⢸⡆⢸ · · · ││ +││ 60┤ ····⢠⢣⢰•······· ││ 20┤ ·····⢸·⠘⡄···⡇·· ││ │ · ·⢠⣼⣿⣿⣿⣿⣿⣿⡇ ││50┤ ·····⡎⠸⡇······ ││ +││ │ · · ⡜•⡎· · · · ││ │ · · ·⡸· ⢸ ·⢀⠇ · ││ │ · ·⢸⣿⣿⣿⣿⣿⣿⣿⡇ ││ │ · · ·⡇ ⠁ · · · ││ +││ │ · ·⢰⠁· · · · · ││ │ · · ·⡇· ·⡇·⢸· · ││30┤ ····⣸⣿⣿⣿⣿⣿⣿⣿⡇ ││ │ · ·⢸⣄⠇ · · · · ││ +││ │ · •⡜ · · · · · ││ │ · ⡄ ⢀⠇· ·⢇·⢸· · ││ │ · ⣆⣿⣿⣿⣿⣿⣿⣿⣿⡇ ││40┤ ···⡸⢻········· ││ +││ │ ·⣆·⡇ · · · · · ││ │ ·⢠⠻⡀⢸ · ·⢸·⡜· · ││ │ · ⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ ││ │ · ·⡇· · · · · ││ +││ 40┤ •⠉⢶⠁··········· ││ 0┼ ─⢸─⢱⡜────⠸⡀⡇─── ││ │ · ⣠⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ ││ │ ·⡄·⡇· · · · · ││ +││ │ ⢸ ⠈ · · · · · ││ │ ·⡇· ⠃ · · ⡇⡇· · ││20┤ ··⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ ││ │ ·⡿⣀⠇· · · · · ││ +││ │ ⡇ · · · · · · ││ │ ⢠⠃· · · · ⢻ · · ││ │ · ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ ││30┤ ⢸·⢿··········· ││ +││ │ · · · · · · · ││ │ ⢸ · · · · ⠘ · · ││ │ ⢀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ ││ │ ⢸ ⠈ · · · · · ││ +││ │ · · · · · · · ││ │ ⠇ · · · · · · · ││ │ ⢸⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ ││ │ ⡜ · · · · · · ││ +││ 20┤ ··············· ││-20┤ ··············· ││10┤ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ ││20┤ ⡇············· ││ +││ 0 2 4 6 8 10 ││ 0 1 2 3 4 5 6 7 ││ 0 5 10 15 20 ││ 0 2 4 6 8 12 ││ +│└───────────────────────────┘└───────────────────────────┘└────────────────────────────┘└────────────────────────────┘│ +│┌─Bar Chart─────────────────┐┌─Candlestick HD────────────┐┌─Heatmap HD─────────────────┐┌─Treemap────────────────────┐│ +││Rust █████████████ 72% ││ ▄▄▄││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ ││ +││Go ██████████ 58 ││ ┃ ┃ ███││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ TypeS ││ +││Python ████████ 45 ││ ┃ ▄▄▄▄██▀▀▀││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ 10 ││ +││Java ███████ 38 ││ ┃ ┃ ┃ ██▀▀▀▀ ┃ ││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ Go Java ││ +││C++ █████████ 52 ││ ┃ ███████▀▀ ┃ ││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ 25 15 ││ +││ ││ ▄▄▄▄ ┃ ██▀▀▀▀▀┃ ││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ Rust Sw Ko ││ +││ ││┃ ████▄▄▄▄▄██ ││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ 40 8 6 ││ +││ ││▄▄██┃ █████ ││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ ││ +││ ││██┃ ▀▀▀▀▀ ││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ Python C++ ││ +││ ││▀▀ ││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ││ 20 12 Zig L ││ +│└───────────────────────────┘└───────────────────────────┘└────────────────────────────┘└────────────────────────────┘│ +│q quit · ←/→ tab · Esc quit │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/tests/snapshots/visual__demo_overlay_anchor.snap b/tests/snapshots/visual__demo_overlay_anchor.snap new file mode 100644 index 0000000..75d0a9e --- /dev/null +++ b/tests/snapshots/visual__demo_overlay_anchor.snap @@ -0,0 +1,27 @@ +--- +source: tests/visual_snapshots.rs +--- + TL erlay_at + overlay_at_offset demo─ TC ────────────────────────────────── TR +│ tl* tr* │ +│ │ +│ Press q to quit. │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ + ML uter flush badges = overlay_at(anc CC MR +│ Inner inset badges = overlay_at_offset(anchor, 2, 1) │ +│ CSS analog: place-self + top/right/bottom/left inset. │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ bl* br* │ + BL ────────────────────────────────── BC ────────────────────────────────── BR diff --git a/tests/visual_snapshots.rs b/tests/visual_snapshots.rs new file mode 100644 index 0000000..45a860e --- /dev/null +++ b/tests/visual_snapshots.rs @@ -0,0 +1,107 @@ +//! Visual regression snapshots. +//! +//! Each test renders a known demo for one frame at a fixed terminal size +//! and snapshots the buffer output as plain text. Failures indicate a +//! layout, border, wrap, or theme regression. +//! +//! Snapshot files live next to this test under `tests/snapshots/visual__*.snap` +//! and are committed to source control. When the visual output changes +//! intentionally (a deliberate widget restyling, layout tweak, etc.), +//! review and accept the new baseline: +//! +//! ```bash +//! cargo insta review +//! ``` +//! +//! ## What this catches +//! +//! - Layout drift (flexbox grow/shrink/gap regressions) +//! - Border rendering (wrong corners, missing edges, title overflow) +//! - Theme color shifts that flip glyph attributes +//! - CJK / wide-char width handling at the right edge +//! - Wrap and truncation at small terminal sizes +//! +//! ## What this does NOT catch +//! +//! - Interactive state transitions (focus, hover, click) — those need +//! `EventBuilder` and assertion-based tests +//! - Animation / frame-timing — different lane (parity/property tests) +//! - Sixel / kitty image output — not represented in plain-text buffer +//! +//! ## Implementation +//! +//! Each `examples/demo_*.rs` file exposes a `pub fn render(ui: &mut Context)` +//! entry point that builds fresh state and runs one rendering pass. The +//! example's own `main` keeps using `slt::run` (or `slt::run_with`) so the +//! interactive demo still works; we just call the render fn directly with +//! a `TestBackend` here. +//! +//! demo.rs and demo_infoviz.rs are imported via `#[path]` because +//! `examples/*.rs` files are not part of the main library crate. + +use slt::TestBackend; + +// Each example exposes `pub fn render(ui: &mut Context)`. +// The example's own `fn main` is unused here, hence `dead_code`. +#[allow(dead_code)] +#[path = "../examples/demo.rs"] +mod demo; + +#[allow(dead_code)] +#[path = "../examples/demo_dashboard.rs"] +mod demo_dashboard; + +#[allow(dead_code)] +#[path = "../examples/demo_cjk.rs"] +mod demo_cjk; + +#[allow(dead_code)] +#[path = "../examples/demo_infoviz.rs"] +mod demo_infoviz; + +#[allow(dead_code)] +#[path = "../examples/demo_overlay_anchor.rs"] +mod demo_overlay_anchor; + +/// Render one frame of `f` at `(w, h)` and snapshot under `tests/snapshots/visual__.snap`. +/// +/// We bind `prepend_module_to_snapshot => false` so the snapshot file is +/// just `visual__.snap` instead of +/// `visual_snapshots__visual__.snap`. +fn snapshot_frame(name: &str, w: u32, h: u32, f: impl FnOnce(&mut slt::Context)) { + let mut tb = TestBackend::new(w, h); + tb.render(f); + let body = tb.to_string_trimmed(); + insta::with_settings!({ + snapshot_path => "snapshots", + prepend_module_to_snapshot => false, + omit_expression => true, + }, { + insta::assert_snapshot!(format!("visual__{name}"), body); + }); +} + +#[test] +fn visual_demo() { + snapshot_frame("demo", 80, 24, demo::render); +} + +#[test] +fn visual_demo_dashboard() { + snapshot_frame("demo_dashboard", 120, 40, demo_dashboard::render); +} + +#[test] +fn visual_demo_cjk() { + snapshot_frame("demo_cjk", 80, 24, demo_cjk::render); +} + +#[test] +fn visual_demo_infoviz() { + snapshot_frame("demo_infoviz", 120, 40, demo_infoviz::render); +} + +#[test] +fn visual_demo_overlay_anchor() { + snapshot_frame("demo_overlay_anchor", 80, 24, demo_overlay_anchor::render); +}