Revision history for Selkie
0.14.4 2026-08-14T21:46:19+01:00
- Release Selkie 0.14.4 with a Notcurses::Native 0.5.2 dependency floor
and a metadata regression test that locks both versions. The previously
drafted 0.5.1 floor named an unreleased intermediate; 0.5.2 is the first
published release with the completed Windows loader contract for both
sibling-closed prebuilts and MSYS2-dependent source builds, plus visual
and blit bindings corrected to their exporting core library. This keeps
Windows Selkie applications from resolving an older, incomplete native
dependency combination.
0.14.3 2026-08-14T13:43:25+01:00
- [2026-08-14] Centralized controlling-terminal operations and lifecycle
signal selection in Selkie::App::Internal::TerminalPlatform. Windows
now returns before every POSIX /dev/tty path, termios NativeCall, stty
invocation, and SIGWINCH/fatal-signal tap. This fixes Windows startup
resolving the nonexistent process-library symbol `close`: the old
method-scoped LEAVE phaser ran even when capture returned before opening
a tty. Selkie::App and Selkie::Test::Snapshot now share the same opaque
in-process tcgetattr/tcsetattr capture and restore, and title/cleanup
writes plus IXON/IXOFF setup go through the same adapter. Windows CI now
runs the terminal cleanup/platform regression directly.
Native image integration tests now reuse SnapshotPlatform's selected
null-device FILE* rather than redeclaring bare fopen, and the extended
snapshot test removes scratch trees with Raku IO instead of `rm -rf`.
ErrorLogPlatform, SnapshotPlatform, and TerminalPlatform keep their
distinct platform semantics while sharing one POSIX-only process-library
declaration for dup/dup2/close; Windows never passes MoarVM descriptors
across its private CRT boundary.
- [2026-08-14] Terminal and image debug logs use Raku's platform-native
`$*TMPDIR` instead of the POSIX-only `/tmp` path.
0.14.2 2026-08-13T21:32:04+01:00
- [2026-08-13] Snapshot helpers now import safely on official Windows
Rakudo builds. Snapshot selects `NUL` and obtains only the FILE* passed
to dynamically linked notcurses from `ucrtbase`; it never applies that
CRT's descriptor calls to MoarVM's private `/MT` fd 1 or 2. POSIX keeps
its process-library descriptor redirection and `/dev/null`.
The Windows CI smoke imports Selkie::Test::Snapshot so its INIT
phaser exercises NativeCall's lazy symbol resolution without
requiring notcurses rendering.
- [2026-08-13] Error-log redirection now works on Windows. The
implementation previously looked for the POSIX symbols `dup`,
`dup2`, and `close` in the process image, so every Selkie app that
configured an error log died during construction with `Cannot
locate symbol 'dup'`. Official Windows MoarVM uses `/MT`, so its fd 2
belongs to a private CRT that no NativeCall DLL can safely modify.
Windows now rebinds Raku's `PROCESS::<$ERR>` and independently opens
and installs an append-only Win32 `STD_ERROR_HANDLE`; POSIX retains
`dup`/`dup2` redirection.
Handle swaps preserve Rakudo's original `PROCESS::<$ERR>` Scalar and
assign into it; binding the symbol itself to a temporary method or
attribute container can hang Windows and lets later state cleanup
replace the restored handle with the `IO::Handle` type object.
The Win32 bindings model `STD_ERROR_HANDLE` as its required
unsigned DWORD value (`0xFFFFFFF4`) and HANDLEs as opaque pointers.
Redirection is transactional and fails open if the runtime calls
cannot be established. A headless subprocess roundtrip runs in the
Windows CI lane so lazy NativeCall resolution is exercised.
- [2026-08-13] Fragmented off-surface SGR mouse reports no longer
leak into focused inputs or application keybinds. Kitty can emit
signed coordinates while the pointer is outside the drawable
terminal surface (for example `\e[<0;3510;-10M`); Selkie's
terminal-report guard previously rejected the minus sign, then
dispatched the replayed bytes as ordinary keys. The recogniser
now accepts signed coordinates only in the strict three-field SGR
mouse grammar, leaving generic CSI matching unchanged. The exact
captured press, motion, and release triplet is covered in
t/99-terminal-report.rakutest.
0.14.1 2026-08-12T20:03:43+01:00
- Notcurses::Native dependency floor raised to 0.5.0 — the release
whose prebuilt binary packs carry self-built, licence-audited
LGPL dependencies (with corresponding source attached to each
binary release) on every platform.
0.14.0 2026-08-12T02:47:38+01:00
- [2026-08-12] Snapshot tests no longer touch the real terminal —
fixes intermittent whole-suite hangs (observed under interactive
runs and `mi6 release`) and one-off blank renders. notcurses
ignores its non-tty output FILE* and interrogates the controlling
terminal via /dev/tty during init, blocking without timeout on
the query replies; with dozens of scenario subprocesses doing
init/stop round-trips against the developer's terminal, a lost
or fragmented reply (tmux fragments them) froze the suite, and a
garbled one could degrade init into an empty render.
Selkie::Test::Snapshot now detaches from the controlling
terminal (POSIX setsid) before notcurses_init, so init is the
same deterministic headless path everywhere: no queries, no
termios/Kitty-protocol churn on the developer's terminal, no
pixel-capability variance between local runs and CI. Best-effort:
a scenario launched directly from an interactive shell is a
process-group leader and stays attached (that's the documented
"rerun standalone" debugging path); SELKIE_SNAPSHOT_ATTACH_TTY=1
skips the detach explicitly. The harness also drains scenario
stderr concurrently with stdout, removing a latent pipe-buffer
deadlock for scenarios that die spewing diagnostics.
xt/07-snapshot-detached-tty reproduces the hang environment with
script(1) — a pty whose master never answers queries — and
asserts prompt completion plus golden output.
- [2026-08-11] Store.subscribe-path-callback guards its prime
against re-entry. The prime invokes the caller's callback
synchronously; a callback that re-subscribed its own id on some
condition (a cache-miss re-arm in a consumer app) recursed
without bound inside the hosting tick — an app-wide freeze with
no error, no further frames, and dead input, since the render
loop hosts store ticks. Ids whose prime is on the call stack are
latched: a re-entered subscription (direct or via a cycle) still
registers, but its nested prime is skipped and logged to the
store debug log. Independent nested subscriptions are unaffected.
The synchronous-prime contract is now documented in the method
Pod. t/106-subscribe-prime-reentry covers self-cycle, mutual
cycle, and the legitimate nested case.
- [2026-08-11] Render-loop performance: five changes root-caused
against a real-world consumer capture where idle frames cost 250-340ms
and `image.destroy-blit-plane` accounted for 33% of all frame
time. Behaviour is unchanged; the framework simply stops doing
work it was throwing away.
(1) Image.render-viewport-crop now diffs a render cache before
tearing its sprixel down, the counterpart of the diff
Image.render already had. The key covers the parent plane, the
source id, clip-only, the widget's own cell rect, cell-pixel
geometry, the caller's crop window, and the pixel protocol —
exported as `crop-cache-key` so the invalidation matrix is
testable without a terminal. "The image is entirely scrolled out
of view" is cached as a verdict of its own, so a fully off-screen
image stops re-running the rescale and geometry probe every
frame. Paired with it: Image.render no longer destroys a
crop-owned blit plane when it defers for a :clip-only image.
Together these break a self-sustaining loop — every teardown
dirtied the card that had just rendered, which re-rendered, which
tore down again.
(2) Selkie::Tree's mark-widgets-in-rect-dirty prunes parked
subtrees. A parked widget's descendants ride its plane off-screen
in notcurses, so none of them can intersect an on-screen rect;
in a long transcript that skips ~95% of the tree (measured 36ms
-> 1.7ms for a 9,600-widget tree). The capability tests for
`children` / `content` moved to nqp::can, which answers the same
question as .^can without building the candidate list (~4x
cheaper per node, and the duck-typing is deliberate: CardList
and ViewportedCardList expose children without composing
Container). Selkie::Widget gains `is-parked` to support this,
maintained by reposition / init-plane / adopt-plane; `park` now
always routes through reposition so the latch cannot desync.
(3) Selkie::Store indexes subscriptions by widget, so
unsubscribe-widget is O(that widget's subs) instead of a grep
over every live subscription — it fires once per widget of a
torn-down subtree, and cost 38-40ms per dialog close at
a consumer app's ~850 live subs. The index is maintained at the
single point where entries actually leave the hash, so it stays
consistent through the defer-mutations-during-walks path.
(4) Selkie::ScreenManager.remove-screen (and .destroy)
unsubscribe the screen's tree before destroying it. Plain destroy
only reaches what each container's own destroy reaches, which
misses exactly the containers that hold children outside
@!children — CardList's cards, Border/Modal's content — leaking
~7 subscriptions per editor open/close cycle in a consumer app,
each one then paying pull-check cost on every later tick. The
walk is now Selkie::Widget.unsubscribe-subtree, shared with
Container's remove / clear cascade, which also fixes that
cascade's blind spot for CardList.
(5) The swap path is instrumented: `screen.switch` (with the
live subscription count) and `screen.remove` spans, so a capture
shows what a screen swap actually costs instead of leaving it
inside an opaque ui.input.dispatch.
- [2026-08-11] Selkie::Widget::MultiLineInput gains an opt-in
`enter-submits` mode for chat-style composition. Default is
False and changes nothing: Enter inserts a newline, Ctrl+Enter
emits on on-submit, and every other Enter chord inserts, exactly
as before. Set it True (constructor argument or runtime
assignment — the attribute is `is rw`) and the Enter branch
inverts: a bare Enter submits, while Alt+Enter, Shift+Enter and
Ctrl+Enter insert a newline. The newline path replaces the
active selection and the keystroke is consumed in both modes;
submitting emits the buffer verbatim and never clears it.
Alt+Enter is the binding worth advertising in an app's help
text: it is the only Enter chord that survives every keyboard
encoding, because legacy terminals transmit Alt as an Escape
prefix (Alt+Enter is ESC CR, two bytes) whereas Shift+Enter and
Ctrl+Enter are byte-identical to a plain Enter there and
therefore submit. Shift/Ctrl are wired as aliases for the
terminals that can report them (kitty keyboard protocol,
XTMODKEYS).
- [2026-08-11] Selkie::Event.from-ncinput now decodes legacy
ESC-prefix Alt. notcurses coalesces the two bytes a legacy
terminal sends for Alt+key into a single ncinput, but records
the fact only in the deprecated `alt` boolean — its `modifiers`
bitmask stays zero (src/lib/automaton.c, escape-root fallback),
and Selkie read only the bitmask. Alt-modified keys were
therefore indistinguishable from unmodified ones on every
terminal without the kitty keyboard protocol or XTMODKEYS: no
`alt+X` keybind could fire there, and Alt+Enter looked like a
bare Enter. The boolean is now folded into the modifier Set
alongside the existing bitmask decode and the legacy Ctrl+A..Z
control-code remap, so widgets and Keybind.matches see one
normalised Mod-Alt whichever encoding the terminal speaks.
0.13.0 2026-08-11T16:20:49+01:00
- [2026-08-11] The typed store. Selkie::Store::Schema is a base
class for declaring an app's state tree as typed, immutable
value classes; Selkie::Store.new(:state-class(MyApp::Db)) (or
:initial-state with a pre-built root) swaps the bare state Hash
for a schema instance. Opt-in and additive: with neither
argument every store code path is exactly what it was, and the
Hash store remains first-class forever, not a deprecation
target. On a typed store the existing db / db-replace /
db-delete effects keep their shapes but validate keys, paths,
and slot types against the schema at dispatch time; failures
route through the existing __effect-error isolation, whose
payload gains an additive `event` key (both stores) naming the
dispatch that produced the failing effect. The new db-update
effect ({ fn => &fn }) is the typed workhorse: fn receives the
root schema instance and returns a patched clone built with the
schema's write methods — with (plus with- FALLBACK
sugar), merge, set-at, deleted-at — all of which return `self`
on writes that change nothing. Because clones share unchanged
branches, the store diffs old vs new root by identity
(Schema.changed-paths) and marks exactly the changed paths
dirty, so push subscriptions keep their precision without any
signature change; schema instances compare by .WHICH in the
subscription digest, which the no-op-returns-self rule makes
exact. Collection slots (@./%.) are the one nuance: Mu.clone
gives clones fresh @/% containers, so those slots compare by
one-level element identity instead — an equal-element write is
a no-op, and large collections are documented as better held in
a $-slot List replaced wholesale. Reads: the new Store.state
returns the schema root (typed accessors) on typed stores and
the state Hash on untyped ones; get-in walks slots as path
segments and keeps its missing-step-reads-as-Nil contract;
.db dies on a typed store with a pointer to .state. assoc-in
translates to schema writes (no auto-vivified slots — unknown
slots die; plain-Hash slots keep classic Hash semantics inside
their region, deep-merging and auto-vivifying on copied
hashes). db-update works on the untyped store too (fn gets the
Hash, the root is marked dirty bluntly, the digest gate still
stops no-op fires). The framework's own focus state lives in
the required `ui` slot — every root schema declares
`has Selkie::Store::Schema::UI $.ui .= new;` (subclassable for
app ui state) and Store.new fails fast with that exact line
when it's missing. Selkie::Test::Store.mock-store passes
:state-class / :initial-state through. New
examples/typed-counter.raku mirrors counter.raku on the typed
store; t/100-store-schema pins the Schema value semantics and
t/101-store-schema-integration pins the store integration,
including the ui/focus* handler flows against a schema root.
0.12.1 2026-08-11T10:53:32+01:00
- Input dispatch no longer types fragmented terminal capability
replies into the focused widget. A terminal answers DA1/DA2, CPR,
XTWINOPS, XTGETTCAP, XTVERSION, XTSMGRAPHICS, DECRPM, SGR mouse,
OSC colour and the kitty keyboard/graphics protocols by writing an
escape sequence back on the tty; notcurses absorbs those replies
only when the whole reply lands in one read(2). Measured against
notcurses 3.0.17 in a pty, splitting `\e[?64;1;2;6;9;15;18;21;22c`
with as little as a 0.5ms pause makes notcurses replay it as 26
ordinary keypresses — which is how twenty-five characters of
`[?64;1;2;...c` once ended up prepended to a passphrase in a
masked login field on a machine whose cores were all busy.
Selkie now reassembles an Escape-led input burst, waiting briefly
(12ms for a bare Escape, 50ms once an introducer has arrived, and
only while what has arrived is still a valid partial sequence)
for the rest of a fragmented reply, and drops complete terminal
reports instead of dispatching them. Real typing cannot match:
a hit needs both a byte sequence nobody types and an arrival rate
nobody can type at — the whole sequence has to already be queued
between two consecutive non-blocking polls of a 60Hz loop. New
Selkie::App::Internal::TerminalReport carries the grammar and the
reasoning; t/99-terminal-report pins both.
- Selkie::Widget::TextInput gained a runtime reveal switch for
masked inputs: `masked`, `revealed`, `set-revealed(Bool)` and
`toggle-revealed`, which is what a show-password affordance is
built on. Display-only — the buffer, the caret, `text` and every
Supply behave identically either way, and revealing emits nothing
on `on-change`. Revealing an input built without a `mask-char` is
a no-op rather than a way to turn an ordinary field into a
password field by accident.
0.12.0 2026-08-09T20:21:10+01:00
- `try shell '...'` no longer takes an app's construction down with
it. Proc throws from its `sink` method, and the statement-level
sink of `try`'s result happens OUTSIDE the try — so the
best-effort `stty -ixon` call in Selkie::App's TWEAK (and the
matching termios restore in Selkie::Test::Snapshot) propagated on
any host where /dev/tty exists but is not usable, killing
notcurses_init from a line whose whole purpose was to be
ignorable. Both now assign the Proc rather than sinking it, and
t/98-best-effort-shell pins the idiom out of the library.
- Modals gained a per-instance C flag (default True).
Building one with C<:!dismissable> makes it fully user-undismissable:
the global Esc keybind ignores it while it's topmost, and
click-outside dismissal (C) is suppressed
too, regardless of its own setting. Programmatic dismissal —
C and C — is untouched, so a
"processing, please wait" modal can still close itself when its own
work finishes.
0.11.1 2026-08-03T17:44:49+01:00
- Use latest Notcurses::Native to improve multiplexer support
- [2026-08-03] Table's cursor row can no longer be styled out of
existence. A row-style that pinned both colours and was already
bold — a budget grid's group headers, a banner row — merged over
the cursor base to exactly the style it merged over the body
base, so the cursor row rendered byte-for-byte like its
neighbours and the cursor was simply invisible. On the cursor row
the highlight background is now authoritative: theme
text-highlight's own bg (the base bg when the slot has none)
wins over whatever the row style asked for, while the row's
foreground and flags still survive — an error-red row under the
cursor is still red, still bold, and now visibly under the
cursor. Where even that comes out identical (palettes whose
highlight background IS the raised-surface colour a header row
uses — Catppuccin, Gruvbox, Nord and Solarized all collapse those
two steps into one) the theme's `selection` pair takes over for
that one row, taking its foreground too if the row's own would
vanish against it. effective-row-style stays public and now
invokes the row-style callback exactly once per call whatever the
cursor state, so all of this is assertable without a terminal.
0.11.0 2026-08-03T11:57:56+01:00
- [2026-08-02] Two of the framework's own overlays can now fade
themselves, both strictly opt-in and both off by default:
Selkie::App.new(:animate-backdrop) makes show-modal ramp a
BackdropScrim modal's scrim up over ~120 ms, and
:animate-toast makes the toast bar resolve in over ~100 ms and
dissolve out over the last ~200 ms of its life. Off is the
important half — with the flags unset no tween is built, the
tween group is never allocated, its per-frame ticker is never
registered, and every existing consumer composites the identical
cells it always did. Both flags are also runtime-settable
(set-animate-backdrop / set-animate-toast) so a "reduce motion"
preference can turn them off mid-session, and turning the toast
one off cancels a fade in flight rather than freezing a
half-transparent bar on screen. What ramps is colour, never
alpha: alpha is a two-bit enum, so the scrim's AlphaBlend is held
fixed while its RGB walks from the theme's base background (a
50/50 mix of the surface with itself — no visible dim) to the
modal-scrim slot. Modal gains set-scrim-style /
clear-scrim-style / effective-scrim-style / scrim-channels'
override, backdrop-fade-endpoints (pure, so a palette's ramp can
be asserted without a frame), fade-backdrop-in and
cancel-backdrop-fade; Toast gains enable-fade / disable-fade,
render-style, and Instant arguments on show and tick so its
lifetime is drivable from a test clock. There is deliberately no
modal fade-OUT: close-modal destroys the modal synchronously, so
an out-fade would have to keep painting into a widget that has
already surrendered its planes. Every tween is owned by the
widget it animates and cancelled by destroy, by set-backdrop, and
by the next show — a fade can never outlive its plane. Soaked at
20 000 open/close/flash cycles (460 000 group ticks, spesh
enabled and again with MVM_SPESH_NODELAY): no leaks, refcount
back at zero after every cycle, flat RSS.
- [2026-08-02] Widgets can be disabled: set-disabled greys the
subtree in place and drops it from focus. Not via
ncplane_greyscale — that walks the framebuffer converting every
cell, and a channel marked "use the default" reads back as 0,0,0,
so it stamps explicit black over every default-coloured cell and
defeats the base-cell fallthrough (verified on a live plane; the
first attempt rendered disabled panels as black bars). Instead
greyscale-plane walks cells and 1×1-stains only what changed,
preserving default-ness, wide glyphs, and never-written cells;
Style.greyscale uses notcurses's exact Rec. 601 truncating
arithmetic, swept against real ncplane_greyscale output over
7550 colours with zero mismatches. Disabled subtrees drop out of
focusable-descendants, App.focus refuses them, mouse clicks no
longer *focus* them, and the focus invariant re-homes focus off
a widget disabled while focused. Two documented limits: Images
don't grey (sprixels are pixel data), and a disabled widget does
not yet swallow mouse clicks — greyed but still clickable until
that semantic gets its own decision. Style.channels is the new
public "style → notcurses channel word" packer that base cells,
the modal scrim, and the greyscale path all share.
- [2026-08-02] Text and the box containers learn alignment. The new
Selkie::Align module defines TextAlign (left / center / right,
left being exactly what Text always did) and CrossAlign (fill /
start / center / end). Text.align x-offsets each line rather than
space-padding it, so centred text over a scrim or gradient
doesn't stamp opaque background where the base cell should show
through; the same offset applies in render-region, so ScrollView
content centres too. VBox/HBox gain align-items, and a child can
override with align-self plus an optional cross-sizing (a plain
Sizing resolved against the parent's cross extent). Defaults are
bit-identical to the old fill-everything layout. One deliberate
rule: a child whose cross extent resolves to zero is parked but
keeps its main-axis slot — a narrow terminal must not silently
reflow the stack. Widths are counted in characters, as
everywhere else in Selkie; CJK/emoji caveat in the Pod.
- [2026-08-02] Gradients. Selkie::Gradient wraps
ncplane_gradient / ncplane_stain / ncplane_gradient2x1 behind a
four-corner Gradient value class with horizontal / vertical /
corners / uniform factories, and — the part that actually earns
the wrapper — for-region, which collapses degenerate geometry
the way notcurses itself interpolates it (top row, left column,
never an invented average), because the raw natives refuse a
one-row region with mismatched corners by painting nothing and
returning -1. gradient-fill writes glyph + colour (call it
before text), gradient-stain recolours existing glyphs (after
text, and it skips never-written cells — pad the row with
spaces first), gradient-fill-hires doubles vertical resolution
with ▀ half-blocks on UTF-8 terminals. Oversized extents clamp
to the plane instead of refusing; negative origins throw rather
than silently meaning "at the cursor". GradientFill is the
trivial decorative-pane widget over the same call.
- [2026-08-02] Tweens and an animation budget. Selkie::Tween is a
wall-clock interpolator — ease (linear / quad in / out / in-out),
lerp-rgb, lerp-style, a Tween class whose tick takes an explicit
Instant so tests never sleep, and a TweenGroup that drops
completed members. Wall-clock matters: under the idle ladder a
throttled tween takes bigger steps but still lands exactly on
1.0 and fires on-complete once. Colours lerp; booleans, alpha
modes, and mixed defined/undefined colours snap at the halfway
point, because notcurses alpha is a two-bit enum and "fade"
means interpolating RGB, not opacity. App.tweens hands out one
lazily-wired group whose activity pins the render loop at the
hot budget (begin-animation / end-animation, refcounted and
thread-safe) so an animation started from a background handler
isn't ticked at 4 Hz — and costs nothing when idle. Every
on-update must mark something dirty; the Pod's examples all do.
- [2026-08-02] TabBar grows badges and active-tab variants. Each
tab can carry a badge (set-badge / clear-badge, Int badges clamp
to 99+ via the default badge-formatter), rendered inside the tab
cell so mouse hit-testing tracks it — render and tab-index-at-col
now share one !tab-display width computation, which is the real
fix; the two had duplicate formulas waiting to drift. Active-tab
styles: brackets (the default, byte-identical), underline, and
pill (filled with tab-active; a pill stays lit when the bar is
unfocused, because unlike brackets it has no glyph to fall back
on). FocusColor mode drops the ▶ prefix and marks focus with the
new tab-focus-accent theme slot instead — note the slot defaults
to border-focused, whose bg replaces a pill's fill; give it a bg
of its own if you combine pill with FocusColor.
- [2026-08-02] Checkbox glyphs are configurable: checked-glyph /
unchecked-glyph (defaults [x] / [ ], byte-identical) plus
set-glyphs. Keep the two the same width or the label jitters on
toggle; ☑/☐ render double-width in some terminal/font combos,
which is why ASCII stays the default. RadioGroup has the same
hardcoding and is a deliberate follow-up, not changed here.
- [2026-08-02] Table showed one row fewer than it held. The body
height was computed from `self.rows`, which inside this class is
Table's own row-data accessor shadowing Selkie::Widget's
plane-height accessor of the same name — so the body was always
exactly one shorter than the data regardless of how tall the
plane was. Three symptoms, all silent: the last row was
unreachable even in a plane with room to spare; the overflow
test degenerated to `elems > elems - 1`, always true, so the
scrollbar was always drawn and always took a column away from
the last flex column; and PageUp / PageDown jumped by the row
count instead of a screenful. The height now comes from the
Widget role explicitly, and is exposed as the new plane-free
`body-height` accessor — the number to add 1 to when sizing a
table that should show everything without scrolling. No API
changed; existing tables gain their missing row and, where they
fit, their missing column.
- [2026-08-02] Styles carry alpha, and widgets can own their plane's
base cell. The new Selkie::Alpha module defines AlphaMode —
AlphaOpaque, AlphaBlend, AlphaTransparent, AlphaHighContrast —
plus alpha-constant to reach the matching NCALPHA_* value. Read
its Pod before reaching for any of this: notcurses stores alpha in
two bits per channel, so those four values are the entire space.
There is no 30% opacity and no alpha to fade out; one AlphaBlend
layer over an opaque one is an exact (truncating) 50/50 mix of
the two colours, and a darker overlay means a darker colour, not
more alpha. Anything that wants a smooth transition interpolates
RGB.
Selkie::Style gains fg-alpha and bg-alpha, undefined by default
and resolving through effective-fg-alpha / effective-bg-alpha to
AlphaOpaque — the same "undefined means don't care" convention fg
and bg already use, so every existing style renders exactly as it
did. merge treats them like the colours rather than the flags:
an alpha the override sets wins, an alpha it leaves undefined
inherits, and because AlphaOpaque is a value like any other an
override can pin one element opaque over a translucent base.
AlphaHighContrast is foreground-only — notcurses returns -1 and
drops the write for a background — so constructing a Style with
bg-alpha => AlphaHighContrast now throws at the construction site
instead of failing silently several layers away.
Widget.apply-style pushes both alpha modes to the plane, but only
when they differ from the last modes it pushed: a per-widget cache
keeps the all-opaque case — which is every widget that never
mentions alpha — free of any additional native calls. init-plane
records the fresh plane's known-opaque state; adopt-plane, which
borrows a plane with a history, drops the cache to unknown so the
first apply-style states both channels explicitly. Anything that
sets the plane's alpha behind Selkie's back should call the new
reset-style-cache afterwards.
Finally, the plane's base cell — what ncplane_erase fills with and
what shows wherever render never writes — is now behind two
overridable hooks: base-style (default: the theme's base slot) and
base-egc (default: a single space). Returning '' from base-egc
leaves the cell with no glyph at all, which is gcluster 0, which
is the sentinel notcurses's glyph search reads as "keep looking
further down the pile" — pair it with a blended or transparent
base-style and a widget becomes a see-through overlay instead of
an opaque rectangle. base-channels exposes the packed 64-bit word
the pair produces, plane-free, for callers and tests. With the
defaults, that word and the base EGC are bit-for-bit what Selkie
has always emitted: 0x40C0C0C0401A1A2E and ' ' for the built-in
theme, pinned by t/87-alpha so no future change to this path can
move a pixel unnoticed.
- [2026-08-02] Modal gains backdrop modes and an optional frame.
`backdrop` takes a BackdropMode — exported by
Selkie::Widget::Modal itself, not by `use Selkie` — and picks what
the dialog does with the screen it covers. BackdropOpaque is the
default and is byte-for-byte what Modal has always drawn.
BackdropScrim leaves the screen visible and tints it through the
new modal-scrim theme slot: the scrim plane has no glyph of its
own (an empty base EGC is gcluster 0, so notcurses's glyph search
falls through to the text underneath) and both its channels blend,
so colours come back mixed 50/50 with the scrim colour. A deeper
dim is a darker scrim colour, never "more alpha" — there is no
fractional alpha (see Selkie::Alpha). Caveat, documented under
"Scrim and images": a scrim ERASES a sprixel beneath it, because
blending sets the cell's blend count and notcurses's paint_sprixel
wipes the cell outright, so use BackdropOpaque over Image content.
BackdropNone draws no backdrop plane at all, which fixes a latent
bug: `dim-background => False` used to show a flat rectangle
rather than the screen behind it, because the modal's own
full-screen plane carries an opaque base cell that occluded
everything on its own. Its base cell now goes fully transparent
with no glyph in both see-through modes, so the screen genuinely
shows through. dim-background still works and still defaults to
True; it is now derived from backdrop rather than stored beside
it, so the two cannot disagree, and an explicit backdrop wins if
you pass both. set-backdrop switches modes at runtime.
`framed => True` wraps the dialog in a Selkie::Widget::Border —
the home for a title and a key-hint strip, so a dialog can drop
the hand-rolled header and footer Text rows it would otherwise
need. frame-style, frame-glyphs, frame-title, frame-title-align,
frame-bottom-title, frame-bottom-title-align and frame-padding
(default 1) configure it; set-frame-title / set-frame-bottom-title
update it live, and `.frame` hands you the Border for anything
else. The frame is chrome, not content: `.content` still answers
YOUR widget, focusable-descendants is unchanged, and the Tab cycle
never lands on the frame. It does eat interior space, though —
two rows and columns of edge plus the padding on each side — so
the new plane-free content-extent(rows, cols) is what a content
layout should be sized against, and modal-rect(rows, cols) gives
the dialog rectangle. handle-resize cascades the interior, not the
dialog rectangle, so a framed modal's flex spacers land where they
should. Four theme slots come with it, each defaulting to an
existing one so turning framing on changes layout and never
palette: modal-frame (= border), modal-title (= overlay-title),
modal-key (= overlay-key) and modal-scrim.
Border grew the mechanism the frame uses: style-slot,
focused-style-slot, title-slot and bottom-title-slot name the
theme slots a frame and its two titles paint from. Names are
resolved through Theme.slot on every render, so a Border
configured this way follows a live theme swap where a pushed
set-style-override would not; app-specific %.custom slots work
too. The defaults ('border' / 'border-focused', and no slot of
their own for the titles) make exactly the native calls Border
always made. Pointing both style slots at one name opts a Border
out of focus-reactive colouring without touching
focus-from-store — which is what Modal's frame does, since a
modal is a focus trap and something inside it is focused
essentially always.
Also fixed on the way past: Modal.render threw outright on a
terminal smaller than the 3x10 dialog floor (a negative origin
coerced to UInt). The origin now clamps at 0.
- [2026-08-02] VBox and HBox gain gap; Border gains padding. `gap`
reserves cells between children — set it in the constructor or
with set-gap — and replaces the empty-Text-spacer idiom outright:
a gap is not a widget, so it costs no plane and paints nothing
(the container's own plane base shows through, which is what
makes it read as breathing room rather than a strip of a
different colour). Gutters are strictly between children: no
leading or trailing gap, and none beside a child allocated zero
cells, so collapsing a child with Sizing.fixed(0) — the hidden-
child idiom — takes its gutter with it instead of leaving a
double gap. Selkie::Layout::Allocate does the arithmetic:
allocate-along-axis picks up a :gap named argument and runs its
three passes over the content box (the axis total minus the newly
exported gap-reserve), so Sizing.percent now means percent-of-
content-box the way it does inside a CSS flex container. Split
deliberately has no gap — it separates its panes with a divider
it draws and drags itself.
Border's `padding` insets the content from the frame: a uniform
constructor shorthand that fans out to padding-top / -right /
-bottom / -left (an explicitly-passed edge wins, so
`padding => 1, padding-top => 0` is the titled-panel case), plus
set-padding and set-padding-edges afterwards. The content box is
exposed as the plane-free `inner-rect(rows, cols)` for callers
and tests. Padding can squeeze that box to nothing on either
axis, and a zero row or column count is a resize notcurses
refuses — which would leave the content plane at its previous,
larger geometry, painting straight through the frame — so Border
parks the content for as long as either extent is zero and
restores it on the first render with room, mirroring VBox's
handling of zero-allocation children. Titles are unaffected by
padding; they live on the frame.
gap => 0 and padding => 0 are the defaults and are bit-identical
to the previous layout, allocation for allocation: every golden
in this distro and in App-Cantina and App-Mindmoor is unchanged.
- [2026-08-02] Border gains glyph sets and title placement. The
new Selkie::BorderStyle module carries a BorderKind enum
(BorderSingle, BorderRounded, BorderDouble, BorderHeavy,
BorderAscii), a TitleAlign enum (TitleLeft, TitleCenter,
TitleRight), and the six-glyph BorderGlyphs value class those
kinds resolve to via a cached .for(). Border picks a kind with
border-style, or takes an explicit BorderGlyphs table via
border-glyphs / set-border-glyphs which wins over the kind
(effective-glyphs is the resolution both the painter and callers
use); it also gains title-align, a second bottom-title with its
own bottom-title-align (the key-hint slot), and title-prefix /
title-suffix around both. Note that border-style is about glyphs
only — set-style-override and the border / border-focused theme
slots still own the colours, and the two compose. Selkie
deliberately does NOT downgrade to ASCII on locale or terminal
detection, which would make rendering (and every snapshot)
environment-dependent; BorderAscii is the escape hatch you
choose. Defaults reproduce the previous frame cell-for-cell —
every existing golden in this distro and in App-Cantina is
byte-identical — with one deliberate exception: a title long
enough to be truncated to the full interior width used to be
painted at column 2, running its last character over the
top-right corner glyph, and is now pulled back to column 1 so
the corner survives. Internally the frame painter is one private
!paint-frame shared by the initial draw and the post-content
redraw that covers image sprixel bleed (the :edges-only pass,
which now re-stamps the bottom title after repainting the edge
it sits on), and each horizontal run is a single putstr instead
of one FFI call per column.
- [2026-08-01] Shutdown tty restore is now spawn-free and
step-isolated. The old restore shelled out to `stty` — if the
process's working directory had been deleted or renamed while
the app ran, posix_spawn failed with ENOENT, the resulting
X::Proc::Unsuccessful broke on a thread-pool thread, escaped
the caller-side try, and propagated out of shutdown — skipping
notcurses_stop and the escape-sequence backstop and leaving
the terminal raw in the alt-screen. Capture/restore now use
in-process tcgetattr/tcsetattr NativeCall bindings treating
the termios snapshot as an opaque blob (no child process, no
cwd dependence, portable across glibc/musl/macOS; Windows
keeps its /dev/tty-existence early-return), and both restore
calls in shutdown are wrapped in !try-log like every other
step so nothing can strand the backstop chain again.
0.10.0 2026-07-08T17:38:27+01:00
- [2026-07-08] TextInput decorations: new pull-based
highlight-provider (per-span buffer coloring via the new
Selkie::Widget::TextInput::HighlightSpan value class) and
suggest-provider (fish-style ghost completion tail painted after
the caret; Right arrow at end-of-buffer accepts it via
insert-text, one on-change emission). Both hooks are consulted
at render time so they can never desync from the buffer
(including set-text-silent store-syncs), and are ignored on
masked inputs. New theme slot input-ghost (defaults to the
placeholder foreground, italic); per-widget ghost-style
override. Styled snapshot 34 pins the span/ghost/caret fg
channels.
- [2026-07-03] Trace-mode events are now written by a dedicated
writer thread: producers enqueue snapshotted event Hashes on a
Channel lock-free, and one thread owns JSON serialization + file
I/O. Span capture on hot paths no longer pays serialization +
the shared lock on the calling (usually main) thread — a real
capture measured ~3ms/frame of instrumentation before this.
shutdown/reset close the channel, JOIN the writer, then write
the JSON tail, so the file is complete and valid once shutdown
returns; producers racing shutdown are safely dropped. New
Selkie::Trace pod documents the modes and the span-discipline
rule below.
- [2026-07-03] Span discipline: create spans AFTER any cheap
early-return guard. Image.destroy-blit-plane / !load / !unload /
render / set-file all hoisted their no-op guards above span
creation — parked image cards re-enter destroy-blit-plane on
every layout pass (VBox/HBox park zero-sized children each
render), which made it the single largest span source in a real
capture: 138k events, 98.4% recording nothing. Store's
subscription digest span likewise no longer fires for
identity-short-circuited checks, and drain-async gained a span
so shutdown time is attributable.
- [2026-07-03] Extend the mouse dispatcher's duplicate-press
suppression to every widget: a press repeating the previous press
(same button, same cell) within a 40ms floor window is dropped even
when the target has no debounce-ms of its own. A terminal-driver
double-fire can no longer double-activate plain widgets or register
as a phantom double-click (click-count 2 from one physical click)
in ListView/Table/TextInput. Widgets exposing debounce-ms keep
their configured window.
- [2026-07-03] Stop treating wheel events (buttons 4/5) as clicks in
the mouse dispatcher: they no longer enter the duplicate-press
window (flick-scrolling over a Button/Checkbox/Select was throttled
to ~8 events/s), no longer seed drag capture (a wheel "press" has
no release, so one scroll permanently captured motion routing), no
longer advance click-count, and no longer dismiss
dismiss-on-click-outside modals from outside. Click-to-focus on
scroll is preserved.
- [2026-07-03] Add per-row styling to Selkie::Widget::Table: a
C callback (constructor arg or C)
receives each row hash and returns a Selkie::Style to overlay —
override colors win, style flags OR with the cursor highlight.
Style resolution is extracted into the public, headless-testable
C.
- [2026-07-03] TextInput/MultiLineInput no longer swallow ctrl+x
when there is no selection: the cut handler returns False so the
chord bubbles to global keybinds (previously a focused input
silently ate app-level ctrl+x bindings — e.g. a queue modal
opener — whenever nothing was selected).
0.9.1 2026-06-23T01:38:56+01:00
- Refactor Selkie::App into a documented facade backed by internal
implementation roles/modules for dispatch, error logging, focus/tree
state, hit testing, idle-budget selection, overlay/tree walking, render
loop state, screen/modal lifecycle, terminal setup/cleanup, and terminal
escape sequences. The new Selkie::App::Internal::* modules are listed in
META6.json, and t/82-app-facade.rakutest guards the public App surface.
- Re-enable generated API docs during distribution builds, expand the API
index categories, add generated docs for the new App internals, and add
docs/api-surface-1.0.md as the first API-freeze inventory.
- Add defaulted theme slots for focused controls, selections, overlay
titles/keys, modal backdrops, toasts, and password-strength bands; migrate
production widgets away from hardcoded colors and update
Selkie::Widget::PasswordStrength to render from theme slots.
- Add Selkie::Store built-in effects db-replace and db-delete for exact
subtree replacement/deletion, document db as deep-merge, and ensure
unknown effect names hit the unknown-effect guard instead of crashing
before it can run.
- Improve Kitty/tab sprixel recovery: user-idle, not background store
traffic, controls refresh timing, and the render loop can refresh
sprixels on idle intervals without waiting for the next keypress.
- Fix internal mouse dispatch to call the hit-test helper directly instead
of relying on a type-object App facade lookup.
- Fix paste batching to coerce `.^can('insert-text')` to a real Bool before
returning from the focused-input capability check.
- Fix viewport-cropped Images so active-modal occlusion destroys any
existing blit and skips loads/blits when the Image is outside the modal
tree, preventing card images from painting over fullscreen modal previews.
- Gate Selkie::Widget.set-viewport diagnostic output behind
SELKIE_DIAGNOSTIC_LOG, SELKIE_WARN_LOG, or SELKIE_DIAGNOSTICS_STDERR=1 so
production stderr stays quiet by default.
- Fix Selkie::Trace JSON event writing and numeric serialization so
non-finite values become null and event commas are emitted atomically with
each event write.
- Add tools/check-release-hygiene.raku to catch ignored package-root
artifacts before release validation.
- Remove confirmed private dead code from Store, MultiLineInput, and
ViewportedCardList, with updated tests for theme slots, store effects,
mouse dispatch, tree/modal helpers, terminal cleanup, sprixel refresh,
trace JSON, and the App facade.
0.9.0 2026-06-18T12:11:18+01:00
- Selkie::Widget::ViewportedCardList: visible rows now get a
`prepare-visible` callback on their item widget, when present,
before the card root is rendered. This gives heavyweight widgets
a framework-level hook to lazily request or materialise resources
only after the list has determined that the card intersects the
viewport, while still allowing the prepared content to render in
the same frame if it is ready.
- perf: ViewportedCardList now maintains prefix-summed item
heights and binary-searches the first visible row instead of
walking from item zero on every render. Offscreen items that
leave the viewport are parked, and a new `visible-index-count`
accessor exposes the current visible set size for tests and
diagnostics.
- fix: Selkie::Store's event queue is now lock-protected. dispatch()
is called from worker threads (the async fx fires on-success /
on-failure from its worker; apps push progress events from their
own `start {}` pipelines), and the drain's copy-then-clear could
wipe a push that landed between the two steps — events were
silently lost under load. dispatch() now pushes under a Lock and
!process-queue swaps the queue out under the same lock; the lock
is never held while handlers run, so re-dispatch (the dispatch
fx, __effect-error) cannot deadlock. dispatch() is now documented
as the one store method that is safe from any thread. New test:
t/79-store-dispatch-thread-safety.
- battery: the idle ladder now tracks USER activity (input, resize,
toast visibility) and STORE activity (events / writes processed
by tick) on separate clocks. Store-only activity is floored at
30 Hz instead of resetting the loop to the hot rate — a
background worker dispatching progress every couple of seconds
(asset generation, network status refreshes) used to pin the
loop at 60 Hz for its whole lifetime. User input still snaps
straight back to hot. The tier math moved from a closure inside
run() to the package sub Selkie::App::pick-frame-budget so it's
unit testable; new test t/80-idle-ladder-budget.
- battery: the idle-remainder sleep is now chunked at 100 ms
(SLEEP-CHUNK-MAX) instead of one 16 ms frame. The 16 ms chunking
kept the thread waking ~60 times/s even at the 4 Hz deep-idle
tier — the ladder reduced work per wake but not the OS wakeup
count, which is what power accounting actually punishes. Deep
idle now wakes ~12 times/s. Trade-off: the first SIGWINCH after
deep idle is noticed within 100 ms instead of 16 ms (one-time;
processing it is user activity, which snaps the ladder back to
hot). Keystroke latency is unchanged — input is only read at
the top of the loop, after the full budget, regardless of how
the sleep is sliced.
- perf: removed the temporary SELKIE_PERF_LOG loop instrumentation
(a %*ENV lookup plus timer reads every iteration at 60 Hz). It
existed to root-cause long-running background-work UI lag, which
has been found and fixed.
- fix: the Kitty tab-switch sprixel refresh threshold is now
measured against user idle specifically. It was measured against
combined activity, so background store traffic (a streaming
response, worker progress) suppressed the refresh — switching
Kitty tabs away and back during a long-running job left Images
blank until the worker went quiet.
- perf: Selkie::Widget::Image.set-rgba now retains the caller's Buf
by reference instead of copying it into a CArray with an
element-wise Raku loop (e.g. ~640K iterations for a 400×400 image,
~4.2M for 1024×1024 — once per call). !load hands the Buf's storage
straight to ncvisual_from_rgba via nativecast(Pointer, $buf) —
notcurses copies internally, so the buffer only has to stay
reachable. New contract: the passed Buf must be treated as
immutable (it's retained, not copied); sharing one buffer across
many Image widgets is now cheap and safe. Removes the per-widget
copy cost when re-rendering many Images.
- Selkie::Widget::Image: new in-memory RGBA ingress alongside the
existing file path. `set-rgba(Buf, :width!, :height!, :id)` blits
decoded pixels straight from memory via `ncvisual_from_rgba`
(rows = height, rowstride = width × 4, cols = width) — for images
you already have as pixels (decoded yourself, generated, or held in
memory) and don't want to round-trip through a file. The caller
produces the RGBA buffer and hands it over; Image retains it for the
clip-only reload-before-rescale path. Everything downstream of
`$!visual` is shared with the file path.
- Image render cache now keys on a small `source-id` string (the
path, the caller's `:id`, or a gen token) instead of the file
path, so a re-render NEVER compares two multi-MB pixel buffers
(`$!last-file` → `$!last-source-id`). New `source-id` accessor.
- Tests: t/76-image-rgba-source (headless state machine), t/77-image-
rgba-blit (non-square blit pins the ncvisual_from_rgba parameters).
0.8.0 2026-05-20T18:53:56+01:00
- BREAKING: `Selkie::Store.subscribe`, `subscribe-path-callback`,
`subscribe-computed`, and `subscribe-with-callback` now default
to structural change detection (the previous opt-in
`:deep-equality-check` behaviour). The default flag is removed;
pass `:identity-check-only` to opt into the old fast-path
`===`-then-`eqv` comparison when the watched value is known to
be a fresh allocation per write. Migration: drop
`:deep-equality-check` at every call site (the safe behaviour
is now the default). Add `:identity-check-only` only where the
hot path can prove the watched value is fresh per call. This
fixes a long-standing silent suppression of ancestor-fire
subscriptions after deep `assoc-in` writes — the
"chat doesn't update until I press a key" class of bug.
- BREAKING: effect payloads must now be `Associative`. The
dispatcher used to wrap bare scalars as `{ value => $x }`
automatically, which silently reshaped the payload and produced
hours of debugging when a handler expected a Hash and got
`{ value => 0 }` instead. Now `!run-effect` throws with a
clear message and routes the throw to `__effect-error` so the
dispatch loop survives. Migration: wrap scalar payloads at
the call site, e.g. `(my-fx => { value => 42 })`, or pass an
empty Hash for no-payload effects.
- behavior fix: `Selkie::Widget.!destroy-plane` now unsubscribes
the widget from the store before tearing the plane down. Pre-
fix, subscriptions held a widget reference, GC never collected
the widget, `DESTROY`/`destroy` never fired, and notcurses
planes leaked on every modal cycle / screen swap / dynamic
widget rebuild. The store's `unsubscribe-widget` is the single
writer; calling it again from a Container's `clear`/`remove`
cascade is a no-op (idempotent). Subclass `destroy` overrides
that already end with `self!destroy-plane` (the documented
convention — Border, Modal, Image all comply) pick this up
automatically. New `Selkie::Store.subscription-count` accessor
for tests.
- behavior fix: effect-handler exceptions are now isolated. A
buggy `register-fx` handler used to tear out of `!run-effect`,
out of `tick`, and into `Selkie::App.run`'s outer CATCH —
one bad handler crashed the whole TUI. The framework now
catches handler exceptions, logs them, and routes them to a
new `__effect-error` event so apps can register a handler
that surfaces failures (toast, modal, telemetry). Event-
handler exceptions in `register-handler` callbacks get the
same isolation. A re-entrance guard prevents infinite
recursion if the `__effect-error` handler itself throws. The
framework ships a default `__effect-error` handler that logs
to the store's debug-log stream.
- behavior fix: `register-fx('async')` now tracks every Promise
it spawns. New `Selkie::Store.drain-async(:$timeout)` waits
for in-flight workers to complete (bounded), flips the store
into shutting-down mode so further async dispatches no-op,
and clears the tracking list. `Selkie::App.shutdown` calls
it before notcurses teardown so a long-running worker
(LLM inference, etc.) can't complete post-`notcurses_stop`
and dispatch into handlers whose native handles are gone.
Async-effect failures now route through the CATCH and include
the full backtrace; pre-fix the Promise was leaked entirely
and its broken-promise diagnostic went to stderr behind the
alt-screen.
- robustness fix: shutdown-time exceptions are no longer
silently swallowed by bare `try { }`. Each step (modal
destroys, screen-manager destroy, drain-async,
`notcurses_stop`) now runs through `App.!try-log(, …)`
which catches inside, preserves the isolation invariant
(each step independent of the others), and writes a tagged
diagnostic to `$*ERR` (captured by `!install-error-log`
when active). Pre-fix, a shutdown failure left the user
with a wedged terminal and zero clue which step blew up.
`t/70-terminal-cleanup.rakutest` updated to assert the new
pattern; existing tests still pass.
- robustness fix: `Selkie::Layout::Split` no longer crashes on
1-cell, 0-cell, or extreme-ratio axes. The boundary math
moved out of `!layout-split` into a pure-math helper
`compute-split-sizes(UInt $total, Rat $ratio --> Hash)` that
clamps before subtraction and bails on 0-total. Pre-fix, a
tmux pane resize to 1×N threw `X::TypeCheck::Assignment`
out of `render()` (UInt rejected the wrapped-negative Int
assignment) and killed the whole frame. `t/74-split-extreme-
sizes.rakutest` covers 1×N, N×1, 1×1, 0×0, and extreme
ratios.
- new tests: `t/72-effect-error-isolation.rakutest`,
`t/73-widget-destroy-unsubscribes.rakutest`,
`t/74-split-extreme-sizes.rakutest`,
`t/75-store-async-drain.rakutest`. `t/63-deep-equality-check`
rewritten for the flipped default and the new
`:identity-check-only` flag.
- `examples/viewported-card-list.raku` no longer reaches into the
sibling Notcurses-Native checkout for sample images. The 10
referenced images are vendored verbatim under `examples/data/`
(~1.2 MB, see that dir's README for attribution) so the example
runs on a fresh Selkie install with no sibling repo present.
Companion change to Notcurses-Native 0.4.0 dropping its
`vendor/notcurses/` tree.
- Test cleanup: `t/22-viewport.rakutest`'s safe-coord regression
subtest no longer leaks its expected corruption diagnostic to
stderr during `prove6` / `zef install`. The previous `quietly`
wrapper had no effect (the diagnostic uses `note`, which goes
straight to `$*ERR` and bypasses `quietly`'s warn-only scope);
we now redirect `$*ERR` to `$*SPEC.devnull` for just the
rejected call. Cosmetic — the contract being tested is
unchanged.
0.7.4 2026-05-19T17:10:03+01:00
- Selkie::Store: subscription mutations made from inside the
per-tick subscription walks (`!check-subscriptions` and
`!flush-push-subs`) are now deferred and applied after the
walk via a `LEAVE` block. Earlier in this release cycle the
same call site was patched with a `.keys.List -> Str:D $id`
snapshot, which proved insufficient — App::Cantina's
character-change path cascades ~120 `unsubscribe` calls
from a single callback firing (ChatMessage / ImageMessage
per-widget subs cleaned up via `Container.clear` →
`unsubscribe-widget`), and the live `%!subscriptions` hash
shrinking under that load left the loop variable undefined
on the next iteration ("Hash keys must be concrete strings
(got )" mid-tick). Neither `.keys.List` (likely fused away
by spesh) nor `Str:D` on a pointy-block parameter (loosely
enforced) caught it.
The new mechanism establishes a hard invariant:
`%!subscriptions` and `%!push-subs-by-key` are never
mutated mid-walk. `unsubscribe` queues into a private
`$!pending-unsubscribes` SetHash when called during a walk;
the LEAVE block at the end of each walk drains the queue
via a new private `!do-unsubscribe`. During the walk,
queued ids are skipped in the dispatch loop, so the
observable contract is unchanged (a sub unsubscribed by
callback X in tick N does not fire later in tick N).
Re-subscribing an id that's queued for removal cancels the
queue and tears the old entry down cleanly before the new
registration. The walks themselves now snapshot keys via a
typed eager Array (`my Str:D @ids = %!subscriptions.keys`)
that spesh cannot fuse away and that throws loudly on any
non-Str:D key as a diagnostic backstop. Pod "Mutation
safety" section rewritten to describe the actual mechanism.
- Selkie::Widget::MultiLineInput: `set-text` and `set-text-silent`
now normalise CRLF and lone CR to LF before splitting. A stray
`\r` inside a logical line made `ncplane_putstr_yx` reset the
cursor to column 0 mid-row, threw off `.chars`-based wrap
counts, and broke scroll. The bug surfaced loading CCv3
character cards (often authored on Windows) into the Cantina
character editor's description box.
- Selkie::Widget::Button: new constructor parameter
`:debounce-ms` (UInt, default 0 = off). When non-zero, click
events emitted within the window of the previous emit are
suppressed. Default of 0 preserves existing behaviour for
every consumer; opt in where mis-fires matter (e.g. Cantina's
"+ Add LoRA" row builder).
- Selkie::App: terminal-tab-switch sprixel recovery. New attribute
`$.sprixel-refresh-idle-threshold` (Num seconds) controls how
long the loop tolerates input-idle before forcing every Image
in the tree to re-emit its sprixel on the next render. Defaults
to 5e0 on Kitty (detected via `KITTY_WINDOW_ID` or `TERM`
starting with `xterm-kitty`) and 0e0 (disabled) elsewhere. The
bug it fixes: Kitty's per-tab compositor restores the cell
buffer on tab return but NOT the direct-placement graphics
notcurses emitted, so any Image widget whose owning widget
hasn't dirtied since the switch stays visually blank — most
visible in App::Cantina's character-select pane (AvatarList),
which rarely re-renders during normal use. Other Image-bearing
widgets recover incidentally because they're already dirtying
on chat-pane scrolls / emotion-pane changes. The "next input
after long idle" signal is the cheapest detector we have:
Kitty does emit focus events under CSI ?1004, but notcurses's
input parser doesn't surface them as keyable events, and
reading raw stdin in parallel with notcurses isn't workable.
- Selkie::App: new public method `force-refresh-sprixels`. Walks
the live tree (active screen + modal stack + toast) and on
every Selkie::Widget::Image calls `destroy-blit-plane` (which
also clears the geometry cache so the next render takes the
cache-miss path) and `mark-dirty`. Apps can call it directly
from a manual-refresh keybind, or after an operation known to
bypass the auto-refresh idle signal.
- Selkie::App: new public class method
`detect-sprixel-bug-prone-terminal` returning Bool. Exposed
so the detection logic is unit-testable without an active
notcurses instance — Selkie::App.detect-sprixel-bug-prone-terminal
runs cleanly in plain Raku.
- t/71-sprixel-refresh.rakutest (new): Kitty detection across
KITTY_WINDOW_ID / TERM signals, the tree-walking helper's
behavior on a mock mixed-widget tree (Images marked dirty,
non-Image widgets untouched, undef tree safe), and source-
level structural invariants tying the threshold into TWEAK
and the run loop.
0.7.3 2026-05-16T04:21:07+01:00
- META6: bump Notcurses::Native dep from 0.3.2+ to 0.3.4+ to
pick up the new prebuilt platform matrix. 0.3.4 adds macOS
x86_64 (Intel Mac / Hackintosh, Catalina 10.15+), Linux
x86_64-musl and aarch64-musl prebuilts (Alpine 3.13+, Postmarket
OS, Void, etc.), rebases the Linux glibc lanes onto
manylinux_2_28 (glibc 2.28 floor; RHEL 8+ / Ubuntu 18.10+ /
Debian 10+), and ships every prebuilt with accelerated codec
libraries (libdav1d for AV1, libvpx for VP8/9, libopus). A
CI release-gate probe dlopens the bundled libavcodec and
decodes PNG/JPEG/BMP fixtures end-to-end before publish.
- docs/Readme.rakudoc: updated the prebuilt platform list +
"falling back to source build" notes to match the new matrix.
0.7.2 2026-05-13T18:16:11+01:00
- Selkie::App: escape-sequence backstop on shutdown. After
notcurses_stop + the stty termios restore, shutdown now writes
a comprehensive "exit terminal protocol" sequence direct to
/dev/tty: Kitty kbd protocol pop ×3, mouse tracking off (every
encoding variant), bracketed paste off, focus events off,
modify-other-keys off, cursor shown, SGR reset, alt-screen
exit. Same direct-to-/dev/tty pattern as set-title.
Smoking-gun motivation: iTerm2 3.5+ advertises Kitty keyboard
protocol support, notcurses pushes it at init, but the pop
emitted by notcurses_stop doesn't take — after the app exits
every keystroke arrives at the shell as `CSI codepoint u`
(e.g. typing 'a' shows `3;5;3u`) and the terminal looks stuck
in raw mode. The pop is the load-bearing fix; the other
disables ride along because all are idempotent and any future
protocol leak notcurses_stop misses is now covered too.
build-terminal-cleanup-sequence is a public class method so
the sequence can be verified headlessly (t/70-terminal-cleanup);
!emit-terminal-cleanup is the private writer that wraps it.
0.7.1 2026-05-12T17:16:11+01:00
- Selkie::Store, Selkie::App: env-gated performance instrumentation.
Setting SELKIE_PERF_LOG enables two diagnostic spurts to
/tmp/selkie-perf.log:
(a) Store!values-equal logs any eqv comparison whose fallback
path (after the === identity shortcut) takes >20 ms, with
the type names + WHICH of both operands. Surfaces the
subscriptions whose computed values land in a deep walk
(Array/Hash/Buf) on every store tick.
(b) Selkie::App's run-loop logs per-phase timing (frame
callbacks, store tick, render-frame) for any iteration
whose total exceeds 50 ms. Localises lag to a specific
phase so a slow input cycle is attributable.
Both branches early-return on unset env, so the overhead in
production is one Bool check per call. Pair with the consumer's
own enable-debug() call on Selkie::Store to capture dispatch /
effect / subscription fires alongside the timings. Designed to
be left in tree as a permanent opt-in profiler.
0.7.0 2026-05-12T00:43:46+01:00
- Selkie::EffectiveBounds: value class returned by
`Widget.effective-bounds` representing the on-screen rectangle a
widget may safely paint into, computed as the intersection of its
plane with every ancestor's plane and the terminal viewport. Lets
sprixel-bearing widgets like Image size their blit-plane to the
visible region only, preventing pixel overflow past ancestor
borders.
- Selkie::Tree: framework-level tree-walking helpers
(`mark-widgets-in-rect-dirty`, `current-active-modal`) that bridge
widgets with the wider tree without requiring a circular import
on Selkie::App. Selkie::App registers provider closures at init
so widgets can reach live tree roots + modal state via free subs.
- Selkie::Layout::Allocate: shared three-pass sizing algorithm
(fixed → percent → flex) extracted from VBox + HBox. Layout
math now lives in one place; per-container code only handles
positioning along the cross axis.
- Selkie::Widget::FocusableByDefault: composable role that defaults
`focusable` to True at construction. Replaces the repeated
three-line `new` override in Button, Checkbox, TextInput,
ListView, RadioGroup, MultiLineInput, CardList, and the new
ViewportedCardList.
- Selkie::Widget::ViewportedCardList: row-scrolled selectable list
of cards where the viewport can start mid-card. Unlike CardList
(which scrolls in card-sized steps), this scrolls by individual
rows via PgUp/PgDown/wheel while Up/Down still walks selection
one card at a time. Designed for tall mixed-height content with
Image children that need partial-clip rendering as cards slide
past the viewport edge.
- Selkie::Widget: added `clip-to-ancestors` attribute (default
True) controlling whether `effective-bounds` includes ancestor
clipping or only terminal-viewport clipping — portal-style
overlays can set it False to escape container bounds. Added
`effective-bounds()` method returning a Selkie::EffectiveBounds
for safe sprixel-painting regions. Added `park-y()` (returns
10_000) standardising off-screen positioning across all widgets.
Added `!park-children(@kids)` helper consolidating park
iteration for Container and CardList.
- Selkie::App: integrated app-level coordination services for
sprixel cleanup and modal occlusion. `!collect-tree-roots`
exposes the live widget tree (active screen + modal stack +
toast) for `mark-widgets-in-rect-dirty`. `!mark-all-images-dirty`
propagates modal mount/unmount/toast state so `Image.render`'s
occlusion check fires reliably. `!park-images-in-rect` pushes
Images underneath the toast strip off-viewport to prevent
sprixel bleed-through. `!capture-tty-state` /
`!restore-tty-state` snapshot+restore terminal TTY state as a
backstop when notcurses shutdown can't (signal-killed runs).
Registers provider closures with Selkie::Tree and
Selkie::EffectiveBounds during init. Crash-restore taps on
SIGABRT/SIGTERM/SIGHUP/SIGQUIT ensure the terminal is cleaned
up before process exit on fatal signals.
- Selkie::Store: added `:deep-equality-check` flag (default False)
on all subscribe variants. When set, change detection uses
structural `eqv` rather than the fast `===` identity check, and
values are snapshotted (deep-cloned for nested Hashes/Arrays) at
every fire. Fixes the silent-suppression trap when assoc-in
mutates a Hash in place — the reference stays the same across
the write, so the `===` check fires no listener. Opt-in: enable
only for subscriptions watching mutable containers updated
in-place. Leaf values and fresh-per-call computes don't need it.
- Selkie::Widget::CardList: major refactor. New
`:min-display-height` parameter on `add-item` (default 1)
controls the minimum visible height before a non-selected
partially-clipped card is parked entirely instead of shown as
a sliver. Selected cards always render regardless of height.
Park decisions now run in a pre-pass before layout so the
planes that won't fit are off-screen by the time render runs,
eliminating sprixel-ghost churn from rapid scroll. Removed
per-Image cache invalidation; sprixel reliability now comes
from deterministic parking + fresh blits. Replaced boilerplate
`focusable` override with the FocusableByDefault role.
- Selkie::Widget::Image: full rewrite of the rendering model and
its Pod6. Visibility is now driven purely by notcurses-queried
plane positions and ancestor-intersection checks via
Selkie::EffectiveBounds, never the Widget-level position cache
(which can desync from notcurses across Container.park cascades
and direct ncplane_move_yx calls). Sprixel is emitted only when
the plane is fully contained in every ancestor and the terminal
viewport; otherwise the blit-plane is destroyed and the render
skips. Adds a `clip-only` mode that pre-scales the visual to
cell-aligned natural dims and uses an additive
`render-viewport-crop` API for partial-clip rendering inside
row-scrolling containers. Adds file-target debug logging via
`SELKIE_IMAGE_DEBUG` env var. The new Pod6 explains the
rendering model, the notcurses-vs-Widget-cache divergence, park
semantics, pixel-bleed protection, and the Sixel vertical
granularity workaround in detail.
- Selkie::Widget::Border: render path for small borders (< 3×3)
properly resizes the content plane even when the border edges
can't be drawn, preventing cell bleed past the border into
adjacent siblings. Added explicit Pod6 for `set-content`,
`set-title`, `set-has-focus`, `has-focus`.
- Selkie::Widget::Modal: documented `set-content`'s `:destroy`
flag and the park-on-swap-without-destroy pattern. Added Pod6
for `on-close`, `focusable-descendants`, `handle-resize`, and
`destroy`.
- Selkie::Widget::ListView: applied FocusableByDefault role.
Expanded Pod6 for `items`, `cursor`, `selected`, `on-select`,
`on-activate`, `set-items`, `select-index`.
- Selkie::Widget::MultiLineInput: applied FocusableByDefault role.
Expanded Pod6 for `text`, `set-text`, `set-text-silent`,
`clear`, `on-submit`, `on-change`, `set-focused`, `is-focused`,
`desired-height`, `line-count`, `cursor-row`, `cursor-col`.
Documented `on-copy` / `on-cut` supply semantics.
- Selkie::Container: park() now uses `!park-children` and the
shared `park-y` method, eliminating the hardcoded 10_000
literals scattered through the codebase.
- Selkie::Layout::VBox / Selkie::Layout::HBox: refactored to call
`allocate-along-axis` from Selkie::Layout::Allocate, removing
~50 lines of duplicated three-pass allocation math each.
Children that collapse to zero rows/cols are now parked
off-viewport so their previous-size planes don't paint stale
cells over adjacent siblings.
- Selkie::Layout::Split: added Pod6 for `handle-resize`,
`focusable-descendants`, and `destroy`.
- Selkie::ScreenManager: clarified Pod6 for the App-level
forwarding methods (`add-screen` / `switch-screen` /
`screen-names`) so it's obvious which call paths each invokes.
- Selkie::Widget::Toast / Select / RadioGroup / TextInput /
Checkbox / TabBar / Table: doc-only Pod6 expansions covering
methods that already existed but weren't fully documented.
- Image.render: when `clip-only` is True, defer the blit entirely
to render-viewport-crop instead of running emit-blit. emit-blit
creates the sprixel as a child of Image's own plane, which sits
in the row-scrolling container's backing chain and gets parked
off-screen at end-of-frame. render-viewport-crop creates a
different sprixel as a child of the container's own plane —
so the two planes live in different parent chains, and the
destroy in render-viewport-crop's prologue would target a
stale `$!blit-plane` rather than the live one. The leftover
sprixel survives in notcurses's tracking, moves with the
backing chain as it parks, and emits a one-row ghost over
the container's bottom border the moment the card scrolls
fully off the top of the viewport. With clip-only set,
render-viewport-crop is the single canonical blit pathway.
- Image.destroy-blit-plane: capture the blit plane's screen
rectangle before destroying and call
Selkie::Tree::mark-widgets-in-rect-dirty so widgets owning
cells under the destroyed sprixel get re-rendered. Documented
in Selkie::Tree as the expected pattern for sprixel-bearing
widgets; was missing from Image.
- t/61-effective-bounds.rakutest (new): unit tests for
Widget.effective-bounds — single and multi-ancestor clipping,
clip-to-ancestors=False overrides, terminal viewport
intersection.
- t/62-tree-helpers.rakutest (new): tests for
Selkie::Tree::mark-widgets-in-rect-dirty's tree walk —
widgets in/out of the marked rectangle are dirtied correctly
and the walk respects children/content patterns.
- t/63-deep-equality-check.rakutest (new): exercises Store's
`:deep-equality-check` flag across all four subscribe
variants. Verifies in-place Hash mutation is detected when the
flag is set and suppressed when it's off (the default).
- t/64-layout-allocate.rakutest (new): unit tests for
`allocate-along-axis` fixed / percent / flex passes, rounding
remainder handling, and edge cases (empty list, single item,
total fixed exceeds available, etc.).
- t/65-viewported-card-list.rakutest (new): ViewportedCardList
selection movement, row scrolling, mouse-wheel interaction,
PgUp/PgDown handling.
- t/68-image-clip-only-math.rakutest (new): pure-function tests
for clip-only source scaling and sub-region math
(compute-clip-only-blit).
- t/69-image-clip-only-blit.rakutest (new): integration test
driving compute-clip-only-blit + ncvisual_blit end-to-end on
a synthetic 8×8 gradient and asserting the rendered cells
correspond to the requested source sub-region. Pairs with the
Notcurses-Native 0.2.7 patch (ncvisual_blit_internal
pre-cropping) — together they fix ViewportedCardList rendering
wrong rows when an image is partially clipped at the top of
the viewport. Bumps minimum Notcurses-Native version to 0.2.7
implicitly via the BINARY_TAG bump
(binaries-notcurses-3.0.17-r3).
- t/23-card-list.rakutest: expanded with three new subtests —
add-item parameter signature check, min-display-height
clipping scenarios, and park iteration covering every card
root via !park-children.
- t/fixtures/test-4x4.png (new): tiny PNG fixture mirrored from
Notcurses-Native for use in image-pipeline tests that need a
real file path. Selkie tests do not get %?RESOURCES access to
sibling distributions' resources, so the file is bundled here.
- xt/snapshots/29-cardlist-min-height-leading-park.raku +
golden/29-…: CardList with three heterogeneous-height cards
where the selected middle card can't fit fully in the
viewport. The leading card's min-display-height exceeds its
partial-visible height; parking pre-pass folds it entirely so
layout compacts upward without producing broken slivers.
- xt/snapshots/30-cardlist-min-height-trailing-park.raku +
golden/30-…: symmetric test parking the trailing card.
- xt/snapshots/31-cardlist-min-height-default-renders-sliver.raku +
golden/31-…: cards with default min-display-height=1 still
render positive slivers (preserves pre-existing behaviour).
- xt/snapshots/32-cardlist-min-height-selected-exempt.raku +
golden/32-…: selected card always renders regardless of
min-display-height (selected cards are never parked by the
threshold check).
- xt/snapshots/33-viewported-cardlist-row-scroll.raku +
golden/33-…: ViewportedCardList with tall content cards,
demonstrating row-level scrolling, selection staying
independent of scroll, and Image children rendering safely at
partial-clip boundaries.
- examples/viewported-card-list.raku (new): comprehensive
ViewportedCardList demo with row scrolling, independent
selection movement, and Image cards interspersed with text
cards inside scrollable layouts.
- META6.json: registered new modules — Selkie::EffectiveBounds,
Selkie::Tree, Selkie::Layout::Allocate,
Selkie::Widget::FocusableByDefault,
Selkie::Widget::ViewportedCardList.
- README: updated example count (8 → 9); added
ViewportedCardList demo and HelpOverlay / PasswordStrength
widget descriptions to the widget-by-widget reference.
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. Mindmoor 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
Mindmoor: `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)