Selkie.git | lib/Selkie/ | Widget.rakumod
=begin pod
=head1 NAME
Selkie::Widget - Base role composed by every Selkie widget
=head1 SYNOPSIS
A minimal custom widget that renders a fixed string:
=begin code :lang<raku>
use Notcurses::Native;
use Notcurses::Native::Plane;
use Selkie::Widget;
unit class My::Hello does Selkie::Widget;
method render() {
return without self.plane;
ncplane_erase(self.plane);
self.apply-style(self.theme.text);
ncplane_putstr_yx(self.plane, 0, 0, 'Hello, Selkie!');
self.clear-dirty;
}
=end code
Add it to any layout and it just works:
=begin code :lang<raku>
use Selkie::Sizing;
$vbox.add: My::Hello.new(sizing => Sizing.fixed(1));
=end code
=head1 DESCRIPTION
C<Selkie::Widget> is the role at the bottom of every widget in the
framework. Compose it to create your own widget; Selkie handles the tree
integration, rendering cycle, focus routing, theme inheritance, store
plumbing, and memory management.
You almost never construct a C<Selkie::Widget> directly — it's a role,
so you C<does Selkie::Widget> on your own class. The framework itself
composes it to build every built-in widget (C<Text>, C<Button>,
C<ListView>, and so on).
=head2 What you get for free
=item A notcurses plane to render into, created and destroyed for you
=item Theme inheritance from the widget tree
=item Themed plane-base painting so erase / unwritten cells show the theme background rather than the terminal default — applied on C<init-plane>, C<set-store>, and C<set-theme>, and overridable per widget via C<base-style> / C<base-egc>
=item Keybind registration and event bubbling
=item Dirty tracking so your C<render> method only runs when needed
=item Per-widget subscription to the reactive store
=item Clean shutdown when the widget goes out of scope
=head2 What you must provide
At minimum, a C<render> method. That's it.
=head2 What you may provide
=item C<handle-event> — to react to keyboard or mouse input when focused
=item C<on-store-attached> — to wire up subscriptions when the store appears
=item C<destroy> — to clean up anything beyond the plane (e.g. extra
notcurses handles, file descriptors, subscriptions)
=head2 How a parent sizes you
C<sizing> is the size you ask for along the axis your container stacks
on — rows in a C<VBox>, columns in an C<HBox>. Two optional attributes
cover the other axis:
=item C<cross-sizing> — a second L<Selkie::Sizing>, resolved against the
container's cross extent. Undefined (the default) means "as wide as the
VBox" / "as tall as the HBox", which is what every layout did before
cross-axis alignment existed.
=item C<align-self> — a L<Selkie::Align> C<CrossAlign> saying where the
resolved extent sits. Undefined inherits the container's C<align-items>.
=begin code :lang<raku>
# A 40-column card, centred, in a full-width column.
$column.add: My::Card.new(
sizing => Sizing.fixed(6),
cross-sizing => Sizing.fixed(40),
align-self => CrossCenter,
);
=end code
Both are inert for a widget whose container isn't a box layout, and
both have runtime setters (C<update-cross-sizing>, C<set-align-self>)
that mark the parent dirty so the next frame re-lays out.
=head1 LIFECYCLE
Construction happens in normal Raku fashion: C<My::Widget.new(...)>. At
this point the widget has no plane and no size. It's safe to store
configuration on the object but not to call notcurses functions.
When the widget is added to a parent layout (via C<$parent.add($child)>),
the parent calls C<init-plane> to create a notcurses plane sized to its
share of the layout. After this point C<self.plane> returns a valid
handle, and C<self.rows> / C<self.cols> reflect the plane's dimensions.
Each frame, the framework walks the tree and calls C<render> on any
widget whose C<is-dirty> is true. Your render method should:
=item Erase the plane with C<ncplane_erase(self.plane)>
=item Apply styles with C<self.apply-style($style)>
=item Write to the plane with notcurses calls
=item Call C<self.clear-dirty> at the end
When the widget is removed or the program exits, C<destroy> is called
and the plane is freed.
=head1 OVERRIDE POINTS
The public API is organised into three buckets.
=head2 Required override
=item C<render> — draw yourself onto C<self.plane>. Must be defined by the composing class.
=head2 Optional overrides
=item C<handle-event($ev --> Bool)> — return True if you consumed the event
=item C<destroy> — call C<self.destroy-plane> and clean up any extras
=item C<on-store-attached($store)> — implement this (no inherited default) to register subscriptions
=item C<base-style> — the style stamped into the plane's base cell (default: the theme's C<base> slot)
=item C<base-egc> — the glyph stamped into the plane's base cell (default: C<' '>; C<''> lets lower planes show through)
=item C<apply-disabled-effect> — how this widget greys out when disabled (default: grey the framebuffer and the base cell, then recurse into the subtree)
=head2 Do not override
=item C<init-plane>, C<adopt-plane> — called by layout containers
=item C<mark-dirty>, C<clear-dirty> — called by the render cycle
=item C<set-viewport> — called by parent layouts
=item C<reposition> — called by parent layouts; it is also where the C<is-parked> latch is maintained
=head2 Parking
C<park> moves a widget's plane to C<park-y> (10,000 rows down, far past
any real terminal) so notcurses clips it away while its state survives
intact — that's how screen swaps and off-screen list cards work.
Containers override C<park> to recurse; widgets holding native
resources whose visibility does not follow plane position (notably
L<Selkie::Widget::Image>'s sprixel) override it to release them.
C<is-parked> reports the latch, maintained by C<reposition> /
C<init-plane> / C<adopt-plane>. Because notcurses carries bound child
planes along with their parent, a parked widget's whole subtree is
off-screen whatever each descendant's own latch says — which is what
makes it a sound prune point for tree walks that only care about
on-screen widgets. It is B<not> a general visibility test: an unparked
widget can still be clipped away by an ancestor. Use C<effective-bounds>
for that.
=head2 Alpha and the plane base
C<apply-style> pushes a style's C<fg-alpha> / C<bg-alpha> (see
L<Selkie::Alpha>) to the plane, but only when they differ from what it
last pushed — a per-widget cache keeps the ordinary all-opaque path free
of any extra native calls. If you change the plane's alpha yourself,
call C<reset-style-cache> so the framework stops trusting its record.
C<base-style> and C<base-egc> control the base cell, which is a
different thing from the styles you apply while rendering: the base cell
is what C<ncplane_erase> fills with and what shows anywhere your
C<render> never writes. Overriding the pair is how a widget becomes
see-through — an empty C<base-egc> leaves the cell with no glyph, so
notcurses's glyph search falls through to the planes beneath it.
=head2 Disabled widgets
C<set-disabled(True)> greys a widget and its whole subtree out and takes
it off every focus path — Tab / Shift-Tab, click-to-focus, and
C<Selkie::App.focus> all skip it. C<set-disabled(False)> puts it back.
=begin code :lang<raku>
my $save = Selkie::Widget::Button.new(label => 'Save', sizing => Sizing.fixed(3));
$save.set-disabled(True); # greyed; Tab now walks straight past it
$save.disabled; # True
$save.focusable; # still True — see below
$save.set-disabled(False); # back to full colour, back in the cycle
=end code
C<disabled> is a separate axis from C<focusable> on purpose. C<focusable>
describes the widget (a Button is focusable, a Text isn't) and is fixed
for its lifetime; C<disabled> is transient state layered on top. Flipping
C<focusable> to disable something would lose the original value, and the
widget could come back wrong.
Greying happens as a post-pass: the framework's render entry points
(container children, C<Border> / C<Modal> content, the render loop's
root and top-most modal, and C<Selkie::Test::Snapshot>) call
C<apply-disabled-effect> on a widget that just rendered and is disabled.
Nothing per-widget opts in, and an app with nothing disabled pays one
boolean test per rendered widget.
=head3 What greying does not reach
=item B<Images do not grey.> C<Selkie::Widget::Image> paints through a sprixel — pixel data notcurses composites outside the cell framebuffer — and greying only converts cells. A disabled pane containing an image greys its text and keeps the image in full colour. Fade the image in your own code (or swap in a pre-greyed one) if that matters.
=item B<Inherited colours grey where they are set, not where they are used.> A cell whose foreground or background is "the default" doesn't hold a colour at all — notcurses resolves it against the planes underneath. Greying leaves those channels alone (see C<greyscale-plane> for why the obvious alternative is worse), so a widget that draws with the C<text> slot — foreground only, background inherited — greys its text and keeps whatever background it was inheriting. Its own base cell greys, so its own footprint dims; a background it was borrowing from an enclosing pane does not, unless you disable that pane too. This is almost always what you want, and it is why disabling a leaf control looks right without disabling its container.
=item B<Palette-indexed colours don't grey.> An index into the terminal's palette has no RGB to average. Selkie never emits palette colours, so this only comes up if you're calling C<ncplane_set_fg_palindex> yourself.
=item B<Custom containers that bypass C<!render-children>.> The hook lives in the framework's render helpers. A container that loops over C<self.children> calling C<$child.render> by hand renders disabled children in full colour; use C<self!render-children>, or call C<$child.apply-disabled-effect if $child.disabled> yourself.
=item B<Direct mouse events.> C<disabled> covers appearance and focus. Keyboard input follows focus, so it never reaches a disabled widget — but a mouse click is delivered to whatever is under the pointer and bubbles up from there, independently of focus. A disabled C<Button> is greyed and unfocusable and will still fire its C<on-click> if clicked. Check C<self.disabled> at the top of C<handle-event> (or of the callback) on any widget where that matters.
=head3 Re-enabling is a repaint
Greying converts cells in place — the original colours are
gone, not stashed. Coming back therefore means drawing the subtree
again, which C<set-disabled(False)> arranges for you: it re-stamps every
base cell in the subtree (C<refresh-plane-base>) and then calls
C<mark-dirty-tree>. That is a full subtree repaint, so treat
enable/disable as a user-triggered state change, not something to toggle
per frame.
=head1 EXAMPLES
=head2 Example 1 — A static colored bar
The simplest useful widget. A solid block of color spanning its full
size. Good for spacers or visual dividers.
=begin code :lang<raku>
use Notcurses::Native;
use Notcurses::Native::Plane;
use Selkie::Widget;
unit class My::ColorBar does Selkie::Widget;
has UInt $.color is required; # 0xRRGGBB
method render() {
return without self.plane;
ncplane_set_bg_rgb(self.plane, $!color);
ncplane_erase(self.plane);
self.clear-dirty;
}
=end code
Use it like:
=begin code :lang<raku>
$vbox.add: My::ColorBar.new(color => 0xFF5555, sizing => Sizing.fixed(1));
=end code
=head2 Example 2 — A focusable toggle that emits on change
A box that flips a boolean when the user presses Space or Enter. The
state is owned by the widget; interested app code subscribes by tapping
the C<on-toggle> Supply. This is the canonical leaf-widget pattern in
Selkie — widgets emit, app code dispatches to the store.
=begin code :lang<raku>
use Notcurses::Native;
use Notcurses::Native::Plane;
use Notcurses::Native::Types;
use Selkie::Widget;
use Selkie::Event;
unit class My::Toggle does Selkie::Widget;
has Bool $.state = False;
has Supplier $!toggle-supplier = Supplier.new;
method new(*%args --> My::Toggle) {
# Focusable by default, so Tab can reach us
%args<focusable> //= True;
callwith(|%args);
}
method on-toggle(--> Supply) { $!toggle-supplier.Supply }
method toggle() {
$!state = !$!state;
$!toggle-supplier.emit($!state);
self.mark-dirty;
}
method render() {
return without self.plane;
my $style = self.theme.text;
self.apply-style($style);
ncplane_erase(self.plane);
my $glyph = $!state ?? '●' !! '○';
ncplane_putstr_yx(self.plane, 0, 0, $glyph);
self.clear-dirty;
}
method handle-event(Selkie::Event $ev --> Bool) {
# Only respond when we have focus — the app routes events to the
# focused widget and up the parent chain.
return False unless $ev.event-type ~~ KeyEvent;
if $ev.id == NCKEY_ENTER || $ev.id == NCKEY_SPACE {
self.toggle;
return True;
}
# Pass through to any registered custom keybinds
self!check-keybinds($ev);
}
=end code
Consuming app code:
=begin code :lang<raku>
my $toggle = My::Toggle.new(sizing => Sizing.fixed(1));
$vbox.add($toggle);
$toggle.on-toggle.tap: -> Bool $on {
# Tap fires whenever state flips. Dispatch to the store from here.
$app.store.dispatch('setting/changed', value => $on);
};
=end code
=head2 Example 3 — Registering a custom keybind
Widgets can register per-instance keybinds with C<on-key>. These fire
when the widget is focused (or, if unfocused, are available for the
parent chain to delegate to). Useful for shortcuts scoped to a specific
view.
=begin code :lang<raku>
my $list-view = Selkie::Widget::ListView.new(sizing => Sizing.flex);
# 'a' on the list triggers "add"
$list-view.on-key: 'a', -> $ {
open-add-dialog();
};
# 'd' with the list focused deletes the cursor item
$list-view.on-key: 'd', -> $ {
delete-current-item();
};
=end code
Keybinds with a modifier (C<ctrl+>, C<alt+>, C<super+>) work even when
a text input is focused — the input lets modified keys bubble up. Bare
character keybinds get consumed by text inputs, so reserve them for
list-style widgets.
=head2 Example 4 — A widget that reacts to store state
When a widget's appearance depends on shared application state, subscribe
to the store from C<on-store-attached>. The framework calls this once
per C<set-store> call, so use C<once-subscribe> / C<once-subscribe-computed>
to avoid duplicate registrations across repeated calls.
=begin code :lang<raku>
use Selkie::Widget;
unit class My::UnreadBadge does Selkie::Widget;
has UInt $!count = 0;
method on-store-attached($store) {
# Idempotent: won't double-register if on-store-attached is called
# again (e.g. if this widget is reparented).
self.once-subscribe-computed('unread-count', -> $s {
$s.get-in('inbox', 'unread') // 0;
});
}
method render() {
return without self.plane;
# Re-read fresh from the store each render; the subscription just
# ensures we're re-rendered when the value changes.
$!count = self.store.get-in('inbox', 'unread') // 0 if self.store;
my $style = self.theme.text-highlight;
self.apply-style($style);
ncplane_erase(self.plane);
my $badge = $!count > 0 ?? "($!count)" !! '';
ncplane_putstr_yx(self.plane, 0, 0, $badge);
self.clear-dirty;
}
=end code
Subscriptions are released for you on the usual teardown routes:
C<destroy> unsubscribes the widget (from C<!destroy-plane>, the
universal chokepoint), and C<Selkie::Container.remove> / C<.clear>
unsubscribe the removed child's whole subtree first. If you tear a
subtree down some other way — you built it yourself and are about to
drop it, or you are a container-like widget with your own destroy
route — call C<unsubscribe-subtree> on its root before destroying, and
subscriptions cannot outlive the planes they would render into.
=head1 SEE ALSO
=item L<Selkie::Container> — for widgets that hold children
=item L<Selkie::Sizing> — the fixed/percent/flex sizing model
=item L<Selkie::Align> — C<CrossAlign>, and how C<cross-sizing> pairs with it
=item L<Selkie::Theme> and L<Selkie::Style> — styling inherited through the tree
=item L<Selkie::Event> — the keyboard/mouse event abstraction
=item L<Selkie::Store> — the reactive state store
=end pod
unit role Selkie::Widget;
use nqp;
use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;
use Notcurses::Native::Channel;
use Selkie::Align;
use Selkie::Alpha;
use Selkie::Style;
use Selkie::Theme;
use Selkie::Event;
use Selkie::Sizing;
use Selkie::EffectiveBounds;
my atomicint $next-widget-id = 0;
#| A monotonically-increasing integer identifier unique to each widget
#| instance. Assigned at construction and never changes. Useful as a
#| key when you need identity-stable references in subscriptions or
#| debug output.
has Int $.widget-id = ++⚛$next-widget-id;
has NcplaneHandle $!plane;
has Bool $!owns-plane = True;
#| The containing widget, set by the parent layout when this widget is
#| added to it. Read-only in practice — layouts manage this — but exposed
#| as C<is rw> so internal helpers can reparent.
has Selkie::Widget $.parent is rw;
has Bool $!dirty = True;
has UInt $!rows = 0;
has UInt $!cols = 0;
has UInt $!y = 0;
has UInt $!x = 0;
has Int $!abs-y = 0;
has Int $!abs-x = 0;
has UInt $!viewport-rows = 0;
has UInt $!viewport-cols = 0;
#|( Park latch, maintained by C<reposition> / C<init-plane> /
C<adopt-plane>: True from the moment the widget is moved to C<park-y>
or beyond, False again as soon as anything moves it back into the
parent's coordinate space. B<Not> a second source of truth for
position — it's a cheap restatement of "C<$!y> is at or past the park
line" so tree walks can skip whole off-screen subtrees without
re-deriving that per node.
Note that C<abs-y> deliberately does B<not> track parking: parking is
a plane move, and the layout pass that would refresh C<abs-y> is
exactly the pass a parked widget doesn't get. That asymmetry is why
a parked card keeps stale on-screen C<abs-y> coordinates, and why a
rect walk that trusts them alone dirties widgets that own no cells.
See C<is-parked>. )
has Bool $!parked = False;
#| How this widget wants to be sized by its parent layout. See
#| L<Selkie::Sizing>: C<Sizing.fixed($n)>, C<Sizing.percent($n)>, or
#| C<Sizing.flex($n = 1)>. Defaults to C<Sizing.flex>.
has Sizing $.sizing = Sizing.flex;
#|( How big this widget wants to be on its parent's B<cross> axis —
columns inside a C<VBox>, rows inside an C<HBox>. Another
L<Selkie::Sizing>, resolved against the container's cross extent by
C<resolve-cross-extent> in L<Selkie::Layout::Allocate>:
C<Sizing.fixed($n)> is an exact cell count, C<Sizing.percent($n)> a
share of the container, C<Sizing.flex> the whole of it.
Undefined — the default — means "fill the cross axis", which is
what every Selkie layout has always done. Leave it alone unless you
want a child narrower (VBox) or shorter (HBox) than its container,
and pair it with C<align-self> or the container's C<align-items> to
say where the leftover space goes. )
has Sizing $.cross-sizing;
#|( Where this widget sits along its parent's cross axis, overriding
the container's C<align-items>. Undefined — the default — inherits
from the container, so setting C<align-items> on a VBox or HBox
really does govern every child that hasn't opted out.
See L<Selkie::Align> for the C<CrossAlign> values, and
C<set-align-self> for the dirty-marking setter. )
has CrossAlign $.align-self is rw;
#| Whether this widget can receive focus via Tab / Shift-Tab cycling or
#| direct C<$app.focus($widget)> calls. Leaf input widgets typically
#| override this to True in their C<new> method:
#|
#| method new(*%args) {
#| %args<focusable> //= True;
#| callwith(|%args);
#| }
has Bool $.focusable = False;
#|( Whether this widget is disabled: greyed out on screen and skipped
by every focus path. False by default.
Deliberately B<not> the same knob as C<focusable>. C<focusable> says
what kind of widget this is — a Button is focusable, a Text is not —
and never changes over a widget's life. C<disabled> is a transient
state on top of it, so re-enabling a control restores exactly the
focus behaviour it was built with, without anyone having to
remember what that was.
Set it through C<set-disabled>, which handles the repaint. See the
"Disabled widgets" section above for what greying does and doesn't
reach. )
has Bool $.disabled = False;
#|( When True (the default), this widget's L<Selkie::EffectiveBounds> is
computed as the intersection of its plane with every ancestor's
plane and the terminal viewport. Sprixel-bearing widgets
(L<Selkie::Widget::Image> and any custom widget that allocates its
own blit plane) use this to size their blit-plane to the visible
region only — pixels never paint outside an ancestor's bounds,
even though notcurses doesn't enforce that itself.
Set False on a widget that intentionally paints outside its
parent's visible area (e.g. a popup / dropdown / portal-style
overlay that escapes its container's footprint). The
intersection-with-terminal-viewport step still applies — pixels
never paint past the terminal's edge regardless. )
has Bool $.clip-to-ancestors is rw = True;
has Selkie::Theme $!theme;
# Last alpha mode pushed to this widget's plane, per channel, so
# apply-style can skip the FFI call when nothing changed. An undefined
# entry means "unknown" — the next apply-style will push the mode
# unconditionally. See reset-style-cache.
#
# The defaults are the correct answer for a widget that has not been
# given a plane yet: ncplane_create zeroes the plane's channels, and
# zero in both alpha fields is NCALPHA_OPAQUE.
has AlphaMode $!applied-fg-alpha = AlphaOpaque;
has AlphaMode $!applied-bg-alpha = AlphaOpaque;
has @!keybinds;
has @!mouse-handlers; # MouseHandler — see C<on-click>, C<on-scroll>, etc.
has $!store; # Selkie::Store — untyped to avoid circular import
#|( Returns the notcurses plane this widget renders to, or the type
object C<NcplaneHandle> if the widget has not been added to a
parent yet. Always guard with C<return without self.plane;> at the
top of C<render>. )
method plane(--> NcplaneHandle) { $!plane }
#| Current row height of the widget's plane.
method rows(--> UInt) { $!rows }
#| Current column width of the widget's plane.
method cols(--> UInt) { $!cols }
#| Y offset relative to the parent plane.
method y(--> UInt) { $!y }
#| X offset relative to the parent plane.
method x(--> UInt) { $!x }
#| True if this widget needs to be re-rendered on the next frame.
method is-dirty(--> Bool) { $!dirty }
#| Absolute Y position on the screen — the parent layout computes this
#| by accumulating its own C<abs-y> with this widget's local offset.
#| Useful for overlay positioning.
method abs-y(--> Int) { $!abs-y }
#| Absolute X position on the screen. See C<abs-y>.
method abs-x(--> Int) { $!abs-x }
#| Number of rows actually visible on screen — may be smaller than
#| C<rows> if a parent ScrollView is clipping us.
method viewport-rows(--> UInt) { $!viewport-rows }
#| Number of columns actually visible on screen. See C<viewport-rows>.
method viewport-cols(--> UInt) { $!viewport-cols }
#|( True when this widget has been moved to (or past) C<park-y> and
nothing has moved it back — i.e. C<park> was called and no
subsequent C<reposition> / C<init-plane> / C<adopt-plane> re-placed
it.
Because notcurses carries bound child planes along with their
parent, a parked widget's B<entire subtree> is off-screen regardless
of each descendant's own latch, so this is a sound prune point for
tree walks that only care about on-screen widgets (see
C<Selkie::Tree>'s C<mark-widgets-in-rect-dirty>).
One deliberate exception: L<Selkie::Widget::Image> overrides C<park>
to tear its sprixel down and B<not> move its own plane (its
ancestors' cascade carries it), so an Image's own latch stays clear
while parked. That costs nothing — an Image is a leaf, and the
ancestor that did move is where the prune happens.
Do not use it as a visibility test in a renderer: a widget can be
unparked and still be clipped away by an ancestor. For that question
use C<effective-bounds> (or, for sprixels, live notcurses geometry —
see L<Selkie::Widget::Image>). )
method is-parked(--> Bool) { $!parked }
#|( Compute this widget's L<Selkie::EffectiveBounds> — the rectangular
intersection of its plane with every ancestor's plane and the
terminal viewport. This is the on-screen rectangle into which the
widget may safely paint pixels; anything outside would bleed past
an ancestor's visible region (notcurses doesn't clip child planes
to parents).
When C<$!clip-to-ancestors> is False, the ancestor walk is skipped
and only the terminal-viewport intersection applies — useful for
portal-style overlays that intentionally escape their container.
Cheap by construction: O(depth) attribute reads + intersections,
no allocations beyond the returned value class. Called per frame
by L<Selkie::Widget::Image>'s blit-plane sizing path. )
method effective-bounds(--> Selkie::EffectiveBounds) {
my Int $cur-y = self.abs-y;
my Int $cur-x = self.abs-x;
my UInt $cur-h = self.rows;
my UInt $cur-w = self.cols;
my UInt $clip-top = 0;
my UInt $clip-left = 0;
if $!clip-to-ancestors {
my $ancestor = self.parent;
while $ancestor.defined && !($cur-h == 0 || $cur-w == 0) {
my $eb = intersect-rect(
ay => $cur-y, ax => $cur-x, ah => $cur-h, aw => $cur-w,
by => $ancestor.abs-y, bx => $ancestor.abs-x,
bh => $ancestor.rows, bw => $ancestor.cols,
:$clip-top, :$clip-left,
);
$cur-y = $eb.abs-y;
$cur-x = $eb.abs-x;
$cur-h = $eb.rows;
$cur-w = $eb.cols;
$clip-top = $eb.clip-top;
$clip-left = $eb.clip-left;
$ancestor = $ancestor.parent;
}
}
# Final intersection against the terminal viewport. Subsumes the
# old SprixelManager.is-off-viewport check.
my ($vp-rows, $vp-cols) = terminal-viewport();
intersect-rect(
ay => $cur-y, ax => $cur-x, ah => $cur-h, aw => $cur-w,
by => 0, bx => 0,
bh => $vp-rows.UInt, bw => $vp-cols.UInt,
:$clip-top, :$clip-left,
);
}
#|( Called by parent layouts during layout. Propagates absolute screen
position and visible bounds to this widget. You don't call this
yourself unless you're implementing a layout container.
Marks the widget dirty when its absolute position changes. Most
widgets render position-independent cells, so this is redundant for
them — but Image needs it: notcurses sprixels don't follow plane
moves, and Image's blit-plane teardown only happens inside its
C<render>. If a parent shifts a card around (CardList scroll, screen
layout reflow) without independently dirtying the subtree, the
Image's render won't fire and the sprixel ghosts at the old screen
coordinates. Marking dirty here ensures the next pass re-runs every
affected widget; Image's cache check then short-circuits the
re-blit when its own state didn't change. )
method set-viewport(:$abs-y! is raw, :$abs-x! is raw, UInt :$rows!, UInt :$cols!) {
# Final-stop CATCH for spesh corruption that even safe-coord's
# NQP gate couldn't ride through. A frame-level skip is the
# right call — the parent layout re-passes coords every frame,
# so the next render gets fresh values. The alternative (letting
# the throw escape into the render loop) trips the App.run outer
# CATCH and kills the whole TUI, which is much worse than a
# one-frame stale position.
CATCH {
default {
emit-diagnostic("Selkie::Widget.set-viewport: corruption escaped gate ({.message}); skipping frame");
return;
}
}
# Defensive gate against MoarVM spesh corruption on the renderer's
# hot path. Three layers, all load-bearing:
#
# 0. The method's own CATCH (above) — final-stop frame-skip when
# corruption escapes layers 1 + 2. The named params are bound
# `is raw` so the slow-path binder doesn't try to type-check
# against Int (which would call `get_boxed_ref` on a corrupt
# Scalar BEFORE the body's CATCH could see it).
#
# 1. `position-changed` — boxed-Int comparison delegated to a free
# sub. An inlined `!=` here triggered a spesh mis-specialisation
# on this method's signature line once the render loop warmed
# spesh statistics; the fresh-frame trick clears it. Boxed Int
# (not native int) means the comparison falls back to bigint
# dispatch at any size, so the read side rides through whatever
# upstream corruption produced.
#
# 2. `safe-coord` — bounds-via-bigint-width gate on the *store*
# side. The previous `$!abs-y = $abs-y` direct assignment
# crashed with "P6opaque: get_boxed_ref could not unbox for the
# representation 'P6bigint' of type Scalar" when an upstream
# spesh mis-specialisation (e.g. App::Mindmoor's old
# View::TaskRow memoization, see its revert note for the "3274
# bit wide bigint" symptom) put a multi-thousand-bit value in
# the slot. The boxed comparison rode through fine but
# Scalar.STORE's inlined unbox didn't. `safe-coord` takes its
# argument as `\val` (capture binding — no type check, no
# decont at the parameter boundary) and runs the width check
# via `nqp::isbig_I` inside its own CATCH, returning a
# freshly-reboxed clean Int when the value fits in int64,
# Nil when it doesn't or when the value is too corrupt to
# introspect.
#
# When safe-coord rejects either coord, we emit an opt-in
# diagnostic and skip the update. See docs/Selkie/
# moarvm-spesh-workaround.md in the workspace cabinet for the
# evidence trail and upstream-repro checklist.
my $safe-y = safe-coord($abs-y);
my $safe-x = safe-coord($abs-x);
unless $safe-y.defined && $safe-x.defined {
emit-diagnostic(self!corruption-note($abs-y, $abs-x));
return;
}
my $moved = position-changed($safe-y, $safe-x, $!abs-y, $!abs-x);
$!abs-y = $safe-y;
$!abs-x = $safe-x;
$!viewport-rows = $rows;
$!viewport-cols = $cols;
self.mark-dirty if $moved;
}
# Boxed-Int comparison helper, kept as a free sub so spesh sees a
# clean specialisation target. See `set-viewport` for the rationale
# (free sub + boxed Int — both load-bearing).
sub position-changed(Int $new-y, Int $new-x, Int $old-y, Int $old-x --> Bool) {
$new-y != $old-y || $new-x != $old-x;
}
# Diagnostic emitted to STDERR when safe-coord rejects a coord. Goal
# is to capture enough state on each rare in-the-wild occurrence that
# a width-distribution + cascade pattern emerges over a handful of
# events, distinguishing among:
#
# - single multiplicative event (always-the-same width across
# occurrences) → look for one site producing the value
# - per-frame accumulator (growing width across frames within a
# session) → look for a `*=` or `**` in a frame-driven path
# - cascade (corrupt parent abs-y propagates via `parent-abs-y +
# cy` in VBox.layout-children) → corruption entered upstream;
# parent's bit width also non-zero confirms it
#
# We capture: widget class + id (which widget hit the gate); both
# coord widths (was abs-x corrupt simultaneously, or just abs-y as in
# the original report?); parent class + parent's coord widths if
# present (cascade detection). All extracted via nqp ops or simple
# attribute reads — no method dispatch on the corrupt value, no
# Backtrace allocation that could itself crash on a fragile frame.
method !corruption-note(Int $abs-y, Int $abs-x --> Str) {
my $y-bits = coord-bit-width($abs-y);
my $x-bits = coord-bit-width($abs-x);
my $widget = "self={self.^name} id={$!widget-id}";
my $coords = "y-bits=$y-bits x-bits=$x-bits";
my $parent-part = "";
if $!parent.defined {
my $py = coord-bit-width($!parent.abs-y);
my $px = coord-bit-width($!parent.abs-x);
$parent-part = " parent={$!parent.^name}"
~ " parent-y-bits=$py parent-x-bits=$px";
}
"Selkie::Widget.set-viewport: skipping corrupt coord; will retry "
~ "next frame [$widget $coords$parent-part]";
}
# Bounds-via-bigint-width gate. Returns a freshly-reboxed clean Int
# when the value fits in int64 (so the subsequent attribute STORE in
# `set-viewport` operates on a value spesh hasn't mis-specialised),
# or Nil when the bigint exceeds int64 OR when the value is too
# corrupt to even introspect — both are upstream-corruption signals;
# screen coordinates can't legitimately need >int64 storage.
#
# IMPORTANT: the parameter is a capture (\val) rather than `Int $n`
# because Rakudo's slow-path positional binder type-checks against
# Int by calling `get_boxed_ref` on the caller's Scalar — and on a
# spesh-corrupt P6bigint slot that exact op throws
# "P6opaque: get_boxed_ref could not unbox for the representation
# 'P6bigint' of type Scalar" BEFORE the sub body runs. Capture
# binding aliases the variable without any decont / type-check, so
# we can do those steps manually inside a CATCH.
sub safe-coord(\val) {
return Nil unless val.defined;
CATCH {
# Decont / istype / isbig_I touching a wide-enough corrupt
# P6bigint can crash at the MoarVM repr level. Treat that as
# "too corrupt to use" and tell the caller to skip the frame.
default { return Nil }
}
my $d := nqp::decont(val);
return Nil unless nqp::istype($d, Int);
nqp::isbig_I($d)
?? Nil
!! nqp::box_i(nqp::unbox_i($d), Int);
}
# Bit width of a corruption-class bigint, or 0 if the value fits in
# int64 (i.e. is not a bigint at all). Goes through nqp::base_I +
# nqp::chars to avoid any boxed-Int dispatch — calling `.base(2)` or
# `.Str` on a corrupt Int could trip the same Scalar.STORE inlined
# unbox that `safe-coord` is gating against. Used by the diagnostic
# note in `set-viewport` to capture the width sequence over multiple
# occurrences — the data point that distinguishes a single
# multiplicative event (constant width) from a per-frame accumulator
# (growing width) when chasing the upstream MoarVM spesh bug.
sub coord-bit-width(Int $n --> Int) {
my $d := nqp::decont($n);
return 0 unless nqp::isbig_I($d);
nqp::chars(nqp::base_I($d, 2));
}
sub emit-diagnostic(Str:D $line --> Nil) {
my $path = %*ENV<SELKIE_DIAGNOSTIC_LOG> // %*ENV<SELKIE_WARN_LOG>;
if $path.defined && $path.Str.trim.chars {
try spurt $path.Str, "$line\n", :append;
return;
}
my $stderr = %*ENV<SELKIE_DIAGNOSTICS_STDERR>;
note $line if $stderr.defined && $stderr.Str.trim ne '' && $stderr ne '0';
}
#|( The effective theme for this widget. Walks up the parent chain until
it finds a widget with an explicit theme, falling back to
C<Selkie::Theme.default>. Use this in C<render> rather than caching
a theme reference, so theme changes propagate correctly. )
method theme(--> Selkie::Theme) {
$!theme // ($!parent andthen .theme) // Selkie::Theme.default;
}
#|( Override the theme for this widget and its subtree. Repaints this
widget's plane base, marks it dirty, then recurses into C<.children>
and C<.content> so every descendant's plane base is repainted too.
The recursion matters: C<ncplane_erase> on a child plane fills with
that child's base cell, which was set the first time C<set-theme>
or C<set-store> ran on it. Without the cascade here, only the root
on which the caller invoked C<set-theme> would repaint, and any
cell a descendant didn't explicitly write would keep showing the
OLD theme background — the visible symptom is "I changed theme
and the tab bar / hint footer kept the old colour".
Equivalent shape to C<set-store> just below — same C<self.can>
detection so containers (children) and decorators (content) are
both reached without coupling C<Widget> to either role. )
method set-theme(Selkie::Theme $t) {
$!theme = $t;
self!sync-plane-base;
self.mark-dirty;
if self.can('children') {
for self.children -> $child {
$child.set-theme($t);
}
}
if self.can('content') {
my $c = self.content;
$c.set-theme($t) if $c.defined;
}
}
#|( The style painted onto this widget's plane base cell — the colours
(and alpha modes) that C<ncplane_erase> and every cell the widget
never writes will carry.
Defaults to the active theme's C<base> slot, which is what makes an
unwritten region show the theme's background instead of the
terminal's own default. Override to opt a widget out of that:
returning a style with C<AlphaTransparent> or C<AlphaBlend>
channels turns the widget's whole footprint into an overlay that
the planes beneath show through, which is how a scrim is built.
# A pane that tints whatever is behind it instead of covering it.
method base-style(--> Selkie::Style) {
Selkie::Style.new(
fg => 0x000000, bg => 0x000000,
fg-alpha => AlphaBlend, bg-alpha => AlphaBlend,
);
}
Only the colours and alpha modes are used. The base cell's style
mask stays 0: bold / italic / underline on a base style are ignored,
because a base cell has no glyph of its own to decorate. )
method base-style(--> Selkie::Style) {
self.theme.base;
}
#|( The EGC (extended grapheme cluster) primed into this widget's plane
base cell. Defaults to a single space, which is what makes the base
cell paint its background across the widget's whole footprint.
Return the empty string to give the base cell no glyph at all. That
stores gcluster 0, the sentinel notcurses's glyph search reads as
"this cell contributes nothing — keep looking further down the
pile", so text on planes underneath stays legible through this one.
Pair it with a transparent or blended C<base-style> for a
see-through overlay; on its own (opaque channels, no glyph) it
still paints this widget's background over everything below.
method base-egc(--> Str) { '' } # glyphs below show through
Keep it to a single column. The base cell is stamped into every
unwritten cell, so a double-width glyph misaligns the entire
plane. )
method base-egc(--> Str) {
' ';
}
#|( The packed 64-bit notcurses C<channels> word this widget's base
cell is primed with, built from C<base-style>. Pure — no plane
needed, nothing written — so it's the thing to assert against when
you want to know what a custom C<base-style> will actually produce.
Colours and alpha share the word. C<ncchannel_set> preserves the two
alpha bits and C<ncchannel_set_alpha> preserves the RGB bits, so the
writes compose in either order; the opaque case is skipped entirely
because a zeroed channel already reads as C<NCALPHA_OPAQUE>, which
keeps the produced word bit-identical to what Selkie emitted before
alpha existed. An undefined C<fg> or C<bg> leaves that channel's
"use the default colour" bit clear, exactly as before.
The packing itself lives on C<Selkie::Style.channels>, so every
place Selkie primes a base cell — here, C<Modal>'s scrim, the
greyed base C<apply-disabled-effect> writes — produces the word the
same way.
Returns 0 if C<base-style> is undefined. )
method base-channels(--> UInt) {
my $base = self.base-style;
return 0 without $base;
$base.channels;
}
#|( Paint this widget's plane base cell from C<base-style> and
C<base-egc> so C<ncplane_erase> and any cell the widget doesn't
explicitly write will carry the theme's background / foreground
rather than notcurses's default-empty state (which renders as
the terminal's own default). Safe to call repeatedly and before
the plane or theme are ready — no-op in those cases. )
method !sync-plane-base() {
return without $!plane;
return without self.base-style;
my $egc = self.base-egc;
ncplane_set_base($!plane, $egc // ' ', 0, self.base-channels);
}
#|( Re-stamp this widget's plane base cell from C<base-style> /
C<base-egc>, and every descendant's too.
The base cell is sticky: C<ncplane_erase> preserves it, so a base
that was written once with the wrong colours keeps re-filling every
unwritten cell with them no matter how many times the widget
re-renders. Anything that changes a base cell behind the framework's
back therefore needs a way to put it back, and this is it —
C<set-disabled(False)> uses it to undo the greyed bases
C<apply-disabled-effect> stamped.
Same C<self.can> cascade shape as C<set-theme>, so containers
(children) and decorators (content) are both reached. Does not mark
anything dirty on its own: pair it with C<mark-dirty-tree> when you
want the new base to actually reach the screen this frame. )
method refresh-plane-base(--> Nil) {
self!sync-plane-base;
if self.can('children') {
for self.children -> $child {
$child.refresh-plane-base;
}
}
if self.can('content') {
my $c = self.content;
$c.refresh-plane-base if $c.defined;
}
}
#|( Enable or disable this widget and everything under it. Disabled
widgets render greyed out and drop out of the Tab cycle, mouse
focus, and C<Selkie::App.focus>.
Both directions force a full subtree repaint, for different
reasons, and both are load-bearing:
=item B<Disabling> has to reach a render pass, because the greyscale post-pass is hooked to the framework's render entry points. A widget that is already clean would otherwise sit there in full colour until something else happened to dirty it.
=item B<Re-enabling> has to repaint because greying is destructive. Cells are converted in place — there is no original to restore — so the only way back to the real colours is to draw them again. The base cells are put back first (C<refresh-plane-base>), then the tree is marked dirty so every widget redraws over its own greyed cells.
Cheap in the only sense that matters: this is a state change a user
triggers, not per-frame work. Enabling and disabling on every frame
would repaint the subtree on every frame.
No-op when the state is already what you asked for, so calling it
unconditionally from a subscription is fine. )
method set-disabled(Bool $disabled --> Nil) {
my Bool $new = ?$disabled;
return if $new === $!disabled;
$!disabled = $new;
self.refresh-plane-base unless $new;
self.mark-dirty-tree;
}
#|( Grey this widget's plane and every descendant's, in place.
Called by the framework immediately after a disabled widget renders
— you do not normally call it yourself; C<set-disabled(True)> is the
public door. Override it if a widget needs a different disabled
treatment (or none at all, for content that must stay legible).
Two halves, because notcurses splits a plane's cells two ways:
=item The B<framebuffer> — every cell the widget actually wrote — is greyed cell by cell (see C<greyscale-plane>).
=item The B<base cell> is not in the framebuffer, so it is re-stamped from C<base-style.greyscale>. That is what greys everything the widget left unwritten, which on a typical pane is most of it: an unwritten cell has no glyph, and notcurses substitutes the base cell for it outright.
Both halves use the same Rec. 601 conversion — see
C<Selkie::Style.greyscale-rgb> — so a re-stamped base and a greyed
cell agree exactly on any given colour.
No plane, no-op: safe to call on a widget that has never been
mounted. The recursion into children and content runs regardless,
since a plane-less grouping node can still own mounted children. )
method apply-disabled-effect(--> Nil) {
if $!plane {
self.greyscale-plane;
my $base = self.base-style;
if $base.defined {
my $egc = self.base-egc;
ncplane_set_base($!plane, $egc // ' ', 0, $base.greyscale.channels);
}
}
# Recurse regardless of our own plane: a widget can legitimately be
# plane-less (never mounted, or a pure grouping node) while its
# children are mounted and visible.
if self.can('children') {
for self.children -> $child {
$child.apply-disabled-effect;
}
}
if self.can('content') {
my $c = self.content;
$c.apply-disabled-effect if $c.defined;
}
}
#|( Grey this widget's own plane's framebuffer, cell by cell. Does not
recurse and does not touch the base cell — C<apply-disabled-effect>
is the entry point that does both. No-op without a plane.
B<Why not C<ncplane_greyscale>?> notcurses ships exactly this
operation as a single native call, and it is the wrong one. It
converts every cell unconditionally, and a channel marked "use the
default colour" reads back as C<0, 0, 0> — so it writes that cell
out as an B<explicit black>, permanently. Selkie leans hard on
default channels: several theme slots (C<text>, C<text-dim>,
C<text-highlight>) set only a foreground, and every unwritten cell
is default in both. Flattening them to black is not a greyed
widget, it is a black rectangle, and it also defeats the base-cell
substitution this method's caller relies on. Verified against
notcurses directly, not inferred.
So the walk is done here instead. Per cell: read the channels
(C<ncplane_at_yx_cell>, no allocation), run them through
C<Selkie::Style.greyscale-channels>, and write the result back with
a 1x1 C<ncplane_stain>. C<stain> is the right write because it
touches channels only — no glyph is rewritten, so double-width
glyphs survive and the EGC pool doesn't churn — and it skips cells
with no glyph, which is exactly the set that should keep falling
through to the (already greyed) base cell.
A cell whose greyed channels equal its current ones — anything
already achromatic, and every cell that is nothing but defaults —
skips the write, so the common case is one native call per cell and
no mutation at all.
B<Cost.> This is O(rows x cols) native calls, against
C<ncplane_greyscale>'s one. It runs only for disabled widgets, and
only on frames where they actually re-rendered, so an app with
nothing disabled pays nothing; but disabling a full-screen root is
a few thousand FFI crossings per repaint. Disable the panel, not
the screen. )
method greyscale-plane(--> Nil) {
return without $!plane;
return unless $!rows && $!cols;
# One cell struct reused for the whole walk: ncplane_at_yx_cell
# duplicates into it, releasing whatever it held from the previous
# iteration, so there is nothing to clean up between cells.
my $cell = Nccell.new;
for ^$!rows -> $y {
for ^$!cols -> $x {
next if ncplane_at_yx_cell($!plane, $y, $x, $cell) < 0;
my UInt $channels = $cell.channels;
my UInt $grey = Selkie::Style.greyscale-channels($channels);
next if $grey == $channels;
ncplane_stain($!plane, $y, $x, 1, 1, $grey, $grey, $grey, $grey);
}
}
}
# --- Private helpers (accessible to composed roles/classes) ---
method !apply-resize(UInt $rows, UInt $cols) {
ncplane_resize_simple($!plane, $rows, $cols) if $!plane;
$!rows = $rows;
$!cols = $cols;
}
method !destroy-plane() {
# Unsubscribe from the store before tearing the plane down. The
# subscription Hash holds widget references; without this step, a
# destroyed-but-subscribed widget can never be GC'd (the subs pin
# it alive) and its DESTROY/destroy chain never fires, leaking
# notcurses planes on every modal cycle / screen swap / dynamic
# widget rebuild. Doing this from destroy-plane (not destroy)
# catches subclass overrides that bypass the role's destroy — the
# convention is that every subclass's destroy ends with
# self!destroy-plane, so this is the universal chokepoint.
# Idempotent: store.unsubscribe-widget is a no-op on widgets with
# no subs (e.g. when called twice during a remove → destroy path).
.unsubscribe-widget(self) with self.store;
if $!plane && $!owns-plane {
ncplane_destroy($!plane);
}
$!plane = NcplaneHandle;
}
#|( Replace the widget's sizing constraint after construction. The
parent layout picks up the new value on its next reflow. Useful
for conditional UI — a form field that should disappear under
one mode can be set to C<Sizing.fixed(0)> to collapse out of the
flow without removing it from the widget tree. Subclasses with
height-driven content (e.g. C<MultiLineInput> growing as the user
types) call this from inside their own re-measure logic. )
method set-sizing(Sizing $s) {
$!sizing = $s;
}
# --- Store integration ---
#| The L<Selkie::Store> attached to this widget, or C<Nil> if no store
#| has been set yet. Propagates automatically from parent to child.
method store() { $!store }
#|( Attach a store to this widget. Called automatically by parent
containers when a widget is added to the tree and a store exists.
Recursively propagates to children and Border/Modal content. Fires
C<on-store-attached> on the widget if implemented. You shouldn't
need to call this directly — just add the widget to a tree that
has a store. )
method set-store($store) {
my $old-store = $!store;
if $old-store.defined && (!$store.defined || $old-store !=== $store) {
$old-store.unsubscribe-widget(self);
$!subscribed-ids = SetHash.new;
}
$!store = $store;
if self.can('children') {
for self.children -> $child {
$child.set-store($store);
}
}
if self.can('content') {
my $c = self.content;
$c.set-store($store) if $c.defined;
}
# Once the store (and with it the ancestral theme chain) is attached,
# re-paint the plane base so themes that only became resolvable at
# this point take effect. Runs once per set-store, not per render.
self!sync-plane-base;
self.on-store-attached($store) if self.can('on-store-attached');
}
#|( Drop every store subscription held by this widget B<and its entire
subtree>, without destroying anything. Call it immediately before
tearing a subtree down when the teardown isn't already routed
through C<Selkie::Container.remove> / C<.clear> — most notably
C<Selkie::ScreenManager.remove-screen>, which destroys a whole
screen root.
The walk reaches children through the same duck-typed C<children> /
C<content> pair C<set-store> uses, deliberately B<not> a
C<~~ Selkie::Container> test: C<Selkie::Widget::CardList> and
C<ViewportedCardList> expose their cards through a C<children>
override without composing C<Container>, and Border / Modal hold
their child under C<content>. Missing any of those is the classic
shape of this bug — subscriptions surviving on widgets whose planes
are already freed, so the next store tick renders into a dead
plane.
C<$fallback-store> is used only for widgets with no store of their
own, and is passed down the walk (so a subtree inherits the nearest
ancestor's store). It covers the case where a subtree was built and
subscribed against an app store that never got propagated into it,
which would otherwise leave those subscriptions behind.
Idempotent, and safe to call from inside a subscription callback —
C<Selkie::Store.unsubscribe-widget> defers during a walk. )
method unsubscribe-subtree($fallback-store = Nil --> Nil) {
my $s = $!store // $fallback-store;
.unsubscribe-widget(self) with $s;
if self.can('children') {
for self.children -> $child {
$child.unsubscribe-subtree($s) if $child.defined;
}
}
if self.can('content') {
my $c = self.content;
$c.unsubscribe-subtree($s) if $c.defined;
}
}
#|( Convenience for dispatching a store event. Equivalent to
C<self.store.dispatch($event, |%payload)> but gracefully no-ops if
no store is attached. Most widgets shouldn't dispatch directly —
prefer emitting on a Supply and letting app code dispatch. )
method dispatch(Str:D $event, *%payload) {
$!store.dispatch($event, |%payload) if $!store;
}
#|( Subscribe this widget to a path in the store. When the value at
that path changes, the widget is marked dirty and re-renders. See
L<Selkie::Store> for details. Typically called from
C<on-store-attached>. )
method subscribe(Str:D $id, *@path) {
$!store.subscribe($id, @path, self) if $!store;
}
#|( Subscribe to a computed value derived from the store. The compute
function receives the store and should return the value; the widget
is marked dirty whenever that value changes. See L<Selkie::Store>. )
method subscribe-computed(Str:D $id, &compute) {
$!store.subscribe-computed($id, &compute, self) if $!store;
}
has SetHash $!subscribed-ids = SetHash.new;
#|( Idempotent version of C<subscribe>. Tracks per-id registration for
the currently attached store so repeated same-store C<set-store> calls
don't create duplicate subscriptions. Prefer this over C<subscribe>
when registering from C<on-store-attached>. )
method once-subscribe(Str:D $id, *@path) {
return unless $!store;
return if $!subscribed-ids{$id};
$!subscribed-ids{$id} = True;
self.subscribe($id, |@path);
}
#| Idempotent version of C<subscribe-computed>. See C<once-subscribe>.
method once-subscribe-computed(Str:D $id, &compute) {
return unless $!store;
return if $!subscribed-ids{$id};
$!subscribed-ids{$id} = True;
self.subscribe-computed($id, &compute);
}
#|( Update the widget's sizing declaration at runtime and request a
re-layout. Use this when a widget's desired size changes — for
example, a MultiLineInput growing as the user types more lines. )
method update-sizing(Sizing $s) {
$!sizing = $s;
self.mark-dirty;
$!parent.mark-dirty if $!parent.defined;
}
#|( Update the widget's B<cross>-axis sizing declaration at runtime and
request a re-layout — the C<cross-sizing> counterpart of
C<update-sizing>.
Pass an undefined C<Sizing> to go back to filling the container's
cross axis:
$card.update-cross-sizing(Sizing.fixed(40)); # 40 columns in a VBox
$card.update-cross-sizing(Sizing); # back to full width
)
method update-cross-sizing(Sizing $s) {
$!cross-sizing = $s;
self.mark-dirty;
$!parent.mark-dirty if $!parent.defined;
}
#|( Override the container's C<align-items> for this widget alone, and
request a re-layout. Pass an undefined C<CrossAlign> to go back to
inheriting:
$badge.set-align-self(CrossEnd); # bottom of an HBox row
$badge.set-align-self(CrossAlign); # whatever the row says
)
method set-align-self(CrossAlign $a) {
return if $a === $!align-self;
$!align-self = $a;
self.mark-dirty;
$!parent.mark-dirty if $!parent.defined;
}
# --- Framework-internal methods (public for cross-widget access) ---
#|( Create and take ownership of a notcurses plane, sized and positioned
as specified. Called by parent layouts when they mount this widget.
Override-safe: layout containers call this, leaf widgets never do. )
method init-plane(NcplaneHandle $parent-plane, UInt :$y = 0, UInt :$x = 0,
UInt :$rows = 1, UInt :$cols = 1) {
my $opts = NcplaneOptions.new(:$y, :$x, :$rows, :$cols);
$!plane = ncplane_create($parent-plane, $opts);
die "Failed to create plane" without $!plane;
$!rows = $rows;
$!cols = $cols;
$!y = $y;
$!x = $x;
$!parked = $y >= self.park-y;
# A plane straight out of ncplane_create has zeroed channels, which
# is NCALPHA_OPAQUE on both. Recording that (rather than "unknown")
# is what keeps the first apply-style of an ordinary opaque widget
# free of any alpha FFI at all.
$!applied-fg-alpha = AlphaOpaque;
$!applied-bg-alpha = AlphaOpaque;
# Paint the theme background onto this plane's base cell so
# erase / unwritten regions carry the theme colour rather than
# falling through to "terminal default" (which visibly breaks
# themed backgrounds).
self!sync-plane-base;
}
#|( Borrow an existing plane (owned elsewhere) as this widget's plane.
Used by C<Selkie::App> to adopt the notcurses stdplane as the root
screen's plane. This widget will not destroy the plane on cleanup.
Rarely used outside the framework itself. )
method adopt-plane(NcplaneHandle $plane, UInt :$rows, UInt :$cols) {
$!plane = $plane;
$!owns-plane = False;
$!rows = $rows;
$!cols = $cols;
$!y = 0;
$!x = 0;
$!parked = False;
# Unlike init-plane, an adopted plane has a history: whoever owns it
# may have left either channel non-opaque. Drop to "unknown" so the
# first apply-style states the alpha explicitly instead of trusting
# an assumption about someone else's plane.
self.reset-style-cache;
}
#|( Mark this widget dirty so it re-renders on the next frame. Also
propagates dirty upwards to the parent chain so the render walk
reaches it. Cheap — short-circuits if already dirty. Call this
whenever your widget's visual state changes. )
method mark-dirty() {
return if $!dirty;
$!dirty = True;
$!parent.mark-dirty if $!parent.defined;
}
#|( Recursively mark this widget and every descendant dirty. Use
when a state change has layout implications that the default
up-propagating C<mark-dirty> can't fully express — for example,
a widget resizing itself causes every sibling's allocation to
shift, and you want every descendant (not just the ancestors)
to re-render fresh on the next frame.
Pairs with L<mark-screen-dirty> for the common "start from the
root of the attached tree" case. )
method mark-dirty-tree() {
self.mark-dirty;
# Use duck-typing instead of a type-check against Selkie::Container
# — Container `does Selkie::Widget`, so a `use` here would be a
# circular dependency. `can('children')` is the same condition
# Selkie::App uses internally for its mark-all-dirty helper.
if self.can('children') {
for self.children -> $child {
$child.mark-dirty-tree;
}
}
if self.can('content') {
my $c = self.content;
$c.mark-dirty-tree if $c.defined;
}
}
#|( Walk up to the root of the attached tree and flag the whole
screen for a full render pass (via C<mark-dirty-tree>). Use
when a local state change should invalidate every widget's
layout — typically a dynamically-sized widget whose height or
width just changed in a way that shifts its siblings'
allocations.
Cheap for rare events (rare meaning: not per-keystroke). For
high-frequency triggers, prefer the default C<mark-dirty>
propagation and let each render walk figure out what actually
needs redrawing. )
method mark-screen-dirty() {
my $root = self;
while $root.parent.defined {
$root = $root.parent;
}
$root.mark-dirty-tree;
}
#|( Clear the dirty flag. Call this as the last line of your C<render>
method so the widget is skipped on subsequent frames until something
changes. )
method clear-dirty() {
$!dirty = False;
}
#|( Apply a C<Selkie::Style> (fg, bg, bold/italic/underline, alpha) to
the widget's plane so subsequent C<ncplane_putstr_yx> calls pick up
those attributes. Handles the distinct notcurses calls (styles, fg,
bg, and the two alpha channels) in one shot.
Alpha is applied through C<sync-plane-alpha>, which skips the FFI
entirely when the plane already carries the requested modes — the
all-opaque case, which is every widget that never mentions alpha,
costs two identity comparisons and no native calls. )
method apply-style(Selkie::Style $style) {
ncplane_set_styles($!plane, $style.styles);
ncplane_set_fg_rgb($!plane, $style.fg) if $style.fg.defined;
ncplane_set_bg_rgb($!plane, $style.bg) if $style.bg.defined;
self.sync-plane-alpha($style);
}
#|( Push C<$style>'s alpha modes onto the plane, skipping any channel
that is already in the requested state.
C<apply-style> calls this; you only need it directly if you're
driving the plane's channels yourself and want the same caching.
Note that the cache tracks what B<Selkie> put on the plane — if you
call C<ncplane_set_fg_alpha> behind the framework's back, call
C<reset-style-cache> afterwards. )
method sync-plane-alpha(Selkie::Style $style --> Nil) {
my $fg-alpha = $style.effective-fg-alpha;
my $bg-alpha = $style.effective-bg-alpha;
# The hot path is both channels already in the requested state:
# two identity comparisons and out, no allocation, no native call.
return if $fg-alpha === $!applied-fg-alpha && $bg-alpha === $!applied-bg-alpha;
self.apply-plane-alpha(
$fg-alpha === $!applied-fg-alpha ?? AlphaMode !! $fg-alpha,
$bg-alpha === $!applied-bg-alpha ?? AlphaMode !! $bg-alpha,
);
$!applied-fg-alpha = $fg-alpha;
$!applied-bg-alpha = $bg-alpha;
Nil
}
#|( The one place Selkie hands an alpha mode to notcurses. An undefined
argument means "leave that channel alone", which is what
C<sync-plane-alpha> passes for a channel whose mode hasn't changed.
Separated out as the single FFI seam so the caching above it can be
tested without a live plane, and so a widget with an unusual plane
arrangement (a second blit plane, say) can redirect the writes. )
method apply-plane-alpha(AlphaMode $fg-alpha, AlphaMode $bg-alpha --> Nil) {
ncplane_set_fg_alpha($!plane, alpha-constant($fg-alpha)) if $fg-alpha.defined;
ncplane_set_bg_alpha($!plane, alpha-constant($bg-alpha)) if $bg-alpha.defined;
Nil
}
#|( Forget what C<apply-style> believes the plane's alpha state to be,
so the next C<apply-style> pushes both channels unconditionally.
The escape hatch for anything that changes the plane's channels
outside the framework — a raw C<ncplane_set_bg_alpha> call, a plane
swapped in from elsewhere, a C<ncplane_erase_region> variant that
resets state. Cheap and always safe: the worst case is two
redundant native calls on the next render. )
method reset-style-cache(--> Nil) {
$!applied-fg-alpha = AlphaMode;
$!applied-bg-alpha = AlphaMode;
Nil
}
# --- Public API ---
#|( Resize the widget's plane to new dimensions. No-ops if the size is
unchanged. Called by parent layouts — you shouldn't call this
directly from a leaf widget. )
method resize(UInt $rows, UInt $cols) {
return if $rows == $!rows && $cols == $!cols;
self!apply-resize($rows, $cols);
self!on-resize;
self.mark-dirty;
}
#|( The framework's explicit terminal-resize protocol. Called when the
terminal is resized; cascades through containers so every widget
learns its new dimensions before the next render. Short-circuits
when dims are unchanged — safe to call redundantly.
Default implementation just delegates to C<resize()>; that's enough
for leaf widgets. Containers override to cascade to their own
children/content.
Prefer this over C<resize()> when propagating a resize event from
outside the layout pass. The built-in containers call
C<handle-resize> on children from their C<layout-children> pass
so the C<on-resize> hook fires for any widget whose dims actually
changed.
B<Custom containers> should override to cascade to their own
children/content. If you hold child widgets in something other
than C<self.children> (e.g. C<CardList>'s item hashes, C<Border>'s
C<content>), your override is the only way the cascade reaches
them. )
method handle-resize(UInt $rows, UInt $cols) {
self.resize($rows, $cols);
}
#|( Optional hook called from C<handle-resize> when dimensions actually
changed. Use for widget-specific bookkeeping that must update at
the moment of resize rather than on the next render — recomputing
cached wrap tables, invalidating pre-rendered buffers, resetting
scroll offsets that no longer make sense, etc.
Default is a no-op. Called after the plane has been resized and
C<mark-dirty> has fired. )
method !on-resize() { }
#|( Move the widget's plane to new coordinates (relative to the parent
plane). No-ops if the position is unchanged. Called by parent
layouts. )
method reposition(UInt $y, UInt $x) {
return if $y == $!y && $x == $!x;
ncplane_move_yx($!plane, $y, $x) if $!plane;
$!y = $y;
$!x = $x;
# Single chokepoint for the park latch: `park` routes through here
# (reposition(park-y, 0)) and so does every unpark — containers
# reposition a child back into flow before they set-viewport it.
# Kept inside the moved-for-real branch so an unchanged position
# costs nothing extra; that is safe precisely because `park` never
# short-circuits (it always requests park-y, which differs from any
# in-flow y).
$!parked = $y >= self.park-y;
}
#|( Render this widget to its plane. B<Required override>: the composing
class must provide a body. Always guard with C<return without self.plane>,
and call C<self.clear-dirty> at the end. )
method render() { ... }
#|( Park the widget off-screen — used by container swap operations
(e.g. C<Border.set-content(:!destroy)>) when an outgoing widget
needs to keep its state but stop appearing on the terminal.
Default implementation repositions the widget's plane to a
far-off Y so notcurses clips it. B<Override in widgets that own
other notcurses resources whose visibility doesn't follow plane
position> — most importantly Image, where the blit-plane carries
a sprixel (Sixel/Kitty pixel image) that the terminal renders
at an absolute on-screen position and won't clear just because
the parent moved. Such widgets need to destroy their auxiliary
plane(s) here so the sprixel gets removed from the terminal.
Containers should override to recurse: park self + each
descendant. )
#| Off-screen Y coordinate used by C<park> to clip a widget's plane
#| out of the visible terminal. Notcurses clips planes whose origin
#| falls beyond the rendered bounds, so any sufficiently large value
#| works; we standardise on 10,000 across Container, CardList, and any
#| custom container override so the parked-Y is greppable and
#| predictable in snapshot tests. Implemented as a method (rather than
#| an `our constant`) because Raku doesn't allow `our`-scoped symbols
#| inside a role — the role is parametric, so there's no single package
#| to install the symbol in.
method park-y(--> Int) { 10_000 }
method park() {
self.reposition(self.park-y, 0);
}
#|( Park each widget in C<@kids>. Used by C<Container.park> and
C<Selkie::Widget::CardList.park> — both walk a list of child
widgets calling C<.park> on each, but they store their children
differently (Container in C<@!children>, CardList in C<@!items>),
so the iteration is the only thing they share. Private to the
Widget role; consumers compose Widget so they can call
C<self!park-children(@kids)>. )
method !park-children(@kids --> Nil) {
.park for @kids;
}
#|( Register a keybind for this widget. Fires when the widget has
focus and an unconsumed event matches the spec. See L<Selkie::Event>
for the spec syntax (C<'a'>, C<'ctrl+q'>, C<'shift+tab'>, etc).
Pass C<:description> to surface the bind in
L<Selkie::Widget::HelpOverlay>. Binds without a description still
work — they just don't appear in the help listing.
Example:
$list.on-key: 'd', -> $ { delete-item }, :description('Delete');
$list.on-key: 'ctrl+r', -> $ { refresh }, :description('Refresh');
)
method on-key(Str:D $spec, &handler, Str :$description = '') {
@!keybinds.push: Keybind.parse($spec, &handler, :$description);
}
# --- Mouse handler API ---
#|( Register a click handler. Fires on a mouse button press whose cell
falls within this widget's on-screen rectangle (per C<abs-y>,
C<abs-x>, viewport extents). Default C<button> is 1 (primary). The
handler receives the C<Selkie::Event>; use C<self.local-row($ev)>
and C<self.local-col($ev)> for widget-local coordinates.
Click handlers receive press events only — release is delivered
via C<on-mouse-up> if you need it. The C<click-count> field on the
event distinguishes single (1), double (2), and triple (3) clicks
within the framework's 300 ms window. )
method on-click(&handler, UInt :$button = 1, Str :$description = '') {
@!mouse-handlers.push: MouseHandler.new(
kind => 'click',
:$button,
:&handler,
:$description,
);
}
#|( Register a scroll-wheel handler. Fires on scroll-up
(C<NCKEY_SCROLL_UP>) and scroll-down (C<NCKEY_SCROLL_DOWN>) events
whose cell falls within this widget's on-screen rectangle. The
handler receives the C<Selkie::Event>; check C<$ev.id> for direction. )
method on-scroll(&handler, Str :$description = '') {
@!mouse-handlers.push: MouseHandler.new(
kind => 'scroll',
button => 0,
:&handler,
:$description,
);
}
#|( Register a drag handler. Fires on motion events while the given
button is held — the press that started the drag is delivered to
C<on-click> (or C<on-mouse-down>); subsequent motion-while-held
events come here regardless of whether the cursor has left the
widget's bounds. Release is delivered via C<on-mouse-up> and
automatically clears the drag capture. )
method on-drag(&handler, UInt :$button = 1, Str :$description = '') {
@!mouse-handlers.push: MouseHandler.new(
kind => 'drag',
:$button,
:&handler,
:$description,
);
}
#|( Register a low-level mouse-down handler. Fires on every press,
regardless of button (defaults to 1 — pass C<:button(0)> to listen
on any button). Use this when you need to react to the press itself
rather than the higher-level "click" abstraction. )
method on-mouse-down(&handler, UInt :$button = 1, Str :$description = '') {
@!mouse-handlers.push: MouseHandler.new(
kind => 'mouse-down',
:$button,
:&handler,
:$description,
);
}
#|( Register a mouse-up (release) handler. Fires on every release,
including releases that end a drag (in which case it fires after
the drag capture has already been cleared). Default C<button> is 1. )
method on-mouse-up(&handler, UInt :$button = 1, Str :$description = '') {
@!mouse-handlers.push: MouseHandler.new(
kind => 'mouse-up',
:$button,
:&handler,
:$description,
);
}
#| Read-only access to this widget's registered mouse handlers. Used
#| by the framework's mouse dispatcher.
method mouse-handlers(--> List) { @!mouse-handlers.List }
#|( Translate an absolute-screen mouse event into this widget's local
Y coordinate (0-based, top-down). Returns C<-1> when the event
falls outside the widget's viewport, so callers can guard with a
single check. )
method local-row(Selkie::Event $ev --> Int) {
return -1 unless $ev.event-type ~~ MouseEvent;
my $r = $ev.y - $!abs-y;
my $h = $!viewport-rows || $!rows;
($r >= 0 && $r < $h) ?? $r !! -1;
}
#|( Translate an absolute-screen mouse event into this widget's local
X coordinate. See C<local-row>. )
method local-col(Selkie::Event $ev --> Int) {
return -1 unless $ev.event-type ~~ MouseEvent;
my $c = $ev.x - $!abs-x;
my $w = $!viewport-cols || $!cols;
($c >= 0 && $c < $w) ?? $c !! -1;
}
#|( True iff the given absolute-screen cell falls within this widget's
on-screen rectangle (taking viewport clipping into account). The
framework uses this for mouse hit-testing; widgets rarely need to
call it directly.
A widget with zero viewport dimensions never contains any point —
that's how we filter out unmounted widgets and parked-off-screen
widgets without needing to consult the plane handle. )
method contains-point(Int $y, Int $x --> Bool) {
my $h = $!viewport-rows || $!rows;
my $w = $!viewport-cols || $!cols;
return False if $h <= 0 || $w <= 0;
$y >= $!abs-y && $y < $!abs-y + $h
&& $x >= $!abs-x && $x < $!abs-x + $w;
}
#|( True iff this widget paints an overlay region that extends past
its nominal rect (per C<contains-point>) AND the given cell falls
within that overlay. The framework's mouse dispatcher does an
overlay-pass against the entire tree before the normal
containment walk, so widgets that paint over the layout flow can
still claim clicks the layout-aware walk would miss.
The canonical consumer is L<Selkie::Widget::Select>: an open
dropdown is rendered as a notcurses child plane that paints
over whatever widget sits below the Select in its layout, and
the widget tree doesn't know about that overdraw. By overriding
C<claims-overlay-at>, the Select can capture clicks on the
dropdown rows even though its parent layout's bounds end at the
Select's closed-display row.
Default returns False; overlay widgets opt in. )
method claims-overlay-at(Int $y, Int $x --> Bool) {
False;
}
#|( Internal: dispatch a C<MouseEvent> to any registered handlers
on this widget. Returns True if a handler consumed the event,
False to let it bubble up. The framework calls this from the
default C<handle-event> when the event is a C<MouseEvent>; widgets
that override C<handle-event> with their own mouse switch can
skip this and handle the event raw, or call it explicitly to mix
the registration API with their own logic.
A press event fans out to both C<'click'> and C<'mouse-down'>
handlers in registration order; first to return True consumes.
Release events fire C<'mouse-up'>. Drag motion (buttons held) and
pure motion (when a drag capture is active upstream) fire
C<'drag'>. Scroll wheel fires C<'scroll'>. )
method !dispatch-mouse-handlers(Selkie::Event $ev --> Bool) {
return False unless $ev.event-type ~~ MouseEvent;
my @kinds = mouse-event-kinds($ev);
return False unless @kinds;
my $btn = mouse-event-button($ev);
my %wanted = @kinds.map(* => True);
for @!mouse-handlers -> $h {
next unless %wanted{$h.kind};
next unless $h.button == 0 || $h.button == $btn;
$h.handler.($ev);
return True;
}
False;
}
#| Read-only access to this widget's registered keybinds. Used by
#| HelpOverlay to render a listing for the focused widget chain.
method keybinds(--> List) { @!keybinds.List }
#|( Walk this widget's ancestor chain (self → parent → … → root)
collecting every keybind whose C<:description> is non-empty.
Returns a list of C<{ spec => Str, description => Str }> hashes
in focused-leaf-first order. Identical specs further up the
chain are deduplicated — the most-specific (leaf-closest)
binding wins, mirroring the order in which event dispatch
would actually invoke handlers.
Cycle-safe: if the parent chain loops back on itself (pathological
reparenting bug, but observed in test rigs), the walk stops at
the first repeat.
Useful for any "what shortcuts are reachable from here" UI —
keybind footers, status bars, tooltips, command palettes. The
grouped-by-class shape used by L<Selkie::Widget::HelpOverlay>
is a separate private helper because the overlay wants section
headers per widget class; flat consumers want a flat list. )
method keybind-chain(--> List) {
my @out;
my %seen-spec;
my %seen-widget;
my $w = self;
while $w {
last if %seen-widget{$w.WHICH};
%seen-widget{$w.WHICH} = True;
for $w.keybinds.grep({ .description.chars > 0 }) -> $kb {
next if %seen-spec{$kb.spec};
%seen-spec{$kb.spec} = True;
@out.push: %( spec => $kb.spec, description => $kb.description );
}
$w = $w.parent;
}
@out.List;
}
method !check-keybinds(Selkie::Event $ev --> Bool) {
for @!keybinds -> $kb {
if $kb.matches($ev) {
$kb.handler.($ev);
return True;
}
}
False;
}
#|( Handle a keyboard or mouse event. Return True if the event was
consumed (the event will stop bubbling to the parent); False to let
it continue up the chain. The default implementation routes
C<MouseEvent>s through any handlers registered via C<on-click>,
C<on-scroll>, C<on-drag>, C<on-mouse-down>, C<on-mouse-up>, and
falls through to the keybind table (registered via C<on-key>) for
everything else.
Override to implement cursor movement, character input, or
widget-specific click handling. Overrides typically split the
event into Mouse and non-Mouse branches:
=begin code :lang<raku>
method handle-event(Selkie::Event $ev --> Bool) {
if $ev.event-type ~~ MouseEvent {
return True if self!dispatch-mouse-handlers($ev);
return False;
}
return False unless $!focused;
# ...keyboard handling...
}
=end code
That call to C<self!dispatch-mouse-handlers> reuses the same
registration API the base uses (handlers registered via
C<on-click>, C<on-scroll>, C<on-drag>, C<on-mouse-down>,
C<on-mouse-up>); your override doesn't need to re-implement
mouse-event classification. Override only the keyboard branch.
A note on C<nextsame>: it does B<not> work for delegating Mouse
handling back to this default. C<Selkie::Widget> is a role, and
Raku flattens role methods into the consuming class — so a
subclass override of C<handle-event> shadows the role version
rather than inheriting it as a separate dispatch candidate, and
C<nextsame> from the override has no role-supplied candidate to
fall through to. Always call C<self!dispatch-mouse-handlers($ev)>
explicitly when you need the registration-API behaviour. )
method handle-event(Selkie::Event $ev --> Bool) {
return True if $ev.event-type ~~ MouseEvent
&& self!dispatch-mouse-handlers($ev);
self!check-keybinds($ev);
}
#|( Release any resources held by this widget. The default implementation
destroys the plane. Override if your widget owns extra notcurses
handles (e.g. ncvisual, child planes) or other resources — and
always call C<self.destroy-plane> (or C<self!destroy-plane> if
you're inside the same role/class) as the last step. )
method destroy() {
self!destroy-plane;
}
method DESTROY() {
self.destroy;
}