Skip to content

feat: v0.20.0 — features + perf + breaking + v0.19.x patch sweep#239

Merged
subinium merged 71 commits into
mainfrom
release/v0.20.0
Apr 28, 2026
Merged

feat: v0.20.0 — features + perf + breaking + v0.19.x patch sweep#239
subinium merged 71 commits into
mainfrom
release/v0.20.0

Conversation

@subinium

Copy link
Copy Markdown
Owner

Summary

v0.20.0 minor release. Single big bump that wraps:

  • 31 v0.20.0-milestoned issues (features, perf, refactors, breaking API consistency).
  • 11 v0.19.x patch-safe issues newly applied in this release after a guarded re-check.
  • 8 v0.19.x issues confirmed already-applied in PR feat: v0.19.3 — 11 patch-safe issues + UI library patterns #202 (v0.19.3) — closed for milestone hygiene.
  • 5 new design-discipline docs (DESIGN_PRINCIPLES.md, ARCHITECTURE.md, NAMING.md, RUSTDOC_GUIDE.md, DEMO_GUIDE.md) and scripts/api_audit.sh (V1–V7 lints).
  • Demo catalogue compactionautoexamples = false + 16 explicit [[example]] entries (53 → 16 cargo example targets); the rest reach users via mod includes inside 6 new tour binaries (v020_tour, cookbook_tour, showcase_tour, canvas_tour, text_tour, system_tour).

CHANGELOG covers everything in detail. This summary is the short version.

Issues closed by this PR

v0.20.0 milestone (29 OPEN → CLOSED + 5 already-CLOSED transitively)

Closes #204
Closes #205
Closes #206
Closes #208
Closes #209
Closes #210
Closes #212
Closes #213
Closes #215
Closes #216
Closes #217
Closes #218
Closes #220
Closes #221
Closes #223
Closes #224
Closes #225
Closes #226
Closes #227
Closes #228
Closes #229
Closes #230
Closes #231
Closes #232
Closes #233
Closes #235
Closes #236
Closes #237
Closes #238

v0.19.x — patch-safe newly applied this release

Closes #98
Closes #102
Closes #149
Closes #150
Closes #157
Closes #161
Closes #171
Closes #184
Closes #192
Closes #193
Closes #201

v0.19.x — verified already applied in PR #202 (v0.19.3)

Closes #134
Closes #146
Closes #147
Closes #148
Closes #152
Closes #153
Closes #155
Closes #162

Breaking changes (semver minor allowed for v0.x)

Known v0.21 migration

The audit surfaced 4 critical drifts that need a breaking change to fully fix. Documented in CHANGELOG ### Known v0.21 migration notes:

  1. Hook family naming asymmetry (use_state_named vs use_state_named_with vs use_state_keyed vs use_state_keyed_default).
  2. scrollbar / separator return shape (() / &mut SelfResponse).
  3. status family Response::none() → real interaction()-populated Response.
  4. ScrollState::progress() -> f32f64.

Patch-safe doc-only gaps from the audit (missing # Example / # Panics sections) will land as a 0.20.x follow-up PR rather than block this release.

Test plan

  • cargo fmt -- --check
  • cargo check --all-features
  • cargo clippy --all-features --lib --tests --examples -- -D warnings ← extended from lib-only
  • cargo test --all-features (30+ test suites, ~600 tests)
  • typos
  • cargo check -p superlighttui --no-default-features
  • cargo check -p slt-wasm --target wasm32-unknown-unknown
  • cargo hack check -p superlighttui --each-feature --no-dev-deps
  • cargo audit (1 allowed: proptest → rand 0.9.2 dev-only RUSTSEC-2026-0097)
  • cargo deny check
  • bash scripts/api_audit.sh — 0 violations
  • CI workflow on this PR
  • Manual visual checks: cargo run --example demo (tab persistence), --example showcase_tour (Infoviz chart label ellipsis), textarea undo/redo via Ctrl+Z/Y, virtual_list cursor mid-viewport, calendar h/l day-step

🤖 Generated with Claude Code

subinium and others added 30 commits April 28, 2026 10:08
Add a stable, deterministic serializer for the render buffer that
captures both text and styles in a format compatible with
`insta::assert_snapshot!`. Default-style cells emit raw text; non-default
runs are wrapped as `[fg=...,bg=...,mods]"text"[/]`. Named palette
colors use short codes (`red`, `light_blue`); RGB → `#rrggbb`; indexed
palette → `idx<N>`. Modifiers emitted in canonical order
(bold, dim, italic, underline, reversed, strikethrough) so two
equivalent values always serialize identically.

Format pinned by `tests/snapshot_format_stability.rs` with 9 hand-crafted
scenarios (default-only, color runs, modifier ordering, fg+bg, indexed,
RGB, escape handling, determinism, wide-char trailing). Hand-rolled
formatter for `Color` / `Modifiers` so upstream `Debug` derive changes
cannot silently break locked snapshots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…serts

Three additive `TestBackend` features for the v0.20 test-utils foundation,
all isolated to `src/test_utils.rs` plus the lib re-exports for the new
public types.

#229 record_frames + FrameRecord
- `TestBackend::record_frames()` enables an opt-in per-render history.
  Disabled by default → zero allocation on the hot path.
- `tb.frames()` returns `&[FrameRecord]`; `FrameRecord` carries both the
  styled snapshot string (via `Buffer::snapshot_format`) and the
  per-row trimmed text view, plus `assert_contains` /
  `to_string_trimmed` / `line` helpers.

#230 sequence builder + type_string
- `TestBackend::sequence()` returns a `TestSequence<'a>` with
  `.tick()`, `.key()`, `.type_string()`, `.events()`, `.run()`. Steps
  execute in order with FrameState advancing naturally between them so
  callers no longer thread `focus_index` / `prev_focus_count` manually.
- Backend-level `tb.type_string(s, render)` fires one render per char.
- Sequence-level `.type_string(s, render)` collapses all chars into a
  single render step — different ergonomics for different needs.

#232 negative assertions
- `assert_not_contains(&str)` — fails listing offending row indices.
- `assert_line_not_contains(y, &str)` — per-row negation.
- `assert_empty_line(y)` — row is blank after trim.
- `assert_style_at(x, y, Style)` — cell-level Style equality with
  `(x, y, expected, actual)` panic message on mismatch.

19 unit tests + 5 doctests cover all three issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained showcase that exercises every new v0.20 test-utils API in
a single file, plus a sibling test that re-imports the demo via `#[path]`
and locks its rendered output.

- examples/v020_test_utils.rs — runnable demo with deterministic
  render functions for each new feature.
- tests/v020_test_utils_demo.rs — 7 regression tests (3 happy-path,
  2 stability / determinism, 2 should_panic) sharing the demo's
  render code so the example and its tests cannot drift.
- CHANGELOG.md — Added section under [Unreleased] with one line per
  issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces v0.19's six-Option `Constraints` struct (36 bytes) with two enum
fields: `width: WidthSpec` and `height: HeightSpec` (24 bytes total, 33 %
reduction). Each axis has five variants — Auto, Fixed(u32), Pct(u8),
Ratio(u16, u16), MinMax { min: u32, max: u32 } — covering everything the
old struct could express plus exact integer-fraction sizing via the new
`w_ratio` / `h_ratio` builders.

Layout resolution in `flexbox.rs::compute_body` and the `layout_row` /
`layout_column` resolution sites collapse from three separate
`if let Some(pct)` blocks into a single `match` per axis (new
`resolve_axis_specs` helper).

Existing 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 imperative setters (`set_min_width`,
`set_max_width`, `set_width_pct`, …) cover the rare imperative-mutation
sites previously written as `c.min_width = Some(value)`. New accessors
(`min_width()`, `max_width()`, `width_pct()`, …) replace direct field
reads with parenthesized method calls and return the same `Option<u32>` /
`Option<u8>` shape.

The `MinMax` variant uses sentinel encoding (`min = 0` ≡ no minimum,
`max = u32::MAX` ≡ no maximum) so the variant fits in 12 bytes — the
only way to hit the 24-byte total without losing expressivity. Sentinels
are isolated behind the public API; users see `Option<u32>` semantics via
the accessors.

Closes #207, #219 (per #237 super-issue resolution). The breaking change
is documented in CHANGELOG with a 3-line migration recipe.

- src/style.rs — WidthSpec / HeightSpec enums + Constraints struct;
  builders, accessors, setters; `_ASSERT_CONSTRAINTS_SIZE` const guard
- src/layout/flexbox.rs — single-match `resolve_axis_specs` per axis;
  call sites in `compute_body` / `layout_row` / `layout_column` updated
- src/layout/tree.rs — accessor calls in `min_width` / `min_height` and
  `raw_draw` constructor
- src/context/widgets_interactive/collections.rs — struct literals →
  builder chain
- src/context/container.rs, src/context/widgets_display/text.rs —
  imperative setter migration to keep build green for downstream agents
- src/lib.rs — public re-exports for WidthSpec, HeightSpec
- tests/widthspec_v020.rs — 13 tests (variants, ratios, size budget,
  serde round-trip for all 5 variants of each spec, imperative setters)
- examples/v020_widthspec.rs — visual showcase of all 5 variants
- tests/v020_widthspec_demo.rs — 80×25 TestBackend snapshot test
- CHANGELOG.md — Breaking section with migration recipe
- Cargo.toml — serde_json dev-dependency for serde round-trip tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…itty Vec clone, modal-aware dim

Implements 4 hot-path perf issues for v0.20.0:

- #204 FrameState reuse 6 per-frame Vec/HashSet allocations via mem::take.
  context_stack, deferred_draws, rollback.group_stack, rollback.text_color_stack,
  pending_tooltips, hovered_groups now follow the same swap/reclaim pattern
  established in #150 / #155. 100-frame steady-state alloc count is now
  bounded (~13 allocs/frame measured; previously scaled with the six fields).

- #205 wrap_segments String allocation. Pre-size each style-run String with
  with_capacity computed from the remaining bytes in the source segment,
  capped at max_width * 4 to bound over-allocation. No behavioral change.

- #206 InlineTerminal::flush kitty Vec<KittyPlacement> clone. Pass
  row_offset: u32 directly to KittyImageManager::flush instead of
  materializing a translated copy. prev_placements now stores post-offset y
  for the diff fast-path. Stable steady-state flushes allocate only once
  (verified — 1 alloc across 100 flushes).

- #228 dim_buffer modal-aware diff path. Replaces the unconditional
  O(W*H) buffer scan with dim_buffer_around(modal_rect) that walks only
  the four strips outside the modal rect. Original dim_entire_buffer is
  retained as the zero-size-modal fallback. Visible output unchanged.

Deliverables:
- src/lib.rs: 6 new FrameState fields + reclaim path in run_frame_kernel
- src/context/runtime.rs: Context::new swap-in via mem::take
- src/layout/tree.rs: with_capacity in wrap_segments
- src/terminal.rs: KittyImageManager::flush takes row_offset; new
  __BenchKittyFixture for tests
- src/layout/render.rs: dim_buffer_around new path; dim_entire_buffer kept
- tests/v020_perf_alloc.rs: 8 alloc-budget tests using a counting global
  allocator
- tests/v020_perf_demo.rs: 5 visual-integrity tests
- examples/v020_perf_audit.rs: timing breakdown demo for all 4 hot paths
- CHANGELOG.md: 4 perf entries under Unreleased / Performance

Quality gates: fmt, check (--all-features), clippy (-D warnings), test
(--all-features), examples — all green. Extended gate: typos, no-default,
WASM target — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements 5 v0.20.0 issues for context state/hooks/focus:

- #208 Response right_clicked / gained_focus / lost_focus signals
- #215 use_state_keyed (BREAKING: State<T> Copy removal — theoretical only,
       audit confirmed zero current call sites depended on Copy)
- #216 use_effect with dependency-tracked side effects
- #217 register_focusable_named + focus_by_name + focused_name
- #218 key_presses_when(active) + public consume_event(idx)

State<T> drops Copy to allow `StateKey::Keyed(String)`. Existing
`let s = ui.use_state(...); s.get(ui)` patterns continue to compile;
audit grep over src/, tests/, examples/ found zero implicit-copy uses.

`gained_focus` / `lost_focus` are populated through begin_widget_interaction
using a new `last_focusable_id` rollback marker recorded by
register_focusable. prev_focus_index lives on FocusState (Option<usize>,
None on first frame so widgets don't falsely report gained_focus).

focus_by_name resolves against the previous frame's focus_name_map; when
the name isn't there yet, the request is held pending so a later frame
that registers the name can still receive focus.

26 new tests + 3 demos. All 5 Core Gates pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Agent 7 — widget additions and return-type unification for v0.20.0.

Added:
- split_pane / vsplit_pane (#223) — resizable container with mouse-drag
  handle and arrow-key adjustment via SplitPaneState
- gauge / gauge_w / gauge_colored / line_gauge (#224) — block-fill and
  single-line progress indicators with inline labels and color tiering
- scrollable_with_gutter (#235) + ScrollState highlight API
  (set_highlights, highlight_next, highlight_previous, clear_highlights)
  with new HighlightRange type — grep-style search navigation in scrollable
  content

Breaking:
- spinner / progress / progress_bar / progress_bar_colored now return
  Response (was &mut Self) (#212) — enables hover/tooltip/scrubber wiring
  on these feedback widgets. toast continues to return &mut Self.
- breadcrumb collapsed to a single BreadcrumbResponse (#213) — replaced
  4-variant API with breadcrumb() / breadcrumb_sep(). Response derefs to
  Response so .hovered/.rect/.focused continue to work after migration.

Tests: 20 unit tests in tests/v020_widgets.rs, 7 snapshot smoke tests in
tests/v020_widgets_demos.rs, 5 internal gauge string-shape tests.
Demos: examples/v020_*.rs covering each issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds four ergonomic helpers that compress common immediate-mode patterns
without changing semantics:

- `Response::on_hover(ui, text)` / `on_hover_ui(ui, f)` — chain a tooltip
  directly onto a widget response, instead of relying on the order-
  sensitive post-call `ui.tooltip(...)`. Skips alloc when the widget is
  not hovered or the text is empty.
- `Context::animate_bool(id, value)` / `animate_value(id, target, dur)` —
  zero-boilerplate implicit animation keyed by `&'static str`, stored in
  `named_states`. First call snaps to target with no visible pop;
  subsequent retargets resume from the current interpolated value.
  Returns `f64` to match SLT's animation type convention.
- `ContainerBuilder::fill()` — self-documenting alias for `.grow(1)`
  (CSS `flex: 1`, ratatui `Constraint::Fill(1)`).
- `Rect::center_in(parent)` / `center_horizontally_in` /
  `center_vertically_in` — `const fn` helpers for the inverse of
  `Rect::centered`, useful in `draw_raw` callbacks.

`pub const DEFAULT_ANIMATE_TICKS: u64 = 12` and the internal
`AnimState` type live in `src/anim.rs`. `wrap_tooltip_text` visibility
in `src/context/widgets_display.rs` is widened to `pub(super)` so
`Response::on_hover` can reuse it.

Tests:
- `tests/v020_dx_shortcuts.rs` (18 unit/integration tests covering
  hover, animation transitions, retarget, fill==grow(1) parity, and
  rect centering).
- `tests/v020_dx_shortcuts_demo.rs` (insta visual snapshot).
- `examples/v020_dx_shortcuts.rs` exercises all four helpers in one
  screen with a `pub fn render` entry point reused by the snapshot.

Quality gates: fmt, check (--all-features), clippy (lib + new
tests/example), test (--all-features), check --examples — all clean.
The pre-existing clippy errors in `examples/demo_infoviz.rs` and
`src/style/theme.rs:1064` are unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ivation

Issues: #225 ModalOptions::tab_trap, #226 ContainerBuilder::theme(),
#227 Theme::spacing density presets

#225 Modal focus trap (WCAG 2.1 SC 2.4.3)
- New ModalOptions struct with tab_trap field; default = true
- New Context::modal_with(opts, f) — opt-in focus trap
- Plain Context::modal(f) preserves legacy non-trapping behavior
- modal_with clamps focus_index back into [start, start+count) when
  set_focus_index left it outside the modal range

#226 Per-subtree theme override
- New ContainerBuilder::theme(theme) method
- finish() swaps ctx.theme + dark_mode flag for the duration of the
  closure body, restoring on exit (panic-safe via catch_unwind)
- Nested .theme() calls compose correctly: inner overrides outer
- Independent of provide / use_context

#227 Theme::spacing activation (BREAKING)
- New Theme::compact() / comfortable() / spacious() density presets
- New Theme::with_spacing(spacing) helper for any preset
- Wired theme.spacing.xs() / sm() into widgets:
  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 spacing remains Spacing::new(1) → v0.19-identical visuals
  on the 10 stock presets
- BREAKING: customized themes with non-default spacing now affect
  widget padding/gap. Migration: set ThemeBuilder::spacing() explicitly

Tests added (21 total)
- tests/v020_theme_modal.rs: 16 tests covering theme override,
  nesting, panic safety, tab_trap on/off, legacy modal() behavior,
  Tab cycling, spacing presets, theme color propagation
- tests/v020_theme_modal_demos.rs: 5 demo snapshot tests

Demos added (3)
- examples/v020_theme_subtree.rs — 4-panel theme override
- examples/v020_modal_trap.rs — focus trap with bg widgets
- examples/v020_spacing_scale.rs — compact/comfortable/spacious

Quality gates: fmt --check, check, clippy -D warnings, test, examples

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#233: ui.static_log(line) — append-only scrollback widget API
- `Context::static_log(impl Into<String>)` queues lines that flush above
  the inline dynamic area in `slt::run_static` / `run_static_with`.
- Other run modes (full-screen, inline, async) drop the buffer with a
  `cfg(debug_assertions)` warning.
- `Context::take_static_log()` accessor for tests / custom backends.
- Plumbing routes through `FrameState::named_states` under a reserved
  sentinel key — disjoint from Agent 3's perf alloc-reuse fields.

#236: WidgetKeyHelp trait + auto help overlay
- `WidgetKeyHelp::key_help() -> &'static [(&'static str, &'static str)]`
  trait for widgets to publish their bindings (allocation-free, const
  array per widget).
- `Context::publish_keymap(name, bindings)` registers entries for the
  current frame; `published_keymaps()` returns them.
- `Context::keymap_help_overlay(open)` renders an automatic modal
  listing every registered keymap — wire to `?` / `F1` for instant
  discoverability.
- Registry cleared at frame start by `run_frame_kernel` so entries do
  not leak across frames.
- New `PublishedKeymap` struct (re-exported at crate root).

#238: RunConfig::handle_ctrl_c(bool) opt-out
- New field on `RunConfig`; defaults to `true` (preserves v0.19
  behavior).
- Builder `RunConfig::handle_ctrl_c(bool)`.
- Threaded through `poll_events` and all 4 entry points (`run_with`,
  `run_inline_with`, `run_static_with`, `run_async_loop`).
- When `false`, Ctrl+C is delivered to the frame closure as a regular
  `Event::Key` with `KeyModifiers::CONTROL` — matches RataTUI's
  raw-mode semantics.
- `run()` rustdoc updated: notes that wrapping with
  `crossterm::enable_raw_mode()` / `disable_raw_mode()` is redundant
  (SLT enters raw mode automatically) and references the new opt-out
  for RataTUI parity.

Tests: 16 unit tests in `tests/v020_lib_toplevel.rs` covering
static_log accumulation/drain semantics, keymap registry frame-clearing,
the const-friendly `PublishedKeymap::new`, and `RunConfig::handle_ctrl_c`
builder + opt-out event delivery.

Demos: `examples/v020_static_log.rs`, `examples/v020_keymap_help.rs`,
`examples/v020_ctrl_c_passthrough.rs`. Snapshot tests in
`tests/v020_lib_demos.rs` with baselines committed.

Quality gates passed: fmt, check, clippy, test (all 1000+),
check --examples, no-default-features, WASM, cargo-hack each-feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

# Conflicts:
#	CHANGELOG.md
#	src/context/runtime.rs
# Conflicts:
#	CHANGELOG.md
#	src/context/runtime.rs
…h reclaim)

Addresses critical issues from Reviewer A + C audits:

1. Sentinel key constants: removed duplicate definitions in
   context::runtime, now imported from lib.rs to prevent drift. Dropped
   the (now redundant) sentinel_key_tests module. Removed
   #[cfg(feature = "crossterm")] gate on STATIC_LOG_NAMED_STATE_KEY since
   it's referenced from the unconditional runtime path. (Reviewer A #9, C)

2. split.rs: extracted RATIO_GROW_SCALE = 1000.0 const replacing two
   magic-number literals; documented why 1000 (~0.1% precision).
   (Reviewer A #7)

3. SplitPaneState: extracted DEFAULT_SPLIT_MIN_RATIO const; aligned
   doc-comment with code (was [0.05, 0.95], now correctly [0.10, 0.90]).
   (Reviewer A #4)

4. run_frame_kernel quit-path: mirrored the alloc-reuse reclaim block
   (issue #204's 6 buffers) into the early-return quit path so
   TestBackend reuse across render() calls doesn't silently revert to
   v0.19 per-frame allocation. (Reviewer C Critical)

5. CHANGELOG: documented the deliberate modal()/modal_with(default)
   tab_trap default asymmetry for migration clarity. (Reviewer A #5, C)

All gates pass: fmt, check, clippy -D warnings, test (1080+), examples.
Codifies the API design rules going forward after the v0.20 retrospective.
The v0.20 release exposed inconsistent shapes across sibling widgets
(gauge family, scrollable_with_gutter, breadcrumb, HighlightRange) which
caused both human and AI coders to guess the public surface incorrectly
on first read.

Document covers:
- Builder pattern for optional fields (Rule 1)
- f64 everywhere on public API (Rule 2)
- Opts struct at 4+ args (Rule 3)
- &mut StateType newtype for stateful widgets (Rule 4)
- Response shape: Response or XxxResponse: Deref<Response> (Rule 5)
- v0.20 retrospective table mapping each mistake to its fix
- PR reviewer checklist
- Conflict resolution priority

Linked from README.md "Learn The Library" table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The five widgets added in v0.20.0 (`gauge`, `line_gauge`, `breadcrumb`,
`scrollable_with_gutter`, `HighlightRange`) shipped with five different
argument styles. This collapses them onto a single design language so
v0.20 doesn't expose five mismatched mental models for callers to learn:

- `gauge` / `line_gauge` / `breadcrumb` become Egui-style chainable
  builders that auto-render on Drop. `.show()` captures the response.
  Deprecated shims (`gauge_w`, `gauge_colored`, `breadcrumb_sep`,
  `line_gauge_with`) remain for one minor cycle.
- `scrollable_with_gutter` takes `GutterOpts<G>` instead of three
  positional bookkeeping args. `GutterOpts::line_numbers(total, viewport)`
  is the 90% case; users rarely write the closure manually.
- `HighlightRange::single(i)` added as an idiomatic alias for `line(i)`.
- `gauge` / `line_gauge` ratio widened from `f32` to `f64` to match
  `animate_value`, chart APIs, and `progress_bar`. `GaugeResponse.ratio`
  is now `f64`.

Demos:
- `examples/v020_showcase.rs` — single-screen tour of breadcrumb,
  WidthSpec, theme override, animate_bool, on_hover, gauge family,
  named_focus, gutter highlights.
- `examples/v020_regression_panel.rs` — visual regression check
  combining overlay_anchor, modal+tab_trap, chart, sparkline, table,
  scrollable_with_gutter, error_boundary, keymap_help_overlay.

Migration recipes recorded in CHANGELOG `Unreleased > Breaking`. All
existing v0.19 tests + v0.20 tests pass; 11 new tests cover the builder
APIs, deprecation shims, drop-vs-show ergonomics, and f64 precision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds scripts/ghostty_demos.sh: an interactive launcher that discovers
v020_*.rs examples, lets the user pick (numbered menu, name list, or
'all'), and opens each chosen demo in a fresh Ghostty.app window via
`open -na Ghostty.app -e`. Falls back to Terminal.app via osascript
when Ghostty isn't installed.

Flags:
  --showcase       launches v020_showcase + v020_regression_panel
  --features       launches the 17 individual feature demos
  --build-first    pre-builds all examples so windows skip cargo logs
  --list / --help  utility flags
  all              launches every v020_*.rs found on disk

Also adds a "v0.20 Demo Catalog" subsection to README.md under the
existing Representative Examples table, mapping each demo to its issue
cluster and listing the launcher's most common invocations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…emplate

Apply the v0.20 demo consistency standard from docs/API_DESIGN.md to:
- v020_use_state_keyed (#215)
- v020_use_effect (#216)
- v020_named_focus (#217)
- v020_theme_subtree (#226)

Each demo now follows the same shape:
- Header: feature line, Demonstrates: #issue, Run cmd, Keys block, Layout art
- Imports: single alphabetized use block
- main(): minimal, delegates to render()
- pub fn render(): public so snapshot tests can pin a frame
- Spacing through ui.spacing().{xs,sm,md}() — no magic numbers
- DemoState newtype + handle_input split where the demo has mutable state
- Comments explain WHY (borrow ordering, dep semantics, theme scope), not WHAT

No behaviour change: existing v020_hooks_demos and v020_theme_modal_demos
parity tests still pass unchanged because they render their own fragments
and don't import the demos' render() function.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply the v0.20.0 art-level consistency standard to the five widget demos
covered by the post-consistency-pass APIs (#212, #213, #223, #224, #235):

- Standardized doc-header (purpose / issues / run / keys / layout)
- Alphabetized imports, KeyModifiers/RunConfig from `slt`
- Minimal `main()` delegating to `pub fn render(ui, &mut state)` so the
  same render function powers both the live binary and snapshot tests
- `theme.spacing.{xs,sm,md}()` via `ui.spacing()` instead of magic 1/2 ints
- Builder API verified for the five widgets:
    gauge: ui.gauge(ratio).label().width()
    line_gauge: ui.line_gauge(ratio).label().width().filled()
    breadcrumb: ui.breadcrumb(&[…]).separator().color().show()
    scrollable_with_gutter: GutterOpts::line_numbers(total, viewport)
    progress / spinner: Response captured for hover wiring
- Migrated `HighlightRange::single` → `HighlightRange::line` (canonical name)
- Ratios are `f64` end-to-end; `SplitPaneResponse::ratio` widened at the
  formatting boundary via `f64::from`
- Snapshot tests in `tests/v020_widgets_demos.rs` continue to pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the five v0.20.0 demos owned by the lib + modal scope to a
uniform header / template / spacing standard so reviewers can scan all of
them with the same eye:

- examples/v020_static_log.rs           (74 -> 80 lines)
- examples/v020_keymap_help.rs          (102 -> 105 lines)
- examples/v020_ctrl_c_passthrough.rs   (81 -> 80 lines)
- examples/v020_modal_trap.rs           (88 -> 146 lines)
- examples/v020_spacing_scale.rs        (53 -> 84 lines)

Standard applied:
- Header: feature one-liner + Demonstrates: #issue + Run: + Keys: +
  optional Layout: ASCII art
- Single alphabetised `use slt::{...}` import
- main() minimal — delegates to a shared body() and pub fn render(...)
- theme.spacing.{xs,sm,md}() for paddings; no magic numbers in p()/gap()
- Comments explain WHY (constants, raw_key_code rationale, snapshot
  fixture intent), not WHAT
- No commented-out code, no unwrap() outside main()

Behaviour preserved: pub fn render(ui: &mut Context) signatures
unchanged, so tests/v020_lib_demos.rs snapshot pins continue to match
byte-for-byte and tests/v020_theme_modal_demos.rs assertions still hold.

Verification (per task scope):
- cargo check on the five examples
- cargo clippy --example ... -- -D warnings
- cargo fmt -- --check
- cargo test --test v020_lib_demos (3 snapshots green)
- cargo test --test v020_theme_modal_demos (5 assertions green)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
subinium and others added 29 commits April 28, 2026 15:56
Per Reviewer D's audit, several v0.20.0 acceptance criteria were
advertised but lacked direct tests. This commit adds the missing
coverage:

#206 — kitty_flush_resize_reemits: verify InlineTerminal-style row_offset
       changes invalidate the manager's fast-path and re-emit placements.
#220 — ContainerStyle const-fn shorthand: confirm `ContainerStyle::new()
       .grow(1)` is const-evaluable and produces output identical to
       `ContainerBuilder::fill()`. (NOTE: a `ContainerStyle::fill()`
       const-fn shorthand is not yet exposed; documented in test.)
#223 — split_pane mouse drag: drive MouseDown + MouseDrag through the
       widget's prev-frame hit-map and assert state.dragging + ratio
       update. Complements the keyboard-only test that already shipped.
#233 — static_log full-screen discard: drain via Context::take_static_log
       and verify the buffer does not leak across frames. The runtime-
       layer `discard_static_log` cannot be reached from `tests/` without
       exposing a helper; gap documented in test.
#236 — WidgetKeyHelp user impl: verify a USER `WidgetKeyHelp` impl
       round-trips through Context::publish_keymap into the registry,
       and that `keymap_help_overlay(true)` renders the user's bindings.
#204 — error_boundary buffer rollback: verify a panic inside a nested
       `group()` does not leave group_stack/text_color_stack/
       deferred_draws/pending_tooltips in an inconsistent state, by
       rendering siblings after the boundary and a clean follow-up
       frame (kernel debug_asserts catch any leak).

All 4 files compile clean with `cargo clippy --tests --all-features
-- -D warnings`. Pre-existing clippy errors in `examples/demo_infoviz.rs`
and a const-block warning in `src/style/theme.rs` (visible in the
all-features clippy run) predate this branch and are out of scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two v0.20.1-deferred perf optimizations absorbed into v0.20.0.

## Fix 1: `use_state_keyed` double-clone (Reviewer A #6)

`use_state_keyed` allocated TWO Strings per call instead of the one its
docstring promised:
1. `let key: String = id.into();` — first allocation
2. `entry(key.clone())` — second clone for the HashMap entry

Switched to a `contains_key(&str)` lookup followed by an insert only on
the first-encounter path. The hot path now allocates exactly the
`id.into()` String (which is identity / no-op when the caller already
passes a `String`), eliminating the per-call extra clone.

Updated the docstring to reflect the new actual behavior (zero string
allocations beyond the call-site `Into<String>` conversion on the hot
path).

## Fix 2: `consume_split_pane_keys` per-event match hoist (Reviewer A #7)

The arrow-key consumption loop was computing four `matches!` arms per
pending key event even though `orientation` is invariant across the
loop. Hoisted the orientation match outside the loop and switched to a
`(neg, pos)` tuple of `KeyCode`s, so the per-event work shrinks to two
equality compares.

Also switched `delta != 0.0` to `delta.abs() > f64::EPSILON` for clarity
(noted by Reviewer A as a nit). Behavior is unchanged for the realistic
input range — `delta` is a sum of exact 0.05 increments, so any non-zero
result is well above EPSILON.

## Tests

Added two alloc-budget tests using the existing global counting
allocator infrastructure in `tests/v020_perf_alloc.rs`:
- `use_state_keyed_allocates_one_string_per_call`: asserts the marginal
  cost of 100 cache-hit calls stays at <= 1.5 N alloc delta over the
  baseline frame. Pre-fix would be >= 2 N.
- `use_state_keyed_cache_hit_scales_one_per_call`: asserts the
  marginal cost of going from 10 to 100 calls stays at <= 1.5 * 90.
  Pre-fix would be >= 2 * 90.

The existing `issue_223_split_pane_arrow_keys_adjust_ratio_when_focused`
test continues to pass byte-for-byte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	tests/v020_perf_alloc.rs
User reported clicks did not reach widgets in spacing_scale,
theme_subtree, etc. — those demos used `slt::run(...)` which
defaults to mouse=false. Switch to `run_with` with `.mouse(true)`.

Also replace U+2014 EM DASH in titles with ASCII colon to avoid
wide-char cell-width drift in bordered() box-drawing.
Sweep the five v0.20 widget demos for the interactions they advertise and
align the exit-key policy with the macOS Ctrl-C constraint (copy is bound
to Ctrl-C on most macOS terminals, so the previous `Ctrl-Q | Esc` minimum
is not enough — `q`, `Esc`, and `Ctrl-Q` all quit now).

Per-demo changes
- v020_split_pane: doc-comment now lists mouse drag explicitly; status row
  also surfaces `state.split.dragging` so users can see the Down/Up flag
  flip in real time alongside `drag_active`.
- v020_gauge: exit-key policy + clippy fix (drop `&format!` on the `.label`
  arg). Add three static rows (25%, 65%, 90%) so all three color tiers —
  success / warning / error — are visible simultaneously regardless of
  the animated values.
- v020_breadcrumb_response: exit-key policy. `r` now resets the path back
  to the full chain so the demo isn't a one-shot after the user clicks
  "Home" (which left them stuck on a single non-clickable bold segment).
  Status row shows depth (`current+1 / total`).
- v020_progress_response: exit-key policy. `Space` toggles pause; ←/→
  nudges the ratio by 5% (auto-pauses on nudge). Adds a static-variants
  block (0%/25%/50%/75%/100%) so the API surface is visible without
  waiting for the animation to traverse it.
- v020_gutter_highlights: exit-key policy. **Bugfix**: `N` (Shift+n) ->
  `p` for previous-highlight navigation, matching the `n`/`p` convention
  the user spec calls for and the convention used elsewhere in v0.20.

Doc-comment "Keys:" block standardized across all five files to
`q / Esc / Ctrl-Q — quit`.

All `tests/v020_widgets_demos.rs` snapshots still pass; `cargo check
--examples --all-features` and `cargo clippy` on the five demos are clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Functional audit of the three v0.20 theme/modal demos. All required
interactions (theme override, density step, modal focus trap) verified
against TestBackend snapshots and existing #225/#226/#227 unit tests.

Standardised the exit-key policy across all three demos to:

    if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL)

instead of relying on Ctrl-C (which macOS Terminal binds to copy by
default). The doc-comment "Keys:" block in each file is updated to
match. Plain `q`, `Esc`, and Ctrl-Q now all quit cleanly.

For modal_trap specifically:

  - Added an `M` key binding that opens the modal (keyboard-accessible
    alternative to clicking the "Open modal" button).
  - Quit branch is gated on `!state.show_modal` so Esc inside the modal
    dismisses it instead of quitting the app — `key_code()` is already
    blocked by the modal/overlay guard inside the event helpers, but
    the explicit `!show_modal` check is belt-and-suspenders.

The Esc-to-dismiss-modal path still uses `raw_key_code(KeyCode::Esc)`
because that bypasses the focus-filter guard so Esc works even when a
modal button has focus. No conflict with the outside-modal quit branch.

Verified:
  - cargo check  --example v020_{modal_trap,theme_subtree,spacing_scale}
  - cargo clippy --example v020_{modal_trap,theme_subtree,spacing_scale} -- -D warnings
  - cargo test   --test v020_theme_modal --test v020_theme_modal_demos
  - cargo fmt    --check on the three files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…endly redesign

Functional audit of the six v0.20 lib/utils demos. Two are stdout-only
and already exit cleanly (perf_audit, test_utils — no changes). The four
interactive demos (static_log, keymap_help, ctrl_c_passthrough, widthspec)
all needed the macOS-Ctrl-C-as-copy fix in their exit-key policy; the
keymap_help overlay had a real interaction bug; and ctrl_c_passthrough
itself was untestable on macOS as written.

Per-demo summary
- v020_static_log: standardise quit to `q / Esc / Ctrl-Q` (was `q / Esc`),
  document the macOS Ctrl-C-as-copy gotcha in the doc-comment, update the
  inline status hint and the snapshot.
- v020_keymap_help: **bugfix**: pressing `?` opens the help overlay, but
  the overlay is a modal — so on the next frame the regular `key('?')` /
  `key_code(Esc)` checks are blocked by the modal/overlay guard, leaving
  the user trapped. Switch the toggle/close handlers to `raw_key_mod` /
  `raw_key_code` so '?' and Esc keep working while the overlay is up.
  Also adopt the standard `q / Ctrl-Q` quit policy and document the
  modal-guard rationale in the doc-comment.
- v020_ctrl_c_passthrough: **redesign**. macOS terminals (Ghostty,
  iTerm2, Terminal.app) bind Ctrl-C to Copy by default — the keystroke
  never reaches the app, making the original "press Ctrl-C three times"
  demo untestable on stock macOS. Keep the headline API showcase
  (`RunConfig::handle_ctrl_c(false)`), but add three input sources that
  all funnel through the same strike counter:
    1. Real Ctrl-C — works on Linux/Windows and macOS terminals where
       the user has unbound Copy.
    2. Ctrl-G — convention is that Ctrl-G is not bound to any clipboard
       command, so it always reaches the closure as a plain Char('g')
       + CONTROL key event. macOS-friendly fallback.
    3. "Send Ctrl+C" button — synthesises the same state transition a
       real Ctrl-C would produce, so the demo is testable even when
       both Ctrl-C and Ctrl-G are intercepted.
  Doc-comment now spells out the macOS gotcha and explains that the API
  contract (`handle_ctrl_c(false)`) is independent of whether the
  terminal lets the keystroke through. Yellow banner inside the running
  UI surfaces the same warning. Quit policy: `q / Esc / Ctrl-Q`.
  Snapshot updated to match the new layout.
- v020_widthspec: replace the `q / Ctrl-C / Esc` exit-key policy with
  the standard `q / Esc / Ctrl-Q`. Update the in-frame quit hint and
  document the macOS-Ctrl-C gotcha in the doc-comment.

Verified
- cargo fmt -- --check
- cargo check --examples --all-features
- cargo clippy --example v020_{static_log,keymap_help,ctrl_c_passthrough,widthspec,perf_audit,test_utils} -- -D warnings
- cargo test --tests --all-features (all suites pass, including the
  v020_lib_demos snapshot tests with the two updated snapshots)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit finding: every v0.20 hooks demo had at least one interaction in
its `Keys:` doc-comment that did not actually function in the running
program. This commit closes those gaps and standardises the quit policy
across all three demos so a macOS user (where Ctrl-C is bound to copy)
is never trapped in the demo.

v020_named_focus (#217)
- Wrap each input row in a clickable container so clicking the row
  (not just the [Focus N] button) routes through `focus_by_name`. Native
  text_input does not claim focus on click, so without this wrapping
  the mouse experience contradicted the doc-comment.
- Add 1/2/3 numeric shortcuts as keyboard analogues of the [Focus N]
  buttons, so the headline `focus_by_name` feature is reachable from
  the keyboard alone.
- Numeric shortcuts and plain `q` are checked AFTER the body renders
  so a focused text_input consumes typed digits/'q' first; Esc and
  Ctrl-Q still quit unconditionally before render.
- Update Keys: to reflect the new bindings.

v020_use_state_keyed (#215)
- Add per-row [-] / [+] buttons that bump/drop that row's counter
  independently of the global cursor — concretely demonstrates that
  every keyed-state slot is addressable, not just the selected one.
- Standardise quit to `q || Esc || Ctrl-Q`.
- Update Keys: + help line.

v020_use_effect (#216)
- Standardise quit to `q || Esc || Ctrl-Q`.
- Update Keys: + help line. (Logic itself was already correct.)

Quit-key standardisation
- Drop `key_mod('c', CONTROL)` which collides with the macOS copy
  binding. All three demos now offer `q`, `Esc`, and `Ctrl-Q` as
  redundant exits.

Verification on this branch:
- cargo check --examples --all-features: pass
- cargo clippy on the three target demos: pass (-D warnings)
- cargo fmt -- --check: clean
- tests/v020_hooks_demos.rs: 5/5 pass
- tests/v020_hooks_focus.rs: 24/24 pass
- cargo test --tests --all-features (serial): all green
Functional audit of the three v0.20 integration demos (showcase,
regression_panel, dx_shortcuts). All required interactions (hover, click,
modal toggle, help overlay, gutter highlight nav, text input typing) are
now reachable from a real keyboard/mouse session.

Standardised the exit-key policy across all three demos to:

    if ui.key('q') || ui.key_code(KeyCode::Esc) || ui.key_mod('q', KeyModifiers::CONTROL)

instead of relying on Ctrl-C, which macOS Terminal and iTerm2 bind to copy
by default. Each demo's doc-comment "Keys:" block now mentions the macOS
gotcha explicitly.

Per-demo summary
- v020_dx_shortcuts: standardise exit policy to q/Esc/Ctrl-Q, refresh the
  inline status hint, regenerate the snapshot. Hover-tooltips on Save/Open
  buttons and the centered help overlay (?) were already wired correctly
  and verified through render path.

- v020_regression_panel: same exit-key fix, plus a real bugfix —
  - When the modal opens, `key('m')`/`key('?')`/`key_code(Esc)` are blocked
    by the modal/overlay guard inside the event helpers, so on the next
    frame the user could not close the modal or toggle the help overlay
    with the same key that opened it. Switched the close path to
    `raw_key_code` (which bypasses the modal guard) so M/?/Esc actually
    dismiss the overlay they opened.
  - Quit branch is gated on `!any_overlay` so Esc inside the modal/help
    dismisses it instead of quitting the app.
  - n/p highlight nav is also gated on `!any_overlay` so it doesn't fire
    while the user is reading the help.
  - Doc-comment "Keys:" block reformatted to match the standard table-style
    used by the other v0.20 demos.

- v020_showcase: same exit-key fix, plus a UX correction —
  - Render now runs BEFORE the global key handlers so the focused
    text_input (Name/Email) gets first crack at the keystream. Without
    this, typing 'q'/'space'/'n'/'p' in a text input would also fire the
    global quit/toggle/highlight handlers — bare `q` would quit while the
    user was typing.
  - Footer text and ASCII layout updated to match the new key list. The
    misleading "? help" entry (no `?` handler existed in the showcase)
    was removed.
  - Doc-comment "Keys:" block expanded to call out every advertised
    interaction (Tab cycle, click breadcrumb, hover Save/Cancel/"Hover me",
    Space toggle, n/p nav, q/Esc/Ctrl-Q quit) plus a note that bare `q`
    quits ONLY when no text input has focus.

Snapshot
- tests/snapshots/visual__v020_dx_shortcuts.snap: regenerated for the
  refreshed inline status hint ("Ctrl-Q to quit." → "q / Esc / Ctrl-Q
  to quit.").

Gates
- cargo fmt -- --check  ✓
- cargo check --examples --all-features  ✓
- cargo clippy on the three touched examples  ✓
- cargo test --tests --all-features  ✓ (pre-existing rust-1.95 clippy
  warnings on perf_regression.rs / demo_infoviz.rs / theme.rs are NOT in
  this audit's scope and were not introduced by this commit.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	examples/v020_named_focus.rs
#	examples/v020_use_effect.rs
#	examples/v020_use_state_keyed.rs
…e + demo §2 + 6 tours)

Cumulative integration on top of the 56 commits already on release/v0.20.0:

- F1 follow-up (#217): register_focusable_named eager-allocate-with-reuse
  pattern. The previous deferred-attach pattern made standalone calls a silent
  no-op (5 unit tests failed); now both shapes work — "name + widget" and
  "name alone" — and `register_focusable()` reuses the reserved slot when
  called immediately after. 24/24 v020_hooks_focus tests pass.
- Chart label ellipsis truncation: shared `truncate_label(text, max_cols)`
  helper used by chart legend and treemap. Replaces bare-truncated forms
  ("Memor", "TypeS") with ellipsis ("Memo…", "Type…") or drops the label
  when budget < 3 cells.
- examples/demo.rs §2 fix: lifted 66 per-frame `let mut state = ...` into a
  pub `DemoState` owned by `main()`. Tab clicks now persist instead of
  flashing for ~0.1s before snapping back to the first tab.
- §2 sweep on 4 v020 demos (keymap_help / gutter_highlights /
  ctrl_c_passthrough / dx_shortcuts): each now exposes
  `pub fn render(ui, &mut state)` plus a snapshot variant for tests.
- 6 new tour binaries (v020/cookbook/showcase/canvas/text/system_tour)
  with scrollable wrap so tall tabs stay reachable on small terminals.
- cookbook_dashboard buffer prefill — frame-0 renders the full sine
  waveform instead of an empty chart and three flat sparklines.
- autoexamples = false + 16 explicit [[example]] entries (53 → 16
  examples). Source files for the demos that compose into tours stay
  in examples/ but are reached via #[path = ...] mod includes.
- 5 new docs: DESIGN_PRINCIPLES.md, ARCHITECTURE.md, NAMING.md,
  RUSTDOC_GUIDE.md, DEMO_GUIDE.md.
- scripts/api_audit.sh — V1–V7 lint heuristics (Two-paths, Mixed verbs,
  Length info, Missing rustdoc, Outer grow missing, Fallback divergence,
  Wide-char title drift).
- typos whitelist: Pytho / Memor (intentional bare-truncation test
  prefixes used in tests/widgets.rs).
- Bump to 0.20.0: Cargo.toml + crates/slt-wasm/Cargo.toml + README +
  CHANGELOG ([Unreleased] → [0.20.0] - 2026-04-29).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… cycle) (#98)

Add `FilePickerState::selected_file()` that returns `Option<&PathBuf>` —
the resolved file path the user picked. Mark the existing `selected()`
method `#[deprecated(since = "0.20.0")]` and delegate it to the new name.

`FilePickerState` had two members spelled `selected` — `pub selected: usize`
(entry index) and `pub fn selected() -> Option<&PathBuf>` (resolved file).
The identical names returned different types, which is legal Rust but
confusing. All other state types use `selected_item()`, `selected_label()`,
or `selected_row()` — `selected()` was the only outlier.

Update the in-crate caller (`examples/demo.rs`) to use `selected_file()`.
Tests already access the `selected_file` field directly so no churn there.
The `selected()` method stays callable until v1.0 with a compiler warning
that points at the new name.

Closes #98

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a snapshot-based undo/redo history stack to `TextareaState`. The widget
pushes a `TextareaSnapshot { lines, cursor_row, cursor_col }` before every
destructive mutation (char insert, delete, Enter, Backspace, paste). Rapid
character typing coalesces into a single undoable batch — only the first
char of a typing burst pushes a snapshot, tracked via `last_was_char_insert`.
A paste is one snapshot regardless of length.

`Ctrl+Z` (`undo()`) walks `history_index` backward and restores the snapshot
content; the first undo after a typing burst captures the live tip so the
redo round-trip is symmetric. `Ctrl+Y` (`redo()`) walks the index forward
when not at the tip. Any new edit after an undo truncates the redo branch.

History is capped at `history_max` (default 100) by evicting the oldest
snapshot via `Vec::remove(0)` — the cap is small enough that the O(n)
shift is bounded.

Pure additive — no public field changed type or visibility. Adds two
read-only getters (`history_len`, `history_cap`) so integration tests can
observe the cap, and a builder setter (`history_max`) to override it.

Tests in `tests/textarea_undo.rs`:
- `textarea_undo_redo_roundtrip` — type "hi", Ctrl+Z → empty, Ctrl+Y → "hi"
- `textarea_rapid_typing_coalesces_into_one_undo` — 5 chars, one Ctrl+Z clears
- `textarea_undo_past_beginning_is_noop` — three Ctrl+Z on empty state
- `textarea_history_capped_at_max` — 20 edits with cap=4 stays bounded
- `textarea_redo_invalidated_by_new_edit` — fresh edit drops the redo tail

Closes #102

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…drag handling (#184)

Returns Response::none() for now — establishes the API extension point so
adding click-to-scroll or drag handling later does not require a further
breaking change. All in-crate callers ignore the return value, so this is
compile-compatible at every existing call site.

Closes #184

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#149)

Add `#[doc(hidden)]` to `ContainerBuilder::scroll_offset` (Option B
from the issue). The method remains `pub` so existing callers and
cargo-semver-checks tracking are preserved, but it is removed from
the public rustdoc surface where it does not belong.

Background: v0.19.1 reverted an earlier `pub(crate)` tightening
because it was a breaking change in a patch release (commit
5788dca). v0.20.0 is a minor release where breaking is allowed,
but the issue prefers the `#[doc(hidden)]` interim — promote to
`pub(crate)` only at v1.0 — so semver tracking remains intact and
backwards compatibility is unconditional.

The accompanying compile-time test
`scroll_offset_is_crate_internal_api` is updated to describe the
new `#[doc(hidden)] pub` arrangement instead of the previous
`pub(crate)` claim.

Verification:
- `cargo fmt -- --check`           clean
- `cargo check --all-features`     clean
- `cargo clippy --all-features -- -D warnings`  clean
- `cargo test --all-features --lib`  388 passed
- `bash scripts/api_audit.sh`       0 violations

Closes #149

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hoist `line_segs` out of the per-line `while` loop in `wrap_segments`.
Previously each iteration paid `Vec::with_capacity(segments.len().min(16))`
once per output line; now the buffer is allocated once per call and reused
via `std::mem::replace` after each line is moved into `lines`. The empty
buffer left behind keeps the same `scratch_hint` capacity so the first
push on the next line still skips the grow-from-zero path.

Net effect on the alloc test: `wrap_segments_alloc_count_low_via_bench_helper`
stays well within its 25_000-call budget (12 perf-alloc tests pass).
The behavior is byte-identical to the prior implementation — only the
allocation lifecycle changes — and the wrap_segments_* unit tests
continue to validate output equality.

Closes #157

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`virtual_list` previously derived `start = selected - vh + 1`, which
pinned the cursor to the bottom row of the viewport at all times.
Pressing `Up` shifted the entire viewport with the cursor, so the
cursor never moved relative to the visible window — the opposite of
how every other TUI library behaves.

This change adds a `viewport_offset: usize` field to `ListState`
(default `0`, `pub(crate)`) and switches `virtual_list` to a
sticky-viewport pattern: `viewport_offset` is only adjusted when
`selected` would otherwise leave the visible range. `Default` and
`new()` initialize it to `0`, so existing callers see no behavior
change for first-frame rendering.

Patch-safe: the new field is `pub(crate)`, no public surface changed.

Closes #192

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#193)

Calendar `h`/`l` previously mapped to `prev_month`/`next_month`, which
contradicted vim convention where `h`/`l` move the cursor by one unit.
Vim users pressing `h` expected to move back one day inside the
current month and instead jumped a whole month back.

Remap so the calendar behaves like every other vim-flavored widget:

- `h` and `l`     — cursor by ±1 day (same as `Left`/`Right` arrows)
- `[` and `]`     — previous/next month (was `h`/`l`)
- `Up`/`Down`     — ±7 days (unchanged)
- `Enter`/`Space` — select cursor day (unchanged)

Mouse navigation on the title row is unchanged. Mouse hit-testing for
`[`/`]` is not added — the title-row arrows already provide a mouse
path for month navigation.

Migration: workflows that pressed `h`/`l` to jump months must switch
to `[`/`]`. The function signature is unchanged; this is a
keybinding-meaning change only and is treated as a behavior bug fix
under semver patch tradition.

The widget rustdoc now enumerates the full keybinding table.

Closes #193

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`build_tree` previously took `commands: Vec<Command>` and consumed it via
`into_iter()`, dropping the buffer at the end of every frame. Combined with
the `commands_buf` field on `FrameState` introduced earlier in the v0.20.0
perf wave, this meant the `mem::take` from `state.commands_buf` in
`Context::new` only ever swapped in an empty `Default` Vec, and the empty
shell that landed back in `state.commands_buf` lacked the capacity used by
the previous frame — so the per-frame log2(N) Vec growth churn (8 reallocs
at ~200 commands/frame, ~480 reallocs/sec at 60fps) was never actually
amortized.

Switch `build_tree` to `&mut Vec<Command>` and `commands.drain(..)` so the
caller retains ownership of the allocation. After the drain, `len == 0`
but `capacity` is preserved. `run_frame_kernel` now reclaims the drained
Vec into `state.commands_buf` at frame end (mirroring the #204 reclamation
pattern) on both the normal and quit paths so `TestBackend`-style reuse
also benefits.

Verified impact (serial measurement to avoid global-allocator-counter
contamination from concurrent tests):
- `framestate_reuse_steady_state_alloc_count_low` 100-frame total drops
  1300 → 1100 allocations (-15%).
- New regression test `build_tree_drains_in_place_preserving_capacity`
  pins the contract: post-`build_tree`, `len == 0` and `capacity` is
  preserved, and a second `build_tree` call within prior capacity does
  not realloc.

Closes #150

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `ContainerBuilder::shrink()` — opt-in CSS-style proportional shrink.
Default behavior unchanged: containers without `.shrink()` keep their
historic overflow-by-design size. Children with `.shrink()` participate
in a `min(available, shrink_total) / fixed_width` scaling pass when the
parent row/column overflows.

Implementation
- New `Command::ShrinkMarker` variant — pushed by `ContainerBuilder
  .finish()` just before `BeginContainer` / `BeginScrollable` when
  `shrink_flag == true`. Mirrors the existing `FocusMarker` /
  `InteractionMarker` pattern, so widget-internal `BeginContainerArgs`
  constructions across the codebase are untouched.
- `LayoutNode::shrink: bool` field (default `false` in every
  constructor). Set by `build_children` when a `ShrinkMarker` is
  buffered into `pending_shrink`.
- `layout_row` / `layout_column` apply
  `floor(min_widths.get(i) * scale)` to children where
  `child.shrink && child.grow == 0` when `fixed_width > available`.
  Grow children consume leftover and ignore the flag. Non-shrink
  children keep their natural width / height.

Tests in `src/layout/tests.rs` — three regression tests pinning the
spec contract:
- `flex_shrink_default_off_preserves_overflow` — no `.shrink()` → both
  children render at width 20 in a 30-cell row (historic overflow).
- `flex_shrink_all_children_proportional_distribution` — both children
  flagged → each scales to `floor(20 * 0.75) = 15`, total 30 (exact fit).
- `flex_shrink_mixed_only_flagged_scale` — only first flagged →
  shrinker becomes 10, non-shrinker keeps 20.

Closes #161
Static-screen flushes (0% dirty) on a 200×60 buffer dropped from
~125 µs to ~130 ns (≈1000× speedup) by skipping the per-cell scan on
rows that match the previous frame.

Bench gate before implementing: `flush/static_200x60` measured 113-138 µs
on 200×60 — well above the 50 µs GO threshold from the issue spec. Added
the new bench so the gate is reproducible from `cargo bench`.

How it works
- `Buffer` gains `line_hashes: Vec<u64>` and `line_dirty: Vec<bool>`,
  both sized to `area.height`. Cell-write paths (`set_string_inner`,
  `set_char`) flag the touched row dirty.
- `recompute_line_hashes` rehashes every dirty row and clears the flag.
  Hashing uses `std::collections::hash_map::DefaultHasher` — no new
  dependency, sufficient quality for equality detection.
- `flush_buffer_diff` short-circuits a row when both
  `current.row_clean(y)` AND
  `current.row_hash(y) == previous.row_hash(y)`. Either failure falls
  through to the legacy per-cell scan, so the skip is a pure
  short-circuit without any correctness change.
- `Terminal::flush` and `InlineTerminal::flush` call
  `recompute_line_hashes` on both buffers before invoking
  `flush_buffer_diff`, matching the bench's mutable entry point.

Bench results (200×60, M-series Apple Silicon)
- `flush/static_200x60`     113-138 µs → 130-136 ns  (~1000× faster)
- `flush/sparse_change`     158-162 µs → 151-153 µs  (-2.3% in noise)
- `flush/full_redraw`       293-385 µs → 284-294 µs  (no regression)

Tests
- 8 new unit tests in `src/buffer.rs` covering dirty-flag lifecycle,
  hash determinism, style-sensitivity, resize sync.
- 2 new flush regression tests in `src/terminal.rs` validating zero
  output for fully-matching frames and selective per-row skip in mixed
  diffs.
- All 12 perf-alloc regression tests pass (`framestate_reuse`,
  `kitty_placement_flush_*`, `use_state_keyed_*`).

Closes #171

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #201's earlier wave shipped the `DebugLayer` enum and the
`Context::debug_layer()` / `Context::set_debug_layer(layer)` API, plus
the render-side fix that walks `node.overlays` from F12. This patch
closes the remaining gaps identified in the spec:

1. **Shift+F12 cycles the active layer** — previously F12 only flipped
   the on/off toggle and there was no keyboard path to scope the
   outline to a single layer at runtime.
   * Plain F12 keeps the legacy on/off toggle (modifier-strict —
     `KeyModifiers::NONE`).
   * Shift+F12 cycles `All → TopMost → BaseOnly → All` without
     touching the on/off flag.
   * The two arms guard against double-firing on the same press by
     matching modifiers explicitly (the previous `..` pattern would
     have fired both branches).

2. **Per-variant docstrings on `DebugLayer`** — each variant explains
   what's outlined and when to pick it, matching `docs/RUSTDOC_GUIDE.md`.

3. **`# Example` blocks** — added to `DebugLayer`, `Context::debug_layer`,
   and `Context::set_debug_layer` per the rustdoc guide. Each example
   compiles via `slt::run`.

Implementation
- Extracted `process_run_loop_event` from `poll_events` (was a nested
  fn) so the keybinding behavior is unit-testable without standing up
  a real crossterm event source. Marked `pub(crate)` — no public
  surface change.
- Added 4 unit tests in `run_loop_tests` covering: plain-F12 toggle,
  Shift+F12 cycle, Shift+F12 does not toggle overlay, plain-F12 does
  not cycle layer.

No breaking changes — `DebugLayer::All` remains the default, and the
existing F12 toggle behavior is preserved bit-for-bit when no modifier
is held.

Closes #201

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced by extending the release gate from `clippy --all-features` (lib only)
to `clippy --all-features --lib --tests --examples -- -D warnings`. None of
these alters runtime behavior:

- `examples/demo.rs:1463` — `let _ = ui.scrollbar(scroll)` after the v0.20
  signature change `() -> Response` (#184) makes the call use the response
  explicitly. Other call sites already ignore via statement form.
- `examples/v020_{ctrl_c_passthrough,dx_shortcuts,keymap_help}.rs` — replace
  3 hand-written `impl Default for DemoState` blocks with `#[derive(Default)]`
  (clippy::derivable_impls). The hand-written impls returned exactly
  `Default::default()` for every field, so the derive is byte-equivalent.
- `src/style/theme.rs:1151` — wrap `assert!(!_CONST_LIGHT.is_dark)` in a
  `const { … }` block (clippy::assertions_on_constants). The const-evaluation
  regression test still pins the same property; the const block now also
  surfaces failure at compile time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…closed-already + v0.21 deferrals

- ### Fixed expanded with the 11 newly-applied fixes (#149, #150, #157,
  #161, #171, #184, #192, #193, #201, plus #98/#102 already in HEAD).
- ### Closed (verified already applied in v0.19.3) lists the 8 v0.19.x
  issues whose fix landed in PR #202 (#134/#146/#147/#148/#152/#153/#155/#162).
  No separate v0.20 commit was needed; PR body will tell GitHub to close
  these so the v0.19.x milestone count goes to zero.
- ### Known v0.21 migration notes documents the 4 critical drifts the
  design-discipline audit surfaced that genuinely need a breaking change
  to resolve: Hook-family naming asymmetry, scrollbar/separator return
  shape, status-family fake Response::none(), ScrollState::progress f32
  outlier. Plus a brief note on the patch-safe doc-only gaps deferred
  to a 0.20.x follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eaningful

`tests/v020_perf_alloc.rs` uses a global `MEASURING: AtomicBool` flag plus
a counting global allocator. CI runners have higher test parallelism than a
local laptop, so other parallel tests' allocations contaminate the counter
during the measurement window — the test then trips its 1500-call budget
even though the actual single-thread count is ~1100 (-15% vs the v0.19.3
baseline of ~1300, post-#150 drain).

The honest fix is single-threaded execution at the workflow level. Adds
~10s to the CI test step but keeps the perf budgets catching real
regressions instead of being inflated to absorb the noise.

Same change applied to `release.yml` so the release gate enforces the
same execution model.

A v0.21 follow-up tracking issue: switch the perf-alloc test infrastructure
to thread-local counters or `serial_test` so the workflow flag is no
longer required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@subinium subinium merged commit b0b9453 into main Apr 28, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment