Revision history for Selkie
0.6.0 2026-04-30T17:03:19+01:00
- First-class mouse support across the whole framework. Selkie::App
enables button + drag events on construction (via
C with the existing v1 button-event mask)
and the input loop now routes mouse events through a new
coordinate-based dispatcher alongside the keyboard path. Apps and
consumer widgets get the behaviour for free — no opt-in flag,
no per-app wiring; every prebuilt widget reacts to the pointer.
Display-only widgets (Text, RichText, Image, Border, ProgressBar,
Spinner, Toast, Legend, charts) deliberately don't react — Border
passes clicks through to its content. The new C
pod section in `lib/Selkie.rakumod` is the user-facing reference.
- `Selkie::App.!dispatch-mouse`: coordinate-based dispatch for
mouse events. Resolves the deepest widget under the cursor via
`widget-at-in`, applies drag-capture so motion / release stay
routed to the press's target (scrollbar drags, text-selection
drags don't break when the cursor leaves the widget), performs
click-to-focus on focusable presses, annotates presses with
double / triple multiplicity, then bubbles up the parent chain
using the same consume-or-bubble rule as keyboard events.
Modal isolation matches the keyboard path — clicks outside the
active modal are dropped (or trigger a synthesized close on
modals that opted in via the new C
flag). Click-to-focus is skipped when the click hits the
already-focused widget so set-focused(False) → set-focused(True)
churn doesn't tear down focus-bound state (Select dropdowns,
caret styles) before the click handler observes it.
- `Selkie::App.widget-at-in($root, $y, $x)`: public hit-test
against an arbitrary root, returning the deepest widget whose
on-screen rectangle contains the given absolute cell. Two-phase
resolution — first pass walks the whole tree looking for any
widget whose `claims-overlay-at` returns True (catches widgets
that paint outside their nominal rect, like an open `Select`
dropdown), then a standard depth-first containment walk.
Children are walked in reverse so later-added (visually on-top)
siblings win at the same point, matching render order. Type
object when nothing claims the point. Companion to the existing
`widget-attached` — tests can drive the hit-test logic without
a live App / notcurses instance.
- `Selkie::Event` gained C (1 / 2 / 3 for single /
double / triple presses, 0 for everything else) and a
C builder. `Selkie::App` computes the count
from inter-press timing (300 ms window) and the press cell —
strict same-cell rule, so sloppy drags don't manufacture
spurious double-clicks. Multiplicity is tracked per-button so
chord-style presses don't reset each other's counters.
- `Selkie::Event::MouseHandler` is the new registration record
produced by the on-* mouse methods on `Selkie::Widget`. Surfaced
via `Widget.mouse-handlers` so future help overlays can list
registered mouse interactions alongside keyboard binds. Two
free subs `mouse-event-kinds(Selkie::Event)` and
`mouse-event-button(Selkie::Event)` classify a mouse event into
the handler kind(s) it should fan out to and extract the
1-indexed button number — the dispatcher fans presses out to
both `'click'` and `'mouse-down'`, motion-while-held to
`'drag'`, releases to `'mouse-up'`, and scroll wheel to
`'scroll'`.
- `Selkie::Widget` gained the matching registration API:
`on-click(&handler, :button = 1, :description)`,
`on-scroll(&handler, :description)`,
`on-drag(&handler, :button = 1, :description)`,
`on-mouse-down(&handler, :button = 1, :description)`,
`on-mouse-up(&handler, :button = 1, :description)`. `:button(0)`
catches any button (used internally by `on-scroll`, useful for
low-level mouse-down handlers). Accompanying coordinate helpers
`local-row(Selkie::Event)` / `local-col(Selkie::Event)` translate
absolute screen coords into widget-local cells (returning -1 for
out-of-bounds, so callers can guard with a single check), and
`contains-point(Int $y, Int $x)` exposes the same hit-test the
framework uses internally. Widgets with zero viewport dimensions
never contain any point — that's how unmounted / parked widgets
drop out of dispatch without consulting plane handles.
- `Selkie::Widget.claims-overlay-at(Int $y, Int $x)`: opt-in
override for widgets that paint over the layout flow. Default
returns False; `Select` overrides it so an open dropdown
captures clicks even though its parent layout's bounds end at
the closed-display row.
- `Selkie::Widget.handle-event` default now routes `MouseEvent`s
through any handlers registered via the on-* methods before
falling through to the keybind table. Existing `handle-event`
overrides that already had a mouse switch (CardList, ListView,
RadioGroup, Table, ScrollView, TextStream, Select)
explicitly call `self!dispatch-mouse-handlers($ev)` after their
built-in scroll-wheel branches so the registration API still
composes with their custom logic.
- `Selkie::Widget::Modal` gained `:dismiss-on-click-outside`
(default False). When True, a primary mouse click outside the
modal's content rectangle dismisses the modal — the framework
calls `close-modal`, restoring pre-modal focus. Default False
matches the keyboard focus-trap behaviour: stray clicks in the
dimmed backdrop are ignored. Confirm-style modals stay safe
(a Yes/No decision shouldn't be silently abandoned by a stray
click); informational overlays opt in.
- `Selkie::Widget::HelpOverlay` flips the new modal flag on by
default — clicking anywhere outside the help panel closes it
(standard help / about / tooltip-style popup convention). The
embedded Close button still works (Enter, Space, click), and so
does Esc.
- Built-in widget mouse behaviours:
* `Button` — primary click activates (same path as Enter/Space).
* `Checkbox` — primary click toggles.
* `TabBar` — primary click activates the tab under the cursor;
clicking the already-active tab re-emits `on-select`.
* `RadioGroup` — single-click commits the row as the new
selection (mouse intent is unambiguous, so no cursor-then-
Enter dance); scroll-wheel still moves the cursor.
* `ListView` — single-click moves the cursor to the row;
double-click fires `on-activate` (matching Enter); scroll-wheel
moves the cursor.
* `Table` — header click cycles sort on sortable columns; body
single-click selects the row; double-click fires `on-activate`;
scroll-wheel moves the cursor. Column-from-x mirrors the
renderer's column-widths pass exactly so hit-testing tracks
the visible layout.
* `Select` — click on the closed-display row toggles the
dropdown; click on a dropdown row commits and closes; click
elsewhere closes. `claims-overlay-at` extends the hit-test
rect over the dropdown so those clicks reach the Select
even though the parent layout doesn't know about the
overdraw. Scroll-wheel scrolls the open dropdown.
* `CardList` — click selects the card under the cursor. Card
heights are heterogeneous, so the new `!card-index-at-row`
helper walks visible items from `scroll-top` accumulating
heights until the click row falls inside one. Scroll-wheel
still moves between cards.
* `ScrollView`, `TextStream` — primary mouse-down on the
scrollbar column jumps the thumb; drag tracks the cursor.
Mapping is proportional (cursor row → scroll-offset such
that thumb-y == cursor-row, clamped to thumb-track bounds).
Drag captures keep the widget as the target even when the
cursor leaves the scrollbar.
* `TextInput` — click positions the caret; drag selects from
the press anchor to the current cursor cell; double-click
selects the word; triple-click selects the buffer; Ctrl+A
selects all; Ctrl+C / Ctrl+X emit on the new `on-copy` /
`on-cut` Supplies (cut also deletes); Backspace and Delete
consume an active selection if present, typing replaces it.
Selections are rendered with reverse-video and exposed via
`has-selection`, `selection-range`, `selected-text`. Selkie
does not own the system clipboard — apps wire OSC 52 /
notcurses paste-buffer in their `on-copy` / `on-cut` taps.
Shift+Left / Shift+Right now also extend the selection, not
just word-jump the caret.
* `MultiLineInput` — same selection model, extended to 2D.
Click positions the caret; drag selects across rows; the
highlight follows the wrapped layout (visual rows), not the
raw logical offsets. Double-click selects the word;
triple-click selects the entire current logical line. Drag
captures keep selection extending past the visible plane —
clamping to the visible cell while `!visual-to-logical` pins
beyond-the-buffer rows to the last line / last column. Same
Ctrl+A / Ctrl+C / Ctrl+X / on-copy / on-cut contract as
TextInput. Drag arms the selection anchor lazily on first
motion (anchoring at click time would turn every post-click
keystroke into a 1-char selection).
* `ConfirmModal`, `CommandPalette`, `FileBrowser` — clicks fall
through to the embedded Button / ListView / TextInput, which
handle them with their built-in behaviour. FileBrowser
descends / selects on double-click.
- `Selkie::Test::Keys.mouse-event` accepts `:click-count` and
documents `NCKEY_MOTION` as a valid `:id`. Existing call sites
keep working — defaults preserve the old single-press shape.
`t/59-mouse-dispatch.rakutest` is the new in-process test
coverage for hit-testing, the event-classification helpers, the
Selkie::Event annotation API, the default Widget.handle-event
mouse fan-out, and Modal's new dismiss-on-click-outside flag.
Live App-instance flow (capture, click-to-focus, modal isolation)
needs notcurses_init and is covered by manual smoke in
App::Cantina; see TODO/Selkie.md for the v1 mouse plan.
- `Selkie::Widget::ScrollView` follow-bottom rewritten around a
persistent `$!follow-active` flag. The previous implementation
computed "follow active" per render by checking
`scroll-offset >= max-offset` against the OLD content-height,
which proved fragile: CardList resizes mid-frame change
`self.rows` (and so `max-offset`) before content-height is
re-measured, and children with lazy `logical-height` (RichText)
can briefly report stale heights between `set-content` and the
next render. Either fragility flipped the per-frame check to
False and silently disabled tail-follow for streaming bodies.
The new flag is maintained at the single `scroll-to` funnel
(which `scroll-by`, `scroll-page-by`, `scroll-to-end`,
`scroll-to-start`, mouse wheel, and scrollbar drag all route
through), so user intent is the only thing that toggles it.
Render also re-engages follow when a content shrink or viewport
grow involuntarily lands the user back at `max-offset` — typical
cause is a sibling card collapsing past where the user previously
parked. New `follow-active` accessor exposes the flag for apps
that want a "follow-mode" indicator and for tests.
- `Selkie::Widget::CardList` defense-in-depth for sprixel ghosting.
Sprixel cleanup goes via the terminal wire as an escape sequence;
rapid sprite churn on scroll / resize occasionally lets the new
blit land before the old remove has flushed, leaving ghost
avatars at the previous positions. CardList now snapshots
scroll-top, selected, viewport dims, and per-item heights across
renders, and parks every item up front when any of them change —
forcing fresh sprixel IDs on the layout pass below and sidestepping
the incremental-update reliability question entirely. Per-item-
height tracking deliberately fires on streaming wraps (~1% of
tokens, when a rendered line wraps and the card grows by a row)
but stays silent on tokens that just append text within an
existing row, so the snapshot comparison stays cheap on the hot
path.
- `Selkie::Widget::CardList.add-item` wires per-item widgets into
the parent chain when their `parent` is undefined. Without this
link, `self.theme` on a card walks past CardList and falls back
to `Selkie::Theme.default`, so the first `init-plane` →
`!sync-plane-base` on each card paints its base cell with the
framework's default palette instead of the active theme — the
card stays themed against the default background until the next
explicit `set-theme` cascade overwrites `$!theme`. Setting
parent here lets the theme inheritance walk find the live theme
at the moment the plane is first created.
- `Selkie::Widget::Select` rendering now resolves all colours
through the active theme. The closed-display row uses
`theme.input-focused` / `theme.input` instead of hardcoded
RGB; the dropdown plane's base cell is painted from
`theme.input.bg` (or `theme.base.bg`) so unwritten regions
(right-edge padding past the longest item) carry the input bg
rather than the terminal default; dropdown rows use
`theme.text-highlight` / `theme.input` for cursor / non-cursor
including bg + styles, instead of the previous `0x4A4A8A` /
`0x3A3A5A` / `0x2A2A3E` hardcoded values. Custom themes that
were already setting these slots picked up the wrong rendering
for the dropdown before; they get the right one now.
- `Widget::Button` now treats Left / Right arrow as focus
cycle, mirroring Tab / Shift-Tab. Buttons in a modal's
action row sit horizontally — most users reach for arrow
keys before Tab, and stranding focus on a single button
until they discover Tab is poor UX. Implementation
dispatches the same `ui/focus-prev` / `ui/focus-next`
store events as the global Tab keybinds, so the focus-chain
semantics (focusable-descendants order, modal focus traps)
stay identical. Modifier-held arrow keys (e.g. Shift+Right
for word-jump in a text input) are deliberately NOT
consumed by Button — only the bare-arrow case is. Three
new subtests in `t/17-button.rakutest` pin the contract:
bare-Right dispatches focus-next, bare-Left dispatches
focus-prev, modifier-held arrow falls through.
- `Widget.set-viewport`: the corruption-rejection note is now
structured with bit-width diagnostics. When `safe-coord`
rejects, we emit `[self= id= y-bits= x-bits=
parent= parent-y-bits= parent-x-bits=]` instead
of the generic "skipping corrupt coord" line. Captures the
data needed across multiple in-the-wild occurrences to
distinguish a single multiplicative event (constant width
across runs) from a per-frame accumulator (growing width)
from a cascade (parent's slot already corrupt). New file-
local helper `coord-bit-width` uses `nqp::base_I` +
`nqp::chars` so it stays in the bigint domain — calling
`.base(2)` directly on the corrupt Int would re-trip the
Scalar.STORE inlined unbox the gate is protecting against.
No new public API surface; the v1-blocker upstream filing
tracks removing this whole machinery once a fixed MoarVM
ships (see TODO/Selkie.md).
- `Selkie::Store.tick` is now safe against subscriptions whose
callbacks call `unsubscribe` (their own id, or any sibling).
`check-subscriptions` previously did `for %!subscriptions.kv ->
$id, %sub` — `Hash.kv` resolves values lazily, so a callback
that deleted another sub mid-tick caused the next pair-bind
to typecheck-fail with "expected Associative but got Mu". The
practical hit was App::ImageTagger's modal-lifecycle
subscriptions: a callback that called `$!app.close-modal`
destroyed the modal's widget tree → cascading
`unsubscribe-widget` calls → next tick crashed
non-deterministically. Now snapshots `%!subscriptions.keys`
up front and `:exists`-guards each lookup, mirroring the
pattern already used in `flush-push-subs`. Three new subtests
in `t/19-store.rakutest` pin the contract: self-unsubscribe,
sibling-unsubscribe, mass-unsubscribe-of-many.
0.5.3 2026-04-30T02:49:37+01:00
- `Widget.set-viewport`: hardened against the long-standing
MoarVM spesh corruption that intermittently crashed the
renderer with "P6opaque: get_boxed_ref could not unbox for the
representation 'P6bigint' of type Scalar" pointed at the
attribute store. The previous workaround (free-sub
`position-changed` for boxed-Int comparison) covered the read
side; the assignment site `$!abs-y = $abs-y` was still
crashing on Scalar.STORE's inlined unbox when an upstream
spesh slot held a multi-thousand-bit bigint. New `safe-coord`
free sub uses `nqp::isbig_I` to detect any value that doesn't
fit in int64 — screen coordinates physically can't, so a
bigger-than-int64 result is the corruption signal — and
otherwise rebuilds a fresh boxed `Int` via
`nqp::unbox_i` + `nqp::box_i` so the subsequent attribute
STORE doesn't traverse the corrupted slot. Corrupt coords
log to STDERR ("skipping corrupt coord (bigint exceeds
int64); will retry next frame") and are skipped — the parent
layout re-passes coords every frame, and most spesh
corruption is local to a hot frame so clean values usually
arrive next render. The recurring log line, when it fires,
is the surest signal we have for an upstream-spesh repro
attempt. Existing `t/22-viewport.rakutest` regression
"tolerates pathological Int values" updated to reflect the
new reject-and-recover semantics (was: "stores any-size
value via boxed-Int dispatch", which was the half-truth that
missed the assignment crash).
0.5.2 2026-04-30T00:41:27+01:00
- `Selkie::App` modals now stack. Calling `show-modal` while a
modal is already open pushes the new modal on top instead of
replacing the existing one; `close-modal` pops the topmost modal
and restores the previous modal as active with all its keybinds
intact. The pre-modal focus target is per-stack-frame, so
popping the inner modal returns focus to where it was inside
the outer modal. Previously, opening a confirm dialog from
inside an editor permanently destroyed the editor's keybinds
(ESC stopped working) — that's now fixed at the framework level.
Render path renders only the topmost modal (modals are opaque
overlays); resize cascades dims through every modal in the
stack so revealed modals have correct dims when popped.
- `Selkie::Event::Keybind.parse`: the literal `+` key is now
bindable. Previously `'+'`, `'shift++'`, `'ctrl++'`, etc. all
died with `Unknown modifier: ` because the spec splitter used
`+` as the modifier separator and pulled an empty key off the
end. Detected upfront via a trailing-`++` check (and a plain
`'+'` short-circuit) before the split, so the key parser sees
a single `'+'` character and emits `'+'.ord` as the keybind id.
KEYBIND SYNTAX docs updated; new `t/04-event.rakutest` subtest
covers `'+'`, `'shift++'`, `'ctrl++'`, and `'ctrl+shift++'`.
0.5.1 2026-04-29T00:45:16+01:00
- `Widget::TextInput` and `Widget::MultiLineInput` no longer
swallow OS-composed alt characters. The "modified keys (except
shift) bubble up for global keybinds" filter at the top of
`handle-event` now only fires when the event's char matches its
keysym (i.e. the modifier was held for chord intent, not OS
composition). When `eff_text` differs from `id` — UK Mac
Alt-3 → '#', US Mac Alt-2 → '™', and similar layout-driven
compositions across non-US keyboards — the composed character
is treated as typed input. Without this, those characters
were untypeable inside any focused TextInput / MultiLineInput.
Pure Alt+letter / Ctrl+letter chords (where char == keysym)
still bubble exactly as before, so global keybinds keep
working. Two regression tests cover the split.
0.5.0 2026-04-27T22:51:02+01:00
- `Widget::TextInput` and `Widget::MultiLineInput` now treat
Shift+Left / Shift+Right as word-jump and Shift+Backspace as
delete-previous-word. Word boundaries are the standard
`\w` / non-`\w` transition (Unicode-aware via Raku's regex).
Shared helpers `next-word-pos` / `prev-word-pos` are exported
from `TextInput` under the `:words` tag — `MultiLineInput`
imports them and adds 2D handling: shift-left at column 0
crosses to the end of the previous line; shift-right at end-
of-line crosses to column 0 of the next; shift-backspace at
column 0 falls through to ordinary line-join backspace.
- `App.run` paste drain rewritten to batch consecutive printable-
char events into a single `insert-text(Str)` call on the
focused widget. The previous per-event dispatch path triggered
an O(n) buffer rebuild for every character (substr + concat +
concat), making a paste of n chars cost O(n²) total — fine
for small bursts but pathological for the >5K-char range
(10 000 words = ~60 KB pastes truncated halfway through a word
because the drain ran out of wall-clock budget before
processing the queue). The new path collects paste-eligible
events (printable, no Ctrl / Alt / Super) into a List, joins
once, and applies via the widget's batch insert — single
buffer rebuild per paste, O(n) total. Wall-clock cap raised to
200 ms to give the drain comfortable headroom on huge pastes
while still bounding pathological infinite-input sources.
- `Widget::TextInput.insert-text(Str)` and
`Widget::MultiLineInput.insert-text(Str)` are the public batch-
insert API the App's drain loop calls. TextInput strips control
chars and newlines (single-line semantics); MultiLineInput
splits on `\n` and lays the pasted content across multiple
buffer lines with the cursor landing at the end of the last
pasted segment. Application code can call them directly to
programmatically populate an input.
- `Widget::Image.render`: defensively cap the blit-plane footprint
at the Image's own cell dimensions. Under `NCSCALE_SCALE` the
geometry returned by `ncvisual_geom` should already fit the
plane, but on certain combinations of blitter, terminal cell-
pixel size, and image aspect ratio it can report `rcelly` /
`rcellx` one cell over the plane. The blit-plane below was
then created larger than its parent — notcurses doesn't clip
child planes — and the sprixel painted onto the next widget's
cells. App::Cantina users observed this as the avatar of one
message ghosting through the bottom border of its card and
onto the message below. The cap is a one-line guard:
`($geom.rcelly min self.rows) max 1`.
- `Widget::ScrollView` rendering rewritten for consistency. Three
bugs fixed:
1. Out-of-view children were `reposition`ed to `(viewport-h, 0)`
— i.e. the BOTTOM EDGE of the ScrollView. The plane's rows
then extended BELOW that point, painting ghost cells onto
whatever widget rendered next in the parent layout. The
visible symptom was an item below the ScrollView going
blank (its cells overwritten) after a scroll. Now uses
`Widget.park` (move to 10_000, 0) just like Image does.
2. Children's wrap-width flipped between `self.cols` and
`self.cols - 1` based on whether the scrollbar was needed,
re-wrapping the body and producing one extra line that
then DID need a scrollbar — feedback loop that clipped the
bottom row under the scrollbar. Now reserves the scrollbar
column unconditionally when `show-scrollbar` is True; the
scrollbar itself is still drawn only on overflow but the
content-width never changes mid-render.
3. `scroll-by` was the only public scroll mutator that took a
row-count, leaving callers like CardList computing pages
themselves with their OWN row count (the chat pane's
height) — way too big for the inner body's smaller
viewport. Added `scroll-page-by(Int $direction)` that
scrolls by the SCROLLVIEW'S OWN viewport-height in
`+1` / `-1` page increments. CardList's PgUp/PgDown
now delegates via the duck-typed
`scroll-content-page-by(±1)` (with a fallback to the
legacy `scroll-content-by(±self.rows)` for older cards).
- `Widget::ScrollView.follow-bottom`: new opt-in Bool attribute
(default False). When True, each render captures whether the
scroll offset was at `max-offset` BEFORE content-height
updates, and if so snaps the new offset to the new max so
streamed content stays visible. Any user scroll-up disables
the auto-pin naturally (the captured "at end" check fails
next frame); scrolling back to the bottom re-enables it.
Designed for log views and chat-message bodies where the
newest content matters most. Silent passthrough when the
flag is off.
- SIGWINCH-driven resize wake. The main loop now installs its own
SIGWINCH tap via Raku's `signal()` Supply and disables notcurses's
built-in handler with `NCOPTION_NO_WINCH_SIGHANDLER`. The handler
sets an atomic flag; the idle sleep is split into ~16ms chunks so
a resize wakes the loop within roughly one frame instead of
waiting for the full 250ms deep-idle budget. `!check-terminal-
resize` now leads with `notcurses_refresh` to force a TIOCGWINSZ
re-query (necessary because we own the signal handler now and
notcurses no longer updates stdplane dims on its own). Closes
the macOS-specific symptom where a terminal resize during idle
wasn't picked up until the user pressed a key — most visible in
App::Cantina, where stale avatar sprixels stayed painted at the
old position. Cairn and any future Image-using consumer benefits
too.
- `Widget::CardList.bottom-anchor`: new opt-in Bool attribute
(default False, preserving existing behaviour). When True and
the visible cards (scroll-top through the last item) sum to
less than the viewport height, render shifts every visible
card down so the LAST item ends at the bottom of the viewport
rather than leaving empty space below it. Designed for chat-
style consumers (App::Cantina's ChatView) where new content
arrives at the bottom and the user expects the latest message
to be anchored there even when the whole conversation fits on
screen. Pickers / file browsers / inventory lists keep the
default top-aligned rendering.
0.4.6 2026-04-25T12:52:41+01:00
- `Widget.set-viewport` now marks the widget dirty when its
absolute position (abs-y / abs-x) changes. Most widgets
render position-independent cells and absorb the extra render
with no visible effect, but Image specifically depends on it:
notcurses sprixels don't follow plane moves, and the teardown
/ re-blit logic that disposes of the stale sprixel only runs
inside `Image.render`. Before this change, a parent layout
that shifted a card's screen position without independently
dirtying the subtree (e.g. CardList rebalancing visible cards
after a resize, or a screen-level VBox sliding panes around
when a sibling's fixed sizing changed) could leave the Image
clean — `render` never fired, the blit-plane was never
destroyed, and the sprixel ghosted at the old terminal
coordinates while the Image's plane sat at the new ones.
Surfaced in App::Cantina as a band of stale avatar pixels
directly below the most recent message after sending /
regenerating.
The Image cache check that compares incoming abs-y / abs-x
against the last-rendered values still does the actual
gating — set-viewport only ensures the check gets to run.
Locked in by `t/22-viewport.rakutest`'s new "set-viewport
marks dirty on absolute-position change" subtest.
- `Image.handle-resize` eagerly destroys the blit-plane on a
real resize. The blit-plane is a child of `self.plane` sized
to the scaled image dimensions; when the parent shrinks
(e.g. the host card is bottom-clipped by CardList), notcurses
doesn't auto-clip or auto-destroy the child, so a stale
blit-plane could extend past the Image's new bounds and paint
a sprixel into whatever sits below. The next `render` would
catch the size mismatch in its cache check and tear the
blit-plane down, but doing it inside handle-resize makes the
cleanup atomic with the resize itself — no window in which
an oversized blit-plane is reachable from the render pass.
- Border learned `focus-from-store` (rw, default True). When
False, the Border no longer registers the
`border-focus-{WHICH}` subscription and its `render` no
longer re-derives `has-focus` from `ui.focused-widget` —
`set-has-focus` becomes the single source of truth, and its
value survives arbitrary numbers of re-renders.
Intended for Borders managed by a parent container whose
selection semantics don't map onto "some descendant is
focused". `CardList` is the driving case: a card's Border
should stay highlighted for the *selected* card even when
keyboard focus has moved elsewhere, and should not be wiped
out when a sibling container triggers a cascade re-render.
`CardList.add-item` now flips this flag off on the card's
Border automatically, so existing CardList callers get the
fix for free. Other containers that pass pre-built Borders
through `:border` to add-item inherit it too.
- App gained `set-error-log(Str $path)` for swapping the
redirect destination after construction. Tears down the
current fd-2 redirect, updates `$.error-log`, and reinstalls
against the new path; passing `Str` (undefined) or an empty
string disables redirection altogether.
Motivated by apps whose log location is only known after
some runtime event — e.g. App::Cantina doesn't know the
selected profile until the login screen reports one, so the
per-profile `{home}/{db}/error.log` path can't be provided
to `Selkie::App.new`.
- Image.render now caches its last-rendered (rows, cols,
abs-y, abs-x, file) tuple and skips the destroy + re-blit
fast path when nothing has changed since the previous
frame. Ancestors cascading dirty through the tree (e.g. a
sibling widget's sizing change propagating up a VBox and
back down via render-children) previously re-blitted every
visible Image per frame; at multi-keystroke-per-second
input rates that produced visible sprite drop-outs and
flicker as terminals failed to keep up with the remove+add
sprixel sequences.
Position is part of the cache key because notcurses
sprixels don't automatically follow their parent plane
when it's repositioned — same-dims-new-position would
leave a ghost sprixel at the old terminal coordinates.
Surfaced in App::Cantina as a discoloured band in the
input bar below a message card that had just shrunk during
swipe / regenerate.
The cache is invalidated by set-file, clear-image, park,
and !destroy-blit-plane so callers that genuinely want a
re-blit still get one.
- CardList.render now calls `set-viewport` on each visible
card's root after the reposition + resize pair. VBox / HBox
layout passes cascade abs-y / abs-x through their own
set-viewport calls, but CardList manages its items in a
separate `@!items` array and previously relied on
reposition alone — which only updates the plane's relative
y / x, not the widget's abs-y / abs-x. Downstream consumers
that read abs-y (Image's blit cache, any future viewport-
sensitive widget) otherwise saw stale coordinates after a
scroll or height-change cascade. Observable before this fix
as ghost sprixels painting the input bar below a shrinking
message card during edit / swipe in App::Cantina.
- Image now reads its blit position from notcurses directly
(`ncplane_abs_y` / `ncplane_abs_x`) rather than from
`Widget.abs-y` / `Widget.abs-x`. The notcurses calls are
single C struct-field reads (O(1)) that reflect the plane's
current absolute screen coordinates after any move,
including moves caused by a repositioned ancestor plane.
Widget.abs-y only updates when a parent layout cascades a
set-viewport call, which several containers had historically
got wrong (CardList, Modal, ScrollView were all audited);
any future container that forgets the cascade would have
caused the same class of ghost-sprixel bug. Reading from
notcurses makes Image's move detection independent of
Selkie-level cascade hygiene: its cache invalidates the
moment notcurses reports a different position, regardless
of whether Widget.abs-y was ever refreshed. Surfaced in
App::Cantina as ghost avatar bands above and below a
chat message card while editing non-bottom messages.
- Modal.render and ScrollView.render now cascade
set-viewport to their content / visible children after
reposition + resize. Neither had done so previously, which
left abs-y / abs-x stale for everything nested inside them.
Image no longer depends on this (see the ncplane_abs_yx
change above), but other abs-y consumers (focus detection,
overlay positioning, future widgets) pick up cleaner
coordinates as a result.
- Image gained two test hooks: `blit-cache-valid` (public Bool
accessor) and `populate-blit-cache-for-test` (test-only
seeder). These make cache-invalidation semantics testable
without a live notcurses plane; see
`t/58-image-blit-cache.rakutest`.
- Layout cascade simplified: `handle-resize` is now strictly a
self-resize on VBox / HBox / Border / CardList. It no longer
triggers `layout-children` or recurses into descendants.
Layout / re-allocation / set-viewport propagation happens
exactly once per frame — top-down through `render` →
`layout-children` → `render-children`. Previously a parent
container would run `layout-children` twice per frame (once
via `handle-resize` from the parent's own Pass 3, then again
via its own `render`), and CardList specifically would
cascade `handle-resize(item-height, cols)` to every card —
with the card's *full logical height*, even when the card
was about to be clipped by `CardList.render`. That left a
window where card planes were oversized relative to
CardList's bounds, visible as cards bleeding past the list
into adjacent widgets (the input bar in App::Cantina being
the most reliable repro).
Net behavioural change: one layout per container per frame,
with allocations always reflecting the *display* dimensions
the parent intended.
- Widget gained two helpers for forcing a full-screen render
pass: `mark-dirty-tree` (recursively marks this widget + all
descendants dirty) and `mark-screen-dirty` (walks up to the
root of the attached tree and calls mark-dirty-tree from
there). Use when a state change has layout implications the
default up-propagating mark-dirty can't express — for
example, a widget resizing itself shifts every sibling's
allocation and a partial cascade can leave planes at stale
positions or sizes.
- MultiLineInput.!update-sizing now calls `mark-screen-dirty`
on a size transition instead of the previous `parent.mark-
dirty`. A multi-line input growing / shrinking shifts every
visible widget's allocation, and relying on the default
cascade-down-in-render-children semantics had been producing
persistent visual glitches (cards bleeding out of a
shrinking CardList into an adjacent input, for one). The
full-screen flag gives every widget a clean re-layout on the
same frame with no cascade-correctness assumptions. Also
unifies the edit and type code paths in App::Cantina
(`set-text` for a bulk-load edit and `insert-char` for a
keystroke both route through update-sizing, so both trigger
the same redraw).
0.4.5 2026-04-21T02:34:52+01:00
- Push-based path subscriptions. `Selkie::Store.subscribe(@path,
$widget)` no longer walks every tick doing `get-in(|@path)`
and comparing to a cached last-value. Instead, every write
to the store (`assoc-in` or the deep-merge that backs the
`db` effect) records the affected path in an internal dirty
set; at the top of `tick`, the set is drained and only
subscribers whose bound path overlaps with a written path
are fired. "Overlap" means one path is a prefix of the other,
covering three cases:
* Exact match: write `foo.bar`, sub on `foo.bar` → fire.
* Ancestor: write `foo.bar.baz`, sub on `foo` or `foo.bar`
→ fire (something in my subtree changed).
* Descendant: write `foo` (replacing the subtree), sub on
`foo.bar.baz` → fire (my state was written over).
Unrelated subs pay zero cost. Idle ticks with no writes do
zero subscription work at all. Value-change gating is kept:
a push fire still runs `!values-equal` against the sub's
last known value, so a no-op write (writing the same value
back) doesn't spuriously fire the callback / dirty the
widget — only real changes propagate.
- `Selkie::Store.subscribe-path-callback($id, @path, &callback,
$widget)`. Path-based subscription with a callback, no compute
closure required — the path IS the watched expression. Fires
`&callback($new-value)` on any real change (using the same
exact / ancestor / descendant rule as `subscribe`) plus marks
the owning widget dirty. Equivalent to `subscribe-with-callback`
with a trivial `-> $s { $s.get-in(|@path) }` compute, but
push-based — no per-tick closure cost. Use when a widget
needs reconfiguration on change (`set-items`, `set-text`,
etc.) rather than just re-rendering.
- Multiple writes to the same path within one tick coalesce to
one subscription fire — reducers that write a path multiple
times during event processing produce one callback invocation,
not many. Dedup happens at flush time against a per-tick
"subs to fire" set keyed by subscription id.
- `subscribe-computed` and `subscribe-with-callback` remain
pull-based (per-tick walk + compute compare). They can depend
on arbitrary computed values of the store, which the push
index can't cheaply track without full reactive dependency
tracking (Vue 3 / MobX-style). The pull walk is skipped on
idle ticks where no events fired (existing 0.4.3 behaviour),
so the overall idle cost is still near-zero.
0.4.4 2026-04-21T01:51:38+01:00
- Fixed idle CPU pinning on macOS. `notcurses_get` with a
16 ms timespec does not block on this platform — it returns
in microseconds rather than sleeping for the requested
duration. The main loop in `Selkie::App.run` treated it as
a blocking wait, so it spun ~378 000 times per second
instead of ~60, pinning a CPU core at ~100%. Measured on
Cairn: `powermetrics` decayed power score dropped from
~29 327 (above WindowServer, ~164× Cubase 14) to ~58 — a
~500× reduction — after wiring an elapsed-based sleep
fallback into the loop. The loop now computes the actual
time spent in `notcurses_get` + dispatch + render and
sleeps the remainder of its frame budget, so it genuinely
runs at the target rate on every platform regardless of
whether notcurses's timespec is honoured.
- Input drain per frame. After the first `notcurses_get`
returns an event, the loop now drains any remaining
pending input via `notcurses_get_nblock` (bounded at 256
events per frame) before rendering. Without this, a
single-event-per-frame cadence at 60 Hz would turn a
1 000-char paste into a ~17 s catch-up — fast typing
would look laggy and bulk input would queue up visibly.
The bound prevents a runaway input source from starving
render + sleep; 256 events/frame is generous enough that
realistic pastes and autorepeat drain in a single frame.
- Activity-aware idle ladder. The main loop now ramps down
its tick rate during extended idle so a backgrounded
Selkie app doesn't burn battery as if it were in-focus:
* 0-30 s since last activity: the hot rate (default 60 Hz).
* 30-60 s: 30 Hz.
* 60-120 s: 12 Hz.
* 120 s+: 4 Hz.
Snap-back from deep idle still feels near-instant because
the deepest tier is 250 ms — the first keystroke after two
minutes of silence lands within a quarter-second and the
very next frame restores the hot rate. "Activity" is any
input event, any resize detected by the terminal poll,
any `Store.tick` that processed queued events (so
registered subscriptions / handlers firing counts), or a
toast visibility flip. Passive store reads don't bump the
activity timestamp.
- Configurable hot rate via `$.hot-hz` on `Selkie::App`.
Defaults to 60 Hz. Apps doing terminal video (notcurses
supports it), high-refresh animations, or live plot
rendering can bump this higher — 120 Hz, 144 Hz, etc. —
at construction: `Selkie::App.new(:hot-hz(120))`. The
notcurses_get timespec and the active-tier frame budget
both scale with this value.
- Idle ladder is monotonically non-speeding. A `max($ideal,
$hot)` clamp on each tier ensures that setting a slow
hot-hz (e.g. 10 Hz for a low-motion tool on a battery-
constrained device) doesn't paradoxically speed the app
UP when it goes idle. The ladder can only decrease the
tick rate from the hot rate, never increase it.
- `Selkie::Store.tick` now returns `Bool`. Returns `True`
when the event queue had events to process this tick
(which may have fired subscription callbacks / registered
handlers), `False` when idle. Existing callers that
ignored the return value are unaffected; the new signal
is used by the idle ladder to distinguish "store did real
work" from "store was a no-op".
- `Selkie::App.!check-terminal-resize` and
`!maybe-check-terminal-resize` now return `Bool` — `True`
when a dim change was detected and the UI re-flowed,
`False` otherwise. Used by the idle ladder to treat a
resize as an activity event.
0.4.3 2026-04-19T18:08:02+01:00
- Idle CPU reduction. A completely idle Selkie app was waking
the CPU ~60 times per second and doing real work on every
wake — compositor render, subscription walk, syscalls.
Three changes collapse idle work to near-zero without
changing the loop's responsiveness when anything's actually
happening:
* `Selkie::App.!render-frame` now gates its
`notcurses_render` call on whether any widget actually
rendered this frame. A static screen produces zero
compositor, diff, and pty-write work per tick.
* `Selkie::Store.tick` skips `!check-subscriptions` when
the event queue was empty and every subscription has
been primed. The compute closures no longer run 60 Hz
on idle; they run when events could plausibly have
changed state. A new `$!subs-primed` flag is flipped on
first check and invalidated whenever a subscription is
registered, so late-added subscriptions still initialise
correctly.
* `Selkie::App.!check-terminal-resize` is now throttled to
~12 Hz via a new `!maybe-check-terminal-resize` wrapper.
Polling is still required (notcurses absorbs SIGWINCH on
macOS without queuing), but 83ms resize latency is
imperceptible and the syscall stops being a measurable
idle cost. Real `ResizeEvent`-driven checks remain
immediate.
- `Selkie::Widget::Toast.tick` now returns `Bool`. It returns
`True` only on the tick where visibility flips from visible
to invisible — the `!render-frame` gate uses this signal
to force one more composite render so the already-painted
toast is erased from the terminal. Existing callers that
ignored the return value are unaffected.
- Focus invariant. `Selkie::App` now maintains the rule that
`$!focused` is always attached to the active input surface
(modal or screen) whenever focusable widgets exist there.
"Focus: nothing" is only a valid state when the surface
genuinely has zero focusables. Apps don't need to manage
focus across transitions themselves — the framework keeps
it in a sensible place:
* `switch-screen` saves the outgoing screen's focus in a
new per-screen focus memory and restores the incoming
screen's last-focused widget (or first focusable if the
screen has never been visited, or its saved reference
has gone stale). Switching back to a previously-visited
screen now lands you where you were rather than nowhere.
* `add-screen` discards any stashed focus for the name
being re-registered — common pattern with overlay
screens rebuilt each time they open.
* `focus(undefined)` coerces to the first focusable on the
active surface. Only a surface with zero focusables
leaves focus undefined.
* `close-modal` validates `$!pre-modal-focus` against the
live tree before restoring. If the modal's action
destroyed that widget, focus falls through to the first
focusable on the active screen instead of dangling.
* New `check-focus-invariant` method runs at the top of
every event-loop iteration. If focus got detached
between ticks (Container::remove, screen destruction,
subscription tearing down a focus holder), it
re-acquires before the next event dispatches into a
stale reference.
* New public `widget-attached(Widget, Root)` — walks
`$w`'s parent chain; returns True iff it reaches
`$root`. Type-object callable (no App instance needed)
so tests can exercise the guard logic directly.
- `Selkie::App.set-theme(Selkie::Theme:D $theme)` — swap the
active theme at runtime. Updates the app's theme attribute,
repaints the stdplane base cell, cascades `set-theme` to
every registered screen's root widget (which in turn walks
their subtrees), and marks every screen dirty. Note that
consumers holding cached `Selkie::Style` values derived
from a theme's slots still need to rebuild those manually —
`set-theme` can't reach closures that copied style values
at construction. The guarantee is "every plane's base cell
and every widget's inherited theme updates"; cached styles
at the consumer layer stay the consumer's responsibility.
- `Selkie::ScreenManager.screen(Str:D $name)` — look up a
registered screen's root widget by name. Returns the
Container type object if no screen with that name exists.
Companion to the existing `active-root` / `has-screen` /
`screen-names` accessors; needed for `set-theme` to walk
every screen regardless of which one is currently active.
0.4.2 2026-04-17T01:48:20+01:00
- Fix SIGBUS on fast container rebuilds under themed apps (macOS).
Two overlapping cleanup gaps conspired to leave widgets holding
stale notcurses plane handles after their host container was
torn down, so the next store tick re-rendered into freed
memory:
* `Container.!unsubscribe-tree` only recursed through
`.children`, missing the widgets that Border / Modal hold
under `.content`.
* `Split` and `CardList` stash their kids in private slots
(`$!first` / `$!second`, `@!items`) rather than `@!children`,
so `.children` returned an empty list and the cascade
skipped their subtrees entirely.
Border and Modal now get walked via their `.content` accessor;
`Split.children` exposes the two panes; `CardList.children`
exposes each card's root + border. `container.clear` /
`container.remove` now reliably unsubscribe every descendant
before destroying, regardless of which container pattern they
live under. Visible as bus errors during tab-switching in
Border-wrapped, Split-hosted task lists.
- Move themed plane-base painting off the hot render path. The
per-widget `!sync-plane-base` call added in 0.4.1 ran from
`apply-style`, i.e. every frame on every widget — a reliable
UAF trigger when a widget whose plane had already been
destroyed by a cascade was still reachable via a leaked
subscription (see above). The sync now runs at
`init-plane` / `set-store` / `set-theme` — the one-shot
attach points — instead. Theme-background full-coverage is
unchanged; the per-frame cost is now zero.
0.4.1 2026-04-17T00:56:00+01:00
- Themed terminal background. `Selkie::App` now paints the notcurses
standard plane's base cell from `$theme.base` when a theme is
supplied, and `Selkie::Widget` syncs each per-widget plane's base
cell from the resolved theme on init-plane / set-theme / each
apply-style call. Together these give a theme background
full-coverage across the terminal — including widget gaps,
unwritten padding, and screen edges — instead of falling through
to the terminal's own default.
- New `Selkie::App.stdplane` public accessor so apps that need to
paint a custom base cell (or otherwise reach the standard plane)
can do so without reflection.
- Docs updated: `Selkie::App` gained a "Theme background" section
documenting the stdplane fill; `Selkie::Widget`'s "What you get
for free" lists the per-plane base painting.
0.4.0 2026-04-16T16:03:44+01:00
- Chart / plot widget family. Twelve new modules covering the
common archetypes: Selkie::Widget::Sparkline (single-row inline
chart, block glyphs), Selkie::Widget::Plot (streaming wrapper
over notcurses ncuplot / ncdplot), Selkie::Widget::BarChart
(vertical or horizontal, 1/8-cell precision via block glyphs),
Selkie::Widget::Histogram (adapter that bins a numeric series
and feeds it into BarChart), Selkie::Widget::Heatmap (2D grid
coloured by value via a ramp), Selkie::Widget::ScatterPlot
(braille dots at 2×4 sub-cell resolution), Selkie::Widget::
LineChart (hand-rolled braille multi-series renderer for
static data with fill-below + legend). Each widget supports
both static construction (:data / :series / :values) and
reactive binding (:store-path / :store-path-fn /
subscribe-with-callback).
- Selkie::Plot::Scaler — linear value→cell mapping primitive
shared across chart widgets. Auto-clamps out-of-domain values,
preserves NaN as undef, handles ±Inf, supports :invert for
y-axes (cell 0 at the top of the plane, max value at row 0).
- Selkie::Plot::Ticks — Heckbert nice-number algorithm for axis
labels at {1, 2, 5} × 10ⁿ. Returns values + formatted labels
with fixed decimal precision so sub-unit ticks line up
visually ("0.000", "0.005", "0.010" rather than "0", "0.005",
"0.01").
- Selkie::Plot::Palette — named series palettes and color ramps.
Default series palette is okabe-ito (8 colours, colourblind-
safe); tol-bright (7), tableau-10 (10) also available. Default
heatmap ramp is viridis (perceptually uniform, colourblind-
safe); magma, plasma, coolwarm, grayscale also provided.
Ramp sampling is linear interpolation in RGB space.
- Selkie::Widget::Axis — labelled tick axis for chart edges,
renders along top / bottom / left / right. Composes internally
or exposes reserved-rows / reserved-cols for layout callers
that want to size their chart body precisely.
- Selkie::Widget::Legend — colour-swatch + label rows for chart
series. Vertical (one row per series) or horizontal (single
row, spaces between). Truncates labels with ellipsis when
sizing-constrained. Theme-aware via new graph-legend-bg slot.
- Theme: six new graph-* slots — graph-axis, graph-axis-label,
graph-grid, graph-line, graph-fill, graph-legend-bg. All are
non-required with defaults derived from existing slots
(text-dim, divider, border-focused, base.bg), so themes
predating the chart widgets keep working without modification.
The default dark theme explicitly sets chart-tuned values for
a distinct presentation.
- All chart widgets render a centered "No data" placeholder in
text-dim style when their input is empty. This is the expected
startup state for monitoring dashboards — the widget is
mounted before the first sample arrives. Each widget accepts
:empty-message to customise the text (default "No data"; pass
"" to suppress).
- Test::Snapshot: new :capture-styles mode. Emits a format-
marked output with parallel glyph + style grids and a legend
keying each unique (fg, bg, stylemask) tuple to a letter.
Heatmaps, multi-series LineChart, and multi-coloured BarChart
can now be snapshot-tested without losing the colour semantics.
- Test::Snapshot::Harness: auto-routes styled scenarios to
xt/snapshots/golden-styled/ by detecting the format marker on
the first line of subprocess stdout. Plain and styled
scenarios coexist in the same directory — authors don't need
to know which bin their golden goes in.
- examples/charts.raku — reactive showcase of the whole chart
family. Sparkline binds to a store path; LineChart rebuilds
from a subscribe-with-callback over two latency paths
(p50 stays ≤ p99 by construction); Plot streams its own
native buffer. BarChart + Histogram + Heatmap + ScatterPlot
stay static to demonstrate the different chart archetypes.
- examples/dashboard.raku — new "History" column on the Servers
table renders an inline sparkline-as-string per row. Proves
the most demanding inline-chart-in-table case works cleanly
(high instance count, no native handles per row).
- tools/build-api-docs.raku: added -I lib to the subprocess
invocation so modules that import other modules from the same
distribution can resolve their deps before install. Previously
any new module with an in-dist `use` line would fail doc
generation with "Could not find X" and silently produce no
docs/api/*.md.
- Widget authors — two Raku gotchas surfaced during this work
that are worth internalising: (1) `@data.all ~~ Pair` is true
when a single-hash array flattened into a list of pairs, and
`@x = { |@pairs }` produces a Block not a Hash; use `%(|@pairs)`
instead. Affects any widget that accepts a list of structured
entries. (2) `.map: { ... }` in a constructor arg list swallows
trailing named arguments because Raku parses `.map: { ... },
foo => bar` as `.map(: { ... }, foo => bar)`. Use explicit
parens — `.map({ ... })` — in constructor arg lists.
0.3.0 2026-04-15T03:00:16+01:00
- Add title helper
- Auto install with no building for most platforms
0.2.4 2026-04-13T17:09:44+01:00
- Widget::HelpOverlay: new modal that walks the focused widget +
its ancestors, collects all `on-key` binds that carry a
`:description`, and renders them grouped by widget class.
Authors opt in by passing `:description` to `on-key` — undocumented
binds (e.g. cursor-movement plumbing) stay out of the overlay.
- Widget.on-key: new `:description` named arg. Stored on the
Keybind so HelpOverlay can surface it. Existing call sites stay
working (no description = bind doesn't appear in the overlay).
- Widget: new public `keybinds()` accessor returning the list of
registered Keybinds. Used by HelpOverlay; harmless to call from
anywhere.
- Event: Keybind gained `$.spec` (original spec string, e.g.
"ctrl+h") and `$.description` fields. `Keybind.parse` accepts
`:description`.
- Widget::Button: new `set-label` method — replace the displayed
label at runtime (e.g. "Save 3 Edited Entries"). The label
attribute was previously immutable.
- Widget::Text: new `set-style` method — swaps the style
override at runtime and marks the widget dirty. Previously
the style attribute was read-only after construction, so
changing colour/emphasis required rebuilding the widget.
- Widget: new `park()` method — explicit hook for moving a widget
off-screen while preserving its state. Default implementation
repositions the widget's plane to (10000, 0). Container override
recurses through children + content. Image widget overrides to
destroy its blit-plane (and thus the sprixel — Sixel/Kitty pixel
data the terminal renders at an absolute screen position and
won't clear just because the parent plane moved).
Every place we previously parked widgets via plain `reposition`
now calls `park` instead — App.switch-screen, App.add-screen,
Border.set-content(:!destroy), Modal.set-content(:!destroy),
and CardList's off-screen item parking. Without this, sprixels
(Image widgets' pixel-graphics output) leaked between screens,
tabs, and scroll positions, painting over the new content.
- Widget::TabBar: focus visibility. Focused bars render a `▶ `
chevron prefix and use `tab-active` styling on the active tab;
unfocused bars show ` ` padding and dim the active tab to
`tab-inactive`. Brackets `[ ]` always mark the active tab so
users can tell WHICH tab is current even when the bar isn't
driven. Critical for screens with multiple TabBars where the
only previous focus cue was "nothing else has focus."
- Theme: new `tab-active` / `tab-inactive` slots. Widget::TabBar
previously reused text / text-highlight for inactive/active —
a subtle bold + color change that users missed. The new
dedicated slots default to a distinct bg colour on the active
tab so it's unambiguously selected. Bracket decorators on the
active tab are retained for low-colour environments and
screenshots. Custom themes must add the two new slots (they're
`is required`); see `examples/chat.raku` for a reference.
0.2.3 2026-04-13T12:31:11+01:00
- App: disable IXON / IXOFF flow control after notcurses_init so
Ctrl+Q (XON) and Ctrl+S (XOFF) reach the application as
keystrokes instead of being eaten by the tty driver. notcurses's
cbreak mode clears ECHO / ICANON / ICRNL but leaves IXON set,
so on macOS Terminal.app the built-in Ctrl+Q quit keybind
silently didn't work (Kitty disables IXON by default so it
worked there). notcurses_stop restores the original termios at
shutdown, so flow-control comes back on automatically when the
app exits.
- Widget::PasswordStrength: live strength meter bound to a
TextInput via its `on-change` Supply. Five levels (weak → fair
→ good → strong → very strong), colored bar + label, simple
length-plus-character-class heuristic. 12-subtest unit coverage
on the scoring; snapshot coverage at each level.
0.2.2 2026-04-13T11:59:55+01:00
- Test::Snapshot: terminal restore on END via notcurses_stop.
Previously skipped, leaving Kitty keyboard protocol pushed and
mouse tracking enabled — after a test run the parent terminal
didn't accept Ctrl+C as SIGINT (transmitted as an escape
sequence) and Enter arrived as ^M (ICRNL off), so shells /
mi6 / prove6 couldn't accept input. Users had to close the
terminal tab.
Fix: redirect fd 1/2 to /dev/null in an INIT phaser (must be
INIT, not module-level mainline — Raku's precomp-and-cache
semantics meant mainline fd juggling never re-ran in the
consumer process), rerouting Raku's $*OUT to a fd-backed
handle pointing at the saved original stdout so TAP keeps
flowing. Then call notcurses_stop in END; its stderr warnings
("signals weren't registered" etc) land in /dev/null rather
than the TAP stream.
Also: previous attempts used open(2) to get the /dev/null fd,
but open is variadic on macOS and Raku's NativeCall can't
reliably invoke variadic C functions; it silently returned -1
and dup2 was a no-op. Switched to fopen + fileno (neither
variadic), which works reliably.
Plus an stty termios restore as a belt-and-suspenders layer.
0.2.1 2026-04-13T10:46:15+01:00
- Add API docs.
0.2.0 2026-04-12T23:00:39+01:00
- Poll-based resize detection. notcurses doesn't reliably emit
NCKEY_RESIZE through the input queue on every platform (macOS
absorbs SIGWINCH into render without queueing the event),
so the App run loop now polls stdplane dims every frame
(~60/s, negligible cost) and handles the resize synchronously
when dims change. Previously, on platforms with no event
delivery, the UI stayed broken until the next input — users
thought the app had crashed and hit Ctrl-Q before recovery.
- Resize now calls notcurses_refresh() to invalidate notcurses's
internal frame-diff state. Without the refresh, cells whose
composited value happens to match pre-resize (but whose
underlying plane shifted) aren't re-emitted, leaving the
terminal with duplicated chrome, missing column titles,
vanished app header, triple-stacked borders, and random
dropped letters. Refresh is the only way to force a full
re-emit of every cell on the next render.
- Resize protocol: new `handle-resize(rows, cols)` method on
Widget — the explicit entry point for terminal-resize events.
Cascades synchronously through containers (VBox, HBox, Split,
Border, Modal, CardList) so every widget knows its new
dimensions before the next render AND before frame-callbacks
that read widget dimensions run. Previously, dims propagated
via next-render layout, leaving a one-frame window where
on-frame handlers saw stale values — visible as ghost-line
artifacts in apps that re-layout based on widget dims (e.g.
Cantina's ChatView.check-refresh). A matching `!on-resize()`
hook fires whenever dims actually change; Text and RichText
use it to rewrap their content.
- ScreenManager.handle-resize propagates a resize to every
registered screen, not just the active one. Switching to a
previously inactive screen after a resize no longer shows
stale dimensions.
- Toast.resize-screen → Toast.handle-resize (aliased for back
compat). Unifies the resize entry point across the framework.
- ConfirmModal: long messages now wrap to modal width instead of
clipping. Switched the message widget from Text (single-line) to
RichText (wraps). At small modal heights the wrapped text can
still overflow — bump `height-ratio` on the call site for prompts
longer than ~60 chars.
- Test::Snapshot::Harness: new module, factored out the
fork-per-scenario harness so every Selkie app can drop
`use Selkie::Test::Snapshot::Harness; run-snapshots;` into an
xt/ file instead of copy-pasting 80 lines. Centralises the
MVM_SPESH_DISABLE=1 subprocess env, the golden-file path
layout, the diff format, and SELKIE_UPDATE_SNAPSHOTS handling.
- Test::Snapshot: render-to-string now composites the pile via
notcurses_render + reads from the rendered frame with
notcurses_at_yx, so container widgets (Border, VBox, HBox,
CardList, nested trees) snapshot correctly. Previously we read
directly from the widget's plane, which only sees what was drawn
on that single plane — child subplanes were invisible, so all
container snapshots came out empty.
- xt/02-snapshots.rakutest: fork-per-snapshot harness. Each
scenario under xt/snapshots/*.raku runs in a fresh Raku process,
captures stdout, and diffs against xt/snapshots/golden/.snap.
Isolating each render sidesteps the MoarVM spesh bug that
made in-process snapshotting of nested widgets flaky. Adding a
new test case is just dropping a .raku file in xt/snapshots/.
- CI: add Windows (MSYS2 UCRT64) to the GitHub Actions test matrix.
Build-only on Windows since notcurses's upstream test harness is
Unix-only.
- README: document system dependencies (cmake, ncurses, deflate,
unistring, ffmpeg/OpenImageIO) with per-OS install commands for
Linux (Debian/Fedora), macOS, and Windows (MSYS2 UCRT64).
- xt/: moved snapshot tests out of `t/` — they can't run in
fork-constrained CI environments.
- Test::Snapshot: golden-file snapshot testing. `snapshot-ok` renders
a widget through a shared headless notcurses instance (notcurses
only allows one init per process), reads cells back via
`ncplane_at_yx`, and diffs against a stored .snap file. Set
SELKIE_UPDATE_SNAPSHOTS=1 to accept new output.
- Test::Keys, Test::Supply, Test::Store, Test::Focus, Test::Tree:
a suite of helpers for widget tests. press-key / press-keys /
type-text for event synthesis, collect-from / emitted-once-ok /
emitted-count-is for Supply observation, mock-store /
dispatch-and-tick / state-at for store-backed tests, with-focus
block helper to gate focus-dependent behaviour, walk / find-widget
/ find-widgets / contains-widget-ok for tree introspection.
- Existing test files migrated from per-file `sub key-event` to
the new helpers.
- Spinner widget: tiny animated loading indicator with built-in
frame sets (BRAILLE, DOTS, LINE, CIRCLE, ARROW) or custom frames.
Drive via tick() from a frame callback.
- TabBar widget: horizontal tab strip with keyboard navigation
(Left/Right/Home/End/Enter). sync-to-app integrates with
ScreenManager — the bar's active tab follows active-screen.
- CommandPalette widget: VS-Code-style fuzzy-filtered action
launcher. Register commands with label + action callback; emits
the activated Command on on-command Supply.
- Table widget: scrollable tabular data with typed columns, header
row, sort indicators, cursor navigation, custom cell rendering.
Column widths use the same fixed/percent/flex sizing model as
layouts.
- Border.set-content / Modal.set-content accept :destroy flag (default
True). Pass :!destroy to swap content without destroying the outgoing
widget — useful for tab-style panes that cycle through persistent
views.
- ListView, RadioGroup, Select: set-items now preserves the current
selection by value when the previously-selected label is still in
the new list. Falls back to clamping the index on missing values.
Resets only when the list becomes empty.
- Selkie::App.on-key accepts :screen to scope a keybind to a named
screen. Unset means truly global (e.g. Ctrl+Q quit).
- Widget.once-subscribe / once-subscribe-computed: idempotent helpers
that track per-id registration, so repeated set-store / reparent
calls don't create duplicate subscriptions. Border now uses this.
- Store.enable-debug: opt-in logging of every dispatched event, the
effects its handlers return, and each subscription fire. Pass a
custom log handle or let it default to $*ERR.
0.1.1 2026-04-12T04:00:39+01:00
- Fix Github Actions
0.1.0 2026-04-12T03:54:47+01:00
- Fix Border focus subscription crash: replaced `return False without
$focused` (which targets the enclosing method, not the closure) with
a ternary. Bug crashed Selkie::App into raw mode on first focus tick.
- examples/: counter, settings, file-viewer, tasks, job-runner, chat —
a curated set covering every widget and store pattern.
- Checkbox widget: focusable toggle with [x]/[ ] indicator, on-change Supply
- ProgressBar widget: determinate (0..1 value with percentage) and
indeterminate (bouncing animation via tick) modes, customizable
fill/empty characters
- RadioGroup widget: single-selection from labeled options with (●)/( )
indicators, keyboard navigation, scrollbar support, on-change Supply
- Select widget: dropdown picker with child-plane overlay, open/close
state management, cursor navigation, Esc to cancel, focus-loss auto-close
- Initial framework with core widget tree architecture
- Style and Theme system with semantic style slots
- Event system with keybind parsing and modifier support
- Layout widgets: VBox, HBox, Split
- Content widgets: Text, TextStream, TextInput, ScrollView, Image
- Virtual scrolling with render-only-visible-rows strategy
- App lifecycle with event loop and dirty-tracking render cycle
- Automatic memory management for all notcurses handles
- ListView widget with keyboard navigation and selection Supply
- Image widget supports set-file for dynamic image swapping
- Image browser example with split pane file list + image preview
- Fix callsame not dispatching to role methods in Widget, Container,
Split — replaced with private helper methods (!apply-resize,
!destroy-plane) that compose correctly across roles
- Comprehensive test suite: Widget role, Container role, Text,
TextStream, TextInput, ListView, ScrollView (137 tests total)
- ScreenManager for named screen switching with scoped focus
- Modal overlay widget with focus trapping and close Supply
- RichText widget with inline-styled spans and span-aware word wrapping
- MultiLineInput widget with 2D cursor, Shift+Enter for newlines,
desired-height for dynamic layout, and submit/change Supplies
- App integration: ScreenManager replaces single root, modal support
with show-modal/close-modal, focus scoping to active screen/modal
- Border widget with auto-focus highlighting via store subscription
- Button widget with activation Supply
- ConfirmModal: yes/no dialog with focus on no-button by default
- CardList widget: cursor-navigated scrollable list of variable-height
widgets with top/bottom clipping and scroll tracking
- Reactive Store (re-frame style): centralized state with event dispatch,
handlers, effects (:db, :dispatch, :async), path/computed/callback
subscriptions, and per-frame tick processing
- Widget/App store integration: auto-propagation through widget tree,
convenience dispatch/subscribe methods, on-store-attached hook
- Focus management via store: Tab/Shift-Tab cycling, modal focus
scoping, Border self-manages highlight via focus subscription
- Error recovery: App.run wraps event loop in CATCH for terminal restore
- Viewport hierarchy: layouts pass absolute position and bounds to
children for correct clipping
- Toast widget: temporary overlay messages with auto-dismiss
- Default keybinds: Tab, Shift-Tab, Esc (close modal), Ctrl-Q (quit)
- FileBrowser modal: shell-style path completion with Tab, type to
filter, Enter to navigate/select, configurable extensions and
show-dotfiles parameter
- TextInput: set-text-silent for programmatic updates without emitting,
keybind check in default branch for registered keys (up/down etc)