Selkie.git | docs/ | Readme.rakudoc
=begin pod
=head1 NAME
Selkie - High-level TUI framework built on Notcurses
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::App;
use Selkie::Layout::VBox;
use Selkie::Widget::TextStream;
use Selkie::Widget::TextInput;
use Selkie::Sizing;
my $app = Selkie::App.new;
my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$app.add-screen('main', $root);
$app.switch-screen('main');
my $log = Selkie::Widget::TextStream.new(sizing => Sizing.flex);
$root.add($log);
my $input = Selkie::Widget::TextInput.new(
sizing => Sizing.fixed(1),
placeholder => 'Type here...',
);
$root.add($input);
$app.focus($input);
$input.on-submit.tap: -> $text {
$log.append($text);
$input.clear;
};
$app.on-key('ctrl+q', -> $ { $app.quit });
$app.run;
=end code
=head1 DESCRIPTION
Selkie is a retained-mode TUI framework for Raku, built on
L<Notcurses::Native|https://github.com/m-doughty/Notcurses-Native>. It provides
a hierarchical widget tree with automatic memory management, declarative
layout sizing, virtual scrolling, theming, and a re-frame-style reactive store
for application state.
Selkie is designed for building full-screen interactive terminal applications:
chat clients, dashboards, editors, browsers, configuration UIs. It handles the
low-level notcurses rendering, focus cycling, event routing, and terminal
resize behaviour so you can focus on your application.
=head2 Design goals
=item B<Retained mode> — build a widget tree once, mutate it as state changes. Re-renders only dirty widgets.
=item B<Declarative sizing> — fixed, percentage, or flex units on each widget. Layouts allocate space automatically.
=item B<Safe by default> — all notcurses handles are owned by widgets and freed on destroy. Terminal state is restored on any exit (normal, exception, signal).
=item B<Reactive state> — optional centralized store with event dispatch, handlers returning effects, path/computed subscriptions, and opt-in dispatch logging for development.
=item B<Composable> — every widget is a role-composed class. Build your own by composing C<Selkie::Widget> or C<Selkie::Container>.
=head1 INSTALLATION
=begin code :lang<bash>
zef install Selkie
=end code
That's it on supported platforms. No system dependencies, no compiler,
no CMake.
=head2 How it works
Selkie depends on L<Notcurses::Native|https://github.com/m-doughty/Notcurses-Native>,
which ships prebuilt, self-contained notcurses libraries (with bundled
ffmpeg / ncurses / libunistring / libdeflate plus accelerated codec
libraries — libdav1d for AV1, libvpx for VP8/9, libopus for Opus
audio) for the common platforms. On C<zef install> the matching
archive is downloaded from GitHub Releases, SHA256-verified against
a checksum baked into the distribution, and staged into a stable
XDG data directory. No system packages are touched.
Supported prebuilt platforms:
=item macOS arm64 (Apple Silicon, macOS 11.0+)
=item macOS x86_64 (Intel Mac / Hackintosh, macOS 10.15 Catalina+)
=item Linux x86_64 glibc — manylinux_2_28 baseline (RHEL 8+ / Ubuntu 18.10+ / Debian 10+ / Arch / Fedora 28+)
=item Linux aarch64 glibc
=item Linux x86_64 musl — alpine:3.20 baseline (Alpine 3.13+ / Postmarket OS / Void / Adelie)
=item Linux aarch64 musl
=item Windows x86_64 (mingw-w64 / UCRT)
=item Windows arm64 (clang / UCRT — built but not CI-verified; see Notcurses::Native README)
=head2 Falling back to a source build
If you're on a platform not in the list above (FreeBSD / OpenBSD /
i686 / riscv64 / ppc64le / …), or you set
C<NOTCURSES_NATIVE_BUILD_FROM_SOURCE=1>, Notcurses::Native compiles
notcurses from source via CMake. That path needs the usual native
deps:
B<Linux (Debian / Ubuntu):>
sudo apt install \
cmake pkg-config \
libncurses-dev libunistring-dev libdeflate-dev \
libavformat-dev libavcodec-dev libavdevice-dev \
libavutil-dev libswscale-dev
B<Linux (Fedora / RHEL):>
sudo dnf install cmake pkgconf-pkg-config \
ncurses-devel libunistring-devel libdeflate-devel ffmpeg-devel
B<macOS (Homebrew):>
brew install cmake pkg-config ffmpeg ncurses libunistring libdeflate
B<Windows (MSYS2 UCRT64):>
Install MSYS2 from L<https://www.msys2.org/>, open a B<UCRT64> shell, and:
pacman -S \
mingw-w64-ucrt-x86_64-cmake \
mingw-w64-ucrt-x86_64-ninja \
mingw-w64-ucrt-x86_64-toolchain \
mingw-w64-ucrt-x86_64-libdeflate \
mingw-w64-ucrt-x86_64-libunistring \
mingw-w64-ucrt-x86_64-ncurses \
mingw-w64-ucrt-x86_64-ffmpeg
The source build takes 5–15 minutes; the prebuilt path takes seconds.
=head2 Useful environment variables
=item C<NOTCURSES_NATIVE_BUILD_FROM_SOURCE=1> — skip the prebuilt download and always compile from source.
=item C<NOTCURSES_NATIVE_BINARY_ONLY=1> — refuse to fall back to source; fail if the prebuilt isn't available for this platform.
=item C<NOTCURSES_NATIVE_LIB_DIR=/path/to/dir> — load notcurses from a directory you manage yourself (escape hatch for custom builds).
See L<Notcurses::Native's README|https://github.com/m-doughty/Notcurses-Native>
for the full set of knobs and the prebuilt-binary security model.
=head2 Windows note
On Windows the module installs and loads, but terminal-dependent tests
don't run (upstream notcurses limitation). Use Linux or macOS for the
full test suite.
=head1 DOCUMENTATION
This README covers the framework's concepts, lifecycle, and common
patterns. For the per-module API reference — every widget's attributes,
methods, and usage examples pulled straight from the source — see
L<docs/api/index.md|docs/api/index.md>. The API pages are regenerated
from Pod6 with C<raku tools/build-api-docs.raku> and stay in sync with
the code by construction.
=head1 EXAMPLES
The C<examples/> directory has nine runnable apps that together demonstrate
every widget and store pattern in the framework. Each is self-contained and
heavily commented — read them in this order:
=item C<counter.raku> — The smallest correct-pattern app. VBox, Text, Button, store handler returning C<(db => ...)>, path subscription, global keybind. Start here.
=item C<settings.raku> — A form covering every input widget: TextInput, MultiLineInput, Checkbox, RadioGroup, Select, Button. Plus ConfirmModal, plain Modal, computed subscription for live form summary, and silent setters that sync inputs from store state without feedback loops.
=item C<file-viewer.raku> — Split layout with a FileBrowser modal, Image preview, ScrollView for long text. Demonstrates a callback subscription that toggles which preview widget is visible based on the selected file's kind.
=item C<tasks.raku> — Todo list with ListView, TextInput, Checkbox filter, ConfirmModal for deletion, Toast notifications, and ScreenManager (list ↔ stats screen).
=item C<job-runner.raku> — ProgressBar (both determinate and indeterminate), TextStream log, async store effect for background work, dispatch-effect chaining, frame callback driving animation.
=item C<chat.raku> — CardList of variable-height RichText cards, MultiLineInput compose, Border auto-focus highlight, Toast, runtime theme toggle (Ctrl+T).
=item C<viewported-card-list.raku> — ViewportedCardList with row scrolling, separate card selection, and a card subtree containing an Image child.
=item C<dashboard.raku> — Tabbed status board showing off the newer widgets: TabBar across three tabs (Servers / Tasks / Logs), Table with sortable columns and custom cell renderers, Spinner in the footer, CommandPalette bound to Ctrl+P, and an inline Sparkline column on the Servers tab rendering each row's recent latency history.
=item C<charts.raku> — Showcase of the chart family: Sparkline reactively bound to a store path, LineChart updated via a subscribe-with-callback (live p50 / p99 latencies), BarChart + Histogram + Heatmap + ScatterPlot demonstrating the static archetypes, and a streaming Plot pushing samples into its own native ring buffer.
Run any of them with:
=begin code :lang<bash>
cd Selkie
raku -I lib examples/counter.raku
=end code
After C<zef install .> in the Selkie directory, the C<-I lib> flag is no
longer required.
=head1 CORE CONCEPTS
=head2 Widgets
Everything on screen is a C<Selkie::Widget>. A widget owns a notcurses plane
(a rectangular rendering surface), tracks its size and position, and knows
how to render itself. Widgets are usually composed from the
C<Selkie::Widget> role — which provides the lifecycle, keybinds, store
integration, and viewport machinery — and add their own rendering logic.
Every widget has:
=item C<plane> — the notcurses handle it renders to (managed for you)
=item C<rows>, C<cols> — current dimensions
=item C<sizing> — a C<Selkie::Sizing> declaring how space should be allocated
=item C<focusable> — whether Tab/Shift-Tab can move focus to it
=item C<parent> — the containing widget
=item C<theme> — inherited from the parent chain, or set explicitly
=head2 Containers
Containers hold other widgets. The C<Selkie::Container> role adds
C<add(widget)>, C<remove(widget)>, C<clear()>, and C<children()>. Containers
are responsible for positioning and sizing their children.
Layouts (VBox, HBox, Split) are the standard containers. You can compose
C<Selkie::Container> to build your own.
=head2 Sizing
Each widget declares how it wants to be sized by its parent layout:
=begin code :lang<raku>
use Selkie::Sizing;
Sizing.fixed(10) # exactly 10 rows/cols
Sizing.percent(50) # 50% of parent
Sizing.flex # flex factor 1 (default)
Sizing.flex(2) # flex factor 2 (gets twice as much leftover space)
=end code
Layouts allocate fixed and percent children first, then distribute the
remainder proportionally to flex children.
=head2 The widget tree
A Selkie app is a tree of widgets rooted at a screen. The tree is built
explicitly: you instantiate widgets and add them to layouts. The framework
walks the tree to dispatch events, propagate themes and the store, and
render dirty subtrees.
=head2 Dirty tracking
Widgets mark themselves dirty when their state changes (C<self.mark-dirty>).
Dirty propagates up to the root. Each frame, the app walks the tree and
re-renders dirty widgets. Calling C<mark-dirty> on a widget that's already
dirty short-circuits, so cascading updates are cheap.
=head1 APP LIFECYCLE
C<Selkie::App> is the entry point. It initializes notcurses, runs the event
loop, and manages screens, modals, toasts, and focus.
=begin code :lang<raku>
use Selkie::App;
my $app = Selkie::App.new;
$app.add-screen('main', $root-container);
$app.switch-screen('main');
$app.focus($first-focusable-widget);
$app.on-key('ctrl+q', -> $ { $app.quit });
$app.on-frame(-> { ... }); # called each frame (~60fps)
$app.run; # blocks until quit
=end code
=head2 Screens
Apps often have multiple top-level views — a login screen plus a main
screen, for example. Register each as a named screen:
=begin code :lang<raku>
$app.add-screen('login', $login-root);
$app.add-screen('main', $main-root);
$app.switch-screen('login'); # shows login, hides main
=end code
Only the active screen renders and receives events. Inactive screens are
parked off-screen but keep their state.
=head2 Modals
Open a modal dialog over the current screen:
=begin code :lang<raku>
my $modal = Selkie::Widget::Modal.new(width-ratio => 0.5, height-ratio => 0.3);
$modal.set-content($some-widget);
$app.show-modal($modal);
$app.focus($some-focusable-inside-modal);
$modal.on-close.tap: -> $ {
$app.close-modal;
};
=end code
While a modal is active, events are routed to the modal's focused descendant
first; only Tab, Shift-Tab, and Esc bubble to the app. Esc closes the modal
by default.
=head2 Toasts
Transient messages that auto-dismiss:
=begin code :lang<raku>
$app.toast('Saved!', 2); # message, seconds
=end code
=head2 Frame callbacks
Run code every frame. Essential for streaming updates (which aren't tied to
user input):
=begin code :lang<raku>
$app.on-frame: {
$my-progress.tick; # animate indeterminate progress bar
$my-stream-widget.pull; # pull latest tokens from a stream
};
=end code
=head1 EVENTS AND KEYBINDS
C<Selkie::Event> wraps keyboard, mouse, and resize events from notcurses.
Widgets implement C<handle-event(Selkie::Event $ev --> Bool)>, returning
C<True> if the event was consumed. Unconsumed events bubble to the parent,
then to the app's global keybinds.
=head2 Per-widget keybinds
=begin code :lang<raku>
$list.on-key: 'a', -> $ { self!add-item };
$list.on-key: 'd', -> $ { self!delete-item };
=end code
Bare character keybinds on a focusable widget fire when it's focused.
=head2 Global keybinds
=begin code :lang<raku>
$app.on-key('ctrl+q', -> $ { $app.quit });
$app.on-key('alt+1', -> $ { $app.focus($pane1) });
=end code
Global keybinds must include a modifier (Ctrl, Alt, Super) to avoid clashing
with text input.
Scope a keybind to a single screen with the C<:screen> named argument. It
fires only when that screen is the active one, which lets you have different
shortcuts per view without reshuffling handlers on every screen switch:
=begin code :lang<raku>
$app.on-key('ctrl+n', :screen('tasks'), -> $ { create-task });
$app.on-key('ctrl+n', :screen('notes'), -> $ { create-note });
$app.on-key('ctrl+q', -> $ { $app.quit }); # unscoped = everywhere
=end code
=head2 Event spec syntax
Keybind specs are strings:
=item C<'a'>, C<'Q'>, C<'?'> — single character
=item C<'tab'>, C<'enter'>, C<'esc'>, C<'space'>, C<'backspace'> — named keys
=item C<'f1'> through C<'f60'> — function keys
=item C<'up'>, C<'down'>, C<'left'>, C<'right'>, C<'home'>, C<'end'>, C<'pgup'>, C<'pgdown'> — navigation
=item C<'ctrl+X'>, C<'alt+X'>, C<'shift+X'>, C<'super+X'> — modifiers (combinable)
Letter keybinds are case-insensitive. The framework handles Shift correctly
(e.g. C<'!'> fires for C<shift+1>).
=head2 Focus cycling
C<Tab> and C<Shift-Tab> cycle focus through focusable widgets on the active
screen (or modal). C<Selkie::App> handles this automatically. You can also
drive it programmatically:
=begin code :lang<raku>
$app.focus-next;
$app.focus-prev;
$app.focus($specific-widget);
=end code
=head1 MOUSE SUPPORT
Mouse support is enabled by default. C<Selkie::App> calls
C<notcurses_mice_enable> on construction (button + drag events), and
the dispatcher routes presses, drags, releases, and scroll-wheel
through the same C<handle-event> path keystrokes use — but with a
different routing rule: B<mouse events follow coordinates, not focus>.
=head2 Dispatch model
=item B<Keyboard> goes to the focused widget, then ancestors, then global keybinds.
=item B<Mouse> goes to the deepest widget under the cursor, then ancestors, then global keybinds.
=item A primary press on a focusable widget gives it focus B<before> the event is delivered. Mouse-driven activation matches keyboard-driven activation.
=item B<Drag capture>: a press on widget X routes subsequent drag and release events for that button to X regardless of where the cursor is. Scrollbar drags and text-selection drags work even when the cursor leaves the widget.
=item B<Modal isolation>: clicks outside the active modal are dropped by default. Pass C<:dismiss-on-click-outside> to opt in (C<HelpOverlay> uses this; C<ConfirmModal> deliberately doesn't).
=head2 Per-widget API
The same shape as C<on-key>:
=begin code :lang<raku>
$widget.on-click: -> $ev {
say "clicked at row {$widget.local-row($ev)}, col {$widget.local-col($ev)}";
};
$widget.on-click: -> $ev { open-context }, button => 3; # right-click
$widget.on-scroll: -> $ev {
given $ev.id {
when NCKEY_SCROLL_UP { ... }
when NCKEY_SCROLL_DOWN { ... }
}
};
$widget.on-drag: -> $ev { ... }; # press + motion-with-button-held
$widget.on-mouse-down: -> $ev { ... }; # every press
$widget.on-mouse-up: -> $ev { ... }; # every release
=end code
C<self.local-row($ev)> / C<self.local-col($ev)> translate absolute
screen coordinates into widget-local cells. C<contains-point(y, x)>
exposes the same hit-test the framework uses.
Press events carry a C<click-count> annotation: 1 = single, 2 =
double, 3 = triple. The framework counts a press as a continuation of
the previous click when it lands on the same cell with the same
button within 300 ms.
=head2 What every built-in widget does with the mouse
=item B<Button>, B<Checkbox> — primary click activates / toggles.
=item B<TabBar> — primary click activates the tab under the cursor.
=item B<RadioGroup>, B<ListView>, B<Table> — single-click selects the row; scroll wheel moves the cursor. Table also clicks the column header to cycle sort. ListView and Table fire C<on-activate> on double-click.
=item B<Select> — click toggles the dropdown; scroll wheel scrolls the open dropdown; click on a dropdown row commits.
=item B<TextInput>, B<MultiLineInput> — click positions the caret; drag selects; double-click selects the word; triple-click selects the line / buffer; Ctrl+A selects all; Ctrl+C / Ctrl+X emit on C<on-copy> / C<on-cut> supplies (apps wire the system clipboard themselves via OSC 52 or notcurses paste-buffer).
=item B<CardList> — click selects the card under the cursor; scroll wheel moves between cards.
=item B<ScrollView>, B<TextStream> — scroll wheel scrolls; drag on the scrollbar column drags the thumb.
=item B<ConfirmModal>, B<CommandPalette>, B<FileBrowser> — clicks fall through to the embedded Button / ListView / TextInput, which handle them with their built-in behaviour.
=item B<HelpOverlay> — click outside dismisses (it sets C<dismiss-on-click-outside>); the embedded Close button still works.
Display-only widgets (Text, RichText, Image, Border, ProgressBar,
Spinner, Toast, Legend, all charts) don't react to mouse — Border
passes through to its content.
=head1 LAYOUTS
=head2 Selkie::Layout::VBox
Arranges children top to bottom.
=begin code :lang<raku>
my $vbox = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$vbox.add: Selkie::Widget::Text.new(text => 'Header', sizing => Sizing.fixed(1));
$vbox.add: $main-content-widget; # sizing => Sizing.flex (fills remainder)
$vbox.add: Selkie::Widget::Text.new(text => 'Footer', sizing => Sizing.fixed(1));
=end code
=head2 Selkie::Layout::HBox
Arranges children left to right. Same API as VBox.
=head2 Selkie::Layout::Split
Two-pane split with a draggable-looking divider (not interactive yet —
ratio is programmatic).
=begin code :lang<raku>
my $split = Selkie::Layout::Split.new(
orientation => 'horizontal', # left | right panes
ratio => 0.3, # 30% | 70%
sizing => Sizing.flex,
);
$split.set-first($sidebar);
$split.set-second($main-content);
=end code
Set C<orientation => 'vertical'> for top/bottom panes.
=head1 WIDGETS
=head2 Selkie::Widget::Text
Static styled text with word wrapping.
=begin code :lang<raku>
my $text = Selkie::Widget::Text.new(
text => 'Hello, world',
style => Selkie::Style.new(fg => 0x7AA2F7, bold => True),
sizing => Sizing.fixed(1),
);
$text.set-text('Updated');
=end code
=head2 Selkie::Widget::RichText
Styled text made of multiple spans, each with its own style. Handles
word-wrap across span boundaries.
=begin code :lang<raku>
use Selkie::Widget::RichText;
use Selkie::Widget::RichText::Span;
my $rich = Selkie::Widget::RichText.new;
$rich.set-content([
Span.new(text => 'Error: ',
style => Selkie::Style.new(fg => 0xFF5555, bold => True)),
Span.new(text => 'file not found'),
]);
=end code
=head2 Selkie::Widget::TextStream
Append-only log with ring buffer and auto-scroll. Ideal for streaming LLM
output, chat logs, command output.
=begin code :lang<raku>
my $stream = Selkie::Widget::TextStream.new(
max-lines => 10000,
sizing => Sizing.flex,
);
$stream.append('New line');
$stream.start-supply($some-supply); # auto-append from a Supply
=end code
Auto-follows (scrolls to bottom on append) unless the user has scrolled up
manually.
=head2 Selkie::Widget::TextInput
Single-line text input with cursor, horizontal scroll, and optional mask
for passwords.
=begin code :lang<raku>
my $input = Selkie::Widget::TextInput.new(
placeholder => 'Search...',
sizing => Sizing.fixed(1),
);
$input.on-submit.tap: -> $text { ... };
$input.on-change.tap: -> $text { ... };
# Password field
my $pw = Selkie::Widget::TextInput.new(mask-char => '•');
=end code
=head2 Selkie::Widget::MultiLineInput
Multi-line text input with word wrapping, 2D cursor, and a configurable
max visible height.
=begin code :lang<raku>
my $area = Selkie::Widget::MultiLineInput.new(
placeholder => 'Type a message... (Ctrl+Enter to send)',
max-lines => 6,
sizing => Sizing.fixed(1), # grows as typed, up to max-lines
);
$area.on-submit.tap: -> $text { ... }; # Ctrl+Enter
# Plain Enter inserts a newline (good for pasted content).
=end code
=head2 Selkie::Widget::Button
Focusable clickable button. Emits on Enter or Space.
=begin code :lang<raku>
my $btn = Selkie::Widget::Button.new(label => 'OK', sizing => Sizing.fixed(1));
$btn.on-press.tap: -> $ { do-something };
=end code
=head2 Selkie::Widget::Checkbox
Focusable boolean toggle. Space or Enter toggles. Renders as C<[x] label>
or C<[ ] label>.
=begin code :lang<raku>
my $cb = Selkie::Widget::Checkbox.new(
label => 'Enable notifications',
sizing => Sizing.fixed(1),
);
$cb.on-change.tap: -> Bool $checked { ... };
$cb.set-checked(True);
=end code
=head2 Selkie::Widget::RadioGroup
Focusable single-selection list with C<(●)>/C<( )> indicators. Up/Down
moves the cursor, Enter/Space commits the selection. Cursor and selection
are independent — the user can navigate without changing selection.
=begin code :lang<raku>
my $radio = Selkie::Widget::RadioGroup.new(sizing => Sizing.fixed(3));
$radio.set-items(<Small Medium Large>);
$radio.on-change.tap: -> UInt $idx {
say "Selected: {$radio.selected-label}";
};
=end code
=head2 Selkie::Widget::Select
Compact dropdown picker. Closed state shows the current value with a C<▼>
marker. Enter/Space opens the dropdown as a child plane rendered on top of
surrounding widgets. Esc cancels, Enter/Space commits.
=begin code :lang<raku>
my $select = Selkie::Widget::Select.new(
placeholder => 'Choose a model...',
max-visible => 8, # max items shown in the open dropdown
sizing => Sizing.fixed(1),
);
$select.set-items(<gpt-4 claude-opus local-model>);
$select.on-change.tap: -> UInt $idx {
say $select.selected-value;
};
=end code
=head2 Selkie::Widget::ProgressBar
Non-focusable progress indicator. Supports determinate (0.0..1.0 with
percentage) and indeterminate (bouncing animation driven by C<tick()>)
modes.
=begin code :lang<raku>
# Determinate
my $pb = Selkie::Widget::ProgressBar.new(sizing => Sizing.fixed(1));
$pb.set-value(0.42); # 42%
# Indeterminate (animate via frame callback)
my $loading = Selkie::Widget::ProgressBar.new(
indeterminate => True,
show-percentage => False,
frames-per-step => 4,
sizing => Sizing.fixed(1),
);
$app.on-frame: { $loading.tick };
=end code
=head2 Selkie::Widget::ListView
Scrollable single-select list of strings with keyboard navigation and
scrollbar.
=begin code :lang<raku>
my $list = Selkie::Widget::ListView.new(sizing => Sizing.flex);
$list.set-items(<Alpha Beta Gamma Delta>);
$list.on-select.tap: -> $name { ... }; # cursor moved
$list.on-activate.tap: -> $name { ... }; # Enter pressed
=end code
C<set-items> preserves the current selection by value: if the
previously-selected label is still in the new list, the cursor follows it
to its new index. Otherwise the cursor is clamped to bounds. Only resets
to zero when the list becomes empty. Same behaviour in C<RadioGroup> and
C<Select>.
=head2 Selkie::Widget::CardList
Scrollable list of variable-height widgets. Cursor navigates cards;
the selected card is always fully visible, with clipping on the opposite
end indicated to the card (for e.g. truncation ellipsis).
Use this when each item is richer than a string — e.g. chat messages,
character cards, task cards.
=begin code :lang<raku>
my $cards = Selkie::Widget::CardList.new(sizing => Sizing.flex);
$cards.add-item(
widget => $some-card-widget,
root => $some-container-wrapping-it,
height => 5,
border => $optional-border-for-focus-highlight,
);
$cards.on-select.tap: -> UInt $idx { ... };
=end code
=head2 Selkie::Widget::ScrollView
Generic virtual-scrolling container. Renders only visible children —
children outside the viewport are moved off-screen, so very long lists stay
cheap.
=begin code :lang<raku>
my $scroll = Selkie::Widget::ScrollView.new(sizing => Sizing.flex);
$scroll.add($_) for @many-widgets;
$scroll.scroll-to-end;
=end code
Children should implement C<logical-height()> so the ScrollView can compute
offsets. Widgets that care about partial rendering (Text, RichText,
TextStream) implement C<render-region(offset, height)> for partial-row
clipping.
=head2 Selkie::Widget::Border
Decorative frame around a child widget. Auto-highlights when its descendant
has focus (via a store subscription on C<ui.focused-widget>).
=begin code :lang<raku>
my $border = Selkie::Widget::Border.new(
title => 'Characters',
sizing => Sizing.fixed(20),
);
$border.set-content($inner-widget);
$border.hide-top-border = True; # useful when stacking borders
$border.hide-bottom-border = True;
=end code
Requires at least 3x3. Redraws its edges after content to cover pixel bleed
from child image blits.
C<set-content> destroys the outgoing widget by default. Pass C<:!destroy>
to swap content while keeping the old widget alive — useful when cycling
through persistent views (e.g. a tab strip where each tab is a widget you
want to retain state for):
=begin code :lang<raku>
$border.set-content($view-a);
$border.set-content($view-b, :!destroy); # $view-a survives
$border.set-content($view-a, :!destroy); # swap back, still intact
=end code
Same C<:destroy> option is on C<Selkie::Widget::Modal.set-content>.
=head2 Selkie::Widget::Modal
Centered overlay dialog with dimmed background.
=begin code :lang<raku>
my $modal = Selkie::Widget::Modal.new(
width-ratio => 0.5,
height-ratio => 0.3,
dim-background => True,
);
$modal.set-content($my-form);
$modal.on-close.tap: -> $ { $app.close-modal };
$app.show-modal($modal);
=end code
Modal closes on Esc by default. While open, all events are scoped to its
content (focus trap) except Tab/Shift-Tab/Esc.
=head2 Selkie::Widget::ConfirmModal
Pre-built yes/no confirmation modal.
=begin code :lang<raku>
my $cm = Selkie::Widget::ConfirmModal.new;
$cm.build(
title => 'Delete file?',
message => "Really delete 'report.pdf'?",
yes-label => 'Delete',
no-label => 'Cancel',
);
$cm.on-result.tap: -> Bool $confirmed {
$app.close-modal;
do-delete if $confirmed;
};
$app.show-modal($cm.modal);
$app.focus($cm.no-button); # safe default
=end code
=head2 Selkie::Widget::FileBrowser
Modal file picker with shell-style path completion, extension filtering,
and an optional dotfile toggle.
=begin code :lang<raku>
my $browser = Selkie::Widget::FileBrowser.new;
my $modal = $browser.build(
extensions => <png json>,
show-dotfiles => False,
width-ratio => 0.7,
height-ratio => 0.7,
);
$browser.on-select.tap: -> Str $path {
$app.close-modal;
load-file($path);
};
$app.show-modal($modal);
$app.focus($browser.focusable-widget);
=end code
=head2 Selkie::Widget::Toast
Transient notification banner (usually managed via C<$app.toast>, but
accessible directly for custom styling).
=head2 Selkie::Widget::Image
Displays an image file. Uses notcurses pixel blitter if the terminal
supports it (Kitty, iTerm2, some others), falling back to block/unicode
art otherwise.
=begin code :lang<raku>
my $img = Selkie::Widget::Image.new(
file => 'avatar.png',
sizing => Sizing.fixed(20),
);
$img.set-file('new-avatar.png');
$img.clear-image;
=end code
Be aware: pixel-blitted images extend past parent plane bounds in notcurses.
Wrap in a C<Border> (which redraws its edges after content) or lay out with
enough margin to avoid bleed.
=head2 Selkie::Widget::Spinner
Tiny animated loading indicator. Drive via C<tick> from a frame callback;
wall-clock throttled so the rate is independent of how fast your event
loop iterates.
=begin code :lang<raku>
my $spinner = Selkie::Widget::Spinner.new(
sizing => Sizing.fixed(2),
interval => 0.1, # 10fps — smooth and calm
);
$app.on-frame: { $spinner.tick };
=end code
Built-in frame sets: C<BRAILLE> (default), C<DOTS>, C<LINE>, C<CIRCLE>,
C<ARROW>. Or pass a custom array of strings via C<frames>.
=head2 Selkie::Widget::PasswordStrength
Non-focusable strength meter that subscribes to a C<TextInput>'s
C<on-change> Supply and renders a five-level bar (weak / fair / good /
strong / very strong). Scoring is a length-plus-character-class
heuristic — long enough for "password1" to look weak and a passphrase
to look strong, without a dictionary or external dependency.
=begin code :lang<raku>
my $pw = Selkie::Widget::TextInput.new(
sizing => Sizing.fixed(1),
placeholder => 'Enter password...',
mask-char => '*',
);
my $meter = Selkie::Widget::PasswordStrength.new(
sizing => Sizing.fixed(1),
input => $pw,
);
$vbox.add($pw);
$vbox.add($meter);
=end code
For real strength analysis use C<zxcvbn>; the built-in heuristic is
meant to give users directional feedback while they type.
=head2 Selkie::Widget::TabBar
Horizontal tab strip with keyboard navigation. Focusable — Left/Right
move the active tab, Enter re-emits C<on-tab-selected>. Integrates with
L<Selkie::ScreenManager> via C<sync-to-app>, which keeps the bar's
active tab synced to C<$app.screen-manager.active-screen>.
=begin code :lang<raku>
my $tabs = Selkie::Widget::TabBar.new(sizing => Sizing.fixed(1));
$tabs.add-tab(name => 'inbox', label => 'Inbox');
$tabs.add-tab(name => 'sent', label => 'Sent');
$tabs.add-tab(name => 'drafts', label => 'Drafts');
$tabs.on-tab-selected.tap: -> Str $name {
$app.switch-screen($name);
};
=end code
=head2 Selkie::Widget::CommandPalette
VS-Code-style fuzzy-filtered action launcher. Register commands by
label + action, bind to C<Ctrl+P>, and you get a searchable palette
modal for free.
=begin code :lang<raku>
my $palette = Selkie::Widget::CommandPalette.new;
$palette.add-command(label => 'New note', -> { create-note });
$palette.add-command(label => 'Toggle theme', -> { toggle-theme });
$palette.add-command(label => 'Quit', -> { $app.quit });
my $modal = $palette.build;
$palette.on-command.tap: -> $cmd {
$app.close-modal;
$cmd.action.();
};
$app.on-key('ctrl+p', -> $ {
$palette.reset;
$app.show-modal($modal);
$app.focus($palette.focusable-widget);
});
=end code
=head2 Selkie::Widget::HelpOverlay
Modal listing reachable keybinds for the focused widget chain. Walks
the focused widget and each ancestor up to the screen root, collects
any C<on-key> binds that carry a C<:description>, and renders a
centred overlay grouped by widget class. Binds without a description
are treated as internal plumbing and skipped — authors opt their
shortcuts in by passing C<:description> when registering them.
=begin code :lang<raku>
# Bind globally on the screen root:
$root.on-key: 'ctrl+h', -> $ {
my $help = Selkie::Widget::HelpOverlay.new(
app => $app,
focused-widget => $app.focused,
);
$app.show-modal($help.build);
};
=end code
The overlay's modal sets C<dismiss-on-click-outside =E<gt> True> by
default — clicking anywhere outside the help panel closes it; Esc and
the embedded Close button still work. See B<MOUSE SUPPORT> above for
how the dismiss-on-click-outside flag fits into modal isolation.
=head2 Selkie::Widget::Table
Scrollable tabular data with typed columns, a header row, sort
indicators, cursor navigation, and custom cell rendering. Column widths
use the same fixed/percent/flex model as layouts.
=begin code :lang<raku>
my $table = Selkie::Widget::Table.new(sizing => Sizing.flex);
$table.add-column(name => 'id', label => 'ID', sizing => Sizing.fixed(6));
$table.add-column(name => 'name', label => 'Name', sizing => Sizing.flex, :sortable);
$table.add-column(name => 'size', label => 'Size', sizing => Sizing.fixed(10), :sortable,
render => -> $b { human-size($b) },
sort-key => -> $b { $b.Int });
$table.set-rows([
{ id => 1, name => 'alpha', size => 42_000 },
{ id => 2, name => 'beta', size => 1_200_000 },
]);
$table.on-activate.tap: -> UInt $idx {
open-item($table.row-at($idx));
};
$table.sort-by('name'); # cycles asc → desc → unsorted
=end code
=head1 CHART WIDGETS
A family of seven widgets for rendering numeric data, plus three pure-logic
helpers. All chart widgets accept either static data (C<:data> / C<:series>)
or a reactive binding to the store (C<:store-path> or
C<subscribe-with-callback> from app code), and all render a centered
"No data" placeholder until the first sample arrives — the expected startup
state for monitoring dashboards.
=head2 The shared primitives
The chart widgets share three pure-logic helpers under C<Selkie::Plot::*>.
You'll occasionally use them directly when composing your own visualisations
or when feeding chart widgets pre-computed data.
C<Selkie::Plot::Scaler> — linear value→cell mapping. Clamps out-of-domain
values, preserves NaN as undef, supports C<:invert> for y-axes (so cell 0
holds the maximum value at the top of the screen):
=begin code :lang<raku>
use Selkie::Plot::Scaler;
my $s = Selkie::Plot::Scaler.linear(min => 0, max => 100, cells => 80);
$s.value-to-cell(50); # → 40
$s.cell-to-value(20); # → 25.31...
=end code
C<Selkie::Plot::Ticks> — Heckbert nice-number tick generation, picking
labels from C<{1, 2, 5} × 10ⁿ>. Returns a tick set whose endpoints may
extend slightly past the data range so labels land on round numbers:
=begin code :lang<raku>
use Selkie::Plot::Ticks;
my $t = Selkie::Plot::Ticks.nice(min => 0, max => 100, count => 5);
$t.values; # → (0, 20, 40, 60, 80, 100)
$t.labels; # → ("0", "20", "40", "60", "80", "100")
$t.step; # → 20
=end code
C<Selkie::Plot::Palette> — colourblind-safe series palettes
(C<okabe-ito>, C<tol-bright>, C<tableau-10>) and continuous color ramps
(C<viridis>, C<magma>, C<plasma>, C<coolwarm>, C<grayscale>):
=begin code :lang<raku>
use Selkie::Plot::Palette;
my @colors = Selkie::Plot::Palette.series('okabe-ito');
my $color = Selkie::Plot::Palette.sample('viridis', 0.42); # interpolates
=end code
=head2 Selkie::Widget::Sparkline
Single-row inline chart using the C<▁▂▃▄▅▆▇█> block series. Designed to
live in tables and status bars, not as a standalone visualisation. Hand-
rolled with no native handle, so cheap to embed many instances (e.g. one
per Table row).
=begin code :lang<raku>
use Selkie::Widget::Sparkline;
# Static
my $sl = Selkie::Widget::Sparkline.new(
data => [1, 4, 2, 8, 5, 9, 3, 7],
sizing => Sizing.fixed(1),
);
# Streaming
my $stream = Selkie::Widget::Sparkline.new(sizing => Sizing.fixed(1));
$cpu-supply.tap: -> $sample { $stream.push-sample($sample) };
# Reactive — auto-subscribes to the store path
my $bound = Selkie::Widget::Sparkline.new(
store-path => <metrics latency-history>,
min => 0,
max => 100,
sizing => Sizing.fixed(1),
);
=end code
The three modes (C<:data> / C<:store-path> / streaming) are mutually
exclusive. Pin C<:min> / C<:max> when streaming so the heights don't
jitter as new samples shift the auto-range.
=head2 Selkie::Widget::Plot
Streaming chart wrapping the native C<ncuplot> (uint64 samples) /
C<ncdplot> (num64 samples) widgets in notcurses. The native code handles
scaling, blitter selection (braille by default), and incremental
rendering — this widget's job is lifecycle management, the Selkie
sample-push API, and optional store binding.
=begin code :lang<raku>
use Selkie::Widget::Plot;
my $cpu = Selkie::Widget::Plot.new(
type => 'uint', # or 'double' for fractional measurements
min-y => 0,
max-y => 100,
title => 'CPU %',
sizing => Sizing.flex,
);
# Push samples as they arrive
$cpu-supply.tap: -> $pct {
state $tick = 0;
$cpu.push-sample($tick++, $pct);
};
=end code
The native handle is created lazily on the first C<render()> after the
widget gets a plane. It's destroyed and recreated on resize — sample
history is lost, which is fine for a streaming dashboard but worth knowing.
For chart history that survives terminal resize, use
C<Selkie::Widget::LineChart> with a store-held sample buffer instead.
=head2 Selkie::Widget::BarChart
Categorical bar chart, vertical (default) or horizontal. Each entry is a
labelled value; the widget renders one bar per entry with 1/8-cell
precision (C<▁▂▃▄▅▆▇█> vertically, C<▏▎▍▌▋▊▉█> horizontally) so
bar heights aren't constrained to whole cells.
=begin code :lang<raku>
use Selkie::Widget::BarChart;
my $bars = Selkie::Widget::BarChart.new(
data => [
{ label => 'Q1', value => 1230 },
{ label => 'Q2', value => 1875 },
{ label => 'Q3', value => 2042 },
{ label => 'Q4', value => 1611 },
],
sizing => Sizing.flex,
);
# Horizontal layout
my $hbars = Selkie::Widget::BarChart.new(
data => @data,
orientation => 'horizontal',
sizing => Sizing.flex,
);
=end code
Bar colours come from C<:palette> (default C<okabe-ito>) cycling through
its entries, or from per-bar C<color =>> overrides for status indicators
where colour means something specific:
=begin code :lang<raku>
my @data = $tasks.map: -> $t {
{
label => $t.name,
value => $t.duration-ms,
color => $t.status eq 'failed' ?? 0xCC4444 !! 0x44AA44,
}
};
=end code
Y-range auto-derives from C<min(0, min-data)> to C<max-data>; pass C<:min>
/ C<:max> to fix it.
=head2 Selkie::Widget::Histogram
Adapter that bins a numeric series into the format C<BarChart> expects.
Same rendering and styling — just feeds C<(label, count)> pairs in.
=begin code :lang<raku>
use Selkie::Widget::Histogram;
# Equal-width bins
my $h = Selkie::Widget::Histogram.new(
values => @latency-samples,
bins => 20,
sizing => Sizing.flex,
);
# Custom bin edges (non-uniform — useful for skewed data)
my $log-h = Selkie::Widget::Histogram.new(
values => @latency-samples,
bin-edges => [0, 5, 10, 25, 50, 100, 250, 500, 1000, 5000],
sizing => Sizing.flex,
);
=end code
Bins are B<left-closed, right-open> (C<[0,10), [10,20)> ...) with the
final bin closed-closed (C<[90, 100]>) so the maximum sample is always
counted. Matches numpy / R / matplotlib defaults.
=head2 Selkie::Widget::Heatmap
2D grid coloured by value via a ramp lookup. Each cell renders as
C<█> with a foreground colour interpolated from the chosen ramp.
=begin code :lang<raku>
use Selkie::Widget::Heatmap;
my $h = Selkie::Widget::Heatmap.new(
data => @grid, # 2D array of Real
ramp => 'viridis', # or magma, plasma, coolwarm, grayscale
sizing => Sizing.flex,
);
# Diverging data centred on zero (e.g. correlations)
my $diverging = Selkie::Widget::Heatmap.new(
data => @correlation-matrix,
ramp => 'coolwarm',
min => -1, # pin the range so 0 stays at the white midpoint
max => 1,
sizing => Sizing.flex,
);
=end code
Each input cell becomes one terminal cell — no aspect-ratio compensation.
A 10×10 data grid renders tall and narrow because terminal cells are
roughly 2:1. Pre-process (e.g. duplicate columns) for square display.
NaN cells render with the C<text-dim> theme slot so missing data is
visually distinct from in-range zero.
=head2 Selkie::Widget::ScatterPlot
2D point plot using braille (U+2800-U+28FF) for B<sub-cell> resolution:
each terminal cell holds a 2×4 dot grid (8 dots per cell), so a
50-cell-wide plot resolves 100 distinct x-positions.
=begin code :lang<raku>
use Selkie::Widget::ScatterPlot;
# Use Pair (x => y), [x, y] arrays, or hash form per point.
# Bare lists `(x, y)` flatten in array context — don't use them.
my @points = (1..50).map: { (rand * 100) => (rand * 100) };
my $sp = Selkie::Widget::ScatterPlot.new(
series => [{ label => 'samples', points => @points }],
sizing => Sizing.flex,
);
# Multi-series with explicit colours
my $sp2 = Selkie::Widget::ScatterPlot.new(
series => [
{ label => 'group A', points => @group-a, color => 0xE69F00 },
{ label => 'group B', points => @group-b, color => 0x56B4E9 },
],
sizing => Sizing.flex,
);
=end code
Per-cell colour limitation: a braille codepoint holds all 8 sub-pixels
under a single foreground colour. When two series have dots in the same
2×4 cell window, the C<:overlap> setting decides who wins —
currently only C<z-order> is supported (last-drawn series wins). For
heavily-overlapping series, prefer faceted layouts (one scatter per
series) over single-plot overlay.
=head2 Selkie::Widget::LineChart
Static-data multi-series line chart, hand-rolled with the same braille
sub-cell resolution as C<ScatterPlot>. Composes its own y-axis and
legend; supports optional fill-below for area-emphasis charts.
=begin code :lang<raku>
use Selkie::Widget::LineChart;
# Single series
my $cpu = Selkie::Widget::LineChart.new(
series => [{ label => 'cpu %', values => @cpu-history }],
y-min => 0,
y-max => 100,
sizing => Sizing.flex,
);
# Multi-series with explicit colours and fill
my $cmp = Selkie::Widget::LineChart.new(
series => [
{ label => 'p50', values => @p50, color => 0xE69F00 },
{ label => 'p99', values => @p99, color => 0xCC4444 },
],
fill-below => True,
sizing => Sizing.flex,
);
=end code
For B<streaming> data, use C<Selkie::Widget::Plot> instead (it has a
native ring buffer better suited to high sample rates). C<LineChart>
expects you to hand it the full series each time — typically via
C<set-series> from a C<subscribe-with-callback>:
=begin code :lang<raku>
$app.store.subscribe-with-callback(
'latency-series',
-> $s {
[
$s.get-in('metrics', 'p50') // [],
$s.get-in('metrics', 'p99') // [],
]
},
-> @paths {
$line-chart.set-series([
{ label => 'p50', values => @paths[0], color => 0x4477AA },
{ label => 'p99', values => @paths[1], color => 0xEE6677 },
]);
},
$line-chart,
);
=end code
The same per-cell colour limitation as C<ScatterPlot> applies for
multi-series crossings. See C<examples/charts.raku> for the canonical
reactive setup.
=head2 Selkie::Widget::Axis and Selkie::Widget::Legend
Standalone primitives that chart widgets compose internally, exposed for
consumers who want to lay out a custom chart by hand. C<Axis> renders
labelled tick marks along one of the four edges; C<Legend> renders a
colour-swatch + label row per series.
=begin code :lang<raku>
use Selkie::Widget::Axis;
use Selkie::Widget::Legend;
my $axis = Selkie::Widget::Axis.new(
edge => 'bottom',
min => 0,
max => 1000,
tick-count => 5,
sizing => Sizing.fixed(2), # 1 row line + 1 row labels
);
my $legend = Selkie::Widget::Legend.new(
series => [
{ label => 'cpu', color => 0xE69F00 },
{ label => 'memory', color => 0x56B4E9 },
],
orientation => 'vertical',
sizing => Sizing.fixed(2),
);
=end code
C<Axis.reserved-rows> (for top/bottom) and C<Axis.reserved-cols> (for
left/right) report the exact dimensions the axis needs given its data
range — useful when sizing the parent layout precisely.
=head2 Theming chart widgets
Six new theme slots cover chart elements:
=item C<graph-axis> — axis line and tick marks
=item C<graph-axis-label> — tick labels
=item C<graph-grid> — optional gridlines
=item C<graph-line> — single-series line / sparkline colour
=item C<graph-fill> — fill-below colour in line charts
=item C<graph-legend-bg> — legend background
All are non-required with defaults derived from existing slots
(C<text-dim>, C<divider>, C<border-focused>, C<base.bg>), so themes
written before the chart widgets shipped keep working. Multi-series
chart colours come from C<Selkie::Plot::Palette> (not theme slots) so
the palette can scale to N series without polluting the theme.
=head1 THEMING
C<Selkie::Style> represents a text style (fg, bg, bold, italic, underline,
strikethrough). C<Selkie::Theme> is a collection of named style slots:
=begin code :lang<raku>
use Selkie::Style;
use Selkie::Theme;
my $theme = Selkie::Theme.new(
base => Selkie::Style.new(fg => 0xFFFFFF, bg => 0x000000),
text => Selkie::Style.new(fg => 0xEEEEEE),
text-dim => Selkie::Style.new(fg => 0x888888),
text-highlight => Selkie::Style.new(fg => 0xFFFFFF, bold => True),
border => Selkie::Style.new(fg => 0x444444),
border-focused => Selkie::Style.new(fg => 0x7AA2F7, bold => True),
input => Selkie::Style.new(fg => 0xEEEEEE, bg => 0x1A1A2E),
input-focused => Selkie::Style.new(fg => 0xFFFFFF, bg => 0x2A2A3E),
input-placeholder => Selkie::Style.new(fg => 0x606080, italic => True),
scrollbar-track => Selkie::Style.new(fg => 0x333333),
scrollbar-thumb => Selkie::Style.new(fg => 0x7AA2F7),
divider => Selkie::Style.new(fg => 0x444444),
);
my $app = Selkie::App.new(theme => $theme);
=end code
Widgets resolve theme via inheritance: if a widget has no explicit theme,
it walks up the parent chain until one is found, defaulting to
C<Selkie::Theme.default> (a dark palette) at the root.
Set a theme on a subtree to scope overrides:
=begin code :lang<raku>
$widget.set-theme($special-theme);
=end code
For ad-hoc per-slot values, use C<$theme.custom{...}> and
C<$theme.slot('my-slot')>.
=head1 REACTIVE STORE
Selkie includes an optional re-frame-inspired store for centralized
application state. Use it when you have shared state accessed by multiple
widgets, or need derived values that update reactively.
=head2 Quick example
=begin code :lang<raku>
use Selkie::Store;
my $store = Selkie::Store.new;
# Register a handler for an event. Handler returns effects (not mutations).
$store.register-handler('counter/increment', -> $st, %ev {
my $current = $st.get-in('counter') // 0;
(db => { counter => $current + 1 },);
});
# Subscribe a widget to a path. It's marked dirty when the value changes.
$store.subscribe('my-counter', ['counter'], $widget);
# Dispatch an event from anywhere.
$store.dispatch('counter/increment');
# The app ticks the store each frame, processing the queue and firing subs.
=end code
=head2 State access
C<%!db> is a nested Hash. Use path-based access:
=begin code :lang<raku>
$store.get-in('app', 'user', 'name'); # deep read; returns Nil if missing
$store.assoc-in('app', 'user', 'name', value => 'Alice'); # deep write
=end code
Prefer dispatch-and-handle over direct C<assoc-in> from app code — handlers
keep state changes auditable and composable.
=head2 Handlers and effects
Handlers are pure functions: C<(store, payload) --> effects>. An effect is a
C<Pair> where the key is a registered effect name and the value is its
parameters.
Built-in effects:
=item C<db => {...}> — deep-merge into the state tree
=item C<dispatch => { event => 'name', ...payload }> — enqueue another event
=item C<async => { work => &fn, on-success => 'name', on-failure => 'name' }> — run in a thread, dispatch a follow-up event with the result
=begin code :lang<raku>
$store.register-handler('user/load', -> $st, %ev {
(async => {
work => -> { fetch-user-from-api(%ev<id>) },
on-success => 'user/loaded',
on-failure => 'user/load-failed',
},);
});
$store.register-handler('user/loaded', -> $st, %ev {
(db => { user => %ev<result> },);
});
=end code
You can return multiple effects from one handler by returning a list of
Pairs, or a single Hash with multiple keys.
=head2 Subscriptions
Three kinds, all triggered at each frame tick when the watched value
changes:
=item C<subscribe($id, @path, $widget)> — watch a state path; marks widget dirty on change
=item C<subscribe-computed($id, &compute, $widget)> — watch a derived value
=item C<subscribe-with-callback($id, &compute, &callback, $widget)> — derive + invoke callback with new value
=begin code :lang<raku>
# Rebuild the list UI when items change
$store.subscribe-with-callback(
'my-items',
-> $s { $s.get-in('app', 'items') // [] },
-> @items { $list-view.set-items(@items.map(*.name)) },
$list-view,
);
=end code
B<Important:> when writing compute closures, never use C<return> inside
them — C<return> targets the enclosing routine, not the block. Use
C<if/else> to yield the final expression.
=head2 Store and widgets
C<Selkie::App> owns a default C<Selkie::Store>. Widgets added to the tree
automatically get a reference (propagation is one-way, parent to child).
Widgets can call C<self.dispatch(event, payload)> and C<self.subscribe(...)>
as convenience methods — but they usually shouldn't. The canonical pattern
is: widgets emit on a Supply; app code taps the Supply and calls
C<$store.dispatch>.
The one legitimate widget-level subscription is C<Border>, which watches
C<ui.focused-widget> to auto-highlight when a descendant has focus.
Custom widgets that need to wire up a subscription from C<on-store-attached>
should use the idempotent helpers C<once-subscribe> and
C<once-subscribe-computed>. They track per-id registration so repeated
C<set-store> calls (e.g. when a widget is reparented) don't create
duplicate subscriptions:
=begin code :lang<raku>
method on-store-attached($store) {
self.once-subscribe-computed("my-derived-state", -> $s {
# compute once, subscribe once — safe to call repeatedly
...
});
}
=end code
=head2 Debug logging
Store state flow is invisible by default. Turn on logging during
development to watch events, effects, and subscription fires in real time:
=begin code :lang<raku>
$app.store.enable-debug; # logs to $*ERR
# or:
$app.store.enable-debug(log => open('store.log', :w));
# Later:
$app.store.disable-debug;
=end code
Output looks like:
=begin code
[1776073200.123] dispatch task/add text=Buy milk
[1776073200.123] → db: {tasks => [...], next-id => 5}
[1776073200.124] sub[task-list] fired: [...]
=end code
Each line shows: the dispatched event and its payload, the effects the
handler returned, and any subscriptions whose computed value changed.
Granularity is configurable — pass
C<:!dispatches>, C<:!effects>, or C<:!subscriptions> to silence a
category. Overhead when disabled is a single Bool check per hook.
=head1 BUILDING CUSTOM WIDGETS
Compose C<Selkie::Widget> (leaf) or C<Selkie::Container> (has children):
=begin code :lang<raku>
use Notcurses::Native;
use Notcurses::Native::Plane;
use Selkie::Widget;
use Selkie::Event;
unit class My::Widget does Selkie::Widget;
has Str $.label is required;
method new(*%args --> My::Widget) {
%args<focusable> //= True;
callwith(|%args);
}
method render() {
return without self.plane;
ncplane_erase(self.plane);
self.apply-style(self.theme.text);
ncplane_putstr_yx(self.plane, 0, 0, $!label);
self.clear-dirty;
}
method handle-event(Selkie::Event $ev --> Bool) {
# ...
self!check-keybinds($ev);
}
=end code
Key points:
=item Declare C<unit class ... does Selkie::Widget;> or C<Selkie::Container>.
=item Always C<return without self.plane;> at the top of C<render> — the plane is created lazily.
=item End C<render> with C<self.clear-dirty>.
=item Use C<self.theme.xxx> to look up theme styles — never hardcode colors.
=item Use C<self.apply-style($style)> to set colors and attributes on the plane.
=item Return C<True> from C<handle-event> if you consumed the event.
=head1 TESTING
Widget apps are testable without ever starting notcurses. The C<Selkie::Test::*>
modules provide synthesis helpers, Supply observation, store assertions, and
tree introspection — everything needed to exercise a widget from outside.
=head2 Selkie::Test::Keys
Keystroke synthesis. Build events from the same spec grammar C<on-key>
accepts:
=begin code :lang<raku>
use Selkie::Test::Keys;
press-key($widget, 'ctrl+q'); # build + dispatch
press-keys($list, 'down', 'down', 'enter'); # sequence
type-text($input, 'hello world'); # char-by-char
my $ev = key-event('ctrl+shift+a'); # just build, don't dispatch
=end code
=head2 Selkie::Test::Supply
Observe what a widget's Supply emits during an action:
=begin code :lang<raku>
use Selkie::Test::Supply;
my @got = collect-from $btn.on-press, {
press-key($btn, 'enter');
};
emitted-once-ok $btn.on-press, True, 'Enter fires press', {
press-key($btn, 'enter');
};
emitted-count-is $list.on-select, 2, 'two moves', {
press-keys($list, 'down', 'down');
};
=end code
=head2 Selkie::Test::Store
Store plumbing for tests — no App required:
=begin code :lang<raku>
use Selkie::Test::Store;
my $store = mock-store(state => { count => 0, user => { name => 'Alice' } });
dispatch-and-tick($store, 'counter/inc');
is state-at($store, 'count'), 1, 'count incremented';
is state-at($store, 'user', 'name'), 'Alice', 'nested state intact';
=end code
=head2 Selkie::Test::Focus
Most focusable widgets gate C<handle-event> on C<is-focused>. Wrap your
test actions in C<with-focus> to avoid the boilerplate:
=begin code :lang<raku>
use Selkie::Test::Focus;
with-focus $input, {
type-text($input, 'hello');
press-key($input, 'enter');
};
# Focus released automatically — even if the block throws.
=end code
=head2 Selkie::Test::Tree
When a widget tree is built by a subscription callback and you don't have
direct references, walk it:
=begin code :lang<raku>
use Selkie::Test::Tree;
my $save-btn = find-widget $root, -> $w {
$w ~~ Selkie::Widget::Button && $w.label eq 'Save';
};
my @all-buttons = find-widgets $root, * ~~ Selkie::Widget::Button;
contains-widget-ok $root, $my-input, 'input still reachable';
=end code
=head2 Selkie::Test::Snapshot
Golden-file snapshot testing. First run saves the widget's rendered
output to C<t/snapshots/$name.snap>; subsequent runs diff against it.
=begin code :lang<raku>
use Selkie::Test::Snapshot;
snapshot-ok $my-widget, 'my-widget-default', rows => 10, cols => 40;
# After making an intentional change that affects the render:
# SELKIE_UPDATE_SNAPSHOTS=1 prove6 -l t
# to accept new output.
=end code
Uses a real headless notcurses instance (one shared across all
snapshots in a test run — notcurses only allows one init per process).
Renders the widget, reads cells back via C<ncplane_at_yx>, compares
against the stored file. No terminal required.
=head2 A complete example
=begin code :lang<raku>
use Test;
use Selkie::Test::Keys;
use Selkie::Test::Supply;
use Selkie::Test::Focus;
use Selkie::Widget::TextInput;
my $input = Selkie::Widget::TextInput.new;
my @submissions = collect-from $input.on-submit, {
with-focus $input, {
type-text($input, 'hello world');
press-key($input, 'enter');
};
};
is @submissions.elems, 1, 'submitted once';
is @submissions[0], 'hello world', 'submitted value is correct';
done-testing;
=end code
=head1 COMMON PITFALLS
=head2 `return` in closures
C<return> in a pointy block (C<-E<gt> { ... }>) targets the enclosing
routine — which for a handler or subscription closure is usually a method
on an outer class. At runtime this throws
"Attempt to return outside of immediately-enclosing Routine". Use C<if/else>
and yield the final expression instead.
=head2 Nil decays to Any
C<Selkie::Store.get-in> returns C<Nil> for missing paths, but
C<my $x = Nil> assigns C<Any>. If you pass that to a typed parameter, the
bind fails before any C<without> guard in the body runs. Use untyped
parameters and C<.defined> checks for helpers that may receive missing
values.
=head2 Pixel bleed past plane boundaries
Notcurses child planes are B<not clipped> to their parent's bounds.
Pixel-blitted images will extend past containers. C<Border> redraws its
edges after content to cover bleed; if you compose similar widgets, do the
same.
=head2 Global keybinds conflicting with input
Global keybinds must include a modifier. Bare character keybinds belong on
focusable widgets that own the key (e.g. C<'a'> on a list view for "add").
=head2 `\r\n` is one grapheme
In Raku, C<"\r\n"> is a single grapheme — C<split("\n")> won't match it.
Use regex split: C<$text.split(/\n/, :v)>. Matters when handling text from
Windows sources (pasted content, some JSON blobs).
=head1 PHILOSOPHY
Selkie favors a few conventions:
=item B<Explicit over implicit>. You build the tree. You own the state. The framework doesn't auto-wire behaviour behind your back.
=item B<One way to do things>. There's one layout system, one sizing model, one store pattern. Opinionated by design.
=item B<Events in, effects out>. Widgets emit via C<Supply>; app code dispatches to the store; handlers return effects; subscriptions derive UI state. Mutations flow one direction.
=item B<No lifecycle hooks>. No C<componentDidMount>, no C<useEffect>. Widgets render when dirty, receive events when focused. That's the whole lifecycle.
=head1 AUTHOR
Matt Doughty <matt@apogee.guru>
=head1 COPYRIGHT AND LICENSE
Copyright 2026 Matt Doughty
This library is free software; you can redistribute it and/or modify it under the Artistic License 2.0.
=end pod