Skip to content

feat: v0.21.0 — competitive gap-closing release (35 issues)#275

Merged
subinium merged 72 commits into
mainfrom
release/v0.21.0
May 29, 2026
Merged

feat: v0.21.0 — competitive gap-closing release (35 issues)#275
subinium merged 72 commits into
mainfrom
release/v0.21.0

Conversation

@subinium

Copy link
Copy Markdown
Owner

What

v0.21.0 closes the competitive-analysis gap audit (vs ratatui / bubbletea / ink) — 35 issues implemented across 9 parallel worktree waves, each integrated with a full --all-features gate before push.

Highlights

See CHANGELOG.md [0.21.0] for the full per-issue notes (Breaking / Added / Fixed / Perf / Deprecated / CI / Docs).

Gate (all green locally)

cargo fmt -- --check · clippy --all-features --all-targets -D warnings · test --all-features (1672 tests) · check --no-default-features · check -p slt-wasm --target wasm32-unknown-unknown · cargo hack check --each-feature --no-dev-deps · cargo audit (0 advisories — ratatui dev-dep dropped to avoid RUSTSEC-2026-0002 via lru) · cargo deny check · typos.

Closes

Closes #222, closes #234, closes #240, closes #241, closes #242, closes #243, closes #246, closes #247, closes #248, closes #249, closes #250, closes #251, closes #252, closes #253, closes #254, closes #255, closes #256, closes #257, closes #258, closes #259, closes #260, closes #261, closes #262, closes #263, closes #264, closes #265, closes #266, closes #267, closes #268, closes #269, closes #270, closes #271, closes #272, closes #273, closes #274

🤖 Generated with Claude Code

subinium and others added 30 commits May 29, 2026 10:12
text(...).wrap() collapsed pre-formatted multiline strings into a single
line: '\n' has display width 0 and was glued into the current word token,
so it never triggered a break. This broke every path that renders text
carrying hard line breaks — LLM/streaming output, file contents, and
error/stack-trace dumps (exactly SLT's AI-native target).

Resolve hard breaks before soft wrapping: split on '\n' (CRLF normalized)
into paragraphs, then word-wrap each independently. wrap_lines uses
split('\n') (not str::lines()) so a trailing '\n' yields a trailing empty
line. wrap_segments factors its kernel into wrap_segments_paragraph and
keeps a no-newline fast path, so output is byte-identical for the common
no-break case. A '\n'/'\r' never reaches a cell.

17 new tests cover basic/blank/leading/trailing breaks, soft wrap within a
paragraph, CJK, CRLF normalization, zero-width, and no-control-char output.

Closes #246

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An open select with more than a handful of options was impractical with
cursor-only navigation. Typing now filters the option list (fuzzy
subsequence match, reusing CommandPaletteState::fuzzy_score), arrow keys
navigate the filtered subset, Backspace edits the query, and Esc clears a
non-empty query before closing. Enter selects the cursor's filtered item.

SelectState gains a public `filter` field (callers can pre-fill or inspect
the live query) and a filtered_indices() helper. The cursor now indexes the
filtered subset; printable keys (including 'j'/'k'/space) type into the
filter, so arrow keys are the sole navigation while open. An empty query
preserves the prior rendering byte-for-byte.

6 tests: filtered_indices (empty / fuzzy-subsequence / no-match) + render
(narrowing, no-match line) + key-driven type-then-Enter selection.

Closes #250

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SLT could not deliver modifier-only key events (a bare Ctrl/Shift/Alt/
Super press or release). The pipeline dropped them at two points: the
Kitty flags we push omitted REPORT_ALL_KEYS_AS_ESCAPE_CODES, so a
spec-compliant terminal never emitted them, and from_crossterm had no
KeyCode::Modifier variant, so even a delivered event hit the
`_ => return None` catch-all.

Fix both: add a public ModifierKey enum mirroring crossterm's
ModifierKeyCode with SLT naming, a KeyCode::Modifier(ModifierKey)
variant, and a from_crossterm arm mapping all 14 variants. Thread a new
opt-in RunConfig.report_all_keys bool (default false, no effect without
kitty_keyboard) through Terminal/InlineTerminal::new ->
TerminalSessionGuard -> write_session_enter via a pure kitty_flags
helper. EventBuilder gains key_modifier() for headless tests. keymap's
in-crate exhaustive KeyCode match gains a Modifier display arm.

Closes #261

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pagination math and key/click navigation were welded onto TableState, so
there was no composable way to page over arbitrary item counts (wizards,
slide decks, carousels). This adds a standalone PaginatorState owning a
page index over a generic item count, plus a paginator() interactive
widget with Dots and Arabic styles. TableState is left untouched; sharing
the page math is deferred to a follow-up to keep this PR additive and
non-breaking.

- PaginatorState (total_items, per_page, page, style) + PaginatorStyle
  (Dots default, Arabic) in src/widgets/collections.rs.
- paginator()/paginator_colored() on Context follow the tabs() pattern:
  register focus, consume Left/h/PageUp (prev) and Right/l/PageDown (next),
  click a dot to jump, click counter halves to step. Dots auto-fall back
  to Arabic past 12 pages to avoid overflow. Response.changed iff page
  moved. No-op-safe when total_items == 0.
- Re-exported from lib.rs; imported in the context.rs facade.
- Unit tests on PaginatorState math + TestBackend integration tests.

Closes #256

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
key_seq was advertised as multi-key sequence detection (vi gg, leader
keys) but only matched when every key arrived in one poll batch, making
chords unreachable at human typing speed. The matcher was stateless
across frames: each frame built a fresh events vector and scanned only
that frame.

Add a persistent per-context ChordState buffer that round-trips through
FrameState using the same std::mem::take out/in pattern as keyed_states
(moved out in Context::new, restored at frame end in run_frame_kernel,
on both the quit and normal paths). The new key_chord / key_chord_timeout
buffer partial input across frames, fire exactly once on completion,
consume the completing key so downstream widgets do not double-handle it,
reset on a mismatching key (longest-suffix overlap, vi semantics), and
expire stale prefixes via the tick clock used by notifications/animation
(default DEFAULT_CHORD_TIMEOUT_TICKS = 60, ~1s at 60Hz). Leader notation
(<space> / <leader> tokens, or a literal space) is supported; modifier
chords remain out of scope.

key_seq is kept as a #[deprecated(since = "0.21.0")] thin wrapper that
delegates to key_chord, so existing callers compile and silently gain
correct cross-frame behavior. The four misleading doc sites are corrected
to describe real cross-frame behavior and point at key_chord.

Closes #262

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every string passed to `Buffer::set_string` / `set_string_linked` was
stored in logical order and painted left-to-right by column, so RTL
scripts (Hebrew, Arabic, Farsi, Syriac, Thaana) rendered mirrored and
mixed LTR+RTL runs were scrambled. Terminals in cell-addressed mode do
not apply the Unicode Bidirectional Algorithm themselves — they paint
exactly the glyph placed at exactly the column — so the logical→visual
transform must happen before the cell-write loop.

Add a `bidi` module helper to the single chokepoint `set_string_inner`:
`needs_bidi_reorder` is a cheap conservative character-class pre-scan
that lets pure-LTR input skip the reorder entirely (no allocation, no
`unicode-bidi` call — byte-identical to before), and `reorder_line_visual`
runs UAX #9 only on lines that actually contain RTL/explicit-bidi
codepoints. The existing width, clip, zero-width-attachment, wide-char
trailing-blank, control-char sanitization, and OSC 8 hyperlink logic are
unchanged and apply to the reordered glyph stream.

Gated behind a new default-on `bidi` Cargo feature (`unicode-bidi` 0.3,
optional). With `--no-default-features` the fast path is the only path,
so behavior is identical to v0.20.1. Render-only: input state stays
logical; caret/selection mapping and Arabic shaping are out of scope.

Closes #260

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a palette/swatch-grid + hex-entry color picker over the existing
Color model. There was no runtime way to choose a Color, leaving a
completeness gap for theming, settings, and design-tool UIs.

- ColorPickerState + PickerMode in src/widgets/selection.rs, with
  new(colors), tailwind() (22 c500 shades), columns() builder, and a
  selected() accessor that prefers a parsed #RRGGBB/#RGB hex value.
- Context::color_picker / color_picker_colored on the interactive
  surface: arrow keys / hjkl move a clamped 2D cursor (no wrap), Tab
  toggles palette/hex, hex keystrokes route to an embedded
  TextInputState with validation, Enter/Space confirm, and left-clicks
  hit-test swatch cells. All handled keys/clicks are consumed.
- Swatches emit a full-RGB background and a contrast_fg marker; the
  terminal flush layer downsamples per ColorDepth, and each Rgb swatch
  carries a #RRGGBB label so the picker stays legible under NoColor.
- New WidgetTheme.color_picker slot + const builder; ColorPickerState /
  PickerMode re-exported from the crate root; color_picker example.
- Unit tests (hex parsing, selected(), depth contract), TestBackend
  integration tests (render, nav, changed, hex, click), and a proptest
  asserting the selection stays in bounds and never panics.

Architecture note: the issue sketch referenced self.color_depth(), but
the Context does not track color depth (it is applied at terminal
flush). The widget therefore emits full RGB and relies on the existing
flush-time downsampling, matching every other widget.

Closes #257

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/context.rs
#	src/lib.rs
#	src/widgets/tests.rs
#	tests/widgets.rs
Extend CalendarState with an opt-in range mode (anchor + extent stored as
absolute CalDates so a range can span month/year boundaries) and an
optional hour/minute row. Single-date mode stays the default and renders
byte-for-byte unchanged.

- New public types: CalDate, CalendarSelect; new methods with_range,
  with_time, mode, selected_range, selected_time (all additive).
- Keyboard: Shift+Left/Right/Up/Down and Shift+H/L extend the range from
  the anchor by the same ±1/±7 day deltas; Enter/Space sets the anchor,
  Shift+Enter/Space sets the extent. Plain h/l (=±1 day) and [/] (=±1
  month) bindings from v0.20 are preserved.
- Mouse: plain left-click anchors, Shift+left-click sets the extent
  (reuses existing click rect math). Added EventBuilder::click_with for
  modifier-carrying clicks.
- Render: interior range band uses theme.surface, endpoints use
  selected_bg/fg, cursor highlight retained; time row renders HH:MM only
  when enabled. All colors from self.theme.*.
- Response.changed is the OR of selection/range/time deltas; all consumed
  keys/clicks are pushed so events don't leak.

Tests: range extend, month-boundary span, order normalization, shift+click
extent, band render, time on/off, changed-flag, and a proptest for range
ordering; new snapshot_calendar_range (snapshot_calendar untouched).

Closes #254

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts, multi-row selection

Extend TableState and Context::table with three structural capabilities while
keeping the existing string-grid API byte-identical for all-Auto, single-select
callers:

- TableColumn width enum (Auto/Fixed/Min/Max/Percent) settable per column via
  TableState::column_widths_spec; resolve_column_widths overlays the spec onto
  the content-derived widths each frame (Percent against the live area width).
  Cells are clamped/truncated with an ellipsis to fit Fixed/Max widths.
- Multi-row selection: multi_selected HashSet + selection_anchor on TableState,
  with toggle_row/select_range/select_single/clear_selection helpers and
  prune_selection wired into rebuild_view so stale view indices never leak on
  set_rows/set_filter/sort. Space toggles the focused row, Ctrl+Space toggles
  without clearing, Shift+Up/Down extends a contiguous range; plain Up/Down
  still moves the cursor only. Mouse: plain click replaces with one row,
  Shift+click ranges from the anchor, Ctrl+click toggles.
- Context::table_with(state, |row, col, raw| -> (String, Style)): per-frame
  per-column renderer (no stored closure, so TableState keeps deriving Clone).
  Rows with a renderer emit one styled segment per column via line(); the plain
  path keeps the single styled-line fast path.

Response.changed now also fires on multi-selection changes. Selected set members
render with a dimmed selection background distinct from the brighter cursor row.

Re-exports TableColumn from lib.rs. Adds TestBackend integration tests (widths,
multi-select keyboard/mouse, pruning, selected_rows order, per-column fg,
changed flag) plus crate-internal width-resolution unit tests and proptests for
Fixed/Min/Max/Percent invariants.

Closes #251

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-enter on resume

On Unix, Ctrl+Z (SIGTSTP) stops the process in kernel space before any Drop
glue or panic hook can run, leaving the terminal in raw mode on the alternate
screen with the cursor hidden. SLT had no signal path to restore it, and no
re-entry on SIGCONT (`fg`), so the terminal stayed broken until exit/`reset`.

Install a signal-hook background thread per run loop (all four modes:
run_with, run_inline_with, run_static_with, run_async_loop) that, on SIGTSTP,
runs the full session teardown (disable raw mode, leave alt screen for
fullscreen, show cursor, disable bracketed paste/focus/mouse/kitty), then
re-raises the default-disposition SIGTSTP to genuinely stop. On SIGCONT it
re-enters the same session from a RunConfig snapshot and flags NEEDS_FULL_REDRAW,
consumed at the top of each loop iteration to force a clear + repaint.

- New RunConfig::handle_suspend (default true, opt-out builder); #[non_exhaustive]
  keeps it backward-compatible.
- Unix-only, gated #[cfg(all(feature = "crossterm", unix))]; compiles to a
  no-op on Windows/WASM/non-crossterm. no-default-features and wasm32 unaffected.
- Uses only signal-hook's safe API (Signals iterator + emulate_default_handler),
  preserving #![forbid(unsafe_code)] — no unsafe introduced.
- has_terminal guard makes repeated/out-of-order signals idempotent.
- signal-hook added as a unix-gated optional dep pulled by the crossterm
  feature; already vendored transitively via crossterm, so no new lock entries.

Tests: suspend/resume escape-sequence unit tests, NEEDS_FULL_REDRAW idempotency,
RunConfig::handle_suspend default/builder, and an end-to-end signal-delivery
test that installs the handler, raises a real SIGCONT, and asserts clean
teardown.

Closes #263

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

Move form validation from a positional, fn-pointer slice to per-field,
state-capturing closures co-located with each field.

- Add `Validator` newtype wrapping `Box<dyn Fn(&str) -> Result<(), String>>`
  (reuses the existing `TextInputValidator` alias) so validators can close
  over state — impossible with the old `fn` pointer.
- Add `ValidateTrigger` (`OnChange`/`OnBlur`/`Manual`, default `OnBlur`) and
  extend `FormField` with a chainable `.validate()` builder, `.on_change()`/
  `.on_blur()`/`.manual()`, and `run_validators()`.
- `Context::form_field` now runs a field's validators automatically per its
  trigger. `text_input`'s container-assembled `Response` does not yet carry
  `lost_focus`, so blur is derived from a focus edge tracked on the field
  (`observe_focus`) rather than changing the `text_input` public API.
- Add a `validators` built-in module: required, min_len, max_len, email,
  range_i64, range_f64, one_of, and a handwritten glob-style `regex`
  (no regex/email crate dependency).
- `FormState` gains `is_valid()`, `errors()`, `validate_all()`, and
  `validate_with()` (cross-field rules). The positional `validate(&[..])` is
  retained as a `#[deprecated(since = "0.21.0")]` shim with identical behavior.
- Async validation behind the existing `async` feature: `validate_async`
  spawns on the tokio runtime and `form_field` polls a oneshot each frame
  (`poll_async`), surfacing the result as the field error.
- Re-export `Validator`, `ValidateTrigger`, `validators`, and (async-gated)
  `AsyncValidation` from the crate root.
- Migrate the two demo examples off the deprecated positional API.

Tests: unit tests for every built-in + builder + aggregate API, TestBackend
integration tests for OnChange/OnBlur/Manual triggers, an allow-deprecated
test for the shim, and a proptest file asserting min_len/max_len agree with
`chars().count()` over arbitrary Unicode.

Closes #252

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/context.rs
#	src/lib.rs
#	tests/widgets.rs
…lti-select

# Conflicts:
#	src/context/widgets_interactive/selection.rs
#	src/lib.rs
# Conflicts:
#	src/context.rs
#	src/lib.rs
#	src/widgets/tests.rs
Two issues surfaced only under `--all-features` after integrating Wave 2
(the worktree agents self-gated on default features):

1. terminal.rs: a `#[cfg(test)]` `TerminalSessionGuard` initializer in the
   suspend/resume round-trip test (added by #263, based on v0.20.1) was
   missing the `report_all_keys` field that #261 added to the struct. Default
   builds skip the test target so it compiled there; `--all-features --tests`
   caught it. Threaded `snapshot.report_all_keys` through.

2. buffer.rs: the #260 bidi proptest `reorder_preserves_total_display_width`
   asserted a FALSE invariant. `unicode-width` 0.2 is contextual — Arabic
   lam+alef (U+0644 U+0627) forms a single-cell ligature (width 1) while
   alef+lam (U+0627 U+0644) does not (width 2) — so a correct visual reorder
   legitimately changes the rendered cell count. `reorder_line_visual` is
   correct (it cleanly reverses the run). Replaced the assertion with the
   invariant that actually holds: reorder is a pure codepoint permutation
   (multiset equality of chars). Removed the stale proptest-regressions entry.

Full `cargo test --all-features` + `cargo clippy --all-features --all-targets`
now green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a wall-clock, async-free scheduler built on the existing per-frame
state round-trip (mirroring named_states). New Context methods:

- schedule(id, dur) -> bool: one-shot, fires once at/after the deadline
- every(id, dur) -> u32: recurring, returns whole intervals since last
  frame (never drops or double-counts across stalled frames)
- debounce(id, dur, dirty) -> bool: typeahead primitive; fires once after
  a quiet window, re-armed on every dirty frame
- exclusive(group, id) -> bool: most-recent claim wins; superseded ids
  stay cancelled (Textual @work(exclusive=True) semantics)
- cancel(id): drop a slot, re-armable
- elapsed(id) -> Option<Duration>: wall-clock since first scheduled

Deadlines use a single std::time::Instant sampled at frame start
(frame_instant), not diagnostics.tick, because run_frame_kernel never
advances the tick. The SchedulerState timer table is moved into Context
at frame start and back at frame end, where untouched slots are GC'd so
abandoned timers cannot leak. Works with --no-default-features and
without the async feature; no new dependencies.

Complements #234 (async ctx.spawn/poll), which owns off-thread work;
this issue only defers work to a future frame on the existing loop.

Closes #248

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a file-backed theming pipeline behind the existing `serde` feature plus
a new optional `theme-watch` feature for live hot-reload.

- `Color::from_hex` / `to_hex` and a custom symmetric serde impl so colors
  serialize/deserialize as human-friendly tokens ("#rrggbb"/"#rgb", named,
  "indexed:N") and round-trip through TOML.
- `WidgetTheme` gains serde derives; `WidgetTheme`/`WidgetColors`/`Theme`
  get struct-level `serde(default)` so partial documents fill from defaults.
- New `ThemeFile { theme, widgets }`, `ThemeLoadError`, and convenience
  `Theme::from_toml_str` / `Theme::load` in the new `src/style/theme_io.rs`.
- `ThemeWatcher` (feature `theme-watch`, via `notify`) with non-blocking
  `poll()` that returns the latest parsed theme on change and keeps the last
  good theme on a parse error (no panic, logs to stderr).
- Re-export new types from `lib.rs`; add `examples/theme_hot_reload.rs`.

Optional deps `toml` (under `serde`) and `notify` (under `theme-watch`) stay
out of the default and wasm builds. Purely additive; no existing fields,
signatures, or derives changed.

Closes #267

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a stateful numeric stepper field as a sibling to the bar-style slider.
NumberInputState holds value/min/max/step plus an integer-mode flag, an
in-progress edit buffer, and a parse_error. Context::number_input renders a
`▾ value ▴` field that:

- adjusts via Up/k (+step) and Down/j (-step), clamped to [min, max];
- adjusts via scroll wheel over the field rect (prev_hit_map pattern);
- accepts direct typing (digits, one `.` in float mode, leading `-` when
  min < 0), committing on Enter, discarding on Esc, editing on Backspace;
- sets parse_error and leaves the committed value untouched on invalid input;
- rounds to whole numbers in integer mode, formats floats via
  format_compact_number;
- consumes all handled key/scroll events and themes via theme.primary /
  text_dim / error.

Re-exported NumberInputState from lib.rs and the context.rs widgets facade.
Adds unit, TestBackend integration, and proptest coverage.

Closes #255

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SLT segmented text by Unicode scalar everywhere a string is wrapped,
truncated, or a cursor moves. A user-perceived character that spans
multiple scalars (ZWJ flag 🇰🇷, family emoji 👨‍👩‍👧‍👦, Devanagari
क्षि, Thai กำ) was sliced mid-cluster when it landed on a wrap /
truncate boundary or under split_long_word, and textarea/text_input
cursors stepped into the middle of a cluster.

Route every segmentation site through extended grapheme clusters
(unicode-segmentation graphemes(true)), measuring width on the whole
cluster string via UnicodeWidthStr. The recent paragraph-split-on-'\n'
behavior in wrap_lines / wrap_segments is preserved — iteration is now
per-cluster *within* each paragraph; the no-newline fast path is
unchanged.

- Cargo.toml: add unicode-segmentation = "1" (unconditional core dep,
  no_std-friendly / WASM-safe, matches unicode-width).
- layout/tree.rs: wrap_lines width accumulation, split_long_word
  chunking, and wrap_segments_paragraph advance by cluster; space
  detection compares the cluster to " ".
- layout/render.rs: truncate_with_ellipsis stops on cluster boundaries
  (a cluster that would overflow is dropped whole).
- context/helpers.rs: add grapheme_count / byte_index_for_grapheme /
  cluster_width; textarea_build_visual_lines soft-wraps per cluster
  (char_start/char_count are now cluster indices). byte_index_for_char
  retained for the few remaining scalar cursor callers.
- context/widgets_input/{textarea_progress,text_input}.rs: Left/Right/
  Home/End/Backspace/Delete and column counts step by cluster; the
  render cursor-glyph loop iterates clusters while still emitting a
  scalar cursor_offset (what the renderer consumes).

ASCII/Latin-1 behavior is byte-identical (cluster index == scalar index
for ASCII); all existing wrap/truncate/textarea tests stay green. Adds
layout-kernel tests (ZWJ flag, family emoji, Devanagari, Thai,
split_long_word, wrap_segments, truncate), a no-partial-cluster
proptest, and TestBackend cursor tests for textarea/text_input.

Closes #259

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/context.rs
#	src/context/widgets_input/tests.rs
#	src/lib.rs
…lusive)

# Conflicts:
#	src/context.rs
#	src/context/runtime.rs
#	src/lib.rs
# Conflicts:
#	Cargo.toml
#	src/lib.rs
…ght)

Add a row-accurate variant of `virtual_list` so chat/feed bubbles of
differing heights virtualize correctly while preserving the existing
fixed-height fast path byte-for-byte.

`ListState` gains an optional per-item height model: caller-provided
`item_heights` (clamped to >= 1) plus a lazily-rebuilt prefix sum
(`row_prefix`) gated behind a dirty flag, and a `viewport_row_offset`
that tracks the cumulative top-row offset while `viewport_offset` keeps
its "top item index" meaning. New public API: `with_item_heights`,
`set_item_heights`, `clear_item_heights`.

`Context::virtual_list_variable` computes the visible item range so the
rendered items sum to at most `visible_height` rows; `PageUp`/`PageDown`
advance the selection by the item count that fills a viewport of rows,
not a fixed item count. An item taller than the viewport renders from
its top and is never skipped. Rendering stays O(visible): prefix-sum
lookups are O(log n) via binary search, rebuild is O(n) behind the dirty
flag. With no per-item heights set, output is identical to the existing
`virtual_list` (both delegate to a shared `virtual_list_impl`). Purely
additive, not breaking.

Tests: inline unit tests for prefix-sum/clamp/dirty-gate; TestBackend
tests for fixed-fallback parity, height-respecting range, row-based page
down, tall-item rendering, item-counting "more" affordances, and
empty/zero-height guards; a proptest asserting the range is non-empty,
bounded by visible_height (or a single tall item), and contains the
selection without panicking.

Closes #253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
subinium and others added 28 commits May 29, 2026 15:23
Resolves v0.21 migration note #2 / ARCHITECTURE.md M4 (no `()` returns).

- separator() / separator_colored(): drop the legacy `&mut Self` text-chain
  (the chained `.bold()` / `.fg()` were a no-op on the cached separator
  string) and return a real Response routed through self.interaction().
- scrollbar(): route the return value through the track container's own
  interaction Response so the hit-test rect is populated for future
  click-to-jump / drag-to-scroll (#249); receiver changes from
  `&ScrollState` to `&mut ScrollState` to reserve that mutation point.

Statement-form callers compile unchanged under `cargo build`; in-crate
example / test / bench statements get `let _ =` to satisfy the
must_use lint under `-D warnings`. scrollbar callers pass `&mut scroll`.

Adds tests for the new Response shape (rect populated on render, none
when content fits) and CHANGELOG breaking-change note.

Closes #241

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Context::use_memo` returned `&T`, a live immutable borrow of `&mut Context`
that could not be held across later `ui.*` calls — the lone asymmetry in the
hook family, where `use_state*` already return owned `State<T>` index handles.
The undocumented deref-and-copy-at-call-site convention (`*ui.use_memo(...)`)
was exactly the implicit rule LLM-generated code gets wrong.

`use_memo` now returns a `Memo<T>` index handle (re-exported at crate root)
mirroring `State<T>`: it stores only the hook slot index + PhantomData and
holds no borrow of `Context`, so it composes with later `ui.*` calls. Read
with `.get(ui)` (&T) / `.copied(ui)` (T: Copy). The memo slot's internal
storage changed from `(D, T)` to a `pub(crate) MemoSlot<T>` carrying
type-erased deps, so the read path needs no `D` re-parameterization.

The original `&T`-returning body is preserved verbatim as
`use_memo_ref`, marked `#[deprecated(since = "0.21.0")]` for one cycle.

Migrated all in-repo callers (examples/demo.rs, tests/kernel_parity.rs,
tests/kernel_proptest.rs, tests/widgets.rs) to `.copied(ui)`.

Closes #272

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Negotiate terminal capabilities at session enter instead of guessing from
TERM/TERM_PROGRAM allowlists. A one-shot DA1/DA2/XTGETTCAP + Kitty-graphics
query (bounded to <=150ms, reusing the existing OSC round-trip infra, cached
in a OnceLock) fills a read-only Capabilities snapshot:
truecolor / sixel / kitty_graphics / kitty_keyboard / sync_output plus a
BlitterSupport { half, quad, sextant } set.

- DA1 attribute 4 sets sixel; XTGETTCAP Tc (or COLORTERM) sets truecolor;
  Kitty graphics confirmed via the APC _G i=31 ack (DA2 id 41 as a secondary
  signal); env-fallback for Kitty/Ghostty/WezTerm when the probe is silent.
- Capabilities::best_blitter() drives an automatic ladder
  (Kitty > Sixel > sextant > half-block); app code never selects a protocol.
- Context::capabilities() -> &Capabilities exposed read-only (diagnostics only)
  and re-exported from the crate root alongside Blitter/BlitterSupport.
- sixel_image() consults the probed sixel flag first, falling back to the env
  allowlist only when the probe is silent.
- Companion fix: add "wezterm" and "ghostty" to the terminal_supports_sixel()
  TERM_PROGRAM env-fallback allowlist so both render images instead of
  [sixel unsupported].
- Probe skipped on headless backends (TestBackend / piped stdout); every field
  defaults conservatively. Non-breaking and additive; SLT_FORCE_SIXEL unchanged.

Pure-string parser unit tests (DA1/DA2/XTGETTCAP/Kitty ack), best_blitter
ladder + exhaustive precedence sweep, wezterm/ghostty allowlist regressions,
and a TestBackend fallback-render test.

Closes #264

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add two opt-in flex properties to ContainerBuilder, both non-breaking:

- wrap(): a row() flows children that overflow the available width onto
  subsequent lines, stacking lines on the cross axis. Within-line spacing
  uses gap/col_gap; between-line spacing uses row_gap (else gap). A child
  wider than the full width occupies its own line (clipped, as a
  single-line row clips). A wrapping row reports the correct multi-line
  min_height so a parent col() reserves enough rows. Row-only; a
  documented no-op on col().
- basis(u32): flex-basis, the initial main-axis size that grow grows from
  and shrink (#161) shrinks from. Defaults to the child's min-width
  (current behavior) when unset; feeds the grow/shrink resolution as the
  per-child base size.

Threaded via new scalar markers Command::WrapMarker(i32) (carrying the
cross-axis gap) and Command::BasisMarker(u32), mirroring the ShrinkMarker
(#161) pattern, so the Command enum stays <= 128 bytes and
WidthSpec/HeightSpec/Constraints are untouched (their 24-byte guard
holds). layout_row now extracts a shared layout_row_line per-line body;
the wrap pass partitions children greedily by base width and lays each
line out with it, advancing a cross-axis cursor. The signed gap_overlap
math (#222) is preserved on both paths.

_ASSERT_LAYOUT_NODE_SIZE bumped 320 -> 328 for two new 4-byte scalar
fields (cross_gap: i32, flex_basis_raw: u32); wrap_children: bool packs
into existing padding. Boxing two 4-byte fields would cost more.

Tests: 11 tree-level unit tests (wrap two-lines, wrap-off single-line,
row_gap between lines, oversize-child own line, min_height reserves
lines, basis feeds grow, basis feeds shrink, basis none falls back) plus
2 TestBackend render tests.

Closes #258

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

The status-family widgets (badge, badge_colored, key_hint, stat,
stat_colored, stat_trend, empty_state, empty_state_action) previously
returned Response::none() unconditionally, so the signature was
misleading — `if ui.badge("New").hovered { … }` compiled but never
fired and `.on_hover(...)` tooltips could not attach.

Route each through the interaction system so the Response carries real
hovered / gained_focus / lost_focus / right_clicked state:
- single-line widgets (badge, badge_colored, key_hint) reserve the slot
  via Context::interaction() before their text command, matching the
  spinner / gauge / separator pattern so the marker attaches to the
  widget's rect.
- column widgets (stat, stat_colored, stat_trend, empty_state) return
  the Response the container layout already produces instead of
  discarding it.
- empty_state_action keeps clicked / changed tied to its action button
  while surfacing hover / right-click / rect for the whole placeholder.

Statement-form callers compile unchanged; only callers asserting
response.hovered == false now observe real state.

Adds TestBackend hover tests for all eight widgets (two-frame
register-then-hover) plus a not-hovered negative case.

Closes #242

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…croll on thumb

Fills in the interaction behind #241's `&mut ScrollState -> Response`
signature so the scrollbar is a real input surface, mirroring the
`split_pane` drag handle.

- Click-to-jump on the track (outside the thumb): a left mouse-down maps
  the clicked row proportionally to the content (top cell -> offset 0,
  bottom cell -> max_offset).
- Drag-to-scroll on the thumb: a mouse-down on the thumb sets
  `ScrollState::dragging`; subsequent drags scroll proportionally to the
  cursor's y within the track (even off the track on x); mouse-up clears
  it.

The handler hit-tests the previous-frame track rect from `prev_hit_map`
(the slot the track container reserves) and consumes only the mouse
events it acts on, so wheel scroll over a sibling `scrollable()` keeps
working and is never double-counted. Offset moves clamp via the existing
`scroll_down` max-offset math; a moving frame reports
`Response.changed = true`. Modal suppression matches `mouse_down`; with
no overflow the bar renders nothing, consumes nothing, and leaves drag
state untouched.

`ScrollState` gains `pub dragging: bool` (default false) and
`pub fn set_offset(&mut self, offset: usize)` (clamping) -- both additive
and non-breaking. Vertical-only; horizontal click-jump and page-on-click
stay out of scope.

Tests: pure-function unit tests + proptest for the pixel->offset mapping
(clamped, monotonic) in layout.rs; TestBackend interaction tests for
click-to-jump (top/middle/bottom), thumb drag, drag off-track-x, wheel
interop, modal suppression, no-overflow inertness, and thumb re-render.

Closes #249

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d; add wide and animation benches

Replace the unmeasured `TODO: measure` target table in docs/PERFORMANCE.md
with real `cargo bench` medians and back the speed claims with committed
numbers.

benches/benchmarks.rs:
- add `full_render_dims` group (80x24, 120x40, 300x100) sharing a single
  `render_dashboard` body so the dimension sweep measures one layout
- add `animation/churn_200x60` — content, progress, and sparkline change
  every iter, forcing a non-empty diff each frame (real build->diff loop),
  guarded by a debug-only frame-to-frame inequality sanity check
- add `headtohead_200x60/{slt,ratatui}` rendering the same dashboard into
  each framework's in-memory test backend (no real I/O)
- add `flush/full_redraw_300x100` and `flush/sparse_change_300x100`
  reusing `fill_realistic` / `fill_sparse`, kept `#[cfg(feature =
  "crossterm")]` with the existing diff sanity asserts
- register the new benches in `criterion_group!`

docs/PERFORMANCE.md:
- remove the `TODO: measure` block; add a "Measured baselines" table with
  criterion medians and the reference HW (Apple M3 Pro, macOS 26.4,
  rustc 1.95.0), flagged indicative due to concurrent compiles
- add a "Head-to-head vs ratatui" subsection quantifying the §5 table
- fix the stale `src/lib.rs:1180-1290` pipeline refs to `src/lib.rs:359`
- extend the §3 bench list with the new bench names

Cargo.toml: add `ratatui = "0.29"` to `[dev-dependencies]` only — it never
enters the published `superlighttui` dependency tree.

No runtime/library code changes. Measurement + docs honesty only.

Closes #270

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

Add the fourth image path alongside Kitty/Sixel/half-block:
`Context::iterm_image` / `iterm_image_fit` emit a spec-correct OSC 1337
`File=inline=1;...:<base64>\x07` sequence. Unlike Kitty/Sixel (raw RGBA),
the iTerm2 path takes encoded image-file bytes (PNG/JPEG/GIF) and lets the
terminal decode and scale — the only pixel-accurate path on Tabby, older
iTerm2, and WezTerm's iTerm2-compat mode.

Detection integrates into the #264 capability ladder rather than
re-inventing it: a new `Capabilities.iterm2` flag is filled by env
identity (TERM_PROGRAM in {iTerm.app, WezTerm, Tabby, mintty}, or
SLT_FORCE_ITERM=1) and `Blitter::Iterm2` slots between Sixel and Sextant.
Unsupported terminals reserve the cell box and draw `[iterm2 unsupported]`;
`#[cfg(not(feature = "crossterm"))]` stubs keep --no-default-features
building.

Add a notcurses-style sprixel damage matrix (`SprixelCell::{Opaque, Mixed,
Transparent, Annihilated}` per owned cell) replacing the all-or-nothing
`flush_raw_sequences` guard for the non-Kitty paths. Sixel now routes
through `Buffer::sprixel_place`; a graphic is re-emitted only when its
`(x, y, content_hash)` changed or a text write annihilates a covered cell,
so a text edit in a transparent/uncovered region emits zero passthrough
bytes. Kitty keeps its `KittyImageManager` lifecycle unchanged.

Non-breaking and purely additive. `SprixelCell`/`SprixelPlacement` are
pub(crate); `sixel_image`/`kitty_image*`/`image` signatures and behavior
are unchanged.

Closes #265

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

Resolve five doc-vs-code drifts flagged in #271 so the canonical design
docs compile against the actual crate:

- alert: fix API_DESIGN.md Rule 3 arg order to the shipped (message, level)
- Rule 1: rewrite to the shipped consuming-builder shape (mut self -> Self,
  Drop-render, .show() for the response); fix the PR checklist accordingly
- progress: doc corrected to the shipped `-> Response` (no code change)
- _colored contradiction: resolved in ONE direction by *blessing* the
  suffix (the immediate widgets that return Response have no chainable
  builder to host `.color()`, so `_colored` is the canonical color-override
  variant). API_DESIGN.md checklist now permits `_colored` and cross-refs
  NAMING.md; deviates from the literal "deprecate 16" wording per the
  issue's "bless OR deprecate per a single documented rule" guidance and
  to avoid pointing deprecations at non-existent replacements.
- NAMING.md suffix table gains `_hd` / `_halfblock` / `_styled`; adds a
  v0.21.0 Deprecations section for the code_block_* aliases

Code:
- add `CodeBlock<'a>` consuming-builder (Drop-render, mirrors Gauge):
  `ui.code_block(code).lang(l).numbered().show()`. `code_block` now returns
  `CodeBlock<'_>` (renders on Drop, so bare-statement callers are unaffected)
- #[deprecated(since = "0.21.0")] the three code_block_* variants as
  behavior-preserving aliases; re-export CodeBlock alongside Gauge/Breadcrumb
- migrate all internal callers (examples, tests, syntax.rs) to the builder
- tests: builder/alias byte-parity, default drop-render parity, alert-order
  regression + doctest

Closes #271

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `ContainerBuilder::cached(version_key, f)` — an author-controlled
stability gate for the LLM token-streaming workload. The dominant
"append one token, re-render the whole frame" loop re-walks the entire
pipeline (closure -> build_tree -> compute -> collect_all -> render)
every token, even for large static chrome that did not change; #171's
row-hash only short-circuits the downstream flush stage.

`cached(key, f)` lets the author declare a subtree stable via a `u64`
key they already own. When the key is unchanged from the previous frame
at that call site it is classified as a cache hit, otherwise a miss
(key change / new call site / first frame / resize). This is NOT
reactive binding (permanently rejected, COMPETITIVE_ANALYSIS P2-4 /
DESIGN_PRINCIPLES R2): `f` runs every frame exactly like `.col(f)`, so
output is byte-for-byte identical to an uncached container in all cases
and there is zero behavior change when unused.

This lands the safe, reversible half of #273: a measured, author-keyed
gate plus hit/miss diagnostics (`region_cache_hits` /
`region_cache_misses`). It deliberately does NOT skip `f` on a hit —
eliding the body would require splicing recorded commands and replaying
focus / hit-map / scroll / raw-draw feedback, risking the retained tree
R2 forbids (out of scope; tracked as follow-up). The Phase-0 streaming
baseline `bench_streaming_append_chat` quantifies the cost the gate is
designed to eventually elide.

Also add `StreamingTextState::version()` / `StreamingMarkdownState::version()`
(monotonic, bumped on push/start/clear) so callers key the surrounding
chrome off the stream delta ("cache the chrome, not the stream").

Implementation:
- FrameState.region_versions{,_buf}: per-call-site keys, round-tripped
  through Context like commands_buf (alloc-reuse + recycle discipline).
- Context.record_cached_region: classifies hit/miss by declaration order.
- Resize invalidation in both clear_frame_layout_cache (real loop) and
  the kernel (path-independent, covers TestBackend/frame_owned).
- ContextCheckpoint rolls back recorded keys on error_boundary panic.

Non-breaking; all additive. Command enum and LayoutNode size guards
untouched. Tests: 7 TestBackend unit tests (byte-identical, hit/miss,
resize invalidation, focus+hit-map continuity, raw-draw replay,
per-call-site isolation), 3 version-counter unit tests, and a proptest
asserting cached output matches uncached under random key sequences.

Closes #273

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

`Context::scroll_row` advertised horizontal scrolling but silently built a
`Direction::Column` scrollable: the scroll engine ignored the container
direction and only ever translated/clipped on the y-axis. This wires the
caller's `.row()` / `.col()` direction through the pipeline and adds a full
x-axis mirror of the existing vertical scroll path.

Pipeline changes (#247):
- command.rs: `BeginScrollableArgs` gains `direction: Direction` +
  `scroll_offset_x`; the field rides inside the already-boxed variant so the
  `Command` enum size guard is unaffected.
- container.rs: `finish()` threads `.row()`/`.col()` direction and the x-offset
  into `BeginScrollableArgs` (was discarded — the root of the bug).
- tree.rs: `LayoutNode` gains `scroll_offset_x` + `content_width` (x-axis mirror
  of `scroll_offset`/`content_height`); `BeginScrollable` honors `direction`
  instead of hardcoding `Column`; size guard 328 -> 336 (two scalar u32s).
- flexbox.rs: scrollable `Direction::Row` arm mirrors the Column arm (zero
  child grow, measure natural width, lay out into an over-wide virtual area on
  overflow, record `content_width`). Both scrollable arms extracted to
  `#[inline(never)]` helpers so their grow-save buffers stay out of
  `compute_body`'s recursion frame (keeps deep non-scrollable trees within the
  MAX_LAYOUT_DEPTH stack budget).
- render.rs: threads `x_offset` alongside `y_offset`; adds `screen_x`,
  x-clipping in `visible_area`, a `set_string_clipped_x` writer that trims
  leading columns scrolled off the left, and x-translation of borders / scroll
  children. Adds horizontal scroll indicators (left/right arrows).
- collect.rs: `scroll_infos` becomes `(content, viewport, is_horizontal)` and
  every collected rect (hit/focus/group/content/scroll/raw_draw) is shifted by
  `x_offset` just like `y_offset`.
- collections.rs: `ScrollState` gains `offset_x`/`content_width`/
  `viewport_width` + `scroll_left`/`scroll_right`/`can_scroll_left`/
  `can_scroll_right`/`progress_x`/`set_bounds_x`. The vertical API is byte
  identical.
- layout.rs: `scrollable()` reads the recorded axis flag and binds the matching
  ScrollState axis; `auto_scroll_nested` handles horizontal wheel + shift+wheel;
  `scroll_row` docstring corrected.

Not breaking: all new types are pub(crate); `ScrollState` and `scroll_row`
signatures are unchanged. Adds unit + TestBackend + proptest coverage for
horizontal scroll; all existing vertical-scroll tests stay green.

Closes #247

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

# Conflicts:
#	benches/benchmarks.rs
# Conflicts:
#	CHANGELOG.md
#	src/widgets/collections.rs
…/bubbletea/ink)

Bumps superlighttui + slt-wasm to 0.21.0 and consolidates the [0.21.0]
CHANGELOG covering all 35 merged issues across correctness fixes, new
widgets, layout, async/runtime, input, terminal/images, styling/theming,
devtools, perf, and the pre-1.0 breaking API cleanup.

Release-prep in this commit:
- Version bump (Cargo.toml, crates/slt-wasm, README install snippet).
- CHANGELOG [0.21.0] - 2026-05-29: per-issue entries grouped
  Breaking / Added / Fixed / Perf / Deprecated / CI / Docs.
- Drop the ratatui dev-dependency from the #270 head-to-head bench: ratatui
  0.29 pulls a transitively advisory `lru 0.12` (RUSTSEC-2026-0002) that
  would fail the release `cargo audit` gate. The SLT dashboard baseline
  bench (dashboard_200x60/slt) stays; the vs-ratatui figure is kept as a
  recorded reference measurement in docs/PERFORMANCE.md.
- Fix two typos (mistyped, unparsable) flagged by the typos gate.

Gate (all green): cargo fmt, clippy --all-features --all-targets,
test --all-features (1672 tests), check --no-default-features,
check -p slt-wasm wasm32, cargo hack --each-feature, cargo audit,
cargo deny, typos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three CI failures on the v0.21.0 PR, all release-readiness (no library
behavior change):

- **MSRV 1.81**: pin `indexmap` 2.9.0 and `unicode-segmentation` 1.12.0 in
  Cargo.lock. The `serde` feature now pulls `toml` (#267 theme files) →
  `indexmap` 2.14 (edition2024, rustc ≥1.85), and #259 added
  `unicode-segmentation` which floated to 1.13.2 (rustc ≥1.85). Both pinned
  to the last 1.81-compatible release; `cargo +1.81 check --features
  async,serde` is green. API-compatible downgrades; `check --all-features`
  on stable unaffected.
- **Deny Check (licenses)**: allow `CC0-1.0` in deny.toml. `notify` (the
  `theme-watch` feature, #267) carries a CC0-1.0 component, surfaced only
  under the `--all-features` license scan CI runs. CC0-1.0 is a permissive
  public-domain dedication.
- **VHS Gallery**: mark the job `continue-on-error: true`. VHS rendering
  needs a virtual TTY + ttyd + ffmpeg and runs the release examples
  headlessly — inherently flaky for a hard gate. The README↔tape parity
  hard gate is the `gallery_manifest` test (plain `cargo test`, green); this
  job stays as a best-effort GIF-gallery artifact.

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

The MSRV CI job runs `cargo generate-lockfile` (regenerating the lock) before
`cargo check --features async,serde`, so a Cargo.lock-only pin is discarded —
the constraint must live in Cargo.toml. Bound:
- `unicode-segmentation` to `>=1.0, <1.13` (1.13+ needs rustc 1.85; #259).
- `indexmap` to `>=2.0, <2.10` (2.10+ needs the edition2024 Cargo feature /
  rustc 1.85; pulled transitively via `toml`, the serde/theme-watch features
  from #267). Declared as a constraint-only direct dep (not used in code).

Verified with the exact CI sequence: `cargo +1.81 generate-lockfile` then
`cargo +1.81 check --features async,serde` is green (indexmap resolves to
2.9.0, unicode-segmentation to 1.12.0). Stable `check --all-features`
unaffected. Keeps the documented MSRV 1.81 contract intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@subinium subinium merged commit 18e76ef into main May 29, 2026
16 of 17 checks passed
@subinium subinium deleted the release/v0.21.0 branch May 29, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment