diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4f8a67..80ca9d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,12 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test --all-features + # `--test-threads=1` is required because `tests/v020_perf_alloc.rs` + # uses a global allocation counter; CI's higher parallelism than a + # local laptop causes other parallel tests to contaminate the count + # and trip its single-thread budget. Single-threaded execution adds + # ~10s but keeps the perf-budget asserts meaningful. + - run: cargo test --all-features -- --test-threads=1 clippy: name: Clippy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc4fc43..e7b389a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,10 @@ jobs: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - run: cargo check --all-features - - run: cargo test --all-features + # See ci.yml — perf-alloc tests use a global counter, so CI's higher + # parallelism contaminates measurements. Single-threaded keeps the + # budget asserts honest. + - run: cargo test --all-features -- --test-threads=1 - run: cargo clippy --all-features -- -D warnings - run: cargo fmt -- --check diff --git a/.gitignore b/.gitignore index 9cf247e..63bb8be 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ SESSION-SUMMARY.md assets/blackpink/ CLAUDE.md .research/ +.claude/worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afb018..9524476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,168 @@ # Changelog -## [Unreleased] +## [0.20.0] - 2026-04-28 + +### Added + +- **`feat(test-utils)` — `TestBackend::record_frames()` + `FrameRecord` history** (#229) — Opt-in frame recorder. Every `render()` call appends a `FrameRecord { snapshot, lines }` accessible via `tb.frames()`. `FrameRecord` exposes `assert_contains`, `to_string_trimmed`, and per-row text. Disabled by default → zero allocation overhead for tests that don't need history. +- **`feat(test-utils)` — `TestBackend::sequence()` builder + `type_string` helper** (#230) — Multi-step interaction sequences without manual `focus_index` / `prev_focus_count` threading. Methods: `.tick()`, `.key(KeyCode)`, `.type_string(&str)`, `.events(Vec)`, `.run()`. Backend-level `tb.type_string("hi", render)` fires one frame per character. +- **`feat(buffer)` — `Buffer::snapshot_format()`** (#231) — Stable styled-snapshot string for `insta::assert_snapshot!` compatibility. Named palette colors → short codes (`red`, `light_blue`); RGB → `#rrggbb`; indexed → `idx`; canonical modifier order (`bold,dim,italic,underline,reversed,strikethrough`). Format guaranteed stable across patch and minor versions; locked by `tests/snapshot_format_stability.rs`. +- **`feat(test-utils)` — `assert_not_contains` / `assert_line_not_contains` / `assert_empty_line` / `assert_style_at`** (#232) — Negative assertion helpers on `TestBackend`. Failures show offending row indices and full row contents; `assert_style_at` reports `(x, y, expected, actual)` on style mismatch. +- **`feat(context)` — `Response::right_clicked` / `gained_focus` / `lost_focus`** (#208) — three new public bool fields on every widget `Response`. `right_clicked` mirrors the existing `clicked` logic for `MouseButton::Right`. `gained_focus` is `true` exactly on the frame focus moves to the widget; `lost_focus` is `true` exactly on the frame focus moves away. Mutually exclusive within a single Response. Hooked into `begin_widget_interaction` so all widgets that use the standard interaction path (button / table / select / radio / checkbox / toggle / tree etc.) populate the signals automatically. +- **`feat(hooks)` — `Context::use_state_keyed` / `use_state_keyed_default`** (#215) — runtime-string-keyed persistent state. Accepts `impl Into`, so `format!("item-{i}")` works in dynamic loops where `use_state_named` (which requires `&'static str`) does not. Stored in a parallel `keyed_states: HashMap>` on `Context` / `FrameState`. Mirrors the namespace-collision + per-frame-allocation caveats of `use_state_named`. **See breaking section below for the `State` Copy removal.** +- **`feat(hooks)` — `Context::use_effect`** (#216) — dependency-tracked side effects. `ui.use_effect(|deps| do_thing(deps), &deps)` runs the closure on the first frame and on every frame thereafter where `*deps != stored_deps`. Positional hook (same rules as `use_state` / `use_memo`). Fire-and-forget — no cleanup callback. Doc warns that `use_effect` inside `error_boundary` may re-fire on rollback. +- **`feat(focus)` — `register_focusable_named` + `focus_by_name` + `focused_name`** (#217) — Ink-style named focus manager. `register_focusable_named(name)` is a drop-in replacement for `register_focusable()` that records `name → focus_index`. `focus_by_name(name)` requests focus on the named widget; resolution happens against the previous frame's map (deferred-command pattern). `focused_name()` returns the name of the currently focused widget, if any. Compatible with the existing positional Tab/Shift+Tab cycling. +- **`feat(context)` — `Context::key_presses_when` + `Context::consume_event`** (#218) — public focus-gated key-press iterator and per-event consume helper. `key_presses_when(active)` returns an empty iterator when `active=false` and the same items as the internal `available_key_presses` when `active=true`. `consume_event(idx)` is the public counterpart of the crate-internal `consume_indices`, enabling user-land `Widget` impls to mark events handled. Out-of-range indices silently no-op. +- **`feat(context)` — `Response::on_hover` / `on_hover_ui` chaining** (#209) — Attach a tooltip (or run an arbitrary tooltip-rendering closure) directly on a widget's `Response` without the order-sensitive `ui.tooltip(...)` post-call. Composes cleanly: `if ui.button("Save").on_hover(ui, "Saves the file").clicked { ... }`. Skips alloc when `hovered == false` or `text` is empty. +- **`feat(anim)` — `Context::animate_bool` / `animate_value` shorthand** (#210) — Zero-boilerplate implicit animation keyed by `&'static str`, stored in `named_states`. `animate_bool(id, value) -> f64` returns 0.0..=1.0 over `DEFAULT_ANIMATE_TICKS` (12 ticks ≈ 200 ms @ 60 Hz). `animate_value(id, target, duration) -> f64` retargets smoothly from the current interpolated value; `duration_ticks == 0` snaps. First call snaps to target with no visible pop. +- **`feat(container)` — `ContainerBuilder::fill()` shorthand** (#220) — Self-documenting alias for `.grow(1)` (CSS `flex: 1`, ratatui `Constraint::Fill(1)`). One-liner that improves readability of the most common flex case without changing semantics. +- **`feat(rect)` — `Rect::center_in` / `center_horizontally_in` / `center_vertically_in`** (#221) — Position a sized rect centered inside a parent (the inverse of `Rect::centered`). Matches ratatui v0.30. Clamps to parent extent on oversize. `const fn` — usable in static contexts. +- **`feat(modal)` — `ModalOptions` + `Context::modal_with`** (#225) — opt-in WCAG 2.1 SC 2.4.3 (Focus Order) compliance. `ModalOptions { tab_trap: true }` prevents focus escape when programmatic `set_focus_index` or a stray click lands focus outside the modal range. `ModalOptions::default()` enables `tab_trap`. Plain `Context::modal(...)` keeps the legacy non-trapping behavior unchanged for backward compatibility — i.e. `ui.modal(f)` and `ui.modal_with(ModalOptions::default(), f)` deliberately produce different focus semantics. Use `modal_with(ModalOptions::default(), f)` (or `ModalOptions { tab_trap: true, ..Default::default() }`) when you want the WCAG-aligned trap; keep `modal(f)` for the v0.19 escape-friendly behavior. +- **`feat(theme)` — `ContainerBuilder::theme(theme)`** (#226) — per-subtree theme override. Swaps `ctx.theme` (and `dark_mode` flag) for the duration of the closure body, restoring on exit — including on panic. Nested `.theme(...)` calls compose correctly: outer theme resumes once the inner scope closes. Independent of `provide` / `use_context` (general-purpose context injection); this method directly mutates the active theme so every built-in widget (which reads `self.theme`) picks up the change without opt-in. +- **`feat(theme)` — `Theme::compact()` / `Theme::comfortable()` / `Theme::spacious()` density presets** (#227) — base spacings 1 / 2 / 3 respectively. Matches the existing `Spacing` scale (`xs` / `sm` / `md` / `lg` / `xl` / `xxl`). `compact()` is bit-identical to existing presets, preserving v0.19 visuals when adopted explicitly. +- **`feat(theme)` — `Theme::with_spacing(spacing)`** (#227) — mutate spacing on any preset (Nord, Dracula, custom) without touching colors. +- **`feat(widgets-display)` — `split_pane` / `vsplit_pane`** (#223) — resizable horizontal/vertical split containers driven by `SplitPaneState`. Drag the 1-cell handle (`│` / `─`) with the mouse, or focus it (Tab) and use arrow keys to grow/shrink the first pane by 5% per press. `SplitPaneResponse` exposes `ratio` and `drag_active`. +- **`feat(widgets-display)` — `gauge` (chainable builder)** (#224, builder finalized in v0.20.0 API consistency pass) — block-fill progress bar with optional centered inline label (e.g. `█████████ 60% ░░░░░░`). Chainable: `ui.gauge(ratio).label(s).width(n).color(c)`. Color-tiered by default (`success` < 50%, `warning` 50–80%, `error` ≥ 80%); `.color(c)` disables tiering. Auto-renders on `Drop`; call `.show()` for a `GaugeResponse` (derefs to `Response`). `ratio` is `f64` for parity with `animate_value`, chart APIs, and `progress_bar`. +- **`feat(widgets-display)` — `line_gauge` (chainable builder)** (#224, builder finalized in v0.20.0 API consistency pass) — single-line gauge with configurable fill/empty characters and an optional trailing label. Chainable: `ui.line_gauge(ratio).label(s).width(n).filled(c).empty(c)`. Auto-renders on `Drop`; call `.show()` for a `GaugeResponse`. +- **`feat(widgets-display)` — `scrollable_with_gutter` + `GutterOpts`** (#235, signature finalized in v0.20.0 API consistency pass) — scrollable container variant rendering a per-line left gutter (line numbers, breakpoint markers, etc.) plus search-result highlight bands. The bookkeeping arguments collapse into `GutterOpts`; use `GutterOpts::line_numbers(total, viewport)` for the 90% case or `GutterOpts::new(total, viewport, |i| ...)` for custom labels. Companion API on `ScrollState`: `set_highlights`, `highlight_next`, `highlight_previous`, `clear_highlights`, `current_highlight`. `HighlightRange` is re-exported at the crate root; use `HighlightRange::line(i)` for single-line and `HighlightRange::span(start, count)` for multi-line ranges. Returns `GutterResponse` carrying the current highlight index and total count. +- **`feat(lib)` — `Context::static_log(line)`** (#233) — append-only scrollback widget API. Inside the frame closure, queue lines that get committed to the terminal's history above the inline dynamic area. Drains automatically through `slt::run_static` / `slt::run_static_with`; no-op (with a `cfg(debug_assertions)` warning) on full-screen and inline runtimes that have no scrollback channel. Inspired by Ink's ``. Companion accessor `Context::take_static_log()` exposes the same buffer to custom backends and `TestBackend` callers. +- **`feat(keymap)` — `WidgetKeyHelp` trait + `Context::publish_keymap` / `published_keymaps` / `keymap_help_overlay`** (#236) — opt-in trait for widgets to publish their `&'static [(key, description)]` shortcut list. The framework aggregates every keymap registered this frame (cleared at frame start by `run_frame_kernel`) and `keymap_help_overlay(open)` renders an automatic modal listing all bindings — wire it to `?` / `F1` for instant discoverability. Standalone `PublishedKeymap` struct exposed for downstream widgets / palettes. +- **`feat(lib)` — `RunConfig::handle_ctrl_c(bool)`** (#238) — opt-out for the auto-Ctrl+C-quits behavior. Defaults to `true` (preserves v0.19 contract). Setting `false` delivers Ctrl+C to the frame closure as a normal `Event::Key` with `KeyModifiers::CONTROL` — matches RataTUI's raw-mode semantics so users migrating code that already handles Ctrl+C explicitly do not need to fork SLT. Threaded through `run_with`, `run_inline_with`, `run_static_with`, and `run_async_loop`. `run()` rustdoc updated to note that wrapping with `crossterm::terminal::enable_raw_mode()` / `disable_raw_mode()` is redundant — SLT enters raw mode automatically. + +### Changed + +- **`change(theme)` Built-in widgets now derive padding/gap from `theme.spacing`** (#227) — code_block, code_block_numbered, accordion, tooltip, help, help_colored, tabs, checkbox, toggle, select trigger, calendar header, text_input, suggestion box, command palette, markdown code blocks. Default theme spacing is unchanged (`Spacing::new(1)`), so every preset produces v0.19-identical output by default. To get larger paddings, use `Theme::comfortable()`/`Theme::spacious()` or set `theme.spacing` explicitly via `ThemeBuilder::spacing(...)`. +- **`change(examples)` Cargo example list compacted from 53 → 16** — `Cargo.toml` sets `autoexamples = false` and lists 16 explicit `[[example]]` entries: 6 tour binaries (`v020_tour`, `cookbook_tour`, `showcase_tour`, `canvas_tour`, `text_tour`, `system_tour`), 5 standalone demos (`hello`, `counter`, `demo`, plus 2 perf tools), 3 dev tools, and 2 v0.20 reports. Source files for the demos that compose into tours stay in `examples/` and are reached via `#[path = ...] mod` includes from the tour binaries — see `docs/DEMO_GUIDE.md` for the archetype rules. + +### Fixed + +- **`fix(focus)` — `register_focusable_named` now allocates a slot eagerly and reserves it for the next `register_focusable()` call** (#217 follow-up) — In v0.20.0-preview the call queued a name and waited for a following widget to drain it, which made `register_focusable_named("x")` a silent no-op when called standalone (the `name → slot` map never picked up the binding). The new behaviour allocates the slot on the named call itself and stores the slot id as a one-shot reservation: the very next `register_focusable()` reuses it instead of allocating a fresh slot, so widgets like `text_input`, `button`, and `tabs` placed immediately after still inherit the name. Both shapes work — "name + widget" (the common idiom) and "name alone" (custom focusable regions, unit tests). Verified by 24 tests in `tests/v020_hooks_focus.rs`. +- **`fix(chart)` — Legend names and treemap labels are clipped with an ellipsis (`…`) instead of bare-truncated** — `crate::chart::truncate_label(text, max_cols)` is the new shared helper. Returns the original text when it fits, an ellipsis-suffixed prefix when it does not, and an empty string when `max_cols < 3` (drops the label entirely rather than emit a 1- or 2-cell garbled prefix). Used by `chart::render` for legend names (after the legend column budget is computed against y-axis width and a `MIN_PLOT_COLS = 4` reservation) and by `treemap` for cell labels. Pre-fix output showed `Memor` / `TypeS`; post-fix shows `Memo…` / `Type…` or drops the label. +- **`fix(examples)` — Tab clicks in `cargo run --example demo` now persist** — `examples/demo.rs` lifted all per-frame `let mut state = ...` into a `pub struct DemoState` owned by `main()`. Previously every render re-created `TabsState`, `TextInputState`, etc., which made tab clicks visibly flash for ≈ 0.1 s before snapping back to the first tab. The same `pub fn render(ui, &mut state)` + `pub fn render_snapshot(ui)` split applied to `v020_keymap_help`, `v020_gutter_highlights`, `v020_ctrl_c_passthrough`, and `v020_dx_shortcuts` so they keep state across frames when embedded in `v020_tour` (per `docs/DEMO_GUIDE.md` §2). +- **`fix(widgets-interactive)` — `virtual_list` keeps cursor mid-viewport instead of always anchored to the bottom** (#192) — Added `pub(crate) viewport_offset: usize` to `ListState` (`Default = 0`, additive). The `virtual_list` viewport now sticks to the cursor on entry/exit only, mirroring every other TUI library. Pre-fix moving up dragged the entire viewport because `start = selected - vh + 1`. Test: `virtual_list_cursor_not_anchored_to_viewport_bottom`. +- **`fix(widgets-interactive)` — `calendar` `h`/`l` now move ±1 day; `[` / `]` move ±1 month** (#193) — Vim convention restored. Pre-fix `h`/`l` were aliased to `prev_month` / `next_month`, which contradicted the universal vim "single-cell move" mental model. `Left`/`Right` arrows still move ±1 day too. Calendar rustdoc carries a keybinding table; `WidgetKeyHelp` updated so the `?` overlay reflects the new bindings. Test: `calendar_h_l_move_by_day`. +- **`refactor(widgets-input)` — `FilePickerState::selected_file()` disambiguates from `selected: usize`** (#98) — Added `pub fn selected_file(&self) -> Option<&PathBuf>`; the existing `selected()` method is `#[deprecated(since = "0.20.0")]` and delegates to `selected_file()`. The duplicate identifier (`pub selected: usize` field vs `pub fn selected() -> Option<&PathBuf>` method) was confusing readers. Migration: replace `state.selected()` with `state.selected_file()` to clear the deprecation warning. +- **`feat(widgets-input)` — Textarea undo/redo (`Ctrl+Z` / `Ctrl+Y`)** (#102) — Pure additive. `TextareaState` now holds a `history: Vec` (default cap 100, configurable via `history_max(cap)` builder) plus `history_index: usize`. Snapshots are pushed before destructive mutations (insert, Enter, Backspace, Delete, paste). Rapid character typing coalesces into a single undoable batch (one snapshot per "edit burst" rather than one per keystroke). 5 new tests in `tests/textarea_undo.rs`. Programmatic `set_value()` clears history (replacement is not undoable). +- **`refactor(container)` — `ContainerBuilder::scroll_offset` hidden from rustdoc** (#149) — Added `#[doc(hidden)]` (Option B from the issue) so the implementation-detail builder method stops appearing in the public docs. `cargo-semver-checks` still tracks the symbol so promote to `pub(crate)` happens at v1.0. No behavior change. +- **`feat(widgets-display)` — `scrollbar()` returns `Response`** (#184) — Reserves a future-compatible extension point for click-to-jump and drag handling without another breaking change. `Response::none()` for now. All in-crate call sites continue to compile (statement form, response ignored). +- **`perf(layout)` — drain `commands` Vec in `build_tree` to reuse capacity across frames** (#150) — `build_tree(commands: Vec)` → `build_tree(&mut Vec)` using `drain(..)`. `run_frame_kernel` reclaims via `mem::take`. Steady-state allocation count for the per-frame command buffer drops -15% (1300 → 1100 over 100 frames in serial mode). +- **`perf(layout)` — reuse `line_segs` scratch in `wrap_segments`** (#157) — Hoisted the per-line `Vec` out of the outer loop; `mem::replace` returns an empty buffer with the previous capacity. Output byte-identical (existing `wrap_segments_*` tests + alloc-count budget hold). Internal-only, no public API change. +- **`feat(flexbox)` — Opt-in proportional `.shrink()` flag for overflow handling** (#161) — `ContainerBuilder::shrink(self) -> Self` marks a child eligible for CSS-style proportional shrink when `fixed_width > available`. Default behaviour (no shrink, overflow-by-design) is preserved. Implementation uses a `Command::ShrinkMarker` indirection (mirrors `FocusMarker` / `InteractionMarker`) so `Constraints` size invariant (`_ASSERT_CONSTRAINTS_SIZE == 24`) is preserved. 3 new layout tests cover (a) default-off, (b) all-shrink, (c) mixed. +- **`perf(terminal)` — Per-row hash skip in `flush_buffer_diff`** (#171) — Added `Buffer::line_hashes` + `line_dirty` (`pub(crate)`); rows with unchanged hash skip cell iteration. Bench `bench_flush_static_200x60`: **113–138 µs → 127 ns (~1000× speedup)** on fully-static screens. Sparse and full-redraw paths unchanged. Uses `std::collections::hash_map::DefaultHasher` — no new dep. +- **`feat(layout)` — `DebugLayer` enum for F12 overlay opt-in** (#201) — Added `Shift+F12` keybinding that cycles `All → TopMost → BaseOnly → All`. Plain `F12` still toggles the overlay on/off. Per-variant rustdoc + `# Example` blocks on `DebugLayer`, `Context::debug_layer`, `Context::set_debug_layer`. The enum + getter/setter base shipped earlier in v0.20; this adds the missing keybinding and rustdoc. + +### Closed (verified already applied in v0.19.3) + +The following v0.19.x-milestoned issues had their fix land in PR #202 (v0.19.3 `0a56880`) ahead of this release. The fix is part of v0.20.0 transitively; no separate v0.20 commit was needed. + +- **#134** `screen_hook_map` cache-hit `String` alloc removed (verified at `src/context/widgets_display/layout.rs:226-232`). +- **#146** `filled_circle` integer Newton's-method `isqrt`. (`u64::isqrt` would be cleaner but is gated behind MSRV 1.84+; SLT MSRV is 1.81. Tracked as a `TODO(msrv)` for v0.21+.) +- **#147** Breakpoint variants for `min_h` / `max_h`. +- **#148** `#[deprecated]` aliases for `pad` / `min_width` / `max_width` / `min_height` / `max_height` (canonical short forms `p` / `min_w` / `max_w` / `min_h` / `max_h`). +- **#152** `LayoutNode::group_name` widened to `Option>` (collect-time pointer-bump, not heap alloc). +- **#153** `LayoutNode` text-only fields hoisted into `Box` — measured `size_of::()` 432 → 304 bytes (-29.6 %). `_ASSERT_LAYOUT_NODE_SIZE` const-asserts the upper bound. +- **#155** `FrameData` re-use via `&mut` parameter (`collect_all(node, data)` + `mem::take` reclaim in `lib::run_frame_kernel`). +- **#162** Viewport bound check before bottom-border corner render in `render_container_border`. + +### Known v0.21 migration notes + +A design-discipline audit of v0.20.0 surfaced four critical drifts that require a *breaking* change to fully resolve. They are intentionally deferred to v0.21.0 so v0.20.0 stays a single-bump migration: + +1. **Hook family naming asymmetry** — `use_state_named` (no init closure, requires `Default`) vs `use_state_named_with` (init closure) flips the suffix relative to `use_state_keyed` (init closure) vs `use_state_keyed_default` (no init, requires `Default`). v0.21 will pick the "no-suffix = init closure" convention (matches `use_state(init)`) and add `#[deprecated]` aliases. +2. **`scrollbar()` and `separator()` return shape** — `scrollbar` already returns `Response::none()` in v0.20 (#184); `separator` / `separator_colored` still return `&mut Self` from a legacy text-chain pattern that doesn't carry chainable methods worth chaining. v0.21 promotes them to `Response` so the `M4 — no () returns` rule from `docs/ARCHITECTURE.md` is met everywhere. +3. **`status` family fake `Response::none()` returns** — `badge` / `key_hint` / `stat` / `stat_colored` / `stat_trend` / `empty_state` return `Response::none()` for shape compatibility but never populate interaction fields. v0.21 will route them through `self.interaction()` so `.on_hover` / `.hovered` / `.gained_focus` work. +4. **`ScrollState::progress() -> f32`** — outlier in the v0.20 ratio surface (every other ratio is `f64`: gauge, line_gauge, split_pane, animate_value, progress_bar). v0.21 adds `progress_ratio() -> f64` and `#[deprecated]`s the `f32` form. + +The audit also identified several *patch-safe* doc-only gaps (missing `# Example` / `# Panics` sections on hook methods, status family widgets, `vsplit_pane`) that will land as a follow-up in `0.20.x` rather than block this release. + +### Performance + +- **`perf(context)` reuse 6 per-frame `Vec`/`HashSet` allocations via `FrameState`** (#204) — `context_stack`, `deferred_draws`, `rollback.group_stack`, `rollback.text_color_stack`, `pending_tooltips`, `hovered_groups` now follow the `mem::take` pattern established by #150 / #155. Steady-state allocation count for a 100-frame loop drops from a baseline that scaled with these six fields to a tight per-frame budget (verified by `tests/v020_perf_alloc.rs::framestate_reuse_steady_state_alloc_count_low`). +- **`perf(layout)` pre-size `wrap_segments` per-style-run `String` with `with_capacity`** (#205) — eliminates the realloc on the first character pushed at every style boundary in `wrap_segments`. Capacity is computed from the remaining bytes in the source segment, capped at `max_width * 4` to bound over-allocation. Output is byte-identical to the prior implementation (`tests/v020_perf_demo.rs::wrap_segments_with_capacity_preserves_byte_output`). +- **`perf(terminal)` avoid `Vec` clone in `InlineTerminal::flush`** (#206) — `KittyImageManager::flush` now accepts a `row_offset: u32` and applies it arithmetically at point of use. The `prev_placements` diff stores post-offset y values so resize-driven offset changes still re-emit. Eliminates one `Vec` allocation + N `Arc::clone`/`Arc::drop` round-trips per inline-mode frame with images (`tests/v020_perf_alloc.rs::kitty_placement_flush_alloc_count_low` confirms 1 alloc across 100 stable flushes). +- **`perf(render)` modal-aware `dim_buffer_around` replaces full-buffer scan** (#228) — for the common modal-with-margin case, `render` now calls `dim_buffer_around(modal_rect)` which walks only the four strips outside the modal instead of the full O(W×H) buffer. The legacy `dim_entire_buffer` path is retained for the zero-size-modal fallback. Visible output is unchanged (`tests/v020_perf_demo.rs::modal_dim_path_preserves_modal_content`). + +### Breaking + +- **API consistency pass on new widgets** — `gauge` / `line_gauge` / `breadcrumb` and `scrollable_with_gutter` were unified onto a single design language so v0.20 does not ship five new widgets with five argument styles. The five mismatched signatures became three Egui-style chainable builders + one options struct. The new builders are the only public form; no deprecated shim wrappers ship in v0.20. v0.19 preview users who manually constructed the unstable `gauge_w` / `gauge_colored` / `line_gauge_with(LineGaugeOpts)` / `breadcrumb_sep` signatures should migrate to the builders: + + ```rust + // before (v0.19.x preview) + ui.gauge(0.42, "CPU"); // 2 positional + ui.gauge_w(0.42, "CPU", 48); // 3 positional + ui.gauge_colored(0.42, "CPU", 48, Color::Red); // 4 positional + ui.line_gauge(0.42, LineGaugeOpts::default().width(48).label("CPU")); + ui.breadcrumb_sep(&["Home", "src"], " > "); + ui.scrollable_with_gutter( + &mut scroll, total, viewport, |i| line_label(i), |ui, i| { ... } + ); + + // after (v0.20.0) + ui.gauge(0.42).label("CPU"); + ui.gauge(0.42).label("CPU").width(48); + ui.gauge(0.42).label("CPU").width(48).color(Color::Red); + ui.line_gauge(0.42).label("CPU").width(48); + ui.breadcrumb(&["Home", "src"]).separator(" > "); + ui.scrollable_with_gutter( + &mut scroll, + GutterOpts::line_numbers(total, viewport), // 90% case shortcut + |ui, i| { ... }, + ); + ``` + + The new builders auto-render on `Drop` so a bare `ui.gauge(0.5).label("CPU");` is the idiomatic form when the response isn't needed; call `.show()` to capture a `GaugeResponse` / `BreadcrumbResponse`. + +- **`f32 → f64` unification on ratio APIs (`gauge` / `line_gauge` / `split_pane`)** — every public `f32` ratio in v0.20 is widened to `f64` to align with `Context::animate_value` (`f64`), chart APIs (`f64`), and `progress_bar` (`f64`). Touched APIs: `Context::gauge` / `line_gauge` argument, `GaugeResponse.ratio`, `SplitPaneState::{ratio, min_ratio, new, with_min_ratio, set_ratio}`, `SplitPaneResponse.ratio`, `DEFAULT_SPLIT_MIN_RATIO`. Most callers need no change because Rust auto-coerces float literals (`SplitPaneState::new(0.5)` works either way). Explicit `f32` casts (`ratio as f32`) at the call site must be removed; use `f64` arithmetic throughout, or cast `as f64` once at the boundary. The `f32` ratio inside `style/color.rs` blending math is intentional graphics-internal precision per `docs/API_DESIGN.md` Rule 2 exception. + +- **`refactor(widgets-display)` — `scrollable_with_gutter` now takes `GutterOpts`** (#235 follow-up) — the four-positional signature `(state, total_lines, viewport_height, gutter_fn, body_fn)` was hard to read and easy to misorder. v0.20 collapses the bookkeeping arguments into a `GutterOpts` struct: `pub fn scrollable_with_gutter(&mut self, state, opts: GutterOpts, body_fn)`. The 90% case (1-based line numbers) gets a `GutterOpts::line_numbers(total, viewport)` shortcut so most callers never write the labeling closure. Use `GutterOpts::new(total, viewport, gutter_fn)` for custom labels (breakpoints, Git diff markers, fold indicators, etc.). + +- **`refactor(style)` — `Constraints` redesign with `WidthSpec`/`HeightSpec`** (#237 — closes #207, #219). The `Constraints` struct now holds two enum-typed fields, `width: WidthSpec` and `height: HeightSpec`, instead of the v0.19 trio of `Option` / `Option` fields per axis. Builder methods (`min_w`, `max_w`, `min_h`, `max_h`, `w`, `h`, `w_pct`, `h_pct`) are preserved as ergonomic shortcuts that set the appropriate variant. New: `w_ratio(num, den)` / `h_ratio(num, den)` for exact integer-fraction sizing — `Ratio(1, 3)` produces `area / 3` (floor division; for `area = 80, num = 1, den = 3` → `26`). `size_of::()` drops from 36 → 24 bytes (33 % reduction); `WidthSpec` and `HeightSpec` are 12 bytes each. The `MinMax` variant uses sentinel encoding (`min = 0` means "no minimum", `max = u32::MAX` means "no maximum") so the variant fits in 12 bytes. + + Migration: + + ```rust + // before (v0.19) + Constraints { + min_width: Some(10), + max_width: Some(40), + ..Default::default() + } + // after (v0.20) + Constraints::default().w_minmax(10, 40) + // or piecewise: + Constraints::default().min_w(10).max_w(40) + ``` + + Direct field reads (`c.min_width`, `c.max_width`, `c.width_pct`, …) become accessor calls (`c.min_width()`, `c.max_width()`, `c.width_pct()`, …) — they still return `Option` / `Option` so the receiving code typically only needs to add parentheses. For imperative mutation (rare; previously `c.min_width = Some(v)`), use the new setter methods (`set_min_width`, `set_max_width`, `set_width_pct`, …). + + `serde` wire format changes: persisted `Constraints` JSON from v0.19 will not deserialize into v0.20 because the field shape is different. Re-export persisted data after upgrading. +- **`State` is no longer `Copy`** (#215) — required to support the `Keyed(String)` variant of the internal `StateKey` enum. `Clone` is still derived (cheap for `Indexed` / `Named`, allocates one `String` for `Keyed`). **Migration**: if you previously relied on implicit copy semantics — for example `let s = ui.use_state(...); use_a(s); use_b(s);` — call `s.clone()` explicitly: `use_a(s.clone()); use_b(s);`. An audit of every `State` use site in `src/`, `tests/`, `examples/` showed **zero** sites depended on `Copy`; existing call sites borrow or move the handle and continue to compile unchanged. +- **`break(theme)` Spacing scale activation may shift visuals** (#227) — if you customized themes with non-default spacing (e.g., `Spacing::new(2)`), affected widgets now respect that scale. Migration: set `Theme::spacing` explicitly via `ThemeBuilder::spacing(...)` or use `Theme::with_spacing(...)` to lock down the visual you depend on. The 10 stock presets still ship `Spacing::new(1)`, so upgraders who never touched the spacing field see no change. +- **`refactor(widgets-display)` — `breadcrumb` is now a chainable builder (#213, builder finalized in v0.20.0 API consistency pass)** — replaced the four-variant API (`breadcrumb`, `breadcrumb_with`, `breadcrumb_response`, `breadcrumb_response_with`) with a chainable builder. `ui.breadcrumb(segments)` returns a `Breadcrumb<'_>` that auto-renders on `Drop`; chain `.separator(s)` or `.color(c)` and call `.show()` to capture a `BreadcrumbResponse` (derefs to `Response`, so `.hovered`, `.rect`, `.focused` work on `r`). + + Migration: + ```rust + // before (v0.19): + let clicked = ui.breadcrumb(&segments); // Option + let (resp, clicked) = ui.breadcrumb_response(&segments); + let clicked = ui.breadcrumb_with(&segments, " > "); + + // after (v0.20): + ui.breadcrumb(&segments); // simple + let r = ui.breadcrumb(&segments).show(); // capture response + let clicked = r.clicked_segment; + let r = ui.breadcrumb(&segments).separator(" > ").show(); // custom separator + ``` + +- **`refactor(widgets-input)` — `spinner` / `progress` / `progress_bar` / `progress_bar_colored` now return `Response` (#212)** — these widgets previously returned `&mut Self` (a builder-chain shim). They now return `Response` so callers can detect hover, attach tooltips, or implement click-to-set scrubbers. `toast` continues to return `&mut Self` (no meaningful single rect — purely visual overlay). + + Migration: code that ignored the return value still compiles; the `#[must_use]` attribute on `Response` will warn at the call site. The recommended fix is `let _ = ui.progress(0.5);`. Code that chained builder-style methods (e.g. `ui.spinner(&s).fg(theme.primary)`) must split into two statements: + ```rust + // before: + ui.spinner(&s).fg(theme.primary); + // after: + let _ = ui.spinner(&s); // color is already theme.primary; if you need a different color, render manually. + ``` ## [0.19.3] — 2026-04-27 diff --git a/Cargo.lock b/Cargo.lock index 77fb4cd..6db77cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1024,7 +1024,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "slt-wasm" -version = "0.19.0" +version = "0.20.0" dependencies = [ "js-sys", "superlighttui", @@ -1052,7 +1052,7 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "superlighttui" -version = "0.19.3" +version = "0.20.0" dependencies = [ "compact_str", "criterion", @@ -1063,6 +1063,7 @@ dependencies = [ "proptest", "qrcode", "serde", + "serde_json", "smallvec", "tokio", "tree-sitter-bash", diff --git a/Cargo.toml b/Cargo.toml index f303570..f17d98b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "crates/slt-wasm"] [package] name = "superlighttui" -version = "0.19.3" +version = "0.20.0" edition = "2021" description = "Super Light TUI - A lightweight, ergonomic terminal UI library" license = "MIT" @@ -15,6 +15,14 @@ keywords = ["tui", "terminal", "cli", "ui", "immediate-mode"] categories = ["command-line-interface"] rust-version = "1.81" exclude = ["examples/", ".github/", "assets/", "AUDIT-REPORT.md", "CLAUDE.md"] +# Disable cargo's `examples/*.rs` auto-discovery: only the binaries listed +# below as `[[example]]` are exposed via `cargo run --example`. Source +# demos that compose into a tour (v020_*, cookbook_*, most demo_*, +# anim, async_demo, inline, error_boundary_demo) stay in examples/ but +# are reached only via `#[path = ...] mod` includes from the tour +# binaries. See `docs/DEMO_GUIDE.md` for the archetype rules and the +# v0.20 release notes for why these were merged into tours. +autoexamples = false [lib] name = "slt" @@ -50,6 +58,7 @@ tree-sitter-yaml = { version = "0.7", optional = true } criterion = { version = "0.5", features = ["html_reports"] } insta = "1" proptest = "1" +serde_json = "1" [features] crossterm = ["dep:crossterm"] @@ -97,90 +106,84 @@ full = ["crossterm", "async", "serde", "image", "qrcode", "kitty-compress"] all-features = true rustdoc-args = ["--cfg", "docsrs"] -[[example]] -name = "hello" -path = "examples/hello.rs" +# ── Tour binaries (6) — integrated demos covering v0.20 features and +# domain showcases. Each tour bundles 4–10 source demos via the +# DEMO_GUIDE.md archetype rules. Run these for end-to-end review. [[example]] -name = "counter" -path = "examples/counter.rs" +name = "v020_tour" +path = "examples/v020_tour.rs" [[example]] -name = "demo" -path = "examples/demo.rs" +name = "cookbook_tour" +path = "examples/cookbook_tour.rs" [[example]] -name = "anim" -path = "examples/anim.rs" +name = "showcase_tour" +path = "examples/showcase_tour.rs" [[example]] -name = "inline" -path = "examples/inline.rs" +name = "canvas_tour" +path = "examples/canvas_tour.rs" [[example]] -name = "async_demo" -path = "examples/async_demo.rs" -required-features = ["async"] +name = "text_tour" +path = "examples/text_tour.rs" [[example]] -name = "demo_dashboard" -path = "examples/demo_dashboard.rs" +name = "system_tour" +path = "examples/system_tour.rs" +required-features = ["async"] -[[example]] -name = "demo_cli" -path = "examples/demo_cli.rs" +# ── Standalone — entry / how-to-start (3) [[example]] -name = "demo_spreadsheet" -path = "examples/demo_spreadsheet.rs" +name = "hello" +path = "examples/hello.rs" [[example]] -name = "demo_website" -path = "examples/demo_website.rs" +name = "counter" +path = "examples/counter.rs" [[example]] -name = "demo_infoviz" -path = "examples/demo_infoviz.rs" +name = "demo" +path = "examples/demo.rs" -[[example]] -name = "cookbook_dashboard" -path = "examples/cookbook_dashboard.rs" +# ── Standalone — performance measurement (2) [[example]] -name = "cookbook_file_picker" -path = "examples/cookbook_file_picker.rs" +name = "perf_interactive" +path = "examples/perf_interactive.rs" [[example]] -name = "cookbook_login" -path = "examples/cookbook_login.rs" +name = "perf_regression" +path = "examples/perf_regression.rs" -[[example]] -name = "cookbook_modal_toast" -path = "examples/cookbook_modal_toast.rs" +# ── Standalone — development tools (3) [[example]] -name = "cookbook_table" -path = "examples/cookbook_table.rs" +name = "debug_selection" +path = "examples/debug_selection.rs" [[example]] -name = "demo_fire" -path = "examples/demo_fire.rs" +name = "test_mouse" +path = "examples/test_mouse.rs" [[example]] -name = "demo_game" -path = "examples/demo_game.rs" +name = "demo_key_test" +path = "examples/demo_key_test.rs" -[[example]] -name = "demo_kitty_image" -path = "examples/demo_kitty_image.rs" +# ── v0.20 non-interactive reports (2) — kept standalone because they +# print to stdout rather than render a TUI, so they don't compose +# into v020_tour. [[example]] -name = "demo_cjk" -path = "examples/demo_cjk.rs" +name = "v020_perf_audit" +path = "examples/v020_perf_audit.rs" [[example]] -name = "demo_overlay_anchor" -path = "examples/demo_overlay_anchor.rs" +name = "v020_test_utils" +path = "examples/v020_test_utils.rs" [[bench]] name = "benchmarks" diff --git a/README.md b/README.md index 5863323..3d90456 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ The same closure runs across several entry points. Pick one based on UI shape, n ```toml [dependencies] -superlighttui = { version = "0.19", features = ["async", "image"] } +superlighttui = { version = "0.20", features = ["async", "image"] } ``` | Feature | What it adds | @@ -258,6 +258,7 @@ For composition advice, see [Patterns Guide]. | [Animation Guide] | Tween, spring, keyframes, sequence, stagger | | [Theming Guide] | Theme struct, presets, ThemeBuilder, custom themes | | [Design Principles] | API constraints and design philosophy | +| [API Design] | Five consistency rules for new widgets and PR review checklist | ## Representative Examples @@ -276,6 +277,46 @@ For composition advice, see [Patterns Guide]. The full categorized index lives in [Examples Guide]. +### v0.20 Demo Catalog + +Run any v020 demo directly: + +| Demo | Issue | Showcases | +|---|---|---| +| `v020_showcase` | (integration) | All v0.20 features on one screen | +| `v020_regression_panel` | (integration) | v0.19 + v0.20 cumulative regression check | +| `v020_dx_shortcuts` | #209/#210/#220/#221 | on_hover, animate_bool, fill, center_in | +| `v020_use_state_keyed` | #215 | Dynamic-string-keyed state | +| `v020_use_effect` | #216 | Dependency-tracked effects | +| `v020_named_focus` | #217 | register_focusable_named, focus_by_name | +| `v020_theme_subtree` | #226 | Per-subtree theme override | +| `v020_modal_trap` | #225 | Modal tab_trap focus containment | +| `v020_spacing_scale` | #227 | compact / comfortable / spacious presets | +| `v020_split_pane` | #223 | split_pane / vsplit_pane with drag handle | +| `v020_gauge` | #224 | gauge / line_gauge builder APIs | +| `v020_gutter_highlights` | #235 | scrollable_with_gutter, GutterOpts | +| `v020_breadcrumb_response` | #213 | Builder breadcrumb API | +| `v020_progress_response` | #212 | progress / spinner returning Response | +| `v020_static_log` | #233 | ui.static_log() append-only scrollback | +| `v020_keymap_help` | #236 | WidgetKeyHelp + auto help overlay | +| `v020_ctrl_c_passthrough` | #238 | RunConfig::handle_ctrl_c opt-out | +| `v020_widthspec` | #237 | WidthSpec / HeightSpec sampler | +| `v020_perf_audit` | #204/205/206/228 | Allocation + timing report (stdout) | +| `v020_test_utils` | #229–232 | record_frames / sequence / snapshot_format / negative asserts (stdout) | + +Or use the launcher script: + +```bash +# Interactive picker +./scripts/ghostty_demos.sh + +# All v0.20 demos at once (each in its own Ghostty window) +./scripts/ghostty_demos.sh --features + +# Just the integration demos +./scripts/ghostty_demos.sh --showcase +``` + ## Custom Widgets And Backends - Implement `Widget` when you want reusable high-level building blocks. @@ -316,6 +357,7 @@ The release process expects format, check, clippy, tests, examples, and backend [Patterns Guide]: docs/PATTERNS.md [Architecture Guide]: docs/ARCHITECTURE.md [Design Principles]: docs/DESIGN_PRINCIPLES.md +[API Design]: docs/API_DESIGN.md [Animation Guide]: docs/ANIMATION.md [Theming Guide]: docs/THEMING.md [Features Guide]: docs/FEATURES.md diff --git a/_typos.toml b/_typos.toml index 3c5f893..16ff661 100644 --- a/_typos.toml +++ b/_typos.toml @@ -9,6 +9,11 @@ "Pn" = "Pn" "flate" = "flate" "flate2" = "flate2" +# Intentional truncation-test prefixes used in tests/widgets.rs to verify +# label-clipping behaviour. They look like typos because they ARE bare +# truncations — that's the point of the assertions. +"Pytho" = "Pytho" +"Memor" = "Memor" [files] extend-exclude = [ diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs index a4498ac..3b11e4f 100644 --- a/benches/benchmarks.rs +++ b/benches/benchmarks.rs @@ -394,7 +394,7 @@ fn bench_flush_full_redraw_200x60(c: &mut Criterion) { let mut group = c.benchmark_group("flush"); group.bench_function("full_redraw_200x60", |b| { let area = Rect::new(0, 0, 200, 60); - let prev = Buffer::empty(area); + let mut prev = Buffer::empty(area); let mut curr = Buffer::empty(area); fill_realistic(&mut curr, 1); // Sanity: make sure we actually have a non-trivial diff workload. @@ -403,10 +403,13 @@ fn bench_flush_full_redraw_200x60(c: &mut Criterion) { let mut sink: Vec = Vec::with_capacity(256 * 1024); b.iter(|| { sink.clear(); - slt::__bench_flush_buffer_diff( + // Issue #171: use the mutable bench entry point so the per-row + // hash refresh is part of the measured cost (matches what + // `Terminal::flush` does in production). + slt::__bench_flush_buffer_diff_mut( &mut sink, - black_box(&curr), - black_box(&prev), + black_box(&mut curr), + black_box(&mut prev), ColorDepth::TrueColor, ) .expect("flush into Vec cannot fail"); @@ -437,10 +440,45 @@ fn bench_flush_sparse_change_200x60(c: &mut Criterion) { let mut sink: Vec = Vec::with_capacity(64 * 1024); b.iter(|| { sink.clear(); - slt::__bench_flush_buffer_diff( + slt::__bench_flush_buffer_diff_mut( + &mut sink, + black_box(&mut curr), + black_box(&mut prev), + ColorDepth::TrueColor, + ) + .expect("flush into Vec cannot fail"); + black_box(sink.len()); + }); + }); + group.finish(); +} + +/// 0%-dirty (static) flush baseline for issue #171's GO/NO-GO decision. +/// +/// Two identical buffers — `flush_buffer_diff` walks every cell, finds no +/// difference, and emits nothing. This is the worst case for the per-cell +/// scan because every cell pays the comparison cost while no output is +/// produced. If this bench stays under 50 µs on 200×60 we do **not** +/// implement the per-row hash skip (issue #171 NO-GO). +#[cfg(feature = "crossterm")] +fn bench_flush_static_200x60(c: &mut Criterion) { + let mut group = c.benchmark_group("flush"); + group.bench_function("static_200x60", |b| { + let area = Rect::new(0, 0, 200, 60); + let mut prev = Buffer::empty(area); + let mut curr = Buffer::empty(area); + fill_realistic(&mut prev, 1); + fill_realistic(&mut curr, 1); + // Sanity: 0% dirty — diff must be empty. + debug_assert!(curr.diff(&prev).is_empty()); + + let mut sink: Vec = Vec::with_capacity(1024); + b.iter(|| { + sink.clear(); + slt::__bench_flush_buffer_diff_mut( &mut sink, - black_box(&curr), - black_box(&prev), + black_box(&mut curr), + black_box(&mut prev), ColorDepth::TrueColor, ) .expect("flush into Vec cannot fail"); @@ -458,6 +496,7 @@ fn bench_flush_group(c: &mut Criterion) { { bench_flush_full_redraw_200x60(c); bench_flush_sparse_change_200x60(c); + bench_flush_static_200x60(c); } let _ = c; } diff --git a/crates/slt-wasm/Cargo.toml b/crates/slt-wasm/Cargo.toml index 1a2019b..e3a9876 100644 --- a/crates/slt-wasm/Cargo.toml +++ b/crates/slt-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slt-wasm" -version = "0.19.0" +version = "0.20.0" edition = "2021" description = "WASM/browser backend for SuperLightTUI" license = "MIT" @@ -9,7 +9,7 @@ homepage = "https://github.com/subinium/SuperLightTUI" documentation = "https://docs.rs/slt-wasm" [dependencies] -superlighttui = { version = "0.19.0", path = "../..", default-features = false } +superlighttui = { version = "0.20.0", path = "../..", default-features = false } wasm-bindgen = "0.2" web-sys = { version = "0.3", features = [ "CssStyleDeclaration", diff --git a/docs/API_DESIGN.md b/docs/API_DESIGN.md new file mode 100644 index 0000000..38a5496 --- /dev/null +++ b/docs/API_DESIGN.md @@ -0,0 +1,337 @@ +# SLT API Design Rules + +These are the consistency rules for adding or changing public API in SLT. +Read this before opening any PR that touches `Context::*`, widget signatures, or `*Response` types. + +Sister docs: +- [DESIGN_PRINCIPLES.md](DESIGN_PRINCIPLES.md) — high-level philosophy +- [WIDGETS.md](WIDGETS.md) — current widget catalog +- [PATTERNS.md](PATTERNS.md) — composition patterns + +--- + +## 1. Why this document exists + +In v0.20 we added `gauge`, `line_gauge`, `breadcrumb`, and `scrollable_with_gutter` in roughly the same week, +each through a different agent. The shapes drifted apart, even though the widgets are conceptually the same kind of thing: + +```rust +// v0.20 reality — four widgets, four signature shapes +ui.gauge(0.6, "60%"); // f32, two positionals +ui.gauge_w(0.6, "60%", 48); // f32, three positionals + suffix variant +ui.gauge_colored(0.6, "60%", 48, Color::Green); // f32, four positionals + another variant +ui.line_gauge(0.6, LineGaugeOpts::default().label("…")); // f32, opts struct (different from above) +ui.breadcrumb(&["a", "b", "c"]); // slice, returns BreadcrumbResponse +ui.scrollable_with_gutter(&mut state, total, vp, gf, f); // five positionals, no opts struct +``` + +When the v0.20 showcase example was first generated by an AI coder, it guessed wrong on every single one +of these widgets. The mistakes were not about logic — they were about which of four plausible shapes +the API actually used. We then audited internal mistakes and external bug reports across v0.18–v0.20: +**roughly 70% of "API guessed wrong" errors traced to inconsistent design between sibling widgets**, not to +actual API complexity. + +The lesson: **consistent APIs reduce both human and AI cognitive load.** A consistent surface lets readers +guess correctly on first read; an inconsistent one forces a doc lookup at every call site. The five rules +below codify the consistency we want going forward. + +--- + +## 2. The Five Rules + +### Rule 1: Builder pattern for widgets with optional fields + +```rust +// Method on Context returns a builder — required args only +impl Context { + pub fn gauge(&mut self, ratio: f64) -> Gauge<'_> { ... } +} + +// Builder methods are chainable, take &mut self -> &mut Self +impl<'a> Gauge<'a> { + pub fn label(&mut self, s: impl Into) -> &mut Self { ... } + pub fn width(&mut self, w: u32) -> &mut Self { ... } + pub fn color(&mut self, c: Color) -> &mut Self { ... } +} + +// Renders on Drop. Use .show() when you need the Response back. +impl Drop for Gauge<'_> { ... } +impl Gauge<'_> { pub fn show(self) -> GaugeResponse { ... } } +``` + +**Why**: matches egui / RataTUI / dioxus mental model. One entry point per widget concept, +optional configuration via chaining. Avoids `gauge_w` / `gauge_colored` / `gauge_with_label` proliferation +where every new optional dimension grows the public surface combinatorially. + +**Example** (good): + +```rust +ui.gauge(0.5).label("CPU").width(48).color(Color::Green); +let r = ui.gauge(0.7).label("Mem").show(); // explicit Response +``` + +**Counter-example** (do NOT do this): + +```rust +ui.gauge_w(0.5, "CPU", 48); // suffix-encoded variant +ui.gauge_colored(0.5, "CPU", Color::Green, 48); // diverging arg order +ui.gauge_label_color(0.5, "CPU", Color::Green); // n^2 explosion as opts grow +``` + +--- + +### Rule 2: Floats are `f64` everywhere + +```rust +pub fn gauge(&mut self, ratio: f64) -> Gauge<'_>; +pub fn slider(&mut self, label: &str, value: &mut f64, range: RangeInclusive) -> Response; +pub fn progress(&mut self, ratio: f64) -> Progress<'_>; +pub fn chart(&mut self) -> ChartBuilder<'_>; // datasets are [(f64, f64)] +``` + +**Why**: +- Avoids precision-loss cliffs at API boundaries (caller has `f64`, API takes `f32`, silent narrowing). +- Matches Rust's default literal type — `0.5` is `f64`, so `ui.gauge(0.5)` just works without `0.5_f32`. +- Consistent with `Duration::as_secs_f64`, `Instant::elapsed`, and the rest of `std`. + +**Example** (good): + +```rust +let cpu: f64 = sys.cpu_usage(); +ui.gauge(cpu / 100.0).label(format!("{cpu:.1}%")); +``` + +**Counter-example** (do NOT do this): + +```rust +pub fn gauge(&mut self, ratio: f32, label: &str) -> GaugeResponse; +// ^^^ caller usually has f64, gets a silent `as f32` cast +ui.gauge(cpu as f32 / 100.0, &format!("{cpu:.1}%")); +``` + +**Exception**: explicit graphics math where `f32` dominates (color blending in `style/color.rs`, +GPU-style normalized math) may use `f32` internally. Public API still takes/returns `f64` and converts at +the boundary — `f32` does not appear in any `pub fn` on `Context`. + +--- + +### Rule 3: Options struct when public function takes 4+ args + +```rust +// 1 arg — positional +pub fn gauge(&mut self, ratio: f64) -> Gauge<'_>; + +// 2 args — positional +pub fn alert(&mut self, level: AlertLevel, body: &str) -> Response; + +// 3 args — positional, OK +pub fn slider(&mut self, label: &str, value: &mut f64, range: RangeInclusive) -> Response; + +// 4+ args — opts struct (or builder) +pub fn scrollable_with_gutter( + &mut self, + state: &mut ScrollState, + opts: GutterOpts, + body: F, +) -> GutterResponse; +``` + +**Why**: positional 5-tuples are unmemorable. Every reader (human or AI) has to look up which `u32` +is width vs height vs row count. Named fields on an opts struct are self-documenting and survive future +additions without breaking the signature. + +**Counter-example** (the v0.20 mistake we are fixing): + +```rust +// 5 positional args — caller has to remember argument order +pub fn scrollable_with_gutter( + &mut self, + state: &mut ScrollState, + total: usize, // is this lines or pixels? + viewport: u32, // height? width? + gutter: G, // before or after body? + body: F, +) -> GutterResponse; +``` + +**Fix**: + +```rust +pub struct GutterOpts { + pub total_lines: usize, + pub viewport_height: u32, + pub gutter: G, +} + +pub fn scrollable_with_gutter( + &mut self, + state: &mut ScrollState, + opts: GutterOpts, + body: F, +) -> GutterResponse; +``` + +The arity boundary is intentionally low: 3 positional args is the comfort threshold for most readers. +If you hit 4, add an opts struct or convert the leaf args to a builder. Do not "just live with" 5 positionals. + +--- + +### Rule 4: Stateful widgets take `&mut StateType`, not `&mut String` / `&mut bool` + +```rust +ui.text_input(&mut TextInputState); // not &mut String +ui.scrollable(&mut ScrollState, ...); // not &mut usize +ui.tabs(&mut TabsState, &[…]); // not &mut usize +ui.list(&mut ListState, &[…]); +ui.tree(&mut TreeState, …); +ui.calendar(&mut CalendarState); +``` + +**Why**: widgets need to remember more than the visible value. Cursor position, selection range, +scroll offset, drag origin, last-clicked index, in-progress IME composition — none of that is the caller's +business. A newtype wrapper hides it. If we take `&mut String`, we either lose the internal state +between frames or hide it in a side table keyed by interaction id (worse: spooky persistence). + +**Acceptable for trivial state**: `slider(value: &mut f64)` is OK because the only thing to remember +is the value itself. The same applies to `checkbox(value: &mut bool)` and `toggle(value: &mut bool)`. +The rule is: **if the widget needs anything beyond the user-visible value, it needs a State newtype.** + +**Counter-example**: + +```rust +// Caller has to manage cursor + selection manually = leaks internal state +pub fn text_input(&mut self, value: &mut String, cursor: &mut usize) -> Response; +``` + +**Fix**: + +```rust +pub struct TextInputState { value: String, cursor: usize, selection: Option>, ... } +pub fn text_input(&mut self, state: &mut TextInputState) -> Response; +``` + +--- + +### Rule 5: Return types — `Response` for one-rect widgets, `XxxResponse` (Deref) for compound + +```rust +// One rect, one set of interactions +pub fn button(&mut self, label: &str) -> Response; +pub fn checkbox(&mut self, label: &str, value: &mut bool) -> Response; + +// Compound: extra data alongside the standard interaction set +#[must_use = "BreadcrumbResponse contains interaction state — check .clicked_segment, .hovered, or .rect"] +pub struct BreadcrumbResponse { + pub response: Response, + pub clicked_segment: Option, +} + +impl Deref for BreadcrumbResponse { + type Target = Response; + fn deref(&self) -> &Response { &self.response } +} +``` + +**Why**: `Response` already covers `clicked / hovered / changed / focused / rect`. A compound widget +adds *more* (which segment clicked, which cell of a grid, current scroll highlight index). Returning a +struct that `Deref`s to `Response` lets callers write `.hovered` and `.rect` exactly like a simple widget, +while still exposing the extra fields. This is the same pattern React uses for components that return +both DOM ref and instance handle. + +**Example** (good): + +```rust +let r = ui.breadcrumb(&["Home", "Settings", "Profile"]); +if r.hovered { /* via Deref */ } +if let Some(idx) = r.clicked_segment { /* compound-specific */ } +``` + +**Counter-example** (do NOT do this): + +```rust +// Returning raw Response loses the extra signal +pub fn breadcrumb(&mut self, segments: &[&str]) -> Response; +// caller now has to track which segment was clicked via mouse coordinates manually + +// Returning a tuple loses naming and Deref ergonomics +pub fn breadcrumb(&mut self, segments: &[&str]) -> (Response, Option); +// .0 / .1 access, no `.hovered` shortcut, no `#[must_use]` enforcement +``` + +The `#[must_use]` attribute on every `*Response` is mandatory — it catches the common bug of dropping +a meaningful response without checking it. + +--- + +## 3. v0.20 Retrospective + +The rules above are not theoretical. They are direct corrections to mistakes shipped in v0.20. + +| Mistake | Symptom | Rule | Fix | +|---|---|---|---| +| `gauge` / `gauge_w` / `gauge_colored` family | Three positional variants for the same widget; AI guessed `gauge_with_label` instead | 1 | Single `gauge(ratio).label().width().color()` builder | +| `f32` for ratios in `gauge`, `progress`, `line_gauge` | Caller had `f64` from `cpu_usage()`, inserted `as f32` casts at every call site | 2 | All public floats are `f64` | +| `scrollable_with_gutter` 5-positional signature | Showcase example used wrong argument order on first attempt | 3 | `GutterOpts` struct | +| `HighlightRange::line` vs `::span` (originally `::single`) | Two constructors with no obvious naming relationship | 1 + naming | Keep `::line` (1-line) and `::span` (n-line); document together | +| Mixed `breadcrumb` / `gauge` return shapes | Some returned `Response`, some returned `(Response, T)`, some returned new types | 5 | All compound widgets return `XxxResponse: Deref` | + +These corrections land in the v0.20 API consistency commit (see `git log --grep="api consistency"` on +`release/v0.20.0`). v0.20 is the last release where the inconsistencies above will appear in the +public surface; v0.21+ enforces all five rules through the PR checklist below. + +--- + +## 4. PR Reviewer Checklist + +Copy-paste this checklist into any PR that adds or changes a public widget: + +```markdown +## API Design Checklist (docs/API_DESIGN.md) + +- [ ] Floats are `f64` (no `f32` in public signature) +- [ ] Public function takes ≤ 3 positional args (otherwise opts struct or builder) +- [ ] Optional configuration uses builder pattern (chainable `&mut self -> &mut Self`) +- [ ] Stateful widget takes `&mut XxxState` newtype (not `&mut String` / `&mut Vec<…>`) +- [ ] Returns `Response` (or `XxxResponse: Deref` for compound widgets) +- [ ] `*Response` struct has `#[must_use = "..."]` attribute +- [ ] Doctest shows idiomatic one-line happy path +- [ ] Naming matches existing widgets (`fn gauge` returns `Gauge<'_>`, not `GaugeBuilder`; opts struct is + `GaugeOpts`, not `GaugeConfig` or `GaugeParams`) +- [ ] No `_w` / `_colored` / `_with_label` suffix variants — fold into builder methods instead +``` + +If a check fails, fix the API before merging. The cost of an inconsistent shape compounds across every +future caller, every future doc reader, and every future AI coder generating SLT code from examples. + +--- + +## 5. References + +- [egui `Widget` pattern](https://docs.rs/egui) — `add(impl Widget)` returns `Response`, builders + configure widgets before `add`. Direct ancestor of SLT's builder shape. +- [ratatui `Widget` / `StatefulWidget`](https://docs.rs/ratatui) — splits stateless from stateful + widgets via two traits; SLT collapses both into `Context::*` methods but inherits the + state-newtype convention. +- React component composition — compound components return both a primary handle and additional refs; + SLT's `XxxResponse: Deref` is the closest type-level analog. +- Anthropic API legibility guidance for AI coders — consistent surfaces are predicted correctly on + first attempt; inconsistent surfaces require disambiguation passes against documentation. See the + v0.20 retrospective above for concrete numbers. +- OpenAI / Cursor coding-agent benchmarks confirm the same trend: per-symbol guess accuracy is + dominated by sibling-widget consistency, not by surface size. + +--- + +## 6. When These Rules Conflict + +If two rules conflict in a specific case, follow this priority: + +1. **Rule 5** (Response shape) — never break the calling convention readers already know +2. **Rule 4** (state newtype) — encapsulation beats convenience +3. **Rule 2** (`f64`) — precision-loss is silent and hard to debug +4. **Rule 3** (opts struct at 4+ args) — readability beats keystroke savings +5. **Rule 1** (builder over suffix variants) — last because the cost is taste, not correctness + +If an existing widget violates a rule for legacy reasons, deprecate-and-replace; do not add a new +inconsistency to "match the existing one." The whole point of this document is that consistency +is the load-bearing property — preserving a bad pattern multiplies its cost. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4a5a803..e6d9dd0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,7 +1,10 @@ # SLT Architecture -This document describes how the code is organized and how data flows through the system. -For design philosophy and conventions, see [Design Principles](DESIGN_PRINCIPLES.md). +This document describes how the code is organized and how data flows +through the system. It is the **Macro tier** of SLT's convention stack — +see [`DESIGN_PRINCIPLES.md`](DESIGN_PRINCIPLES.md) §4 for the full +4-tier map. For naming and signature conventions, see +[`NAMING.md`](NAMING.md) and [`API_DESIGN.md`](API_DESIGN.md). Related docs: - [QUICK_START.md](QUICK_START.md) @@ -14,6 +17,174 @@ Related docs: --- +## The 5 Layers + +Every public method belongs to exactly one of five layers. The layer +determines the file the method lives in, what it can mutate, and what +shape it returns. + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1. Context (frame state + events) │ +│ └── 2. ContainerBuilder (layout + style chain) │ +│ └── 3. Widget (text, button, gauge, ...) │ +│ └── 4. State (XxxState; persists) │ +│ └── 5. Response (XxxResponse: Deref) │ +└─────────────────────────────────────────────────────────┘ +``` + +The arrow is "calls into" — Context creates ContainerBuilders, +ContainerBuilders host Widgets, Widgets read & write State, Widgets +return Response. + +### Layer 1: Context + +**Owns**: frame buffer, hooks (`use_state`, `use_effect`), focus state, +event queue, theme + spacing, command list, modal stack, name maps. + +**Methods belong here when**: they affect frame-level state (not visual +output) or they orchestrate (focus, quit, notify). + +**Source**: `src/context/core.rs`, `src/context/runtime.rs`. + +**Examples**: + +```rust +ui.quit(); +ui.notify("saved", ToastLevel::Success); +ui.register_focusable_named("search"); +ui.focus_by_name("search"); +let count = ui.use_state(|| 0); +let theme = ui.theme(); +``` + +### Layer 2: ContainerBuilder + +**Owns**: layout configuration (col/row/line direction, gap, grow), +styling (border, padding, background, foreground), per-subtree theme +override, alignment. + +**Lifecycle**: created via `ui.container()` or shortcut entry points +(`ui.bordered(B)`, `ui.col(...)`, `ui.row(...)`, `ui.row_gap(g, ...)`), +mutated via chained methods, finalized by `.col(closure)` / +`.row(closure)` / `.line(closure)` / `Drop`. + +**Source**: `src/context/container.rs`. + +**Examples**: + +```rust +ui.container().border(Border::Rounded).p(2).gap(1).grow(1).col(|ui| { /* ... */ }); +ui.bordered(Border::Single).title("Settings").p(1).col(|ui| { /* ... */ }); +ui.container().theme(Theme::dark()).fill().col(|ui| { /* ... */ }); +``` + +### Layer 3: Widget + +**Owns**: rendering primitives — text, buttons, gauges, tables, charts, +inputs. + +**Source**: `src/context/widgets_*/*.rs`. The directory encodes family: +- `widgets_display/` — text, alerts, badges, code blocks +- `widgets_input/` — text input, textarea, sliders, spinners +- `widgets_interactive/` — tables, lists, tabs, palettes +- `widgets_viz/` — charts, sparklines, heatmaps + +**Stateless widgets** take primitive args: +```rust +ui.text("hello"); +ui.gauge(0.6).label("60%").width(24); +ui.button("Save"); +``` + +**Stateful widgets** take `&mut State`: +```rust +let mut input = TextInputState::default(); +ui.text_input(&mut input); +``` + +### Layer 4: State + +**Owns**: persistent per-widget state. + +**Pattern**: `pub struct State { ... }` with `Default` impl. Field +visibility is `pub` for trivial fields and `pub(crate)` for invariants. + +**Source**: `src/widgets/*.rs` (grouped by family). + +**Examples**: `TextInputState`, `TextareaState`, `TabsState`, +`ScrollState`, `SplitPaneState`, `TreeState`. + +### Layer 5: Response + +**Owns**: interaction results. + +**Pattern**: every interactive widget returns `Response` or a compound +`Response: Deref`. + +**Source**: `src/widgets/responses.rs` (compound types). + +**Why Deref**: callers can write `r.hovered`, `r.rect` regardless of +whether `r` is `Response` or `BreadcrumbResponse`. The compound fields +(`r.clicked_segment`) are accessed normally. + +--- + +## Layer Cross-Cutting Rules + +### M1 — One method, one layer + +A method must not exist on more than one layer with the same name and +similar semantics. Where SLT currently has duplicates, the rule is +documented but not yet enforced. + +**Currently allowed (documented exceptions)**: + +| Name | Context | Builder | Resolution | +|------|---------|---------|------------| +| `text` | unbordered shortcut | inside-builder form | both keep | +| `theme` | getter | per-subtree override | both keep (different semantics) | + +**Currently disallowed (planned removal in v0.22)**: + +| Name | Context | Builder | +|------|---------|---------| +| `bordered` (shortcut) vs `container().border()` (explicit) | shortcut wins | explicit deprecated | + +### M2 — Composition, not inheritance + +A widget that wants container-like behaviour uses `ContainerBuilder` via +`ui.container()`, not by re-implementing layout. Compound widgets like +`code_block` internally call `self.bordered(...).col(|ui| ...)`. + +### M3 — State is `&mut` always + +No widget mutates frame state through `&self`. State changes go through +the `&mut Context` parameter or `&mut State`. + +### M4 — Response is the only return shape + +Interactive widgets return `Response` or a compound `Response: Deref`. +Stateless rendering returns `Response::none()` — no `()` returns. + +--- + +## Adding a new widget — checklist + +1. **Family**: display, input, interactive, or viz? +2. **Stateful**: needs persistence? If yes, create `State` in + `src/widgets/.rs`. +3. **Return shape**: simple `Response` or compound `Response`? +4. **Builder vs immediate**: see [API_DESIGN.md](API_DESIGN.md) rule 1 + (builder when ≥4 optional fields). +5. **Implement** in `src/context/widgets_/.rs`. +6. **Document** per [RUSTDOC_GUIDE.md](RUSTDOC_GUIDE.md) — 4-part + docstring with at least one runnable example. +7. **Audit**: run `scripts/api_audit.sh`. Update DESIGN_PRINCIPLES.md + matrix if the new widget changes a cell's status. + +--- + ## Module Map ``` diff --git a/docs/DEMO_GUIDE.md b/docs/DEMO_GUIDE.md new file mode 100644 index 0000000..c90076f --- /dev/null +++ b/docs/DEMO_GUIDE.md @@ -0,0 +1,396 @@ +# SLT Demo Guide + +> Companion to [`DESIGN_PRINCIPLES.md`](./DESIGN_PRINCIPLES.md). Not a tier +> doc — this is a **practitioner manual** for the people writing +> `examples/*.rs`. Every rule here exists because a real demo bug shipped +> when the rule was implicit. The "Why" line cites the bug. + +If you're touching `examples/v020_*.rs`, `examples/cookbook_*.rs`, or +adding a new `demo_*.rs`, read this first. The audit checks +(`scripts/api_audit.sh` V5-V7) catch the mechanical violations; the +human-judgment items (sample widget policy, intent labeling) are +reviewer responsibility. + +--- + +## Document map + +``` +1. The 4 demo archetypes ← decide which one your demo is +2. Render-function contract ← signature, state ownership +3. Outer-container policy ← grow / fill rules +4. Key handling ← quit keys, modal-aware paths +5. Composition rules ← demos that nest into other demos +6. Sample-widget labeling ← when you stuff helpers in for show +7. Title and visible text ← character set, length +8. Snapshot-vs-live render ← when both are needed +9. macOS quirks ← Ctrl-C, mouse, scrollback +10. Pre-merge checklist +``` + +--- + +## 1. The 4 demo archetypes + +Every demo falls into one. Picking the right archetype is the single +biggest layout decision; mismatches cause the bugs §3-§5 prevent. + +| Archetype | Owns frame buffer? | Owns scrollback? | Has overlay? | Example | +|-----------|--------------------|--------------------|--------------|---------| +| **Standard** | yes (full canvas) | no | no | `v020_named_focus`, `v020_split_pane` | +| **Overlay-first** | yes | no | yes (centered modal/help) | `v020_modal_trap`, `v020_keymap_help`, `v020_dx_shortcuts` | +| **Scrollback** | partial (inline) | yes (append-only) | no | `v020_static_log` | +| **System** | no (passes through) | no | no | `v020_perf_audit`, `v020_test_utils` (non-interactive reports) | + +**Why this matters**: the v0.20 tour combined demos from different +archetypes into a 2x2 grid and broke them all (overlays covered +neighbours, scrollback corrupted the bordered frame, focus widgets +fought for keys). Combining demos works only within the same archetype +— see §5. + +--- + +## 2. Render-function contract + +Every demo exposes one of these two signatures and exactly one of them: + +```rust +// Stateless — for snapshot tests AND live runs +pub fn render(ui: &mut Context) + +// Stateful — caller owns persistent state +pub fn render(ui: &mut Context, state: &mut DemoState) +``` + +If your demo holds state that must persist across frames (counters, +toggles, modal open/closed, input value), **the second form is +mandatory**. The first form's caller has no place to keep state, so any +mutation gets thrown away on the next call. + +**Why**: v0.20's `v020_modal_trap` shipped with the stateless signature +even though it had a `show_modal` flag — every frame reset the modal to +"open", swallowing every Yes/No click. This was caught only when the +tour embedded the demo and tried to use it for real. + +**Snapshot fix**: if you also want a one-shot frame for snapshot tests +(e.g. "render the modal-open variant for the test image"), expose a +**second** function: + +```rust +// Snapshot-only. Constructs a fresh state internally, renders one frame. +// NEVER call this from a live loop or from another demo — clicks are +// silently dropped because state never persists. +pub fn render_snapshot(ui: &mut Context) { + let mut state = DemoState { /* deterministic fixture */ }; + body(ui, &mut state); +} +``` + +The two functions share a private `body(ui, state)` helper. The live +`main()` and any tour-embedding caller use `render`; snapshot tests use +`render_snapshot`. + +--- + +## 3. Outer-container policy + +The outermost container (`bordered`, `container().col`, etc.) at the +top of `render()` must have `.grow(1)` or `.fill()` **unless** the demo +is intentionally letting content flow only as wide as it needs. + +```rust +// good +let _ = ui + .bordered(Border::Rounded) + .title("…") + .p(pad) + .grow(1) // ← this + .col(|ui| { … }); + +// bad — outer box only fills its content's natural size, leaving the +// rest of the terminal blank. Inputs inside flex rows shrink to 1 cell. +let _ = ui.bordered(Border::Rounded).title("…").p(pad).col(|ui| { … }); +``` + +**Why**: v0.20 `v020_named_focus` shipped without `.grow(1)`; the input +boxes were 1 cell wide because a flex row with no parent grow gives its +children only their natural minimum width. The bug was visible +instantly on launch. + +**Stateful inputs in a row need a wrapping container too**: when you +put a `text_input(&mut s)` inside a `row_gap(...)` next to a label, the +input's natural width is the placeholder length (often 1 cell). Wrap +the input in `container().fill().col(|ui| ui.text_input(&mut s))` so it +claims the row's remaining width. + +```rust +// good — input fills row's remaining width +let _ = ui.row_gap(gap, |ui| { + ui.text("Name: "); + let _ = ui.register_focusable_named("name"); + let r = ui.container().fill().col(|ui| { + let _ = ui.text_input(&mut state.name); + }); + if r.clicked { + let _ = ui.focus_by_name("name"); + } +}); +``` + +The wrapping container is also where you read `r.clicked` if you want +the whole input area to focus on click — the parent row's `clicked` +gets shadowed by the wrapping container's hit area (this surprised the +v0.20 author and produced the second iteration of the named_focus bug). + +--- + +## 4. Key handling + +### Quit keys + +Every demo binds quit to the **same triple**: `q`, `Esc`, `Ctrl-Q`. +**Never bind `Ctrl-C`** — it's bound to "Copy" by default in Ghostty, +iTerm2, and Terminal.app on macOS, so the keystroke never reaches the +app reliably. + +```rust +if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); +} +``` + +### Modal-aware paths + +When a demo opens a modal, the modal's Esc-to-dismiss must take +precedence over Esc-to-quit. Two options: + +```rust +// Option A: gate the quit on !show_modal +if !state.show_modal && (ui.key('q') || ui.key_code(KeyCode::Esc) || …) { + ui.quit(); +} +if state.show_modal && ui.raw_key_code(KeyCode::Esc) { + state.show_modal = false; +} +``` + +```rust +// Option B: use raw_key_code in both branches and let the modal +// consume Esc first (raw_key_code bypasses the focus-filter modal +// guard so it works even when a modal button has focus) +``` + +**Why**: v0.20 tour ate Esc at the top-level dispatcher, leaving +`v020_modal_trap`'s confirm modal undismissable. + +### Conflict avoidance + +If your demo binds keys outside the standard quit triple +(e.g. `Space`, digits, `?`, letters), document them at the top of +the file in the `//!` doc comment and **don't pick keys that other +demos in the same archetype use** — Tab is reserved for focus +cycling, Left/Right are reserved for tabs widgets, `?` is the +keymap-help convention. + +--- + +## 5. Composition rules + +A "composition" is a demo that embeds other demos — the prime example +is `v020_tour.rs`, which dispatches to one of N feature demos by tab. + +### Rule C1: one archetype per tab/cell + +Don't combine an Overlay-first demo with a Scrollback demo with a +Standard demo into one screen. Each archetype claims a different +resource (overlay z-order, scrollback rows, frame buffer +geometry); collisions are visible immediately and not always graceful. + +The v0.20 tour originally had a 2x2 "Util" grid combining +`keymap_help` (Overlay-first), `static_log` (Scrollback), +`ctrl_c_passthrough` (Standard, fullscreen-keyed), +`dx_shortcuts` (Overlay-first). Result: overlays covered each other, +the log appended unboundedly into the bordered frame, key handlers +fought. The fix was 4 separate tabs. + +### Rule C2: scrollback demos can't be re-embedded + +`v020_static_log` calls `ui.static_log(line)` which writes to the +terminal scrollback. In a single-binary live run that's fine — the +line lands above the inline buffer. In a composing demo (tour), every +frame now appends a new line, scrolling the bordered frame off the top +of the screen. + +If you need to surface a scrollback demo inside a tour, render a +**description page** with a code snippet and a "run standalone" +pointer instead of calling the actual scrollback API. See +`v020_tour.rs::render_log` for the pattern. + +### Rule C3: state passes through, not state resets + +A composing demo holds the embedded demo's state in its own state +struct: + +```rust +struct TourState { + tabs: TabsState, + modal: modal_trap::State, // owned by tour, passed to embedded render + use_state_keyed: use_state_keyed::DemoState, + // … +} +``` + +Never construct embedded demo state inside the composing demo's render +closure — it gets reset every frame. (See §2 "Snapshot fix" for the +underlying reason.) + +--- + +## 6. Sample-widget labeling + +When a demo embeds widgets purely for visual showcase (a `help` bar +showing keybinds, a button labeled "Click me", a code block of +`fn main()`), label each one with the issue number it demonstrates. +Otherwise viewers can't tell which widget is the *point* and which is +the *backdrop*. + +```rust +// good — viewer maps each visible element to a v0.20 issue +ui.text("(buttons here demo #209 on_hover)").dim().fg(Color::Cyan); +let _ = ui.button("Save").on_hover(ui, "…"); + +ui.text(format!("#210 panel_alpha = {alpha:.2}")).dim(); +``` + +**Why**: v0.20 `v020_dx_shortcuts` packed four DX helpers (#209, #210, +#220, #221) onto one screen with no per-helper labels. Reviewers asked +"what's this demo showing?" — answer required reading the source. + +**Anti-pattern**: a `ui.help(&[("Tab", "next"), ("Enter", "ok"), +("Esc", "cancel")])` row stuffed into a layout demo to "fill space". +The viewer reasonably interprets the help bar as the demo's actual +keybindings, even when none of those keys do anything in that demo. +Drop it or label it `"(help bar — sample widget, keys are decorative)"`. + +--- + +## 7. Title and visible text + +### Title character set + +`.title("...")` strings on bordered containers must be **BMP ASCII +only**. No em-dash, en-dash, ideographic chars, smart quotes, or any +codepoint above U+007F unless the demo is *specifically* exercising +wide-character handling. + +```rust +// good +.title("SLT v0.20: Density presets") + +// bad — em-dash counts as 1 col in `unicode-width` but 2 cols when +// rendered in some terminals, breaking border alignment +.title("SLT v0.20 — Density presets") +``` + +**Why**: v0.20 demo polish caught this for ~10 demos. The +`scripts/api_audit.sh` V7 check now flags it automatically. + +### Body text length + +Help banners and instructional text inside the demo body should fit +the demo's intended minimum width (typically 80 cols). Long sentences +that wrap mid-instruction look like artifacts. If you need a long +explanation, use `.line_wrap(...)` so the wrap is intentional. + +--- + +## 8. Snapshot-vs-live render + +If your demo has both a snapshot test (`tests/v020_*_demo.rs`) and a +live binary, use the §2 split: + +```rust +fn body(ui: &mut Context, state: &mut DemoState) { /* shared */ } + +pub fn render(ui: &mut Context, state: &mut DemoState) { + handle_input(ui, state); + body(ui, state); +} + +pub fn render_snapshot(ui: &mut Context) { + let mut state = deterministic_fixture(); + body(ui, &mut state); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + slt::run_with(RunConfig::default().mouse(true), move |ui| { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + render(ui, &mut state); + }) +} +``` + +`render_snapshot` constructs the fixture and calls `body` directly, +bypassing input handling. Snapshot tests call `render_snapshot`; live +binary uses `render`. + +--- + +## 9. macOS quirks + +- **Ctrl-C is bound to "Copy"** in Ghostty, iTerm2, and Terminal.app + by default. Don't rely on it as a quit key. See §4. +- **Mouse capture**: every interactive demo uses + `slt::run_with(RunConfig::default().mouse(true), …)`. Without this, + click events never reach the app and demos appear "broken". +- **Scrollback in alternate screen**: full-screen demos (default + `slt::run`) use the alternate screen, where scrollback is isolated + from the user's shell. `static_log` only makes sense in inline mode + (`InlineTerminal`); don't add it to alternate-screen demos. + +--- + +## 10. Pre-merge checklist + +Before submitting a demo (new or modified): + +- [ ] Archetype declared (one of §1) — comment in the file header. +- [ ] Render signature matches §2. +- [ ] Outer container has `.grow(1)` or `.fill()` (or §3 explains why + not). +- [ ] Quit keys are exactly `q` / `Esc` / `Ctrl-Q` (§4). +- [ ] Title characters are BMP ASCII (§7). +- [ ] If composing other demos, follows §5 rules C1-C3. +- [ ] If sample widgets are present, each is labeled or dim-captioned + (§6). +- [ ] `cargo run --example ` actually exercises the intended + interaction without manual prodding. +- [ ] `scripts/api_audit.sh --strict` exits 0. +- [ ] `cargo test --all-features` passes. +- [ ] (When the v0.21 visual-regression fixtures land:) at least one + assertion in `tests/v020_interaction_regression.rs` covers the + demo's headline interaction. + +--- + +## Appendix: bug → rule mapping + +Every rule in this document was added because a real bug shipped or +nearly shipped. Use this table to navigate from "I'm hitting bug X" +back to "the rule that prevents X". + +| Bug | Section | Rule | +|-----|---------|------| +| Input shrinks to 1 cell | §3 | Outer container needs `.grow(1)` | +| Click on row doesn't focus input | §3 | Wrap input in `container().fill()`, read `clicked` from THAT | +| Modal Yes/No reset every frame | §2 | Stateful demos need `(ui, &mut state)` signature | +| Esc closes app instead of modal | §4 | Modal-aware paths use `raw_key_code` + `!show_modal` gate | +| Tokens stack vertically | (audit V6) | Fallback path must mirror primary's container nesting | +| Border misaligned around title | §7 | BMP ASCII titles only | +| `register_focusable_named("X")` doesn't focus the next widget | (lib fix) | Library now uses deferred-attach; demo pattern unchanged | +| Tour overlays cover other cells | §5 C1 | One archetype per cell | +| Tour log appends unboundedly | §5 C2 | Scrollback demos render description page when embedded | +| Demo helpers are visually indistinguishable | §6 | Label sample widgets with `dim().fg(Cyan)` captions | diff --git a/docs/DESIGN_PRINCIPLES.md b/docs/DESIGN_PRINCIPLES.md index 89bc089..f601367 100644 --- a/docs/DESIGN_PRINCIPLES.md +++ b/docs/DESIGN_PRINCIPLES.md @@ -1,20 +1,211 @@ # SLT Design Principles -These principles guide every design decision in SLT. -Read this before contributing code. If a decision conflicts with these principles, raise it in the PR. +These principles guide every design decision in SLT. Read this before +contributing code. If a decision conflicts with these principles, raise +it in the PR. + +This is a **living document**. Every PR that adds, removes, or changes a +public API must: + +1. Identify which principles the change touches. +2. Identify which tiers are affected (Macro / Meso / Micro / Detail). +3. Update the [Audit Matrix](#audit-matrix-v020) if a cell's status changes. +4. Run `scripts/api_audit.sh` and address any V1 / V2 / V4 flags. Related docs: - [QUICK_START.md](QUICK_START.md) - [WIDGETS.md](WIDGETS.md) - [PATTERNS.md](PATTERNS.md) -- [ARCHITECTURE.md](ARCHITECTURE.md) +- [ARCHITECTURE.md](ARCHITECTURE.md) — Macro tier (layers, modules) +- [API_DESIGN.md](API_DESIGN.md) — Meso tier (signatures) +- [NAMING.md](NAMING.md) — Micro tier (identifiers) +- [RUSTDOC_GUIDE.md](RUSTDOC_GUIDE.md) — Detail tier (rustdoc) + +--- + +## Document Map + +This document is layered. Read top-to-bottom on first pass; jump to +specific sections on later visits. + +``` +1. The North Star — Predictability ← why every other rule exists +2. Meta-Principles (P1–P7) ← how to evaluate a design choice +3. Concrete Rules (R1–R10) ← what the choices have settled into +4. The 4-Tier Convention Stack ← where rules live +5. Audit Matrix (v0.20) ← where SLT is/isn't compliant today +6. Roadmap (v0.20 → v1.0) ← when each cell becomes green +7. Failure Mode Catalog ← real bugs, classified +8. How to Use This Document ← reviewer / contributor workflow +``` + +--- + +## 1. The North Star — Predictability + +**A developer (or AI assistant) reading SLT for the first time should be +able to predict the next method call without checking the docs.** If they +can't, the API is broken — fix the API, not the reader. + +ratatui is predictable because every widget is a struct with `default()` +plus chained setters. egui is predictable because every interaction is +`ui.(args)` and every method returns the same kind of `Response`. +SLT mixes both styles — more **expressive**, but only **predictable** when +the mixing rules are themselves predictable. + +Two failure modes to watch for: + +- **Surprise on next-call**: a developer who just wrote `ui.gauge(0.6)` + cannot predict whether the label argument goes inside `gauge(...)` or + chains as `.label(...)`. (v0.20 answer: chains. We unified to builder + in #224.) +- **Surprise on next-widget**: a developer who learned + `ui.text_input(&mut state)` cannot predict that `ui.gauge(0.6).label(...)` + chains instead. Different widget family, different shape — that's + allowed, but the *family boundary* must be obvious from the call. + +Predictability is what makes SLT viable as a library: +- For humans: lower memory tax → faster iteration → more shipped TUIs. +- For AI assistants: training-data-style guesses succeed → AI-built + TUIs work on first try. +- For the project: every kept-promise is documentation that doesn't + need writing. + +--- + +## 2. Meta-Principles (P1–P7) + +These seven principles govern *how* design decisions are made. They are +the criteria a reviewer applies when judging a new API. They are not +project-specific (any TUI library could adopt them); the project-specific +results are codified in §3 [Concrete Rules](#3-concrete-rules-r1r10). + +### P1 — Predictability over Cleverness + +**Rule**: choose the boring shape that matches existing widgets, even if a +clever shorthand exists. + +**Why**: every clever shorthand is a memory tax. SLT has 70+ public +methods; users remember patterns, not individual calls. + +**Apply when**: introducing a new widget, alias, shortcut, or convenience. + +**Anti-pattern (caught in v0.20)**: `gauge_w(ratio, w)` and +`gauge_colored(ratio, c)` introduced as shortcuts. Same-release +deprecation: removed entirely, replaced by the builder +`gauge(ratio).width(w).color(c)`. See [F1](#f1--same-release-deprecation-p1--meso). + +### P2 — Layer Discipline + +**Rule**: every method belongs to exactly one of the 5 layers +(Context / ContainerBuilder / Widget / State / Response). When the layer +is ambiguous, document why and resolve in the next minor version. + +**Why**: when the same call exists on multiple layers, callers stop +predicting. Two-path APIs are the single biggest source of +"AI guesses wrong" failures. + +**Apply when**: a method shows up in `Context::method` and +`ContainerBuilder::method`. Resolve to one. + +**Anti-pattern (currently documented, scheduled for v0.22)**: +`ui.bordered(B)` (shortcut) and `ui.container().border(B)` (explicit) +both work. Documented in [`ARCHITECTURE.md`](./ARCHITECTURE.md) but the +rule isn't yet enforced. + +### P3 — Naming as Contract + +**Rule**: a method's name encodes its category — verbs for actions, nouns +for getters, adjectives for builder modifiers. No abbreviations except +universal ones (`bg`, `fg`, `id`, `idx`, `len`, `min`, `max`, `pos`, +`pct`, `w`, `h`, `x`, `y`). + +**Why**: naming is a memory-cheap contract. If `register_focusable_named` +is a long verb but `bordered` is a short adjective, the user has to +memorize each one. If both follow the same shape, one call teaches the next. + +**Apply when**: introducing a new method or renaming an existing one. +See [`NAMING.md`](./NAMING.md) for category-by-category specifics. + +**Anti-pattern**: mixing verbs and noun-as-verbs in the same family — +`ui.styled(text, style)` (verb) sits next to `ui.text(s)` +(noun-as-verb). Both are valid; not predictable from each other. + +### P4 — Immediate-Mode Honesty + +**Rule**: state lives in the caller. Widgets that need persistence take +`&mut State`. No hidden global state, no implicit +`Rc>` inside the library. + +**Why**: SLT advertises immediate-mode (see [R4 — State Ownership](#r4--state-ownership)). +Hidden state breaks the mental model and makes testing harder. + +**Apply when**: implementing a stateful widget. The state struct must be +public, `Default`-able where possible, and named `State`. + +**Anti-pattern**: a widget that lazily allocates an internal `static` or +`thread_local` cache. The cache should be either a `&mut` parameter or a +field on `Context::frame_state` with explicit lifecycle. + +### P5 — Composability First + +**Rule**: containers compose. Every widget that holds children does so +via the standard builder mechanism (`ContainerBuilder::col`, `::row`, +`::line`), not bespoke "parent" parameters. + +**Why**: composition is what lets users build their own widgets. Bespoke +parent params force users to learn a per-widget customization model. + +**Apply when**: building a new container-like widget. + +**Anti-pattern**: a `panel(title, body, footer)` function with three +closures. Replace with `panel(title)` + chained `.body(...)` / +`.footer(...)`, or compose standard containers. + +### P6 — Visible Failure Modes + +**Rule**: failure must be loud. Wrong types → compile error. Wrong values +→ panic in debug, warn in release. Silent fallbacks are themselves the bug. + +**Why**: silent fallbacks are how the v0.20 spacing_scale demo shipped +with a broken syntax-highlight render path: the `code_block_lang(code, "")` +fallback omitted `ui.line(...)` wrapping and stacked tokens vertically. +A panic, warn, or structural lint would have caught this. + +**Apply when**: writing a fallback path. Decide explicitly: panic +(debug), `slt_warn!(...)` (release), structural-equivalence test, or +compile error. + +**Anti-pattern (caught in v0.20)**: see [F2](#f2--silent-fallback-divergence-p6--macro). + +### P7 — AI-Readable Documentation + +**Rule**: every public method has a one-paragraph doc + at least one +runnable example. Examples must compile (`cargo doc --no-deps`). + +**Why**: SLT users include AI assistants. Method-level rustdoc is what +they read. If `register_focusable_named`'s docstring doesn't show the +exact pattern (register first, render widget after), the AI won't infer it. + +**Apply when**: any new `pub fn`, `pub struct`, `pub enum`. Missing +rustdoc is a clippy warning (`-W missing_docs`). + +**Anti-pattern**: `#[allow(missing_docs)]` on a public type. Either it's +internal (make it `pub(crate)`) or it deserves a docstring. --- -## 1. Ease of Use Above All +## 3. Concrete Rules (R1–R10) + +These rules are the project-specific outcomes of applying the +meta-principles. They are non-negotiable in current-version SLT — a PR +that violates any of these without explicit waiver is rejected. + +### R1 — Ease of Use Above All SLT exists so that building a TUI is as easy as building a web page. -Every API decision is judged by: **"Can a developer use this correctly on the first try, without reading the docs?"** +Every API decision is judged by: **"Can a developer use this correctly +on the first try, without reading the docs?"** ```rust // 5 lines. No App struct. No Model/Update/View. No event loop. @@ -27,11 +218,12 @@ fn main() -> std::io::Result<()> { If an API requires explanation, the API is wrong — not the developer. ---- +(Operationalizes [P1 — Predictability over Cleverness](#p1--predictability-over-cleverness).) -## 2. Your Closure IS the App +### R2 — Your Closure IS the App -SLT is an **immediate-mode** UI library. There is no framework state to manage. +SLT is an **immediate-mode** UI library. There is no framework state to +manage. - You write a closure. SLT calls it every frame. - State lives in YOUR code — variables, structs, whatever you want. @@ -46,57 +238,62 @@ slt::run(|ui| { }); ``` -This is the foundational decision. Every other principle flows from it. +This is the foundational decision. Every other rule flows from it. ---- +(Operationalizes [P4 — Immediate-Mode Honesty](#p4--immediate-mode-honesty).) -## 3. Widget Contract +### R3 — Widget Contract -Every widget should fit one of a very small number of patterns. Prefer consistency over cleverness. +Every widget should fit one of a very small number of patterns. Prefer +consistency over cleverness. -### Interactive Widgets +#### Interactive widgets ```rust pub fn widget_name(&mut self, state: &mut WidgetState) -> Response ``` -- Return `Response` — contains `clicked`, `hovered`, `changed`, `focused`, `rect` -- Call `register_focusable()` for keyboard navigation -- Consume handled key events (don't let them bubble) -- Use `self.theme.*` for default colors — never hardcode +- Return `Response` — contains `clicked`, `hovered`, `changed`, + `focused`, `rect`. +- Call `register_focusable()` for keyboard navigation. +- Consume handled key events (don't let them bubble). +- Use `self.theme.*` for default colors — never hardcode. -### Display Widgets +#### Display widgets ```rust pub fn text(&mut self, content: impl Into) -> &mut Self ``` -- Return `&mut Self` for chaining (`.bold().fg(Color::Cyan)`) -- No focusable registration -- No event consumption +- Return `&mut Self` for chaining (`.bold().fg(Color::Cyan)`). +- No focusable registration. +- No event consumption. -### Containers +#### Containers ```rust pub fn col(self, f: impl FnOnce(&mut Context)) -> Response ``` -- `ContainerBuilder` uses consuming `self` pattern (builder is done after `.col()`/`.row()`) -- Return `Response` for interaction detection +- `ContainerBuilder` uses consuming `self` pattern (builder is done after + `.col()` / `.row()`). +- Return `Response` for interaction detection. -### State Structs +#### State structs -- Live in `widgets.rs` — e.g., `TextInputState`, `TableState` -- Named `{Widget}State` -- Implement `Default` when sensible -- Re-exported from `lib.rs` +- Live in `widgets.rs` — e.g. `TextInputState`, `TableState`. +- Named `{Widget}State`. +- Implement `Default` when sensible. +- Re-exported from `lib.rs`. ---- +(Operationalizes [P3 — Naming as Contract](#p3--naming-as-contract) and +[P2 — Layer Discipline](#p2--layer-discipline). Detailed signature rules +in [API_DESIGN.md](./API_DESIGN.md).) -## 4. Layout = CSS Flexbox, Syntax = Tailwind +### R4 — Layout = CSS Flexbox, Syntax = Tailwind -Layout uses flexbox semantics: `row()`, `col()`, `gap()`, `grow()`, `spacer()`. -Styling uses Tailwind-inspired shorthand: +Layout uses flexbox semantics: `row()`, `col()`, `gap()`, `grow()`, +`spacer()`. Styling uses Tailwind-inspired shorthand: | Full name | Shorthand | |-----------|-----------| @@ -107,12 +304,12 @@ Styling uses Tailwind-inspired shorthand: Both forms are always available. Shorthand is preferred in examples. -### Responsive Breakpoints +#### Responsive breakpoints Prefix with breakpoint: `.md_w(40)`, `.lg_p(2)`, `.xl_gap(3)`. -Breakpoints: Xs (<40), Sm (40-79), Md (80-119), Lg (120-159), Xl (>=160). +Breakpoints: Xs (<40), Sm (40–79), Md (80–119), Lg (120–159), Xl (≥160). -### Builder Patterns +#### Builder patterns | Builder | Pattern | Why | |---------|---------|-----| @@ -120,9 +317,7 @@ Breakpoints: Xs (<40), Sm (40-79), Md (80-119), Lg (120-159), Xl (>=160). | `Style` | Consuming `mut self` | Chainable, zero-cost | | `ChartBuilder` | Mutable `&mut self` | Historical — scheduled for unification in v1.0 | ---- - -## 5. State Ownership +### R5 — State Ownership | State type | Owner | Example | |------------|-------|---------| @@ -130,16 +325,24 @@ Breakpoints: Xs (<40), Sm (40-79), Md (80-119), Lg (120-159), Xl (>=160). | Component-local state | Hook system | `ui.use_state(|| 0)` | | Widget state | User | `let mut input = TextInputState::new()` | -### Hook Rules (same as React) +#### Hook rules (same as React) -- `use_state()` and `use_memo()` must be called in the **same order** every frame -- 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 +- `use_state()` and `use_memo()` must be called in the **same order** + every frame. +- 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. +- v0.20.0 added `use_state_keyed(impl Into, init)` that accepts + runtime-computed keys (e.g. `format!("counter-{i}")`). Use it for + list-item state where the key isn't `&'static str`. ---- +(Operationalizes [P4 — Immediate-Mode Honesty](#p4--immediate-mode-honesty).) -## 6. Error Handling +### R6 — Error Handling SLT uses `std::io::Result` for all fallible operations. **We intentionally avoid custom error types.** @@ -150,25 +353,28 @@ SLT uses `std::io::Result` for all fallible operations. | Programmer error | `panic!()` with message | Hook type mismatch, invariant violation | | Input validation | `Result<(), String>` | User-provided validator closures | -### Rules +#### Rules -- **No `unwrap()` in Result-returning functions** — enforced by `clippy::unwrap_in_result` -- **Panics are for programmer errors only** — never for user input or I/O -- Panic messages must include context: index, expected type, actual value -- Use `#[track_caller]` on public functions that may panic +- **No `unwrap()` in Result-returning functions** — enforced by `clippy::unwrap_in_result`. +- **Panics are for programmer errors only** — never for user input or I/O. +- Panic messages must include context: index, expected type, actual value. +- Use `#[track_caller]` on public functions that may panic. -### Why No Custom Error Type? +#### Why no custom error type? -SLT's only runtime error path is terminal I/O. Wrapping `io::Error` in `SltError` would: -- Add API surface that becomes a semver commitment -- Require `From` conversions with no added information -- Complicate downstream `?` chains +SLT's only runtime error path is terminal I/O. Wrapping `io::Error` in +`SltError` would: +- Add API surface that becomes a semver commitment. +- Require `From` conversions with no added information. +- Complicate downstream `?` chains. -When distinct error categories emerge (config parsing, resource loading, backend initialization), we will introduce a structured error type. Not before. +When distinct error categories emerge (config parsing, resource loading, +backend initialization), we will introduce a structured error type. Not +before. ---- +(Operationalizes [P6 — Visible Failure Modes](#p6--visible-failure-modes).) -## 7. Performance Principles +### R7 — Performance Patterns SLT renders at 60+ FPS on modest hardware. These patterns keep it fast: @@ -180,16 +386,14 @@ SLT renders at 60+ FPS on modest hardware. These patterns keep it fast: | Double-buffer diff | Only changed cells are written to the terminal | | Viewport culling | Off-screen widgets are skipped entirely | -### Rules +#### Rules -- Performance changes must not break correctness — run the full test suite -- Measure before optimizing — use the `benchmarks` bench suite (`cargo bench`) -- Minimize per-frame allocations — prefer reuse over allocation -- Profile before assuming — `cargo flamegraph` for hot path identification +- Performance changes must not break correctness — run the full test suite. +- Measure before optimizing — use the `benchmarks` bench suite (`cargo bench`). +- Minimize per-frame allocations — prefer reuse over allocation. +- Profile before assuming — `cargo flamegraph` for hot path identification. ---- - -## 8. API Stability +### R8 — API Stability SLT follows [Semantic Versioning](https://semver.org/). @@ -199,21 +403,22 @@ SLT follows [Semantic Versioning](https://semver.org/). | 0.x → 0.y (minor) | May contain breaking changes (pre-1.0) | | 1.x (post-1.0) | Strict semver — breaking changes only in major versions | -### MSRV Policy +#### MSRV policy -- Minimum Supported Rust Version is declared in `Cargo.toml` (`rust-version`) -- MSRV bumps only happen in **minor** version releases -- MSRV bumps are documented in CHANGELOG.md +- Minimum Supported Rust Version is declared in `Cargo.toml` + (`rust-version`). +- MSRV bumps only happen in **minor** version releases. +- MSRV bumps are documented in CHANGELOG.md. -### Deprecation +#### Deprecation -- Deprecate before removing: at least one minor version with `#[deprecated]` -- Deprecated items include a migration path in the deprecation message -- Removal happens in the next minor version at earliest +- Deprecate before removing: at least one minor version with `#[deprecated]`. +- Deprecated items include a migration path in the deprecation message. +- Removal happens in the next minor version at earliest. +- **Same-release deprecation is forbidden** — see + [F1 in Failure Mode Catalog](#f1--same-release-deprecation-p1--meso). ---- - -## 9. Dependencies +### R9 — Dependencies **Minimal by design.** @@ -230,19 +435,263 @@ SLT follows [Semantic Versioning](https://semver.org/). | `syntax` | Convenience: enables all `syntax-*` bundles | Optional | | `kitty-compress` | Compressed Kitty image uploads | Optional | -### Rules +#### Rules + +- Do not add new required dependencies without discussion. +- Optional dependencies go behind feature flags. +- Feature flags must be **additive** — enabling a feature must not remove + types or change existing behavior. +- Prefer `dep:` syntax in `[features]` to avoid implicit feature names. +- Keep docs explicit about which APIs require `crossterm` and which work + on the core backend path without it. + +### R10 — Safety + +- **Zero `unsafe` code** — enforced by `#![forbid(unsafe_code)]`. +- No `unwrap()` in library code where `Result` is returned — enforced by + `clippy::unwrap_in_result`. +- `dbg!()`, `println!()`, `eprintln!()` are forbidden in library code — + enforced by clippy lints. +- `missing_docs` tracked via CI (non-blocking) — all new public API + items should have doc comments. -- Do not add new required dependencies without discussion -- Optional dependencies go behind feature flags -- Feature flags must be **additive** — enabling a feature must not remove types or change existing behavior -- Prefer `dep:` syntax in `[features]` to avoid implicit feature names -- Keep docs explicit about which APIs require `crossterm` and which work on the core backend path without it +(Operationalizes [P6 — Visible Failure Modes](#p6--visible-failure-modes) +and [P7 — AI-Readable Documentation](#p7--ai-readable-documentation).) --- -## 10. Safety +## 4. The 4-Tier Convention Stack + +Conventions live at four levels of granularity. Each tier has a dedicated +companion doc, and each tier maps to a different kind of lint signal. + +| Tier | Owner doc | Sample question | Lint signal | +|------|-----------|-----------------|-------------| +| **Macro** | [`ARCHITECTURE.md`](./ARCHITECTURE.md) | "Where does mouse handling live?" | module imports cross-layer | +| **Meso** | [`API_DESIGN.md`](./API_DESIGN.md) | "Builder or immediate?" | method signature regex | +| **Micro** | [`NAMING.md`](./NAMING.md) | "Verb or noun?" | identifier-shape lint | +| **Detail** | [`RUSTDOC_GUIDE.md`](./RUSTDOC_GUIDE.md) | "How long is the docstring?" | rustdoc-presence lint | + +When a PR violates a rule, the reviewer should be able to point at *which +tier* it broke. If they can't, the rule isn't tier-localized — that's a +problem with the rule, not the PR. + +The 4 tiers × 7 meta-principles = 28 audit cells. The next section makes +that grid explicit. + +--- + +## 5. Audit Matrix (v0.20) + +Status legend: ✅ enforced & green, ⚠️ documented & ad-hoc, ❌ known gap. + +``` + Macro Meso Micro Detail + ───── ───── ───── ────── +P1 Predictability ⚠️ ✅ ⚠️ ✅ +P2 Layer disc. ⚠️ two-paths ⚠️ ✅ ✅ +P3 Naming ✅ ✅ ⚠️ mixed ✅ +P4 Immediate ✅ ✅ ✅ ✅ +P5 Composability ✅ ✅ ✅ ⚠️ +P6 Visible fail ❌ ⚠️ ✅ ⚠️ +P7 AI-readable ⚠️ ✅ ✅ ✅ +``` + +**Total cells: 28. Green: 16. Yellow: 9. Red: 3.** + +### Cell-by-cell notes + +**P1 × Macro (⚠️)**: 5 layers documented but layer-membership audit is +manual. *Action by v0.21*: every public type marked with which layer it +belongs to (rustdoc tag). + +**P1 × Micro (⚠️)**: shortcuts coexist with longer canonical forms in +some families (e.g. `bordered` vs `container().border()`). Not yet a +sweep target. + +**P2 × Macro (⚠️ two-paths)**: `ui.bordered` vs `ui.container().border()`, +`text` vs `styled` for plain text. Documented in +[`ARCHITECTURE.md`](./ARCHITECTURE.md). Decision deferred to v0.22 sweep. + +**P2 × Meso (⚠️)**: `Context::text` and `ContainerBuilder::text` both +exist. The latter is the inner-of-builder form, the former is the +unbordered shortcut. Layer rule (P2) doesn't formalize the +shortcut/explicit split. + +**P3 × Micro (⚠️ mixed)**: `register_focusable_named` (long verb) +coexists with `bordered` (terse adjective). Both correct under their +categories, but a new reader doesn't predict the long form when they +only saw the short. *Action by v0.21*: NAMING.md adds verb-length +conventions; lint flags identifier-shape outliers. + +**P5 × Detail (⚠️)**: composition examples in rustdoc are rare; most +show single-widget calls. *Action*: RUSTDOC_GUIDE.md mandates one +composition example per builder type. + +**P6 × Macro (❌)**: silent fallback bugs landed in v0.20 +(`code_block_lang` empty-lang path; see +[F2](#f2--silent-fallback-divergence-p6--macro)). No structural lint +exists yet. *Action by v0.21*: clippy custom rule or audit-script flag +for "fallback diverges from primary path." + +**P6 × Meso (⚠️)**: `slt_assert!` and `slt_warn!` exist but inconsistent +adoption. Many widgets silently clamp values rather than warning. + +**P6 × Detail (⚠️)**: failure section missing from many docstrings. +RUSTDOC_GUIDE.md now mandates it for non-trivial methods. + +**P7 × Macro (⚠️)**: module-level docs (`//!` at file head) inconsistent. +v0.20 added `//!` to all 6 facade files (`lib.rs`, `context.rs`, +`widgets_display.rs`, `widgets_input.rs`, `widgets_interactive.rs`, +`widgets_viz.rs`); ~50 implementation files remain unchanged. +*Action by v0.21*: enforce with rustdoc lint + sweep remaining files. + +--- + +## 6. Roadmap: v0.20 → v1.0 + +| Version | Phase | Deliverable | Status | +|---------|-------|-------------|--------| +| v0.20 | **Define** | This doc + ARCHITECTURE / NAMING / RUSTDOC_GUIDE | ⏳ this PR | +| v0.20 | **Define** | `scripts/api_audit.sh` (report-only) | ⏳ this PR | +| v0.21 | **Automate** | clippy custom rules for P3, P6 | planned | +| v0.21 | **Automate** | CI gate: audit script blocks on V1, V2, V4 | planned | +| v0.22 | **Refine** | Two-path resolution (P2 × Macro/Meso) | planned | +| v0.22 | **Refine** | Verb-length normalization (P3 × Micro) | planned | +| v0.23–0.30 | **Stabilize** | Deprecate-and-remove sweep per principle | planned | +| v1.0 | **Freeze** | Public API semver-locked. Matrix all ✅. | planned | + +Each "Refine" step is a single-issue PR that takes one yellow cell to +green. Each "Stabilize" step removes deprecated API per the schedule +in [R8 — API Stability](#r8--api-stability). + +--- + +## 7. Failure Mode Catalog + +Real bugs, classified by which principle they violated. Used to refine +the matrix and as case studies for new contributors. + +### F1 — Same-release deprecation (P1 × Meso) + +**v0.20 #224, #226**: `gauge_w`, `gauge_colored`, `line_gauge_with`, +`breadcrumb_sep`, `LineGaugeOpts`, `HighlightRange::single`, `label_owned` +all introduced in v0.20 and immediately deprecated by the API consistency +pass. Net effect: zero deprecation tax — the methods existed for a few +unmerged hours. + +**Lesson**: builder consolidation must precede shortcut introduction. +When two team members add overlapping APIs in parallel, the gateway +review must catch the overlap before either lands. + +**Prevention**: API_DESIGN.md rule 1 (builder when 4+ optional fields) ++ audit script V2 check. + +### F2 — Silent fallback divergence (P6 × Macro) + +**v0.20 spacing_scale demo**: `code_block_lang(code, "")` falls back to +a loop calling `render_highlighted_line(ui, line)` directly. The +non-fallback path wraps each line in `ui.line(...)`, but the fallback +path didn't. Result: tokens rendered one-per-row vertically. + +```rust +// before fix +} else { + for line in code.lines() { + render_highlighted_line(ui, line); // ← no ui.line(...) + } +} + +// after fix +} else { + for line in code.lines() { + ui.line(|ui| render_highlighted_line(ui, line)); + } +} +``` + +**Lesson**: fallback path must mirror primary path's container nesting. + +**Prevention**: structural-equivalence test or lint that checks +"every branch in this `match` / `if` produces the same container shape." + +### F3 — Outer-container missing grow (P5 × Macro) + +**v0.20 named_focus demo**: outer `bordered().col()` lacked `.grow(1)`, +so the box only filled one row's worth of vertical space. Inside, input +fields lacked grow on the input's row column, so they shrank to 1 cell. + +**Lesson**: demo template must explicitly call out grow defaults; +flexbox inheritance is not intuitive even for experienced devs. + +**Prevention**: demo lint or visual snapshot test. + +### F4 — Em-dash wide-char drift (P6 × Detail) + +**v0.20 demo polish**: titles like `"SLT v0.20 — Density presets"` used +U+2014 EM DASH. In some terminals, em-dash counts as 1 column under +`unicode-width` but 2 columns when rendered, causing border misalignment. + +**Lesson**: titles restrict to BMP ASCII unless the demo explicitly +tests wide-char handling. + +**Prevention**: demo lint that scans for non-ASCII in `.title(...)` +arguments. + +### F5 — Race in parallel-agent commits (process, not API) + +**v0.20 functional audit**: 4 of 5 audit agents committed directly to +`release/v0.20.0` instead of their assigned worktree branches, racing +each other. One agent's report explicitly noted "다른 agent가 내 unstaged +work를 reset함." + +**Lesson**: not an API issue, but parallel-agent isolation is part of +the same predictability principle. Worktrees must be enforced for +multi-agent work. + +**Prevention**: `scripts/spawn_agent.sh` that wraps `Task` calls and +forces `isolation: "worktree"` for parallelizable work. + +--- + +## 8. How to Use This Document + +### When you write a new public API + +1. Read [`API_DESIGN.md`](./API_DESIGN.md) for the 5 signature rules. +2. Read [`NAMING.md`](./NAMING.md) for the naming categories. +3. Read [`ARCHITECTURE.md`](./ARCHITECTURE.md) and place the method in + exactly one layer. +4. Run `scripts/api_audit.sh`. Fix any V1 / V2 / V4 flags. +5. If the new method changes a matrix cell's status, update the matrix. + +### When you review a PR + +1. Identify which principles the change touches. +2. For each touched principle, check the corresponding tier doc. +3. If a violation exists but is documented (yellow cell), note it in the + PR description and link the matrix row. +4. If a violation is undocumented (would change the matrix), block until + the matrix is updated. + +### When you propose a redesign + +1. Quote the principle(s) being violated. +2. Propose the tier change (Macro / Meso / Micro / Detail). +3. Update the milestone roadmap if the change spans multiple versions. + +--- + +## Out of Scope + +This document does **not** define: + +- **File-level coding conventions** (mod patterns, derive order, attribute + placement) — those live in `CLAUDE.md` because they're project-specific. +- **Release process** — that lives in `CLAUDE.md` "Release Workflow Checklist." +- **Test coverage requirements** — that lives in CI config. +- **Performance budgets** — that lives in `tests/v020_perf_alloc.rs` and + benchmark suites. -- **Zero `unsafe` code** — enforced by `#![forbid(unsafe_code)]` -- No `unwrap()` in library code where `Result` is returned — enforced by `clippy::unwrap_in_result` -- `dbg!()`, `println!()`, `eprintln!()` are forbidden in library code — enforced by clippy lints -- `missing_docs` tracked via CI (non-blocking) — all new public API items should have doc comments +Design principles are *what* the API should look like; the project's +release / test / perf docs are *how* we ship it. diff --git a/docs/NAMING.md b/docs/NAMING.md new file mode 100644 index 0000000..6c6e4a4 --- /dev/null +++ b/docs/NAMING.md @@ -0,0 +1,444 @@ +# SLT Naming Conventions (Micro Tier) + +> Companion to [`DESIGN_PRINCIPLES.md`](./DESIGN_PRINCIPLES.md). This doc +> is the **Micro tier**: how methods, types, parameters, and modules are +> named. + +The principle is **shape-encoding**: a name's shape (verb / noun / +adjective, length, suffix, prefix) tells the reader its category. When a +new contributor or AI assistant sees a name they've never seen, they +should infer category and call shape from the name alone. + +--- + +## Categories + +Every public identifier falls into one category. The category drives the +shape. + +### 1. Verbs — Actions + +Methods that perform a side effect or trigger a state transition. + +**Shape**: `` or `_`. + +**Examples**: +```rust +ui.quit(); +ui.notify("saved", ToastLevel::Info); +ui.focus_by_name("search"); +ui.register_focusable(); +ui.consume_indices(vec![0, 2]); +state.set_ratio(0.5); +``` + +**Anti-pattern**: noun-as-verb for actions. + +```rust +ui.text("hi") // grandfathered: immediate-mode tradition; "text" treated as a verb here +ui.button("ok") // grandfathered for the same reason +``` + +New actions should use real verbs. + +### 2. Nouns — Getters + +Methods that return a value without side effects. + +**Shape**: `` or `_`. + +**Examples**: +```rust +ui.theme(); +ui.spacing(); +ui.width(); +ui.focused_name(); +ui.events(); +state.cursor; +``` + +**Anti-pattern**: `get_X` prefix. + +```rust +fn get_theme(&self) -> Theme { /* ... */ } // bad — Rust convention drops `get_` +fn theme(&self) -> Theme { /* ... */ } // good +``` + +### 3. Adjectives — Builder modifiers + +Methods on `ContainerBuilder` (Layer 2) that configure the container. + +**Shape**: short adjective or terse phrase. + +**Examples**: +```rust +ui.container() + .bordered(Border::Single) + .p(2) + .gap(1) + .grow(1) + .fill() + .bold() + .dim() + .italic() + .bg(theme.surface) + .fg(Color::Cyan) + .col(|ui| { /* ... */ }); +``` + +**Anti-pattern**: verb form for builder modifiers. + +```rust +.apply_padding(2) // bad — too long, verb-shape +.with_padding(2) // ok if `with_` prefix is the family convention +.p(2) // good — terse, adjective-shape +.padding(2) // also good — full word ok +``` + +### 4. Constructors + +Functions that create instances. + +**Shape**: +- `Type::default()` — when `Default` is implementable. +- `Type::new(args)` — minimal required config. +- `Type::with_X(arg)` — narrow construction variant. + +**Examples**: +```rust +TextInputState::default(); +TextInputState::with_placeholder("Search..."); +SplitPaneState::new(0.5); +TabsState::new(["Files", "Settings"]); +``` + +--- + +## Suffix Patterns + +| Suffix | Meaning | Example | +|--------|---------|---------| +| `_named` | name-keyed variant | `register_focusable_named` | +| `_keyed` | runtime-key variant | `use_state_keyed` | +| `_colored` | color-customized variant | `text_input_colored` | +| `_lang` | language-aware variant | `code_block_lang` | +| `_pct` | percent variant | `w_pct(50)` | +| `_ratio` | ratio variant | `w_ratio(1, 3)` | +| `_minmax` | min/max bounds variant | `w_minmax(10, 30)` | +| `_gap` | gap-supplied variant | `row_gap(g, ...)` | +| `_with` | callback-ish variant | `with_if(cond, f)` | +| `State` | Layer 4 state type | `TextInputState` | +| `Response` | Layer 5 response type | `BreadcrumbResponse` | +| `Opts` | options struct (4+ fields) | `GutterOpts` | +| `Builder` | builder type (rare; usually anon ``) | — | + +--- + +## Prefix Patterns + +| Prefix | Meaning | Example | +|--------|---------|---------| +| `with_` | builder-style override / conditional | `with_if`, `with_padding` | +| `use_` | hook (state-bound across frames) | `use_state`, `use_effect`, `use_state_keyed` | +| `register_` | frame-level registration | `register_focusable`, `register_focusable_named` | +| `consume_` | mark event/index as handled | `consume_indices` | +| `is_` | boolean getter | `is_quit_requested` | +| `has_` | possession boolean getter | `has_focus` (use sparingly — prefer noun like `focused`) | +| `slt_` | macro/function from SLT internals | `slt_warn!`, `slt_assert!` | + +--- + +## Length Conventions + +Length should match category: + +- **Adjectives** (Layer 2 modifiers): ≤ 2 syllables typical. + - ✅ `bg`, `fg`, `p`, `w`, `h`, `gap`, `grow`, `fill`, `border`, `bold`, `dim` + - ✅ `bordered`, `padding`, `italic`, `inverted` + - ❌ `with_padding_all_sides` — too long +- **Nouns** (Layer 1/2 getters): full word. + - ✅ `theme`, `spacing`, `width`, `events` + - ❌ `tm`, `sp`, `ev` +- **Verbs** (Layer 1 actions): full word + object. + - ✅ `register_focusable`, `focus_by_name`, `consume_indices` + - ❌ `reg_foc`, `foc_name` +- **Types**: full word. + - ✅ `TextInputState`, `SplitPaneState`, `BreadcrumbResponse` + - ❌ `TIState`, `SPState`, `BCResp` + +**Cross-category mismatch is a yellow signal**: when one method in a +family is ≤ 6 chars and another is > 20, ask whether the categories are +correctly assigned. + +--- + +## Abbreviation Policy + +### Allowed (universal) + +These are recognizable across languages and don't need expansion: + +``` +bg fg id idx len min max pos pct +w h x y r g b a (RGBA) +``` + +### Forbidden (domain-specific) + +Even common-sounding domain abbreviations are forbidden because they +hide their family from new readers: + +| Forbidden | Use instead | +|-----------|-------------| +| `ctx` | `context` (in private code only — public API uses `ui`) | +| `btn` | `button` | +| `lbl` | `label` | +| `dbg` | `debug` | +| `cfg` | `config` | +| `req` | `request` | +| `res` | `response` (or just don't shorten — `Response` is the type) | +| `srv` | `server` | +| `db` | `database` (universal-ish, but spell it out in public APIs) | + +**Rationale**: AI assistants and new contributors can guess `bg` = +"background" because it's universal CSS/web vocabulary. They can't guess +`lbl` = "label" because it could be "label", "label-id", "labour", etc. +The cost of typing `label` over `lbl` is 2 characters; the cost of a +miscalled API is much higher. + +--- + +## Parameter Naming + +### Closures + +```rust +fn use_effect(&mut self, f: F, deps: &D) // f for the callback +fn with_if(self, cond: bool, f: F) -> Self // f for the callback +``` + +Use single-letter names only when the role is unambiguous from the +function context. + +### Indices + +- `idx` (preferred) or `i` (in tight loops). +- Never `index_of_thing` unless disambiguation is needed. + +### Lengths and sizes + +- `n` — count, generic +- `len` — length of a collection +- `w`, `h` — width, height in cells (SLT uses cells, not pixels) +- `cap` — capacity +- `cnt` — forbidden; use `count` or `n` + +### References to `Context` / `ui` + +- **Always `ui`** in public closures (`|ui| { ui.text("hi") }`). +- **`ctx`** allowed only in private internals. +- **`self`** when defining a method on `Context`. + +```rust +// good — public closure +ui.col(|ui| { + ui.text("hello"); +}); + +// good — private impl +fn helper(ctx: &mut Context) { /* ... */ } +``` + +--- + +## Method Ordering on `impl` blocks + +When defining `impl Type`, order methods to maximize rustdoc readability: + +1. **Constructors** — `new`, `default` (via `impl Default`), `with_X`. +2. **Getters** — nouns, return values without side effects. +3. **Builder modifiers** — adjectives, return `Self` or `&mut Self`. +4. **Actions** — verbs, perform side effects. +5. **Trait impls** — separate `impl Trait for Type` blocks. + +Generated rustdoc pages then read top-to-bottom: "how to construct, how +to read, how to configure, how to act." + +--- + +## Type Naming + +### State (Layer 4) + +`State`. Always `pub struct`. + +```rust +pub struct TextInputState { /* ... */ } +pub struct SplitPaneState { /* ... */ } +pub struct TableState { /* ... */ } +``` + +### Response (Layer 5) + +`Response`. Compound responses must `Deref`. + +```rust +pub struct BreadcrumbResponse { + pub response: Response, + pub clicked_segment: Option, +} + +impl Deref for BreadcrumbResponse { + type Target = Response; + fn deref(&self) -> &Response { &self.response } +} +``` + +### Options structs + +`Opts`. Used when ≥ 4 optional fields would otherwise inflate +the function signature. + +```rust +pub struct GutterOpts { + pub width: u32, + pub bg: Color, + pub label_fn: G, + pub highlights: Vec, +} +``` + +### Enums + +Full words for variants, no abbreviations: + +```rust +pub enum BarDirection { + Horizontal, // good + Vertical, +} + +pub enum Border { + None, Single, Double, Rounded, Thick, // good +} + +// bad +pub enum BD { H, V } +``` + +### Builders (anonymous) + +Builder structs use `` shape: + +```rust +pub struct Gauge<'a> { /* borrows &mut Context */ } +pub struct LineGauge<'a> { /* ... */ } +pub struct Breadcrumb<'a> { /* ... */ } +``` + +The lifetime parameter is required because builders borrow `&mut Context`. + +--- + +## Module Naming + +### Files + +`snake_case.rs`. Group by family, not by widget count. + +| Module | Family | +|--------|--------| +| `widgets_display/text.rs` | text & inline styling | +| `widgets_display/status.rs` | alerts, badges, code blocks | +| `widgets_input/text_input.rs` | text input variants | +| `widgets/responses.rs` | Layer 5 compound response types | + +### Modules vs files + +Rust 2018 style: `filename.rs` + `filename/` directory. Avoid `mod.rs`. + +``` +src/widgets.rs # facade re-exports +src/widgets/ + input.rs # state types in input family + selection.rs + ... +``` + +--- + +## Rename History + +For AI assistants: do **not** search for these old names. They were +removed in v0.20. + +| Old | New | Reason | +|-----|-----|--------| +| `gauge_w(r, w)` | `gauge(r).width(w)` | Builder consolidation | +| `gauge_colored(r, c)` | `gauge(r).color(c)` | Builder consolidation | +| `line_gauge_with(r, opts)` | `line_gauge(r).` | Builder consolidation | +| `breadcrumb_sep(b, s)` | `breadcrumb(b).separator(s)` | Builder consolidation | +| `LineGaugeOpts` | `LineGauge<'_>` builder | Builder consolidation | +| `HighlightRange::single(...)` | `HighlightRange::line(...)` | Single-line was the only use | +| `label_owned(s)` | `label(s)` | `impl Into` accepts both | + +These removals are **breaking changes**, but they were introduced *and* +removed in the same release (v0.20), so the pre-release window was the +only place they ever existed. + +--- + +## Anti-patterns Observed in Review + +### Mixed verbs in the same widget family + +```rust +// historic — pre-v0.20 the gauge family had both +ui.gauge(0.6); // immediate +ui.gauge_w(0.6, 24); // immediate-with-arg +LineGaugeOpts::new(...).render() // builder + +// resolved — v0.20 unified to: +ui.gauge(0.6).width(24).color(Color::Cyan); +ui.line_gauge(0.6).label("60%").width(24); +``` + +### Long form when a short adjective exists + +```rust +// rejected in review +ui.container().apply_border(Border::Single).p_padding_value(2) + +// correct +ui.container().border(Border::Single).p(2) +``` + +### Abbreviation creep + +```rust +// rejected +fn dbg_print_state(...) + +// correct +fn debug_print_state(...) // or, better: slt_debug!(...) +``` + +### Type abbreviation + +```rust +// rejected +pub struct TIState { /* TextInputState */ } + +// correct +pub struct TextInputState { /* ... */ } +``` + +--- + +## When in doubt + +1. **Find an existing analogous method/type** in the same family. +2. **Match its shape** — same prefix, same suffix, same length category. +3. **If no analog exists**, document why this is a new pattern in the + PR description and update [`DESIGN_PRINCIPLES.md`](./DESIGN_PRINCIPLES.md) + matrix if the new shape changes a cell. diff --git a/docs/RUSTDOC_GUIDE.md b/docs/RUSTDOC_GUIDE.md new file mode 100644 index 0000000..cb99b01 --- /dev/null +++ b/docs/RUSTDOC_GUIDE.md @@ -0,0 +1,366 @@ +# SLT Rustdoc Style Guide (Detail Tier) + +> Companion to [`DESIGN_PRINCIPLES.md`](./DESIGN_PRINCIPLES.md). This doc +> is the **Detail tier**: how rustdoc is written so both humans and AI +> assistants can predict the next call from the docs alone. + +The principle is **predictable docs**: a developer who reads the rustdoc +for one method should be able to anticipate the structure of every other +method's rustdoc. Inconsistent docs force readers to re-orient on every +page. + +--- + +## The 4-part docstring + +Every public method has these four sections, in order: + +```rust +/// One-sentence purpose. Imperative voice — "Render a gauge" not +/// "This function renders a gauge." +/// +/// One paragraph explaining how it fits into the widget family. Mention +/// the layer (Context, Builder, Widget, State, Response). Mention the +/// return shape. Mention any state interaction. +/// +/// # Example +/// +/// ```no_run +/// // Minimal correct usage. Compiles. Shows the canonical call shape. +/// ``` +/// +/// # Failure +/// +/// What happens on bad input — panic, warn, or silent? If silent, why? +pub fn gauge(&mut self, ratio: f64) -> Gauge<'_> { /* ... */ } +``` + +### Required sections (every public item) + +- **Purpose** — 1 sentence, imperative voice. +- **Family** — 1 paragraph: which layer, what family of widgets it joins. +- **Example** — 1 code block, minimal correct usage. + +### Conditional sections + +- **`# Failure`** — required when the method can panic or silently fall back. +- **`# State`** — required when the method reads or writes hook state. +- **`# Layout`** — required when the method affects parent or child layout. +- **`# Performance`** — required when the method has surprising cost + (e.g. allocates per-call, runs O(n²)). + +--- + +## Imperative Voice + +✅ "Render a gauge with the given ratio." +❌ "This function renders a gauge with the given ratio." +❌ "Renders a gauge..." — third-person inflection conflicts with Rust +convention (Rust API guidelines use imperative). + +The first sentence is the rustdoc summary, picked up by IDE tooltips and +docs.rs index. Make it stand alone. + +--- + +## Code Examples Must Compile + +Every `# Example` block must compile via `cargo doc --no-deps` / +`cargo test --doc`. Use: + +- **`no_run`** — when the example needs a running terminal but should + still type-check. **Preferred for SLT**, since most examples need + `slt::run(...)`. +- **`ignore`** — only when the example must skip both compile and run + (rare; needs justification). + +```rust +/// # Example +/// +/// ```no_run +/// # use slt::widgets::TextInputState; +/// # slt::run(|ui: &mut slt::Context| { +/// let mut state = TextInputState::default(); +/// ui.text_input(&mut state); +/// # }); +/// ``` +``` + +`no_run` is preferred over `ignore` because the compiler still +type-checks — catches API drift in the docs. + +--- + +## Hidden Setup Lines + +Lines starting with `# ` are hidden from rendered docs but still +compile-checked. Use them for: + +- `use` statements +- `slt::run(...)` wrappers +- helper variable initialization + +```rust +/// ```no_run +/// # use slt::Color; +/// # slt::run(|ui: &mut slt::Context| { +/// ui.text("hi").fg(Color::Red); +/// # }); +/// ``` +``` + +The reader sees only the meaningful lines; the compiler sees and +type-checks everything. + +--- + +## Cross-references + +When mentioning another method or type, **always use intra-doc links**: + +```rust +/// See [`Context::register_focusable`] for the unnamed variant. +/// The matching state type is [`TextInputState`]. +/// Use [`Self::with_placeholder`] for the placeholder-only constructor. +``` + +Never write the path as plain text: + +```rust +// bad +/// See Context::register_focusable for the unnamed variant. +``` + +Intra-doc links generate working hyperlinks on docs.rs. Plain text +breaks under refactor. + +--- + +## Module-level Documentation + +Every public module file starts with a `//!` doc comment: + +```rust +//! Display widgets — text, alerts, badges, code blocks. +//! +//! This module hosts Layer 3 (Widget) primitives that produce visual +//! output without consuming events. For interactive widgets see +//! [`widgets_interactive`](super::widgets_interactive). +//! +//! # Family +//! +//! - [`text`](crate::Context::text) — basic text rendering +//! - [`alert`](crate::Context::alert) — info / warning / error banners +//! - [`badge`](crate::Context::badge) — colored chips +//! - [`code_block`](crate::Context::code_block) — syntax-highlighted code +``` + +The first line is the rustdoc summary picked up by the parent module's +index. The body explains: + +1. What's **in** this module (the family). +2. What's **out** (link to sibling modules with adjacent families). +3. **Layer** the module belongs to. + +--- + +## Length Conventions + +| Item | Length | +|------|--------| +| **Trivial getters** (`theme()`, `width()`) | 1 sentence + example | +| **Common widgets** (`text`, `button`, `gauge`) | 1 paragraph + example + optional Layout/State | +| **Complex widgets** (`textarea`, `table`, `chart`) | up to 5 paragraphs, multiple subsections, ≥ 2 examples (minimal + composition) | +| **Hooks** (`use_state`, `use_effect`) | 5+ paragraphs covering rules, re-render semantics, ≥ 1 detailed example | + +Hooks are the most surprising part of immediate-mode for newcomers; over-document them. + +--- + +## State and Layout Sections + +When a method reads/writes hook state, document **what it touches** and +**when it triggers re-render**: + +```rust +/// Reset the focus to the first focusable in the next frame. +/// +/// # State +/// +/// Writes [`Context::focus_index`]. The reset takes effect on the next +/// frame; the current frame still reflects the previous focus. +/// +/// # Example +/// +/// ```no_run +/// # slt::run(|ui: &mut slt::Context| { +/// if ui.button("Reset").clicked { +/// ui.focus_first(); +/// } +/// # }); +/// ``` +pub fn focus_first(&mut self) { /* ... */ } +``` + +When a method affects layout, document **what container shape it produces**: + +```rust +/// Wrap children in a flex column. +/// +/// # Layout +/// +/// Produces a column container with `gap = 0`. For non-zero gap use +/// [`Self::col_gap`]. The column claims its parent's full cross-axis +/// (width) by default; pass `.grow(0)` to opt out. +pub fn col(&mut self, f: F) -> Response { /* ... */ } +``` + +--- + +## Failure Section + +Required for any method that can panic, warn, or silently fall back. Use +this template: + +```rust +/// # Failure +/// +/// Panics in debug if `ratio < 0.0` or `ratio > 1.0`. In release, the +/// value is clamped silently. To detect bad input at the call site, +/// validate before calling. +``` + +For panics, include the **exact assertion text** if possible. AI +assistants searching for an error message benefit from finding it in the +docs. + +--- + +## AI-Readable Formatting + +Two patterns make AI consumption easier: + +### 1. Each parameter named in prose + +Don't rely on the signature alone: + +```rust +/// Render a block-fill gauge. +/// +/// `ratio` is clamped to `[0.0, 1.0]`. Values outside this range are +/// silently clamped — no warning, no panic. To detect bad input, +/// validate before calling. +/// +/// `label`, set via the builder method, is rendered inside the bar +/// when present. +``` + +### 2. Each related method linked + +If `gauge` is the entry point, link siblings: + +```rust +/// Render a block-fill gauge. +/// +/// For a single-line variant, see [`Context::line_gauge`]. +/// For an animated indeterminate variant, see [`Context::progress`]. +/// For the underlying state-free flush, see [`Context::progress_bar`]. +``` + +The AI sees: +- the bounds (clamping) +- the visual model (block fill) +- the alternatives (line_gauge, progress, progress_bar) + +That's enough to suggest the right call without checking three other +docs. + +--- + +## Forbidden + +- **Marketing language**: "blazing fast", "ergonomic", "modern", "powerful". + The user is reading this to learn how to call the method, not be sold + the library. Marketing belongs in `README.md`. +- **Internal jargon without expansion**: "the hot path", "the rollback", + "the hit map" without context. First mention in any doc must clarify. +- **TODO/FIXME in public docs**: leave those as inline comments. + Public docstrings represent the committed API. +- **Empty examples**: `# Example` with `// see source` is worse than no + example. Either write a real example or omit the section. + +--- + +## Real Example: a complete docstring + +```rust +/// Render a block-fill gauge with optional label and color override. +/// +/// The gauge is a Layer-3 widget. It fills a horizontal bar in 8 sub-cell +/// steps using Unicode block characters; the bar's width is set via the +/// builder's `.width(n)` method (default: parent's full width). +/// +/// `ratio` is clamped to `[0.0, 1.0]`. Color tiers are automatic by +/// default — green below 50%, yellow 50–80%, red ≥ 80% — and can be +/// overridden via [`Gauge::color`]. +/// +/// # Example +/// +/// ```no_run +/// # use slt::Color; +/// # slt::run(|ui: &mut slt::Context| { +/// ui.gauge(0.42).label("42%").width(24); +/// ui.gauge(0.95).color(Color::Red); +/// # }); +/// ``` +/// +/// # Layout +/// +/// The gauge claims one row. With no `.width(n)` call, it fills the +/// parent's cross-axis (typically the parent column's full width). +/// +/// # Failure +/// +/// `ratio` outside `[0.0, 1.0]` is silently clamped — no warning, no +/// panic. To detect bad input, validate before calling. +/// +/// # See Also +/// +/// - [`Context::line_gauge`] — single-line variant with custom characters +/// - [`Context::progress`] — animated indeterminate variant +pub fn gauge(&mut self, ratio: f64) -> Gauge<'_> { /* ... */ } +``` + +This docstring: +- ✅ Imperative voice ("Render a...") +- ✅ Layer noted (Layer-3) +- ✅ Example compiles (`no_run` + hidden setup) +- ✅ Cross-references via intra-doc links +- ✅ Failure section explicit (silent clamping) +- ✅ Layout section explicit (claims one row, fills cross-axis) +- ✅ See Also links siblings + +--- + +## Lint Enforcement + +In v0.20 (current): +- `#![deny(missing_docs)]` enabled at the crate root for `pub` items. +- `cargo doc --no-deps` runs in CI; broken intra-doc links fail. +- `cargo test --doc` runs; broken examples fail. + +Planned in v0.21: +- Custom clippy rule: detect public methods without `# Example`. +- Custom rule: detect public methods that can panic but lack `# Failure`. +- Audit script (`scripts/api_audit.sh`) flag V4 (rustdoc presence) blocks + CI. + +--- + +## When in doubt + +1. **Find a similar method's docstring** in the same family. +2. **Copy its structure** — sections, length, link style. +3. **Adapt the content**. + +Consistency across the codebase beats local optimization. diff --git a/examples/anim.rs b/examples/anim.rs index 34dea69..c24bedf 100644 --- a/examples/anim.rs +++ b/examples/anim.rs @@ -90,7 +90,7 @@ fn main() -> std::io::Result<()> { .gap(1) .col(|ui| { ui.text("Press Space to retarget"); - ui.progress(progress); + let _ = ui.progress(progress); ui.text(format!( "value {:.2} -> target {:.2} | done {}", progress, @@ -121,7 +121,7 @@ fn main() -> std::io::Result<()> { .gap(1) .col(|ui| { let kf_val = kf.value(ui.tick()); - ui.progress(kf_val / 100.0); + let _ = ui.progress(kf_val / 100.0); ui.text(format!( "value {:.1} | done {} | mode PingPong", kf_val, @@ -137,7 +137,7 @@ fn main() -> std::io::Result<()> { .gap(1) .col(|ui| { let seq_val = seq.value(ui.tick()); - ui.progress(seq_val / 100.0); + let _ = ui.progress(seq_val / 100.0); ui.text(format!( "value {:.1} | done {} | mode Repeat", seq_val, @@ -157,7 +157,7 @@ fn main() -> std::io::Result<()> { let val = stagger.value(ui.tick(), i); let _ = ui.row(|ui| { ui.text(format!("{label}:")); - ui.progress(val); + let _ = ui.progress(val); }); } ui.text("5 items, 6-tick delay each").dim(); @@ -166,7 +166,7 @@ fn main() -> std::io::Result<()> { let accent = ui.theme().accent; ui.text("Callback").bold().fg(accent); let val = cb_tween.value(ui.tick()); - ui.progress(val / 100.0); + let _ = ui.progress(val / 100.0); if cb_tween.is_done() && !cb_fired { cb_fired = true; } diff --git a/examples/canvas_tour.rs b/examples/canvas_tour.rs new file mode 100644 index 0000000..def809f --- /dev/null +++ b/examples/canvas_tour.rs @@ -0,0 +1,1990 @@ +//! Canvas Tour — five canvas/animation demos in one Tabs-driven tour. +//! +//! Mirrors `examples/v020_tour.rs`: each tab embeds the rendering logic +//! of a single canvas/animation demo. Per-frame state (fire pixels, +//! game boards, animation primitives, scroll offsets) lives in the +//! `TourState` struct so switching tabs and back resumes where you left +//! off rather than re-initialising every frame. +//! +//! Run: `cargo run --example canvas_tour --all-features` +//! +//! Tabs: +//! 1. Intro — overview + key reference +//! 2. Fire — animated fire effect (top-half-block double-resolution) +//! 3. Game — tetris/snake/minesweeper switcher +//! 4. Raw Draw — `ContainerBuilder::draw(|buf, rect|)` primitives +//! 5. Kitty Image — kitty graphics protocol image gallery +//! 6. Anim — Tween / Spring / Keyframes / Sequence / Stagger +//! +//! Top-level keys: +//! Tab / Shift-Tab — cycle focus (tabs bar ↔ demo) +//! Left / Right — switch tab when the tabs bar is focused +//! q / Esc / Ctrl-Q — quit +//! +//! Per-tab keys (documented in each tab's status line): +//! Fire — Space pauses; resizes auto-rebuild the buffer +//! Game — 1/2/3 switch game; arrows + Space + r + p + t + f +//! Anim — Space retargets tween; j/k or arrows nudge spring; r restarts +//! Kitty — j/k or arrows scroll; q quits + +use std::collections::VecDeque; +use std::time::Duration; + +use slt::anim::{ease_in_out_cubic, ease_out_bounce, ease_out_quad}; +use slt::widgets::{ScrollState, TabsState}; +use slt::{ + Border, Buffer, Color, Context, KeyCode, KeyModifiers, Keyframes, LoopMode, Rect, RunConfig, + Sequence, Spring, Stagger, Style, Theme, Tween, +}; + +// ─── Fire constants ───────────────────────────────────────────────── +const FIRE_PALETTE_SIZE: usize = 37; + +fn build_fire_palette() -> [Color; FIRE_PALETTE_SIZE] { + let raw: [(u8, u8, u8); FIRE_PALETTE_SIZE] = [ + (7, 7, 7), + (31, 7, 7), + (47, 15, 7), + (71, 15, 7), + (87, 23, 7), + (103, 31, 7), + (119, 31, 7), + (143, 39, 7), + (159, 47, 7), + (175, 63, 7), + (191, 71, 7), + (199, 71, 7), + (223, 79, 7), + (223, 87, 7), + (223, 87, 7), + (215, 95, 7), + (215, 95, 7), + (215, 103, 15), + (207, 111, 15), + (207, 119, 15), + (207, 127, 15), + (207, 135, 23), + (199, 135, 23), + (199, 143, 23), + (199, 151, 31), + (191, 159, 31), + (191, 159, 31), + (191, 167, 39), + (191, 167, 39), + (191, 175, 47), + (183, 175, 47), + (183, 183, 47), + (183, 183, 55), + (207, 207, 111), + (223, 223, 159), + (239, 239, 199), + (255, 255, 255), + ]; + let mut palette = [Color::Rgb(0, 0, 0); FIRE_PALETTE_SIZE]; + for (i, (r, g, b)) in raw.iter().enumerate() { + palette[i] = Color::Rgb(*r, *g, *b); + } + palette +} + +struct Fire { + w: usize, + h: usize, + pixels: Vec, + rng: u64, +} + +impl Fire { + fn new(w: usize, h: usize) -> Self { + let mut pixels = vec![0usize; w * h]; + if h > 0 { + for x in 0..w { + pixels[(h - 1) * w + x] = FIRE_PALETTE_SIZE - 1; + } + } + Self { + w, + h, + pixels, + rng: 0xDEAD_BEEF_CAFE_1234, + } + } + + fn next_rand(&mut self) -> u64 { + self.rng ^= self.rng << 13; + self.rng ^= self.rng >> 7; + self.rng ^= self.rng << 17; + self.rng + } + + fn step(&mut self) { + for x in 0..self.w { + for y in 1..self.h { + let src = y * self.w + x; + let rand_val = self.next_rand(); + let decay = (rand_val & 3) as usize; + let wind = ((rand_val >> 2) & 1) as usize; + let dst_x = x.saturating_sub(wind); + let dst = (y - 1) * self.w + dst_x; + self.pixels[dst] = self.pixels[src].saturating_sub(decay); + } + } + } + + fn color_at(&self, x: usize, y: usize, palette: &[Color; FIRE_PALETTE_SIZE]) -> Color { + palette[self.pixels[y * self.w + x]] + } +} + +struct FireState { + fire: Option, + palette: [Color; FIRE_PALETTE_SIZE], + paused: bool, +} + +impl Default for FireState { + fn default() -> Self { + Self { + fire: None, + palette: build_fire_palette(), + paused: false, + } + } +} + +// ─── Game (Tetris / Snake / Minesweeper) ─────────────────────────── +const BOARD_W: usize = 10; +const BOARD_H: usize = 20; + +const KICKS: [(i32, i32); 7] = [(0, 0), (-1, 0), (1, 0), (0, -1), (-2, 0), (2, 0), (0, 1)]; + +const PIECES: [[[(i32, i32); 4]; 4]; 7] = [ + [ + [(0, 1), (1, 1), (2, 1), (3, 1)], + [(2, 0), (2, 1), (2, 2), (2, 3)], + [(0, 2), (1, 2), (2, 2), (3, 2)], + [(1, 0), (1, 1), (1, 2), (1, 3)], + ], + [ + [(1, 0), (2, 0), (1, 1), (2, 1)], + [(1, 0), (2, 0), (1, 1), (2, 1)], + [(1, 0), (2, 0), (1, 1), (2, 1)], + [(1, 0), (2, 0), (1, 1), (2, 1)], + ], + [ + [(1, 0), (0, 1), (1, 1), (2, 1)], + [(1, 0), (1, 1), (2, 1), (1, 2)], + [(0, 1), (1, 1), (2, 1), (1, 2)], + [(1, 0), (0, 1), (1, 1), (1, 2)], + ], + [ + [(1, 0), (2, 0), (0, 1), (1, 1)], + [(1, 0), (1, 1), (2, 1), (2, 2)], + [(1, 1), (2, 1), (0, 2), (1, 2)], + [(0, 0), (0, 1), (1, 1), (1, 2)], + ], + [ + [(0, 0), (1, 0), (1, 1), (2, 1)], + [(2, 0), (1, 1), (2, 1), (1, 2)], + [(0, 1), (1, 1), (1, 2), (2, 2)], + [(1, 0), (0, 1), (1, 1), (0, 2)], + ], + [ + [(0, 0), (0, 1), (1, 1), (2, 1)], + [(1, 0), (2, 0), (1, 1), (1, 2)], + [(0, 1), (1, 1), (2, 1), (2, 2)], + [(1, 0), (1, 1), (0, 2), (1, 2)], + ], + [ + [(2, 0), (0, 1), (1, 1), (2, 1)], + [(1, 0), (1, 1), (1, 2), (2, 2)], + [(0, 1), (1, 1), (2, 1), (0, 2)], + [(0, 0), (1, 0), (1, 1), (1, 2)], + ], +]; + +const SNAKE_W: i32 = 20; +const SNAKE_H: i32 = 15; + +const MINE_W: usize = 16; +const MINE_H: usize = 16; +const MINE_COUNT: usize = 40; + +#[derive(Clone, Copy)] +enum ActiveGame { + Tetris, + Snake, + Minesweeper, +} + +#[derive(Clone, Copy)] +struct Active { + kind: usize, + rot: usize, + x: i32, + y: i32, +} + +struct TetrisGame { + board: [[Option; BOARD_W]; BOARD_H], + active: Active, + next_kind: usize, + rng: u64, + score: u64, + lines: u32, + level: u32, + game_over: bool, + paused: bool, + last_drop_tick: u64, +} + +impl TetrisGame { + fn new(seed: u64, tick: u64) -> Self { + let mut game = Self { + board: [[None; BOARD_W]; BOARD_H], + active: Active { + kind: 0, + rot: 0, + x: 3, + y: 0, + }, + next_kind: 0, + rng: seed.wrapping_mul(1664525).wrapping_add(1013904223), + score: 0, + lines: 0, + level: 1, + game_over: false, + paused: false, + last_drop_tick: tick, + }; + game.active.kind = game.random_kind(); + game.next_kind = game.random_kind(); + game.active.rot = 0; + game.active.x = 3; + game.active.y = 0; + if !game.is_valid( + game.active.kind, + game.active.rot, + game.active.x, + game.active.y, + ) { + game.game_over = true; + } + game + } + + fn random_kind(&mut self) -> usize { + self.rng ^= self.rng << 13; + self.rng ^= self.rng >> 7; + self.rng ^= self.rng << 17; + (self.rng % 7) as usize + } + + fn gravity_interval(&self) -> u64 { + let level_speedup = self.level.min(18) as u64; + let speed = 20_u64.saturating_sub(level_speedup); + speed.max(2) + } + + fn is_valid(&self, kind: usize, rot: usize, x: i32, y: i32) -> bool { + for &(dx, dy) in &PIECES[kind][rot] { + let px = x + dx; + let py = y + dy; + if px < 0 || px >= BOARD_W as i32 || py >= BOARD_H as i32 { + return false; + } + if py >= 0 && self.board[py as usize][px as usize].is_some() { + return false; + } + } + true + } + + fn try_move(&mut self, dx: i32, dy: i32) -> bool { + let nx = self.active.x + dx; + let ny = self.active.y + dy; + if self.is_valid(self.active.kind, self.active.rot, nx, ny) { + self.active.x = nx; + self.active.y = ny; + return true; + } + false + } + + fn rotate_cw(&mut self) { + let new_rot = (self.active.rot + 1) % 4; + for (kx, ky) in KICKS { + let nx = self.active.x + kx; + let ny = self.active.y + ky; + if self.is_valid(self.active.kind, new_rot, nx, ny) { + self.active.rot = new_rot; + self.active.x = nx; + self.active.y = ny; + return; + } + } + } + + fn soft_drop_step(&mut self) { + if !self.try_move(0, 1) { + self.lock_active(); + self.clear_lines(); + self.spawn_next(); + } + } + + fn hard_drop(&mut self) { + while self.try_move(0, 1) {} + self.lock_active(); + self.clear_lines(); + self.spawn_next(); + } + + fn ghost_y(&self) -> i32 { + let mut y = self.active.y; + while self.is_valid(self.active.kind, self.active.rot, self.active.x, y + 1) { + y += 1; + } + y + } + + fn lock_active(&mut self) { + for &(dx, dy) in &PIECES[self.active.kind][self.active.rot] { + let px = self.active.x + dx; + let py = self.active.y + dy; + if py < 0 { + self.game_over = true; + continue; + } + if (0..BOARD_W as i32).contains(&px) && (0..BOARD_H as i32).contains(&py) { + self.board[py as usize][px as usize] = Some(self.active.kind); + } + } + } + + fn clear_lines(&mut self) { + let mut new_board = [[None; BOARD_W]; BOARD_H]; + let mut write_y = BOARD_H as i32 - 1; + let mut cleared = 0_u32; + + for y in (0..BOARD_H).rev() { + let full = self.board[y].iter().all(Option::is_some); + if full { + cleared += 1; + } else { + new_board[write_y as usize] = self.board[y]; + write_y -= 1; + } + } + + self.board = new_board; + + if cleared > 0 { + self.lines += cleared; + self.level = self.lines / 10 + 1; + self.score += match cleared { + 1 => 100, + 2 => 300, + 3 => 500, + 4 => 800, + _ => 0, + }; + } + } + + fn spawn_next(&mut self) { + self.active.kind = self.next_kind; + self.active.rot = 0; + self.active.x = 3; + self.active.y = 0; + self.next_kind = self.random_kind(); + if !self.is_valid( + self.active.kind, + self.active.rot, + self.active.x, + self.active.y, + ) { + self.game_over = true; + } + } + + fn restart(&mut self, seed: u64, tick: u64) { + *self = Self::new(seed, tick); + } + + fn sync_tick(&mut self, tick: u64) { + self.last_drop_tick = tick; + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Direction { + Up, + Down, + Left, + Right, +} + +struct SnakeGame { + snake: VecDeque<(i32, i32)>, + dir: Direction, + queued_dir: Direction, + food: (i32, i32), + rng: u64, + score: u32, + game_over: bool, + paused: bool, + last_move_tick: u64, +} + +impl SnakeGame { + fn new(seed: u64, tick: u64) -> Self { + let mut snake = VecDeque::new(); + snake.push_back((7, 7)); + snake.push_back((6, 7)); + snake.push_back((5, 7)); + let mut game = Self { + snake, + dir: Direction::Right, + queued_dir: Direction::Right, + food: (0, 0), + rng: seed.wrapping_mul(1664525).wrapping_add(1013904223), + score: 0, + game_over: false, + paused: false, + last_move_tick: tick, + }; + game.spawn_food(); + game + } + + fn next_rand(&mut self) -> u64 { + self.rng ^= self.rng << 13; + self.rng ^= self.rng >> 7; + self.rng ^= self.rng << 17; + self.rng + } + + fn move_interval(&self) -> u64 { + let base = 8_u64; + let speedup = (self.score / 4) as u64; + base.saturating_sub(speedup).max(2) + } + + fn is_opposite(a: Direction, b: Direction) -> bool { + matches!( + (a, b), + (Direction::Up, Direction::Down) + | (Direction::Down, Direction::Up) + | (Direction::Left, Direction::Right) + | (Direction::Right, Direction::Left) + ) + } + + fn set_direction(&mut self, next: Direction) { + if !Self::is_opposite(self.dir, next) { + self.queued_dir = next; + } + } + + fn spawn_food(&mut self) { + loop { + let x = (self.next_rand() % SNAKE_W as u64) as i32; + let y = (self.next_rand() % SNAKE_H as u64) as i32; + if !self.snake.iter().any(|&(sx, sy)| sx == x && sy == y) { + self.food = (x, y); + break; + } + } + } + + fn step(&mut self) { + if self.game_over || self.paused { + return; + } + + self.dir = self.queued_dir; + let (hx, hy) = self.snake.front().copied().unwrap_or((0, 0)); + let (nx, ny) = match self.dir { + Direction::Up => (hx, hy - 1), + Direction::Down => (hx, hy + 1), + Direction::Left => (hx - 1, hy), + Direction::Right => (hx + 1, hy), + }; + + if !(0..SNAKE_W).contains(&nx) || !(0..SNAKE_H).contains(&ny) { + self.game_over = true; + return; + } + + let will_grow = (nx, ny) == self.food; + let tail = self.snake.back().copied(); + if self.snake.iter().any(|&(x, y)| { + if will_grow { + x == nx && y == ny + } else if let Some((tx, ty)) = tail { + if x == tx && y == ty { + false + } else { + x == nx && y == ny + } + } else { + x == nx && y == ny + } + }) { + self.game_over = true; + return; + } + + self.snake.push_front((nx, ny)); + if will_grow { + self.score += 1; + if self.snake.len() < (SNAKE_W * SNAKE_H) as usize { + self.spawn_food(); + } + } else { + let _ = self.snake.pop_back(); + } + } + + fn restart(&mut self, seed: u64, tick: u64) { + *self = Self::new(seed, tick); + } + + fn sync_tick(&mut self, tick: u64) { + self.last_move_tick = tick; + } +} + +#[derive(Clone, Copy)] +struct MineCell { + mine: bool, + revealed: bool, + flagged: bool, + adjacent: u8, +} + +impl MineCell { + fn empty() -> Self { + Self { + mine: false, + revealed: false, + flagged: false, + adjacent: 0, + } + } +} + +struct MinesweeperGame { + board: [[MineCell; MINE_W]; MINE_H], + rng: u64, + cursor_x: usize, + cursor_y: usize, + first_reveal: bool, + game_over: bool, + won: bool, +} + +impl MinesweeperGame { + fn new(seed: u64) -> Self { + let mut game = Self { + board: [[MineCell::empty(); MINE_W]; MINE_H], + rng: seed.wrapping_mul(1664525).wrapping_add(1013904223), + cursor_x: 0, + cursor_y: 0, + first_reveal: true, + game_over: false, + won: false, + }; + game.generate_mines(None); + game + } + + fn next_rand(&mut self) -> u64 { + self.rng ^= self.rng << 13; + self.rng ^= self.rng >> 7; + self.rng ^= self.rng << 17; + self.rng + } + + fn generate_mines(&mut self, avoid: Option<(usize, usize)>) { + self.board = [[MineCell::empty(); MINE_W]; MINE_H]; + let mut placed = 0; + while placed < MINE_COUNT { + let x = (self.next_rand() % MINE_W as u64) as usize; + let y = (self.next_rand() % MINE_H as u64) as usize; + if let Some((ax, ay)) = avoid { + if x == ax && y == ay { + continue; + } + } + if self.board[y][x].mine { + continue; + } + self.board[y][x].mine = true; + placed += 1; + } + self.compute_adjacency(); + } + + fn compute_adjacency(&mut self) { + for y in 0..MINE_H { + for x in 0..MINE_W { + if self.board[y][x].mine { + self.board[y][x].adjacent = 0; + continue; + } + let mut count = 0_u8; + for ny in y.saturating_sub(1)..=((y + 1).min(MINE_H - 1)) { + for nx in x.saturating_sub(1)..=((x + 1).min(MINE_W - 1)) { + if nx == x && ny == y { + continue; + } + if self.board[ny][nx].mine { + count = count.saturating_add(1); + } + } + } + self.board[y][x].adjacent = count; + } + } + } + + fn reveal_current(&mut self) { + self.reveal(self.cursor_x, self.cursor_y); + } + + fn reveal(&mut self, x: usize, y: usize) { + if self.game_over || self.won { + return; + } + if self.board[y][x].flagged || self.board[y][x].revealed { + return; + } + + if self.first_reveal { + if self.board[y][x].mine { + self.generate_mines(Some((x, y))); + } + self.first_reveal = false; + } + + if self.board[y][x].mine { + self.board[y][x].revealed = true; + self.game_over = true; + self.reveal_all_mines(); + return; + } + + self.flood_reveal(x, y); + self.check_win(); + } + + fn flood_reveal(&mut self, x: usize, y: usize) { + let mut queue = VecDeque::new(); + queue.push_back((x, y)); + + while let Some((cx, cy)) = queue.pop_front() { + if self.board[cy][cx].revealed || self.board[cy][cx].flagged { + continue; + } + + self.board[cy][cx].revealed = true; + if self.board[cy][cx].adjacent != 0 { + continue; + } + + for ny in cy.saturating_sub(1)..=((cy + 1).min(MINE_H - 1)) { + for nx in cx.saturating_sub(1)..=((cx + 1).min(MINE_W - 1)) { + if nx == cx && ny == cy { + continue; + } + if !self.board[ny][nx].revealed && !self.board[ny][nx].mine { + queue.push_back((nx, ny)); + } + } + } + } + } + + fn toggle_flag_current(&mut self) { + if self.game_over || self.won { + return; + } + let cell = &mut self.board[self.cursor_y][self.cursor_x]; + if !cell.revealed { + cell.flagged = !cell.flagged; + } + } + + fn reveal_all_mines(&mut self) { + for y in 0..MINE_H { + for x in 0..MINE_W { + if self.board[y][x].mine { + self.board[y][x].revealed = true; + } + } + } + } + + fn check_win(&mut self) { + for y in 0..MINE_H { + for x in 0..MINE_W { + if !self.board[y][x].mine && !self.board[y][x].revealed { + return; + } + } + } + self.won = true; + } + + fn flags_count(&self) -> usize { + let mut n = 0; + for y in 0..MINE_H { + for x in 0..MINE_W { + if self.board[y][x].flagged { + n += 1; + } + } + } + n + } + + fn mines_remaining(&self) -> i32 { + MINE_COUNT as i32 - self.flags_count() as i32 + } + + fn restart(&mut self, seed: u64) { + *self = Self::new(seed); + } +} + +struct GameState { + active: ActiveGame, + theme_idx: usize, + tetris: TetrisGame, + snake: SnakeGame, + mines: MinesweeperGame, +} + +impl Default for GameState { + fn default() -> Self { + Self { + active: ActiveGame::Tetris, + theme_idx: 0, + tetris: TetrisGame::new(1, 0), + snake: SnakeGame::new(2, 0), + mines: MinesweeperGame::new(3), + } + } +} + +// ─── Anim demo state ──────────────────────────────────────────────── +struct AnimState { + progress_target: f64, + progress_tween: Tween, + spring_target: f64, + spring: Spring, + kf: Keyframes, + seq: Sequence, + stagger: Stagger, + anim_started: bool, + cb_tween: Tween, + cb_fired: bool, +} + +impl Default for AnimState { + fn default() -> Self { + let progress_target = 0.2; + let mut progress_tween = + Tween::new(progress_target, progress_target, 1).easing(ease_in_out_cubic); + progress_tween.reset(0); + + let kf = Keyframes::new(120) + .stop(0.0, 0.0) + .stop(0.3, 100.0) + .stop(0.7, 20.0) + .stop(1.0, 80.0) + .loop_mode(LoopMode::PingPong); + + let seq = Sequence::new() + .then(0.0, 80.0, 40, ease_out_quad) + .then(80.0, 20.0, 30, ease_in_out_cubic) + .then(20.0, 60.0, 20, ease_out_bounce) + .loop_mode(LoopMode::Repeat); + + let stagger = Stagger::new(0.0, 1.0, 30) + .delay(6) + .easing(ease_out_quad) + .items(5) + .loop_mode(LoopMode::Repeat); + + Self { + progress_target, + progress_tween, + spring_target: 0.0, + spring: Spring::new(0.0, 0.15, 0.85), + kf, + seq, + stagger, + anim_started: false, + cb_tween: Tween::new(0.0, 100.0, 120), + cb_fired: false, + } + } +} + +// ─── Tour state ───────────────────────────────────────────────────── +struct TourState { + tabs: TabsState, + /// Scroll offset for the active tab body. The Kitty Image tab owns + /// its own inner scrollable for the gallery; mouse-wheel events + /// inside that inner region are consumed there, while wheel events + /// outside the inner gallery (or on tabs with overflowing content) + /// scroll the tab body. + tab_scroll: ScrollState, + fire: FireState, + game: GameState, + raw_draw_tick_offset: u64, + kitty_scroll: ScrollState, + kitty_images: Vec<(String, Vec, u32, u32)>, + anim: AnimState, +} + +impl Default for TourState { + fn default() -> Self { + Self { + tabs: TabsState::new(vec![ + "Intro", + "Fire", + "Game", + "Raw Draw", + "Kitty Image", + "Anim", + ]), + tab_scroll: ScrollState::default(), + fire: FireState::default(), + game: GameState::default(), + raw_draw_tick_offset: 0, + kitty_scroll: ScrollState::default(), + kitty_images: build_kitty_images(), + anim: AnimState::default(), + } + } +} + +fn main() -> std::io::Result<()> { + let mut state = TourState::default(); + slt::run_with( + RunConfig::default() + .mouse(true) + .tick_rate(Duration::from_millis(33)) + .max_fps(60), + move |ui: &mut Context| { + // Top-of-frame Ctrl-Q quit. Esc and 'q' are NOT consumed + // here so each tab can claim them locally — Fire/Anim use + // Esc for quit, Game's `q` is handled inside its tab so + // text-like keys (1/2/3/r/p/f) are not stolen. + if ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("Canvas Tour: drawing & animation") + .p(pad) + .grow(1) + .col(|ui| { + let _ = ui.tabs(&mut state.tabs); + ui.separator(); + + // Wrap the tab body in a vertical scrollable so tabs + // with content that overflows the viewport stay + // reachable. The Kitty Image tab owns its own inner + // scrollable; the auto_scroll_nested logic inside + // `scrollable` makes the outer wrapper skip wheel + // events that land in the inner gallery, so the two + // do not fight each other. + let _ = ui.scrollable(&mut state.tab_scroll).grow(1).col(|ui| { + match state.tabs.selected { + 0 => render_intro(ui), + 1 => render_fire(ui, &mut state.fire), + 2 => render_game(ui, &mut state.game), + 3 => render_raw_draw(ui, &mut state.raw_draw_tick_offset), + 4 => render_kitty(ui, &mut state.kitty_scroll, &state.kitty_images), + 5 => render_anim(ui, &mut state.anim), + _ => {} + } + }); + }); + + // After-dispatch quit — `q`/Esc only quit when the active + // tab did not consume them. The Game tab claims `q` first + // because text-style keys belong to it; on every other tab + // these reach the top level and end the tour. + if ui.key('q') || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + }, + ) +} + +// ─── Intro tab ────────────────────────────────────────────────────── +fn render_intro(ui: &mut Context) { + let _ = ui.col(|ui| { + let pad = ui.spacing().xs(); + ui.text("Canvas / animation tour").bold(); + ui.text(""); + ui.text("Each tab embeds the matching standalone demo's render path.") + .dim(); + ui.text("Per-frame state (fire pixels, game boards, anim primitives)") + .dim(); + ui.text("lives in TourState so switching tabs and back resumes cleanly.") + .dim(); + ui.text(""); + let _ = ui + .bordered(Border::Single) + .title("Demos at a glance") + .p(pad) + .col(|ui| { + row_pair(ui, "Fire", "DOOM-style fire palette over half-block cells"); + row_pair(ui, "Game", "Tetris / Snake / Minesweeper switcher (1/2/3)"); + row_pair( + ui, + "Raw Draw", + "ContainerBuilder::draw(|buf, rect|) primitives", + ); + row_pair(ui, "Kitty", "Kitty graphics protocol image gallery"); + row_pair( + ui, + "Anim", + "Tween / Spring / Keyframes / Sequence / Stagger", + ); + }); + ui.text(""); + ui.text("Navigation: Tab focuses the bar, Left/Right switch tabs.") + .fg(Color::Cyan); + ui.text("Quit: q / Esc / Ctrl-Q (some tabs claim these locally).") + .fg(Color::Cyan); + }); +} + +fn row_pair(ui: &mut Context, label: &str, desc: &str) { + let _ = ui.row_gap(1, |ui| { + ui.text(format!("{label:<10}")).bold().fg(Color::Cyan); + ui.text(desc).dim(); + }); +} + +// ─── Fire tab ─────────────────────────────────────────────────────── +fn render_fire(ui: &mut Context, state: &mut FireState) { + if ui.key(' ') { + state.paused = !state.paused; + } + + let term_w = ui.width() as usize; + let term_h = ui.height() as usize; + + // Reserve a couple of cell-rows at the bottom for the help line so + // the fire grid does not collide with the outer border footer. + let help_rows = 2_usize; + let canvas_h = term_h.saturating_sub(help_rows + 2).max(2); + let canvas_w = term_w.saturating_sub(2).max(2); + + let fire_w = canvas_w; + let fire_h = canvas_h * 2; + + let fire = state.fire.get_or_insert_with(|| Fire::new(fire_w, fire_h)); + if fire.w != fire_w || fire.h != fire_h { + *fire = Fire::new(fire_w, fire_h); + } + + if !state.paused { + for _ in 0..2 { + fire.step(); + } + } + + let palette = &state.palette; + + let _ = ui.col(|ui| { + for row in 0..canvas_h { + let top_y = row * 2; + let bot_y = top_y + 1; + + let _ = ui.row(|ui| { + let mut run_start = 0; + let mut cur_top = fire.color_at(0, top_y, palette); + let mut cur_bot = fire.color_at(0, bot_y, palette); + + for col in 1..=canvas_w { + let (t, b) = if col < canvas_w { + ( + fire.color_at(col, top_y, palette), + fire.color_at(col, bot_y, palette), + ) + } else { + (Color::Reset, Color::Reset) + }; + + if col == canvas_w || t != cur_top || b != cur_bot { + let len = col - run_start; + let s: String = (0..len).map(|_| '\u{2580}').collect(); + ui.styled(s, Style::new().fg(cur_top).bg(cur_bot)); + run_start = col; + cur_top = t; + cur_bot = b; + } + } + }); + } + ui.text(if state.paused { + "PAUSED — Space resume | q/Esc quit" + } else { + "Space pause | q/Esc quit" + }) + .dim() + .fg(Color::Cyan); + }); +} + +// ─── Game tab (Tetris / Snake / Minesweeper) ─────────────────────── +fn render_game(ui: &mut Context, state: &mut GameState) { + let themes: [fn() -> Theme; 7] = [ + Theme::dark, + Theme::light, + Theme::dracula, + Theme::catppuccin, + Theme::nord, + Theme::solarized_dark, + Theme::tokyo_night, + ]; + let theme_names = [ + "Dark", + "Light", + "Dracula", + "Catppuccin", + "Nord", + "Solarized", + "Tokyo Night", + ]; + + if ui.key('t') { + state.theme_idx = (state.theme_idx + 1) % themes.len(); + } + let theme = themes[state.theme_idx](); + let theme_name = theme_names[state.theme_idx]; + let tick = ui.tick(); + + let mut switched = false; + if ui.key('1') { + state.active = ActiveGame::Tetris; + switched = true; + } + if ui.key('2') { + state.active = ActiveGame::Snake; + switched = true; + } + if ui.key('3') { + state.active = ActiveGame::Minesweeper; + switched = true; + } + if switched { + state.tetris.sync_tick(tick); + state.snake.sync_tick(tick); + } + + match state.active { + ActiveGame::Tetris => { + if ui.key('r') { + state + .tetris + .restart(tick.wrapping_mul(7919).wrapping_add(state.tetris.rng), tick); + } + + if ui.key('p') && !state.tetris.game_over { + state.tetris.paused = !state.tetris.paused; + state.tetris.last_drop_tick = tick; + } + + if !state.tetris.paused && !state.tetris.game_over { + if ui.key_code(KeyCode::Left) { + state.tetris.try_move(-1, 0); + } + if ui.key_code(KeyCode::Right) { + state.tetris.try_move(1, 0); + } + if ui.key_code(KeyCode::Up) { + state.tetris.rotate_cw(); + } + if ui.key_code(KeyCode::Down) { + state.tetris.soft_drop_step(); + state.tetris.last_drop_tick = tick; + } + if ui.key(' ') { + state.tetris.hard_drop(); + state.tetris.last_drop_tick = tick; + } + if tick.saturating_sub(state.tetris.last_drop_tick) + >= state.tetris.gravity_interval() + { + state.tetris.soft_drop_step(); + state.tetris.last_drop_tick = tick; + } + } + } + ActiveGame::Snake => { + if ui.key('r') { + state + .snake + .restart(tick.wrapping_mul(7919).wrapping_add(state.snake.rng), tick); + } + if ui.key('p') && !state.snake.game_over { + state.snake.paused = !state.snake.paused; + state.snake.last_move_tick = tick; + } + + if ui.key_code(KeyCode::Left) { + state.snake.set_direction(Direction::Left); + } + if ui.key_code(KeyCode::Right) { + state.snake.set_direction(Direction::Right); + } + if ui.key_code(KeyCode::Up) { + state.snake.set_direction(Direction::Up); + } + if ui.key_code(KeyCode::Down) { + state.snake.set_direction(Direction::Down); + } + + if !state.snake.game_over + && !state.snake.paused + && tick.saturating_sub(state.snake.last_move_tick) >= state.snake.move_interval() + { + state.snake.step(); + state.snake.last_move_tick = tick; + } + } + ActiveGame::Minesweeper => { + if ui.key('r') { + state + .mines + .restart(tick.wrapping_mul(7919).wrapping_add(state.mines.rng)); + } + + if ui.key_code(KeyCode::Left) { + state.mines.cursor_x = state.mines.cursor_x.saturating_sub(1); + } + if ui.key_code(KeyCode::Right) { + state.mines.cursor_x = (state.mines.cursor_x + 1).min(MINE_W - 1); + } + if ui.key_code(KeyCode::Up) { + state.mines.cursor_y = state.mines.cursor_y.saturating_sub(1); + } + if ui.key_code(KeyCode::Down) { + state.mines.cursor_y = (state.mines.cursor_y + 1).min(MINE_H - 1); + } + + if ui.key('f') { + state.mines.toggle_flag_current(); + } + if ui.key(' ') || ui.key_code(KeyCode::Enter) { + state.mines.reveal_current(); + } + } + } + + render_game_header(ui, state.active, theme, theme_name); + + let _ = ui.container().grow(1).col(|ui| { + ui.spacer(); + match state.active { + ActiveGame::Tetris => render_tetris_screen(ui, &state.tetris, theme), + ActiveGame::Snake => render_snake_screen(ui, &state.snake, theme), + ActiveGame::Minesweeper => render_minesweeper_screen(ui, &state.mines, theme), + } + ui.spacer(); + }); +} + +fn piece_color(kind: usize) -> Color { + match kind { + 0 => Color::Cyan, + 1 => Color::Yellow, + 2 => Color::Magenta, + 3 => Color::Green, + 4 => Color::Red, + 5 => Color::Blue, + _ => Color::Rgb(255, 165, 0), + } +} + +fn active_at(game: &TetrisGame, x: usize, y: usize) -> bool { + for &(dx, dy) in &PIECES[game.active.kind][game.active.rot] { + let px = game.active.x + dx; + let py = game.active.y + dy; + if px == x as i32 && py == y as i32 { + return true; + } + } + false +} + +fn ghost_at(game: &TetrisGame, ghost_y: i32, x: usize, y: usize) -> bool { + for &(dx, dy) in &PIECES[game.active.kind][game.active.rot] { + let px = game.active.x + dx; + let py = ghost_y + dy; + if px == x as i32 && py == y as i32 { + return true; + } + } + false +} + +fn next_preview_at(kind: usize, x: usize, y: usize) -> bool { + for &(px, py) in &PIECES[kind][0] { + if px == x as i32 && py == y as i32 { + return true; + } + } + false +} + +fn format_score(n: u64) -> String { + let s = n.to_string(); + let bytes = s.as_bytes(); + let len = bytes.len(); + let mut out = String::with_capacity(len + len / 3); + for (i, &b) in bytes.iter().enumerate() { + if i > 0 && (len - i) % 3 == 0 { + out.push(','); + } + out.push(b as char); + } + out +} + +fn render_tetris_board(ui: &mut Context, game: &TetrisGame, theme: Theme) { + let ghost_y = game.ghost_y(); + for y in 0..BOARD_H { + let _ = ui.row(|ui| { + for x in 0..BOARD_W { + if let Some(kind) = game.board[y][x] { + ui.styled("\u{2588}\u{2588}", Style::new().fg(piece_color(kind))); + } else if active_at(game, x, y) { + ui.styled( + "\u{2588}\u{2588}", + Style::new().fg(piece_color(game.active.kind)), + ); + } else if ghost_y != game.active.y && ghost_at(game, ghost_y, x, y) { + ui.styled("\u{2591}\u{2591}", Style::new().fg(theme.text_dim)); + } else { + ui.styled("\u{00B7} ", Style::new().fg(theme.text_dim)); + } + } + }); + } +} + +fn render_tetris_next(ui: &mut Context, kind: usize) { + let color = piece_color(kind); + for y in 0..4 { + let _ = ui.row(|ui| { + for x in 0..4 { + if next_preview_at(kind, x, y) { + ui.styled("\u{2588}\u{2588}", Style::new().fg(color)); + } else { + ui.text(" "); + } + } + }); + } +} + +fn render_snake_board(ui: &mut Context, game: &SnakeGame, theme: Theme) { + for y in 0..SNAKE_H { + let _ = ui.row(|ui| { + for x in 0..SNAKE_W { + if (x, y) == game.food { + ui.styled("\u{25CF} ", Style::new().fg(theme.accent)); + } else if let Some(&(hx, hy)) = game.snake.front() { + if (x, y) == (hx, hy) { + ui.styled("\u{2588}\u{2588}", Style::new().fg(theme.success)); + } else if game.snake.iter().any(|&(sx, sy)| (sx, sy) == (x, y)) { + ui.styled("\u{2588}\u{2588}", Style::new().fg(theme.primary)); + } else { + ui.styled("\u{00B7} ", Style::new().fg(theme.text_dim)); + } + } else { + ui.styled("\u{00B7} ", Style::new().fg(theme.text_dim)); + } + } + }); + } +} + +fn mine_number_color(n: u8) -> Color { + match n { + 1 => Color::Blue, + 2 => Color::Green, + 3 => Color::Red, + 4 => Color::Rgb(0, 0, 139), + 5 => Color::Rgb(139, 0, 0), + 6 => Color::Cyan, + 7 => Color::Black, + 8 => Color::Rgb(128, 128, 128), + _ => Color::White, + } +} + +fn render_mine_board(ui: &mut Context, game: &MinesweeperGame, theme: Theme) { + for y in 0..MINE_H { + let _ = ui.row(|ui| { + for x in 0..MINE_W { + let cell = game.board[y][x]; + let mut style = Style::new(); + let content = if cell.revealed { + if cell.mine { + style = style.fg(theme.error); + "*" + } else if cell.adjacent == 0 { + style = style.fg(theme.text_dim); + " " + } else { + style = style.fg(mine_number_color(cell.adjacent)); + match cell.adjacent { + 1 => "1", + 2 => "2", + 3 => "3", + 4 => "4", + 5 => "5", + 6 => "6", + 7 => "7", + _ => "8", + } + } + } else if cell.flagged { + style = style.fg(theme.warning); + "\u{2691}" + } else { + style = style.fg(theme.text_dim); + "\u{00B7}" + }; + + if x == game.cursor_x && y == game.cursor_y { + style = style.reversed(); + } + ui.styled(format!("{} ", content), style); + } + }); + } +} + +fn render_game_header(ui: &mut Context, active: ActiveGame, theme: Theme, theme_name: &str) { + let _ = ui + .container() + .bg(theme.surface) + .border(Border::Rounded) + .border_style(Style::new().fg(theme.border)) + .px(2) + .py(1) + .row(|ui| { + let tetris_style = if matches!(active, ActiveGame::Tetris) { + Style::new().fg(theme.primary).bold() + } else { + Style::new().fg(theme.text_dim) + }; + let snake_style = if matches!(active, ActiveGame::Snake) { + Style::new().fg(theme.primary).bold() + } else { + Style::new().fg(theme.text_dim) + }; + let mine_style = if matches!(active, ActiveGame::Minesweeper) { + Style::new().fg(theme.primary).bold() + } else { + Style::new().fg(theme.text_dim) + }; + + ui.styled("[1] Tetris", tetris_style); + ui.text(" ").fg(theme.surface_text); + ui.styled("[2] Snake", snake_style); + ui.text(" ").fg(theme.surface_text); + ui.styled("[3] Minesweeper", mine_style); + ui.spacer(); + ui.text(format!("Theme: {}", theme_name)) + .fg(theme.surface_text); + ui.text(" t cycle q quit").fg(theme.text_dim); + }); +} + +fn render_tetris_screen(ui: &mut Context, game: &TetrisGame, theme: Theme) { + let game_w = 45_u32; + let left = ui.width().saturating_sub(game_w) / 2; + + let _ = ui + .bordered(Border::Rounded) + .title_styled(" T E T R I S ", Style::new().bold().fg(theme.primary)) + .border_style(Style::new().fg(theme.border)) + .bg(theme.surface) + .w(game_w) + .ml(left) + .col(|ui| { + let _ = ui.row_gap(1, |ui| { + let _ = ui + .bordered(Border::Single) + .border_style(Style::new().fg(theme.border)) + .col(|ui| { + render_tetris_board(ui, game, theme); + }); + + let _ = ui.container().w(20).col(|ui| { + let _ = ui + .container() + .bg(theme.surface) + .border(Border::Rounded) + .title("NEXT") + .border_style(Style::new().fg(theme.border)) + .px(2) + .py(1) + .col(|ui| { + render_tetris_next(ui, game.next_kind); + }); + + let _ = ui + .container() + .bg(theme.surface) + .border(Border::Rounded) + .title("SCORE") + .border_style(Style::new().fg(theme.border)) + .px(1) + .col(|ui| { + ui.text(format_score(game.score)) + .bold() + .fg(theme.surface_text); + let _ = ui.row(|ui| { + ui.text("LEVEL").fg(theme.text_dim); + ui.spacer(); + ui.text(format!("{}", game.level)).bold().fg(theme.primary); + }); + let _ = ui.row(|ui| { + ui.text("LINES").fg(theme.text_dim); + ui.spacer(); + ui.text(format!("{}", game.lines)).bold().fg(theme.accent); + }); + }); + + ui.spacer(); + + if game.game_over { + ui.text(" GAME OVER").bold().fg(theme.error); + ui.text(format!(" Score: {}", format_score(game.score))) + .fg(theme.text_dim); + ui.text(" [R] Restart").fg(theme.text_dim); + } else if game.paused { + ui.text(" PAUSED").bold().fg(theme.warning); + ui.text(" [P] Resume").fg(theme.text_dim); + } + + ui.separator(); + ui.text(" Arrows Move/Rotate").fg(theme.text_dim); + ui.text(" SPC Drop P Pause").fg(theme.text_dim); + ui.text(" R Reset Q Quit").fg(theme.text_dim); + }); + }); + }); +} + +fn render_snake_screen(ui: &mut Context, game: &SnakeGame, theme: Theme) { + let game_w = 58_u32; + let left = ui.width().saturating_sub(game_w) / 2; + + let _ = ui + .container() + .bg(theme.surface) + .border(Border::Rounded) + .title_styled(" S N A K E ", Style::new().bold().fg(theme.primary)) + .border_style(Style::new().fg(theme.border)) + .w(game_w) + .ml(left) + .col(|ui| { + let _ = ui.row_gap(1, |ui| { + let _ = ui + .bordered(Border::Single) + .border_style(Style::new().fg(theme.border)) + .col(|ui| { + render_snake_board(ui, game, theme); + }); + + let _ = ui + .container() + .bg(theme.surface) + .border(Border::Rounded) + .border_style(Style::new().fg(theme.border)) + .w(14) + .px(1) + .col(|ui| { + ui.text("SCORE").bold().fg(theme.surface_text); + ui.text(format!("{}", game.score)).bold().fg(theme.primary); + ui.text("SPEED").bold().fg(theme.surface_text); + ui.text(format!("{}", 10_u64.saturating_sub(game.move_interval()))) + .fg(theme.accent); + ui.separator(); + if game.game_over { + ui.text("GAME OVER").bold().fg(theme.error); + } else if game.paused { + ui.text("PAUSED").bold().fg(theme.warning); + } + ui.separator(); + ui.text("Arrows Move").fg(theme.text_dim); + ui.text("P Pause").fg(theme.text_dim); + ui.text("R Restart").fg(theme.text_dim); + }); + }); + }); +} + +fn render_minesweeper_screen(ui: &mut Context, game: &MinesweeperGame, theme: Theme) { + let game_w = 56_u32; + let left = ui.width().saturating_sub(game_w) / 2; + + let _ = ui + .container() + .bg(theme.surface) + .border(Border::Rounded) + .title_styled( + " M I N E S W E E P E R ", + Style::new().bold().fg(theme.primary), + ) + .border_style(Style::new().fg(theme.border)) + .w(game_w) + .ml(left) + .col(|ui| { + let _ = ui.row_gap(1, |ui| { + let _ = ui + .bordered(Border::Single) + .border_style(Style::new().fg(theme.border)) + .col(|ui| { + render_mine_board(ui, game, theme); + }); + + let _ = ui + .container() + .bg(theme.surface) + .border(Border::Rounded) + .border_style(Style::new().fg(theme.border)) + .w(18) + .px(1) + .col(|ui| { + ui.text("MINES").bold().fg(theme.surface_text); + ui.text(format!("{}", game.mines_remaining())) + .bold() + .fg(theme.primary); + ui.text("FLAGS").bold().fg(theme.surface_text); + ui.text(format!("{}", game.flags_count())).fg(theme.accent); + ui.separator(); + if game.game_over { + ui.text("GAME OVER").bold().fg(theme.error); + } else if game.won { + ui.text("YOU WIN").bold().fg(theme.success); + } + ui.separator(); + ui.text("Arrows Move").fg(theme.text_dim); + ui.text("Enter/Space Reveal").fg(theme.text_dim); + ui.text("F Flag").fg(theme.text_dim); + ui.text("R Restart").fg(theme.text_dim); + }); + }); + }); +} + +// ─── Raw Draw tab ─────────────────────────────────────────────────── +fn render_raw_draw(ui: &mut Context, tick_offset: &mut u64) { + *tick_offset = ui.tick(); + let t_offset = *tick_offset; + + let _ = ui + .bordered(Border::Rounded) + .title("draw_raw demo") + .p(1) + .gap(1) + .grow(1) + .col(|ui| { + ui.text("Direct buffer access via ContainerBuilder::draw()") + .bold(); + ui.text("Each tile owns its rect; closures run at flush time.") + .dim(); + + let _ = ui.row(|ui| { + ui.bordered(Border::Single) + .title("Gradient") + .w(34) + .h(12) + .draw(|buf: &mut Buffer, rect: Rect| { + for y in rect.y..rect.bottom() { + for x in rect.x..rect.right() { + let r = ((x - rect.x) as f32 / rect.width as f32 * 255.0) as u8; + let b = ((y - rect.y) as f32 / rect.height as f32 * 255.0) as u8; + buf.set_char( + x, + y, + '\u{2588}', + Style::new().fg(Color::Rgb(r, 80, b)), + ); + } + } + }); + + ui.bordered(Border::Single) + .title("Plasma") + .w(34) + .h(12) + .draw(move |buf: &mut Buffer, rect: Rect| { + let t = t_offset as f64 * 0.05; + for y in rect.y..rect.bottom() { + for x in rect.x..rect.right() { + let fx = (x - rect.x) as f64 * 0.15; + let fy = (y - rect.y) as f64 * 0.3; + let v = ((fx + t).sin() + + (fy + t * 0.7).cos() + + ((fx + fy + t * 0.5).sin())) + / 3.0; + let n = ((v + 1.0) * 0.5 * 255.0) as u8; + let r = n; + let g = 255 - n; + let b = ((n as u16 + 128) % 256) as u8; + buf.set_char( + x, + y, + '\u{2593}', + Style::new().fg(Color::Rgb(r, g, b)), + ); + } + } + }); + + ui.bordered(Border::Single) + .title("Box Drawing") + .w(20) + .h(12) + .draw(|buf: &mut Buffer, rect: Rect| { + let chars = [ + '\u{250C}', '\u{2500}', '\u{2510}', '\u{2502}', ' ', '\u{2502}', + '\u{2514}', '\u{2500}', '\u{2518}', + ]; + let w = rect.width.min(18); + let h = rect.height.min(10); + for dy in 0..h { + for dx in 0..w { + let ci = if dy == 0 { + if dx == 0 { + 0 + } else if dx == w - 1 { + 2 + } else { + 1 + } + } else if dy == h - 1 { + if dx == 0 { + 6 + } else if dx == w - 1 { + 8 + } else { + 7 + } + } else if dx == 0 { + 3 + } else if dx == w - 1 { + 5 + } else { + 4 + }; + buf.set_char( + rect.x + dx, + rect.y + dy, + chars[ci], + Style::new().fg(Color::Cyan), + ); + } + } + }); + }); + + ui.text("q/Esc quit").dim().fg(Color::Cyan); + }); +} + +// ─── Kitty Image tab ──────────────────────────────────────────────── +fn render_kitty( + ui: &mut Context, + scroll: &mut ScrollState, + images: &[(String, Vec, u32, u32)], +) { + if ui.key('j') || ui.key_code(KeyCode::Down) { + scroll.offset = scroll.offset.saturating_add(2); + } + if ui.key('k') || ui.key_code(KeyCode::Up) { + scroll.offset = scroll.offset.saturating_sub(2); + } + + let _ = ui + .bordered(Border::Rounded) + .title("Kitty Image Gallery") + .grow(1) + .col(|ui| { + let _ = ui.row(|ui| { + ui.text("j/k or Up/Down scroll | q/Esc quit").dim(); + ui.spacer(); + ui.text(format!("offset: {}", scroll.offset)).dim(); + }); + + let _ = ui.scrollable(scroll).grow(1).col(|ui| { + for (i, (label, rgba, w, h)) in images.iter().enumerate() { + ui.text(format!("{}. {}", i + 1, label)) + .bold() + .fg(Color::Yellow); + let _ = ui.kitty_image(rgba, *w, *h, 30, 8); + if i < images.len() - 1 { + ui.separator(); + } + } + ui.text(""); + ui.text("--- End of gallery ---").dim(); + }); + }); +} + +fn build_kitty_images() -> Vec<(String, Vec, u32, u32)> { + vec![ + gradient_image("Red-Blue", 120, 60, (255, 60, 60), (60, 60, 255)), + gradient_image("Green-Yellow", 120, 60, (60, 255, 60), (255, 255, 60)), + checkerboard_image("Checkerboard", 120, 60, 12), + gradient_image("Cyan-Magenta", 120, 60, (60, 255, 255), (255, 60, 255)), + stripe_image("Rainbow", 120, 60), + gradient_image("White-Black", 120, 60, (240, 240, 240), (20, 20, 20)), + gradient_image("Orange-Purple", 120, 60, (255, 140, 0), (128, 0, 128)), + checkerboard_image("Fine Grid", 120, 60, 6), + gradient_image("Teal-Rose", 120, 60, (0, 128, 128), (255, 100, 130)), + stripe_image("Rainbow 2", 120, 60), + ] +} + +fn gradient_image( + label: &str, + width: u32, + height: u32, + from: (u8, u8, u8), + to: (u8, u8, u8), +) -> (String, Vec, u32, u32) { + let mut rgba = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + let t = y as f64 / height.max(1) as f64; + let r = lerp(from.0, to.0, t); + let g = lerp(from.1, to.1, t); + let b = lerp(from.2, to.2, t); + for _x in 0..width { + rgba.extend_from_slice(&[r, g, b, 255]); + } + } + (label.to_string(), rgba, width, height) +} + +fn checkerboard_image( + label: &str, + width: u32, + height: u32, + cell_size: u32, +) -> (String, Vec, u32, u32) { + let mut rgba = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + let is_dark = ((x / cell_size) + (y / cell_size)) % 2 == 0; + let v = if is_dark { 40u8 } else { 200u8 }; + rgba.extend_from_slice(&[v, v, v, 255]); + } + } + (label.to_string(), rgba, width, height) +} + +fn stripe_image(label: &str, width: u32, height: u32) -> (String, Vec, u32, u32) { + let colors: [(u8, u8, u8); 7] = [ + (255, 0, 0), + (255, 127, 0), + (255, 255, 0), + (0, 255, 0), + (0, 0, 255), + (75, 0, 130), + (148, 0, 211), + ]; + let mut rgba = Vec::with_capacity((width * height * 4) as usize); + let stripe_h = height / colors.len() as u32; + for y in 0..height { + let idx = ((y / stripe_h.max(1)) as usize).min(colors.len() - 1); + let (r, g, b) = colors[idx]; + for _x in 0..width { + rgba.extend_from_slice(&[r, g, b, 255]); + } + } + (label.to_string(), rgba, width, height) +} + +fn lerp(a: u8, b: u8, t: f64) -> u8 { + (a as f64 + (b as f64 - a as f64) * t).clamp(0.0, 255.0) as u8 +} + +// ─── Anim tab ─────────────────────────────────────────────────────── +fn render_anim(ui: &mut Context, state: &mut AnimState) { + if ui.key(' ') { + let current = state.progress_tween.value(ui.tick()); + state.progress_target = if state.progress_target < 0.5 { + 0.9 + } else { + 0.1 + }; + state.progress_tween = + Tween::new(current, state.progress_target, 12).easing(ease_in_out_cubic); + state.progress_tween.reset(ui.tick()); + } + + if ui.key('r') { + let t = ui.tick(); + state.kf.reset(t); + state.seq.reset(t); + state.stagger.reset(t); + state.anim_started = true; + state.progress_tween = Tween::new(0.1, 0.9, 12).easing(ease_in_out_cubic); + state.progress_tween.reset(t); + state.spring_target = 0.0; + state.spring = Spring::new(0.0, 0.15, 0.85); + } + + if !state.anim_started { + let t = ui.tick(); + state.kf.reset(t); + state.seq.reset(t); + state.stagger.reset(t); + state.anim_started = true; + } + + if ui.key_code(KeyCode::Up) || ui.key('k') { + state.spring_target += 10.0; + state.spring.set_target(state.spring_target); + } + if ui.key_code(KeyCode::Down) || ui.key('j') { + state.spring_target -= 10.0; + state.spring.set_target(state.spring_target); + } + + state.spring.tick(); + let progress = state.progress_tween.value(ui.tick()); + + let _ = ui + .bordered(Border::Rounded) + .title("Animation Primitives") + .p(1) + .gap(1) + .grow(1) + .col(|ui| { + let _ = ui + .bordered(Border::Single) + .title("Tween") + .p(1) + .gap(1) + .col(|ui| { + ui.text("Press Space to retarget"); + let _ = ui.progress(progress); + ui.text(format!( + "value {:.2} -> target {:.2} | done {}", + progress, + state.progress_target, + state.progress_tween.is_done() + )); + }); + + let _ = ui + .bordered(Border::Single) + .title("Spring") + .p(1) + .gap(1) + .col(|ui| { + ui.text("Up/k +10, Down/j -10"); + ui.text(format!( + "value {:.2} | target {:.2} | settled {}", + state.spring.value(), + state.spring_target, + state.spring.is_settled() + )); + }); + + let _ = ui + .bordered(Border::Single) + .title("Keyframes") + .p(1) + .gap(1) + .col(|ui| { + let kf_val = state.kf.value(ui.tick()); + let _ = ui.progress(kf_val / 100.0); + ui.text(format!( + "value {:.1} | done {} | mode PingPong", + kf_val, + state.kf.is_done() + )); + ui.text("4 stops: 0->100->20->80").dim(); + }); + + let _ = ui + .bordered(Border::Single) + .title("Sequence") + .p(1) + .gap(1) + .col(|ui| { + let seq_val = state.seq.value(ui.tick()); + let _ = ui.progress(seq_val / 100.0); + ui.text(format!( + "value {:.1} | done {} | mode Repeat", + seq_val, + state.seq.is_done() + )); + ui.text("3 chained: 0->80->20->60").dim(); + }); + + let _ = ui + .bordered(Border::Single) + .title("Stagger") + .p(1) + .gap(1) + .col(|ui| { + let labels = ["Item A", "Item B", "Item C", "Item D", "Item E"]; + for (i, label) in labels.iter().enumerate() { + let val = state.stagger.value(ui.tick(), i); + let _ = ui.row(|ui| { + ui.text(format!("{label}:")); + let _ = ui.progress(val); + }); + } + ui.text("5 items, 6-tick delay each").dim(); + }); + + let accent = ui.theme().accent; + ui.text("Callback").bold().fg(accent); + let val = state.cb_tween.value(ui.tick()); + let _ = ui.progress(val / 100.0); + if state.cb_tween.is_done() && !state.cb_fired { + state.cb_fired = true; + } + let _ = ui.row_gap(1, |ui| { + if state.cb_fired { + ui.text("on_complete fired!").fg(Color::Green); + } + if ui.button("Restart").clicked { + state.cb_tween.reset(ui.tick()); + state.cb_fired = false; + } + }); + + ui.text("space tween | up/down spring | r restart all | q/Esc quit") + .dim() + .fg(Color::Cyan); + }); +} diff --git a/examples/cookbook_dashboard.rs b/examples/cookbook_dashboard.rs index 51b18be..997137b 100644 --- a/examples/cookbook_dashboard.rs +++ b/examples/cookbook_dashboard.rs @@ -1,11 +1,18 @@ //! Cookbook: simulated real-time dashboard with line chart and sparklines. //! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! //! Demonstrates: //! - storing a rolling history (`VecDeque` capped at 60 points) //! - `ui.chart(..)` with a line dataset //! - two `ui.sparkline` rows for secondary metrics //! - stat tiles with `ui.stat` / `ui.stat_colored` //! - `q` to quit +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo (e.g. `cookbook_tour.rs`) can embed it without losing +//! the rolling histories every frame. The standalone `main()` is a thin +//! wrapper that owns the state. use std::collections::VecDeque; @@ -35,73 +42,113 @@ fn push(buf: &mut VecDeque, v: f64) { buf.push_back(v); } -fn main() -> std::io::Result<()> { - let mut cpu_hist: VecDeque = VecDeque::with_capacity(MAX_POINTS); - let mut mem_hist: VecDeque = VecDeque::with_capacity(MAX_POINTS); - let mut req_hist: VecDeque = VecDeque::with_capacity(MAX_POINTS); +/// Persistent rolling histories. Bundled into a struct so a composing +/// demo can hold it across frames — keeping these as `main`-local locals +/// breaks the moment the tour re-enters this demo on every tab switch. +pub struct DemoState { + cpu_hist: VecDeque, + mem_hist: VecDeque, + req_hist: VecDeque, +} - slt::run(|ui: &mut Context| { - if ui.key('q') || ui.key_mod('q', KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { - ui.quit(); +impl Default for DemoState { + fn default() -> Self { + // Pre-fill the rolling histories with the same deterministic + // mock data the per-frame `tick_metrics` produces for ticks + // 0..MAX_POINTS. Without this prefill the very first frame + // renders an empty chart and three flat sparklines that look + // broken to a first-time viewer; the real metrics only catch + // up after MAX_POINTS frames of `push`. The standalone + // `main()` and any composing tour inherit the populated + // histories on cold start, then `render` continues with the + // existing per-frame `push` (which `pop_front`s once full), + // so behaviour after frame 0 is identical to the unprefilled + // version. + let mut s = Self { + cpu_hist: VecDeque::with_capacity(MAX_POINTS), + mem_hist: VecDeque::with_capacity(MAX_POINTS), + req_hist: VecDeque::with_capacity(MAX_POINTS), + }; + for t in 0..(MAX_POINTS as u64) { + let m = tick_metrics(t); + s.cpu_hist.push_back(m.cpu); + s.mem_hist.push_back(m.mem); + s.req_hist.push_back(m.req_per_s); } + s + } +} - let tick = ui.tick(); - let m = tick_metrics(tick); - push(&mut cpu_hist, m.cpu); - push(&mut mem_hist, m.mem); - push(&mut req_hist, m.req_per_s); +/// Render one frame of the dashboard. Caller owns the rolling +/// histories so they survive across frames (and across tab switches in +/// a composing demo). +pub fn render(ui: &mut Context, state: &mut DemoState) { + let tick = ui.tick(); + let m = tick_metrics(tick); + push(&mut state.cpu_hist, m.cpu); + push(&mut state.mem_hist, m.mem); + push(&mut state.req_hist, m.req_per_s); - let chart_w = ui.width().saturating_sub(6).max(20); - let chart_h = ui.height().saturating_sub(18).max(8); - let cpu_points: Vec<(f64, f64)> = cpu_hist - .iter() - .enumerate() - .map(|(i, v)| (i as f64, *v)) - .collect(); - let mem_slice: Vec = mem_hist.iter().copied().collect(); - let req_slice: Vec = req_hist.iter().copied().collect(); + let chart_w = ui.width().saturating_sub(6).max(20); + let chart_h = ui.height().saturating_sub(18).max(8); + let cpu_points: Vec<(f64, f64)> = state + .cpu_hist + .iter() + .enumerate() + .map(|(i, v)| (i as f64, *v)) + .collect(); + let mem_slice: Vec = state.mem_hist.iter().copied().collect(); + let req_slice: Vec = state.req_hist.iter().copied().collect(); - let _ = ui - .bordered(Border::Rounded) - .title("Cookbook — Dashboard") - .p(1) - .gap(1) - .grow(1) - .col(|ui| { - let _ = ui.row_gap(2, |ui| { - let _ = ui.bordered(Border::Single).p(1).grow(1).col(|ui| { - let _ = ui.stat_colored("CPU", &format!("{:.1}%", m.cpu), Color::Cyan); - }); - let _ = ui.bordered(Border::Single).p(1).grow(1).col(|ui| { - let _ = ui.stat_colored("Memory", &format!("{:.1}%", m.mem), Color::Yellow); - }); - let _ = ui.bordered(Border::Single).p(1).grow(1).col(|ui| { - let _ = - ui.stat_colored("Req/s", &format!("{:.0}", m.req_per_s), Color::Green); - }); + let _ = ui + .bordered(Border::Rounded) + .title("Cookbook: Dashboard") + .p(1) + .gap(1) + .grow(1) + .col(|ui| { + let _ = ui.row_gap(2, |ui| { + let _ = ui.bordered(Border::Single).p(1).grow(1).col(|ui| { + let _ = ui.stat_colored("CPU", &format!("{:.1}%", m.cpu), Color::Cyan); }); - - ui.text("CPU history (last 60 ticks)").dim(); - let _ = ui.chart( - |c| { - let _ = c.line(&cpu_points).color(Color::Cyan).label("cpu"); - c.grid(true); - }, - chart_w, - chart_h, - ); - - let spark_w = ui.width().saturating_sub(14).max(20); - let _ = ui.row_gap(2, |ui| { - ui.text("Memory").dim(); - let _ = ui.sparkline(&mem_slice, spark_w); + let _ = ui.bordered(Border::Single).p(1).grow(1).col(|ui| { + let _ = ui.stat_colored("Memory", &format!("{:.1}%", m.mem), Color::Yellow); }); - let _ = ui.row_gap(2, |ui| { - ui.text("Req/s ").dim(); - let _ = ui.sparkline(&req_slice, spark_w); + let _ = ui.bordered(Border::Single).p(1).grow(1).col(|ui| { + let _ = ui.stat_colored("Req/s", &format!("{:.0}", m.req_per_s), Color::Green); }); + }); + + ui.text("CPU history (last 60 ticks)").dim(); + let _ = ui.chart( + |c| { + let _ = c.line(&cpu_points).color(Color::Cyan).label("cpu"); + c.grid(true); + }, + chart_w, + chart_h, + ); - ui.text("q or Ctrl+Q to quit.").dim(); + let spark_w = ui.width().saturating_sub(14).max(20); + let _ = ui.row_gap(2, |ui| { + ui.text("Memory").dim(); + let _ = ui.sparkline(&mem_slice, spark_w); + }); + let _ = ui.row_gap(2, |ui| { + ui.text("Req/s ").dim(); + let _ = ui.sparkline(&req_slice, spark_w); }); + + ui.text("q or Ctrl+Q to quit.").dim(); + }); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + slt::run(move |ui: &mut Context| { + if ui.key('q') || ui.key_mod('q', KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + render(ui, &mut state); }) } diff --git a/examples/cookbook_file_picker.rs b/examples/cookbook_file_picker.rs index 6640e9d..2f215a4 100644 --- a/examples/cookbook_file_picker.rs +++ b/examples/cookbook_file_picker.rs @@ -1,5 +1,7 @@ //! Cookbook: file picker with text preview. //! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! //! Left pane: `FilePickerState` browsing the current working directory. //! Right pane: preview of the selected text file (first ~30 lines, up to 64 KB). //! @@ -10,9 +12,15 @@ //! - Enter: open directory / select file //! - Backspace: go up one level //! - q or Esc: quit +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo can preserve the picker's selection / preview cache +//! across tab switches. `DemoState::new()` seeds the picker with the +//! current working directory; the standalone `main()` constructs it +//! before entering the run loop. use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use slt::{Border, Color, Context, FilePickerState, KeyCode, KeyModifiers}; @@ -47,73 +55,100 @@ fn read_preview(path: &Path) -> Result { Ok(preview) } -fn main() -> std::io::Result<()> { - let start = std::env::current_dir().unwrap_or_else(|_| ".".into()); - let mut picker = FilePickerState::new(start); - let mut preview: Option> = None; - let mut preview_path: Option = None; +/// Persistent picker + preview cache. Owning this from outside means a +/// tour can switch tabs and return without losing the user's +/// directory navigation. +pub struct DemoState { + picker: FilePickerState, + preview: Option>, + preview_path: Option, +} - slt::run(|ui: &mut Context| { - if ui.key('q') || ui.key_mod('q', KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { - ui.quit(); +impl DemoState { + pub fn new() -> Self { + let start = std::env::current_dir().unwrap_or_else(|_| ".".into()); + Self { + picker: FilePickerState::new(start), + preview: None, + preview_path: None, } + } +} - // If the picker surfaced a new selection this frame, refresh the preview. - if picker.selected_file.as_ref() != preview_path.as_ref() { - preview_path = picker.selected_file.clone(); - preview = preview_path.as_ref().map(|p| { - if is_text(p) { - read_preview(p) - } else { - Err("Binary or unsupported file type.".into()) - } - }); - } +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} - let _ = ui - .bordered(Border::Rounded) - .title("Cookbook — File Picker") - .p(1) - .gap(1) - .grow(1) - .col(|ui| { - let _ = ui.container().grow(1).row(|ui| { - let _ = ui - .bordered(Border::Single) - .title("Files") - .p(1) - .grow(1) - .col(|ui| { - let _ = ui.file_picker(&mut picker); - }); +/// Render one frame of the file-picker demo. Refreshes the preview only +/// when the picker surfaces a new selected file, so non-text or huge +/// files don't get re-stat'd every frame. +pub fn render(ui: &mut Context, state: &mut DemoState) { + if state.picker.selected_file.as_ref() != state.preview_path.as_ref() { + state.preview_path = state.picker.selected_file.clone(); + state.preview = state.preview_path.as_ref().map(|p| { + if is_text(p) { + read_preview(p) + } else { + Err("Binary or unsupported file type.".into()) + } + }); + } - let _ = ui - .bordered(Border::Single) - .title("Preview") - .p(1) - .grow(2) - .col(|ui| match preview.as_ref() { - Some(Ok(text)) if !text.is_empty() => { - for line in text.lines() { - ui.text(line.to_string()); - } - } - Some(Ok(_)) => { - ui.text("(empty file)").dim(); - } - Some(Err(msg)) => { - ui.text(msg.as_str()).fg(Color::Yellow); - } - None => { - ui.text("Select a text file to preview.").dim(); - ui.text("").dim(); - ui.text("Supported: .txt .md .rs .toml .log").dim(); - } - }); - }); + let _ = ui + .bordered(Border::Rounded) + .title("Cookbook: File Picker") + .p(1) + .gap(1) + .grow(1) + .col(|ui| { + let _ = ui.container().grow(1).row(|ui| { + let _ = ui + .bordered(Border::Single) + .title("Files") + .p(1) + .grow(1) + .col(|ui| { + let _ = ui.file_picker(&mut state.picker); + }); - ui.text("Enter: open/select Backspace: up q/Esc: quit") - .dim(); + let _ = ui + .bordered(Border::Single) + .title("Preview") + .p(1) + .grow(2) + .col(|ui| match state.preview.as_ref() { + Some(Ok(text)) if !text.is_empty() => { + for line in text.lines() { + ui.text(line.to_string()); + } + } + Some(Ok(_)) => { + ui.text("(empty file)").dim(); + } + Some(Err(msg)) => { + ui.text(msg.as_str()).fg(Color::Yellow); + } + None => { + ui.text("Select a text file to preview.").dim(); + ui.text("").dim(); + ui.text("Supported: .txt .md .rs .toml .log").dim(); + } + }); }); + + ui.text("Enter: open/select Backspace: up q/Esc: quit") + .dim(); + }); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + slt::run(move |ui: &mut Context| { + if ui.key('q') || ui.key_mod('q', KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + render(ui, &mut state); }) } diff --git a/examples/cookbook_login.rs b/examples/cookbook_login.rs index c797a2c..c7f1eb9 100644 --- a/examples/cookbook_login.rs +++ b/examples/cookbook_login.rs @@ -1,87 +1,123 @@ //! Cookbook: login form with validation. //! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! //! Demonstrates: //! - two `TextInputState` fields (Tab cycles focus automatically) //! - masked password input //! - validation: username non-empty, password length >= 6 //! - inline error rendering below the form //! - Ctrl+Q or Esc to quit +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo can preserve the typed username/password and the +//! `logged_in` welcome state across tab switches. The standalone +//! `main()` is a thin wrapper. use slt::{Border, Color, Context, KeyCode, KeyModifiers, TextInputState}; -fn main() -> std::io::Result<()> { - let mut username = TextInputState::with_placeholder("your name"); - let mut password = TextInputState::with_placeholder("at least 6 chars"); - password.masked = true; - - let mut error: Option = None; - let mut logged_in = false; - let mut current_user = String::new(); - - slt::run(|ui: &mut Context| { - if ui.key_mod('q', KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { - ui.quit(); - } +/// Persistent form state. `error` mirrors the inline validation +/// message; `logged_in` flips after a successful submit so subsequent +/// frames render the welcome view. +pub struct DemoState { + pub username: TextInputState, + pub password: TextInputState, + pub error: Option, + pub logged_in: bool, + pub current_user: String, +} - if logged_in { - let _ = ui - .bordered(Border::Rounded) - .title("Cookbook — Login") - .p(2) - .grow(1) - .center() - .col(|ui| { - ui.text(format!("Welcome, {current_user}!")) - .bold() - .fg(Color::Green); - ui.text("").dim(); - ui.text("Ctrl+Q or Esc to quit.").dim(); - }); - return; +impl DemoState { + pub fn new() -> Self { + let mut password = TextInputState::with_placeholder("at least 6 chars"); + password.masked = true; + Self { + username: TextInputState::with_placeholder("your name"), + password, + error: None, + logged_in: false, + current_user: String::new(), } + } +} - let submitted = ui.key_code(KeyCode::Enter); +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} +/// Render one frame of the login demo. Caller owns `DemoState` so the +/// typed text and `logged_in` flag persist across frames. +pub fn render(ui: &mut Context, state: &mut DemoState) { + if state.logged_in { let _ = ui .bordered(Border::Rounded) - .title("Cookbook — Login") + .title("Cookbook: Login") .p(2) - .gap(1) .grow(1) + .center() .col(|ui| { - ui.text("Sign in").bold().fg(Color::Cyan); - ui.text("Tab to switch fields. Enter to submit.").dim(); + ui.text(format!("Welcome, {}!", state.current_user)) + .bold() + .fg(Color::Green); + ui.text("").dim(); + ui.text("Ctrl+Q or Esc to quit.").dim(); + }); + return; + } - let _ = ui.col(|ui| { - ui.text("Username").dim(); - let _ = ui.text_input(&mut username); - }); + let submitted = ui.key_code(KeyCode::Enter); - let _ = ui.col(|ui| { - ui.text("Password").dim(); - let _ = ui.text_input(&mut password); - }); + let _ = ui + .bordered(Border::Rounded) + .title("Cookbook: Login") + .p(2) + .gap(1) + .grow(1) + .col(|ui| { + ui.text("Sign in").bold().fg(Color::Cyan); + ui.text("Tab to switch fields. Enter to submit.").dim(); - let clicked_login = ui.button("Login").clicked; + let _ = ui.col(|ui| { + ui.text("Username").dim(); + let _ = ui.text_input(&mut state.username); + }); - if submitted || clicked_login { - if username.value.trim().is_empty() { - error = Some("Username is required.".into()); - } else if password.value.chars().count() < 6 { - error = Some("Password must be at least 6 characters.".into()); - } else { - error = None; - current_user = username.value.trim().to_string(); - logged_in = true; - } - } + let _ = ui.col(|ui| { + ui.text("Password").dim(); + let _ = ui.text_input(&mut state.password); + }); + + let clicked_login = ui.button("Login").clicked; - if let Some(err) = &error { - ui.text(err).fg(Color::Red).bold(); + if submitted || clicked_login { + if state.username.value.trim().is_empty() { + state.error = Some("Username is required.".into()); + } else if state.password.value.chars().count() < 6 { + state.error = Some("Password must be at least 6 characters.".into()); + } else { + state.error = None; + state.current_user = state.username.value.trim().to_string(); + state.logged_in = true; } + } - ui.text("").dim(); - ui.text("Ctrl+Q or Esc to quit.").dim(); - }); + if let Some(err) = &state.error { + ui.text(err.as_str()).fg(Color::Red).bold(); + } + + ui.text("").dim(); + ui.text("Ctrl+Q or Esc to quit.").dim(); + }); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + slt::run(move |ui: &mut Context| { + if ui.key_mod('q', KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + render(ui, &mut state); }) } diff --git a/examples/cookbook_modal_toast.rs b/examples/cookbook_modal_toast.rs index 4830eba..0d0e66c 100644 --- a/examples/cookbook_modal_toast.rs +++ b/examples/cookbook_modal_toast.rs @@ -1,5 +1,9 @@ //! Cookbook: confirmation modal with toast feedback. //! +//! Archetype: **Overlay-first** (full-canvas + a centered modal that +//! claims overlay z-order while open). Toasts also draw in overlay +//! space. +//! //! Demonstrates: //! - opening a modal from a button click //! - rendering Yes / No buttons inside the modal @@ -9,80 +13,121 @@ //! //! Toast duration is expressed in ticks (default tick rate ~60fps), so the //! messages dismiss in roughly half a second once pushed. +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo can preserve `show_modal`, the items counter, and the +//! pending toasts across tab switches. The earlier stateless form ate +//! every modal click because state reset every frame — see Demo Guide +//! §2 for the canonical fix. +//! +//! §4 modal-aware: Esc-to-dismiss inside the modal uses +//! `raw_key_code(Esc)` so the global Esc-to-quit doesn't fire when a +//! modal is open. The standalone `main()` gates `ui.key_code(Esc)` on +//! `!state.show_modal` for the same reason. use slt::{Border, ButtonVariant, Color, Context, KeyCode, KeyModifiers, ToastState}; -fn main() -> std::io::Result<()> { - let mut show_modal = false; - let mut items_left: u32 = 3; - let mut toasts = ToastState::new(); +/// Persistent demo state. `show_modal` and `items_left` survive across +/// frames; `toasts` retains pending messages until they expire. +pub struct DemoState { + pub show_modal: bool, + pub items_left: u32, + pub toasts: ToastState, +} - slt::run(|ui: &mut Context| { - if ui.key_mod('q', KeyModifiers::CONTROL) { - ui.quit(); - } - if ui.key_code(KeyCode::Esc) && !show_modal { - ui.quit(); +impl DemoState { + pub fn new() -> Self { + Self { + show_modal: false, + items_left: 3, + toasts: ToastState::new(), } + } +} - let tick = ui.tick(); +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} - let _ = ui - .bordered(Border::Rounded) - .title("Cookbook — Modal + Toast") - .p(2) - .gap(1) - .grow(1) - .col(|ui| { - ui.text("Destructive actions need a confirmation modal.") - .dim(); +/// Render one frame of the modal-toast demo. Caller owns the modal +/// open/closed flag so a click on Yes/No resolves instead of being +/// thrown away on the next frame. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let tick = ui.tick(); - let _ = ui.row_gap(2, |ui| { - ui.text(format!("Items remaining: {items_left}")) - .bold() - .fg(Color::Cyan); - ui.spacer(); - if ui.button_with("Delete item", ButtonVariant::Danger).clicked - && items_left > 0 - { - show_modal = true; - } - }); + let _ = ui + .bordered(Border::Rounded) + .title("Cookbook: Modal + Toast") + .p(2) + .gap(1) + .grow(1) + .col(|ui| { + ui.text("Destructive actions need a confirmation modal.") + .dim(); - ui.text("").dim(); - ui.text("Tab to cycle focus. Ctrl+Q to quit.").dim(); + let _ = ui.row_gap(2, |ui| { + ui.text(format!("Items remaining: {}", state.items_left)) + .bold() + .fg(Color::Cyan); + ui.spacer(); + if ui.button_with("Delete item", ButtonVariant::Danger).clicked + && state.items_left > 0 + { + state.show_modal = true; + } }); - if show_modal { - let _ = ui.modal(|ui| { - let _ = ui - .bordered(Border::Rounded) - .title("Confirm") - .p(2) - .gap(1) - .col(|ui| { - ui.text("Delete this item? This cannot be undone.").bold(); - let _ = ui.row_gap(2, |ui| { - if ui.button_with("Yes", ButtonVariant::Danger).clicked { - items_left = items_left.saturating_sub(1); - toasts.success("Deleted", tick); - show_modal = false; - } - if ui.button_with("No", ButtonVariant::Outline).clicked { - toasts.info("Cancelled", tick); - show_modal = false; - } - }); - ui.text("Esc to dismiss.").dim(); + ui.text("").dim(); + ui.text("Tab to cycle focus. Ctrl+Q to quit.").dim(); + }); + + if state.show_modal { + let _ = ui.modal(|ui| { + let _ = ui + .bordered(Border::Rounded) + .title("Confirm") + .p(2) + .gap(1) + .col(|ui| { + ui.text("Delete this item? This cannot be undone.").bold(); + let _ = ui.row_gap(2, |ui| { + if ui.button_with("Yes", ButtonVariant::Danger).clicked { + state.items_left = state.items_left.saturating_sub(1); + state.toasts.success("Deleted", tick); + state.show_modal = false; + } + if ui.button_with("No", ButtonVariant::Outline).clicked { + state.toasts.info("Cancelled", tick); + state.show_modal = false; + } }); - }); - // `key_code` ignores events while a modal is active — use the - // `raw_*` variants for global/modal-aware shortcuts. - if ui.raw_key_code(KeyCode::Esc) { - show_modal = false; - } + ui.text("Esc to dismiss.").dim(); + }); + }); + // `key_code` ignores events while a modal is active — use the + // `raw_*` variants for global/modal-aware shortcuts. + if ui.raw_key_code(KeyCode::Esc) { + state.show_modal = false; } + } - ui.toast(&mut toasts); + ui.toast(&mut state.toasts); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + slt::run(move |ui: &mut Context| { + if ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + // Esc-to-quit is gated on `!show_modal` so the modal's own + // Esc-to-dismiss path (inside `render`) wins when the modal is + // open. + if ui.key_code(KeyCode::Esc) && !state.show_modal { + ui.quit(); + } + render(ui, &mut state); }) } diff --git a/examples/cookbook_table.rs b/examples/cookbook_table.rs index 9a397da..8f1579a 100644 --- a/examples/cookbook_table.rs +++ b/examples/cookbook_table.rs @@ -1,5 +1,7 @@ //! Cookbook: searchable and sortable data table. //! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! //! Demonstrates: //! - `TextInputState` wired to `TableState::set_filter` (Tab to focus it) //! - `s` cycles the sort column, Enter inverts direction @@ -7,6 +9,11 @@ //! are never double-handled //! - footer status bar shows rows + active sort //! - Ctrl+Q or Esc to quit +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo can preserve the typed filter, current sort column, +//! and table cursor across tab switches. The standalone `main()` is a +//! thin wrapper. use slt::{Border, Color, Context, KeyCode, KeyModifiers, TableState, TextInputState}; @@ -25,66 +32,96 @@ const ROWS: &[[&str; 4]] = &[ ["10", "Judy", "researcher", "95"], ]; -fn main() -> std::io::Result<()> { - let mut table = TableState::new( - HEADERS.to_vec(), - ROWS.iter().map(|r| r.to_vec()).collect::>(), - ); - table.zebra = true; - table.sort_column = Some(0); +/// Persistent table + filter state. +pub struct DemoState { + pub table: TableState, + pub search: TextInputState, +} - let mut search = TextInputState::with_placeholder("filter... (press / anywhere)"); +impl DemoState { + pub fn new() -> Self { + let mut table = TableState::new( + HEADERS.to_vec(), + ROWS.iter().map(|r| r.to_vec()).collect::>(), + ); + table.zebra = true; + table.sort_column = Some(0); - slt::run(|ui: &mut Context| { - if ui.key_mod('q', KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { - ui.quit(); + Self { + table, + search: TextInputState::with_placeholder("filter... (press / anywhere)"), } + } +} - let _ = ui - .bordered(Border::Rounded) - .title("Cookbook — Table") - .p(1) - .gap(1) - .grow(1) - .col(|ui| { - let _ = ui.row_gap(1, |ui| { - ui.text("Search:").dim(); - let resp = ui.text_input(&mut search); - if resp.changed { - table.set_filter(search.value.clone()); - } - }); +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} - let _ = ui.table(&mut table); +/// Render one frame of the table demo. Caller owns `DemoState` so the +/// filter text, sort column, and table cursor persist across frames. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let _ = ui + .bordered(Border::Rounded) + .title("Cookbook: Table") + .p(1) + .gap(1) + .grow(1) + .col(|ui| { + let _ = ui.row_gap(1, |ui| { + ui.text("Search:").dim(); + let resp = ui.text_input(&mut state.search); + if resp.changed { + state.table.set_filter(state.search.value.clone()); + } + }); + + let _ = ui.table(&mut state.table); - let sort_label = match table.sort_column { - Some(c) => { - let dir = if table.sort_ascending { "asc" } else { "desc" }; - format!("sorted by {} {dir}", HEADERS[c]) - } - None => "unsorted".to_string(), - }; - let n = table.visible_indices().len(); - let _ = ui.row(|ui| { - ui.text(format!("{n} rows / {sort_label}")).fg(Color::Cyan); - ui.spacer(); - ui.text("s cycle sort Enter invert Esc quit").dim(); - }); + let sort_label = match state.table.sort_column { + Some(c) => { + let dir = if state.table.sort_ascending { + "asc" + } else { + "desc" + }; + format!("sorted by {} {dir}", HEADERS[c]) + } + None => "unsorted".to_string(), + }; + let n = state.table.visible_indices().len(); + let _ = ui.row(|ui| { + ui.text(format!("{n} rows / {sort_label}")).fg(Color::Cyan); + ui.spacer(); + ui.text("s cycle sort Enter invert Esc quit").dim(); }); + }); - // Global shortcuts run AFTER widgets so a focused text_input can - // consume typed characters first. - if ui.consume_key('s') { - let next = table - .sort_column - .map(|c| (c + 1) % HEADERS.len()) - .unwrap_or(0); - table.sort_by(next); + // Global shortcuts run AFTER widgets so a focused text_input can + // consume typed characters first. + if ui.consume_key('s') { + let next = state + .table + .sort_column + .map(|c| (c + 1) % HEADERS.len()) + .unwrap_or(0); + state.table.sort_by(next); + } + if ui.consume_key_code(KeyCode::Enter) { + if let Some(c) = state.table.sort_column { + state.table.toggle_sort(c); } - if ui.consume_key_code(KeyCode::Enter) { - if let Some(c) = table.sort_column { - table.toggle_sort(c); - } + } +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + slt::run(move |ui: &mut Context| { + if ui.key_mod('q', KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { + ui.quit(); } + render(ui, &mut state); }) } diff --git a/examples/cookbook_tour.rs b/examples/cookbook_tour.rs new file mode 100644 index 0000000..5d94b83 --- /dev/null +++ b/examples/cookbook_tour.rs @@ -0,0 +1,225 @@ +//! Cookbook Tour — five real-world recipes from `examples/cookbook_*.rs`, +//! switched via SLT's own `Tabs` widget. Each tab embeds the corresponding +//! standalone cookbook demo's `pub fn render(ui, &mut DemoState)` so what +//! you see here is exactly what the standalone demo renders. +//! +//! Run: `cargo run --example cookbook_tour` +//! +//! Keys: +//! Left / Right — switch tab (when the tabs bar is focused; Tab to focus) +//! Tab / Shift-Tab — cycle focus (tabs bar -> demo) +//! q / Esc / Ctrl-Q — quit (Esc only when no modal is open; see notes +//! below) +//! +//! Tabs: +//! 1. Intro — overview + navigation help (description-only) +//! 2. Dashboard — rolling line chart + stat tiles + sparklines +//! 3. Picker — file picker with side-by-side text preview +//! 4. Login — text inputs + validation + welcome state +//! 5. Modal+Toast — confirmation modal driving a toast notification +//! 6. Table — searchable + sortable data table + +use slt::widgets::{ScrollState, TabsState}; +use slt::{Border, Color, Context, KeyModifiers, RunConfig}; + +// Each `#[path = ...] mod ...;` re-includes a single-feature demo so the +// tour can call its `pub fn render(...)` directly. The demos' own `fn +// main()` and helpers are unused in this build, hence the blanket +// `#[allow(dead_code)]` on every include. +#[allow(dead_code)] +#[path = "cookbook_dashboard.rs"] +mod dashboard; +#[allow(dead_code)] +#[path = "cookbook_file_picker.rs"] +mod file_picker; +#[allow(dead_code)] +#[path = "cookbook_login.rs"] +mod login; +#[allow(dead_code)] +#[path = "cookbook_modal_toast.rs"] +mod modal_toast; +#[allow(dead_code)] +#[path = "cookbook_table.rs"] +mod table; + +/// Aggregated state for every embedded demo. Each field is the +/// `DemoState` from the corresponding cookbook recipe. +struct TourState { + tabs: TabsState, + /// Scroll offset for the active tab body. Mouse-wheel events outside any + /// inner scrollable scroll the whole tab so long intros / overflowing + /// content stay reachable on small terminals. + tab_scroll: ScrollState, + dashboard: dashboard::DemoState, + file_picker: file_picker::DemoState, + login: login::DemoState, + modal_toast: modal_toast::DemoState, + table: table::DemoState, +} + +impl Default for TourState { + fn default() -> Self { + Self { + tabs: TabsState::new(vec![ + "Intro", + "Dashboard", + "Picker", + "Login", + "Modal+Toast", + "Table", + ]), + tab_scroll: ScrollState::new(), + dashboard: Default::default(), + file_picker: file_picker::DemoState::new(), + login: Default::default(), + modal_toast: Default::default(), + table: table::DemoState::new(), + } + } +} + +fn main() -> std::io::Result<()> { + let mut state = TourState::default(); + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + // Tour-level quit: Ctrl-Q always, plain `q` after demos render + // (so a focused text_input on Login/Table consumes it as text + // first). We intentionally do NOT consume Esc here — the + // Modal+Toast tab relies on Esc to dismiss its own modal, and + // each demo's standalone `main()` already handles Esc-to-quit + // when used standalone. + if ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("Cookbook Tour: real-world patterns") + .p(pad) + .grow(1) + .col(|ui| { + let _ = ui.tabs(&mut state.tabs); + ui.separator(); + + // Wrap the tab body in a vertical scrollable so the intro's + // long help text and any overflowing recipe stay reachable + // on small terminals. Mouse wheel outside any inner scroll + // region scrolls the whole tab; when the body fits the + // viewport this is a no-op. + let _ = ui.scrollable(&mut state.tab_scroll).grow(1).col(|ui| { + match state.tabs.selected { + 0 => render_intro(ui), + 1 => render_dashboard(ui, &mut state), + 2 => render_picker(ui, &mut state), + 3 => render_login(ui, &mut state), + 4 => render_modal_toast(ui, &mut state), + 5 => render_table(ui, &mut state), + _ => {} + } + }); + }); + + // 'q' is checked AFTER demos render so a focused text_input + // (Login fields, Table search box) consumes it as text first. + if ui.key('q') { + ui.quit(); + } + }) +} + +/// Tab 1: Intro. Pure overview — no embedded demo. +fn render_intro(ui: &mut Context) { + let _ = ui.col(|ui| { + let pad = ui.spacing().xs(); + ui.text("Welcome to the cookbook tour.").bold(); + ui.text(""); + ui.text("Each tab embeds the corresponding standalone recipe from") + .dim(); + ui.text("examples/cookbook_*.rs without changes -- what you see in") + .dim(); + ui.text("a tab is exactly the standalone demo's render path.") + .dim(); + ui.text(""); + let _ = ui + .bordered(Border::Single) + .title("Recipes at a glance") + .p(pad) + .col(|ui| { + row_pair( + ui, + "Dashboard", + "rolling line chart + sparklines + stat tiles", + ); + row_pair( + ui, + "Picker", + "FilePickerState with side-by-side text preview", + ); + row_pair( + ui, + "Login", + "two text inputs, validation, masked password, welcome state", + ); + row_pair( + ui, + "Modal+Toast", + "confirmation modal driving toast notifications", + ); + row_pair( + ui, + "Table", + "searchable + sortable table with footer status bar", + ); + }); + ui.text(""); + ui.text( + "Navigation: Left/Right arrows switch tabs (Tab to focus the bar). q / Ctrl-Q quits.", + ) + .fg(Color::Cyan); + ui.text("Esc quits everywhere except inside the Modal+Toast modal, where it dismisses.") + .dim(); + }); +} + +/// One label/description row for the intro recipe list. +fn row_pair(ui: &mut Context, label: &str, desc: &str) { + let _ = ui.row_gap(1, |ui| { + ui.text(format!("{label:<12}")).bold().fg(Color::Cyan); + ui.text(desc).dim(); + }); +} + +/// Tab 2: Dashboard. Rolling histories live in TourState so tab +/// switches don't clear the chart. +fn render_dashboard(ui: &mut Context, state: &mut TourState) { + dashboard::render(ui, &mut state.dashboard); +} + +/// Tab 3: File picker. The picker's selected directory and preview +/// cache survive across tab switches because the state is owned here. +fn render_picker(ui: &mut Context, state: &mut TourState) { + file_picker::render(ui, &mut state.file_picker); +} + +/// Tab 4: Login. The typed username / password and `logged_in` flag +/// are kept in TourState so a successful submit sticks even if the +/// user navigates away and back. +fn render_login(ui: &mut Context, state: &mut TourState) { + login::render(ui, &mut state.login); +} + +/// Tab 5: Modal + Toast. The embedded demo handles Delete-to-open, +/// Yes/No clicks, and Esc-to-dismiss internally. We pass a persistent +/// `state.modal_toast` so clicks settle and the items counter +/// decrements correctly. +fn render_modal_toast(ui: &mut Context, state: &mut TourState) { + modal_toast::render(ui, &mut state.modal_toast); +} + +/// Tab 6: Table. The search filter, sort column, and table cursor +/// live in TourState so the user's filter doesn't reset on tab +/// switch. +fn render_table(ui: &mut Context, state: &mut TourState) { + table::render(ui, &mut state.table); +} diff --git a/examples/demo.rs b/examples/demo.rs index d80f13b..5216774 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -7,81 +7,344 @@ use slt::{ ToastLevel, ToastState, ToolApprovalState, TreeNode, TreeState, Trend, WidgetColors, }; +/// Persistent state for the widget showcase demo. +/// +/// All widget state and mode flags live on this struct so the runtime +/// `main` loop can keep selections, scroll positions, form input, and +/// theme indices stable across frames. The previous version constructed +/// every widget state inline in `render`, which reset it every frame — +/// that caused tab clicks to flash for a single frame and snap back to +/// the first tab. +pub struct DemoState { + // Top-level navigation / scroll + page_tabs: TabsState, + section_tabs: TabsState, + scroll: ScrollState, + + // Core inputs / textareas + input: TextInputState, + textarea: TextareaState, + table_filter: TextInputState, + password: TextInputState, + list_filter_input: TextInputState, + ime_name: TextInputState, + ime_search: TextInputState, + ime_message: TextareaState, + + // Lists / tables / trees + list: ListState, + table: TableState, + vlist: ListState, + list_with_filter: ListState, + tree: TreeState, + v13_list_a: ListState, + v13_list_b: ListState, + v132_zebra_table: TableState, + rich_log: RichLogState, + dir_tree: DirectoryTreeState, + + // Selection widgets + select: SelectState, + radio: RadioState, + multi: MultiSelectState, + + // Spinner (read-only frame state, but kept here for pattern consistency) + spinner: SpinnerState, + + // Mode flags / counters / accumulators + accordion_general: bool, + accordion_advanced: bool, + alert_visible: bool, + progress: f64, + dark_mode: bool, + notifications: bool, + autosave: bool, + vim_mode: bool, + saves: u32, + show_modal: bool, + show_overlay: bool, + theme_idx: usize, + v8_dark_mode: bool, + v8_anim_done: bool, + v11_button_clicks: u32, + v11_volume: f64, + v11_brightness: f64, + v11_confirm_delete: bool, + v13_show_modal: bool, + v13_modal_message: String, + v13_palette_last: String, + v132_fuzzy_last: String, + v7_stream_tick: u64, + + // Toasts / forms / palettes + toasts: ToastState, + form: FormState, + palette: CommandPaletteState, + v13_palette: CommandPaletteState, + v132_fuzzy_palette: CommandPaletteState, + + // v0.7.0 widgets + v7_scroll: ScrollState, + v7_stream: StreamingTextState, + v7_tool: ToolApprovalState, + + // Animation + v8_tween: slt::anim::Tween, + + // v0.11 widgets + v11_autocomplete: TextInputState, + v11_validated: TextInputState, + v11_file_picker: FilePickerState, + + // v0.13/v0.13.2 widgets + v13_debug_input: TextInputState, + v132_calendar: CalendarState, + v132_screens: ScreenState, + + // v0.15.2 focus inputs + v152_focus_a: TextInputState, + v152_focus_b: TextInputState, + v152_search: TextInputState, +} + +impl Default for DemoState { + fn default() -> Self { + let mut table = TableState::new( + vec!["Name", "Lang", "Stars"], + vec![ + vec!["SLT", "Rust", "500"], + vec!["Ratatui", "Rust", "12000"], + vec!["Bubbletea", "Go", "30000"], + vec!["Ink", "JS/TS", "8000"], + vec!["Textual", "Python", "26000"], + vec!["Cursive", "Rust", "4200"], + ], + ); + table.page_size = 3; + + let mut password = TextInputState::with_placeholder("Password"); + password.masked = true; + + let mut v11_autocomplete = TextInputState::with_placeholder("Try: hel / dev / rust"); + v11_autocomplete.set_suggestions(vec![ + "hello".to_string(), + "help".to_string(), + "helm".to_string(), + "developer".to_string(), + "device".to_string(), + "rust".to_string(), + "runner".to_string(), + ]); + + let mut v11_validated = TextInputState::with_placeholder("username (>=3 chars, alnum)"); + v11_validated.add_validator(|v| { + if v.len() >= 3 { + Ok(()) + } else { + Err("Must be at least 3 characters".to_string()) + } + }); + v11_validated.add_validator(|v| { + if v.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + Ok(()) + } else { + Err("Only [a-zA-Z0-9_] allowed".to_string()) + } + }); + + let mut v13_debug_input = TextInputState::with_placeholder("Type and mutate this state"); + v13_debug_input.value = "seed".to_string(); + + let v13_list_a = ListState::new(vec!["Alpha", "Beta", "Gamma", "Delta"]); + let v13_list_b = v13_list_a.clone(); + + let mut v132_zebra_table = TableState::new( + vec!["Name", "Role", "Status"], + vec![ + vec!["Alice", "Engineer", "Active"], + vec!["Bob", "Designer", "Away"], + vec!["Carol", "PM", "Active"], + vec!["Dave", "QA", "Busy"], + vec!["Eve", "DevOps", "Active"], + ], + ); + v132_zebra_table.zebra = true; + + Self { + page_tabs: TabsState::new(vec![ + "Core Widgets", + "Data Viz", + "Layout", + "Forms", + "IME/CJK", + "Feedback", + "Advanced", + "v0.7.0", + "v0.8.0", + "v0.9.4", + "v0.11.0", + "v0.12.10", + "v0.13", + "v0.13.2", + "v0.14.0", + "v0.14.1", + "v0.15.2", + ]), + section_tabs: TabsState::new(vec!["Primary", "Secondary", "Accent"]), + scroll: ScrollState::new(), + input: TextInputState::with_placeholder("Type here..."), + textarea: TextareaState::new(), + table_filter: TextInputState::with_placeholder("Filter table..."), + password, + list_filter_input: TextInputState::with_placeholder("Filter list..."), + ime_name: TextInputState::with_placeholder("Type Korean/Japanese/Chinese..."), + ime_search: TextInputState::with_placeholder("Search CJK terms..."), + ime_message: TextareaState::new(), + list: ListState::new(vec!["Rust", "Go", "Python", "TypeScript", "Zig", "C++"]), + table, + vlist: ListState::new((0..100).map(|i| format!("Item {i}")).collect()), + list_with_filter: ListState::new(vec![ + "Rust", + "Go", + "Python", + "TypeScript", + "JavaScript", + "C++", + "Zig", + "Haskell", + ]), + tree: TreeState::new(vec![ + TreeNode::new("src").expanded().children(vec![ + TreeNode::new("lib.rs"), + TreeNode::new("context.rs"), + TreeNode::new("layout.rs"), + TreeNode::new("style.rs"), + TreeNode::new("widgets.rs"), + ]), + TreeNode::new("examples") + .children(vec![TreeNode::new("demo.rs"), TreeNode::new("counter.rs")]), + TreeNode::new("tests").children(vec![ + TreeNode::new("widgets.rs"), + TreeNode::new("snapshots.rs"), + ]), + ]), + v13_list_a, + v13_list_b, + v132_zebra_table, + rich_log: RichLogState::new(), + dir_tree: DirectoryTreeState::from_paths(&[ + "src/lib.rs", + "src/context.rs", + "src/context/widgets_display.rs", + "src/context/widgets_interactive.rs", + "src/widgets.rs", + "Cargo.toml", + "README.md", + ]), + select: SelectState::new(vec!["Rounded", "Single", "Double", "Thick"]), + radio: RadioState::new(vec!["Dark", "Light", "System"]), + multi: MultiSelectState::new(vec![ + "Vim motions", + "Mouse support", + "Clipboard", + "Unicode", + "Async", + ]), + spinner: SpinnerState::dots(), + accordion_general: true, + accordion_advanced: false, + alert_visible: true, + progress: 0.64_f64, + dark_mode: true, + notifications: true, + autosave: false, + vim_mode: false, + saves: 0, + show_modal: false, + show_overlay: true, + theme_idx: 0, + v8_dark_mode: false, + v8_anim_done: false, + v11_button_clicks: 0, + v11_volume: 35.0_f64, + v11_brightness: 72.0_f64, + v11_confirm_delete: false, + v13_show_modal: false, + v13_modal_message: String::from("No modal interaction yet"), + v13_palette_last: String::from("None"), + v132_fuzzy_last: String::from("None"), + v7_stream_tick: 0, + toasts: ToastState::new(), + form: FormState::new() + .field(FormField::new("Email").placeholder("you@example.com")) + .field(FormField::new("Password").placeholder("********")), + palette: CommandPaletteState::new(vec![ + PaletteCommand::new("Switch Theme", "Cycle to next theme"), + PaletteCommand::new("Toggle Modal", "Show/hide modal dialog"), + PaletteCommand::new("Toggle Overlay", "Show/hide overlay"), + PaletteCommand::new("Quit", "Exit the application"), + ]), + v13_palette: CommandPaletteState::new(vec![ + PaletteCommand::new("Build", "Run cargo check"), + PaletteCommand::new("Test", "Run cargo test"), + PaletteCommand::new("Format", "Run cargo fmt"), + ]), + v132_fuzzy_palette: CommandPaletteState::new(vec![ + PaletteCommand::new("Save File", "Save the current document"), + PaletteCommand::new("Open Project", "Open a project folder"), + PaletteCommand::new("Find Replace", "Search and replace text"), + PaletteCommand::new("Git Commit", "Commit staged changes"), + PaletteCommand::new("Run Tests", "Execute test suite"), + PaletteCommand::new("Toggle Theme", "Switch dark/light mode"), + ]), + v7_scroll: ScrollState::new(), + v7_stream: StreamingTextState::new(), + v7_tool: ToolApprovalState::new("read_file", "Read contents of config.toml"), + v8_tween: slt::anim::Tween::new(0.0, 100.0, 120), + v11_autocomplete, + v11_validated, + v11_file_picker: FilePickerState::new( + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ), + v13_debug_input, + v132_calendar: CalendarState::new(), + v132_screens: ScreenState::new("main"), + v152_focus_a: TextInputState::with_placeholder("Input A (focusable #0)"), + v152_focus_b: TextInputState::with_placeholder("Input B (focusable #1)"), + v152_search: TextInputState::with_placeholder("Search fills remaining space..."), + } + } +} + fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); slt::run_with( RunConfig::default().mouse(true).kitty_keyboard(true), - render, + move |ui| render(ui, &mut state), ) } /// 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", - "Layout", - "Forms", - "IME/CJK", - "Feedback", - "Advanced", - "v0.7.0", - "v0.8.0", - "v0.9.4", - "v0.11.0", - "v0.12.10", - "v0.13", - "v0.13.2", - "v0.14.0", - "v0.14.1", - "v0.15.2", - ]); - let mut section_tabs = TabsState::new(vec!["Primary", "Secondary", "Accent"]); - let mut scroll = ScrollState::new(); - let mut input = TextInputState::with_placeholder("Type here..."); - let mut textarea = TextareaState::new(); - let mut list = ListState::new(vec!["Rust", "Go", "Python", "TypeScript", "Zig", "C++"]); - let mut table = TableState::new( - vec!["Name", "Lang", "Stars"], - vec![ - vec!["SLT", "Rust", "500"], - vec!["Ratatui", "Rust", "12000"], - vec!["Bubbletea", "Go", "30000"], - vec!["Ink", "JS/TS", "8000"], - vec!["Textual", "Python", "26000"], - vec!["Cursive", "Rust", "4200"], - ], - ); - table.page_size = 3; - let mut table_filter = TextInputState::with_placeholder("Filter table..."); - let spinner = SpinnerState::dots(); - let mut accordion_general = true; - let mut accordion_advanced = false; - let mut alert_visible = true; - let mut progress = 0.64_f64; - let mut dark_mode = true; - let mut notifications = true; - let mut autosave = false; - let mut vim_mode = false; - let mut saves: u32 = 0; - let mut show_modal = false; - let mut show_overlay = true; - let mut toasts = ToastState::new(); - let mut form = FormState::new() - .field(FormField::new("Email").placeholder("you@example.com")) - .field(FormField::new("Password").placeholder("********")); +/// State lives on the supplied [`DemoState`] so selections, scroll +/// positions, theme indices, and form input persist across frames. +/// The runtime `main` loop owns one [`DemoState`] for the lifetime of +/// the process; visual snapshot tests use [`render_snapshot`] which +/// constructs a fresh state per frame for determinism. +pub fn render(ui: &mut Context, state: &mut DemoState) { + body(ui, state); +} + +/// Render one frame with fresh, default state — used by visual snapshot +/// tests in `tests/visual_snapshots.rs`. Constructs a new [`DemoState`] +/// every call so the rendered frame is deterministic regardless of the +/// caller's history. +pub fn render_snapshot(ui: &mut Context) { + let mut state = DemoState::default(); + body(ui, &mut state); +} +fn body(ui: &mut Context, state: &mut DemoState) { let themes: [fn() -> Theme; 7] = [ Theme::dark, Theme::light, @@ -100,61 +363,6 @@ pub fn render(ui: &mut Context) { "Solarized", "Tokyo Night", ]; - let mut theme_idx: usize = 0; - let mut select = SelectState::new(vec!["Rounded", "Single", "Double", "Thick"]); - let mut radio = RadioState::new(vec!["Dark", "Light", "System"]); - let mut multi = MultiSelectState::new(vec![ - "Vim motions", - "Mouse support", - "Clipboard", - "Unicode", - "Async", - ]); - let mut tree = TreeState::new(vec![ - TreeNode::new("src").expanded().children(vec![ - TreeNode::new("lib.rs"), - TreeNode::new("context.rs"), - TreeNode::new("layout.rs"), - TreeNode::new("style.rs"), - TreeNode::new("widgets.rs"), - ]), - TreeNode::new("examples") - .children(vec![TreeNode::new("demo.rs"), TreeNode::new("counter.rs")]), - TreeNode::new("tests").children(vec![ - TreeNode::new("widgets.rs"), - TreeNode::new("snapshots.rs"), - ]), - ]); - let mut vlist = ListState::new((0..100).map(|i| format!("Item {i}")).collect()); - let mut password = TextInputState::with_placeholder("Password"); - password.masked = true; - let mut palette = CommandPaletteState::new(vec![ - PaletteCommand::new("Switch Theme", "Cycle to next theme"), - PaletteCommand::new("Toggle Modal", "Show/hide modal dialog"), - PaletteCommand::new("Toggle Overlay", "Show/hide overlay"), - PaletteCommand::new("Quit", "Exit the application"), - ]); - let mut v7_scroll = ScrollState::new(); - let mut v7_stream = StreamingTextState::new(); - let mut v7_tool = ToolApprovalState::new("read_file", "Read contents of config.toml"); - let mut v7_stream_tick: u64 = 0; - let mut list_with_filter = ListState::new(vec![ - "Rust", - "Go", - "Python", - "TypeScript", - "JavaScript", - "C++", - "Zig", - "Haskell", - ]); - let mut list_filter_input = TextInputState::with_placeholder("Filter list..."); - let mut v8_dark_mode = false; - let mut v8_tween = slt::anim::Tween::new(0.0, 100.0, 120); - let mut v8_anim_done = false; - let mut ime_name = TextInputState::with_placeholder("Type Korean/Japanese/Chinese..."); - let mut ime_search = TextInputState::with_placeholder("Search CJK terms..."); - let mut ime_message = TextareaState::new(); let ime_items: Vec = vec![ "한글 입력 테스트", "日本語テスト", @@ -169,38 +377,6 @@ pub fn render(ui: &mut Context) { .into_iter() .map(str::to_string) .collect(); - let mut v11_button_clicks: u32 = 0; - let mut v11_volume = 35.0_f64; - let mut v11_brightness = 72.0_f64; - let mut v11_confirm_delete = false; - let mut v11_autocomplete = TextInputState::with_placeholder("Try: hel / dev / rust"); - v11_autocomplete.set_suggestions(vec![ - "hello".to_string(), - "help".to_string(), - "helm".to_string(), - "developer".to_string(), - "device".to_string(), - "rust".to_string(), - "runner".to_string(), - ]); - let mut v11_validated = TextInputState::with_placeholder("username (>=3 chars, alnum)"); - v11_validated.add_validator(|v| { - if v.len() >= 3 { - Ok(()) - } else { - Err("Must be at least 3 characters".to_string()) - } - }); - v11_validated.add_validator(|v| { - if v.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { - Ok(()) - } else { - Err("Only [a-zA-Z0-9_] allowed".to_string()) - } - }); - let mut v11_file_picker = FilePickerState::new( - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), - ); let v11_keymap = KeyMap::new() .bind_mod('q', KeyModifiers::CONTROL, "quit") .bind_code(KeyCode::Tab, "focus next") @@ -208,53 +384,6 @@ pub fn render(ui: &mut Context) { .bind_code(KeyCode::Right, "slider +") .bind('y', "confirm yes") .bind('n', "confirm no"); - let mut v13_show_modal = false; - let mut v13_modal_message = String::from("No modal interaction yet"); - let mut v13_palette = CommandPaletteState::new(vec![ - PaletteCommand::new("Build", "Run cargo check"), - PaletteCommand::new("Test", "Run cargo test"), - PaletteCommand::new("Format", "Run cargo fmt"), - ]); - let mut v13_palette_last = String::from("None"); - let mut v13_debug_input = TextInputState::with_placeholder("Type and mutate this state"); - v13_debug_input.value = "seed".to_string(); - let mut v13_list_a = ListState::new(vec!["Alpha", "Beta", "Gamma", "Delta"]); - let mut v13_list_b = v13_list_a.clone(); - let mut v132_zebra_table = TableState::new( - vec!["Name", "Role", "Status"], - vec![ - vec!["Alice", "Engineer", "Active"], - vec!["Bob", "Designer", "Away"], - vec!["Carol", "PM", "Active"], - vec!["Dave", "QA", "Busy"], - vec!["Eve", "DevOps", "Active"], - ], - ); - v132_zebra_table.zebra = true; - let mut v132_calendar = CalendarState::new(); - let mut v132_screens = ScreenState::new("main"); - let mut v132_fuzzy_palette = CommandPaletteState::new(vec![ - PaletteCommand::new("Save File", "Save the current document"), - PaletteCommand::new("Open Project", "Open a project folder"), - PaletteCommand::new("Find Replace", "Search and replace text"), - PaletteCommand::new("Git Commit", "Commit staged changes"), - PaletteCommand::new("Run Tests", "Execute test suite"), - PaletteCommand::new("Toggle Theme", "Switch dark/light mode"), - ]); - let mut v132_fuzzy_last = String::from("None"); - let mut rich_log = RichLogState::new(); - let mut dir_tree = DirectoryTreeState::from_paths(&[ - "src/lib.rs", - "src/context.rs", - "src/context/widgets_display.rs", - "src/context/widgets_interactive.rs", - "src/widgets.rs", - "Cargo.toml", - "README.md", - ]); - let mut v152_focus_a = TextInputState::with_placeholder("Input A (focusable #0)"); - let mut v152_focus_b = TextInputState::with_placeholder("Input B (focusable #1)"); - let mut v152_search = TextInputState::with_placeholder("Search fills remaining space..."); { let tick = ui.tick(); @@ -263,37 +392,115 @@ pub fn render(ui: &mut Context) { 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); + state.theme_idx = (state.theme_idx + 1) % themes.len(); + state + .toasts + .info(format!("Theme: {}", theme_names[state.theme_idx]), tick); } if ui.key_mod('h', slt::KeyModifiers::CONTROL) { - progress = (progress - 0.05).max(0.0); + state.progress = (state.progress - 0.05).max(0.0); } if ui.key_mod('l', slt::KeyModifiers::CONTROL) { - progress = (progress + 0.05).min(1.0); + state.progress = (state.progress + 0.05).min(1.0); } if ui.key_mod('m', slt::KeyModifiers::CONTROL) { - show_modal = !show_modal; + state.show_modal = !state.show_modal; } if ui.key_mod('o', slt::KeyModifiers::CONTROL) { - show_overlay = !show_overlay; + state.show_overlay = !state.show_overlay; } if ui.key_mod('p', slt::KeyModifiers::CONTROL) { - palette.open = !palette.open; + state.palette.open = !state.palette.open; } if ui.key_mod('g', slt::KeyModifiers::CONTROL) { - scroll.offset = 0; + state.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; + state.page_tabs.selected = (i - 1) as usize; } } - ui.set_theme(themes[theme_idx]()); - ui.set_dark_mode(v8_dark_mode); + ui.set_theme(themes[state.theme_idx]()); + ui.set_dark_mode(state.v8_dark_mode); let theme = *ui.theme(); + let theme_label = theme_names[state.theme_idx]; + + // Destructure `state` into per-field mutable references so the + // dispatch closure below can borrow disjoint fields without + // running into nested-closure borrow conflicts. The reborrow + // (`&mut *state`) keeps `state` itself usable at the end of the + // block once these bindings go out of scope. + let DemoState { + page_tabs, + section_tabs, + scroll, + input, + textarea, + table_filter, + password, + list_filter_input, + ime_name, + ime_search, + ime_message, + list, + table, + vlist, + list_with_filter, + tree, + v13_list_a, + v13_list_b, + v132_zebra_table, + rich_log, + dir_tree, + select, + radio, + multi, + spinner, + accordion_general, + accordion_advanced, + alert_visible, + progress, + dark_mode, + notifications, + autosave, + vim_mode, + saves, + show_modal, + show_overlay, + theme_idx, + v8_dark_mode, + v8_anim_done, + v11_button_clicks, + v11_volume, + v11_brightness, + v11_confirm_delete, + v13_show_modal, + v13_modal_message, + v13_palette_last, + v132_fuzzy_last, + v7_stream_tick, + toasts, + form, + palette, + v13_palette, + v132_fuzzy_palette, + v7_scroll, + v7_stream, + v7_tool, + v8_tween, + v11_autocomplete, + v11_validated, + v11_file_picker, + v13_debug_input, + v132_calendar, + v132_screens, + v152_focus_a, + v152_focus_b, + v152_search, + } = &mut *state; + let _ = ui .container() .border(Border::Rounded) @@ -304,112 +511,80 @@ pub fn render(ui: &mut Context) { 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(theme_label).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); + render_page_tabs(ui, page_tabs); ui.separator(); let _ = ui - .scrollable(&mut scroll) + .scrollable(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, + section_tabs, + input, + textarea, + dark_mode, + notifications, + autosave, + vim_mode, + 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, - ), + 2 => render_layout(ui, list, table, table_filter, show_overlay), + 3 => render_forms(ui, form, password), + 4 => render_ime(ui, ime_name, ime_search, ime_message, &ime_items), + 5 => render_feedback(ui, spinner, *progress), + 6 => render_advanced(ui, select, radio, multi, tree, vlist), + 7 => render_v070(ui, v7_scroll, v7_stream, v7_tool, 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, + list_with_filter, + list_filter_input, + v8_dark_mode, + v8_tween, + v8_anim_done, tick, ), - 9 => render_v094( - ui, - &mut accordion_general, - &mut accordion_advanced, - &mut alert_visible, - ), + 9 => render_v094(ui, accordion_general, accordion_advanced, 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_button_clicks, + v11_volume, + v11_brightness, + v11_confirm_delete, + v11_autocomplete, + v11_validated, + 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, + v13_show_modal, + v13_modal_message, + v13_palette, + v13_palette_last, + v13_debug_input, + v13_list_a, + 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, + v132_zebra_table, + v132_calendar, + v132_screens, + v132_fuzzy_palette, + v132_fuzzy_last, ), - 14 => render_v014(ui, tick, &mut rich_log, &mut dir_tree), + 14 => render_v014(ui, tick, rich_log, dir_tree), 15 => render_v0141(ui), - 16 => { - render_v0152(ui, &mut v152_focus_a, &mut v152_focus_b, &mut v152_search) - } + 16 => render_v0152(ui, v152_focus_a, v152_focus_b, v152_search), _ => {} }); @@ -428,7 +603,7 @@ pub fn render(ui: &mut Context) { ]); }); - if show_modal { + if *show_modal { let _ = ui.modal(|ui| { let theme = *ui.theme(); let _ = ui @@ -442,23 +617,23 @@ pub fn render(ui: &mut Context) { .fg(theme.surface_text); ui.text("Press m or click close.").fg(theme.surface_text); if ui.button("Close").clicked { - show_modal = false; + *show_modal = false; } }); }); } - ui.toast(&mut toasts); + ui.toast(toasts); - let _cp = ui.command_palette(&mut palette); + let _cp = ui.command_palette(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); + *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, + 1 => *show_modal = !*show_modal, + 2 => *show_overlay = !*show_overlay, 3 => ui.quit(), _ => {} } @@ -1008,7 +1183,7 @@ fn render_v011( card(ui, |ui| { ui.text("File Picker").bold().fg(theme.accent); if ui.file_picker(file_picker).changed { - if let Some(path) = file_picker.selected() { + if let Some(path) = file_picker.selected_file() { let name = path .file_name() .and_then(|s| s.to_str()) @@ -1194,10 +1369,10 @@ fn render_feedback(ui: &mut Context, spinner: &SpinnerState, progress: f64) { card(ui, |ui| { ui.text("Progress").bold().fg(theme.primary); let _ = ui.row(|ui| { - ui.spinner(spinner); + let _ = ui.spinner(spinner); ui.text(" Loading...").fg(theme.surface_text); }); - ui.progress(progress); + let _ = ui.progress(progress); ui.text(format!("{:.0}%", progress * 100.0)) .fg(theme.surface_text); }); @@ -1285,7 +1460,7 @@ fn render_v070( ui.text(format!(" Line {i}")).fg(fg); } }); - ui.scrollbar(scroll); + let _ = ui.scrollbar(scroll); }); }); @@ -1618,7 +1793,7 @@ fn render_v080( card(ui, |ui| { let val = v8_tween.value(tick); let progress = val / 100.0; - ui.progress(progress); + let _ = ui.progress(progress); let _ = ui.row_gap(1, |ui| { ui.text(format!("Value: {:.0}", val)); @@ -2447,7 +2622,7 @@ fn render_v094( } let _ = ui.divider_text("Navigation"); - ui.breadcrumb(&["Home", "Settings", "Profile"]); + let _ = ui.breadcrumb(&["Home", "Settings", "Profile"]); let _ = ui.divider_text("Dashboard"); let _ = ui.row(|ui| { diff --git a/examples/demo_cli.rs b/examples/demo_cli.rs index b526ce7..7423329 100644 --- a/examples/demo_cli.rs +++ b/examples/demo_cli.rs @@ -1,4 +1,12 @@ -use slt::{Border, Color, Context, ListState, ScrollState, SpinnerState, TextInputState, Theme}; +//! Demo: cargo-style CLI / package manager UI with search, install, and an +//! output log scrolling region. +//! +//! Archetype: Standard. No overlays; uses theming via `set_theme` in the +//! standalone `main`. The composable [`render`] keeps theme decisions to the +//! caller so a parent tour's theme stays in charge. + +use slt::widgets::{ListState, ScrollState, SpinnerState, TextInputState}; +use slt::{Border, Color, Context, Theme}; struct PackageInfo { name: &'static str, @@ -121,227 +129,251 @@ const PACKAGES: &[PackageInfo] = &[ }, ]; -fn main() -> std::io::Result<()> { - let mut search = TextInputState::with_placeholder("Search packages..."); - let mut pkg_list = ListState::new( - PACKAGES - .iter() - .map(|p| p.name.to_string()) - .collect::>(), - ); - let mut output_scroll = ScrollState::new(); - let spinner = SpinnerState::dots(); - let mut installing = false; - let mut install_progress = 0.0_f64; - let mut output_lines: Vec<(Color, String)> = vec![ - (Color::Indexed(245), "cargo-slt v0.1.0".into()), - ( - Color::Indexed(245), - "Type to search, Enter to install/update".into(), - ), - ]; - let mut dark_mode = true; +/// State persisted across frames for the CLI demo. +pub struct DemoState { + pub search: TextInputState, + pub pkg_list: ListState, + pub output_scroll: ScrollState, + pub spinner: SpinnerState, + pub installing: bool, + pub install_progress: f64, + pub output_lines: Vec<(Color, String)>, +} - slt::run_with(slt::RunConfig::default().mouse(true), |ui: &mut Context| { - if ui.key_mod('q', slt::KeyModifiers::CONTROL) { - ui.quit(); +impl Default for DemoState { + fn default() -> Self { + Self { + search: TextInputState::with_placeholder("Search packages..."), + pkg_list: ListState::new( + PACKAGES + .iter() + .map(|p| p.name.to_string()) + .collect::>(), + ), + output_scroll: ScrollState::new(), + spinner: SpinnerState::dots(), + installing: false, + install_progress: 0.0, + output_lines: vec![ + (Color::Indexed(245), "cargo-slt v0.1.0".into()), + ( + Color::Indexed(245), + "Type to search, Enter to install/update".into(), + ), + ], } - if ui.key_code(slt::KeyCode::Esc) { - installing = false; - install_progress = 0.0; - } - if ui.key_mod('t', slt::KeyModifiers::CONTROL) { - dark_mode = !dark_mode; - } - ui.set_theme(if dark_mode { - Theme::dark() - } else { - Theme::light() - }); + } +} - let filtered: Vec = PACKAGES - .iter() - .enumerate() - .filter(|(_, p)| { - search.value.is_empty() || p.name.contains(&search.value.to_lowercase()) - }) - .map(|(i, _)| i) - .collect(); +/// Render one frame of the CLI / package-manager demo. +/// +/// Theming is left to the caller — a parent tour can wrap this in a +/// `container().theme(...)` subtree, and the standalone `main` toggles +/// the global theme directly. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let filtered: Vec = PACKAGES + .iter() + .enumerate() + .filter(|(_, p)| { + state.search.value.is_empty() || p.name.contains(&state.search.value.to_lowercase()) + }) + .map(|(i, _)| i) + .collect(); - if filtered.is_empty() { - pkg_list.selected = 0; - } else { - pkg_list.selected = pkg_list.selected.min(filtered.len().saturating_sub(1)); - } + if filtered.is_empty() { + state.pkg_list.selected = 0; + } else { + state.pkg_list.selected = state + .pkg_list + .selected + .min(filtered.len().saturating_sub(1)); + } - if installing { - install_progress = (install_progress + 0.02).min(1.0); - if install_progress >= 1.0 { - installing = false; - if let Some(&pkg_idx) = filtered.get(pkg_list.selected) { - let pkg = &PACKAGES[pkg_idx]; - output_lines.push(( - Color::Green, - format!("Installed {} v{}", pkg.name, pkg.version), - )); - } - install_progress = 0.0; + if state.installing { + state.install_progress = (state.install_progress + 0.02).min(1.0); + if state.install_progress >= 1.0 { + state.installing = false; + if let Some(&pkg_idx) = filtered.get(state.pkg_list.selected) { + let pkg = &PACKAGES[pkg_idx]; + state.output_lines.push(( + Color::Green, + format!("Installed {} v{}", pkg.name, pkg.version), + )); } + state.install_progress = 0.0; } + } - let _ = ui - .bordered(Border::Rounded) - .title("cargo-slt") - .p(1) - .grow(1) - .col(|ui| { - let _ = ui.row(|ui| { - ui.text("cargo-slt").bold().fg(Color::Cyan); - ui.spacer(); - ui.text(format!("{} packages", PACKAGES.len())).dim(); - }); - ui.separator(); + let _ = ui + .bordered(Border::Rounded) + .title("cargo-slt") + .p(1) + .grow(1) + .col(|ui| { + let _ = ui.row(|ui| { + ui.text("cargo-slt").bold().fg(Color::Cyan); + ui.spacer(); + ui.text(format!("{} packages", PACKAGES.len())).dim(); + }); + ui.separator(); + + let _ = ui.container().grow(1).row(|ui| { + // left: search + list + let _ = ui + .bordered(Border::Rounded) + .title("Packages") + .p(1) + .grow(1) + .col(|ui| { + let _ = ui.text_input(&mut state.search); + ui.separator(); + if filtered.is_empty() { + ui.text("No packages found").dim(); + } else { + let items: Vec = filtered + .iter() + .map(|&i| { + let p = &PACKAGES[i]; + let marker = match p.status { + "outdated" => "↑", + "not installed" => "○", + _ => "●", + }; + format!( + "{marker} {:<12} {:<10} {}", + p.name, p.version, p.status + ) + }) + .collect(); + state.pkg_list.set_items(items); + let _ = ui.list(&mut state.pkg_list); + } + }); + + // right: detail + output + let _ = ui.container().grow(1).col(|ui| { + let sel = filtered.get(state.pkg_list.selected).copied().unwrap_or(0); + let pkg = &PACKAGES[sel]; - let _ = ui.container().grow(1).row(|ui| { - // left: search + list let _ = ui .bordered(Border::Rounded) - .title("Packages") + .title("Details") .p(1) - .grow(1) .col(|ui| { - let _ = ui.text_input(&mut search); + ui.text(pkg.name).bold().fg(Color::Cyan); + ui.text(format!("v{}", pkg.version)).dim(); ui.separator(); - if filtered.is_empty() { - ui.text("No packages found").dim(); - } else { - let items: Vec = filtered - .iter() - .map(|&i| { - let p = &PACKAGES[i]; - let marker = match p.status { - "outdated" => "↑", - "not installed" => "○", - _ => "●", - }; - let color_char = match p.status { - "outdated" => '!', - "not installed" => ' ', - _ => ' ', - }; - let _ = color_char; - format!( - "{marker} {:<12} {:<10} {}", - p.name, p.version, p.status - ) - }) - .collect(); - pkg_list.set_items(items); - let _ = ui.list(&mut pkg_list); - } - }); - - // right: detail + output - let _ = ui.container().grow(1).col(|ui| { - let sel = filtered.get(pkg_list.selected).copied().unwrap_or(0); - let pkg = &PACKAGES[sel]; + ui.text(pkg.desc); + let _ = ui.row(|ui| { + ui.text("License:").dim(); + ui.text(pkg.license); + }); + let _ = ui.row(|ui| { + ui.text("Dependencies:").dim(); + ui.text(format!("{}", pkg.deps)); + }); + let _ = ui.row(|ui| { + ui.text("Size:").dim(); + ui.text(pkg.size); + }); + let _ = ui.row(|ui| { + ui.text("Status:").dim(); + let (label, color) = match pkg.status { + "installed" => ("installed", Color::Green), + "outdated" => ("update available", Color::Yellow), + _ => ("not installed", Color::Indexed(245)), + }; + ui.text(label).fg(color); + }); - let _ = ui - .bordered(Border::Rounded) - .title("Details") - .p(1) - .col(|ui| { - ui.text(pkg.name).bold().fg(Color::Cyan); - ui.text(format!("v{}", pkg.version)).dim(); + if state.installing { ui.separator(); - ui.text(pkg.desc); - let _ = ui.row(|ui| { - ui.text("License:").dim(); - ui.text(pkg.license); - }); - let _ = ui.row(|ui| { - ui.text("Dependencies:").dim(); - ui.text(format!("{}", pkg.deps)); - }); let _ = ui.row(|ui| { - ui.text("Size:").dim(); - ui.text(pkg.size); + let _ = ui.spinner(&state.spinner); + ui.text(format!( + " Installing... {:.0}%", + state.install_progress * 100.0 + )) + .fg(Color::Yellow); }); + let _ = ui.progress(state.install_progress); + } else { + ui.separator(); let _ = ui.row(|ui| { - ui.text("Status:").dim(); - let (label, color) = match pkg.status { - "installed" => ("installed", Color::Green), - "outdated" => ("update available", Color::Yellow), - _ => ("not installed", Color::Indexed(245)), + let action = match pkg.status { + "installed" => "Reinstall", + "outdated" => "Update", + _ => "Install", }; - ui.text(label).fg(color); + if ui.button(action).clicked { + state.installing = true; + state.install_progress = 0.0; + state.output_lines.push(( + Color::Yellow, + format!("Installing {} v{}...", pkg.name, pkg.version), + )); + } + if (pkg.status == "installed" || pkg.status == "outdated") + && ui.button("Remove").clicked + { + state + .output_lines + .push((Color::Red, format!("Removed {}", pkg.name))); + } }); + } + }); - if installing { - ui.separator(); - let _ = ui.row(|ui| { - ui.spinner(&spinner); - ui.text(format!( - " Installing... {:.0}%", - install_progress * 100.0 - )) - .fg(Color::Yellow); - }); - ui.progress(install_progress); - } else { - ui.separator(); - let _ = ui.row(|ui| { - let action = match pkg.status { - "installed" => "Reinstall", - "outdated" => "Update", - _ => "Install", - }; - if ui.button(action).clicked { - installing = true; - install_progress = 0.0; - output_lines.push(( - Color::Yellow, - format!( - "Installing {} v{}...", - pkg.name, pkg.version - ), - )); - } - if (pkg.status == "installed" || pkg.status == "outdated") - && ui.button("Remove").clicked - { - output_lines.push(( - Color::Red, - format!("Removed {}", pkg.name), - )); - } - }); + let _ = ui + .bordered(Border::Rounded) + .title("Output") + .p(1) + .grow(1) + .col(|ui| { + let _ = ui.scrollable(&mut state.output_scroll).grow(1).col(|ui| { + for (color, line) in &state.output_lines { + ui.text(line.as_str()).fg(*color); } }); - - let _ = ui - .bordered(Border::Rounded) - .title("Output") - .p(1) - .grow(1) - .col(|ui| { - let _ = ui.scrollable(&mut output_scroll).grow(1).col(|ui| { - for (color, line) in &output_lines { - ui.text(line.as_str()).fg(*color); - } - }); - }); - }); + }); }); + }); + + ui.separator(); + let _ = ui.help(&[ + ("Ctrl+Q", "quit"), + ("Ctrl+T", "theme"), + ("Tab", "focus"), + ("Enter", "action"), + ("Esc", "cancel"), + ]); + }); +} - ui.separator(); - let _ = ui.help(&[ - ("Ctrl+Q", "quit"), - ("Ctrl+T", "theme"), - ("Tab", "focus"), - ("Enter", "action"), - ("Esc", "cancel"), - ]); +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + let mut dark_mode = true; + + slt::run_with( + slt::RunConfig::default().mouse(true), + move |ui: &mut Context| { + if ui.key_mod('q', slt::KeyModifiers::CONTROL) { + ui.quit(); + } + if ui.key_code(slt::KeyCode::Esc) { + state.installing = false; + state.install_progress = 0.0; + } + if ui.key_mod('t', slt::KeyModifiers::CONTROL) { + dark_mode = !dark_mode; + } + ui.set_theme(if dark_mode { + Theme::dark() + } else { + Theme::light() }); - }) + + render(ui, &mut state); + }, + ) } diff --git a/examples/demo_dashboard.rs b/examples/demo_dashboard.rs index a1b2b28..ebc9357 100644 --- a/examples/demo_dashboard.rs +++ b/examples/demo_dashboard.rs @@ -1,3 +1,14 @@ +//! Demo: system dashboard (metric cards, processes, log stream, toasts). +//! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo (e.g. `examples/showcase_tour.rs`) can preserve the +//! spinner phase, log scroll position, process-table cursor, theme +//! toggle, and toast queue across tab switches. The legacy stateless +//! `pub fn render(ui)` (snapshot-style) is retained for visual snapshot +//! tests in `tests/visual_snapshots.rs`. + use slt::{ Border, Color, Context, ScrollState, SpinnerState, Style, TableState, Theme, ToastState, Trend, }; @@ -55,6 +66,53 @@ pub fn render(ui: &mut Context) { ); } +/// Persistent dashboard state. Owns the spinner phase, log scroll +/// position, process-table cursor, theme toggle, and the toast queue +/// so they all survive across frames in the showcase tour. +pub struct DemoState { + pub spinner: SpinnerState, + pub log_scroll: ScrollState, + pub proc_table: TableState, + pub dark_mode: bool, + pub toasts: ToastState, + pub logs: Vec<(&'static str, &'static str, &'static str)>, +} + +impl DemoState { + pub fn new() -> Self { + Self { + spinner: SpinnerState::dots(), + log_scroll: ScrollState::new(), + proc_table: make_proc_table(), + dark_mode: true, + toasts: ToastState::new(), + logs: make_logs(), + } + } +} + +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} + +/// Render one frame of the dashboard with caller-owned state — used by +/// the showcase tour so the spinner phase, log scroll, table cursor, +/// theme toggle, and toast queue survive across frames. Snapshot tests +/// use [`render`] (which constructs fresh state each call). +pub fn render_with_state(ui: &mut Context, state: &mut DemoState) { + render_frame( + ui, + &state.spinner, + &mut state.log_scroll, + &mut state.proc_table, + &mut state.dark_mode, + &mut state.toasts, + &state.logs, + ); +} + /// Render one frame of the dashboard demo into the supplied context. /// /// Exposed so both `main` (which keeps state across frames) and the @@ -91,7 +149,7 @@ pub fn render_frame( .grow(1) .col(|ui| { let _ = ui.row(|ui| { - ui.spinner(spinner); + let _ = ui.spinner(spinner); ui.text(" LIVE").bold().fg(Color::Green); ui.spacer(); ui.text(format!( diff --git a/examples/demo_design_system.rs b/examples/demo_design_system.rs index 4efb4de..4adc9f0 100644 --- a/examples/demo_design_system.rs +++ b/examples/demo_design_system.rs @@ -3,10 +3,17 @@ //! Tests ThemeColor, Spacing tokens, ContainerStyle extends, //! WidgetTheme, and new theme presets. //! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo can preserve the selected theme index, the +//! `show_themes` toggle, the typed input value, the list cursor, and +//! the counter across tab switches. +//! //! Run: cargo run --example demo_design_system --features crossterm use slt::{ - Border, Color, ContainerStyle, ListState, RunConfig, Spacing, TextInputState, Theme, + Border, Color, ContainerStyle, Context, ListState, RunConfig, Spacing, TextInputState, Theme, ThemeColor, WidgetColors, WidgetTheme, }; @@ -28,12 +35,8 @@ const CARD_ERROR: ContainerStyle = const CARD_SUCCESS: ContainerStyle = ContainerStyle::extending(&CARD).theme_border_fg(ThemeColor::Success); -fn main() -> std::io::Result<()> { - let widget_theme = WidgetTheme::new().button(WidgetColors::new().accent(Color::Cyan)); - - let config = RunConfig::default().mouse(true).widget_theme(widget_theme); - - let themes: Vec<(&str, Theme)> = vec![ +fn build_themes() -> Vec<(&'static str, Theme)> { + vec![ ("Dark", Theme::dark()), ("Light", Theme::light()), ("Dracula", Theme::dracula()), @@ -44,161 +47,202 @@ fn main() -> std::io::Result<()> { ("One Dark", Theme::one_dark()), ("Solarized Dark", Theme::solarized_dark()), ("Solarized Light", Theme::solarized_light()), - ]; - - let mut theme_idx: usize = 0; - let mut show_themes = false; - let mut input = TextInputState::new(); - input.placeholder = "Type here...".into(); - let mut list = ListState::new(vec![ - "Alpha".to_string(), - "Beta".to_string(), - "Gamma".to_string(), - "Delta".to_string(), - ]); - let mut counter: u32 = 0; - - slt::run_with(config, |ui| { - let (theme_name, theme) = &themes[theme_idx]; - ui.set_theme(*theme); - - // Cache colors before any container calls - let primary = ui.color(ThemeColor::Primary); - let text_dim = ui.color(ThemeColor::TextDim); - let surface_text = ui.color(ThemeColor::SurfaceText); - let sp = ui.spacing(); - - if ui.key('q') { - ui.quit(); - } - if ui.key_code(slt::KeyCode::Right) { - theme_idx = (theme_idx + 1) % themes.len(); - } - if ui.key_code(slt::KeyCode::Left) { - theme_idx = theme_idx.checked_sub(1).unwrap_or(themes.len() - 1); - } - if ui.key('t') { - show_themes = !show_themes; - } + ] +} - // ── Header ─────────────────────────────────────────────────── - let _ = ui.col_gap(sp.xs(), |ui| { - ui.text("Design System Demo (v0.17)").bold().fg(primary); - ui.text(format!( - "Theme: {} │ ←/→ cycle themes │ t: toggle theme view │ q: quit", - theme_name - )) - .fg(text_dim); - ui.separator(); - }); +/// Persistent state: theme cursor, theme-browser toggle, plus the +/// inputs/list/counter the showcase exposes. +pub struct DemoState { + pub themes: Vec<(&'static str, Theme)>, + pub theme_idx: usize, + pub show_themes: bool, + pub input: TextInputState, + pub list: ListState, + pub counter: u32, +} - if show_themes { - // ── Theme browser ──────────────────────────────────────── - let _ = ui.col_gap(sp.sm(), |ui| { - ui.text("All Theme Presets").bold(); - for (i, (name, t)) in themes.iter().enumerate() { - let marker = if i == theme_idx { "> " } else { " " }; - let _ = ui.row_gap(1, |ui| { - ui.text(format!("{}{}", marker, name)).fg(t.primary).bold(); - for (label, color) in [ - ("pri", t.primary), - ("sec", t.secondary), - ("acc", t.accent), - ("suc", t.success), - ("wrn", t.warning), - ("err", t.error), - ] { - let fg = Color::contrast_fg(color); - let _ = ui.container().bg(color).px(1).col(|ui| { - ui.text(label).fg(fg); - }); - } - }); - } - }); - } else { - // ── Showcase ───────────────────────────────────────────── - let _ = ui.col_gap(sp.sm(), |ui| { - // Row 1: Style extends + ThemeColor - ui.text("Style Extends + ThemeColor").bold(); - let _ = ui.row_gap(sp.xs(), |ui| { - let _ = ui.container().apply(&CARD).grow(1).col(|ui| { - ui.text("CARD (base)").fg(surface_text); - ui.text("theme_bg: Surface").fg(text_dim); - }); - let _ = ui.container().apply(&CARD_PRIMARY).grow(1).col(|ui| { - ui.text("CARD_PRIMARY").fg(surface_text); - ui.text("extends CARD").fg(text_dim); - }); - let _ = ui.container().apply(&CARD_ERROR).grow(1).col(|ui| { - ui.text("CARD_ERROR").fg(surface_text); - ui.text("extends CARD").fg(text_dim); - }); - let _ = ui.container().apply(&CARD_SUCCESS).grow(1).col(|ui| { - ui.text("CARD_SUCCESS").fg(surface_text); - ui.text("extends CARD").fg(text_dim); - }); - }); +impl DemoState { + pub fn new() -> Self { + let mut input = TextInputState::new(); + input.placeholder = "Type here...".into(); + let list = ListState::new(vec![ + "Alpha".to_string(), + "Beta".to_string(), + "Gamma".to_string(), + "Delta".to_string(), + ]); + Self { + themes: build_themes(), + theme_idx: 0, + show_themes: false, + input, + list, + counter: 0, + } + } +} + +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} - // Row 2: Spacing tokens - ui.text("Spacing Tokens").bold(); - let _ = ui.row_gap(sp.xs(), |ui| { - let scale = Spacing::new(1); - for (name, val) in [ - ("xs", scale.xs()), - ("sm", scale.sm()), - ("md", scale.md()), - ("lg", scale.lg()), - ("xl", scale.xl()), +/// Render one frame of the design-system demo. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let (theme_name, theme) = state.themes[state.theme_idx]; + ui.set_theme(theme); + + // Cache colors before any container calls + let primary = ui.color(ThemeColor::Primary); + let text_dim = ui.color(ThemeColor::TextDim); + let surface_text = ui.color(ThemeColor::SurfaceText); + let sp = ui.spacing(); + + if ui.key_code(slt::KeyCode::Right) { + state.theme_idx = (state.theme_idx + 1) % state.themes.len(); + } + if ui.key_code(slt::KeyCode::Left) { + state.theme_idx = state + .theme_idx + .checked_sub(1) + .unwrap_or(state.themes.len() - 1); + } + if ui.key('t') { + state.show_themes = !state.show_themes; + } + + // ── Header ─────────────────────────────────────────────────── + let _ = ui.col_gap(sp.xs(), |ui| { + ui.text("Design System Demo (v0.17)").bold().fg(primary); + ui.text(format!( + "Theme: {} | Left/Right cycle themes | t: toggle theme view | q: quit", + theme_name + )) + .fg(text_dim); + ui.separator(); + }); + + if state.show_themes { + // ── Theme browser ──────────────────────────────────────── + let _ = ui.col_gap(sp.sm(), |ui| { + ui.text("All Theme Presets").bold(); + for (i, (name, t)) in state.themes.iter().enumerate() { + let marker = if i == state.theme_idx { "> " } else { " " }; + let _ = ui.row_gap(1, |ui| { + ui.text(format!("{}{}", marker, name)).fg(t.primary).bold(); + for (label, color) in [ + ("pri", t.primary), + ("sec", t.secondary), + ("acc", t.accent), + ("suc", t.success), + ("wrn", t.warning), + ("err", t.error), ] { - let _ = ui.container().apply(&CARD).p(val).grow(1).col(|ui| { - ui.text(format!("sp.{}() = {}", name, val)).fg(surface_text); + let fg = Color::contrast_fg(color); + let _ = ui.container().bg(color).px(1).col(|ui| { + ui.text(label).fg(fg); }); } }); + } + }); + } else { + // ── Showcase ───────────────────────────────────────────── + let _ = ui.col_gap(sp.sm(), |ui| { + // Row 1: Style extends + ThemeColor + ui.text("Style Extends + ThemeColor").bold(); + let _ = ui.row_gap(sp.xs(), |ui| { + let _ = ui.container().apply(&CARD).grow(1).col(|ui| { + ui.text("CARD (base)").fg(surface_text); + ui.text("theme_bg: Surface").fg(text_dim); + }); + let _ = ui.container().apply(&CARD_PRIMARY).grow(1).col(|ui| { + ui.text("CARD_PRIMARY").fg(surface_text); + ui.text("extends CARD").fg(text_dim); + }); + let _ = ui.container().apply(&CARD_ERROR).grow(1).col(|ui| { + ui.text("CARD_ERROR").fg(surface_text); + ui.text("extends CARD").fg(text_dim); + }); + let _ = ui.container().apply(&CARD_SUCCESS).grow(1).col(|ui| { + ui.text("CARD_SUCCESS").fg(surface_text); + ui.text("extends CARD").fg(text_dim); + }); + }); - // Row 3: WidgetTheme + interactive widgets - ui.text("WidgetTheme (buttons have cyan accent)").bold(); - let _ = ui.row_gap(sp.sm(), |ui| { - let _ = ui.container().apply(&CARD).grow(1).col(|ui| { - if ui.button("Increment").clicked { - counter += 1; - } - ui.text(format!("Counter: {}", counter)); - if ui.button("Reset").clicked { - counter = 0; - } - }); - let _ = ui.container().apply(&CARD).grow(1).col(|ui| { - ui.text("Text Input:"); - let _ = ui.text_input(&mut input); - ui.text(format!("Value: \"{}\"", input.value)).fg(text_dim); + // Row 2: Spacing tokens + ui.text("Spacing Tokens").bold(); + let _ = ui.row_gap(sp.xs(), |ui| { + let scale = Spacing::new(1); + for (name, val) in [ + ("xs", scale.xs()), + ("sm", scale.sm()), + ("md", scale.md()), + ("lg", scale.lg()), + ("xl", scale.xl()), + ] { + let _ = ui.container().apply(&CARD).p(val).grow(1).col(|ui| { + ui.text(format!("sp.{}() = {}", name, val)).fg(surface_text); }); - let _ = ui.container().apply(&CARD).grow(1).col(|ui| { - ui.text("List:"); - let _ = ui.list(&mut list); - }); - }); + } + }); - // Row 4: Contrast helpers - ui.text("Contrast Helpers").bold(); - let test_bgs = [ - ("Primary", theme.primary), - ("Error", theme.error), - ("Success", theme.success), - ("Surface", theme.surface), - ]; - let _ = ui.row_gap(sp.xs(), |ui| { - for (label, bg_color) in test_bgs { - let fg = Color::contrast_fg(bg_color); - let ratio = Color::contrast_ratio(fg, bg_color); - let _ = ui.container().bg(bg_color).p(1).grow(1).col(|ui| { - ui.text(label).fg(fg).bold(); - ui.text(format!("ratio: {:.1}", ratio)).fg(fg); - }); + // Row 3: WidgetTheme + interactive widgets + ui.text("WidgetTheme (buttons have cyan accent)").bold(); + let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.container().apply(&CARD).grow(1).col(|ui| { + if ui.button("Increment").clicked { + state.counter += 1; + } + ui.text(format!("Counter: {}", state.counter)); + if ui.button("Reset").clicked { + state.counter = 0; } }); + let _ = ui.container().apply(&CARD).grow(1).col(|ui| { + ui.text("Text Input:"); + let _ = ui.text_input(&mut state.input); + ui.text(format!("Value: \"{}\"", state.input.value)) + .fg(text_dim); + }); + let _ = ui.container().apply(&CARD).grow(1).col(|ui| { + ui.text("List:"); + let _ = ui.list(&mut state.list); + }); }); + + // Row 4: Contrast helpers + ui.text("Contrast Helpers").bold(); + let test_bgs = [ + ("Primary", theme.primary), + ("Error", theme.error), + ("Success", theme.success), + ("Surface", theme.surface), + ]; + let _ = ui.row_gap(sp.xs(), |ui| { + for (label, bg_color) in test_bgs { + let fg = Color::contrast_fg(bg_color); + let ratio = Color::contrast_ratio(fg, bg_color); + let _ = ui.container().bg(bg_color).p(1).grow(1).col(|ui| { + ui.text(label).fg(fg).bold(); + ui.text(format!("ratio: {:.1}", ratio)).fg(fg); + }); + } + }); + }); + } +} + +fn main() -> std::io::Result<()> { + let widget_theme = WidgetTheme::new().button(WidgetColors::new().accent(Color::Cyan)); + let config = RunConfig::default().mouse(true).widget_theme(widget_theme); + + let mut state = DemoState::new(); + slt::run_with(config, move |ui: &mut Context| { + if ui.key('q') || ui.key_code(slt::KeyCode::Esc) { + ui.quit(); } + render(ui, &mut state); }) } diff --git a/examples/demo_ime.rs b/examples/demo_ime.rs index e2d1dc3..920e7d6 100644 --- a/examples/demo_ime.rs +++ b/examples/demo_ime.rs @@ -1,90 +1,131 @@ -use slt::{Context, KeyCode, RunConfig}; - -fn main() -> std::io::Result<()> { - let mut name = slt::TextInputState::with_placeholder("이름을 입력하세요"); - let mut message = slt::TextareaState::new(); - let mut search = slt::TextInputState::with_placeholder("검색어 입력..."); - let mut results: Vec = Vec::new(); +//! Demo: IME (input method editor) flow with Korean / Japanese / Chinese +//! composition input across two text inputs and a textarea, plus a tiny +//! filter list to exercise live search across CJK content. +//! +//! Verifies: +//! - Multi-byte composition input updates `TextInputState.value` correctly. +//! - CJK + ASCII mixed strings round-trip through `text_input` / `textarea`. +//! - Live filtering on a vec of CJK strings stays consistent each frame. +//! +//! Archetype: Standard. The render function holds no overlays and does not +//! write to scrollback, so it composes cleanly into a tabbed tour. - let items = vec![ - "한글 입력 테스트", - "日本語テスト", - "中文测试", - "English test", - "Emoji 🎉🔥", - "Mixed 한글+English", - "서울특별시", - "부산광역시", - "대구광역시", - "인천광역시", - ]; +use slt::widgets::{TextInputState, TextareaState}; +use slt::{Context, KeyCode, RunConfig}; - slt::run_with( - RunConfig::default().mouse(true).kitty_keyboard(true), - |ui: &mut Context| { - if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { - ui.quit(); - } +const ITEMS: &[&str] = &[ + "한글 입력 테스트", + "日本語テスト", + "中文测试", + "English test", + "Emoji 🎉🔥", + "Mixed 한글+English", + "서울특별시", + "부산광역시", + "대구광역시", + "인천광역시", +]; - let theme = *ui.theme(); - let term_h = ui.height(); +/// State persisted across frames for the IME demo. Owned by either the +/// standalone `main` or the parent tour. +pub struct DemoState { + pub name: TextInputState, + pub search: TextInputState, + pub message: TextareaState, + pub results: Vec, +} - let _ = ui.col(|ui| { - let _ = ui.container().grow(1).gap(1).p(1).col(|ui| { - ui.text("IME Input Demo").bold().fg(theme.primary); - ui.text("한글, 日本語, 中文 조합 입력 테스트").dim(); - ui.separator(); +impl Default for DemoState { + fn default() -> Self { + Self { + name: TextInputState::with_placeholder("이름을 입력하세요"), + search: TextInputState::with_placeholder("검색어 입력..."), + message: TextareaState::new(), + results: Vec::new(), + } + } +} - let _ = ui.row_gap(2, |ui| { - let _ = ui.container().grow(1).gap(1).col(|ui| { - ui.text("Name").bold(); - let _ = ui.text_input(&mut name); - if !name.value.is_empty() { - ui.line(|ui| { - ui.text("→ "); - ui.text(&name.value).fg(theme.accent); - ui.text(format!(" ({} chars)", name.value.chars().count())) - .dim(); - }); - } - }); +/// Render one frame of the IME demo into the supplied context. +/// +/// Composing demos (e.g. `examples/text_tour.rs`) call this with their +/// owned state so input persists across frames. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let theme = *ui.theme(); + let term_h = ui.height(); - let _ = ui.container().grow(1).gap(1).col(|ui| { - ui.text("Search").bold(); - let _ = ui.text_input(&mut search); + let _ = ui.col(|ui| { + let _ = ui.container().grow(1).gap(1).p(1).col(|ui| { + ui.text("IME Input Demo").bold().fg(theme.primary); + ui.text("Hangul / Japanese / Chinese composition input") + .dim(); + ui.separator(); - let query = search.value.to_lowercase(); - let tokens: Vec<&str> = query.split_whitespace().collect(); - results = items - .iter() - .filter(|item| { - let lower = item.to_lowercase(); - tokens.is_empty() || tokens.iter().all(|t| lower.contains(t)) - }) - .map(|s| s.to_string()) - .collect(); - ui.text(format!("{}/{} items", results.len(), items.len())) + let _ = ui.row_gap(2, |ui| { + let _ = ui.container().grow(1).gap(1).col(|ui| { + ui.text("Name").bold(); + let _ = ui.text_input(&mut state.name); + if !state.name.value.is_empty() { + ui.line(|ui| { + ui.text("-> "); + ui.text(&state.name.value).fg(theme.accent); + ui.text(format!(" ({} chars)", state.name.value.chars().count())) .dim(); }); - }); - - ui.separator(); + } + }); - ui.text("Message").bold(); - let rows = term_h.saturating_sub(16).max(5); - let _ = ui.textarea(&mut message, rows); + let _ = ui.container().grow(1).gap(1).col(|ui| { + ui.text("Search").bold(); + let _ = ui.text_input(&mut state.search); - let total: usize = message.lines.iter().map(|l| l.chars().count()).sum(); - ui.text(format!("{} lines, {} chars", message.lines.len(), total,)) + let query = state.search.value.to_lowercase(); + let tokens: Vec<&str> = query.split_whitespace().collect(); + state.results = ITEMS + .iter() + .filter(|item| { + let lower = item.to_lowercase(); + tokens.is_empty() || tokens.iter().all(|t| lower.contains(t)) + }) + .map(|s| s.to_string()) + .collect(); + ui.text(format!("{}/{} items", state.results.len(), ITEMS.len())) .dim(); }); - - let _ = ui.help(&[ - ("^Q/Esc", "quit"), - ("Tab", "next field"), - ("Type", "한글/CJK input"), - ]); }); + + ui.separator(); + + ui.text("Message").bold(); + let rows = term_h.saturating_sub(16).max(5); + let _ = ui.textarea(&mut state.message, rows); + + let total: usize = state.message.lines.iter().map(|l| l.chars().count()).sum(); + ui.text(format!( + "{} lines, {} chars", + state.message.lines.len(), + total + )) + .dim(); + }); + + let _ = ui.help(&[ + ("^Q/Esc", "quit"), + ("Tab", "next field"), + ("Type", "CJK input"), + ]); + }); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + slt::run_with( + RunConfig::default().mouse(true).kitty_keyboard(true), + move |ui: &mut Context| { + if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + render(ui, &mut state); }, ) } diff --git a/examples/demo_infoviz.rs b/examples/demo_infoviz.rs index 971a556..0c7c0a1 100644 --- a/examples/demo_infoviz.rs +++ b/examples/demo_infoviz.rs @@ -1,8 +1,54 @@ +//! Demo: information visualization (line/scatter/bar/heatmap/candlestick/ +//! treemap/canvas). +//! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` (via +//! [`render_with_state`]) so a composing demo (e.g. +//! `examples/showcase_tour.rs`) can preserve the selected tab across +//! tab switches. The legacy stateless `pub fn render(ui)` (snapshot- +//! style) is retained for visual snapshot tests in +//! `tests/visual_snapshots.rs`. + use slt::{ Bar, BarDirection, BarGroup, Border, Candle, Color, Context, LegendPosition, Marker, TabsState, TreemapItem, }; +/// Persistent state for the infoviz demo: the selected tab. +pub struct DemoState { + pub tabs: TabsState, +} + +impl DemoState { + pub fn new() -> Self { + Self { + tabs: TabsState::new(vec![ + "Overview", + "Lines", + "Scatter", + "Bars", + "Heatmap", + "Financial", + "Treemap", + "Canvas", + ]), + } + } +} + +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} + +/// Render one frame with caller-owned state — used by the showcase +/// tour so the selected chart tab survives across tab switches. +pub fn render_with_state(ui: &mut Context, state: &mut DemoState) { + render_frame(ui, &mut state.tabs); +} + fn main() -> std::io::Result<()> { let mut tabs = TabsState::new(vec![ "Overview", @@ -294,8 +340,8 @@ pub fn render_frame(ui: &mut Context, tabs: &mut TabsState) { ui.quit(); } { - let tw = ui.width() as u32; - let th = ui.height() as u32; + let tw = ui.width(); + let th = ui.height(); let grid_dim = slt::Style::new().fg(Color::Indexed(237)); let _ = ui diff --git a/examples/demo_pretext.rs b/examples/demo_pretext.rs index b4a94ba..2064b0b 100644 --- a/examples/demo_pretext.rs +++ b/examples/demo_pretext.rs @@ -1,11 +1,15 @@ -/// Pretext-style demo: text reflows around the mouse cursor trail in real time. -/// -/// Inspired by — demonstrates how fast -/// text relayout enables interactive, mouse-reactive typography in the terminal. -/// -/// Move the mouse to create a caterpillar-shaped exclusion zone that text -/// flows around. The trail follows the cursor with smooth interpolation and -/// each segment pushes words aside independently. +//! Pretext-style demo: text reflows around the mouse cursor trail in real time. +//! +//! Inspired by — demonstrates how fast +//! text relayout enables interactive, mouse-reactive typography in the terminal. +//! +//! Move the mouse to create a caterpillar-shaped exclusion zone that text +//! flows around. The trail follows the cursor with smooth interpolation and +//! each segment pushes words aside independently. +//! +//! Archetype: Standard (full-canvas raw draw, no overlay, no scrollback). +//! Composes into a tabbed tour by calling [`render`] with caller-owned state. + use std::collections::VecDeque; use std::time::Duration; @@ -151,296 +155,303 @@ const MIN_TRAIL_DIST: f64 = 1.5; /// Smoothing factor for the cursor position. const SMOOTH: f64 = 0.05; -fn main() { - let mut smooth_mx: f64 = -100.0; - let mut smooth_my: f64 = -100.0; - let mut first_mouse = true; - let mut trail: VecDeque<(f64, f64)> = VecDeque::with_capacity(TRAIL_LEN + 1); +/// State persisted across frames. Owned by either the standalone `main` or +/// the parent tour. +pub struct DemoState { + pub smooth_mx: f64, + pub smooth_my: f64, + pub first_mouse: bool, + pub trail: VecDeque<(f64, f64)>, +} - let _ = slt::run_with( - RunConfig::default() - .mouse(true) - .tick_rate(Duration::from_millis(16)) - .max_fps(60), - move |ui: &mut Context| { - if ui.key('q') || ui.key_code(KeyCode::Esc) { - ui.quit(); +impl Default for DemoState { + fn default() -> Self { + Self { + smooth_mx: -100.0, + smooth_my: -100.0, + first_mouse: true, + trail: VecDeque::with_capacity(TRAIL_LEN + 1), + } + } +} + +/// Render one frame of the pretext demo. The caller owns `state` so the +/// trail and smoothed cursor persist across frames. +pub fn render(ui: &mut Context, state: &mut DemoState) { + // Smooth mouse tracking. + if let Some((mx, my)) = ui.mouse_pos() { + let mx = mx as f64; + let my = my as f64; + if state.first_mouse { + state.smooth_mx = mx; + state.smooth_my = my; + state.first_mouse = false; + } else { + state.smooth_mx += (mx - state.smooth_mx) * (1.0 - SMOOTH); + state.smooth_my += (my - state.smooth_my) * (1.0 - SMOOTH); + } + } + + // Record trail points with minimum distance threshold. + if let Some(&(last_x, last_y)) = state.trail.back() { + let dx = state.smooth_mx - last_x; + let dy = state.smooth_my - last_y; + if dx * dx + dy * dy >= MIN_TRAIL_DIST * MIN_TRAIL_DIST { + state.trail.push_back((state.smooth_mx, state.smooth_my)); + if state.trail.len() > TRAIL_LEN { + state.trail.pop_front(); + } + } + } else { + state.trail.push_back((state.smooth_mx, state.smooth_my)); + } + + let tick = ui.tick(); + let trail_snap: Vec<(f64, f64)> = state.trail.iter().copied().collect(); + + ui.container() + .grow(1) + .draw(move |buf: &mut Buffer, rect: Rect| { + let words: Vec<&str> = SAMPLE_TEXT.split_whitespace().collect(); + let word_widths: Vec = words + .iter() + .map(|w| { + w.chars() + .map(|c| UnicodeWidthChar::width(c).unwrap_or(1) as u32) + .sum() + }) + .collect(); + + let area_x = rect.x + 1; + let area_y = rect.y + 1; + let area_w = rect.width.saturating_sub(2); + let area_h = rect.height.saturating_sub(2); + + if area_w < 10 || area_h < 3 { return; } - // Smooth mouse tracking - if let Some((mx, my)) = ui.mouse_pos() { - let mx = mx as f64; - let my = my as f64; - if first_mouse { - smooth_mx = mx; - smooth_my = my; - first_mouse = false; - } else { - smooth_mx += (mx - smooth_mx) * (1.0 - SMOOTH); - smooth_my += (my - smooth_my) * (1.0 - SMOOTH); - } + // Draw subtle border. + let border_style = Style::new().fg(Color::Indexed(238)); + for x in rect.x..rect.right() { + buf.set_char(x, rect.y, '─', border_style); + buf.set_char(x, rect.bottom().saturating_sub(1), '─', border_style); } - - // Record trail points with minimum distance threshold - if let Some(&(last_x, last_y)) = trail.back() { - let dx = smooth_mx - last_x; - let dy = smooth_my - last_y; - if dx * dx + dy * dy >= MIN_TRAIL_DIST * MIN_TRAIL_DIST { - trail.push_back((smooth_mx, smooth_my)); - if trail.len() > TRAIL_LEN { - trail.pop_front(); - } - } - } else { - trail.push_back((smooth_mx, smooth_my)); + for y in rect.y..rect.bottom() { + buf.set_char(rect.x, y, '│', border_style); + buf.set_char(rect.right().saturating_sub(1), y, '│', border_style); } - - let tick = ui.tick(); - // Copy trail for the 'static closure - let trail_snap: Vec<(f64, f64)> = trail.iter().copied().collect(); - - ui.container() - .grow(1) - .draw(move |buf: &mut Buffer, rect: Rect| { - let words: Vec<&str> = SAMPLE_TEXT.split_whitespace().collect(); - let word_widths: Vec = words - .iter() - .map(|w| { - w.chars() - .map(|c| UnicodeWidthChar::width(c).unwrap_or(1) as u32) - .sum() - }) - .collect(); - - let area_x = rect.x + 1; - let area_y = rect.y + 1; - let area_w = rect.width.saturating_sub(2); - let area_h = rect.height.saturating_sub(2); - - if area_w < 10 || area_h < 3 { - return; + buf.set_char(rect.x, rect.y, '╭', border_style); + buf.set_char(rect.right().saturating_sub(1), rect.y, '╮', border_style); + buf.set_char(rect.x, rect.bottom().saturating_sub(1), '╰', border_style); + buf.set_char( + rect.right().saturating_sub(1), + rect.bottom().saturating_sub(1), + '╯', + border_style, + ); + + // Title. + let title = " ✦ pretext reflow "; + let tx = area_x + (area_w.saturating_sub(title.len() as u32)) / 2; + buf.set_string( + tx, + rect.y, + title, + Style::new().fg(Color::Rgb(180, 140, 255)), + ); + + // Help text at bottom. + let help = " q: quit │ move mouse to push text "; + let hx = area_x + (area_w.saturating_sub(help.len() as u32)) / 2; + buf.set_string( + hx, + rect.bottom().saturating_sub(1), + help, + Style::new().fg(Color::Indexed(243)), + ); + + let trail_len = trail_snap.len(); + + let is_excluded = |px: f64, py: f64| -> bool { + for (i, &(tx, ty)) in trail_snap.iter().enumerate() { + let age = (trail_len - 1 - i) as f64 / TRAIL_LEN as f64; + let r = HEAD_RADIUS * (1.0 - age * 0.6); + let r_sq = r * r; + let dist_sq = (px - tx) * (px - tx) + (py - ty) * (py - ty) * 4.0; + if dist_sq < r_sq { + return true; } - - // Draw subtle border - let border_style = Style::new().fg(Color::Indexed(238)); - for x in rect.x..rect.right() { - buf.set_char(x, rect.y, '─', border_style); - buf.set_char(x, rect.bottom().saturating_sub(1), '─', border_style); - } - for y in rect.y..rect.bottom() { - buf.set_char(rect.x, y, '│', border_style); - buf.set_char(rect.right().saturating_sub(1), y, '│', border_style); + } + false + }; + + let glow_at = |px: f64, py: f64| -> (f64, f64) { + let mut best_t = 1.0_f64; + let mut best_age = 1.0_f64; + for (i, &(tx, ty)) in trail_snap.iter().enumerate() { + let age = (trail_len - 1 - i) as f64 / TRAIL_LEN as f64; + let r = HEAD_RADIUS * (1.0 - age * 0.6); + let r_sq = r * r; + let dist_sq = (px - tx) * (px - tx) + (py - ty) * (py - ty) * 4.0; + if dist_sq < r_sq { + let t = (dist_sq / r_sq).sqrt(); + if t < best_t { + best_t = t; + best_age = age; + } } - buf.set_char(rect.x, rect.y, '╭', border_style); - buf.set_char(rect.right().saturating_sub(1), rect.y, '╮', border_style); - buf.set_char(rect.x, rect.bottom().saturating_sub(1), '╰', border_style); - buf.set_char( - rect.right().saturating_sub(1), - rect.bottom().saturating_sub(1), - '╯', - border_style, - ); + } + (best_t, best_age) + }; - // Title - let title = " ✦ pretext reflow "; - let tx = area_x + (area_w.saturating_sub(title.len() as u32)) / 2; - buf.set_string( - tx, - rect.y, - title, - Style::new().fg(Color::Rgb(180, 140, 255)), - ); + // Reflow text around the caterpillar trail. + let mut word_idx = 0; + let mut cy = area_y; + let total_words = words.len(); - // Help text at bottom - let help = " q: quit │ move mouse to push text "; - let hx = area_x + (area_w.saturating_sub(help.len() as u32)) / 2; - buf.set_string( - hx, - rect.bottom().saturating_sub(1), - help, - Style::new().fg(Color::Indexed(243)), - ); + while cy < area_y + area_h { + let mut cx = area_x; + let row_end = area_x + area_w; - let trail_len = trail_snap.len(); - - // Exclusion test: is (px, py) inside any trail segment? - // Each segment has a radius that shrinks toward the tail. - let is_excluded = |px: f64, py: f64| -> bool { - for (i, &(tx, ty)) in trail_snap.iter().enumerate() { - // Tail segments are smaller — linear falloff - let age = (trail_len - 1 - i) as f64 / TRAIL_LEN as f64; - let r = HEAD_RADIUS * (1.0 - age * 0.6); - let r_sq = r * r; - let dist_sq = (px - tx) * (px - tx) + (py - ty) * (py - ty) * 4.0; - if dist_sq < r_sq { - return true; - } - } - false - }; - - // Glow intensity at a point (0.0 = outside, up to 1.0 = center) - let glow_at = |px: f64, py: f64| -> (f64, f64) { - let mut best_t = 1.0_f64; - let mut best_age = 1.0_f64; - for (i, &(tx, ty)) in trail_snap.iter().enumerate() { - let age = (trail_len - 1 - i) as f64 / TRAIL_LEN as f64; - let r = HEAD_RADIUS * (1.0 - age * 0.6); - let r_sq = r * r; - let dist_sq = (px - tx) * (px - tx) + (py - ty) * (py - ty) * 4.0; - if dist_sq < r_sq { - let t = (dist_sq / r_sq).sqrt(); - if t < best_t { - best_t = t; - best_age = age; - } - } - } - (best_t, best_age) - }; + if word_idx >= total_words { + word_idx = 0; + } - // Reflow text around the caterpillar trail - let mut word_idx = 0; - let mut cy = area_y; - let total_words = words.len(); + let row_start_word = word_idx; + let mut placed_any = false; - while cy < area_y + area_h { - let mut cx = area_x; - let row_end = area_x + area_w; + while cx < row_end { + let wi = word_idx % total_words; + let ww = word_widths[wi]; + let space_needed = if cx == area_x { ww } else { ww + 1 }; - if word_idx >= total_words { - word_idx = 0; + let end_x = cx + space_needed; + let mut blocked = false; + for check_x in cx..end_x.min(row_end) { + if is_excluded(check_x as f64, cy as f64) { + blocked = true; + break; } + } - let row_start_word = word_idx; - let mut placed_any = false; - - while cx < row_end { - let wi = word_idx % total_words; - let ww = word_widths[wi]; - let space_needed = if cx == area_x { ww } else { ww + 1 }; - - // Does this word span intersect any trail segment? - let end_x = cx + space_needed; - let mut blocked = false; - for check_x in cx..end_x.min(row_end) { - if is_excluded(check_x as f64, cy as f64) { - blocked = true; - break; - } - } - - if blocked { - // Skip past the excluded region on this row - let mut skip_x = cx + 1; - while skip_x < row_end { - if !is_excluded(skip_x as f64, cy as f64) { - break; - } - skip_x += 1; - } - - if skip_x >= row_end || (row_end - skip_x) < ww { - break; - } - cx = skip_x; - continue; - } - - if cx + space_needed > row_end { + if blocked { + let mut skip_x = cx + 1; + while skip_x < row_end { + if !is_excluded(skip_x as f64, cy as f64) { break; } - - if cx > area_x { - cx += 1; - } - - // Color: subtle gradient based on word position - let progress = wi as f64 / total_words as f64; - let wave = - ((progress * 6.0 + tick as f64 * 0.02).sin() * 0.5 + 0.5) as f32; - let r = (140.0 + wave * 80.0) as u8; - let g = (160.0 + wave * 60.0) as u8; - let b = (200.0 + wave * 55.0) as u8; - let word_style = Style::new().fg(Color::Rgb(r, g, b)); - - let mut wx = cx; - for ch in words[wi].chars() { - let cw = UnicodeWidthChar::width(ch).unwrap_or(1) as u32; - if wx + cw <= row_end { - buf.set_char(wx, cy, ch, word_style); - } - wx += cw; - } - - cx = wx; - word_idx += 1; - placed_any = true; + skip_x += 1; } - if !placed_any && word_idx == row_start_word { - word_idx += 1; + if skip_x >= row_end || (row_end - skip_x) < ww { + break; } + cx = skip_x; + continue; + } - cy += 1; + if cx + space_needed > row_end { + break; } - // Draw the caterpillar glow overlay - for dy in 0..area_h { - for dx in 0..area_w { - let px = (area_x + dx) as f64; - let py = (area_y + dy) as f64; - let (t, age) = glow_at(px, py); - if t < 1.0 { - let brightness = ((1.0 - t) * 40.0) as u8; - // Color shifts from purple (head) to blue (tail) - let head_mix = 1.0 - age; - let r_c = (100.0 + brightness as f64 * 2.0 * head_mix) as u8; - let g_c = (60.0 + brightness as f64 * head_mix * 0.5) as u8; - let b_c = (140.0 + brightness as f64 * (1.0 + age)) as u8; - let ch = if t < 0.3 { - ' ' - } else if t < 0.6 { - '·' - } else { - '∙' - }; - buf.set_char( - area_x + dx, - area_y + dy, - ch, - Style::new().fg(Color::Rgb(r_c, g_c, b_c)), - ); - } - } + if cx > area_x { + cx += 1; } - // Draw trail segment centers (caterpillar spine) - for (i, &(tx, ty)) in trail_snap.iter().enumerate() { - let sx = tx.round() as u32; - let sy = ty.round() as u32; - if sx >= area_x - && sx < area_x + area_w - && sy >= area_y - && sy < area_y + area_h - { - let age = (trail_len - 1 - i) as f64 / TRAIL_LEN as f64; - let brightness = (255.0 * (1.0 - age * 0.7)) as u8; - let ch = if i == trail_len - 1 { '◉' } else { '○' }; - buf.set_char( - sx, - sy, - ch, - Style::new().fg(Color::Rgb( - brightness, - (brightness as f64 * 0.7) as u8, - 255, - )), - ); + let progress = wi as f64 / total_words as f64; + let wave = ((progress * 6.0 + tick as f64 * 0.02).sin() * 0.5 + 0.5) as f32; + let r = (140.0 + wave * 80.0) as u8; + let g = (160.0 + wave * 60.0) as u8; + let b = (200.0 + wave * 55.0) as u8; + let word_style = Style::new().fg(Color::Rgb(r, g, b)); + + let mut wx = cx; + for ch in words[wi].chars() { + let cw = UnicodeWidthChar::width(ch).unwrap_or(1) as u32; + if wx + cw <= row_end { + buf.set_char(wx, cy, ch, word_style); } + wx += cw; } - }); + + cx = wx; + word_idx += 1; + placed_any = true; + } + + if !placed_any && word_idx == row_start_word { + word_idx += 1; + } + + cy += 1; + } + + // Draw the caterpillar glow overlay. + for dy in 0..area_h { + for dx in 0..area_w { + let px = (area_x + dx) as f64; + let py = (area_y + dy) as f64; + let (t, age) = glow_at(px, py); + if t < 1.0 { + let brightness = ((1.0 - t) * 40.0) as u8; + let head_mix = 1.0 - age; + let r_c = (100.0 + brightness as f64 * 2.0 * head_mix) as u8; + let g_c = (60.0 + brightness as f64 * head_mix * 0.5) as u8; + let b_c = (140.0 + brightness as f64 * (1.0 + age)) as u8; + let ch = if t < 0.3 { + ' ' + } else if t < 0.6 { + '·' + } else { + '∙' + }; + buf.set_char( + area_x + dx, + area_y + dy, + ch, + Style::new().fg(Color::Rgb(r_c, g_c, b_c)), + ); + } + } + } + + // Draw trail segment centers (caterpillar spine). + for (i, &(tx, ty)) in trail_snap.iter().enumerate() { + let sx = tx.round() as u32; + let sy = ty.round() as u32; + if sx >= area_x && sx < area_x + area_w && sy >= area_y && sy < area_y + area_h { + let age = (trail_len - 1 - i) as f64 / TRAIL_LEN as f64; + let brightness = (255.0 * (1.0 - age * 0.7)) as u8; + let ch = if i == trail_len - 1 { '◉' } else { '○' }; + buf.set_char( + sx, + sy, + ch, + Style::new().fg(Color::Rgb( + brightness, + (brightness as f64 * 0.7) as u8, + 255, + )), + ); + } + } + }); +} + +fn main() { + let mut state = DemoState::default(); + let _ = slt::run_with( + RunConfig::default() + .mouse(true) + .tick_rate(Duration::from_millis(16)) + .max_fps(60), + move |ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) { + ui.quit(); + return; + } + render(ui, &mut state); }, ); } diff --git a/examples/demo_spreadsheet.rs b/examples/demo_spreadsheet.rs index 51192ee..5fdcca1 100644 --- a/examples/demo_spreadsheet.rs +++ b/examples/demo_spreadsheet.rs @@ -1,15 +1,24 @@ +//! Demo: spreadsheet-style table editor. +//! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo can preserve the cursor position, edit-mode flag, the +//! typed edit value, and the scroll offset across tab switches. The +//! standalone `main()` is a thin wrapper. + use slt::{Border, Color, Context, ScrollState, Style, TextInputState, Theme}; -struct Sheet { - headers: Vec, - rows: Vec>, - cursor_row: usize, - cursor_col: usize, - col_widths: Vec, +pub struct Sheet { + pub headers: Vec, + pub rows: Vec>, + pub cursor_row: usize, + pub cursor_col: usize, + pub col_widths: Vec, } impl Sheet { - fn new(headers: Vec<&str>, data: Vec>) -> Self { + pub fn new(headers: Vec<&str>, data: Vec>) -> Self { let headers: Vec = headers.into_iter().map(String::from).collect(); let rows: Vec> = data .into_iter() @@ -35,7 +44,7 @@ impl Sheet { } } - fn cell(&self, row: usize, col: usize) -> &str { + pub fn cell(&self, row: usize, col: usize) -> &str { self.rows .get(row) .and_then(|r| r.get(col)) @@ -43,412 +52,448 @@ impl Sheet { .unwrap_or("") } - fn set_cell(&mut self, row: usize, col: usize, val: String) { + pub fn set_cell(&mut self, row: usize, col: usize, val: String) { if row < self.rows.len() && col < self.headers.len() { self.rows[row][col] = val.clone(); self.col_widths[col] = self.col_widths[col].max(val.len()); } } - fn total_rows(&self) -> usize { + pub fn total_rows(&self) -> usize { self.rows.len() } - fn total_cols(&self) -> usize { + pub fn total_cols(&self) -> usize { self.headers.len() } } -fn main() -> std::io::Result<()> { - let mut sheet = Sheet::new( - vec![ - "ID", - "Name", - "Department", - "Salary", - "Start Date", - "Status", - "Rating", - ], - vec![ - vec![ - "1001", - "Alice Kim", - "Engineering", - "125000", - "2021-03-15", - "Active", - "4.8", - ], - vec![ - "1002", - "Bob Chen", - "Marketing", - "95000", - "2020-07-22", - "Active", - "4.2", - ], - vec![ - "1003", - "Carol Wu", - "Engineering", - "132000", - "2019-11-01", - "Active", - "4.9", - ], - vec![ - "1004", - "Dan Park", - "Design", - "105000", - "2022-01-10", - "Active", - "4.5", - ], - vec![ - "1005", - "Eve Liu", - "Engineering", - "128000", - "2020-05-18", - "On Leave", - "4.7", - ], - vec![ - "1006", - "Frank Lee", - "Sales", - "88000", - "2023-02-14", - "Active", - "3.9", - ], - vec![ - "1007", - "Grace Cho", - "Engineering", - "140000", - "2018-09-30", - "Active", - "5.0", - ], - vec![ - "1008", - "Hank Yun", - "Marketing", - "92000", - "2021-08-05", - "Active", - "4.1", - ], - vec![ - "1009", - "Ivy Song", - "Design", - "108000", - "2022-06-20", - "Active", - "4.6", - ], - vec![ - "1010", - "Jack Oh", - "Sales", - "91000", - "2023-04-01", - "Probation", - "3.5", - ], - vec![ - "1011", - "Kate Ryu", - "Engineering", - "135000", - "2019-01-15", - "Active", - "4.8", - ], - vec![ - "1012", - "Leo Bae", - "HR", - "98000", - "2020-11-22", - "Active", - "4.3", - ], - vec![ - "1013", - "Mia Jang", - "Engineering", - "130000", - "2021-06-01", - "Active", - "4.7", - ], - vec![ - "1014", - "Noah Shin", - "Finance", - "115000", - "2022-03-08", - "Active", - "4.4", - ], - vec![ - "1015", - "Olive Han", - "Design", - "102000", - "2023-01-20", - "Active", - "4.0", - ], - vec![ - "1016", - "Paul Lim", - "Engineering", - "142000", - "2017-04-10", - "Active", - "4.9", - ], - vec![ - "1017", - "Quinn Jung", - "Sales", - "87000", - "2023-07-15", - "Probation", - "3.2", - ], - vec![ - "1018", - "Rose Ahn", - "Marketing", - "96000", - "2021-09-12", - "Active", - "4.3", - ], +/// Persistent state for the spreadsheet demo. +pub struct DemoState { + pub sheet: Sheet, + pub editing: bool, + pub edit_input: TextInputState, + pub scroll: ScrollState, + pub formula_bar: String, + pub dark_mode: bool, +} + +impl DemoState { + pub fn new() -> Self { + let sheet = Sheet::new( vec![ - "1019", - "Sam Kang", - "Engineering", - "138000", - "2018-12-01", - "Active", - "4.8", + "ID", + "Name", + "Department", + "Salary", + "Start Date", + "Status", + "Rating", ], vec![ - "1020", - "Tina Moon", - "HR", - "95000", - "2022-08-25", - "Active", - "4.1", + vec![ + "1001", + "Alice Kim", + "Engineering", + "125000", + "2021-03-15", + "Active", + "4.8", + ], + vec![ + "1002", + "Bob Chen", + "Marketing", + "95000", + "2020-07-22", + "Active", + "4.2", + ], + vec![ + "1003", + "Carol Wu", + "Engineering", + "132000", + "2019-11-01", + "Active", + "4.9", + ], + vec![ + "1004", + "Dan Park", + "Design", + "105000", + "2022-01-10", + "Active", + "4.5", + ], + vec![ + "1005", + "Eve Liu", + "Engineering", + "128000", + "2020-05-18", + "On Leave", + "4.7", + ], + vec![ + "1006", + "Frank Lee", + "Sales", + "88000", + "2023-02-14", + "Active", + "3.9", + ], + vec![ + "1007", + "Grace Cho", + "Engineering", + "140000", + "2018-09-30", + "Active", + "5.0", + ], + vec![ + "1008", + "Hank Yun", + "Marketing", + "92000", + "2021-08-05", + "Active", + "4.1", + ], + vec![ + "1009", + "Ivy Song", + "Design", + "108000", + "2022-06-20", + "Active", + "4.6", + ], + vec![ + "1010", + "Jack Oh", + "Sales", + "91000", + "2023-04-01", + "Probation", + "3.5", + ], + vec![ + "1011", + "Kate Ryu", + "Engineering", + "135000", + "2019-01-15", + "Active", + "4.8", + ], + vec![ + "1012", + "Leo Bae", + "HR", + "98000", + "2020-11-22", + "Active", + "4.3", + ], + vec![ + "1013", + "Mia Jang", + "Engineering", + "130000", + "2021-06-01", + "Active", + "4.7", + ], + vec![ + "1014", + "Noah Shin", + "Finance", + "115000", + "2022-03-08", + "Active", + "4.4", + ], + vec![ + "1015", + "Olive Han", + "Design", + "102000", + "2023-01-20", + "Active", + "4.0", + ], + vec![ + "1016", + "Paul Lim", + "Engineering", + "142000", + "2017-04-10", + "Active", + "4.9", + ], + vec![ + "1017", + "Quinn Jung", + "Sales", + "87000", + "2023-07-15", + "Probation", + "3.2", + ], + vec![ + "1018", + "Rose Ahn", + "Marketing", + "96000", + "2021-09-12", + "Active", + "4.3", + ], + vec![ + "1019", + "Sam Kang", + "Engineering", + "138000", + "2018-12-01", + "Active", + "4.8", + ], + vec![ + "1020", + "Tina Moon", + "HR", + "95000", + "2022-08-25", + "Active", + "4.1", + ], ], - ], - ); + ); + Self { + sheet, + editing: false, + edit_input: TextInputState::new(), + scroll: ScrollState::new(), + formula_bar: String::new(), + dark_mode: true, + } + } +} - let mut editing = false; - let mut edit_input = TextInputState::new(); - let mut scroll = ScrollState::new(); - let mut formula_bar = String::new(); - let mut dark_mode = true; +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} - 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) { - if editing { - editing = false; - } else { - ui.quit(); - } +/// Render one frame of the spreadsheet demo. +pub fn render(ui: &mut Context, state: &mut DemoState) { + if ui.key_mod('t', slt::KeyModifiers::CONTROL) { + state.dark_mode = !state.dark_mode; + } + ui.set_theme(if state.dark_mode { + Theme::dark() + } else { + Theme::light() + }); + + if !state.editing { + if ui.key_code(slt::KeyCode::Up) { + state.sheet.cursor_row = state.sheet.cursor_row.saturating_sub(1); } - if ui.key_mod('t', slt::KeyModifiers::CONTROL) { - dark_mode = !dark_mode; + if ui.key_code(slt::KeyCode::Down) { + state.sheet.cursor_row = (state.sheet.cursor_row + 1).min(state.sheet.total_rows() - 1); } - ui.set_theme(if dark_mode { - Theme::dark() - } else { - Theme::light() - }); + if ui.key_code(slt::KeyCode::Left) { + state.sheet.cursor_col = state.sheet.cursor_col.saturating_sub(1); + } + if ui.key_code(slt::KeyCode::Right) { + state.sheet.cursor_col = (state.sheet.cursor_col + 1).min(state.sheet.total_cols() - 1); + } + if ui.key_code(slt::KeyCode::Enter) { + state.editing = true; + state.edit_input.value = state + .sheet + .cell(state.sheet.cursor_row, state.sheet.cursor_col) + .to_string(); + state.edit_input.cursor = state.edit_input.value.len(); + } + state.formula_bar = state + .sheet + .cell(state.sheet.cursor_row, state.sheet.cursor_col) + .to_string(); + } else { + if ui.key_code(slt::KeyCode::Enter) { + state.sheet.set_cell( + state.sheet.cursor_row, + state.sheet.cursor_col, + state.edit_input.value.clone(), + ); + state.editing = false; + } + if ui.key_code(slt::KeyCode::Esc) { + state.editing = false; + } + } - if !editing { - if ui.key_code(slt::KeyCode::Up) { - sheet.cursor_row = sheet.cursor_row.saturating_sub(1); - } - if ui.key_code(slt::KeyCode::Down) { - sheet.cursor_row = (sheet.cursor_row + 1).min(sheet.total_rows() - 1); - } - if ui.key_code(slt::KeyCode::Left) { - sheet.cursor_col = sheet.cursor_col.saturating_sub(1); - } - if ui.key_code(slt::KeyCode::Right) { - sheet.cursor_col = (sheet.cursor_col + 1).min(sheet.total_cols() - 1); - } - if ui.key_code(slt::KeyCode::Enter) { - editing = true; - edit_input.value = sheet.cell(sheet.cursor_row, sheet.cursor_col).to_string(); - edit_input.cursor = edit_input.value.len(); - } - formula_bar = sheet.cell(sheet.cursor_row, sheet.cursor_col).to_string(); + let col_letter = |c: usize| -> String { + if c < 26 { + format!("{}", (b'A' + c as u8) as char) } else { - if ui.key_code(slt::KeyCode::Enter) { - sheet.set_cell(sheet.cursor_row, sheet.cursor_col, edit_input.value.clone()); - editing = false; - } - if ui.key_code(slt::KeyCode::Esc) { - editing = false; - } + format!( + "{}{}", + (b'A' + (c / 26 - 1) as u8) as char, + (b'A' + (c % 26) as u8) as char + ) } + }; - let col_letter = |c: usize| -> String { - if c < 26 { - format!("{}", (b'A' + c as u8) as char) - } else { - format!( + let _ = ui + .bordered(Border::Rounded) + .title("Spreadsheet") + .p(1) + .grow(1) + .col(|ui| { + // formula bar + let _ = ui.row(|ui| { + ui.text(format!( "{}{}", - (b'A' + (c / 26 - 1) as u8) as char, - (b'A' + (c % 26) as u8) as char - ) - } - }; + col_letter(state.sheet.cursor_col), + state.sheet.cursor_row + 1 + )) + .bold() + .fg(Color::Cyan); + ui.text(" | ").dim(); + if state.editing { + let _ = ui.text_input(&mut state.edit_input); + } else { + ui.text(&state.formula_bar); + } + }); + ui.separator(); - let _ = ui - .bordered(Border::Rounded) - .title("Spreadsheet") - .p(1) - .grow(1) - .col(|ui| { - // formula bar - let _ = ui.row(|ui| { - ui.text(format!( - "{}{}", - col_letter(sheet.cursor_col), - sheet.cursor_row + 1 - )) - .bold() - .fg(Color::Cyan); - ui.text(" │ ").dim(); - if editing { - let _ = ui.text_input(&mut edit_input); - } else { - ui.text(&formula_bar); + // column headers + let _ = ui.scrollable(&mut state.scroll).grow(1).col(|ui| { + let mut header_line = String::from(" "); + for (c, h) in state.sheet.headers.iter().enumerate() { + let w = state.sheet.col_widths[c] + 2; + header_line.push_str(&format!("{:^w$}", h, w = w)); + if c < state.sheet.total_cols() - 1 { + header_line.push('\u{2502}'); } - }); - ui.separator(); + } + ui.text(&header_line).bold().fg(Color::Cyan); - // column headers - let _ = ui.scrollable(&mut scroll).grow(1).col(|ui| { - let mut header_line = String::from(" "); - for (c, h) in sheet.headers.iter().enumerate() { - let w = sheet.col_widths[c] + 2; - header_line.push_str(&format!("{:^w$}", h, w = w)); - if c < sheet.total_cols() - 1 { - header_line.push('│'); - } + let mut sep_line = String::from("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"); + for (c, _) in state.sheet.headers.iter().enumerate() { + let w = state.sheet.col_widths[c] + 2; + sep_line.push_str(&"\u{2500}".repeat(w)); + if c < state.sheet.total_cols() - 1 { + sep_line.push('\u{253C}'); } - ui.text(&header_line).bold().fg(Color::Cyan); + } + ui.text(&sep_line).dim(); - let mut sep_line = String::from("─────"); - for (c, _) in sheet.headers.iter().enumerate() { - let w = sheet.col_widths[c] + 2; - sep_line.push_str(&"─".repeat(w)); - if c < sheet.total_cols() - 1 { - sep_line.push('┼'); + for r in 0..state.sheet.total_rows() { + let row_num = format!("{:>4} ", r + 1); + let mut line = String::new(); + for c in 0..state.sheet.total_cols() { + let w = state.sheet.col_widths[c] + 2; + let val = state.sheet.cell(r, c); + let formatted = if is_numeric(val) { + format!("{:>w$}", val, w = w) + } else { + format!(" {:4} ", r + 1); - let mut line = String::new(); - for c in 0..sheet.total_cols() { - let w = sheet.col_widths[c] + 2; - let val = sheet.cell(r, c); - let formatted = if is_numeric(val) { - format!("{:>w$}", val, w = w) - } else { - format!(" {:w$}", val, w = w) - } else { - format!(" {:w$}", val, w = w) + } else { + format!(" {: std::io::Result<()> { + let mut state = DemoState::new(); + slt::run_with( + slt::RunConfig::default().mouse(true), + move |ui: &mut Context| { + if ui.key_mod('q', slt::KeyModifiers::CONTROL) + || (ui.key_code(slt::KeyCode::Esc) && !state.editing) + { + ui.quit(); + } + render(ui, &mut state); + }, + ) } fn is_numeric(s: &str) -> bool { diff --git a/examples/demo_table.rs b/examples/demo_table.rs index d955382..501c508 100644 --- a/examples/demo_table.rs +++ b/examples/demo_table.rs @@ -1,100 +1,145 @@ +//! Demo: searchable, sortable table. +//! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo (e.g. `examples/showcase_tour.rs`) can preserve the +//! typed filter, current sort column, and table cursor across tab +//! switches. The standalone `main()` is a thin wrapper that owns the +//! quit triple and a single persistent `DemoState`. + use slt::{Context, TableState, TextInputState, Theme}; -fn main() -> std::io::Result<()> { - let mut table = TableState::new( - vec!["Rank", "Name", "Language", "Stars", "Category"], - vec![ - vec!["1", "Bubbletea", "Go", "30200", "TUI"], - vec!["2", "Textual", "Python", "26800", "TUI"], - vec!["3", "Charm", "Go", "18500", "CLI"], - vec!["4", "Ratatui", "Rust", "12500", "TUI"], - vec!["5", "Rich", "Python", "51000", "CLI"], - vec!["6", "Ink", "JS/TS", "8200", "TUI"], - vec!["7", "Blessed", "JS", "11200", "TUI"], - vec!["8", "Cursive", "Rust", "4200", "TUI"], - vec!["9", "Prompts", "JS/TS", "9500", "CLI"], - vec!["10", "Click", "Python", "15800", "CLI"], - vec!["11", "Cobra", "Go", "39000", "CLI"], - vec!["12", "Clap", "Rust", "14500", "CLI"], - vec!["13", "Ncurses", "C", "2100", "Library"], - vec!["14", "Notcurses", "C", "3700", "Library"], - vec!["15", "SLT", "Rust", "500", "TUI"], - vec!["16", "Tview", "Go", "11000", "TUI"], - vec!["17", "Crossterm", "Rust", "3300", "Library"], - vec!["18", "Urwid", "Python", "2800", "TUI"], - vec!["19", "Termion", "Rust", "2200", "Library"], - vec!["20", "FTXUI", "C++", "7200", "TUI"], - ], - ); - table.page_size = 8; +/// Persistent state for the table demo: the data table, the filter +/// input, and a `dark_mode` toggle. +pub struct DemoState { + pub table: TableState, + pub filter: TextInputState, + pub dark_mode: bool, +} - let mut filter_input = TextInputState::with_placeholder("Type to filter..."); - let mut dark_mode = true; +impl DemoState { + pub fn new() -> Self { + let mut table = TableState::new( + vec!["Rank", "Name", "Language", "Stars", "Category"], + vec![ + vec!["1", "Bubbletea", "Go", "30200", "TUI"], + vec!["2", "Textual", "Python", "26800", "TUI"], + vec!["3", "Charm", "Go", "18500", "CLI"], + vec!["4", "Ratatui", "Rust", "12500", "TUI"], + vec!["5", "Rich", "Python", "51000", "CLI"], + vec!["6", "Ink", "JS/TS", "8200", "TUI"], + vec!["7", "Blessed", "JS", "11200", "TUI"], + vec!["8", "Cursive", "Rust", "4200", "TUI"], + vec!["9", "Prompts", "JS/TS", "9500", "CLI"], + vec!["10", "Click", "Python", "15800", "CLI"], + vec!["11", "Cobra", "Go", "39000", "CLI"], + vec!["12", "Clap", "Rust", "14500", "CLI"], + vec!["13", "Ncurses", "C", "2100", "Library"], + vec!["14", "Notcurses", "C", "3700", "Library"], + vec!["15", "SLT", "Rust", "500", "TUI"], + vec!["16", "Tview", "Go", "11000", "TUI"], + vec!["17", "Crossterm", "Rust", "3300", "Library"], + vec!["18", "Urwid", "Python", "2800", "TUI"], + vec!["19", "Termion", "Rust", "2200", "Library"], + vec!["20", "FTXUI", "C++", "7200", "TUI"], + ], + ); + table.page_size = 8; - 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(); + Self { + table, + filter: TextInputState::with_placeholder("Type to filter..."), + dark_mode: true, } - ui.set_theme(if dark_mode { - Theme::dark() - } else { - Theme::light() - }); - let theme = *ui.theme(); + } +} - let _ = ui.container().p(1).grow(1).col(|ui| { - let _ = ui.row(|ui| { - ui.text("Table Demo").bold().fg(theme.primary); - ui.spacer(); - let _ = ui.toggle("Dark", &mut dark_mode); - }); +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} - ui.separator(); +/// Render one frame of the table demo. Caller owns `DemoState` so the +/// typed filter, sort column, and table cursor persist across frames. +pub fn render(ui: &mut Context, state: &mut DemoState) { + ui.set_theme(if state.dark_mode { + Theme::dark() + } else { + Theme::light() + }); + let theme = *ui.theme(); - let _ = ui.row(|ui| { - ui.text("Filter").bold().fg(theme.text_dim); - let _ = ui.container().grow(1).col(|ui| { - let _ = ui.text_input(&mut filter_input); - }); - }); - table.set_filter(&filter_input.value); + let _ = ui.container().p(1).grow(1).col(|ui| { + let _ = ui.row(|ui| { + ui.text("Table Demo").bold().fg(theme.primary); + ui.spacer(); + let _ = ui.toggle("Dark", &mut state.dark_mode); + }); - let _ = ui.container().grow(1).gap(0).col(|ui| { - let _ = ui.table(&mut table); + ui.separator(); + + let _ = ui.row(|ui| { + ui.text("Filter").bold().fg(theme.text_dim); + let _ = ui.container().grow(1).col(|ui| { + let _ = ui.text_input(&mut state.filter); }); + }); + state.table.set_filter(&state.filter.value); - ui.separator(); + let _ = ui.container().grow(1).gap(0).col(|ui| { + let _ = ui.table(&mut state.table); + }); - if let Some(row) = table.selected_row() { - let _ = ui.row(|ui| { - ui.text("Selected").bold().fg(theme.primary); - ui.text(row.join(" · ")); - }); - } else { - ui.text("No matching rows").dim(); - } + ui.separator(); + if let Some(row) = state.table.selected_row() { let _ = ui.row(|ui| { - ui.text(format!( - "{} / {} rows", - table.visible_indices().len(), - table.rows.len(), - )) - .dim(); - ui.spacer(); - if let Some(col) = table.sort_column { - let dir = if table.sort_ascending { "ASC" } else { "DESC" }; - ui.text(format!("{} {}", table.headers[col], dir)) - .fg(theme.text_dim); - } + ui.text("Selected").bold().fg(theme.primary); + ui.text(row.join(" \u{00b7} ")); }); + } else { + ui.text("No matching rows").dim(); + } - let _ = ui.help(&[ - ("q", "quit"), - ("↑↓/jk", "select"), - ("PgUp/Dn", "page"), - ("Header click", "sort"), - ]); + let _ = ui.row(|ui| { + ui.text(format!( + "{} / {} rows", + state.table.visible_indices().len(), + state.table.rows.len(), + )) + .dim(); + ui.spacer(); + if let Some(col) = state.table.sort_column { + let dir = if state.table.sort_ascending { + "ASC" + } else { + "DESC" + }; + ui.text(format!("{} {}", state.table.headers[col], dir)) + .fg(theme.text_dim); + } }); - }) + + let _ = ui.help(&[ + ("q", "quit"), + ("\u{2191}\u{2193}/jk", "select"), + ("PgUp/Dn", "page"), + ("Header click", "sort"), + ]); + }); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + slt::run_with( + slt::RunConfig::default().mouse(true), + move |ui: &mut Context| { + if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(slt::KeyCode::Esc) { + ui.quit(); + } + render(ui, &mut state); + }, + ) } diff --git a/examples/demo_trading.rs b/examples/demo_trading.rs index 3830518..d873e94 100644 --- a/examples/demo_trading.rs +++ b/examples/demo_trading.rs @@ -1,3 +1,16 @@ +//! Demo: trading dashboard mockup (BTC/USDT order book, candlestick +//! chart, order form, positions table). +//! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo (e.g. `examples/showcase_tour.rs`) can preserve the +//! random-walk price feed, candle history, order book, recent trades, +//! orders/positions tables, and order-form inputs across tab switches. +//! `DemoState` is a public newtype wrapper around the private `St` +//! struct so the trading demo's many private types (`OB`, `Trade`, +//! `Order`, `Pos`, `Rng`, `PendingOrder`) stay private. + use std::collections::VecDeque; use slt::*; @@ -116,81 +129,112 @@ impl Rng { // ── entry ─────────────────────────────────────────────────────── -fn main() -> std::io::Result<()> { - let mut s = St::new(); +/// Public newtype wrapper around the private `St` struct so a tour can +/// own the trading state across frames without exposing internal +/// types (`OB`, `Trade`, `Order`, `Pos`, `Rng`, `PendingOrder`). +pub struct DemoState(St); - slt::run_with( - RunConfig::default().mouse(true).theme(Theme::dark()), - move |ui: &mut Context| { - hotkeys(ui, &mut s); - if let Some(ord) = s.pending.take() { - submit(&mut s, ord.side, ord.price, ord.amount, ord.is_limit); - } - tick(&mut s); - - let _ = ui.container().grow(1).gap(0).col(|ui| { - // header - header(ui, &s); - // main 3-col - let _ = ui.container().grow(1).gap(0).row(|ui| { - // LEFT: order book - let _ = ui - .bordered(Border::Single) - .title("Order Book") - .w_pct(25) - .col(|ui| { - order_book(ui, &s); - }); - // CENTER: chart + trades - let _ = ui.container().w_pct(50).gap(0).col(|ui| { - let old_tf = s.tab_tf.selected; - let _ = ui.tabs(&mut s.tab_tf); - if s.tab_tf.selected != old_tf { - s.candle_interval = tf_interval(s.tab_tf.selected); - regen_candles(&mut s); - } - let tf_label = - ["1m", "5m", "15m", "1H", "4H", "1D"][s.tab_tf.selected.min(5)]; - let _ = ui - .bordered(Border::Single) - .title(format!("BTC/USDT {tf_label}")) - .grow(2) - .col(|ui| { - chart(ui, &s); - }); - let _ = ui - .bordered(Border::Single) - .title("Recent Trades") - .grow(1) - .col(|ui| { - trades(ui, &s); - }); +impl DemoState { + pub fn new() -> Self { + Self(St::new()) + } +} + +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} + +/// Render one frame of the trading demo. Caller owns `DemoState` so +/// the random-walk price feed, candle history, order book, and recent +/// trades all keep ticking across tab switches. +/// +/// Quit handling is intentionally NOT performed here — callers (the +/// standalone `main` and the showcase tour) own the quit triple so +/// embedded use can let the host swallow Esc/Ctrl-Q without the trading +/// demo grabbing it. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let s = &mut state.0; + hotkeys(ui, s); + if let Some(ord) = s.pending.take() { + submit(s, ord.side, ord.price, ord.amount, ord.is_limit); + } + tick(s); + + let _ = ui.container().grow(1).gap(0).col(|ui| { + // header + header(ui, s); + // main 3-col + let _ = ui.container().grow(1).gap(0).row(|ui| { + // LEFT: order book + let _ = ui + .bordered(Border::Single) + .title("Order Book") + .w_pct(25) + .col(|ui| { + order_book(ui, s); + }); + // CENTER: chart + trades + let _ = ui.container().w_pct(50).gap(0).col(|ui| { + let old_tf = s.tab_tf.selected; + let _ = ui.tabs(&mut s.tab_tf); + if s.tab_tf.selected != old_tf { + s.candle_interval = tf_interval(s.tab_tf.selected); + regen_candles(s); + } + let tf_label = ["1m", "5m", "15m", "1H", "4H", "1D"][s.tab_tf.selected.min(5)]; + let _ = ui + .bordered(Border::Single) + .title(format!("BTC/USDT {tf_label}")) + .grow(2) + .col(|ui| { + chart(ui, s); }); - // RIGHT: order form + balance - let _ = ui.container().w_pct(25).gap(0).col(|ui| { - let _ = ui - .bordered(Border::Single) - .title("Order") - .grow(1) - .col(|ui| { - order_form(ui, &mut s); - }); - let _ = ui.bordered(Border::Single).title("Balance").h(6).col(|ui| { - balance(ui, &s); - }); + let _ = ui + .bordered(Border::Single) + .title("Recent Trades") + .grow(1) + .col(|ui| { + trades(ui, s); }); - }); - // bottom + }); + // RIGHT: order form + balance + let _ = ui.container().w_pct(25).gap(0).col(|ui| { let _ = ui .bordered(Border::Single) - .title("Orders & Positions") - .h(10) + .title("Order") + .grow(1) .col(|ui| { - bottom(ui, &mut s); + order_form(ui, s); }); - // status - status_bar(ui, &s); + let _ = ui.bordered(Border::Single).title("Balance").h(6).col(|ui| { + balance(ui, s); + }); }); + }); + // bottom + let _ = ui + .bordered(Border::Single) + .title("Orders & Positions") + .h(10) + .col(|ui| { + bottom(ui, s); + }); + // status + status_bar(ui, s); + }); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + slt::run_with( + RunConfig::default().mouse(true).theme(Theme::dark()), + move |ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + render(ui, &mut state); }, ) } @@ -349,12 +393,9 @@ impl St { // ── hotkeys ───────────────────────────────────────────────────── fn hotkeys(ui: &mut Context, s: &mut St) { - if ui.key('q') { - ui.quit(); - } - if ui.key_code(KeyCode::Esc) { - ui.quit(); - } + // Quit handling is owned by the caller (standalone `main` or the + // showcase tour) so embedded use can let the host swallow Esc / + // Ctrl-Q without the trading demo grabbing them. if ui.key('1') { s.tab_bottom.selected = 0; } diff --git a/examples/demo_website.rs b/examples/demo_website.rs index 091d8bb..de18f09 100644 --- a/examples/demo_website.rs +++ b/examples/demo_website.rs @@ -1,5 +1,7 @@ //! demo_website — showcase example. //! +//! Archetype: **Standard** (full-canvas, no overlay, no scrollback). +//! //! Refactored in v0.19.0 to demonstrate: //! - `ui.provide` / `ui.use_context::()` for app-wide state injection //! - `.with_if(cond, modifier)` for fluent conditional styling @@ -7,6 +9,12 @@ //! Mutation-heavy sections still receive explicit `&mut` params for clarity //! (hybrid approach): context for reads (`theme`, `tick`), explicit params //! for writes (toasts, form state, navigation intents, modal flags). +//! +//! §2 (Demo Guide): exposes `pub fn render(ui, &mut DemoState)` so a +//! composing demo (e.g. `examples/showcase_tour.rs`) can preserve nav +//! tab, scroll position, theme cursor, email input, blog view, toasts, +//! subscribe flag, modal state, selected plan, and contact form across +//! tab switches. The standalone `main()` is a thin wrapper. use slt::{ Border, ButtonVariant, Color, Context, FormField, FormState, KeyCode, Padding, ScrollState, @@ -27,150 +35,206 @@ struct AppState { tick: u64, } -fn main() -> std::io::Result<()> { - let mut nav = TabsState::new(vec!["Home", "Docs", "Blog", "Pricing", "Contact"]); - let mut scroll = ScrollState::new(); - let themes: [fn() -> Theme; 10] = [ - Theme::dark, - Theme::light, - Theme::dracula, - Theme::catppuccin, - Theme::nord, - Theme::solarized_dark, - Theme::solarized_light, - Theme::tokyo_night, - Theme::gruvbox_dark, - Theme::one_dark, - ]; - let theme_names = [ - "Dark", - "Light", - "Dracula", - "Catppuccin", - "Nord", - "Solarized Dark", - "Solarized Light", - "Tokyo Night", - "Gruvbox", - "One Dark", - ]; - let mut theme_idx: usize = 0; - let mut email = slt::TextInputState::with_placeholder("you@example.com"); - let mut blog_view: Option = None; - let mut toasts = ToastState::new(); - let mut subscribed = false; - let mut nav_target: Option = None; - let mut show_modal = false; - let mut selected_plan = String::new(); - let mut contact_form = FormState::new() - .field(FormField::new("Name").placeholder("Jane Doe")) - .field(FormField::new("Email").placeholder("jane@example.com")) - .field(FormField::new("Message").placeholder("How can we help?")); - - slt::run_with(slt::RunConfig::default().mouse(true), |ui: &mut Context| { - 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_code(KeyCode::Esc) { - blog_view = None; - } - for (i, ch) in ['1', '2', '3', '4', '5'].iter().enumerate() { - if ui.key(*ch) { - nav_target = Some(i); - } +const THEMES: [fn() -> Theme; 10] = [ + Theme::dark, + Theme::light, + Theme::dracula, + Theme::catppuccin, + Theme::nord, + Theme::solarized_dark, + Theme::solarized_light, + Theme::tokyo_night, + Theme::gruvbox_dark, + Theme::one_dark, +]; + +const THEME_NAMES: [&str; 10] = [ + "Dark", + "Light", + "Dracula", + "Catppuccin", + "Nord", + "Solarized Dark", + "Solarized Light", + "Tokyo Night", + "Gruvbox", + "One Dark", +]; + +/// Persistent state for the website demo: every cross-frame value the +/// pages mutate. +pub struct DemoState { + pub nav: TabsState, + pub scroll: ScrollState, + pub theme_idx: usize, + pub email: slt::TextInputState, + pub blog_view: Option, + pub toasts: ToastState, + pub subscribed: bool, + pub nav_target: Option, + pub show_modal: bool, + pub selected_plan: String, + pub contact_form: FormState, +} + +impl DemoState { + pub fn new() -> Self { + Self { + nav: TabsState::new(vec!["Home", "Docs", "Blog", "Pricing", "Contact"]), + scroll: ScrollState::new(), + theme_idx: 0, + email: slt::TextInputState::with_placeholder("you@example.com"), + blog_view: None, + toasts: ToastState::new(), + subscribed: false, + nav_target: None, + show_modal: false, + selected_plan: String::new(), + contact_form: FormState::new() + .field(FormField::new("Name").placeholder("Jane Doe")) + .field(FormField::new("Email").placeholder("jane@example.com")) + .field(FormField::new("Message").placeholder("How can we help?")), } - ui.set_theme(themes[theme_idx]()); + } +} - if let Some(target) = nav_target.take() { - nav.selected = target; - scroll = ScrollState::new(); +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} + +/// Render one frame of the website demo. Caller owns `DemoState` so +/// nav tab, scroll, theme cursor, modal flags, and form values persist +/// across frames. +/// +/// Quit handling is intentionally NOT performed here — callers (the +/// standalone `main` and the showcase tour) own the quit triple. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let tick = ui.tick(); + + if ui.key_mod('t', slt::KeyModifiers::CONTROL) { + state.theme_idx = (state.theme_idx + 1) % THEMES.len(); + state + .toasts + .info(format!("Theme: {}", THEME_NAMES[state.theme_idx]), tick); + } + if ui.key_code(KeyCode::Esc) { + state.blog_view = None; + } + for (i, ch) in ['1', '2', '3', '4', '5'].iter().enumerate() { + if ui.key(*ch) { + state.nav_target = Some(i); } + } + ui.set_theme(THEMES[state.theme_idx]()); + + if let Some(target) = state.nav_target.take() { + state.nav.selected = target; + state.scroll = ScrollState::new(); + } - // Inject AppState for the rest of the frame. Every render_* helper - // below reads `theme`/`tick` via `ui.use_context::()`. - let app = AppState { - theme: *ui.theme(), - tick, - }; - ui.provide(app, |ui| { - let theme = app.theme; + // Inject AppState for the rest of the frame. Every render_* helper + // below reads `theme`/`tick` via `ui.use_context::()`. + let app = AppState { + theme: *ui.theme(), + tick, + }; + ui.provide(app, |ui| { + let theme = app.theme; - let _ = ui.container().grow(1).col(|ui| { - // ── navbar ── + let _ = ui.container().grow(1).col(|ui| { + // ── navbar ── + let _ = ui + .container() + .bg(theme.surface) + .padding(Padding::xy(2, 0)) + .col(|ui| { + let _ = ui.row(|ui| { + ui.text("SLT").bold().fg(theme.primary); + ui.text(" ").fg(theme.text_dim); + ui.spacer(); + let _ = ui.tabs(&mut state.nav); + ui.styled( + format!(" {} ", THEME_NAMES[state.theme_idx]), + Style::new().fg(theme.text).bg(theme.surface_hover), + ); + }); + }); + + let selected = state.nav.selected; + let _ = ui.scrollable(&mut state.scroll).grow(1).col(|ui| { + match selected { + 0 => render_home( + ui, + &mut state.email, + &mut state.nav_target, + &mut state.toasts, + &mut state.subscribed, + ), + 1 => render_docs(ui), + 2 => render_blog(ui, &mut state.blog_view), + 3 => render_pricing( + ui, + &mut state.toasts, + &mut state.show_modal, + &mut state.selected_plan, + ), + _ => render_contact( + ui, + &mut state.nav_target, + &mut state.contact_form, + &mut state.toasts, + ), + } + + // ── footer ── let _ = ui .container() .bg(theme.surface) - .padding(Padding::xy(2, 0)) + .padding(Padding::xy(2, 1)) .col(|ui| { let _ = ui.row(|ui| { ui.text("SLT").bold().fg(theme.primary); - ui.text(" ").fg(theme.text_dim); + ui.text("Framework").fg(theme.surface_text); ui.spacer(); - let _ = ui.tabs(&mut nav); - ui.styled( - format!(" {} ", theme_names[theme_idx]), - Style::new().fg(theme.text).bg(theme.surface_hover), - ); + ui.text("MIT License").fg(theme.surface_text); }); - }); - - let selected = nav.selected; - let _ = ui.scrollable(&mut scroll).grow(1).col(|ui| { - match selected { - 0 => render_home( - ui, - &mut email, - &mut nav_target, - &mut toasts, - &mut subscribed, - ), - 1 => render_docs(ui), - 2 => render_blog(ui, &mut blog_view), - 3 => render_pricing(ui, &mut toasts, &mut show_modal, &mut selected_plan), - _ => render_contact(ui, &mut nav_target, &mut contact_form, &mut toasts), - } - - // ── footer ── - let _ = ui - .container() - .bg(theme.surface) - .padding(Padding::xy(2, 1)) - .col(|ui| { - let _ = ui.row(|ui| { - ui.text("SLT").bold().fg(theme.primary); - ui.text("Framework").fg(theme.surface_text); - ui.spacer(); - ui.text("MIT License").fg(theme.surface_text); - }); - ui.text(""); - let _ = ui.row(|ui| { - ui.link("GitHub", "https://github.com/subinium/SuperLightTUI"); - ui.link("Docs", "https://docs.rs/superlighttui"); - ui.link("Discord", "https://discord.gg/slt"); - ui.spacer(); - ui.text("v0.5.0").fg(theme.surface_text); - }); + ui.text(""); + let _ = ui.row(|ui| { + ui.link("GitHub", "https://github.com/subinium/SuperLightTUI"); + ui.link("Docs", "https://docs.rs/superlighttui"); + ui.link("Discord", "https://discord.gg/slt"); + ui.spacer(); + ui.text("v0.5.0").fg(theme.surface_text); }); - }); + }); + }); - ui.toast(&mut toasts); + ui.toast(&mut state.toasts); - let _ = ui.help(&[ - ("Ctrl+Q", "quit"), - ("Ctrl+T", "theme"), - ("1-5", "tabs"), - ("Esc", "back"), - ("Tab", "focus"), - ]); - }); + let _ = ui.help(&[ + ("Ctrl+Q", "quit"), + ("Ctrl+T", "theme"), + ("1-5", "tabs"), + ("Esc", "back"), + ("Tab", "focus"), + ]); }); - }) + }); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + slt::run_with( + slt::RunConfig::default().mouse(true), + move |ui: &mut Context| { + if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + render(ui, &mut state); + }, + ) } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/examples/hello.rs b/examples/hello.rs index 3dcbd04..9c3d7e4 100644 --- a/examples/hello.rs +++ b/examples/hello.rs @@ -2,7 +2,10 @@ use slt::{Border, Color, Context}; fn main() -> std::io::Result<()> { slt::run(|ui: &mut Context| { - if ui.key_mod('q', slt::KeyModifiers::CONTROL) || ui.key_code(slt::KeyCode::Esc) { + if ui.key('q') + || ui.key_mod('q', slt::KeyModifiers::CONTROL) + || ui.key_code(slt::KeyCode::Esc) + { ui.quit(); } diff --git a/examples/perf_regression.rs b/examples/perf_regression.rs index 70b130b..ae11de9 100644 --- a/examples/perf_regression.rs +++ b/examples/perf_regression.rs @@ -24,7 +24,7 @@ fn main() { let start = Instant::now(); for frame in 0..200 { - input.cursor = (frame as usize) % input.value.chars().count().max(1); + input.cursor = frame % input.value.chars().count().max(1); textarea.scroll_offset = frame % 20; tb.render(|ui| { let _ = ui diff --git a/examples/showcase_tour.rs b/examples/showcase_tour.rs new file mode 100644 index 0000000..b7fe7dd --- /dev/null +++ b/examples/showcase_tour.rs @@ -0,0 +1,260 @@ +//! Showcase Tour — seven domain `demo_*` examples integrated into a +//! single Tabs-driven tour. Each tab embeds the corresponding standalone +//! demo's `pub fn render(...)` so what you see is exactly what the +//! standalone binary renders. +//! +//! Run: `cargo run --example showcase_tour` +//! +//! Keys: +//! Left / Right - switch tab (when the tabs bar is focused; Tab to focus) +//! Tab / Shift-Tab - cycle focus (tabs bar -> demo) +//! q / Esc / Ctrl-Q - quit (Esc only when no demo's modal is open; +//! see notes below) +//! +//! Tabs: +//! 1. Intro - overview + navigation help (description-only) +//! 2. Dashboard - system dashboard layout +//! 3. Design - typography / colors / spacing showcase +//! 4. Infoviz - chart / heatmap / treemap / canvas patterns +//! 5. Trading - finance/trading dashboard mockup +//! 6. Spreadsheet - editable cell grid +//! 7. Table - searchable + sortable data table +//! 8. Website - website-style layout with multiple sub-pages +//! +//! All embedded demos are **Standard** archetype (full-canvas, no +//! overlay, no scrollback) so per §5 C1 they coexist cleanly in the +//! tabbed shell. The trading and infoviz demos own private modals +//! (none) and tabs (own `TabsState`); their internal tabs receive +//! Left/Right *after* the tour's outer tabs widget consumes them when +//! focused, so users can focus the inner widgets to switch. + +use slt::widgets::{ScrollState, TabsState}; +use slt::{Border, Color, Context, KeyCode, KeyModifiers, RunConfig}; + +// Each `#[path = ...] mod ...;` re-includes a single-feature demo so the +// tour can call its `pub fn render(...)` directly. The demos' own `fn +// main()` and helpers are unused in this build, hence the blanket +// `#[allow(dead_code)]` on every include. +#[allow(dead_code)] +#[path = "demo_dashboard.rs"] +mod dashboard; +#[allow(dead_code)] +#[path = "demo_design_system.rs"] +mod design_system; +#[allow(dead_code)] +#[path = "demo_infoviz.rs"] +mod infoviz; +#[allow(dead_code)] +#[path = "demo_spreadsheet.rs"] +mod spreadsheet; +#[allow(dead_code)] +#[path = "demo_table.rs"] +mod table; +#[allow(dead_code)] +#[path = "demo_trading.rs"] +mod trading; +#[allow(dead_code)] +#[path = "demo_website.rs"] +mod website; + +/// Aggregated state for every embedded demo. Each field is the +/// `DemoState` from the corresponding domain demo. +struct TourState { + tabs: TabsState, + /// Scroll offset for the active tab body. The Design tab in particular + /// stacks typography / colours / spacing samples that overflow the + /// viewport; a tour-level scrollable keeps the lower content reachable. + tab_scroll: ScrollState, + dashboard: dashboard::DemoState, + design_system: design_system::DemoState, + infoviz: infoviz::DemoState, + trading: trading::DemoState, + spreadsheet: spreadsheet::DemoState, + table: table::DemoState, + website: website::DemoState, +} + +impl Default for TourState { + fn default() -> Self { + Self { + tabs: TabsState::new(vec![ + "Intro", + "Dashboard", + "Design", + "Infoviz", + "Trading", + "Spreadsheet", + "Table", + "Website", + ]), + tab_scroll: ScrollState::new(), + dashboard: dashboard::DemoState::new(), + design_system: design_system::DemoState::new(), + infoviz: infoviz::DemoState::new(), + trading: trading::DemoState::new(), + spreadsheet: spreadsheet::DemoState::new(), + table: table::DemoState::new(), + website: website::DemoState::new(), + } + } +} + +fn main() -> std::io::Result<()> { + let mut state = TourState::default(); + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + // Tour-level quit: Ctrl-Q always at the top of the frame. We do + // NOT consume Esc here so embedded demos can use it for their + // own escape paths (e.g. `demo_website` clears `blog_view` on + // Esc, `demo_spreadsheet` exits edit mode on Esc). + if ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("Showcase Tour: domain examples") + .p(pad) + .grow(1) + .col(|ui| { + let _ = ui.tabs(&mut state.tabs); + ui.separator(); + + // Wrap the tab body in a vertical scrollable so tabs whose + // demo content overflows the viewport (Design's stacked + // typography / colour / spacing showcase, in particular) + // stay fully reachable. Mouse wheel outside any inner + // scroll region scrolls the whole tab body; when the + // content fits the viewport this is a no-op. + let _ = ui.scrollable(&mut state.tab_scroll).grow(1).col(|ui| { + match state.tabs.selected { + 0 => render_intro(ui), + 1 => render_dashboard(ui, &mut state), + 2 => render_design(ui, &mut state), + 3 => render_infoviz(ui, &mut state), + 4 => render_trading(ui, &mut state), + 5 => render_spreadsheet(ui, &mut state), + 6 => render_table(ui, &mut state), + 7 => render_website(ui, &mut state), + _ => {} + } + }); + }); + + // 'q' and Esc are checked AFTER demos render so a focused + // text_input (Design's input, Table's filter, Trading's order + // form, Spreadsheet's editor, Website's email/contact form) + // consumes them as text first. + if ui.key('q') || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + }) +} + +/// Tab 1: Intro. Pure overview - no embedded demo. +fn render_intro(ui: &mut Context) { + let _ = ui.col(|ui| { + let pad = ui.spacing().xs(); + ui.text("Welcome to the showcase tour.").bold(); + ui.text(""); + ui.text("Each tab embeds a domain example from examples/demo_*.rs") + .dim(); + ui.text("without changes - what you see in a tab is exactly the").dim(); + ui.text("standalone demo's render path.").dim(); + ui.text(""); + let _ = ui + .bordered(Border::Single) + .title("Domain examples at a glance") + .p(pad) + .col(|ui| { + row_pair(ui, "Dashboard", "metric cards, processes, log stream, toasts"); + row_pair( + ui, + "Design", + "typography, ThemeColor, Spacing, ContainerStyle extends", + ); + row_pair( + ui, + "Infoviz", + "line / scatter / bars / heatmap / candlestick / treemap / canvas", + ); + row_pair( + ui, + "Trading", + "BTC/USDT order book, candles, order form, positions", + ); + row_pair( + ui, + "Spreadsheet", + "editable cell grid with cursor, formula bar, edit mode", + ); + row_pair( + ui, + "Table", + "searchable + sortable data table with footer status", + ); + row_pair( + ui, + "Website", + "multi-page layout (Home / Docs / Blog / Pricing / Contact)", + ); + }); + ui.text(""); + ui.text( + "Navigation: Left/Right arrows switch tabs (Tab to focus the bar). q / Ctrl-Q quits.", + ) + .fg(Color::Cyan); + ui.text("Esc quits everywhere except inside Spreadsheet edit mode and Website blog-view, where it dismisses.") + .dim(); + }); +} + +/// One label/description row for the intro example list. +fn row_pair(ui: &mut Context, label: &str, desc: &str) { + let _ = ui.row_gap(1, |ui| { + ui.text(format!("{label:<12}")).bold().fg(Color::Cyan); + ui.text(desc).dim(); + }); +} + +/// Tab 2: Dashboard. Spinner phase, log scroll, table cursor, theme +/// toggle, and toast queue all live in TourState. +fn render_dashboard(ui: &mut Context, state: &mut TourState) { + dashboard::render_with_state(ui, &mut state.dashboard); +} + +/// Tab 3: Design system. Theme cursor, theme-browser toggle, input +/// value, list cursor, and counter persist across tab switches. +fn render_design(ui: &mut Context, state: &mut TourState) { + design_system::render(ui, &mut state.design_system); +} + +/// Tab 4: Infoviz. Selected chart tab persists across tab switches. +fn render_infoviz(ui: &mut Context, state: &mut TourState) { + infoviz::render_with_state(ui, &mut state.infoviz); +} + +/// Tab 5: Trading. The random-walk price feed, candle history, order +/// book, recent trades, and order-form inputs all keep ticking across +/// tab switches because the `St` wrapped in `DemoState` is owned here. +fn render_trading(ui: &mut Context, state: &mut TourState) { + trading::render(ui, &mut state.trading); +} + +/// Tab 6: Spreadsheet. Cursor position, edit-mode flag, typed edit +/// value, and scroll offset survive tab switches. +fn render_spreadsheet(ui: &mut Context, state: &mut TourState) { + spreadsheet::render(ui, &mut state.spreadsheet); +} + +/// Tab 7: Table. Filter text, sort column, table cursor persist. +fn render_table(ui: &mut Context, state: &mut TourState) { + table::render(ui, &mut state.table); +} + +/// Tab 8: Website. Sub-page nav, scroll, theme cursor, blog view, +/// modal flags, and contact form all persist. +fn render_website(ui: &mut Context, state: &mut TourState) { + website::render(ui, &mut state.website); +} diff --git a/examples/system_tour.rs b/examples/system_tour.rs new file mode 100644 index 0000000..6e9106b --- /dev/null +++ b/examples/system_tour.rs @@ -0,0 +1,310 @@ +//! System Tour — runtime modes and system-archetype demos grouped by tab. +//! +//! Run: `cargo run --example system_tour --features async` +//! +//! Keys: +//! Left / Right — switch tab (Tab to focus the tabs bar) +//! Tab / Shift-Tab — cycle focus +//! q / Esc / Ctrl-Q — quit +//! +//! Tabs: +//! 1. Intro — overview + navigation help +//! 2. Async — description-only (`run_async` + tokio runtime) +//! 3. Error Boundary — live: `error_boundary_with` recovers from panics +//! 4. Inline (info) — description-only (`run_inline` / InlineTerminal) +//! 5. Overlay Anchor — live: `overlay_at` / `overlay_at_offset` +//! +//! Why some tabs are description-only: +//! - **Async**: `async_demo` uses `slt::run_async` (a separate entry point +//! with a `Vec` parameter) and a `#[tokio::main]` producer +//! task. It cannot compose into a sync `slt::run_with` tour without +//! reframing the whole binary as async, so this tab is a code-snippet +//! description per DEMO_GUIDE §5 C2. +//! - **Inline (info)**: `inline.rs` uses `slt::run_inline`, which renders +//! below the cursor without an alternate screen. The tour itself runs +//! in alternate-screen mode (`slt::run_with`); the two terminal modes +//! are mutually exclusive within one binary, so this tab is a +//! description page (DEMO_GUIDE §9 macOS quirks + §5 C2). + +use slt::widgets::{ScrollState, TabsState}; +use slt::{Border, ButtonVariant, Color, Context, KeyCode, KeyModifiers, RunConfig}; + +// Each `#[path = ...] mod ...;` re-includes a single-feature demo so the +// tour can call its `pub fn render(...)` directly. Demos whose state is +// owned in their own `fn main()` are not re-included here — their +// description-only tabs are rendered inline in this file. +#[allow(dead_code)] +#[path = "demo_overlay_anchor.rs"] +mod overlay_anchor; + +/// Aggregated state for every embedded demo. Each field is the persistent +/// state for one tab; constructing them here (not in the render closure) +/// keeps the state alive across frames per DEMO_GUIDE §5 C3. +struct TourState { + tabs: TabsState, + /// Scroll offset for the active tab body. Description-only tabs + /// (Async / Inline) include code blocks that overflow short + /// terminals; the wrapper keeps the tail reachable via mouse wheel. + tab_scroll: ScrollState, + error_boundary: ErrorBoundaryState, +} + +/// State for the live Error Boundary tab. Mirrors the locals from +/// `examples/error_boundary_demo.rs::main` so clicks on the panic button +/// persist their effect into `panic_count`. +#[derive(Default)] +struct ErrorBoundaryState { + panic_count: u32, +} + +impl Default for TourState { + fn default() -> Self { + Self { + tabs: TabsState::new(vec![ + "Intro", + "Async", + "Error Boundary", + "Inline (info)", + "Overlay Anchor", + ]), + tab_scroll: ScrollState::new(), + error_boundary: ErrorBoundaryState::default(), + } + } +} + +fn main() -> std::io::Result<()> { + let mut state = TourState::default(); + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + // Tour-level quit: Ctrl-Q only at the top of the frame. We + // intentionally do NOT consume Esc here so embedded demos can + // route their own Esc handling. + if ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("System Tour: runtime modes") + .p(pad) + .grow(1) + .col(|ui| { + let _ = ui.tabs(&mut state.tabs); + ui.separator(); + + // Wrap the tab body in a vertical scrollable so + // description-only tabs (Async / Inline code blocks) + // and overflowing live tabs stay reachable on small + // terminals. Mouse wheel outside any inner scroll + // region scrolls the whole tab; no-op when content + // fits. + let _ = ui.scrollable(&mut state.tab_scroll).grow(1).col(|ui| { + match state.tabs.selected { + 0 => render_intro(ui), + 1 => render_async(ui), + 2 => render_error_boundary(ui, &mut state.error_boundary), + 3 => render_inline(ui), + 4 => overlay_anchor::render(ui), + _ => {} + } + }); + }); + + // 'q' / 'Esc' handled AFTER demos render so embedded interactive + // tabs (e.g. Error Boundary) get first crack at the keystrokes. + if ui.key('q') || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + }) +} + +/// Tab 1: Intro. Pure overview — no embedded demo. +fn render_intro(ui: &mut Context) { + let _ = ui.col(|ui| { + let pad = ui.spacing().xs(); + ui.text("System Tour — runtime modes and system-archetype demos.") + .bold(); + ui.text(""); + ui.text("Two of the four source demos use a runtime mode that") + .dim(); + ui.text("does not compose into a fullscreen alternate-screen tour.") + .dim(); + ui.text("Those tabs render a code-snippet description and a") + .dim(); + ui.text("`cargo run --example ` pointer for the standalone form.") + .dim(); + ui.text(""); + let _ = ui + .bordered(Border::Single) + .title("Tabs at a glance") + .p(pad) + .col(|ui| { + row_pair(ui, "Async", "run_async + tokio task (description-only)"); + row_pair( + ui, + "Boundary", + "error_boundary_with — panic recovery (live)", + ); + row_pair( + ui, + "Inline", + "run_inline / InlineTerminal (description-only)", + ); + row_pair(ui, "Overlay", "overlay_at + overlay_at_offset (live)"); + }); + ui.text(""); + ui.text("Navigation: Left/Right switch tabs (Tab focuses the tabs bar).") + .fg(Color::Cyan); + ui.text("q / Esc / Ctrl-Q quits.").fg(Color::Cyan); + }); +} + +/// One label/description row for the intro feature list. +fn row_pair(ui: &mut Context, label: &str, desc: &str) { + let _ = ui.row_gap(1, |ui| { + ui.text(format!("{label:<9}")).bold().fg(Color::Cyan); + ui.text(desc).dim(); + }); +} + +/// Tab 2: Async. Description-only page for `async_demo`. +/// +/// The standalone demo uses `slt::run_async` (a distinct entry point that +/// receives `&mut Vec` of channel messages) and a `#[tokio::main]` +/// producer task that pushes status updates every 2 seconds. Composing it +/// into a sync `slt::run_with` tour would require rewriting the tour as +/// async and forwarding the producer's channel through the tour state — +/// out of scope. Run the standalone binary to see the actual async flow. +fn render_async(ui: &mut Context) { + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("async_demo: run_async + tokio task") + .p(pad) + .grow(1) + .col(|ui| { + ui.text("slt::run_async spawns the render loop on the current tokio") + .dim(); + ui.text("runtime and returns an `mpsc::Sender` so background tasks") + .dim(); + ui.text("can push messages into the render closure without polling.") + .dim(); + ui.text(""); + let _ = ui + .bordered(Border::Single) + .title("typical usage") + .p(pad) + .col(|ui| { + let _ = ui.code_block_lang( + "#[tokio::main(flavor = \"current_thread\")]\nasync fn main() -> std::io::Result<()> {\n let tx = slt::run_async(move |ui, messages: &mut Vec| {\n for m in messages.drain(..) { /* ... */ }\n ui.text(\"...\");\n })?;\n tokio::spawn(async move {\n loop { tx.send(\"tick\".into()).await.ok(); }\n }).await.ok();\n Ok(())\n}", + "rust", + ); + }); + ui.text(""); + ui.text("This page is description-only because the tour binary uses") + .fg(Color::Yellow); + ui.text("sync `slt::run_with`. Embedding `run_async` would require") + .fg(Color::Yellow); + ui.text("an async tour entry point and forwarding the channel.") + .fg(Color::Yellow); + ui.text(""); + ui.text("To see the actual async flow, run the standalone demo:") + .dim(); + ui.text(" cargo run --example async_demo --features async") + .fg(Color::Cyan); + }); +} + +/// Tab 3: Error Boundary. Live render — `error_boundary_with` already +/// wraps its child in `std::panic::catch_unwind`, so a panic from the +/// inner closure is recovered without affecting the surrounding tour. +/// +/// Mirrors `examples/error_boundary_demo.rs::main` body verbatim, but +/// pulls `panic_count` out of a local and into tour-owned state per +/// DEMO_GUIDE §5 C3 (otherwise the count would reset every frame). +fn render_error_boundary(ui: &mut Context, state: &mut ErrorBoundaryState) { + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("error_boundary_with: panic recovery in widgets") + .p(pad) + .gap(1) + .grow(1) + .col(|ui| { + ui.text("Trigger panic inside error boundary.").bold(); + ui.text("Press button or key 'p'. Esc/q/Ctrl-Q to quit the tour.") + .dim(); + ui.text(format!("Recovered panics: {}", state.panic_count)) + .fg(Color::Cyan); + + let trigger_panic = ui + .button_with("Panic in boundary", ButtonVariant::Danger) + .clicked + || ui.key('p'); + + ui.error_boundary_with( + |ui| { + if trigger_panic { + panic!("demo panic from error boundary"); + } + ui.text("No panic this frame").fg(Color::Green); + }, + |ui, _msg| { + state.panic_count = state.panic_count.saturating_add(1); + ui.text("Recovered from panic").bold().fg(Color::Yellow); + }, + ); + }); +} + +/// Tab 4: Inline. Description-only page for `inline.rs`. +/// +/// The standalone demo uses `slt::run_inline(height, ...)` which renders +/// in InlineTerminal mode below the cursor — no alternate screen. The +/// tour binary already entered alternate-screen mode via `slt::run_with`, +/// so switching to inline mode mid-frame would corrupt the terminal +/// state. Description-only per DEMO_GUIDE §9 macOS quirks + §5 C2. +fn render_inline(ui: &mut Context) { + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("inline: run_inline / InlineTerminal mode") + .p(pad) + .grow(1) + .col(|ui| { + ui.text("slt::run_inline(height, render) renders a fixed-height TUI") + .dim(); + ui.text("below the user's cursor without entering the alternate") + .dim(); + ui.text("screen. The shell scrollback above the inline buffer is") + .dim(); + ui.text("preserved; the inline region updates in place each frame.") + .dim(); + ui.text(""); + let _ = ui + .bordered(Border::Single) + .title("typical usage") + .p(pad) + .col(|ui| { + let _ = ui.code_block_lang( + "fn main() -> std::io::Result<()> {\n let mut count: i32 = 0;\n slt::run_inline(4, |ui| {\n if ui.key('k') { count += 1; }\n if ui.key('j') { count -= 1; }\n ui.text(format!(\"Inline count: {count}\"));\n })\n}", + "rust", + ); + }); + ui.text(""); + ui.text("This page is description-only because run_inline uses a") + .fg(Color::Yellow); + ui.text("different terminal mode (InlineTerminal, no alternate") + .fg(Color::Yellow); + ui.text("screen) than the tour's `slt::run_with`. The two modes") + .fg(Color::Yellow); + ui.text("cannot coexist within one binary frame.") + .fg(Color::Yellow); + ui.text(""); + ui.text("To see the inline render mode, run the standalone demo:") + .dim(); + ui.text(" cargo run --example inline").fg(Color::Cyan); + }); +} diff --git a/examples/text_tour.rs b/examples/text_tour.rs new file mode 100644 index 0000000..2ae9fd8 --- /dev/null +++ b/examples/text_tour.rs @@ -0,0 +1,188 @@ +//! Text Tour — every text / IME / CLI demo, switched via SLT's own `Tabs` +//! widget. Each tab dispatches to the matching `pub fn render(...)` from a +//! single-feature demo so behaviour matches the standalone binary 1:1. +//! +//! Run: `cargo run --example text_tour` +//! +//! Keys (tour-level): +//! Left / Right — switch tab (when the tabs bar is focused; Tab to focus) +//! Tab / Shift-Tab — cycle focus (tabs bar -> active demo) +//! q / Esc / Ctrl-Q — quit +//! +//! Tabs: +//! 1. Intro — overview + navigation help +//! 2. CJK — Korean / Chinese / Japanese title and content rendering +//! 3. IME — composition input across two text inputs and a textarea +//! 4. Pretext — mouse-reactive text reflow (raw `draw` API) +//! 5. CLI — cargo-style package manager UI +//! +//! Note on §7 of `docs/DEMO_GUIDE.md` (BMP ASCII titles): the audit V7 +//! rule only scans `examples/v020_*.rs`, not this file, so the embedded +//! demos may legitimately use wide-character titles to exercise CJK +//! rendering. The tour's *own* wrapper title stays BMP ASCII so the outer +//! border alignment is guaranteed regardless of terminal width-reporting +//! quirks. Do not strip wide chars from the embedded demos — the wide +//! chars are the point. + +use slt::widgets::ScrollState; +use slt::widgets::TabsState; +use slt::widgets::TextInputState; +use slt::{Border, Color, Context, KeyModifiers, RunConfig}; + +#[allow(dead_code)] +#[path = "demo_cjk.rs"] +mod demo_cjk; +#[allow(dead_code)] +#[path = "demo_cli.rs"] +mod demo_cli; +#[allow(dead_code)] +#[path = "demo_ime.rs"] +mod demo_ime; +#[allow(dead_code)] +#[path = "demo_pretext.rs"] +mod demo_pretext; + +/// Aggregated state for every embedded demo. Each field is the +/// `DemoState` from the corresponding feature demo (or, for `demo_cjk`, +/// the two `TextInputState`s its `render_frame` accepts). +struct TourState { + tabs: TabsState, + /// Scroll offset for the active tab body. Mouse-wheel events outside + /// any inner scrollable scroll the whole tab so wide-character / + /// CLI scrollback content stays reachable on small terminals. + tab_scroll: ScrollState, + cjk_name: TextInputState, + cjk_tag: TextInputState, + ime: demo_ime::DemoState, + pretext: demo_pretext::DemoState, + cli: demo_cli::DemoState, +} + +impl Default for TourState { + fn default() -> Self { + Self { + tabs: TabsState::new(vec!["Intro", "CJK", "IME", "Pretext", "CLI"]), + tab_scroll: ScrollState::new(), + cjk_name: TextInputState::with_placeholder("name (CJK ok)"), + cjk_tag: TextInputState::with_placeholder("tag"), + ime: Default::default(), + pretext: Default::default(), + cli: Default::default(), + } + } +} + +fn main() -> std::io::Result<()> { + let mut state = TourState::default(); + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + // Tour-level quit: Ctrl-Q always, Esc ONLY at the top of the + // frame. We intentionally do NOT consume `q` here — the IME and + // CLI tabs both host text inputs that need to receive `q` as + // composition / search input, so plain `q` is checked at the + // bottom of the frame after the active demo has had a chance to + // claim it. + if ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("Text Tour: i18n and text input") + .p(pad) + .grow(1) + .col(|ui| { + let _ = ui.tabs(&mut state.tabs); + ui.separator(); + + // Wrap the tab body in a vertical scrollable so the + // intro's long help text and any overflowing demo + // content stay reachable. Mouse wheel outside any inner + // scroll region scrolls the whole tab; when the body + // fits the viewport this is a no-op. + let _ = ui.scrollable(&mut state.tab_scroll).grow(1).col(|ui| { + match state.tabs.selected { + 0 => render_intro(ui), + 1 => demo_cjk::render_frame(ui, &mut state.cjk_name, &mut state.cjk_tag), + 2 => demo_ime::render(ui, &mut state.ime), + 3 => demo_pretext::render(ui, &mut state.pretext), + 4 => demo_cli::render(ui, &mut state.cli), + _ => {} + } + }); + }); + + // `q` is checked AFTER demos render so a focused text_input + // (CJK, IME, or CLI tabs) consumes it as text first. `Esc` is + // also deferred — `demo_cli`'s own Esc handler clears its + // install state, and we only want tour-level quit when no demo + // claimed the key. + if ui.key('q') || ui.key_code(slt::KeyCode::Esc) { + ui.quit(); + } + }) +} + +/// Tab 1: Intro. Pure overview — no embedded demo. +fn render_intro(ui: &mut Context) { + let _ = ui.col(|ui| { + let pad = ui.spacing().xs(); + ui.text("Welcome to the Text Tour.").bold(); + ui.text(""); + ui.text("Each tab embeds the corresponding single-feature demo from") + .dim(); + ui.text("examples/demo_*.rs without modification — what you see in") + .dim(); + ui.text("a tab is exactly the standalone demo's render path.") + .dim(); + ui.text(""); + let _ = ui + .bordered(Border::Single) + .title("Tabs at a glance") + .p(pad) + .col(|ui| { + row_pair( + ui, + "CJK", + "Korean / Chinese / Japanese title and content rendering with mouse cards", + ); + row_pair( + ui, + "IME", + "composition input across two text inputs and a textarea, live filter", + ); + row_pair( + ui, + "Pretext", + "raw-draw text reflow around a mouse-tracking caterpillar trail", + ); + row_pair( + ui, + "CLI", + "cargo-style package manager: search, install, scrolling output log", + ); + }); + ui.text(""); + ui.text("Navigation: click a tab, or focus the bar with Tab and use Left/Right.") + .fg(Color::Cyan); + ui.text("Quit: q / Esc / Ctrl-Q (a focused text input claims `q` as input first).") + .fg(Color::Cyan); + ui.text(""); + ui.text( + "Note: the CJK and IME tabs intentionally render wide-character\ + content to test fullwidth glyph handling. The tour's own wrapper\ + title stays BMP ASCII so the outer border alignment is guaranteed.", + ) + .dim() + .wrap(); + }); +} + +/// One label/description row for the intro feature list. +fn row_pair(ui: &mut Context, label: &str, desc: &str) { + let _ = ui.row_gap(1, |ui| { + ui.text(format!("{label:<8}")).bold().fg(Color::Cyan); + ui.text(desc).dim(); + }); +} diff --git a/examples/v020_breadcrumb_response.rs b/examples/v020_breadcrumb_response.rs new file mode 100644 index 0000000..0c03715 --- /dev/null +++ b/examples/v020_breadcrumb_response.rs @@ -0,0 +1,95 @@ +//! v0.20.0 BreadcrumbResponse demo — focusable navigation segments with a +//! compound `Response`. +//! +//! Demonstrates: #213 (`breadcrumb` builder, `BreadcrumbResponse: Deref`, +//! custom separator and link color). +//! +//! Run: `cargo run --example v020_breadcrumb_response` +//! +//! Keys: +//! Tab / Shift-Tab — focus a segment +//! Enter / Space — activate the focused segment (drops trailing crumbs) +//! Mouse click — same as Enter +//! r — reset path back to the full chain +//! q / Esc / Ctrl-Q — quit +//! +//! Layout: +//! ┌── v0.20.0 #213: BreadcrumbResponse ────────┐ +//! │ Home › Projects › SuperLightTUI › v0.20.0 │ +//! │ Current segment: v0.20.0 │ +//! └─────────────────────────────────────────────┘ + +use slt::{Border, Color, Context, KeyCode, KeyModifiers, RunConfig}; + +/// Path rendered by the breadcrumb. The "current" cursor walks back along +/// these segments in response to `clicked_segment`. +const SEGMENTS: &[&str] = &["Home", "Projects", "SuperLightTUI", "v0.20.0"]; + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + + slt::run_with(RunConfig::default().mouse(true), |ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + if ui.key('r') { + state.current = SEGMENTS.len() - 1; + } + render(ui, &mut state); + }) +} + +/// Demo state — the index of the currently selected segment. +pub struct DemoState { + /// Index into [`SEGMENTS`] for the rightmost (active) crumb. Defaults to + /// the last segment so the demo opens fully expanded. + pub current: usize, +} + +impl Default for DemoState { + fn default() -> Self { + Self { + current: SEGMENTS.len() - 1, + } + } +} + +/// Render one frame. Stable signature for snapshot tests. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let sp = ui.spacing(); + + let _ = ui + .bordered(Border::Rounded) + .title("v0.20.0 #213: BreadcrumbResponse") + .p(sp.xs()) + .gap(sp.xs()) + .grow(1) + .col(|ui| { + ui.text("Tab / Shift-Tab focuses a segment; Enter, Space, or click activates it. 'r' resets the path.") + .fg(Color::Cyan); + + let visible: Vec<&str> = SEGMENTS.iter().take(state.current + 1).copied().collect(); + let r = ui + .breadcrumb(&visible) + .separator(" › ") + .color(Color::Cyan) + .show(); + + if let Some(i) = r.clicked_segment { + state.current = i; + } + // BreadcrumbResponse derefs to Response, so .hovered works directly. + if r.hovered { + ui.text("(whole bar hovered)").fg(Color::Yellow); + } + + ui.text(format!( + "Current segment: {} (depth {} / {})", + SEGMENTS[state.current], + state.current + 1, + SEGMENTS.len(), + )) + .dim(); + ui.text("q / Esc / Ctrl-Q quits.").dim(); + }); +} diff --git a/examples/v020_ctrl_c_passthrough.rs b/examples/v020_ctrl_c_passthrough.rs new file mode 100644 index 0000000..a000b7f --- /dev/null +++ b/examples/v020_ctrl_c_passthrough.rs @@ -0,0 +1,185 @@ +//! v0.20.0 Ctrl+C passthrough demo — opt out of SLT's default Ctrl+C +//! interception so the closure can implement its own quit policy. +//! +//! Demonstrates: #238. +//! +//! Run: `cargo run --example v020_ctrl_c_passthrough` +//! +//! ## What this demo shows +//! +//! `RunConfig::default().handle_ctrl_c(false)` flips off SLT's default +//! "exit on Ctrl+C" behavior. With it off, Ctrl+C arrives at the frame +//! closure as a plain `Event::Key { code: Char('c'), modifiers: CONTROL }`, +//! and the closure decides what to do with it (count strikes, prompt to +//! save, defer, ignore — whatever your app needs). +//! +//! The runtime side of that contract is unconditional: with +//! `handle_ctrl_c(false)`, every Ctrl+C the terminal emits *will* be passed +//! through. Whether the terminal *emits* a Ctrl+C in the first place is a +//! separate, terminal-emulator-level concern. +//! +//! ## macOS / Ghostty / iTerm2 gotcha +//! +//! On macOS, most terminals (Ghostty, iTerm2, Terminal.app) bind Ctrl+C to +//! the system Copy command in their default keybindings. Pressing Ctrl+C +//! puts the current selection on the clipboard; the keystroke never reaches +//! the foreground program. That makes a "press Ctrl+C three times" demo +//! literally untestable on a stock macOS install — the keystroke is +//! intercepted before it can flow through SLT's `handle_ctrl_c(false)` +//! path. +//! +//! Two ways to still observe the passthrough behavior on macOS: +//! +//! 1. **Press Ctrl+G** — by convention not bound to any clipboard command, +//! so it reaches the closure as a plain `Char('g')` + CONTROL key event. +//! This demo treats Ctrl+G the same way it treats a real Ctrl+C: as a +//! "the closure got a keypress with the CONTROL modifier" signal. +//! +//! 2. **Click "Send Ctrl+C"** — the demo synthesizes the same state change +//! a real `Ctrl+C` would trigger. The runtime path differs (button click +//! instead of `is_ctrl_c` matching), but the application-visible state +//! transition (strike counter advances) is the one your closure would +//! have run on a real Ctrl+C. +//! +//! On Linux/Windows terminals where Ctrl+C is *not* rebound, real Ctrl+C +//! presses also work; the same handler fires. The point of this demo is +//! the API contract — `handle_ctrl_c(false)` — not any one input source. +//! +//! Keys: +//! Ctrl-C — counted as a strike when the terminal lets it through +//! Ctrl-G — same handler (macOS-friendly alternative) +//! Click button — synthesize a strike +//! q / Esc / Ctrl-Q — quit immediately +//! +//! Layout: +//! ┌────────────── main view ─────────────┐ +//! │ Ctrl+C passthrough demo (issue #238) │ +//! │ Ctrl+C / Ctrl+G observed: N / 3 │ +//! │ [ Send Ctrl+C ] │ +//! │ Press Ctrl+C / Ctrl+G three times… │ +//! └──────────────────────────────────────┘ + +use slt::{Color, Context, KeyCode, KeyModifiers, RunConfig, Style}; + +/// Strike count required before this demo confirms quit. Matches Vim/IPython +/// "interrupt three times to leave" muscle memory. +const QUIT_STRIKES: u32 = 3; + +/// Snapshot fixture strike count. Matches the saved snapshot under +/// `tests/snapshots/v020_lib_demos__v020_ctrl_c_passthrough.snap`. +const SNAPSHOT_COUNT: u32 = 1; + +/// Persistent strike counter for the passthrough demo. Survives across +/// frames so a real Ctrl+C/Ctrl+G keypress (or a button click) advances +/// the counter the same way every time the demo is rendered. +#[derive(Default)] +pub struct DemoState { + pub ctrl_c_count: u32, +} + +/// Shared body. The count is the only varying input — keeping the visible +/// text identical between snapshot and live loop avoids documentation drift. +/// +/// Returns `true` when the embedded button was clicked this frame, so +/// `render` can fold the click into the same strike counter that real +/// Ctrl+C / Ctrl+G presses advance. +fn body(ui: &mut Context, ctrl_c_count: u32) -> bool { + let mut button_clicked = false; + let _ = ui.col(|ui| { + ui.styled( + "Ctrl+C passthrough demo (issue #238)", + Style::new().bold().fg(Color::Cyan), + ); + ui.text(""); + ui.styled( + format!("Ctrl+C / Ctrl+G observed: {ctrl_c_count} / {QUIT_STRIKES}"), + Style::new().bold(), + ); + ui.text(""); + // Banner: macOS users will hit this every time, so put it front and + // center rather than burying it in the demo header. + ui.styled( + "Note: macOS terminals bind Ctrl+C to Copy by default.", + Style::new().fg(Color::Yellow), + ); + ui.styled( + "Use Ctrl+G to test pass-through, or click the button below.", + Style::new().fg(Color::Yellow), + ); + ui.text(""); + if ui.button("Send Ctrl+C").clicked { + button_clicked = true; + } + ui.text(""); + ui.styled( + "(With handle_ctrl_c(false), real Ctrl+C arrives as a normal key event.)", + Style::new().dim(), + ); + ui.styled("Quit: q, Esc, or Ctrl-Q.", Style::new().dim()); + }); + button_clicked +} + +/// Per-frame entry point. Folds Ctrl+C / Ctrl+G keypresses and the +/// "Send Ctrl+C" button click into the same strike counter so embedding +/// surfaces (the v0.20 tour) react to user input the same way the +/// standalone binary does. +/// +/// Caller owns [`DemoState`] so the strike count survives across frames. +/// Reaching `QUIT_STRIKES` does NOT auto-quit here — quit policy is the +/// caller's responsibility (the standalone `main` opts in below; the tour +/// keeps running). +pub fn render(ui: &mut Context, state: &mut DemoState) { + let mut strike = false; + if ui.key_mod('c', KeyModifiers::CONTROL) { + strike = true; + } + if ui.key_mod('g', KeyModifiers::CONTROL) { + strike = true; + } + + // Render the body first; it returns whether the embedded button was + // clicked this frame. + if body(ui, state.ctrl_c_count) { + strike = true; + } + + if strike { + state.ctrl_c_count = state.ctrl_c_count.saturating_add(1); + } +} + +/// One-frame deterministic render entry point used by snapshot tests +/// (`tests/v020_lib_demos.rs`). Pins the strike count at one so the +/// snapshot shows the mid-quit state instead of a fresh-counter zero. +/// +/// NEVER call this from a live loop or from another demo — strikes and +/// clicks are silently dropped because state never persists. Live +/// embeddings should call [`render`] with their own `&mut DemoState`. +pub fn render_snapshot(ui: &mut Context) { + let _ = body(ui, SNAPSHOT_COUNT); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + + // Opt out of the default ctrl-c-quits behaviour so the loop can decide + // when (and after how many strikes) to exit. Mouse on so the + // "Send Ctrl+C" button can be clicked. + let config = RunConfig::default().handle_ctrl_c(false).mouse(true); + + slt::run_with(config, move |ui: &mut Context| { + render(ui, &mut state); + + if state.ctrl_c_count >= QUIT_STRIKES { + ui.quit(); + } + + // Quit — Ctrl-Q is the portable alternative to Ctrl-C on macOS. + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + })?; + + Ok(()) +} diff --git a/examples/v020_dx_shortcuts.rs b/examples/v020_dx_shortcuts.rs new file mode 100644 index 0000000..0a2fe91 --- /dev/null +++ b/examples/v020_dx_shortcuts.rs @@ -0,0 +1,227 @@ +//! v0.20.0 DX shorthand demo — four ergonomic helpers on a single screen. +//! +//! Demonstrates: #209 (Response::on_hover), #210 (animate_bool), +//! #220 (ContainerBuilder::fill), #221 (Rect::center_in). +//! +//! Run: `cargo run --example v020_dx_shortcuts` +//! +//! Keys: +//! Space — toggle the animated side panel (drives animate_bool) +//! ? / h — toggle the centered help overlay (drives Rect::center_in) +//! Hover Save / Open — show the chained tooltip (drives Response::on_hover) +//! q / Esc / Ctrl-Q — quit (Ctrl-C may be bound to copy on macOS) +//! +//! Layout (80x24 minimum): +//! +//! ```text +//! +- v0.20 DX Shorthand Demo --------------------------------------+ +//! | Press Space to toggle the panel, hover Save for a tooltip, | +//! | press ? for the centered help overlay, Ctrl-Q to quit. | +//! | +- Actions ----+ +- Status ---------------------------------+ | +//! | | [Save] | | Shorthand helpers are about reading code | | +//! | | [Open] | | not writing it. fill() == grow(1). | | +//! | | panel_alpha | | | | +//! | +--------------+ +------------------------------------------+ | +//! +----------------------------------------------------------------+ +//! ``` + +use slt::{Border, Color, Context, KeyCode, KeyModifiers, Rect, Style}; + +/// Persistent state for the DX shorthand demo. Public so the v0.20 tour +/// can own a single [`DemoState`] across frames — clicks on the help +/// overlay or panel toggle would otherwise reset every frame. +#[derive(Default)] +pub struct DemoState { + pub panel_open: bool, + pub show_help: bool, +} + +// Layout constants. Pinned here so the help overlay and the action column +// never drift between this demo, the snapshot test, and the doc-comment +// ASCII layout above. +const ACTIONS_W: u32 = 28; +const HELP_DIALOG_W: u32 = 44; +const HELP_DIALOG_H: u32 = 7; + +// animate_bool fade thresholds. The color tier mirrors the standard +// "alarm-yellow-green" gauge palette so the demo composes cleanly with +// the showcase example. +const PANEL_ALPHA_HIGH: f64 = 0.5; +const PANEL_ALPHA_MID: f64 = 0.25; + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + + slt::run_with( + slt::RunConfig::default().mouse(true), + move |ui: &mut Context| { + // Standard exit-key policy: bare `q`, Esc, and Ctrl-Q. Ctrl-C is + // intentionally NOT bound — many terminals (e.g. macOS Terminal, + // iTerm2 with default copy-shortcut) intercept Ctrl-C, so it never + // reaches the app reliably. + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + + render(ui, &mut state); + }, + ) +} + +/// Per-frame entry point. Handles Space (panel toggle) and ?/h (help +/// overlay toggle), then renders the demo body. Caller owns [`DemoState`] +/// so toggles persist across frames — this is the path the tour uses. +pub fn render(ui: &mut Context, state: &mut DemoState) { + if ui.key(' ') { + state.panel_open = !state.panel_open; + } + if ui.key('?') || ui.key('h') { + state.show_help = !state.show_help; + } + + body(ui, state); +} + +/// One-frame deterministic render entry point used by snapshot tests +/// (`tests/v020_dx_shortcuts_demo.rs`). +/// +/// Stable defaults: panel closed, help overlay on. Reviewers can see the +/// centered help dialog (#221), the chained tooltip helpers (#209), and +/// the `fill()` status column (#220) all in one frame. The animated panel +/// alpha (#210) is exercised by the unit tests. +/// +/// NEVER call this from a live loop or from another demo — toggles are +/// silently dropped because state never persists. Live embeddings should +/// call [`render`] with their own `&mut DemoState`. +pub fn render_snapshot(ui: &mut Context) { + let mut snapshot = DemoState { + panel_open: false, + show_help: true, + }; + body(ui, &mut snapshot); +} + +fn body(ui: &mut Context, state: &mut DemoState) { + let theme = ui.theme(); + let pad = theme.spacing.xs(); + let gap = theme.spacing.xs(); + + // #210 — animate_bool: smooth 0..1 progress drives panel alpha and + // width contribution. The id is keyed by demo so multiple animate_bool + // calls in the same app don't collide. + let panel_alpha = ui.animate_bool("dx_demo::panel_open", state.panel_open); + + let _ = ui + .bordered(Border::Rounded) + .title("v0.20 DX Shorthand Demo") + .p(pad) + .gap(gap) + .col(|ui| { + ui.text("Press Space to toggle the panel, hover Save for a tooltip,") + .dim(); + ui.text("press ? for the centered help overlay, q / Esc / Ctrl-Q to quit.") + .dim(); + + let _ = ui.row(|ui| { + render_actions_column(ui, panel_alpha); + render_status_column(ui, panel_alpha); + }); + }); + + if state.show_help { + render_centered_help(ui); + } +} + +fn render_actions_column(ui: &mut Context, panel_alpha: f64) { + let gap = ui.theme().spacing.xs(); + let _ = ui.container().w(ACTIONS_W).gap(gap).col(|ui| { + ui.text("Actions").bold(); + + // #209 — on_hover: tooltip chained directly onto the button response. + let _ = ui + .button("Save") + .on_hover(ui, "Saves the current document to disk."); + let _ = ui + .button("Open") + .on_hover(ui, "Open an existing file from your project."); + let _ = ui.button("Toggle Panel"); + + ui.text(format!("panel_alpha = {panel_alpha:.2}")).dim(); + }); +} + +fn render_status_column(ui: &mut Context, panel_alpha: f64) { + let pad = ui.theme().spacing.xs(); + // #220 — fill(): the right-hand column claims all remaining width + // without writing `grow(1)`. Reads as plain English at the call site. + let _ = ui + .container() + .fill() + .border(Border::Single) + .p(pad) + .col(|ui| { + ui.text("Status").bold(); + ui.text("Shorthand helpers are about reading code, not"); + ui.text("writing it. fill() == grow(1) — but plain English."); + + if panel_alpha > 0.0 { + let pad = ui.theme().spacing.xs(); + let _ = ui.container().mt(pad).p(pad).col(|ui| { + let alpha_color = if panel_alpha > PANEL_ALPHA_HIGH { + Color::Green + } else if panel_alpha > PANEL_ALPHA_MID { + Color::Yellow + } else { + Color::DarkGray + }; + ui.text(format!("Animated panel ({:.0}%)", panel_alpha * 100.0)) + .fg(alpha_color) + .bold(); + ui.text("Smoothly tweened via animate_bool."); + }); + } + }); +} + +fn render_centered_help(ui: &mut Context) { + // #221 — center_in: position a fixed-size help dialog dead-center on + // the viewport using a raw-draw closure. The dotted outline shows the + // geometry returned by `Rect::center_in`; in real apps the inner area + // would host real widgets, but raw_draw keeps the demo geometry-only. + let area_w = ui.width(); + let area_h = ui.height(); + let dot_style = Style::new().fg(Color::DarkGray); + let label_style = Style::new().fg(Color::Cyan); + + let _ = ui.overlay(|ui| { + ui.container().w(area_w).h(area_h).draw(move |buf, area| { + let dialog = Rect::new(0, 0, HELP_DIALOG_W, HELP_DIALOG_H); + let positioned = dialog.center_in(area); + + // Dotted outline traces the rect produced by center_in. + for y in positioned.rows() { + for x in positioned.x..positioned.right() { + let on_edge = y == positioned.y + || y + 1 == positioned.bottom() + || x == positioned.x + || x + 1 == positioned.right(); + if on_edge { + buf.set_char(x, y, '·', dot_style); + } + } + } + + // Inner label — confirms the geometry visually. + let label = "Help (centered via center_in)"; + let label_w = label.chars().count() as u32; + if positioned.width >= label_w + 2 && positioned.height >= 3 { + let lx = positioned.x + (positioned.width - label_w) / 2; + let ly = positioned.y + positioned.height / 2; + for (i, ch) in label.chars().enumerate() { + buf.set_char(lx + i as u32, ly, ch, label_style); + } + } + }); + }); +} diff --git a/examples/v020_gauge.rs b/examples/v020_gauge.rs new file mode 100644 index 0000000..60a2abe --- /dev/null +++ b/examples/v020_gauge.rs @@ -0,0 +1,108 @@ +//! v0.20.0 gauge / line_gauge demo — block-fill and line-style progress bars. +//! +//! Demonstrates: #224 (gauge / line_gauge builder API, color-tiered fills, +//! custom characters, `f64` ratios). +//! +//! Run: `cargo run --example v020_gauge` +//! +//! Keys: +//! q / Esc / Ctrl-Q — quit +//! +//! Builder API (post v0.20.0 consistency pass): +//! ui.gauge(0.6).label("60%").width(24) +//! ui.line_gauge(0.6).label("60%").width(24).filled('━') +//! +//! Color tiers are automatic: success below 50%, warning 50–80%, error >= 80%. + +use slt::{Border, Color, Context, KeyCode, KeyModifiers, RunConfig}; + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + + slt::run_with(RunConfig::default().mouse(true), |ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + state.tick = state.tick.wrapping_add(1); + render(ui, &mut state); + }) +} + +/// Animated demo state — one frame counter drives all three live gauges. +#[derive(Default)] +pub struct DemoState { + /// Frame counter; converted to seconds for the sin/cos animations. + pub tick: u64, +} + +/// Render one frame. Stable signature for snapshot tests. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let sp = ui.spacing(); + let t = state.tick as f64 / 60.0; + + // Live values: CPU oscillates 60–90%, memory 40–80%, disk steady at 25%. + let cpu = (t.sin().abs() * 0.3 + 0.6).clamp(0.0, 1.0); + let memory = ((t * 0.5).cos().abs() * 0.4 + 0.4).clamp(0.0, 1.0); + let disk: f64 = 0.25; + + let _ = ui + .bordered(Border::Rounded) + .title("v0.20.0 #224: gauge / line_gauge") + .p(sp.xs()) + .gap(sp.xs()) + .grow(1) + .col(|ui| { + ui.text("Block-style gauge with inline label (color-tiered, animated):") + .fg(Color::Cyan); + + metric_row(ui, "CPU ", cpu); + metric_row(ui, "MEM ", memory); + metric_row(ui, "DISK ", disk); + + ui.text(""); + ui.text("Static tiers — green < 50%, yellow 50–80%, red ≥ 80%:") + .fg(Color::Cyan); + + metric_row(ui, "25% ", 0.25); + metric_row(ui, "65% ", 0.65); + metric_row(ui, "90% ", 0.90); + + ui.text(""); + ui.text("Single-line gauge with custom characters:") + .fg(Color::Cyan); + + let _ = ui.row_gap(sp.sm(), |ui| { + ui.text("Default "); + ui.line_gauge(0.6).label("60%").width(24); + }); + let _ = ui.row_gap(sp.sm(), |ui| { + ui.text("Hash/dot "); + ui.line_gauge(0.45) + .filled('#') + .empty('.') + .width(24) + .label("45%"); + }); + let _ = ui.row_gap(sp.sm(), |ui| { + ui.text("Block "); + ui.line_gauge(0.85) + .filled('█') + .empty('▒') + .width(24) + .label("85%"); + }); + + ui.text("q / Esc / Ctrl-Q quits.").dim(); + }); +} + +/// Render a single labelled `gauge` row with an auto-formatted percentage. +fn metric_row(ui: &mut Context, label: &str, value: f64) { + let sp = ui.spacing(); + let _ = ui.row_gap(sp.sm(), |ui| { + ui.text(label); + ui.gauge(value) + .label(format!("{:.0}%", value * 100.0)) + .width(24); + }); +} diff --git a/examples/v020_gutter_highlights.rs b/examples/v020_gutter_highlights.rs new file mode 100644 index 0000000..4b1c459 --- /dev/null +++ b/examples/v020_gutter_highlights.rs @@ -0,0 +1,179 @@ +//! v0.20.0 scrollable_with_gutter demo — grep-style log viewer with search- +//! result highlights. +//! +//! Demonstrates: #235 (scrollable_with_gutter via `GutterOpts`, `HighlightRange`, +//! `ScrollState::highlight_next` / `highlight_previous`). +//! +//! Run: `cargo run --example v020_gutter_highlights` +//! +//! Keys: +//! n — jump to the next matching line +//! p — jump to the previous matching line +//! 1 — filter by ERROR +//! 2 — filter by WARN +//! 3 — filter by INFO +//! Mouse wheel — scroll the viewport +//! q / Esc / Ctrl-Q — quit +//! +//! Layout: +//! ┌── filter status / match counter ────────────┐ +//! │ │ +//! │ 1 │ INFO app starting up │ +//! │ 2 │ DEBUG loaded config from ./config.toml │ +//! │ 3 │ INFO listening on :8080 │ +//! │ … │ … │ +//! └──────────────────────────────────────────────┘ + +use slt::{ + Border, Color, Context, GutterOpts, HighlightRange, KeyCode, KeyModifiers, RunConfig, + ScrollState, +}; + +const SAMPLE_LOG: &[&str] = &[ + "INFO app starting up", + "DEBUG loaded config from ./config.toml", + "INFO listening on :8080", + "DEBUG accepted connection from 127.0.0.1", + "WARN rate limit nearing for /api/heavy", + "ERROR upstream timeout after 30s", + "DEBUG retry attempt 1 of 3", + "DEBUG retry attempt 2 of 3", + "ERROR upstream timeout after 30s", + "INFO switching to fallback service", + "DEBUG cache hit for key=user.42", + "INFO request 200 OK in 12ms", + "WARN slow query detected (412ms)", + "ERROR database connection lost", + "INFO reconnecting...", + "INFO reconnected to db1", + "DEBUG cache miss for key=user.99", + "INFO request 200 OK in 8ms", + "WARN rate limit nearing for /api/light", + "INFO graceful shutdown begin", + "INFO graceful shutdown complete", +]; + +const VIEWPORT_HEIGHT: u32 = 12; + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + + slt::run_with(RunConfig::default().mouse(true), |ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + render(ui, &mut state); + }) +} + +/// Demo state — scroll position, the active filter query, and the matching +/// highlights derived from it. +pub struct DemoState { + /// Scroll position + active highlight ranges live here. + pub scroll: ScrollState, + /// Current filter substring shown in the status row. + pub query: String, +} + +impl DemoState { + /// Construct with `ERROR` selected so the snapshot test sees a non-empty + /// match set on first frame. + pub fn new() -> Self { + let mut s = Self { + scroll: ScrollState::new(), + query: String::new(), + }; + s.set_query("ERROR"); + s + } + + /// Replace the active filter and rebuild the highlight set. + pub fn set_query(&mut self, query: &str) { + self.query.clear(); + self.query.push_str(query); + let hits: Vec = if query.is_empty() { + Vec::new() + } else { + SAMPLE_LOG + .iter() + .enumerate() + .filter_map(|(i, line)| line.contains(query).then_some(HighlightRange::line(i))) + .collect() + }; + self.scroll.set_highlights(&hits); + } +} + +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} + +/// Render one frame. Stable signature for snapshot tests. +/// +/// Handles n/p navigation and 1/2/3 filter swaps so the tour-embedded +/// version reacts to keystrokes the same way the standalone binary does. +/// `main` only handles quit; everything else lives here. +pub fn render(ui: &mut Context, state: &mut DemoState) { + if ui.key('n') { + state.scroll.highlight_next(); + } + if ui.key('p') { + state.scroll.highlight_previous(); + } + if ui.key('1') { + state.set_query("ERROR"); + } + if ui.key('2') { + state.set_query("WARN"); + } + if ui.key('3') { + state.set_query("INFO"); + } + + let sp = ui.spacing(); + + let _ = ui + .bordered(Border::Rounded) + .title("v0.20.0 #235: scrollable_with_gutter") + .p(sp.xs()) + .gap(sp.xs()) + .grow(1) + .col(|ui| { + ui.text(format!( + "Filter: {:?} n=next p=prev 1=ERROR 2=WARN 3=INFO wheel=scroll", + state.query, + )) + .fg(Color::Cyan); + + let r = ui.scrollable_with_gutter( + &mut state.scroll, + GutterOpts::line_numbers(SAMPLE_LOG.len(), VIEWPORT_HEIGHT), + |ui, abs| { + if let Some(line) = SAMPLE_LOG.get(abs) { + let fg = if line.contains("ERROR") { + Color::Red + } else if line.contains("WARN") { + Color::Yellow + } else { + Color::Reset + }; + ui.text(*line).fg(fg); + } + }, + ); + + if let Some(idx) = r.current_highlight { + ui.text(format!( + "Match {}/{} (press n / p to navigate)", + idx + 1, + r.total_highlights + )) + .dim(); + } else { + ui.text("No matches").dim(); + } + ui.text("q / Esc / Ctrl-Q quits.").dim(); + }); +} diff --git a/examples/v020_keymap_help.rs b/examples/v020_keymap_help.rs new file mode 100644 index 0000000..fef51df --- /dev/null +++ b/examples/v020_keymap_help.rs @@ -0,0 +1,151 @@ +//! v0.20.0 keymap-help demo — auto-generated help overlay from per-widget +//! key bindings. +//! +//! Demonstrates: #236. +//! +//! Run: `cargo run --example v020_keymap_help` +//! +//! Keys: +//! k / Up — increment counter +//! j / Down — decrement counter +//! r — reset counter to zero +//! ? — toggle the auto-help overlay +//! Esc — close the overlay (when open) +//! q / Ctrl-Q — quit +//! +//! Note: while the help overlay is open it acts as a modal, so the +//! regular `ui.key('?')` / `ui.key_code(KeyCode::Esc)` checks are blocked +//! by the modal guard. The toggle uses `raw_key_mod` / `raw_key_code` so +//! '?' and Esc keep working while the overlay is up. +//! +//! Layout: +//! ┌──────── main view ────────┐ +//! │ Keymap publishing demo │ ┌── help overlay (on '?') ──┐ +//! │ Counter: N │ │ global: ?, q │ +//! │ Press ? for help… │ │ counter: k/Up, j/Down, r │ +//! └───────────────────────────┘ └───────────────────────────┘ + +use slt::{Color, Context, KeyCode, KeyModifiers, RunConfig, Style, WidgetKeyHelp}; + +/// Counter widget bindings — published every frame the widget renders, so +/// the help overlay only lists keys that are actually live. +struct CounterWidget; +impl WidgetKeyHelp for CounterWidget { + fn key_help(&self) -> &'static [(&'static str, &'static str)] { + const HELP: &[(&str, &str)] = &[ + ("k / Up", "increment"), + ("j / Down", "decrement"), + ("r", "reset to zero"), + ]; + HELP + } +} + +/// Always-visible global bindings (quit, help toggle). +struct GlobalKeys; +impl WidgetKeyHelp for GlobalKeys { + fn key_help(&self) -> &'static [(&'static str, &'static str)] { + const HELP: &[(&str, &str)] = &[("?", "toggle this help overlay"), ("q", "quit")]; + HELP + } +} + +/// Snapshot fixture counter value. Matches the saved snapshot under +/// `tests/snapshots/v020_lib_demos__v020_keymap_help.snap`. +const SNAPSHOT_COUNT: i32 = 3; + +/// Persistent state for the keymap-help demo. +/// +/// Counter increments survive across frames; `help_open` controls whether +/// the auto-generated overlay is rendered. +#[derive(Default)] +pub struct DemoState { + pub count: i32, + pub help_open: bool, +} + +/// Shared body. Every focusable widget publishes its bindings BEFORE the +/// overlay call — the overlay reads the per-frame keymap registry so order +/// matters for deterministic snapshots. +fn body(ui: &mut Context, count: i32, help_open: bool) { + ui.publish_keymap("global", GlobalKeys.key_help()); + ui.publish_keymap("counter", CounterWidget.key_help()); + + let _ = ui.col(|ui| { + ui.styled( + "Keymap publishing demo", + Style::new().bold().fg(Color::Cyan), + ); + ui.text(""); + ui.styled(format!("Counter: {count}"), Style::new().bold()); + ui.text(""); + ui.styled("Press ? to view the auto-help overlay", Style::new().dim()); + ui.styled("Press q to quit", Style::new().dim()); + }); + + ui.keymap_help_overlay(help_open); +} + +/// Per-frame entry point. Handles k/j/r counter updates and the `?`/Esc +/// overlay toggle, then renders the body. Caller owns [`DemoState`] so +/// counter and overlay state persist across frames — this is the path +/// the tour uses. +/// +/// `?` and Esc go through `raw_key_*` because once the overlay is open it +/// counts as a modal and the regular `key()` checks are blocked by the +/// modal guard. +pub fn render(ui: &mut Context, state: &mut DemoState) { + // Toggle help overlay. Use `raw_key_mod` so '?' keeps toggling + // even after the overlay opens — the regular `key('?')` is + // blocked by the overlay's modal guard. + if ui.raw_key_mod('?', KeyModifiers::NONE) { + state.help_open = !state.help_open; + } + // Close overlay on Esc as well (also via `raw_*` to bypass the + // modal guard). + if state.help_open && ui.raw_key_code(KeyCode::Esc) { + state.help_open = false; + } + if ui.key('k') || ui.key_code(KeyCode::Up) { + state.count = state.count.saturating_add(1); + } + if ui.key('j') || ui.key_code(KeyCode::Down) { + state.count = state.count.saturating_sub(1); + } + if ui.key('r') { + state.count = 0; + } + + body(ui, state.count, state.help_open); +} + +/// One-frame deterministic render entry point used by snapshot tests +/// (`tests/v020_lib_demos.rs`). Pins the help overlay open so the snapshot +/// covers both the main view and the auto-generated overlay. +/// +/// NEVER call this from a live loop or from another demo — clicks and +/// counter mutations are silently dropped because state never persists. +/// Live embeddings should call [`render`] with their own `&mut DemoState`. +pub fn render_snapshot(ui: &mut Context) { + body(ui, SNAPSHOT_COUNT, true); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + // Quit. Ctrl-Q is the portable alternative to Ctrl-C, which is + // intercepted as Copy on macOS terminals. Esc is gated on + // `!help_open` so the overlay's Esc-to-dismiss takes precedence. + if ui.key('q') || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + if !state.help_open && ui.key_code(KeyCode::Esc) { + ui.quit(); + } + + render(ui, &mut state); + })?; + + Ok(()) +} diff --git a/examples/v020_modal_trap.rs b/examples/v020_modal_trap.rs new file mode 100644 index 0000000..8ef2d98 --- /dev/null +++ b/examples/v020_modal_trap.rs @@ -0,0 +1,172 @@ +//! v0.20.0 modal focus-trap demo — Tab cycles inside the modal and never +//! escapes to background widgets (WCAG 2.1 SC 2.4.3). +//! +//! Demonstrates: #225. +//! +//! Run: `cargo run --example v020_modal_trap` +//! +//! Keys: +//! M — open the modal +//! Tab / Shift-Tab — cycle focus (only inside modal once it is open) +//! Enter — activate the focused button +//! Esc — close the modal (or quit, when no modal is open) +//! q / Ctrl-Q — quit (only when no modal is open) +//! +//! Layout: +//! ┌── main view ────────────────────────┐ +//! │ [bg btn] [bg btn] [bg btn] │ ┌── modal ──┐ +//! │ [Open modal] │ │ Confirm │ +//! │ Last answer: … │ │ [Yes][No] │ +//! └─────────────────────────────────────┘ └───────────┘ + +use slt::{ + context::ModalOptions, Border, ButtonVariant, Context, KeyCode, KeyModifiers, RunConfig, +}; + +/// Mutable demo state. Bundling these into a struct keeps `main()` minimal +/// and lets `render` synthesise a deterministic snapshot frame without +/// duplicating field-by-field defaults. +pub struct State { + pub show_modal: bool, + pub answered: Option, +} + +impl State { + pub fn new() -> Self { + Self { + show_modal: false, + answered: None, + } + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +/// Shared body. Padding/gap come from `theme.spacing` — when the embedding +/// app overrides the theme, this demo's density follows automatically. +fn body(ui: &mut Context, state: &mut State) { + let sp = ui.spacing(); + let _ = ui + .bordered(Border::Rounded) + .title("SLT v0.20: Modal focus trap") + .p(sp.sm()) + .gap(sp.xs()) + .grow(1) + .col(|ui| { + ui.text("Tab cycles through the BACKGROUND focusables right now.") + .dim(); + ui.text("Open the modal — Tab will then cycle ONLY between Yes/No.") + .dim(); + ui.text("Click outside or press a Tab while focused on a background") + .dim(); + ui.text("widget: focus is yanked back inside the modal.") + .dim(); + + // Three throwaway background buttons. They exist so the user can + // see that focus genuinely escapes the modal scope when no trap + // is active — and is held captive once the modal opens. + let _ = ui.row_gap(sp.sm(), |ui| { + let _ = ui.button("First bg button"); + let _ = ui.button("Second bg button"); + let _ = ui.button("Third bg button"); + }); + + ui.text(""); + if ui.button_with("Open modal", ButtonVariant::Primary).clicked { + state.show_modal = true; + state.answered = None; + } + if let Some(a) = state.answered { + let label = if a { "Yes" } else { "No" }; + ui.text(format!("Last answer: {label}")).dim(); + } + }); + + if state.show_modal { + // tab_trap = true is the load-bearing line: focus cannot leave the + // modal even if a stray click hits a background widget rect. + let _ = ui.modal_with(ModalOptions { tab_trap: true }, |ui| { + let _ = ui + .bordered(Border::Rounded) + .title("Confirm") + .p(sp.sm()) + .gap(sp.xs()) + .col(|ui| { + ui.text("Press Tab — focus stays inside the modal.").bold(); + let _ = ui.row_gap(sp.sm(), |ui| { + if ui.button_with("Yes", ButtonVariant::Primary).clicked { + state.answered = Some(true); + state.show_modal = false; + } + if ui.button_with("No", ButtonVariant::Outline).clicked { + state.answered = Some(false); + state.show_modal = false; + } + }); + ui.text("Esc to dismiss.").dim(); + }); + }); + } +} + +/// Per-frame entry point. Handles M-to-open, Esc-to-dismiss, and the +/// modal body. Caller owns the [`State`] so user clicks on Yes/No +/// persist across frames — that is the difference between this and +/// [`render_snapshot`] (which is for one-shot tests only and should not +/// be used as the live entry). +pub fn render(ui: &mut Context, state: &mut State) { + // M opens the modal (keyboard-accessible alternative to clicking the + // "Open modal" button below). Blocked automatically while a modal is + // already open via the same overlay guard. + if ui.key('m') || ui.key('M') { + state.show_modal = true; + state.answered = None; + } + + body(ui, state); + + // Modal-scoped Esc-to-dismiss. raw_key_code bypasses focus filtering + // so Esc still works even when a modal button has focus. + if state.show_modal && ui.raw_key_code(KeyCode::Esc) { + state.show_modal = false; + } +} + +/// One-frame deterministic snapshot render. Constructs a fresh +/// modal-open state every call, which is what snapshot tests want but +/// is NOT what an interactive embedding wants — clicks would be reset +/// next frame. Live embeddings should call [`render`] with their own +/// `&mut State`. +pub fn render_snapshot(ui: &mut Context) { + let mut state = State { + show_modal: true, + answered: None, + }; + body(ui, &mut state); +} + +fn main() -> std::io::Result<()> { + let mut state = State::new(); + + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + // Quit keys only fire when no modal is open. macOS Ctrl-C is bound to + // copy in many terminals — bind quit to plain `q`, Esc, and Ctrl-Q so + // the demo is escape-able under every common setup. + // + // Note: `key()` / `key_code()` / `key_mod()` are blocked when a modal + // is active (the modal/overlay guard inside the event helpers), so the + // explicit `!show_modal` check below is belt-and-suspenders for the + // Esc branch — Esc inside the modal must dismiss it, not quit the app. + if !state.show_modal + && (ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL)) + { + ui.quit(); + } + + render(ui, &mut state); + }) +} diff --git a/examples/v020_named_focus.rs b/examples/v020_named_focus.rs new file mode 100644 index 0000000..d09d3c2 --- /dev/null +++ b/examples/v020_named_focus.rs @@ -0,0 +1,186 @@ +//! v0.20.0 named-focus demo — register_focusable_named + focus_by_name. +//! +//! Demonstrates: #217 +//! +//! Three text inputs ("name", "email", "city") and three "Focus X" +//! buttons. Tab / Shift-Tab cycle through inputs positionally; clicking a +//! button — or pressing the matching number key — calls +//! `focus_by_name(...)` to jump directly without caring about render +//! order. Clicking a row also routes through `focus_by_name` so the +//! mouse-click experience matches a normal form. The current focus name +//! is echoed at the top — it stays in sync because `focused_name()` +//! reads the resolved name from the previous frame's name map. +//! +//! Run: `cargo run --example v020_named_focus` +//! +//! Keys: +//! Tab — focus next input (positional) +//! Shift-Tab — focus previous input (positional) +//! 1 / 2 / 3 — jump focus to name / email / city +//! Click row — jump focus to that input by name +//! Click [Focus N]— jump focus to that named input +//! Type — text flows into the focused input +//! q / Esc / Ctrl-Q — quit (Esc and Ctrl-Q always quit; plain `q` +//! quits only when no input has focus, otherwise +//! it types into the focused input) +//! +//! Layout: +//! ┌── register_focusable_named + focus_by_name ──┐ +//! │ help line │ +//! │ focused_name: city │ +//! │ Name: [_________] │ +//! │ Email: [_________] │ +//! │ City: [_________] │ +//! │ [ Focus name ] [ Focus email ] [ Focus city ] │ +//! └───────────────────────────────────────────────┘ + +use slt::widgets::TextInputState; +use slt::{Border, Color, Context, KeyCode, KeyModifiers, RunConfig}; + +/// Persistent inputs across frames. Each `TextInputState` carries its own +/// cursor and selection, so we can't substitute a bare `String` here. +#[derive(Default)] +pub struct DemoState { + pub name: TextInputState, + pub email: TextInputState, + pub city: TextInputState, +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + render(ui, &mut state) + }) +} + +/// Render one frame of the named-focus demo. +/// +/// Public so snapshot tests can pin a specific focus state without +/// reimplementing the input layout. +pub fn render(ui: &mut Context, state: &mut DemoState) { + // Esc / Ctrl-Q always quit — they never collide with text-input + // typing, so they can fire before the body renders. + if ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + let pad = ui.spacing().xs(); + let gap = ui.spacing().xs(); + let row_gap = ui.spacing().xs(); + + let _ = ui + .bordered(Border::Rounded) + .title("register_focusable_named + focus_by_name") + .p(pad) + .gap(gap) + .grow(1) + .col(|ui| { + ui.text("Tab/Shift+Tab cycle 1/2/3 or click row jumps by name Esc / Ctrl+Q quit") + .dim(); + + // `focused_name` resolves against the previous frame's map, so + // it lags one frame on the very first focus_by_name call — + // acceptable because the next frame already shows the new name. + let current = ui.focused_name().map(|s| s.to_string()); + let label = current.as_deref().unwrap_or("(none)"); + ui.text(format!("focused_name: {label}")).fg(Color::Cyan); + + render_input_rows(ui, state); + render_focus_buttons(ui, row_gap); + }); + + // Numeric shortcuts and plain `q` are checked AFTER the body so a + // focused `text_input` consumes typed digits and 'q' first. This + // means: if no input is focused, `1`/`2`/`3` jump focus and `q` + // quits; if an input has focus, the keys flow into it as text. Use + // Esc or Ctrl-Q to quit unconditionally. + if ui.key('1') { + let _ = ui.focus_by_name("name"); + } + if ui.key('2') { + let _ = ui.focus_by_name("email"); + } + if ui.key('3') { + let _ = ui.focus_by_name("city"); + } + if ui.key('q') { + ui.quit(); + } +} + +/// Render the three named inputs. The order here is positional Tab order; +/// names are independent of visual position and persist across re-orders. +/// +/// Each row is wrapped in a clickable container. Clicking anywhere in the +/// row routes through `focus_by_name` so mouse focus matches keyboard +/// focus — text_input itself doesn't claim focus on click. +fn render_input_rows(ui: &mut Context, state: &mut DemoState) { + let _ = ui.col(|ui| { + let row_gap = ui.spacing().xs(); + + // The wrapping `container().fill().col()` has its own hit area, so + // we read its `clicked` from inside the row closure (via the + // returned Response) rather than the row's. The outer + // `row_gap(...)` Response also captures clicks on the bare + // "Name:"/"Email:"/"City:" label cells outside the input box, + // routing them through the same `focus_by_name` call. + let name_row = ui.row_gap(row_gap, |ui| { + ui.text("Name: "); + let _ = ui.register_focusable_named("name"); + let r = ui.container().fill().col(|ui| { + let _ = ui.text_input(&mut state.name); + }); + if r.clicked { + let _ = ui.focus_by_name("name"); + } + }); + if name_row.clicked { + let _ = ui.focus_by_name("name"); + } + + let email_row = ui.row_gap(row_gap, |ui| { + ui.text("Email:"); + let _ = ui.register_focusable_named("email"); + let r = ui.container().fill().col(|ui| { + let _ = ui.text_input(&mut state.email); + }); + if r.clicked { + let _ = ui.focus_by_name("email"); + } + }); + if email_row.clicked { + let _ = ui.focus_by_name("email"); + } + + let city_row = ui.row_gap(row_gap, |ui| { + ui.text("City: "); + let _ = ui.register_focusable_named("city"); + let r = ui.container().fill().col(|ui| { + let _ = ui.text_input(&mut state.city); + }); + if r.clicked { + let _ = ui.focus_by_name("city"); + } + }); + if city_row.clicked { + let _ = ui.focus_by_name("city"); + } + }); +} + +/// Render three focus buttons. Each button targets a name; clicking it +/// asks the focus system to jump on the next frame. +fn render_focus_buttons(ui: &mut Context, gap: u32) { + let _ = ui.row_gap(gap, |ui| { + if ui.button("Focus name").clicked { + let _ = ui.focus_by_name("name"); + } + if ui.button("Focus email").clicked { + let _ = ui.focus_by_name("email"); + } + if ui.button("Focus city").clicked { + let _ = ui.focus_by_name("city"); + } + }); +} diff --git a/examples/v020_perf_audit.rs b/examples/v020_perf_audit.rs new file mode 100644 index 0000000..742ea97 --- /dev/null +++ b/examples/v020_perf_audit.rs @@ -0,0 +1,149 @@ +//! v0.20.0 hot-path perf audit — timing breakdown for the four optimized paths. +//! +//! Demonstrates: #204 (FrameState reuse), #205 (wrap_segments alloc), +//! #206 (kitty placement flush), #228 (modal-aware dim_buffer). +//! +//! Non-interactive (stdout report) — runs each hot path `ITERS` times and +//! prints total + per-iter timing. Use after applying the v0.20.0 perf +//! fixes to confirm steady-state behavior. +//! +//! Run: `cargo run --release --example v020_perf_audit` +//! +//! `--release` is required for representative numbers — debug builds add +//! ~10x overhead and obscure the diff between the four paths. + +use std::time::Instant; + +use slt::buffer::Buffer; +use slt::rect::Rect; +use slt::{Border, Style, TestBackend}; + +// Iteration count is shared across all four benchmarks so the per-iter +// numbers are directly comparable. 10k strikes a balance between sample +// stability and a sub-second total wall time per benchmark. +const ITERS: u32 = 10_000; + +// Steady-state render fixture. 80x24 is the canonical "small terminal" +// reference size used elsewhere in the test suite. +const RENDER_W: u32 = 80; +const RENDER_H: u32 = 24; + +// dim_buffer fixture geometry. 200x60 is large enough that the +// modal-aware path materially beats the full-buffer scan. +const DIM_W: u32 = 200; +const DIM_H: u32 = 60; + +// Wrap fixture — three styled segments wrapping at 40 columns. Segment +// content is mixed-style so the bench exercises the per-style allocation +// path rather than a single contiguous run. +const WRAP_COLS: u32 = 40; + +// Kitty fixture — three stable image placements. Above 1 image we exercise +// the placement-diff fast path; 3 keeps the cost noticeable but bounded. +const KITTY_IMAGES: usize = 3; +const KITTY_ROW_OFFSET: u32 = 5; + +fn bench(label: &'static str, iters: u32, mut f: F) { + let start = Instant::now(); + for _ in 0..iters { + f(); + } + let elapsed = start.elapsed(); + let per_iter = elapsed / iters; + println!( + " {:<32} {:>10?} total | {:>9?} / iter", + label, elapsed, per_iter + ); +} + +fn audit_framestate_reuse() { + println!("[#204] FrameState 6-vec/hashset reuse — {ITERS} frames:"); + + let mut tb = TestBackend::new(RENDER_W, RENDER_H); + bench("steady-state render", ITERS, || { + tb.render(|ui| { + let _ = ui.bordered(Border::Rounded).title("audit").col(|ui| { + ui.text("hello").bold(); + ui.text("world").dim(); + }); + }); + }); +} + +fn audit_wrap_segments() { + println!("[#205] wrap_segments String alloc — {ITERS} 3-segment wraps at {WRAP_COLS}w:"); + + let segments: Vec<(String, Style)> = vec![ + ("hello world alpha beta".to_string(), Style::new().bold()), + (" ".to_string(), Style::default()), + ( + "gamma delta epsilon zeta eta theta".to_string(), + Style::new().italic(), + ), + ]; + + bench("wrap to 40 cols", ITERS, || { + let _ = slt::__bench_wrap_segments(&segments, WRAP_COLS); + }); +} + +fn audit_kitty_placement_flush() { + println!("[#206] kitty placement flush — {ITERS} flushes ({KITTY_IMAGES} stable images):"); + + let mut fx = slt::__bench_new_kitty_fixture(KITTY_IMAGES); + let mut sink: Vec = Vec::with_capacity(8192); + + // First flush uploads images; we measure the steady-state diff path. + fx.flush_inline(&mut sink, KITTY_ROW_OFFSET).unwrap(); + sink.clear(); + + bench("stable inline flush", ITERS, || { + fx.flush_inline(&mut sink, KITTY_ROW_OFFSET).unwrap(); + }); +} + +fn audit_dim_buffer_modal() { + println!("[#228] dim_buffer modal — {ITERS} iterations on {DIM_W}x{DIM_H} buf:"); + + let area = Rect::new(0, 0, DIM_W, DIM_H); + + // Three modal sizes exercise the strip-based, mostly-inside, and full- + // buffer fallback paths respectively. + let small_modal = Rect::new(85, 25, 30, 10); + let large_modal = Rect::new(20, 5, 160, 50); + let zero_modal = Rect::new(0, 0, 0, 0); + + bench("modal-aware (small 30x10 modal)", ITERS, || { + let mut buf = Buffer::empty(area); + slt::__bench_dim_buffer_around(&mut buf, small_modal); + }); + bench("modal-aware (large 160x50 modal)", ITERS, || { + let mut buf = Buffer::empty(area); + slt::__bench_dim_buffer_around(&mut buf, large_modal); + }); + bench("full-buffer scan (legacy path)", ITERS, || { + let mut buf = Buffer::empty(area); + slt::__bench_dim_buffer_around(&mut buf, zero_modal); + }); + + println!(" (large modal should beat full-buffer; small modal beats it less.)"); +} + +fn main() { + println!("=== SLT v0.20.0 hot-path perf audit ==="); + println!(); + + audit_framestate_reuse(); + println!(); + + audit_wrap_segments(); + println!(); + + audit_kitty_placement_flush(); + println!(); + + audit_dim_buffer_modal(); + println!(); + + println!("Tip: re-run with `--release` for steady-state numbers."); +} diff --git a/examples/v020_progress_response.rs b/examples/v020_progress_response.rs new file mode 100644 index 0000000..eb2aaaa --- /dev/null +++ b/examples/v020_progress_response.rs @@ -0,0 +1,170 @@ +//! v0.20.0 progress / spinner Response demo — hover both widgets to confirm +//! they now return a real `Response`. +//! +//! Demonstrates: #212 (`Context::progress` and `Context::spinner` upgraded +//! from `&mut Self` to `Response`, enabling hover / tooltip wiring). +//! +//! Run: `cargo run --example v020_progress_response` +//! +//! Keys: +//! Space — pause / resume the animation +//! Left / Right — nudge ratio by 5% (also pauses if running) +//! Hover (mouse) — highlights the spinner / progress bar +//! q / Esc / Ctrl-Q — quit +//! +//! Layout: +//! ┌── v0.20.0 #212: Response from progress / spinner ──┐ +//! │ ⠋ Loading... │ +//! │ ████████░░░░░░░░░░░░ ratio = 42% │ +//! └──────────────────────────────────────────────────────┘ + +use slt::widgets::SpinnerState; +use slt::{Border, Color, Context, KeyCode, KeyModifiers, RunConfig}; + +/// Per-frame ratio step. Negated when ratio reaches the [0.0, 1.0] bounds so +/// the bar pingpongs forever without runaway accumulation. +const RATIO_STEP: f64 = 0.01; + +/// Manual nudge applied by Left/Right arrows when the user takes over. +const MANUAL_STEP: f64 = 0.05; + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + + slt::run_with(RunConfig::default().mouse(true), |ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + if ui.key(' ') { + state.paused = !state.paused; + } + if ui.key_code(KeyCode::Left) { + state.nudge(-MANUAL_STEP); + } + if ui.key_code(KeyCode::Right) { + state.nudge(MANUAL_STEP); + } + if !state.paused { + state.advance(); + } + render(ui, &mut state); + }) +} + +/// Demo state — animated progress ratio plus the spinner glyph cycle. +pub struct DemoState { + /// Spinner phase (held by `SpinnerState::dots()`). + pub spinner: SpinnerState, + /// Current progress in `0.0..=1.0`. + pub ratio: f64, + /// Direction of motion. Flipped at each endpoint. + pub step: f64, + /// `true` when the auto-advance is paused (manually nudged or Space-toggled). + pub paused: bool, +} + +impl DemoState { + /// Construct with the spinner at frame 0 and the bar at the start. + pub fn new() -> Self { + Self { + spinner: SpinnerState::dots(), + ratio: 0.0, + step: RATIO_STEP, + paused: false, + } + } + + /// Advance the ratio by one tick, reversing direction at each endpoint. + pub fn advance(&mut self) { + self.ratio += self.step; + if self.ratio >= 1.0 { + self.ratio = 1.0; + self.step = -self.step; + } else if self.ratio <= 0.0 { + self.ratio = 0.0; + self.step = -self.step; + } + } + + /// Manually shift the ratio by `delta`, clamped to `[0.0, 1.0]`. Pauses + /// the auto-advance so subsequent frames don't immediately overwrite the + /// user's intent. + pub fn nudge(&mut self, delta: f64) { + self.ratio = (self.ratio + delta).clamp(0.0, 1.0); + self.paused = true; + } +} + +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} + +/// Render one frame. Stable signature for snapshot tests. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let sp = ui.spacing(); + + let _ = ui + .bordered(Border::Rounded) + .title("v0.20.0 #212: Response from progress / spinner") + .p(sp.xs()) + .gap(sp.xs()) + .grow(1) + .col(|ui| { + ui.text("Hover the spinner or the progress bar to see Response in action.") + .fg(Color::Cyan); + + let _ = ui.row(|ui| { + let s = ui.spinner(&state.spinner); + ui.text(" Loading...").dim(); + if s.hovered { + ui.text(" (spinner hovered!)").fg(Color::Yellow); + } + }); + + let pr = ui.progress(state.ratio); + ui.text(format!( + "ratio = {:.0}% {}", + state.ratio * 100.0, + if state.paused { + "(paused)" + } else { + "(running)" + }, + )) + .dim(); + if pr.hovered { + ui.text(" Progress hovered — click would trigger a scrubber") + .fg(Color::Yellow); + } + + ui.text(""); + ui.text("Static variants (different ratios, different widths):") + .fg(Color::Cyan); + + let _ = ui.row(|ui| { + ui.text(" 0% "); + let _ = ui.progress(0.0); + }); + let _ = ui.row(|ui| { + ui.text(" 25% "); + let _ = ui.progress(0.25); + }); + let _ = ui.row(|ui| { + ui.text(" 50% "); + let _ = ui.progress(0.50); + }); + let _ = ui.row(|ui| { + ui.text(" 75% "); + let _ = ui.progress(0.75); + }); + let _ = ui.row(|ui| { + ui.text("100% "); + let _ = ui.progress(1.0); + }); + + ui.text("Space pauses; ←/→ nudges 5%. q / Esc / Ctrl-Q quits.") + .dim(); + }); +} diff --git a/examples/v020_regression_panel.rs b/examples/v020_regression_panel.rs new file mode 100644 index 0000000..e8907e2 --- /dev/null +++ b/examples/v020_regression_panel.rs @@ -0,0 +1,308 @@ +//! v0.20.0 cumulative regression panel — visual proof that v0.19 → v0.20 +//! features still render correctly together on a single screen. +//! +//! Reviewers running this binary should confirm at a glance that: +//! - **#200 overlay_anchor** — corner / center anchors still pin correctly +//! - **#225 modal + tab_trap** — Tab/Shift-Tab cycles inside the modal only +//! - **chart / sparkline** — line + sparkline still render (regression check) +//! - **table + scrollable** — table state with movable selection +//! - **error_boundary** — caught panic in a child closure does not crash app +//! - **#236 keymap_help_overlay** — `?` opens an overlay with all bindings +//! - **#235 gutter highlights** — gutter + n/p navigation +//! - **#224 gauge / line_gauge** — both gauge variants render together +//! +//! Keys: +//! Tab / Shift-Tab — navigate focusable widgets +//! ↑ / ↓ (j / k) — move table selection (when table is focused) +//! M — toggle the modal (tab_trap on, Esc dismisses) +//! ? — toggle the key-help overlay +//! n / p — next / prev gutter highlight +//! q / Esc / Ctrl-Q — quit (Ctrl-C may be bound to copy on macOS) + +use slt::{ + Anchor, Border, Color, Context, GutterOpts, HighlightRange, KeyCode, KeyModifiers, ScrollState, + TableState, Theme, +}; + +/// Key bindings advertised by the demo. Pulled out of the live closure so +/// both [`render`] (snapshot) and `main` (interactive) publish the exact +/// same keymap, keeping the help overlay byte-identical between paths. +pub const PANEL_KEYS: &[(&str, &str)] = &[ + ("Tab / ⇧Tab", "next / prev focusable"), + ("M", "open modal (tab_trap on)"), + ("?", "open key-help overlay"), + ("n / p", "next / prev highlight"), + ("Esc / ^Q", "quit"), +]; + +/// Log lines fed to the gutter scrollable. Public so the [`render`] +/// fixture and `main` stay aligned without duplicating the strings. +pub const LOG_LINES: &[&str] = &[ + "INFO app starting", + "DEBUG loaded config", + "INFO listening on :8080", + "ERROR upstream timeout", + "INFO retrying...", + "ERROR database unavailable", + "INFO switched to fallback", + "DEBUG cache hit", + "INFO request OK", + "WARN rate limit nearing", +]; + +/// Compute the highlight ranges marked on the scrollable's gutter. +pub fn highlights() -> Vec { + LOG_LINES + .iter() + .enumerate() + .filter(|(_, l)| l.starts_with("ERROR")) + .map(|(i, _)| HighlightRange::line(i)) + .collect() +} + +/// All mutable widget state owned by the demo. +/// +/// Held by `main` for the live loop and constructed fresh by the snapshot +/// test. Keeping the fields `pub` lets the snapshot flip `modal_open` / +/// `help_open` deterministically without re-running the event loop. +pub struct DemoState { + /// Table widget state (selection cursor, scroll). + pub table: TableState, + /// Scrollable widget state with highlights pre-loaded. + pub scroll: ScrollState, + /// Whether the tab-trap modal is currently open. + pub modal_open: bool, + /// Whether the keymap help overlay is currently open. + pub help_open: bool, + /// 40-sample CPU sparkline history. + pub cpu_history: Vec, + /// 40-sample request-rate sparkline history. + pub req_history: Vec, +} + +impl DemoState { + /// Build the state used by the live binary. Sparkline histories are + /// deterministic (sin/cos of index) so `render` produces the same + /// frame regardless of when the demo is launched. + pub fn new() -> Self { + let mut scroll = ScrollState::default(); + scroll.set_highlights(&highlights()); + let table = TableState::new( + vec!["Name", "Status", "Latency"], + vec![ + vec!["alpha", "ok", "12ms"], + vec!["beta", "ok", "47ms"], + vec!["gamma", "warn", "284ms"], + vec!["delta", "fail", "—"], + ], + ); + let cpu_history: Vec = (0..40) + .map(|i| 0.5 + (i as f64 * 0.4).sin() * 0.25) + .collect(); + let req_history: Vec = (0..40) + .map(|i| 30.0 + (i as f64 * 0.6).cos() * 12.0) + .collect(); + Self { + table, + scroll, + modal_open: false, + help_open: false, + cpu_history, + req_history, + } + } +} + +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} + +/// One-frame deterministic render entry point used by snapshot tests +/// (`tests/v020_regression_panel_demo.rs`). +/// +/// Renders the full regression panel — gauges, sparklines, table, gutter +/// scrollable, anchored overlays, and (when toggled) the modal + +/// keymap-help overlays. `main` calls this from inside the live event +/// loop after consuming input, so interactive output and the snapshot are +/// pixel-identical for a given `state`. +pub fn render(ui: &mut Context, state: &mut DemoState) { + ui.publish_keymap("regression_panel", PANEL_KEYS); + + let theme = ui.theme(); + let pad = theme.spacing.xs(); + // Pull theme colors out of the Theme so closures don't need to + // re-borrow `theme` — they capture the `Color` value directly. + // Showcasing the theme system rather than hardcoding RGB demo values. + let badge_bg = theme.surface; + let badge_fg = theme.primary; + let center_dim = theme.text_dim; + + // Wrap in error_boundary so a panic in any sub-section is caught. + let cpu_history = state.cpu_history.clone(); + let req_history = state.req_history.clone(); + let table = &mut state.table; + let scroll = &mut state.scroll; + let modal_open = &mut state.modal_open; + ui.error_boundary(|ui| { + let _ = ui + .bordered(Border::Rounded) + .title("v0.20 Regression Panel") + .p(pad) + .gap(pad) + .grow(1) + .col(|ui| { + // Row 1: gauges (#224 — builder API). + let _ = ui.row(|ui| { + let _ = ui.container().fill().col(|ui| { + ui.text("Gauges (#224)").bold(); + ui.gauge(0.42).label("CPU 42%").width(28); + ui.line_gauge(0.78).label("MEM 78%").width(28); + }); + let _ = ui.container().w(36).col(|ui| { + ui.text("Sparkline + chart").bold(); + let _ = ui.sparkline(&cpu_history, 30); + let _ = ui.sparkline(&req_history, 30); + }); + }); + + // Row 2: table + log/gutter highlights (#235). + let _ = ui.row(|ui| { + let _ = ui.container().fill().col(|ui| { + ui.text("Table (j/k or ↑/↓)").bold(); + let _ = ui.table(table); + }); + let _ = ui.container().w(40).col(|ui| { + ui.text("Gutter highlights (#235) n/p").bold(); + let r = ui.scrollable_with_gutter( + scroll, + GutterOpts::line_numbers(LOG_LINES.len(), 8), + |ui, abs| { + let line = LOG_LINES[abs]; + let color = if line.contains("ERROR") { + Color::Red + } else if line.contains("WARN") { + Color::Yellow + } else { + Color::Reset + }; + ui.styled(line, slt::Style::new().fg(color)); + }, + ); + if let (Some(i), n) = (r.current_highlight, r.total_highlights) { + ui.text(format!("match {}/{}", i + 1, n)).dim(); + } + }); + }); + + ui.text("press M to open modal (tab_trap), ? for key-help, n/p navigates") + .dim(); + }); + + // overlay_anchor (#200) — 4 corners + center, all visible at once. + // Colors come from the active theme so the demo also showcases #226. + let _ = ui.overlay_at(Anchor::TopLeft, |ui| { + ui.styled(" ◤ TL ", slt::Style::new().bg(badge_bg).fg(badge_fg)); + }); + let _ = ui.overlay_at(Anchor::TopRight, |ui| { + ui.styled(" TR ◥ ", slt::Style::new().bg(badge_bg).fg(badge_fg)); + }); + let _ = ui.overlay_at(Anchor::BottomLeft, |ui| { + ui.styled(" ◣ BL ", slt::Style::new().bg(badge_bg).fg(badge_fg)); + }); + let _ = ui.overlay_at(Anchor::BottomRight, |ui| { + ui.styled(" BR ◢ ", slt::Style::new().bg(badge_bg).fg(badge_fg)); + }); + let modal_is_open = *modal_open; + let _ = ui.overlay_at(Anchor::Center, |ui| { + if modal_is_open { + // The actual modal renders below; this overlay just shows + // we did NOT confuse overlay_at with modal. + } else { + ui.text(" ⊕ ").fg(center_dim); + } + }); + + // modal + tab_trap (#225). Toggled by `M`. + if *modal_open { + let pad = ui.theme().spacing.sm(); + let _ = ui.modal_with(slt::context::ModalOptions { tab_trap: true }, |ui| { + let _ = ui + .bordered(Border::Double) + .title("Modal (#225 tab_trap)") + .p(pad) + .theme(Theme::dracula()) + .col(|ui| { + ui.text("Tab cycles within this modal only."); + ui.text(""); + let _ = ui.button("OK"); + let _ = ui.button("Cancel"); + if ui.button("Close (M)").clicked { + *modal_open = false; + } + }); + }); + } + }); + + // keymap_help_overlay (#236) renders on top so it can dismiss modal too. + ui.keymap_help_overlay(state.help_open); +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + + // publish keymap so keymap_help_overlay has something to show + slt::run_with( + slt::RunConfig::default().mouse(true), + move |ui: &mut Context| { + // Standard exit-key policy: bare `q`, Esc, and Ctrl-Q. Ctrl-C is + // intentionally NOT bound — many terminals (e.g. macOS Terminal, + // iTerm2 with default copy-shortcut) intercept Ctrl-C before it + // reaches the app. Quit only when no overlay/modal is intercepting + // input — Esc inside the modal/help-overlay must dismiss it + // first. + let any_overlay = state.modal_open || state.help_open; + if !any_overlay + && (ui.key('q') + || ui.key_code(KeyCode::Esc) + || ui.key_mod('q', KeyModifiers::CONTROL)) + { + ui.quit(); + } + // M toggles the modal. When the modal is already open, the modal + // guard inside `key()` blocks us — fall back to `raw_key_code` + // so the same key still closes the modal. + if !any_overlay && (ui.key('m') || ui.key('M')) { + state.modal_open = true; + } else if state.modal_open && ui.raw_key_code(KeyCode::Char('m')) { + state.modal_open = false; + } + // `?` toggles the key-help overlay. Same `raw_*` fallback as M: + // once the overlay is open it counts as a modal, so the plain + // `key('?')` check is blocked by the overlay guard. + if !any_overlay && ui.key('?') { + state.help_open = true; + } else if state.help_open && ui.raw_key_code(KeyCode::Char('?')) { + state.help_open = false; + } + // Esc dismisses any open overlay (modal first, then help). Both + // need raw_key_code because plain `key_code(Esc)` is blocked + // while a modal is active. + if state.modal_open && ui.raw_key_code(KeyCode::Esc) { + state.modal_open = false; + } else if state.help_open && ui.raw_key_code(KeyCode::Esc) { + state.help_open = false; + } + if !any_overlay && ui.key('n') { + state.scroll.highlight_next(); + } + if !any_overlay && ui.key('p') { + state.scroll.highlight_previous(); + } + + render(ui, &mut state); + }, + ) +} diff --git a/examples/v020_showcase.rs b/examples/v020_showcase.rs new file mode 100644 index 0000000..6bc65c1 --- /dev/null +++ b/examples/v020_showcase.rs @@ -0,0 +1,277 @@ +//! v0.20.0 showcase — every major v0.20 feature on a single screen. +//! +//! Demonstrates the **new builder APIs** introduced by the v0.20.0 API +//! consistency pass: chainable `gauge` / `line_gauge` / `breadcrumb`, +//! `GutterOpts::line_numbers`, `HighlightRange::line`, plus widthspec / +//! theme override / animate_bool / on_hover / named_focus that ship in +//! v0.20. +//! +//! Layout (80×30 minimum): +//! +//! ```text +//! ┌─ v0.20 Showcase ──────────────────────────────────────────────┐ +//! │ Home › Project › src › lib.rs ← breadcrumb │ +//! ├──────────── WidthSpec sampler ─────────────┬─ Theme subtree ──┤ +//! │ Fixed(20) ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ Hover me [btn] │ +//! │ Pct(40) ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ animate_bool fade│ +//! │ Ratio(1,3) ▒▒▒▒▒▒▒▒▒▒ │ Dracula colors │ +//! │ MinMax ▒▒▒▒▒▒▒▒▒▒▒▒ │ │ +//! │ Auto (content-sized) │ │ +//! ├─────────────── Gauges (color tiers) ────────┴──────────────────┤ +//! │ CPU ━━━━━━━━━━━━━━━━━━━━━━━━━━━───── 42% │ +//! │ Memory ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 78% │ +//! │ Disk ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 95% │ +//! ├──────── named_focus + on_hover ────┬─── gutter highlights ─────┤ +//! │ Name [ ] │ 1 │ pub fn one() │ +//! │ Email [ ] │ 2 │ ERROR unresolved │ +//! │ [Save] [Cancel] │ 3 │ pub fn two() │ +//! ├────────────────────────────────────┴───────────────────────────┤ +//! │ Tab focus · Space toggle · n/p highlight · q/Esc/^Q quit │ +//! └────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! Run with: `cargo run --example v020_showcase` +//! +//! Keys: +//! Tab / Shift-Tab — cycle focus across the registered fields/buttons +//! Type into Name/Email — text_input is interactive; type/backspace/etc. +//! Click a breadcrumb — drops trailing crumbs (notification fires) +//! Hover Save / Cancel — chained on_hover tooltip (#209) +//! Hover "Hover me" — Dracula-themed tooltip in the theme subtree +//! Space — toggle the panel_alpha animate_bool target +//! n / p — next / prev gutter highlight +//! q / Esc / Ctrl-Q — quit (Ctrl-C may be bound to copy on macOS) +//! +//! Note: bare `q` quits ONLY when no text input has focus — typed `q` goes +//! to the focused input. Click outside the input or Tab past it to make +//! `q` quit. +//! +//! Demos: #209 on_hover, #210 animate_bool, #213 breadcrumb_response, +//! #217 named_focus, #224 gauge / line_gauge, #226 theme override, +//! #235 gutter highlights, #237 WidthSpec. + +use slt::{ + Color, Constraints, Context, GutterOpts, HighlightRange, KeyCode, KeyModifiers, ScrollState, + TextInputState, Theme, ToastLevel, +}; + +const BREADCRUMB: &[&str] = &["Home", "Project", "src", "lib.rs"]; + +const SAMPLE_LINES: &[&str] = &[ + "pub fn one() -> u32 { 1 }", + "ERROR: unresolved import `super::missing`", + "pub fn two() -> u32 { 2 }", + "WARN: unused variable `x`", + "pub fn three() -> u32 { 3 }", + "INFO: build complete in 1.42s", + "pub fn four() -> u32 { 4 }", +]; + +fn highlights() -> Vec { + SAMPLE_LINES + .iter() + .enumerate() + .filter(|(_, line)| line.starts_with("ERROR") || line.starts_with("WARN")) + .map(|(i, _)| HighlightRange::line(i)) + .collect() +} + +fn main() -> std::io::Result<()> { + let mut name = TextInputState::new(); + let mut email = TextInputState::new(); + let mut panel_open = true; + let mut scroll = ScrollState::default(); + let hl = highlights(); + scroll.set_highlights(&hl); + + slt::run_with(slt::RunConfig::default().mouse(true), |ui: &mut Context| { + // Render FIRST so the focused text_input can claim keys (q, space, + // n, p, etc.) before our global handlers see them. text_input + // consumes character keys when focused; this ordering means the + // global `key('q')` quit can never strand the user mid-typing. + render(ui, &mut name, &mut email, panel_open, &mut scroll); + + // Standard exit-key policy: bare `q`, Esc, and Ctrl-Q. Ctrl-C is + // intentionally NOT bound — many terminals (e.g. macOS Terminal, + // iTerm2 with default copy-shortcut) intercept Ctrl-C before it + // reaches the app. + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + if ui.key(' ') { + panel_open = !panel_open; + } + if ui.key('n') { + scroll.highlight_next(); + } + if ui.key('p') { + scroll.highlight_previous(); + } + }) +} + +/// Render one full showcase frame. Public so the snapshot test can pin it. +pub fn render( + ui: &mut Context, + name: &mut TextInputState, + email: &mut TextInputState, + panel_open: bool, + scroll: &mut ScrollState, +) { + let theme = ui.theme(); + let pad = theme.spacing.xs(); + let panel_alpha = ui.animate_bool("showcase::panel", panel_open); + + let _ = ui + .bordered(slt::Border::Rounded) + .title("v0.20 Showcase") + .p(pad) + .gap(pad) + .grow(1) + .col(|ui| { + // Row 1 — breadcrumb (#213, builder API). + // The new chainable form: `.separator(s).color(c)`. The Drop + // renders without us having to capture the response. + let crumb = ui + .breadcrumb(BREADCRUMB) + .separator(" › ") + .color(Color::Cyan) + .show(); + if let Some(idx) = crumb.clicked_segment { + ui.notify(&format!("breadcrumb: clicked {idx}"), ToastLevel::Info); + } + + // Row 2 — WidthSpec sampler (#237) | theme subtree (#226). + let _ = ui.row(|ui| { + let _ = ui.container().fill().gap(pad).col(|ui| { + ui.text("WidthSpec sampler (#237)").bold(); + spec_row(ui, "Fixed(20)", Constraints::default().w(20)); + spec_row(ui, "Pct(40)", Constraints::default().w_pct(40)); + spec_row(ui, "Ratio(1,3)", Constraints::default().w_ratio(1, 3)); + spec_row( + ui, + "MinMax(10,30)", + Constraints::default().min_w(10).max_w(30), + ); + spec_row(ui, "Auto", Constraints::default()); + }); + + // Theme subtree (#226). Switching theme on the builder + // affects every widget rendered inside; the original theme + // is restored automatically on exit (panic-safe). + let _ = ui + .container() + .w(28) + .theme(Theme::dracula()) + .border(slt::Border::Single) + .p(pad) + .gap(pad) + .col(|ui| { + ui.text("Theme: Dracula (#226)").bold(); + let _ = ui + .button("Hover me") + .on_hover(ui, "on_hover tooltip — chained Response (#209)"); + ui.text(format!("animate_bool α = {panel_alpha:.2}")).dim(); + if panel_alpha > 0.0 { + let alpha_color = match panel_alpha { + a if a > 0.66 => Color::Green, + a if a > 0.33 => Color::Yellow, + _ => Color::DarkGray, + }; + ui.text(format!("Fading panel ({:.0}%)", panel_alpha * 100.0)) + .fg(alpha_color); + } + }); + }); + + // Row 3 — gauges (#224, builder API). + // Chainable: `ui.gauge(ratio).label(...).width(...)`, + // `ui.line_gauge(ratio).label(...).width(...).filled(...)`. + let _ = ui.bordered(slt::Border::Single).p(pad).gap(pad).col(|ui| { + ui.text("Gauges (#224 — color tiers green / yellow / red)") + .bold(); + gauge_row(ui, "CPU ", 0.42); + gauge_row(ui, "Memory", 0.78); + gauge_row(ui, "Disk ", 0.95); + }); + + // Row 4 — named_focus (#217) | gutter highlights (#235). + let _ = ui.row(|ui| { + let _ = ui.container().fill().gap(pad).col(|ui| { + ui.text("named_focus + on_hover (#217 + #209)").bold(); + ui.register_focusable_named("name"); + let _ = ui.text_input(name); + ui.register_focusable_named("email"); + let _ = ui.text_input(email); + let _ = ui.row(|ui| { + let save = ui.button("Save").on_hover(ui, "save form (Enter on Save)"); + if save.clicked { + ui.notify("saved", ToastLevel::Success); + } + let _ = ui.button("Cancel").on_hover(ui, "discard changes"); + }); + if let Some(name) = ui.focused_name() { + ui.text(format!("focused: {name}")).dim(); + } + }); + + let _ = ui.container().w(34).gap(pad).col(|ui| { + ui.text("Gutter highlights (#235) n/p navigates").bold(); + // GutterOpts::new takes the labeling closure; for the + // 90% line-number case use `GutterOpts::line_numbers`. + let r = ui.scrollable_with_gutter( + scroll, + GutterOpts::line_numbers(SAMPLE_LINES.len(), SAMPLE_LINES.len() as u32), + |ui, i| { + let line = SAMPLE_LINES[i]; + let style = if line.starts_with("ERROR") { + Color::LightRed + } else if line.starts_with("WARN") { + Color::Yellow + } else { + Color::White + }; + ui.styled(line, slt::Style::new().fg(style)); + }, + ); + if let (Some(i), total) = (r.current_highlight, r.total_highlights) { + ui.text(format!("match {} of {}", i + 1, total)).dim(); + } + }); + }); + + // Row 5 — footer (key bindings). Mirrors the doc-comment "Keys" + // block; keep these two in sync when adding bindings. + ui.text( + "Tab/⇧Tab focus · type into inputs · Space toggle · n/p highlights · q/Esc/^Q quit", + ) + .dim(); + }); +} + +fn spec_row(ui: &mut Context, label: &str, constraints: Constraints) { + let _ = ui.row(|ui| { + let _ = ui.container().w(14).col(|ui| { + ui.text(label); + }); + let _ = ui + .bordered(slt::Border::Single) + .constraints(constraints) + .h(3) + .col(|ui| { + // f64 ratio. New builder. + ui.gauge(0.55); + }); + }); +} + +fn gauge_row(ui: &mut Context, label: &str, ratio: f64) { + let _ = ui.row(|ui| { + let _ = ui.container().w(8).col(|ui| { + ui.text(label); + }); + // Builder API: chained `.label().width().filled()`. + ui.line_gauge(ratio).width(48).filled('━'); + ui.text(format!("{:>3.0}%", ratio * 100.0)).bold(); + }); +} diff --git a/examples/v020_spacing_scale.rs b/examples/v020_spacing_scale.rs new file mode 100644 index 0000000..f78e5f9 --- /dev/null +++ b/examples/v020_spacing_scale.rs @@ -0,0 +1,86 @@ +//! v0.20.0 spacing-scale demo — three density presets side-by-side, all +//! widgets identical, only `theme.spacing` differs. +//! +//! Demonstrates: #227. +//! +//! Run: `cargo run --example v020_spacing_scale` +//! +//! Keys: +//! q / Esc / Ctrl-Q — quit +//! +//! Layout: +//! ┌─────────── outer frame ─────────────────────────────────┐ +//! │ ┌── compact ──┐ ┌── comfortable ──┐ ┌── spacious ──┐ │ +//! │ │ help row │ │ help row │ │ help row │ │ +//! │ │ [Click me] │ │ [Click me] │ │ [Click me] │ │ +//! │ │ code block │ │ code block │ │ code block │ │ +//! │ └─────────────┘ └─────────────────┘ └──────────────┘ │ +//! └─────────────────────────────────────────────────────────┘ + +use slt::{Border, Context, KeyCode, KeyModifiers, RunConfig, Theme}; + +/// Shared body. The outer frame uses the OUTER theme's spacing scale; +/// each panel re-establishes its own spacing via `container().theme(...)`. +fn body(ui: &mut Context) { + // Outer-frame spacing comes from the active (default) theme. The inner + // panels each pick up their own scale via the per-subtree theme override. + let sp = ui.spacing(); + let _ = ui + .bordered(Border::Rounded) + .title("SLT v0.20: Density presets") + .p(sp.xs()) + .grow(1) + .col(|ui| { + ui.text("Same widgets, three Theme presets — note the widening padding.") + .dim(); + ui.text("compact = base 1, comfortable = base 2, spacious = base 3.") + .dim(); + + let _ = ui.row_gap(sp.sm(), |ui| { + panel(ui, "compact", Theme::compact()); + panel(ui, "comfortable", Theme::comfortable()); + panel(ui, "spacious", Theme::spacious()); + }); + }); +} + +/// Per-panel render. Padding/gap inside the panel resolve against the +/// PANEL's theme — not the outer frame's — so the visual density step +/// between panels is proportional to `Theme::*::spacing.base`. +fn panel(ui: &mut Context, label: &str, theme: Theme) { + // Capture the panel's spacing BEFORE entering the closure so the + // padding helpers below see the override theme, not the outer one. + let inner_sp = theme.spacing; + let _ = ui + .container() + .theme(theme) + .border(Border::Rounded) + .title(label) + .p(inner_sp.xs()) + .grow(1) + .col(|ui| { + let _ = ui.help(&[("Tab", "next"), ("Enter", "ok"), ("Esc", "cancel")]); + ui.text(""); + let _ = ui.button("Click me"); + ui.text(""); + let _ = ui.code_block("fn main() {\n println!(\"hi\");\n}"); + }); +} + +/// One-frame deterministic render entry point used by snapshot tests +/// (`tests/v020_theme_modal_demos.rs`). Equivalent to the live loop's +/// view with no events processed. +pub fn render(ui: &mut Context) { + body(ui); +} + +fn main() -> std::io::Result<()> { + slt::run_with(RunConfig::default().mouse(true), |ui: &mut Context| { + // macOS Ctrl-C is bound to copy in many terminals — bind quit to plain + // `q`, Esc, and Ctrl-Q so the demo is escape-able under every setup. + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + body(ui); + }) +} diff --git a/examples/v020_split_pane.rs b/examples/v020_split_pane.rs new file mode 100644 index 0000000..583a286 --- /dev/null +++ b/examples/v020_split_pane.rs @@ -0,0 +1,115 @@ +//! v0.20.0 split_pane / vsplit_pane demo — draggable two-pane container. +//! +//! Demonstrates: #223 (split_pane / vsplit_pane builder, mouse drag, focusable handle). +//! +//! Run: `cargo run --example v020_split_pane` +//! +//! Keys: +//! Tab / Shift-Tab — focus the split handle +//! Left / Right — adjust ratio when horizontal handle is focused +//! Up / Down — adjust ratio when vertical handle is focused +//! Mouse drag (handle) — adjust ratio by dragging `│` or `─` +//! v — toggle horizontal / vertical orientation +//! q / Esc / Ctrl-Q — quit +//! +//! Layout: +//! ┌── horizontal ──────────────────────────┐ +//! │ LEFT PANE │ RIGHT PANE │ +//! │ │ │ +//! │ ↑ drag this handle │ +//! └────────────────────────────────────────┘ + +use slt::{Border, Color, Context, KeyCode, KeyModifiers, RunConfig, SplitPaneState}; + +fn main() -> std::io::Result<()> { + let mut state = DemoState::new(); + + slt::run_with(RunConfig::default().mouse(true), |ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + if ui.key('v') { + state.vertical = !state.vertical; + } + render(ui, &mut state); + }) +} + +/// Per-demo state captured in one place so `render` can be called from both +/// the runtime loop and the snapshot test in `tests/v020_widgets_demos.rs`. +pub struct DemoState { + /// Backing state for the split widget (ratio + drag flag). + pub split: SplitPaneState, + /// `true` when the demo is in vertical orientation (`vsplit_pane`). + pub vertical: bool, +} + +impl DemoState { + /// Construct with the same defaults the live demo opens with. + pub fn new() -> Self { + Self { + split: SplitPaneState::new(0.4), + vertical: false, + } + } +} + +impl Default for DemoState { + fn default() -> Self { + Self::new() + } +} + +/// Render one frame. Stable signature for snapshot tests. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let sp = ui.spacing(); + let title = if state.vertical { + "v0.20.0 #223: vsplit_pane (vertical)" + } else { + "v0.20.0 #223: split_pane (horizontal)" + }; + + let _ = ui + .bordered(Border::Rounded) + .title(title) + .p(sp.xs()) + .gap(sp.xs()) + .grow(1) + .col(|ui| { + ui.text("Tab focuses the handle; arrows adjust the ratio; mouse-drag the handle. 'v' toggles orientation.") + .fg(Color::Cyan); + + let r = if state.vertical { + ui.vsplit_pane( + &mut state.split, + |ui| { + ui.text("TOP PANE").bold(); + ui.text("Drag the handle below or arrow-key it."); + }, + |ui| { + ui.text("BOTTOM PANE").bold(); + ui.text("Status: ratio updates live."); + }, + ) + } else { + ui.split_pane( + &mut state.split, + |ui| { + ui.text("LEFT PANE").bold(); + ui.text("Drag the handle right of this pane."); + }, + |ui| { + ui.text("RIGHT PANE").bold(); + ui.text("Or use the arrow keys when the handle is focused."); + }, + ) + }; + + ui.text(format!( + "ratio = {:.2} drag_active = {} dragging = {}", + r.ratio, r.drag_active, state.split.dragging + )) + .dim(); + ui.text("q / Esc / Ctrl-Q quits.").dim(); + }); +} diff --git a/examples/v020_static_log.rs b/examples/v020_static_log.rs new file mode 100644 index 0000000..7640da5 --- /dev/null +++ b/examples/v020_static_log.rs @@ -0,0 +1,85 @@ +//! v0.20.0 static-log demo — append-only scrollback above an inline TUI. +//! +//! Demonstrates: #233. +//! +//! Run: `cargo run --example v020_static_log` +//! +//! Keys: +//! Space / Enter — bump the counter +//! q / Esc / Ctrl-Q — quit +//! +//! Note: this demo intentionally avoids Ctrl-C as a quit key. macOS +//! terminals (Ghostty, iTerm2, Terminal.app) bind Ctrl-C to Copy by +//! default, so the keystroke never reaches the app — Ctrl-Q is the +//! portable alternative. +//! +//! Layout: +//! ┌──────────────────────────────────┐ +//! │ scrollback (println-like, frozen)│ +//! │ [tick] counter reached 5 │ +//! │ [tick] counter reached 10 │ +//! ├──────────────────────────────────┤ ← inline frame redraws here +//! │ Counter: N │ +//! │ Space/Enter: bump counter | q… │ +//! └──────────────────────────────────┘ + +use slt::{Color, Context, KeyCode, KeyModifiers, StaticOutput, Style}; + +/// Counter increment that triggers a scrollback log entry. Five matches the +/// snapshot fixture below — keeping it as a constant avoids a magic number +/// drifting between `render` and `main` if either is later edited. +const LOG_EVERY: u32 = 5; + +/// Shared inline-area body. Used by both `render` (one-frame snapshot) and +/// `main` (live loop) so the visible UI stays identical across both paths. +fn inline_body(ui: &mut Context, count: u32) { + let _ = ui.col(|ui| { + ui.styled( + format!("Counter: {count}"), + Style::new().bold().fg(Color::Cyan), + ); + ui.text("Space/Enter: bump counter | q / Esc / Ctrl-Q: quit"); + ui.styled( + "Lines logged to scrollback every 5 ticks via ui.static_log()", + Style::new().dim(), + ); + }); +} + +/// One-frame deterministic render entry point used by snapshot tests +/// (`tests/v020_lib_demos.rs`). Mirrors the state right after the fifth +/// counter bump, when the next scrollback line was just queued. +pub fn render(ui: &mut Context) { + let count: u32 = LOG_EVERY; + // Queue a scrollback line so the snapshot also exercises the + // static_log code path, not just the inline body. + ui.static_log(format!("[tick] counter reached {count}")); + inline_body(ui, count); +} + +fn main() -> std::io::Result<()> { + let mut output = StaticOutput::new(); + output.println("[demo] starting v020_static_log — try pressing Space"); + + let mut count: u32 = 0; + let mut last_logged: u32 = 0; + + slt::run_static(&mut output, 4, |ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + if ui.key(' ') || ui.key_code(KeyCode::Enter) { + count = count.saturating_add(1); + } + + // Throttle so a held key cannot flood scrollback faster than the + // user can read it. + if count != last_logged && count % LOG_EVERY == 0 { + ui.static_log(format!("[tick] counter reached {count}")); + last_logged = count; + } + + inline_body(ui, count); + })?; + Ok(()) +} diff --git a/examples/v020_test_utils.rs b/examples/v020_test_utils.rs new file mode 100644 index 0000000..a22912e --- /dev/null +++ b/examples/v020_test_utils.rs @@ -0,0 +1,150 @@ +//! v0.20.0 test-utils demo — exercises the four new test-harness APIs. +//! +//! Demonstrates: #229 (record_frames), #230 (sequence + type_string), +//! #231 (Buffer::snapshot_format), #232 (negative assertions). +//! +//! Non-interactive (stdout report) — runs deterministically and prints +//! captured frames + a styled snapshot so the demo can be eyeballed without +//! a live terminal. `tests/v020_test_utils_demo.rs` calls `render_demo` and +//! `render_step` directly to pin regression coverage. +//! +//! Run: `cargo run --example v020_test_utils` + +use slt::{Color, Context, KeyCode, Style, TestBackend}; + +// Buffer dimensions for the deterministic showcase. Picked once so the demo +// frames, the snapshot tests, and any future tooling all line up. +const DEMO_W: u32 = 30; +const DEMO_H: u32 = 5; + +// Slightly wider buffer for the multi-step `record_frames` and `sequence` +// recordings — keeps the rendered text readable without wrapping. +const STEPS_W: u32 = 40; +const STEPS_H: u32 = 6; + +/// Render the static layout used by the demo. +/// +/// Single source of truth — `main()` and the snapshot tests both call this +/// so the example and its regression coverage cannot drift. +pub fn render_demo(ui: &mut Context) { + let _ = ui.col(|ui| { + ui.text("v0.20 test-utils showcase").fg(Color::Cyan).bold(); + ui.text("--------------------------").fg(Color::Cyan); + ui.text("normal").fg(Color::White); + ui.text("warning").fg(Color::Yellow).italic(); + ui.text("error").fg(Color::Red).bold(); + }); +} + +/// Render a single animation step — used to demo `record_frames`. +pub fn render_step(ui: &mut Context, label: &str, n: usize) { + let _ = ui.col(|ui| { + ui.text(format!("step {n}: {label}")).bold(); + ui.text("---------------------").fg(Color::DarkGray); + ui.text(format!("count = {n}")).fg(Color::Green); + }); +} + +fn main() { + println!("=== SLT v0.20.0 test-utils demo ==="); + println!(); + + demo_record_frames(); + println!(); + + demo_sequence_and_type_string(); + println!(); + + demo_snapshot_format(); + println!(); + + demo_negative_assertions(); +} + +// #229 — record_frames captures a `FrameRecord` per render so tests can +// inspect the entire animation history rather than a single end-state. +fn demo_record_frames() { + let mut tb = TestBackend::new(STEPS_W, STEPS_H).record_frames(); + for n in 0..3 { + tb.render(|ui| render_step(ui, "tick", n)); + } + + println!("== #229 record_frames =="); + println!("captured {} frames", tb.frames().len()); + for (i, frame) in tb.frames().iter().enumerate() { + println!("--- frame {i} ---"); + println!("{}", frame.to_string_trimmed()); + } +} + +// #230 — `sequence` chains tick/key/type_string steps and threads frame +// state automatically; `type_string` at the backend level fires one render +// per character so each typed key is observable. +fn demo_sequence_and_type_string() { + let mut seq_tb = TestBackend::new(STEPS_W, 4).record_frames(); + seq_tb + .sequence() + .tick(|ui| { + ui.text("ready").fg(Color::Green); + }) + .key(KeyCode::Tab, |ui| { + ui.text("after tab").fg(Color::Yellow); + }) + .type_string("hi", |ui| { + ui.text("typed: hi").fg(Color::Cyan); + }) + .run(); + + println!("== #230 sequence + type_string =="); + println!("frames captured by sequence(): {}", seq_tb.frames().len()); + seq_tb.assert_contains("typed: hi"); + + let mut typing_tb = TestBackend::new(STEPS_W, 2).record_frames(); + typing_tb.type_string("abc", render_demo); + println!( + "frames captured by type_string(\"abc\"): {}", + typing_tb.frames().len() + ); +} + +// #231 — `Buffer::snapshot_format` produces a stable styled-snapshot string +// (named colors, hex RGB, canonical modifier order) suitable for `insta` +// regression tests without committing raw escape sequences. +fn demo_snapshot_format() { + // Truncate the styled-snapshot preview so the demo stays readable; the + // test harness exercises the full string elsewhere. + const PREVIEW_BYTES: usize = 500; + + let mut tb = TestBackend::new(DEMO_W, DEMO_H); + tb.render(render_demo); + let snapshot = tb.buffer().snapshot_format(); + + println!("== #231 Buffer::snapshot_format =="); + let preview: String = snapshot.chars().take(PREVIEW_BYTES).collect(); + println!("{preview}"); +} + +// #232 — negative assertions surface "this should be absent" expectations +// directly, instead of forcing tests to scrape `to_string_trimmed()` and +// invert the match by hand. +fn demo_negative_assertions() { + let mut tb = TestBackend::new(DEMO_W, DEMO_H); + tb.render(render_demo); + tb.assert_not_contains("crash"); + + // `assert_empty_line` needs a row that is genuinely blank; render a + // single-line buffer so the trailing rows are guaranteed empty. + let mut blank_tb = TestBackend::new(20, 3); + blank_tb.render(|ui| { + ui.text("only row 0").fg(Color::White); + }); + blank_tb.assert_empty_line(2); + + let bold_cyan = Style::new().fg(Color::Cyan).bold(); + tb.assert_style_at(0, 0, bold_cyan); + + println!("== #232 negative assertions =="); + println!("assert_not_contains(\"crash\") OK"); + println!("assert_empty_line(2) on blank row OK"); + println!("assert_style_at(0, 0, cyan|bold) OK"); +} diff --git a/examples/v020_theme_subtree.rs b/examples/v020_theme_subtree.rs new file mode 100644 index 0000000..3a15a8a --- /dev/null +++ b/examples/v020_theme_subtree.rs @@ -0,0 +1,97 @@ +//! v0.20.0 theme-subtree demo — per-subtree theme override. +//! +//! Demonstrates: #226 +//! +//! Four side-by-side panels render the same widgets under four different +//! themes. Each panel uses `ContainerBuilder::theme(...)` to scope the +//! theme change to its own subtree — the outer container keeps its +//! parent theme, so nothing leaks across panel boundaries. +//! +//! Run: `cargo run --example v020_theme_subtree` +//! +//! Keys: +//! q / Esc / Ctrl-Q — quit +//! +//! Layout: +//! ┌── SLT v0.20: Per-subtree theme override ──────────────────┐ +//! │ ┌── Dark ──┐ ┌── Light ──┐ ┌── Dracula ──┐ ┌── Nord ──┐ │ +//! │ │ body… │ │ body… │ │ body… │ │ body… │ │ +//! │ │ [Press] │ │ [Press] │ │ [Press] │ │ [Press] │ │ +//! │ │ alert │ │ alert │ │ alert │ │ alert │ │ +//! │ │ code │ │ code │ │ code │ │ code │ │ +//! │ └──────────┘ └───────────┘ └─────────────┘ └──────────┘ │ +//! └────────────────────────────────────────────────────────────┘ + +use slt::widgets::AlertLevel; +use slt::{Border, Context, KeyCode, KeyModifiers, RunConfig, Theme}; + +/// Theme bench: label + theme constructor pairs rendered in panel order. +/// Centralised so the layout and the test render the same set. +pub fn theme_bench() -> [(&'static str, Theme); 4] { + [ + ("Dark (default)", Theme::dark()), + ("Light", Theme::light()), + ("Dracula", Theme::dracula()), + ("Nord", Theme::nord()), + ] +} + +fn main() -> std::io::Result<()> { + slt::run_with(RunConfig::default().mouse(true), render) +} + +/// Render one frame of the theme-subtree demo. +/// +/// Public so snapshot tests can compare per-theme renders against fixed +/// markers without re-deriving the panel layout in each test. +pub fn render(ui: &mut Context) { + // macOS Ctrl-C is bound to copy in many terminals — bind quit to plain `q`, + // Esc, and Ctrl-Q so the demo is escape-able under every common setup. + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + let pad = ui.spacing().xs(); + let panel_gap = ui.spacing().sm(); + + let _ = ui + .bordered(Border::Rounded) + .title("SLT v0.20: Per-subtree theme override") + .p(pad) + .grow(1) + .col(|ui| { + ui.text("Each panel below uses a different Theme via container().theme(...).") + .dim(); + ui.text("Outer scope keeps its parent theme — nothing leaks across panels.") + .dim(); + + let _ = ui.row_gap(panel_gap, |ui| { + for (label, theme) in theme_bench() { + panel(ui, label, theme); + } + }); + }); +} + +/// Render a single themed panel. All widgets inside the closure resolve +/// their colours against `theme`, not the parent's theme — that's the +/// invariant #226 added to `ContainerBuilder`. +fn panel(ui: &mut Context, label: &str, theme: Theme) { + let pad = ui.spacing().xs(); + let _ = ui + .container() + .theme(theme) + .border(Border::Rounded) + .p(pad) + .grow(1) + .col(|ui| { + ui.text(label).bold(); + ui.text("body text").dim(); + let _ = ui.button("Press me"); + // Two alert levels exercise distinct theme tokens (info vs warning). + let _ = ui.alert("info banner", AlertLevel::Info); + let _ = ui.alert("warning", AlertLevel::Warning); + let _ = ui.code_block("let x = 1;"); + }); +} diff --git a/examples/v020_tour.rs b/examples/v020_tour.rs new file mode 100644 index 0000000..4458318 --- /dev/null +++ b/examples/v020_tour.rs @@ -0,0 +1,349 @@ +//! v0.20.0 Tour — every interactive v0.20 demo, grouped by category and +//! switched via SLT's own `Tabs` widget. Renders the meta-view: each tab +//! reuses the existing `pub fn render(...)` from a single-feature demo. +//! +//! Run: `cargo run --example v020_tour` +//! +//! Keys: +//! Left / Right — switch tab (when the tabs bar is focused; Tab to focus) +//! Tab / Shift-Tab — cycle focus (tabs bar → demo) +//! q / Esc / Ctrl-Q — quit +//! +//! Categories: +//! 1. Intro — overview + navigation help +//! 2. Hooks — use_state_keyed + use_effect + named_focus +//! 3. Theme — theme_subtree + spacing_scale +//! 4. Modal — modal_trap +//! 5. Layout — split_pane + widthspec +//! 6. Widgets — gauge + progress + breadcrumb + gutter +//! 7. Util — ctrl_c_passthrough + keymap_help + static_log + dx_shortcuts + +use slt::widgets::{ScrollState, TabsState}; +use slt::{Border, Color, Context, KeyModifiers, RunConfig}; + +// Each `#[path = ...] mod ...;` re-includes a single-feature demo so the +// tour can call its `pub fn render(...)` directly. The demos' own `fn +// main()` and helpers are unused in this build, hence the blanket +// `#[allow(dead_code)]` on every include. +#[allow(dead_code)] +#[path = "v020_breadcrumb_response.rs"] +mod breadcrumb_response; +#[allow(dead_code)] +#[path = "v020_ctrl_c_passthrough.rs"] +mod ctrl_c_passthrough; +#[allow(dead_code)] +#[path = "v020_dx_shortcuts.rs"] +mod dx_shortcuts; +#[allow(dead_code)] +#[path = "v020_gauge.rs"] +mod gauge_demo; +#[allow(dead_code)] +#[path = "v020_gutter_highlights.rs"] +mod gutter_highlights; +#[allow(dead_code)] +#[path = "v020_keymap_help.rs"] +mod keymap_help; +#[allow(dead_code)] +#[path = "v020_modal_trap.rs"] +mod modal_trap; +#[allow(dead_code)] +#[path = "v020_named_focus.rs"] +mod named_focus; +#[allow(dead_code)] +#[path = "v020_progress_response.rs"] +mod progress_response; +#[allow(dead_code)] +#[path = "v020_spacing_scale.rs"] +mod spacing_scale; +#[allow(dead_code)] +#[path = "v020_split_pane.rs"] +mod split_pane_demo; +#[allow(dead_code)] +#[path = "v020_static_log.rs"] +mod static_log; +#[allow(dead_code)] +#[path = "v020_theme_subtree.rs"] +mod theme_subtree; +#[allow(dead_code)] +#[path = "v020_use_effect.rs"] +mod use_effect; +#[allow(dead_code)] +#[path = "v020_use_state_keyed.rs"] +mod use_state_keyed; +#[allow(dead_code)] +#[path = "v020_widthspec.rs"] +mod widthspec; + +/// Aggregated state for every embedded demo. Each field is the +/// `DemoState` from the corresponding feature demo. +struct TourState { + tabs: TabsState, + /// Scroll offset for the active tab body. Mouse-wheel events outside any + /// inner scrollable scroll the whole tab, so tall tabs (notably Hooks + /// with three stacked demos) stay reachable on small terminals. + tab_scroll: ScrollState, + use_state_keyed: use_state_keyed::DemoState, + use_effect: use_effect::DemoState, + named_focus: named_focus::DemoState, + modal: modal_trap::State, + split_pane: split_pane_demo::DemoState, + gauge: gauge_demo::DemoState, + progress: progress_response::DemoState, + gutter: gutter_highlights::DemoState, + breadcrumb: breadcrumb_response::DemoState, + keymap_help: keymap_help::DemoState, + ctrl_c_passthrough: ctrl_c_passthrough::DemoState, + dx_shortcuts: dx_shortcuts::DemoState, +} + +impl Default for TourState { + fn default() -> Self { + Self { + tabs: TabsState::new(vec![ + "Intro", "Hooks", "Theme", "Density", "Modal", "Layout", "Widgets", "Help", "Log", + "Ctrl", "DX", + ]), + tab_scroll: ScrollState::new(), + use_state_keyed: Default::default(), + use_effect: Default::default(), + named_focus: Default::default(), + modal: Default::default(), + split_pane: Default::default(), + gauge: Default::default(), + progress: Default::default(), + gutter: Default::default(), + breadcrumb: Default::default(), + keymap_help: Default::default(), + ctrl_c_passthrough: Default::default(), + dx_shortcuts: Default::default(), + } + } +} + +fn main() -> std::io::Result<()> { + let mut state = TourState::default(); + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + // Tour-level quit: Ctrl-Q only at the top of the frame. We + // intentionally do NOT consume Esc here — embedded demos like + // `modal_trap` rely on Esc to dismiss their own modals, and any + // demo that wants Esc-to-quit handles it inside its own + // `render(...)`. + if ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("SLT v0.20 Tour: every feature, one demo") + .p(pad) + .grow(1) + .col(|ui| { + let _ = ui.tabs(&mut state.tabs); + ui.separator(); + + // Wrap the tab body in a vertical scrollable so tabs that + // stack multiple sub-demos (notably Hooks: three demos in + // one tab) stay reachable on small terminals. Mouse wheel + // outside any inner scroll region scrolls the tab body; + // when the body fits the viewport this is a no-op. + let _ = ui.scrollable(&mut state.tab_scroll).grow(1).col(|ui| { + match state.tabs.selected { + 0 => render_intro(ui), + 1 => render_hooks(ui, &mut state), + 2 => render_theme(ui), + 3 => render_density(ui), + 4 => render_modal(ui, &mut state), + 5 => render_layout(ui, &mut state), + 6 => render_widgets(ui, &mut state), + 7 => keymap_help::render(ui, &mut state.keymap_help), + 8 => render_log(ui), + 9 => ctrl_c_passthrough::render(ui, &mut state.ctrl_c_passthrough), + 10 => dx_shortcuts::render(ui, &mut state.dx_shortcuts), + _ => {} + } + }); + }); + + // 'q' is checked AFTER demos render so a focused text_input + // (e.g. on the Hooks tab) consumes it as text first. + if ui.key('q') { + ui.quit(); + } + }) +} + +/// Tab 1: Intro. Pure overview — no embedded demo. +fn render_intro(ui: &mut Context) { + let _ = ui.col(|ui| { + let pad = ui.spacing().xs(); + ui.text("Welcome to the v0.20 tour.").bold(); + ui.text(""); + ui.text("Each tab embeds the corresponding single-feature demo from").dim(); + ui.text("examples/v020_*.rs without modification — what you see in").dim(); + ui.text("a tab is exactly the standalone demo's render path.").dim(); + ui.text(""); + let _ = ui + .bordered(Border::Single) + .title("v0.20 features at a glance") + .p(pad) + .col(|ui| { + row_pair(ui, "Hooks", "use_state_keyed · use_effect · register_focusable_named"); + row_pair(ui, "Theme", "per-subtree theme override (Dark/Light/Dracula/Nord)"); + row_pair(ui, "Density", "compact / comfortable / spacious spacing presets"); + row_pair(ui, "Modal", "tab-trap focus locking inside modals"); + row_pair(ui, "Layout", "split_pane / vsplit_pane · WidthSpec / HeightSpec"); + row_pair(ui, "Widgets", "gauge / line_gauge builders · progress / spinner Response · breadcrumb · gutter highlights"); + row_pair(ui, "Help", "keymap_help — `?` opens a centered keyboard-shortcut overlay"); + row_pair(ui, "Log", "static_log — append once-only scrollback lines without re-rendering"); + row_pair(ui, "Ctrl", "ctrl_c passthrough (Ctrl-G alternative on macOS where Ctrl-C is copy)"); + row_pair(ui, "DX", "provide / use_context / use_state_named / with_if shortcuts"); + }); + ui.text(""); + ui.text("Navigation: Left/Right arrows switch tabs (Tab to focus the bar). q / Esc / Ctrl-Q quits.") + .fg(Color::Cyan); + }); +} + +/// One label/description row for the intro feature list. +fn row_pair(ui: &mut Context, label: &str, desc: &str) { + let _ = ui.row_gap(1, |ui| { + ui.text(format!("{label:<8}")).bold().fg(Color::Cyan); + ui.text(desc).dim(); + }); +} + +/// Tab 2: Hooks. Three demos in one tab — keyed counters | effect log, +/// with named_focus inputs across the bottom. +fn render_hooks(ui: &mut Context, state: &mut TourState) { + let _ = ui.col(|ui| { + let _ = ui.row(|ui| { + let _ = ui.container().fill().col(|ui| { + use_state_keyed::render(ui, &mut state.use_state_keyed); + }); + let _ = ui.container().fill().col(|ui| { + use_effect::render(ui, &mut state.use_effect); + }); + }); + let _ = ui.container().fill().col(|ui| { + named_focus::render(ui, &mut state.named_focus); + }); + }); +} + +/// Tab 3: Theme. Same widgets rendered with four different `Theme` +/// presets via `container().theme(...)`. The point is that the inner +/// panels override the colour palette without leaking back to the +/// outer scope. +fn render_theme(ui: &mut Context) { + theme_subtree::render(ui); +} + +/// Tab 4: Density. Same widgets rendered with three `Theme.spacing` +/// presets (compact/comfortable/spacious). The point is the shared +/// `theme.spacing` scale — padding, gap, and margin all widen +/// proportionally without per-widget overrides. +fn render_density(ui: &mut Context) { + spacing_scale::render(ui); +} + +/// Tab 4: Modal. The embedded demo handles M-to-open and Esc-to-dismiss +/// internally. We pass a persistent `state.modal` so clicks on Yes/No +/// settle and don't get reset next frame. +fn render_modal(ui: &mut Context, state: &mut TourState) { + modal_trap::render(ui, &mut state.modal); +} + +/// Tab 5: Layout. split_pane on the left half, widthspec on the right. +fn render_layout(ui: &mut Context, state: &mut TourState) { + let _ = ui.row(|ui| { + let _ = ui.container().fill().col(|ui| { + split_pane_demo::render(ui, &mut state.split_pane); + }); + let _ = ui.container().fill().col(|ui| { + widthspec::render(ui); + }); + }); +} + +/// Tab 6: Widgets. 2x2 grid — gauge, progress, breadcrumb, gutter. +fn render_widgets(ui: &mut Context, state: &mut TourState) { + let _ = ui.col(|ui| { + let _ = ui.row(|ui| { + let _ = ui.container().fill().col(|ui| { + gauge_demo::render(ui, &mut state.gauge); + }); + let _ = ui.container().fill().col(|ui| { + progress_response::render(ui, &mut state.progress); + }); + }); + let _ = ui.row(|ui| { + let _ = ui.container().fill().col(|ui| { + breadcrumb_response::render(ui, &mut state.breadcrumb); + }); + let _ = ui.container().fill().col(|ui| { + gutter_highlights::render(ui, &mut state.gutter); + }); + }); + }); +} + +// Tabs 8-11 dispatch directly to the embedded demo's `render` (see the +// match arms in `main`). They were originally combined under a single +// "Util" tab as a 2x2 grid, but each demo claims overlay space and +// owns its own keymap — `keymap_help` opens a centered overlay that +// covers neighbouring cells, `static_log` appends to the terminal +// scrollback unboundedly, `dx_shortcuts` and `ctrl_c_passthrough` both +// register quit keys — so combining them produced overlapping +// overlays, infinite log spam, and key conflicts. One tab per demo +// gives each its own full canvas without duplicate work. + +/// Tab 9: Log. Description-only page for `static_log`. The real demo +/// would call `ui.static_log(...)` on every frame, which writes to the +/// terminal scrollback and visibly corrupts the tour's bordered frame +/// (each push moves the inline buffer down a row). Run the standalone +/// demo to see the actual scrollback effect. +fn render_log(ui: &mut Context) { + let pad = ui.spacing().xs(); + let _ = ui + .bordered(Border::Rounded) + .title("v0.20 #233: static_log (append-only scrollback)") + .p(pad) + .grow(1) + .col(|ui| { + ui.text( + "ui.static_log(line) prints `line` once into the terminal's", + ) + .dim(); + ui.text( + "scrollback above the inline TUI buffer, then never re-renders", + ) + .dim(); + ui.text("it. Use for cumulative event logs that must survive tear-down") + .dim(); + ui.text("and re-render cycles without flicker.").dim(); + ui.text(""); + let _ = ui + .bordered(Border::Single) + .title("typical usage") + .p(pad) + .col(|ui| { + let _ = ui.code_block_lang( + "if frame % 5 == 0 {\n ui.static_log(format!(\"[tick] count = {count}\"));\n}", + "rust", + ); + }); + ui.text(""); + ui.text("This page is description-only because calling static_log on") + .fg(Color::Yellow); + ui.text("every frame would push a new line into scrollback each tick,") + .fg(Color::Yellow); + ui.text("visibly corrupting the tour's bordered frame above.") + .fg(Color::Yellow); + ui.text(""); + ui.text("To see the actual scrollback effect, run the standalone demo:") + .dim(); + ui.text(" cargo run --example v020_static_log").fg(Color::Cyan); + }); +} diff --git a/examples/v020_use_effect.rs b/examples/v020_use_effect.rs new file mode 100644 index 0000000..30cc5dc --- /dev/null +++ b/examples/v020_use_effect.rs @@ -0,0 +1,169 @@ +//! v0.20.0 use_effect demo — dependency-tracked side effects. +//! +//! Demonstrates: #216 +//! +//! Three effects with three different dependency shapes: +//! - `&()` runs **once** on first frame (run-once setup). +//! - `&count` runs on every counter change (PartialEq + Clone deps). +//! - `&log_visible` runs on each visibility transition. +//! +//! All three append to a shared `Vec` so the effect log is +//! visible in the lower panel. The log itself is rendered last so it +//! reflects the writes from the current frame. +//! +//! Run: `cargo run --example v020_use_effect` +//! +//! Keys: +//! k / Up — count++ +//! j / Down — count-- +//! Space — toggle effect-log panel +//! q / Esc / Ctrl-Q — quit +//! +//! Layout: +//! ┌── use_effect: dep-tracked side effects ──┐ +//! │ help line │ +//! │ count = 3 │ +//! │ log panel: visible │ +//! │ ┌── Effect log ──┐ │ +//! │ │ [setup] … │ │ +//! │ │ [count] → 3 │ │ +//! │ └────────────────┘ │ +//! └────────────────────────────────────────────┘ + +use slt::{Border, Color, Context, KeyCode, KeyModifiers, RunConfig}; +use std::cell::RefCell; +use std::rc::Rc; + +/// Tail length of the effect log shown in the bottom panel. +const LOG_TAIL_LEN: usize = 10; + +/// Shared state for [`render`]. The log is `Rc>` because every +/// effect closure captures its own clone — `use_effect`'s callback runs +/// during the render closure, where `&mut state` is already borrowed. +pub struct DemoState { + pub count: i32, + pub log_visible: bool, + pub log: Rc>>, +} + +impl Default for DemoState { + fn default() -> Self { + Self { + count: 0, + log_visible: true, + log: Rc::new(RefCell::new(Vec::new())), + } + } +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + render(ui, &mut state) + }) +} + +/// Render one frame of the use_effect demo. +/// +/// Public so snapshot tests can drive frames sequentially and inspect the +/// shared log without depending on a real terminal backend. +pub fn render(ui: &mut Context, state: &mut DemoState) { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + handle_input(ui, state); + + // Run-once setup. `&()` never changes, so the closure fires exactly + // once across the lifetime of the run loop. + let log_setup = state.log.clone(); + ui.use_effect( + move |_| { + log_setup + .borrow_mut() + .push("[setup] run-once effect fired".to_string()); + }, + &(), + ); + + // Counter-change effect. PartialEq + Clone on the dep (`i32`) is what + // lets `use_effect` detect "did it change since last frame". + let log_count = state.log.clone(); + ui.use_effect( + move |c| { + log_count + .borrow_mut() + .push(format!("[count] changed → {c}")); + }, + &state.count, + ); + + // Visibility-change effect. The dep is a `bool` cloned by value. + let log_vis = state.log.clone(); + ui.use_effect( + move |v| { + let label = if *v { "shown" } else { "hidden" }; + log_vis.borrow_mut().push(format!("[panel] log {label}")); + }, + &state.log_visible, + ); + + let pad = ui.spacing().xs(); + let gap = ui.spacing().xs(); + let count = state.count; + let visible = state.log_visible; + let log = state.log.clone(); + + let _ = ui + .bordered(Border::Rounded) + .title("use_effect: dep-tracked side effects") + .p(pad) + .gap(gap) + .grow(1) + .col(|ui| { + ui.text("k/Up = count++ j/Down = count-- Space = toggle log q quit") + .dim(); + ui.text(format!("count = {count}")).bold().fg(Color::Cyan); + + // Visibility status mirrors the bool the effect is watching. + let status = if visible { "visible" } else { "hidden" }; + let status_color = if visible { Color::Green } else { Color::Red }; + ui.text(format!("log panel: {status}")).fg(status_color); + + if visible { + let _ = ui + .bordered(Border::Single) + .title("Effect log") + .p(pad) + .col(|ui| render_log_tail(ui, &log)); + } + }); +} + +/// Apply key bindings to mutate `state` BEFORE effects run, so an effect +/// reading `state.count` sees the post-input value on the same frame. +fn handle_input(ui: &mut Context, state: &mut DemoState) { + if ui.key('k') || ui.key_code(KeyCode::Up) { + state.count += 1; + } + if ui.key('j') || ui.key_code(KeyCode::Down) { + state.count -= 1; + } + if ui.key_code(KeyCode::Char(' ')) { + state.log_visible = !state.log_visible; + } +} + +/// Render the last [`LOG_TAIL_LEN`] entries of the effect log. Placed last +/// in the frame so writes from this frame's effects are already in `log`. +fn render_log_tail(ui: &mut Context, log: &Rc>>) { + let entries = log.borrow(); + if entries.is_empty() { + ui.text("(no events yet)").dim(); + return; + } + let start = entries.len().saturating_sub(LOG_TAIL_LEN); + for entry in &entries[start..] { + ui.text(entry.clone()).dim(); + } +} diff --git a/examples/v020_use_state_keyed.rs b/examples/v020_use_state_keyed.rs new file mode 100644 index 0000000..3ee4097 --- /dev/null +++ b/examples/v020_use_state_keyed.rs @@ -0,0 +1,168 @@ +//! v0.20.0 use_state_keyed demo — runtime-keyed per-item state. +//! +//! Demonstrates: #215 +//! +//! Each list row owns its own counter, keyed by a runtime +//! `format!("counter-{i}")` string. Rows can be added or removed at any +//! time; counters that survive across frames keep their values. Stale +//! entries from removed rows are tolerated — the state map keeps them +//! but the closure simply stops reading them. +//! +//! Run: `cargo run --example v020_use_state_keyed` +//! +//! Keys: +//! j / Down — move selection down +//! k / Up — move selection up +//! l / Right — bump selected counter (+1) +//! h / Left — drop selected counter (-1) +//! + — add a row (max 20) +//! - — remove a row (min 1) +//! Click [-]/[+] — bump / drop that row's counter (independent of selection) +//! q / Esc / Ctrl-Q — quit +//! +//! Layout: +//! ┌── use_state_keyed: per-item counters ──────────┐ +//! │ helper text │ +//! │ │ +//! │ ▶ item 0 count = 0 [-] [+] │ +//! │ item 1 count = 7 [-] [+] │ +//! │ item 2 count = -3 [-] [+] │ +//! └─────────────────────────────────────────────────┘ + +use slt::{Border, Color, Context, KeyCode, KeyModifiers, RunConfig}; + +/// Per-frame inputs for [`render`]. Kept on the stack in `main`; passed by +/// `&mut` so snapshot tests can drive the same render path frame-by-frame. +pub struct DemoState { + pub item_count: usize, + pub selected: usize, +} + +impl Default for DemoState { + fn default() -> Self { + Self { + item_count: 3, + selected: 0, + } + } +} + +const MAX_ROWS: usize = 20; +const MIN_ROWS: usize = 1; + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + slt::run_with(RunConfig::default().mouse(true), move |ui: &mut Context| { + render(ui, &mut state) + }) +} + +/// Render one frame of the keyed-state demo. +/// +/// Public so snapshot tests can pin frames against this exact UI shape +/// without re-deriving widget composition in test fragments. +pub fn render(ui: &mut Context, state: &mut DemoState) { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + handle_input(ui, state); + + // Capture intent BEFORE the render closure so the inner loop can use + // it without reborrowing `ui` for key checks (which would clash with + // the mutable closure borrow). + let bump = ui.key('l') || ui.key_code(KeyCode::Right); + let drop = ui.key('h') || ui.key_code(KeyCode::Left); + let item_count = state.item_count; + let selected = state.selected; + + let pad = ui.spacing().xs(); + let gap = ui.spacing().xs(); + + let _ = ui + .bordered(Border::Rounded) + .title("use_state_keyed: per-item counters") + .p(pad) + .gap(gap) + .grow(1) + .col(|ui| { + ui.text( + "Each row owns its own state via use_state_keyed(format!(\"counter-{i}\"), …).", + ) + .dim(); + ui.text( + "j/k move l/h bump/drop selected +/- add/remove rows click [-]/[+] bump per row q quit", + ) + .dim(); + + for i in 0..item_count { + // Runtime key — the equivalent `use_state_named` would not + // compile because it requires a `&'static str`. + let counter = ui.use_state_keyed(format!("counter-{i}"), || 0i32); + if i == selected { + if bump { + *counter.get_mut(ui) += 1; + } else if drop { + *counter.get_mut(ui) -= 1; + } + } + + let row_gap = ui.spacing().xs(); + let _ = ui.row_gap(row_gap, |ui| { + let value = *counter.get(ui); + let prefix = if i == selected { "▶" } else { " " }; + let label = format!("{prefix} item {i:>2} count = {value:>4}"); + ui.text(label).fg(row_color(i == selected, value)); + + // Per-row clickable buttons mutate THIS row's counter, + // independent of the global selection. Demonstrates + // that keyed state works regardless of which item the + // cursor points at — every row holds its own slot. + if ui.button("-").clicked { + *counter.get_mut(ui) -= 1; + } + if ui.button("+").clicked { + *counter.get_mut(ui) += 1; + } + }); + } + }); +} + +/// Apply growth / shrink / selection-move keystrokes. +/// +/// Split out so `render` reads as a pure render path and so snapshot tests +/// can mutate `DemoState` directly without going through key events. +fn handle_input(ui: &mut Context, state: &mut DemoState) { + if ui.key_code(KeyCode::Char('+')) { + state.item_count = (state.item_count + 1).min(MAX_ROWS); + } + if ui.key_code(KeyCode::Char('-')) { + state.item_count = state.item_count.saturating_sub(1).max(MIN_ROWS); + } + if ui.key('k') || ui.key_code(KeyCode::Up) { + state.selected = state.selected.saturating_sub(1); + } + if ui.key('j') || ui.key_code(KeyCode::Down) { + state.selected = (state.selected + 1).min(state.item_count.saturating_sub(1)); + } + // Trim selection if rows shrank below the cursor. + if state.selected >= state.item_count { + state.selected = state.item_count.saturating_sub(1); + } +} + +/// Colour a row by selection + sign. Keeps the rule out of `render` so the +/// visual contract (cyan = selected, green/red = sign, default otherwise) is +/// readable at a glance. +fn row_color(selected: bool, value: i32) -> Color { + if selected { + Color::Cyan + } else if value > 0 { + Color::Green + } else if value < 0 { + Color::Red + } else { + Color::Reset + } +} diff --git a/examples/v020_widthspec.rs b/examples/v020_widthspec.rs new file mode 100644 index 0000000..4b56dd7 --- /dev/null +++ b/examples/v020_widthspec.rs @@ -0,0 +1,126 @@ +//! v0.20.0 WidthSpec demo — five constraint variants stacked side-by-side. +//! +//! Demonstrates: #237 (unified WidthSpec / HeightSpec enum, with helpers +//! for Fixed / Pct / Ratio / MinMax / Auto). +//! +//! Run: `cargo run --example v020_widthspec` +//! +//! Keys: +//! q / Esc / Ctrl-Q — quit +//! +//! Note: Ctrl-C is intentionally not bound here. macOS terminals (Ghostty, +//! iTerm2, Terminal.app) bind Ctrl-C to Copy by default, so the keystroke +//! never reaches the app. Ctrl-Q is the portable alternative. +//! +//! Layout (80x12 minimum): +//! +//! ```text +//! +- WidthSpec showcase --------------------------------------------+ +//! | Each row demonstrates a WidthSpec variant. | +//! | Fixed(20) +- WidthSpec::Fixed(20) -+ | +//! | Pct(50) +- WidthSpec::Pct(50) -----------------+ | +//! | Ratio(1, 3) +- WidthSpec::Ratio(1, 3) -+ | +//! | MinMax { 10..=30 } +- WidthSpec::MinMax { 10..=30 } --+ | +//! | Auto (content) +- WidthSpec::Auto -+ | +//! +-----------------------------------------------------------------+ +//! ``` + +use slt::{Border, Color, Constraints, Context, KeyCode, KeyModifiers, RunConfig}; + +// Label column width. Pinned so every variant's left-hand label aligns +// regardless of how its right-hand container resolves. +const LABEL_W: usize = 20; + +// MinMax bounds for the demo's MinMax variant. Lifted into constants so +// the doc-comment layout, the label, and the constraint stay in sync. +const MINMAX_LO: u32 = 10; +const MINMAX_HI: u32 = 30; + +fn main() -> std::io::Result<()> { + slt::run_with(RunConfig::default().mouse(true), |ui: &mut Context| { + if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + } + + render(ui); + }) +} + +/// Render one full WidthSpec showcase frame. +/// +/// Public so the snapshot test in `tests/v020_widthspec_demo.rs` (and any +/// future visual regression coverage) can pin a deterministic frame. +pub fn render(ui: &mut Context) { + let theme = ui.theme(); + let pad = theme.spacing.xs(); + + let _ = ui + .bordered(Border::Rounded) + .title("WidthSpec showcase") + .p(pad) + .grow(1) + .col(|ui| { + ui.text("Each row demonstrates a WidthSpec variant.") + .fg(Color::Cyan) + .bold(); + ui.text("(Press q / Esc / Ctrl-Q to quit)").dim(); + + // Fixed(20) — exact column count. + row(ui, "Fixed(20)", |ui| { + let _ = ui + .bordered(Border::Single) + .constraints(Constraints::default().w(20)) + .col(|ui| { + ui.text("WidthSpec::Fixed(20)").fg(Color::Yellow); + }); + }); + + // Pct(50) — half of the parent width. + row(ui, "Pct(50)", |ui| { + let _ = ui + .bordered(Border::Single) + .constraints(Constraints::default().w_pct(50)) + .col(|ui| { + ui.text("WidthSpec::Pct(50)").fg(Color::Green); + }); + }); + + // Ratio(1, 3) — exact 1/3 of the parent width. + row(ui, "Ratio(1, 3)", |ui| { + let _ = ui + .bordered(Border::Single) + .constraints(Constraints::default().w_ratio(1, 3)) + .col(|ui| { + ui.text("WidthSpec::Ratio(1, 3)").fg(Color::Magenta); + }); + }); + + // MinMax { 10..=30 } — clamps to the inclusive range. + row(ui, "MinMax { 10..=30 }", |ui| { + let _ = ui + .bordered(Border::Single) + .constraints(Constraints::default().w_minmax(MINMAX_LO, MINMAX_HI)) + .col(|ui| { + ui.text("WidthSpec::MinMax { 10..=30 }").fg(Color::Blue); + }); + }); + + // Auto — sized to fit content (the default when no width spec + // is supplied). + row(ui, "Auto (content)", |ui| { + let _ = ui.bordered(Border::Single).col(|ui| { + ui.text("WidthSpec::Auto").fg(Color::White); + }); + }); + }); +} + +// One labeled row. The label column is pinned so every variant's content +// box starts at the same x — readers can compare resolved widths visually +// without measuring against a moving baseline. +fn row(ui: &mut Context, label: &str, content: F) { + let _ = ui.row_gap(1, |ui| { + ui.text(format!("{label: Response` next to a builder type +# `` for the same family in the same file. +# V3 — Naming length mismatch (informational) +# An impl block where one public method is > 20 chars and +# another is <= 6 chars. Often correct, but worth a glance. +# V4 — Public API missing rustdoc +# pub fn / pub struct / pub enum without /// directly above. +# V5 — Outer container missing grow/fill (v0.20+ demos) +# examples/v020_*.rs whose entry-point function ships an +# outermost `.bordered(...)` / `.container()` chain that lacks +# `.grow(N)` or `.fill()` before the `.col(|ui|`/`.row(|ui|` +# closure. Without one of those, the inner area collapses to +# intrinsic widget width and inputs render as 1-cell strips. +# V6 — Fallback path container nesting divergence (informational) +# Pattern: an `if let Some(...) { ui.line(|ui| inner(ui)) } +# else { inner(ui) }` shape where one branch wraps the inner +# call in `ui.line(`/`ui.row(`/`ui.col(` and the other does +# not, producing inconsistent indentation/wrapping. v0.20 ships +# this as informational only — see the section below for the +# v0.21 dylint-based plan. +# V7 — Demo title wide-character drift (v0.20+ demos) +# `.title("…")` strings inside examples/v020_*.rs that contain +# non-ASCII codepoints not in the per-check allowlist. Wide +# glyphs (em-dash U+2014, en-dash U+2013, ideographs, etc.) +# cause border-misalignment in terminals that report a +# single-cell width for them. +# +# Historical bugs each new check defends against: +# V5 → examples/v020_named_focus.rs shipped without `.grow(1)`, +# making the email/name/city inputs render as a single column. +# V6 → src/context/widgets_display/status.rs `code_block_lang` +# previously wrapped the tree-sitter branch in `ui.line(...)` +# but called `render_highlighted_line(...)` bare in the +# non-highlight fallback, producing inconsistent line wrapping. +# V7 → examples like `"SLT v0.20 — Density presets"` shipped with +# an em-dash (U+2014) in the title, breaking border alignment +# on terminals that render it as a 1-cell glyph. +# +# Output is human-readable plus an exit code. v0.20 is report-only. +# v0.21 will gate on V1, V2, V4, V5, V7 (V3 and V6 stay informational +# until the dylint-based fallback rule lands). +# +# Run from repo root: +# scripts/api_audit.sh +# scripts/api_audit.sh --strict # exit 1 on V1/V2/V4/V5/V7 (preview of v0.21 gate) +# +# This is intentionally heuristic — false positives are expected and +# allowlisted via the per-check "allowlist" sections below. The point is +# a regular signal, not perfection. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="${REPO_ROOT}/src" +EXAMPLES="${REPO_ROOT}/examples" + +if [[ ! -d "${SRC}" ]]; then + echo "api_audit: src/ not found at ${SRC}" >&2 + exit 2 +fi + +STRICT=0 +if [[ "${1:-}" == "--strict" ]]; then + STRICT=1 +fi + +violations=0 +warnings=0 + +# --- V1: Two-path methods ---------------------------------------------------- + +echo "── V1: Two-path methods (Context vs ContainerBuilder) ──" + +# Allowlist — methods intentionally on both layers (documented in +# ARCHITECTURE.md). Keep this list short; every entry is technical debt. +# Update DESIGN_PRINCIPLES.md matrix when adding/removing. +V1_ALLOWLIST=( + "text" # Context: unbordered shortcut; Builder: inside-builder form + "theme" # Context: getter; Builder: per-subtree override + "width" # Context: terminal width; Builder: w() (different name already) + "height" # same as width + "push_container" # internal helper used in both layers + "line" # Context: row-shorthand; ContainerBuilder: not present (false pos) +) + +is_allowlisted_v1() { + local m="$1" + for allow in "${V1_ALLOWLIST[@]}"; do + [[ "$m" == "$allow" ]] && return 0 + done + return 1 +} + +# Collect Context-layer pub fn names. Context lives in core.rs, runtime.rs, +# and the various widgets_*/*.rs files (those add `impl Context { ... }` +# blocks). +ctx_files=( + "${SRC}/context/core.rs" + "${SRC}/context/runtime.rs" +) +for d in widgets_display widgets_input widgets_interactive widgets_viz; do + if [[ -d "${SRC}/context/${d}" ]]; then + while IFS= read -r f; do + ctx_files+=("$f") + done < <(find "${SRC}/context/${d}" -name '*.rs' -type f) + fi +done + +ctx_methods="" +for f in "${ctx_files[@]}"; do + [[ -f "$f" ]] || continue + ctx_methods+=$(grep -hE '^\s*pub\s+fn\s+\w+' "$f" 2>/dev/null \ + | sed -E 's/.*pub\s+fn\s+(\w+).*/\1/' || true) + ctx_methods+=$'\n' +done +ctx_methods=$(printf '%s' "${ctx_methods}" | sort -u) + +# ContainerBuilder lives in container.rs. +cb_methods=$(grep -hE '^\s*pub\s+fn\s+\w+' "${SRC}/context/container.rs" 2>/dev/null \ + | sed -E 's/.*pub\s+fn\s+(\w+).*/\1/' \ + | sort -u || true) + +shared=$(comm -12 <(echo "${ctx_methods}") <(echo "${cb_methods}") || true) +v1_count=0 +if [[ -n "${shared}" ]]; then + while IFS= read -r m; do + [[ -z "$m" ]] && continue + if is_allowlisted_v1 "$m"; then + continue + fi + echo " V1: ${m} defined on both Context and ContainerBuilder" + v1_count=$((v1_count + 1)) + done <<< "${shared}" +fi +if [[ "${v1_count}" -eq 0 ]]; then + echo " ✅ none (allowlist size: ${#V1_ALLOWLIST[@]})" +fi +violations=$((violations + v1_count)) + +# --- V2: Mixed verbs (immediate + builder for same widget) ------------------- + +echo +echo "── V2: Mixed verbs in same widget file ──" + +# Heuristic: a widget file with both +# pub fn (...) -> Response (immediate) +# and a corresponding +# pub struct (builder) +# for the SAME widget name. + +v2_count=0 +for f in "${SRC}/context/widgets_display/"*.rs \ + "${SRC}/context/widgets_input/"*.rs \ + "${SRC}/context/widgets_interactive/"*.rs; do + [[ -f "$f" ]] || continue + + # Immediate fns: pub fn returning Response on `&mut self`. + immediate_fns=$(grep -E '^\s*pub\s+fn\s+\w+\([^)]*&mut\s+self' "$f" 2>/dev/null \ + | grep -E '\)\s*->\s*Response' \ + | sed -E 's/.*pub\s+fn\s+(\w+).*/\1/' || true) + + # Builder structs in same file. + builder_types=$(grep -E '^\s*pub\s+struct\s+[A-Z]\w*<' "$f" 2>/dev/null \ + | sed -E 's/.*pub\s+struct\s+([A-Z]\w*).*/\1/' || true) + + [[ -z "${immediate_fns}" || -z "${builder_types}" ]] && continue + + while IFS= read -r fn; do + [[ -z "$fn" ]] && continue + # Convert snake_case to PascalCase. + bt=$(echo "$fn" | awk -F_ '{ + for(i=1;i<=NF;i++) printf "%s%s", toupper(substr($i,1,1)), substr($i,2) + }') + if echo "${builder_types}" | grep -qx "$bt"; then + echo " V2: ${fn}() (immediate) coexists with ${bt}<'_> (builder) in $(basename "$f")" + v2_count=$((v2_count + 1)) + fi + done <<< "${immediate_fns}" +done +if [[ "${v2_count}" -eq 0 ]]; then + echo " ✅ none" +fi +violations=$((violations + v2_count)) + +# --- V3: Naming length mismatch (informational) ------------------------------ + +echo +echo "── V3: Naming length mismatch (informational) ──" + +# Heuristic: per-impl-block, list public method names. Flag when one is +# > 20 chars and another is <= 6 chars in the same file. Often correct +# (different categories), but worth a human glance. +v3_files=() +for f in "${SRC}/context/container.rs" \ + "${SRC}/context/runtime.rs" \ + "${SRC}/context/core.rs"; do + [[ -f "$f" ]] || continue + methods=$(grep -E '^\s*pub\s+fn\s+\w+' "$f" \ + | sed -E 's/.*pub\s+fn\s+(\w+).*/\1/' \ + | sort -u) + long=$(echo "${methods}" | awk 'length > 20') + short=$(echo "${methods}" | awk 'length <= 6 && length >= 1') + if [[ -n "${long}" && -n "${short}" ]]; then + v3_files+=("$f") + fi +done + +if [[ "${#v3_files[@]}" -gt 0 ]]; then + for f in "${v3_files[@]}"; do + echo " V3 (info): $(basename "$f") has both long (>20) and short (<=6) public method names" + done + echo " (informational — see NAMING.md \"Length Conventions\")" + warnings=$((warnings + ${#v3_files[@]})) +else + echo " ✅ none" +fi + +# --- V4: Public API missing rustdoc ------------------------------------------ + +echo +echo "── V4: Public API missing rustdoc ──" + +# Heuristic: every pub fn / pub struct / pub enum should have a /// line +# directly above (allowing #[derive(...)] / #[must_use] / blank attrs in +# between). Skip pub(crate) and pub(super). +v4_count=0 +while IFS= read -r line; do + [[ -z "$line" ]] && continue + file="${line%%:*}" + rest="${line#*:}" + lineno="${rest%%:*}" + [[ "$lineno" -lt 1 ]] && continue + + # Walk upward past attributes (#[...]) and blank lines, looking for + # the nearest preceding non-blank line. + seek=$((lineno - 1)) + while [[ "$seek" -ge 1 ]]; do + prev_line=$(sed -n "${seek}p" "$file" 2>/dev/null || echo "") + # skip attribute lines and blank lines + if [[ "$prev_line" =~ ^[[:space:]]*\#\[ ]] || \ + [[ -z "${prev_line//[[:space:]]/}" ]]; then + seek=$((seek - 1)) + continue + fi + break + done + + prev_line=$(sed -n "${seek}p" "$file" 2>/dev/null || echo "") + if ! [[ "$prev_line" =~ ^[[:space:]]*/// ]]; then + echo " V4: ${file}:${lineno} — missing rustdoc" + v4_count=$((v4_count + 1)) + fi +done < <(grep -rnE '^\s*pub\s+(fn|struct|enum)\s+[A-Za-z_]' "${SRC}" 2>/dev/null \ + | grep -v 'pub(' || true) + +if [[ "${v4_count}" -eq 0 ]]; then + echo " ✅ none" +fi +violations=$((violations + v4_count)) + +# --- V5: Outer container missing grow/fill (v0.20+ demos) -------------------- + +echo +echo "── V5: Outer container missing grow/fill (v020_*.rs) ──" + +# Heuristic. For each examples/v020_*.rs file: +# 1. Locate the entry-point function: prefer `pub fn render(`, then +# `fn body(`, then `fn main(` (with `slt::run` inside). +# 2. Extract its body by tracking `{`/`}` depth from the opening line. +# 3. Within that body, look for the first chain anchored on +# `.bordered(` or `.container()` and ending at `.col(|ui|` / +# `.row(|ui|`. +# 4. Flag when the chain does NOT contain `.grow(` or `.fill()`. +# +# Files that delegate to a helper (no chain in the entry-point body) +# are skipped — V5 does not chase across function boundaries. The +# v0.21 dylint-based rule will handle inter-procedural cases. +# +# Allowlist: demos that intentionally use a non-growing top-level chain +# (e.g. width-bounded showcase with an outer scroll container). Add +# basenames here to suppress flagging. +V5_ALLOWLIST=( + "v020_test_utils.rs" # not a runnable demo — exercises test harness only +) + +is_allowlisted_v5() { + local b="$1" + for allow in "${V5_ALLOWLIST[@]}"; do + [[ "$b" == "$allow" ]] && return 0 + done + return 1 +} + +v5_count=0 +if [[ -d "${EXAMPLES}" ]]; then + while IFS= read -r f; do + [[ -f "$f" ]] || continue + base=$(basename "$f") + is_allowlisted_v5 "$base" && continue + + # Pick the first matching entry-point line by priority. + start="" + for pat in '^pub fn render\(' '^fn body\(' '^fn main\('; do + line=$(grep -nE "$pat" "$f" 2>/dev/null | head -1 | cut -d: -f1 || true) + if [[ -n "$line" ]]; then + # For `fn main(`, require a `slt::run` somewhere in the file. + if [[ "$pat" == '^fn main\(' ]] \ + && ! grep -q 'slt::run' "$f" 2>/dev/null; then + continue + fi + start="$line" + break + fi + done + [[ -z "$start" ]] && continue + + # Walk forward from `start`, tracking { / } depth across the + # function. Capture lines up to (and including) the matching + # closing brace into `body`. + body=$(awk -v s="$start" ' + NR < s { next } + NR == s { depth = 0 } + { print } + { + for (i = 1; i <= length($0); i++) { + c = substr($0, i, 1) + if (c == "{") depth++ + else if (c == "}") { + depth-- + if (depth == 0 && NR > s) exit + } + } + } + ' "$f") + [[ -z "$body" ]] && continue + + # Within the body, capture the first chain that begins at a + # line containing `.bordered(` or `.container()` and ends at a + # line containing `.col(|ui|` or `.row(|ui|`. This ignores + # later inner chains. + chain=$(echo "$body" | awk ' + /\.bordered\(|\.container\(\)/ { capture = 1 } + capture { + print + if ($0 ~ /\.col\(\|ui\||\.row\(\|ui\|/) exit + } + ') + [[ -z "$chain" ]] && continue + + if ! echo "$chain" | grep -qE '\.grow\(|\.fill\(\)'; then + echo " V5: ${base} — outer chain lacks .grow(N) or .fill() before .col(|ui|/.row(|ui|" + v5_count=$((v5_count + 1)) + fi + done < <(find "${EXAMPLES}" -maxdepth 1 -name 'v020_*.rs' -type f 2>/dev/null | sort) +fi + +if [[ "${v5_count}" -eq 0 ]]; then + echo " ✅ none (allowlist size: ${#V5_ALLOWLIST[@]})" +fi +violations=$((violations + v5_count)) + +# --- V6: Fallback path container nesting divergence (informational) ---------- + +echo +echo "── V6: Fallback path container nesting divergence ──" + +# Catching this reliably with grep is impractical: the bug requires +# matching a primary `if let Some(...) { ... ui.line(|ui| inner(ui)) }` +# against an `else { inner(ui) }` and confirming that the inner +# function is the same in both branches. Plain regex flags hundreds of +# false positives across src/. +# +# v0.21 plan: replace this section with a dylint-based AST rule that +# walks each `if let`/`match` expression, identifies its mirror branch, +# normalizes wrap-call shapes (line/row/col/styled-line), and flags +# only when wrap depth differs for an otherwise-identical body. Until +# then, this section is a manual-review reminder so the principle +# stays visible in CI logs. +echo " V6 (informational): manual review required for fallback parity" +echo " (see status.rs::code_block_lang for the canonical pattern fixed in v0.20.x;" +echo " the v0.21 dylint rule will mechanize this check)" + +# --- V7: Demo title wide-character drift (v0.20+ demos) ---------------------- + +echo +echo "── V7: Demo title wide-character drift (v020_*.rs) ──" + +# Heuristic. For each `.title("…")` call inside examples/v020_*.rs, +# inspect the literal between the first pair of double quotes and +# flag any byte ≥ 0x80 (UTF-8 lead/cont byte) that isn't part of an +# allowlisted multi-byte sequence. +# +# Implementation note: we rely on `LC_ALL=C` plus bash `$'\x80'`-style +# byte literals so the byte range is consistent across BSD grep +# (macOS) and GNU grep (Linux). Plain `\x` inside a regex is NOT +# portable across grep implementations — the `$'…'` form expands the +# escape in the shell before grep sees it, sidestepping the issue. +# +# Allowlisted UTF-8 byte sequences (regex alternation literal). The +# bug we're catching is specifically em-dash (U+2014 → e2 80 94), +# en-dash (U+2013 → e2 80 93), ideographic glyphs, etc., so the +# default is empty — flag any non-ASCII byte. Add to this list (e.g. +# $'\xc3\xb1' for `ñ`) only when a demo legitimately needs it. +V7_ALLOWED_PATTERN="" + +v7_count=0 +if [[ -d "${EXAMPLES}" ]]; then + while IFS= read -r f; do + [[ -f "$f" ]] || continue + base=$(basename "$f") + + # Grab raw .title("…") occurrences, then test the inner + # literal for non-ASCII bytes. + while IFS= read -r hit; do + [[ -z "$hit" ]] && continue + lineno="${hit%%:*}" + rest="${hit#*:}" + # Extract the first `"…"` literal after `.title(`. + literal=$(LC_ALL=C printf '%s' "$rest" \ + | LC_ALL=C sed -nE 's/.*\.title\("([^"]*)".*/\1/p') + [[ -z "$literal" ]] && continue + + # Strip allowlisted byte sequences before scanning. + scan="$literal" + if [[ -n "$V7_ALLOWED_PATTERN" ]]; then + scan=$(LC_ALL=C printf '%s' "$scan" \ + | LC_ALL=C sed -E "s/(${V7_ALLOWED_PATTERN})//g") + fi + + # Use bash $'…' to expand the byte range BEFORE grep + # parses the regex. Anything ≥ 0x80 is non-ASCII. + if LC_ALL=C printf '%s' "$scan" \ + | LC_ALL=C grep -q $'[\x80-\xff]'; then + # Show the literal (truncated) for quick triage. + snippet=$(LC_ALL=C printf '%s' "$literal" | head -c 80) + echo " V7: ${base}:${lineno} — non-ASCII char in .title(\"${snippet}\")" + v7_count=$((v7_count + 1)) + fi + done < <(LC_ALL=C grep -nE '\.title\("' "$f" 2>/dev/null || true) + done < <(find "${EXAMPLES}" -maxdepth 1 -name 'v020_*.rs' -type f 2>/dev/null | sort) +fi + +if [[ "${v7_count}" -eq 0 ]]; then + echo " ✅ none" +fi +violations=$((violations + v7_count)) + +# --- Summary ----------------------------------------------------------------- + +echo +echo "── Summary ──" +echo "Violations (V1+V2+V4+V5+V7): ${violations}" +echo "Warnings (V3+V6 info): ${warnings}" +echo + +if [[ "${violations}" -eq 0 ]]; then + echo "✅ Clean." + exit 0 +fi + +echo "⚠️ Reported ${violations} violation(s)." +if [[ "${STRICT}" -eq 1 ]]; then + echo " --strict mode: exiting 1 (preview of v0.21 CI gate gating V1/V2/V4/V5/V7)." + exit 1 +fi +echo " v0.20 is report-only — these do NOT block CI." +echo " Run 'scripts/api_audit.sh --strict' to preview the v0.21 gate." +exit 0 diff --git a/scripts/ghostty_demos.sh b/scripts/ghostty_demos.sh new file mode 100755 index 0000000..dcd7f43 --- /dev/null +++ b/scripts/ghostty_demos.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +# +# ghostty_demos.sh — interactive launcher for SuperLightTUI v0.20 demos. +# +# Each chosen demo is opened in a fresh Ghostty.app window via +# open -na Ghostty.app -e "" +# so multiple demos run side-by-side without clobbering the current terminal. +# +# Usage examples: +# ./scripts/ghostty_demos.sh # interactive picker +# ./scripts/ghostty_demos.sh all # every v020_* example +# ./scripts/ghostty_demos.sh v020_showcase # explicit demo names +# ./scripts/ghostty_demos.sh --showcase # the 2 integration demos +# ./scripts/ghostty_demos.sh --features # the 17 feature demos +# ./scripts/ghostty_demos.sh --build-first all # pre-build, then launch all +# ./scripts/ghostty_demos.sh --help # full help +# +set -euo pipefail + +# --- paths --------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +EXAMPLES_DIR="${REPO_ROOT}/examples" +GHOSTTY_APP="/Applications/Ghostty.app" + +# --- preset playlists ---------------------------------------------------- + +# Two integration demos that show "everything on one screen". +SHOWCASE_PRESET=( + "v020_showcase" + "v020_regression_panel" +) + +# The 18 individual feature demos, one per v0.20 issue cluster. +# v020_perf_audit and v020_test_utils are non-interactive stdout reports — +# included for completeness, they will exit immediately in the launched window. +FEATURES_PRESET=( + "v020_dx_shortcuts" + "v020_use_state_keyed" + "v020_use_effect" + "v020_named_focus" + "v020_theme_subtree" + "v020_modal_trap" + "v020_spacing_scale" + "v020_split_pane" + "v020_gauge" + "v020_gutter_highlights" + "v020_breadcrumb_response" + "v020_progress_response" + "v020_static_log" + "v020_keymap_help" + "v020_ctrl_c_passthrough" + "v020_widthspec" + "v020_perf_audit" + "v020_test_utils" +) + +# --- helpers ------------------------------------------------------------- + +print_usage() { + cat <<'EOF' +ghostty_demos.sh — launch SuperLightTUI v0.20 demos in fresh Ghostty windows. + +USAGE: + scripts/ghostty_demos.sh [FLAGS] [DEMOS...] + +FLAGS: + -h, --help Show this help and exit. + --build-first Run `cargo build --examples --all-features` before + launching, so each Ghostty window skips the long + first-compile output. + --showcase Launch only the integration demos: + v020_showcase, v020_regression_panel + --features Launch the 17 individual feature demos, one per + Ghostty window. + --list Print the discovered v020_* demo names and exit. + +POSITIONAL: + all Launch every v020_* example. + [...] Launch only the named demos (no `v020_` prefix + needed — both `showcase` and `v020_showcase` work). + +INTERACTIVE MODE: + With no arguments, prints a numbered menu of all v020_* demos and + prompts: + select demos (e.g. 1 3 5 or 'all'): + +EXAMPLES: + # Interactive picker + scripts/ghostty_demos.sh + + # Pre-build once, then launch everything + scripts/ghostty_demos.sh --build-first all + + # Just the two integration demos + scripts/ghostty_demos.sh --showcase + + # All 17 feature demos + scripts/ghostty_demos.sh --features + + # Specific demos by name (prefix optional) + scripts/ghostty_demos.sh v020_showcase gauge use_effect + +NOTES: + - Requires Ghostty.app at /Applications/Ghostty.app. If missing, the + script falls back to AppleScript / new Terminal tabs and warns. + - Each launched window runs: + cd && cargo run --example + - Ctrl-C in a Ghostty window stops only that demo. +EOF +} + +err() { + printf 'ghostty_demos: %s\n' "$*" >&2 +} + +# Print all v020_* demos found on disk, one per line, sorted. +discover_demos() { + if [[ ! -d "${EXAMPLES_DIR}" ]]; then + err "examples dir not found: ${EXAMPLES_DIR}" + return 1 + fi + + local found=() + local f + for f in "${EXAMPLES_DIR}"/v020_*.rs; do + [[ -e "${f}" ]] || continue + local base + base="$(basename "${f}" .rs)" + found+=("${base}") + done + + if (( ${#found[@]} == 0 )); then + return 0 + fi + + printf '%s\n' "${found[@]}" | LC_ALL=C sort +} + +# Normalize a name: strip a leading "v020_" if the user dropped it, then +# re-add it so we always end up with the canonical form. +normalize_name() { + local raw="$1" + if [[ "${raw}" == v020_* ]]; then + printf '%s' "${raw}" + else + printf 'v020_%s' "${raw}" + fi +} + +# Verify a normalized name exists on disk. +demo_exists() { + local name="$1" + [[ -f "${EXAMPLES_DIR}/${name}.rs" ]] +} + +# Open one demo in a fresh Ghostty window if available, else fall back. +launch_demo() { + local name="$1" + local cmd="cd ${REPO_ROOT@Q} && cargo run --example ${name@Q}" + + if [[ -d "${GHOSTTY_APP}" ]]; then + printf ' -> Ghostty: %s\n' "${name}" + open -na "Ghostty.app" -e "${cmd}" + return 0 + fi + + # Fallback 1: macOS Terminal.app via AppleScript. + if command -v osascript >/dev/null 2>&1; then + printf ' -> Terminal.app (Ghostty not found): %s\n' "${name}" + local script + printf -v script 'tell application "Terminal" to do script "%s"' \ + "${cmd//\"/\\\"}" + osascript -e "${script}" >/dev/null + return 0 + fi + + # Fallback 2: just print what we *would* have run. + err "Ghostty.app and osascript both unavailable; please run manually:" + err " ${cmd}" + return 1 +} + +# Build all examples once so each Ghostty window opens straight into the +# running TUI instead of a multi-second cargo compile log. +build_all_examples() { + printf 'Building all examples (cargo build --examples --all-features)...\n' + ( + cd "${REPO_ROOT}" + cargo build --examples --all-features + ) + printf 'Build complete.\n\n' +} + +# --- argument parsing ---------------------------------------------------- + +build_first=0 +preset="" + +# Scan flags first so they can appear in any position. +positional=() +while (( $# > 0 )); do + case "$1" in + -h|--help) + print_usage + exit 0 + ;; + --build-first) + build_first=1 + shift + ;; + --showcase) + preset="showcase" + shift + ;; + --features) + preset="features" + shift + ;; + --list) + discover_demos + exit 0 + ;; + --) + shift + while (( $# > 0 )); do + positional+=("$1") + shift + done + ;; + -*) + err "unknown flag: $1" + err "run with --help for usage" + exit 2 + ;; + *) + positional+=("$1") + shift + ;; + esac +done + +# --- environment sanity -------------------------------------------------- + +if [[ ! -d "${EXAMPLES_DIR}" ]]; then + err "examples dir not found: ${EXAMPLES_DIR}" + err "is this script being run from a SuperLightTUI checkout?" + exit 1 +fi + +if [[ ! -d "${GHOSTTY_APP}" ]]; then + err "warning: ${GHOSTTY_APP} not found; will fall back to Terminal.app" +fi + +# --- discover -------------------------------------------------------------- + +mapfile -t ALL_DEMOS < <(discover_demos || true) + +if (( ${#ALL_DEMOS[@]} == 0 )); then + err "no v020_*.rs examples found in ${EXAMPLES_DIR}" + err "v0.20 demos may not have landed yet on this branch." + exit 1 +fi + +# --- resolve which demos to launch --------------------------------------- + +selected=() + +if [[ -n "${preset}" ]]; then + if (( ${#positional[@]} > 0 )); then + err "cannot combine preset (--${preset}) with positional demo names" + exit 2 + fi + case "${preset}" in + showcase) selected=("${SHOWCASE_PRESET[@]}") ;; + features) selected=("${FEATURES_PRESET[@]}") ;; + esac +elif (( ${#positional[@]} == 1 )) && [[ "${positional[0]}" == "all" ]]; then + selected=("${ALL_DEMOS[@]}") +elif (( ${#positional[@]} > 0 )); then + for raw in "${positional[@]}"; do + selected+=("$(normalize_name "${raw}")") + done +else + # Interactive picker. + printf 'SuperLightTUI v0.20 demo launcher\n' + printf '=================================\n\n' + printf 'Available demos (%d):\n' "${#ALL_DEMOS[@]}" + i=0 + for name in "${ALL_DEMOS[@]}"; do + i=$((i + 1)) + printf ' %2d) %s\n' "${i}" "${name}" + done + printf '\n' + printf "select demos (e.g. 1 3 5 or 'all'): " + IFS= read -r reply || true + reply="${reply## }" + reply="${reply%% }" + + if [[ -z "${reply}" ]]; then + err "no selection; nothing to do" + exit 0 + fi + + if [[ "${reply}" == "all" ]]; then + selected=("${ALL_DEMOS[@]}") + else + # Parse space-separated indices. + for tok in ${reply}; do + if [[ "${tok}" =~ ^[0-9]+$ ]]; then + idx=$((tok - 1)) + if (( idx < 0 || idx >= ${#ALL_DEMOS[@]} )); then + err "index out of range: ${tok}" + exit 2 + fi + selected+=("${ALL_DEMOS[${idx}]}") + else + # Allow names alongside numbers, just in case. + selected+=("$(normalize_name "${tok}")") + fi + done + fi +fi + +if (( ${#selected[@]} == 0 )); then + err "nothing selected" + exit 0 +fi + +# --- validate ------------------------------------------------------------- + +missing=() +for name in "${selected[@]}"; do + if ! demo_exists "${name}"; then + missing+=("${name}") + fi +done + +if (( ${#missing[@]} > 0 )); then + err "the following demos do not exist on this branch:" + for name in "${missing[@]}"; do + err " - ${name}" + done + err "run 'scripts/ghostty_demos.sh --list' to see available demos." + exit 1 +fi + +# --- optional pre-build --------------------------------------------------- + +if (( build_first == 1 )); then + build_all_examples +fi + +# --- launch --------------------------------------------------------------- + +printf 'Launching %d demo(s) in fresh Ghostty windows...\n' "${#selected[@]}" +for name in "${selected[@]}"; do + launch_demo "${name}" +done +printf 'Done. %d Ghostty window(s) requested.\n' "${#selected[@]}" diff --git a/src/anim.rs b/src/anim.rs index cfd2a5c..a0552d2 100644 --- a/src/anim.rs +++ b/src/anim.rs @@ -201,6 +201,58 @@ impl Tween { } } +/// Default animation duration in ticks used by +/// [`Context::animate_bool`](crate::Context::animate_bool) and +/// [`Context::animate_value`](crate::Context::animate_value) when no explicit +/// duration is supplied. +/// +/// 12 ticks at the default 60 Hz tick rate is roughly 200 ms — short enough +/// to feel snappy, long enough to read as motion. +pub const DEFAULT_ANIMATE_TICKS: u64 = 12; + +/// Internal state used by [`Context::animate_value`] / +/// [`Context::animate_bool`] to drive an implicit +/// `Tween` keyed in `Context::named_states`. +/// +/// Stores the most recently seen `target` so the tween can smoothly retarget +/// when the caller changes the goal mid-animation. Not part of the public +/// API; users keying their own animation state should construct a [`Tween`] +/// directly. +pub(crate) struct AnimState { + pub(crate) tween: Tween, + pub(crate) last_target: f64, +} + +impl AnimState { + /// Initialize with the tween already at its target so the first sample + /// has no visible animation pop. + pub(crate) fn new(target: f64, tick: u64) -> Self { + let mut tween = Tween::new(target, target, 0); + tween.reset(tick); + Self { + tween, + last_target: target, + } + } + + /// Sample the current value, retargeting if the goal changed. + /// + /// On retarget the new tween starts from the current interpolated value, + /// avoiding a visible jump when the target flips mid-flight. A + /// `duration_ticks` of 0 snaps to the new target immediately. + pub(crate) fn sample(&mut self, target: f64, duration_ticks: u64, tick: u64) -> f64 { + // Compare bit patterns so two NaNs are treated as equal — avoids + // re-resetting forever if a caller threads NaN through. + if self.last_target.to_bits() != target.to_bits() { + let current = self.tween.value(tick); + self.tween = Tween::new(current, target, duration_ticks); + self.tween.reset(tick); + self.last_target = target; + } + self.tween.value(tick) + } +} + /// Defines how an animation behaves after reaching its end. #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/buffer.rs b/src/buffer.rs index bdec24b..442c58f 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -124,12 +124,31 @@ pub struct Buffer { /// closures. The top entry is the active clip; nested raw-draw regions /// push and pop without losing the outer clip. pub(crate) kitty_clip_info_stack: Vec, + /// Per-row digest of every cell on row `y`, used by `flush_buffer_diff` + /// to skip the per-cell scan when both the dirty flag and the hash + /// match the previous frame (issue #171). + /// + /// Length equals `area.height`. Stale until + /// [`Buffer::recompute_line_hashes`] is called — `flush_buffer_diff` is + /// the only call site that relies on these being up to date. + pub(crate) line_hashes: Vec, + /// Per-row dirty flag. Set by every cell-write path + /// ([`Buffer::set_string`], [`Buffer::set_string_linked`], + /// [`Buffer::set_char`], [`Buffer::reset`], [`Buffer::reset_with_bg`]). + /// Cleared by [`Buffer::recompute_line_hashes`] after the row hash is + /// refreshed. + /// + /// A `false` entry means the row has not been touched since the last + /// hash refresh, so `flush_buffer_diff` can short-circuit the cell + /// scan when its hash also matches `previous.line_hashes[y]`. + pub(crate) line_dirty: Vec, } impl Buffer { /// Create a buffer filled with blank cells covering `area`. pub fn empty(area: Rect) -> Self { let size = area.area() as usize; + let height = area.height as usize; Self { area, content: vec![Cell::default(); size], @@ -138,6 +157,11 @@ impl Buffer { kitty_placements: Vec::new(), cursor_pos: None, kitty_clip_info_stack: Vec::new(), + // Empty buffers start with default cells on every row; their + // hashes are equal across two empty buffers, so initialise to + // 0 with `line_dirty=true` so the first flush still recomputes. + line_hashes: vec![0; height], + line_dirty: vec![true; height], } } @@ -343,6 +367,11 @@ impl Buffer { if y >= self.area.bottom() { return; } + // Issue #171: mark this row dirty so the next flush refreshes its + // hash. Marking unconditionally here keeps the write paths cheap; + // false positives only cost one redundant hash recompute, never a + // correctness issue. + self.mark_row_dirty(y); let clip = self.effective_clip().copied(); for ch in s.chars() { if x >= self.area.right() { @@ -409,11 +438,109 @@ impl Buffer { if !self.in_bounds(x, y) || !in_clip { return; } + // Issue #171: mark this row dirty so the next flush refreshes its + // hash before deciding whether to skip the per-cell scan. + self.mark_row_dirty(y); let cell = self.get_mut(x, y); cell.set_char(ch); cell.set_style(style); } + /// Mark row `y` as dirty so the next flush recomputes its line hash. + /// + /// `y` is in the buffer's coordinate space (i.e. `area.y..area.bottom()`). + /// Out-of-range values are ignored so callers don't need to bounds-check + /// before invoking this on every cell write. + #[inline] + pub(crate) fn mark_row_dirty(&mut self, y: u32) { + if y < self.area.y { + return; + } + let idx = (y - self.area.y) as usize; + if let Some(slot) = self.line_dirty.get_mut(idx) { + *slot = true; + } + } + + /// Recompute the per-row digest for every row currently flagged dirty. + /// + /// This is the only call site that updates [`Self::line_hashes`]; once + /// a row's hash is refreshed its `line_dirty` entry is cleared. Hashes + /// derive from each cell's `(symbol, style, hyperlink)` tuple via + /// [`std::collections::hash_map::DefaultHasher`] — sufficient for + /// equality detection with no extra dependency. + /// + /// Called by `flush_buffer_diff` once per frame, before the per-row + /// skip check (issue #171). + pub(crate) fn recompute_line_hashes(&mut self) { + let height = self.area.height; + if height == 0 { + return; + } + // `line_hashes` / `line_dirty` are sized at construction / resize; + // an interior mutation (e.g. resize before reset) could leave them + // out of step with `area.height`. Repair lazily here so callers + // never observe a stale length. + let expected_len = height as usize; + if self.line_hashes.len() != expected_len { + self.line_hashes.resize(expected_len, 0); + } + if self.line_dirty.len() != expected_len { + self.line_dirty.resize(expected_len, true); + } + + let width = self.area.width as usize; + for (idx, dirty) in self.line_dirty.iter_mut().enumerate() { + if !*dirty { + continue; + } + let row_start = idx * width; + let row_end = row_start + width; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for cell in &self.content[row_start..row_end] { + cell.symbol.as_str().hash(&mut hasher); + cell.style.hash(&mut hasher); + cell.hyperlink.as_deref().hash(&mut hasher); + } + self.line_hashes[idx] = hasher.finish(); + *dirty = false; + } + } + + /// Returns `true` if row `y` (buffer-space) was not touched since the + /// last [`Self::recompute_line_hashes`] call. + /// + /// Used by `flush_buffer_diff` to short-circuit the per-cell scan when + /// combined with a hash match against the previous frame (issue #171). + /// Out-of-range rows report as dirty so callers fall back to the + /// existing per-cell path on edge inputs. + #[inline] + pub(crate) fn row_clean(&self, y: u32) -> bool { + if y < self.area.y { + return false; + } + let idx = (y - self.area.y) as usize; + self.line_dirty + .get(idx) + .copied() + .map(|d| !d) + .unwrap_or(false) + } + + /// Read row `y`'s cached digest, or `None` if out of range. + /// + /// Pairs with [`Self::row_clean`] inside `flush_buffer_diff`: only the + /// hash for clean rows is used as a short-circuit signal, so callers + /// must check `row_clean` first. + #[inline] + pub(crate) fn row_hash(&self, y: u32) -> Option { + if y < self.area.y { + return None; + } + let idx = (y - self.area.y) as usize; + self.line_hashes.get(idx).copied() + } + /// Compute the diff between `self` (current) and `other` (previous). /// /// Returns `(x, y, cell)` tuples for every cell that changed. Useful for @@ -455,6 +582,11 @@ impl Buffer { self.kitty_placements.clear(); self.cursor_pos = None; self.kitty_clip_info_stack.clear(); + // Issue #171: every row is now blank — flag them all dirty so the + // next flush refreshes the digest before any skip check. + for d in &mut self.line_dirty { + *d = true; + } } /// Reset every cell and apply a background color to all cells. @@ -468,6 +600,10 @@ impl Buffer { self.kitty_placements.clear(); self.cursor_pos = None; self.kitty_clip_info_stack.clear(); + // Issue #171: every cell was just rewritten — mark all rows dirty. + for d in &mut self.line_dirty { + *d = true; + } } /// Resize the buffer to fit a new area, resetting all cells. @@ -478,8 +614,215 @@ impl Buffer { self.area = area; let size = area.area() as usize; self.content.resize(size, Cell::default()); + // Issue #171: keep the per-row tracking arrays sized to the new + // height. `reset()` re-marks every row dirty so initial values + // here don't affect correctness. + let height = area.height as usize; + self.line_hashes.resize(height, 0); + self.line_dirty.resize(height, true); self.reset(); } + + /// Serialize the buffer into a stable, styled-snapshot format suitable for + /// snapshot testing (e.g. with `insta::assert_snapshot!`). + /// + /// # Format + /// + /// One line per buffer row, joined with `\n`. Within a row, runs of cells + /// that share an identical [`Style`] are grouped. The default style (no + /// foreground, no background, no modifiers) emits **unannotated** text — + /// no `[...]` markers. Any non-default run is wrapped: + /// + /// ```text + /// [fg=...,bg=...,mods]"text"[/] + /// ``` + /// + /// Trailing whitespace per row is preserved in the styled segment but + /// trailing default-style spaces at the end of a row are emitted verbatim + /// (they are visually invisible in diffs). Empty cells render as a single + /// space. The terminating `[/]` marker only appears when a styled run is + /// in effect at the end of a row. + /// + /// # Color formatting + /// + /// Named palette colors use short lowercase codes: + /// `reset`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, + /// `white`, `dark_gray`, `light_red`, `light_green`, `light_yellow`, + /// `light_blue`, `light_magenta`, `light_cyan`, `light_white`. RGB colors + /// emit `#rrggbb`. Indexed palette colors emit `idx` (decimal). + /// + /// # Modifier formatting + /// + /// Modifiers are emitted as comma-separated lowercase tokens in a fixed + /// canonical order: `bold`, `dim`, `italic`, `underline`, `reversed`, + /// `strikethrough`. Order is independent of the bit pattern, so two + /// equivalent `Modifiers` values always serialize identically. + /// + /// # Stability + /// + /// The output format is stable across patch and minor versions of SLT. + /// Names use a hand-rolled formatter (not `Debug`) so derives changing + /// upstream cannot accidentally break locked snapshots. A breaking change + /// to the format would be reserved for a major version bump. + /// + /// # Determinism + /// + /// Identical input buffers always produce byte-equal output. This is a + /// hard requirement — snapshot tests rely on it. + /// + /// # Example + /// + /// ``` + /// use slt::{Buffer, Color, Rect, Style}; + /// + /// let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1)); + /// buf.set_string(0, 0, "ab", Style::new().fg(Color::Red).bold()); + /// buf.set_string(2, 0, "cd", Style::new()); + /// let snap = buf.snapshot_format(); + /// assert!(snap.starts_with("[fg=red,bold]\"ab\"[/]cd")); + /// ``` + pub fn snapshot_format(&self) -> String { + let mut out = String::new(); + let width = self.area.width; + let height = self.area.height; + if width == 0 || height == 0 { + return out; + } + + for y in self.area.y..self.area.bottom() { + if y > self.area.y { + out.push('\n'); + } + + // Walk the row, grouping consecutive cells by Style. + let mut current_style: Option