Selkie.git | docs/api/ | Selkie--Widget.md
NAME
====
Selkie::Widget - Base role composed by every Selkie widget
SYNOPSIS
========
A minimal custom widget that renders a fixed string:
```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;
}
```
Add it to any layout and it just works:
```raku
use Selkie::Sizing;
$vbox.add: My::Hello.new(sizing => Sizing.fixed(1));
```
DESCRIPTION
===========
`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 `Selkie::Widget` directly — it's a role, so you `does Selkie::Widget` on your own class. The framework itself composes it to build every built-in widget (`Text`, `Button`, `ListView`, and so on).
What you get for free
---------------------
* A notcurses plane to render into, created and destroyed for you
* Theme inheritance from the widget tree
* Themed plane-base painting so erase / unwritten cells show the theme background rather than the terminal default — applied on `init-plane`, `set-store`, and `set-theme`, and overridable per widget via `base-style` / `base-egc`
* Keybind registration and event bubbling
* Dirty tracking so your `render` method only runs when needed
* Per-widget subscription to the reactive store
* Clean shutdown when the widget goes out of scope
What you must provide
---------------------
At minimum, a `render` method. That's it.
What you may provide
--------------------
* `handle-event` — to react to keyboard or mouse input when focused
* `on-store-attached` — to wire up subscriptions when the store appears
* `destroy` — to clean up anything beyond the plane (e.g. extra notcurses handles, file descriptors, subscriptions)
How a parent sizes you
----------------------
`sizing` is the size you ask for along the axis your container stacks on — rows in a `VBox`, columns in an `HBox`. Two optional attributes cover the other axis:
* `cross-sizing` — a second [Selkie::Sizing](Selkie--Sizing.md), 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.
* `align-self` — a [Selkie::Align](Selkie--Align.md) `CrossAlign` saying where the resolved extent sits. Undefined inherits the container's `align-items`.
```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,
);
```
Both are inert for a widget whose container isn't a box layout, and both have runtime setters (`update-cross-sizing`, `set-align-self`) that mark the parent dirty so the next frame re-lays out.
LIFECYCLE
=========
Construction happens in normal Raku fashion: `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 `$parent.add($child)`), the parent calls `init-plane` to create a notcurses plane sized to its share of the layout. After this point `self.plane` returns a valid handle, and `self.rows` / `self.cols` reflect the plane's dimensions.
Each frame, the framework walks the tree and calls `render` on any widget whose `is-dirty` is true. Your render method should:
* Erase the plane with `ncplane_erase(self.plane)`
* Apply styles with `self.apply-style($style)`
* Write to the plane with notcurses calls
* Call `self.clear-dirty` at the end
When the widget is removed or the program exits, `destroy` is called and the plane is freed.
OVERRIDE POINTS
===============
The public API is organised into three buckets.
Required override
-----------------
* `render` — draw yourself onto `self.plane`. Must be defined by the composing class.
Optional overrides
------------------
* `handle-event($ev --` Bool)> — return True if you consumed the event
* `destroy` — call `self.destroy-plane` and clean up any extras
* `on-store-attached($store)` — implement this (no inherited default) to register subscriptions
* `base-style` — the style stamped into the plane's base cell (default: the theme's `base` slot)
* `base-egc` — the glyph stamped into the plane's base cell (default: `' '`; `''` lets lower planes show through)
* `apply-disabled-effect` — how this widget greys out when disabled (default: grey the framebuffer and the base cell, then recurse into the subtree)
Do not override
---------------
* `init-plane`, `adopt-plane` — called by layout containers
* `mark-dirty`, `clear-dirty` — called by the render cycle
* `set-viewport` — called by parent layouts
* `reposition` — called by parent layouts; it is also where the `is-parked` latch is maintained
Parking
-------
`park` moves a widget's plane to `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 `park` to recurse; widgets holding native resources whose visibility does not follow plane position (notably [Selkie::Widget::Image](Selkie--Widget--Image.md)'s sprixel) override it to release them.
`is-parked` reports the latch, maintained by `reposition` / `init-plane` / `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 **not** a general visibility test: an unparked widget can still be clipped away by an ancestor. Use `effective-bounds` for that.
Alpha and the plane base
------------------------
`apply-style` pushes a style's `fg-alpha` / `bg-alpha` (see [Selkie::Alpha](Selkie--Alpha.md)) 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 `reset-style-cache` so the framework stops trusting its record.
`base-style` and `base-egc` control the base cell, which is a different thing from the styles you apply while rendering: the base cell is what `ncplane_erase` fills with and what shows anywhere your `render` never writes. Overriding the pair is how a widget becomes see-through — an empty `base-egc` leaves the cell with no glyph, so notcurses's glyph search falls through to the planes beneath it.
Disabled widgets
----------------
`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 `Selkie::App.focus` all skip it. `set-disabled(False)` puts it back.
```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
```
`disabled` is a separate axis from `focusable` on purpose. `focusable` describes the widget (a Button is focusable, a Text isn't) and is fixed for its lifetime; `disabled` is transient state layered on top. Flipping `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, `Border` / `Modal` content, the render loop's root and top-most modal, and `Selkie::Test::Snapshot`) call `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.
### What greying does not reach
* **Images do not grey.** `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.
* **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 `greyscale-plane` for why the obvious alternative is worse), so a widget that draws with the `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.
* **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 `ncplane_set_fg_palindex` yourself.
* **Custom containers that bypass `!render-children`.** The hook lives in the framework's render helpers. A container that loops over `self.children` calling `$child.render` by hand renders disabled children in full colour; use `self!render-children`, or call `$child.apply-disabled-effect if $child.disabled` yourself.
* **Direct mouse events.** `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 `Button` is greyed and unfocusable and will still fire its `on-click` if clicked. Check `self.disabled` at the top of `handle-event` (or of the callback) on any widget where that matters.
### 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 `set-disabled(False)` arranges for you: it re-stamps every base cell in the subtree (`refresh-plane-base`) and then calls `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.
EXAMPLES
========
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.
```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;
}
```
Use it like:
```raku
$vbox.add: My::ColorBar.new(color => 0xFF5555, sizing => Sizing.fixed(1));
```
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 `on-toggle` Supply. This is the canonical leaf-widget pattern in Selkie — widgets emit, app code dispatches to the store.
```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);
}
```
Consuming app code:
```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);
};
```
Example 3 — Registering a custom keybind
----------------------------------------
Widgets can register per-instance keybinds with `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.
```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();
};
```
Keybinds with a modifier (`ctrl+`, `alt+`, `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.
Example 4 — A widget that reacts to store state
-----------------------------------------------
When a widget's appearance depends on shared application state, subscribe to the store from `on-store-attached`. The framework calls this once per `set-store` call, so use `once-subscribe` / `once-subscribe-computed` to avoid duplicate registrations across repeated calls.
```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;
}
```
Subscriptions are released for you on the usual teardown routes: `destroy` unsubscribes the widget (from `!destroy-plane`, the universal chokepoint), and `Selkie::Container.remove` / `.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 `unsubscribe-subtree` on its root before destroying, and subscriptions cannot outlive the planes they would render into.
SEE ALSO
========
* [Selkie::Container](Selkie--Container.md) — for widgets that hold children
* [Selkie::Sizing](Selkie--Sizing.md) — the fixed/percent/flex sizing model
* [Selkie::Align](Selkie--Align.md) — `CrossAlign`, and how `cross-sizing` pairs with it
* [Selkie::Theme](Selkie--Theme.md) and [Selkie::Style](Selkie--Style.md) — styling inherited through the tree
* [Selkie::Event](Selkie--Event.md) — the keyboard/mouse event abstraction
* [Selkie::Store](Selkie--Store.md) — the reactive state store
### has Int $.widget-id
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 Selkie::Widget $.parent
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 `is rw` so internal helpers can reparent.
### has Bool $!parked
Park latch, maintained by `reposition` / `init-plane` / `adopt-plane`: True from the moment the widget is moved to `park-y` or beyond, False again as soon as anything moves it back into the parent's coordinate space. **Not** a second source of truth for position — it's a cheap restatement of "`$!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 `abs-y` deliberately does **not** track parking: parking is a plane move, and the layout pass that would refresh `abs-y` is exactly the pass a parked widget doesn't get. That asymmetry is why a parked card keeps stale on-screen `abs-y` coordinates, and why a rect walk that trusts them alone dirties widgets that own no cells. See `is-parked`.
### has Selkie::Sizing::Sizing $.sizing
How this widget wants to be sized by its parent layout. See [Selkie::Sizing](Selkie--Sizing.md): `Sizing.fixed($n)`, `Sizing.percent($n)`, or `Sizing.flex($n = 1)`. Defaults to `Sizing.flex`.
### has Selkie::Sizing::Sizing $.cross-sizing
How big this widget wants to be on its parent's **cross** axis — columns inside a `VBox`, rows inside an `HBox`. Another [Selkie::Sizing](Selkie--Sizing.md), resolved against the container's cross extent by `resolve-cross-extent` in [Selkie::Layout::Allocate](Selkie--Layout--Allocate.md): `Sizing.fixed($n)` is an exact cell count, `Sizing.percent($n)` a share of the container, `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 `align-self` or the container's `align-items` to say where the leftover space goes.
### has CrossAlign $.align-self
Where this widget sits along its parent's cross axis, overriding the container's `align-items`. Undefined — the default — inherits from the container, so setting `align-items` on a VBox or HBox really does govern every child that hasn't opted out. See [Selkie::Align](Selkie--Align.md) for the `CrossAlign` values, and `set-align-self` for the dirty-marking setter.
### has Bool $.focusable
Whether this widget can receive focus via Tab / Shift-Tab cycling or direct `$app.focus($widget)` calls. Leaf input widgets typically override this to True in their `new` method: method new(*%args) { %args<focusable> //= True; callwith(|%args); }
### has Bool $.disabled
Whether this widget is disabled: greyed out on screen and skipped by every focus path. False by default. Deliberately **not** the same knob as `focusable`. `focusable` says what kind of widget this is — a Button is focusable, a Text is not — and never changes over a widget's life. `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 `set-disabled`, which handles the repaint. See the "Disabled widgets" section above for what greying does and doesn't reach.
### has Bool $.clip-to-ancestors
When True (the default), this widget's [Selkie::EffectiveBounds](Selkie--EffectiveBounds.md) is computed as the intersection of its plane with every ancestor's plane and the terminal viewport. Sprixel-bearing widgets ([Selkie::Widget::Image](Selkie--Widget--Image.md) 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.
### method plane
```raku
method plane() returns Notcurses::Native::Types::NcplaneHandle
```
Returns the notcurses plane this widget renders to, or the type object `NcplaneHandle` if the widget has not been added to a parent yet. Always guard with `return without self.plane;` at the top of `render`.
### method rows
```raku
method rows() returns UInt
```
Current row height of the widget's plane.
### method cols
```raku
method cols() returns UInt
```
Current column width of the widget's plane.
### method y
```raku
method y() returns UInt
```
Y offset relative to the parent plane.
### method x
```raku
method x() returns UInt
```
X offset relative to the parent plane.
### method is-dirty
```raku
method is-dirty() returns Bool
```
True if this widget needs to be re-rendered on the next frame.
### method abs-y
```raku
method abs-y() returns Int
```
Absolute Y position on the screen — the parent layout computes this by accumulating its own `abs-y` with this widget's local offset. Useful for overlay positioning.
### method abs-x
```raku
method abs-x() returns Int
```
Absolute X position on the screen. See `abs-y`.
### method viewport-rows
```raku
method viewport-rows() returns UInt
```
Number of rows actually visible on screen — may be smaller than `rows` if a parent ScrollView is clipping us.
### method viewport-cols
```raku
method viewport-cols() returns UInt
```
Number of columns actually visible on screen. See `viewport-rows`.
### method is-parked
```raku
method is-parked() returns Bool
```
True when this widget has been moved to (or past) `park-y` and nothing has moved it back — i.e. `park` was called and no subsequent `reposition` / `init-plane` / `adopt-plane` re-placed it. Because notcurses carries bound child planes along with their parent, a parked widget's **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 `Selkie::Tree`'s `mark-widgets-in-rect-dirty`). One deliberate exception: [Selkie::Widget::Image](Selkie--Widget--Image.md) overrides `park` to tear its sprixel down and **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 `effective-bounds` (or, for sprixels, live notcurses geometry — see [Selkie::Widget::Image](Selkie--Widget--Image.md)).
### method effective-bounds
```raku
method effective-bounds() returns Selkie::EffectiveBounds
```
Compute this widget's [Selkie::EffectiveBounds](Selkie--EffectiveBounds.md) — 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 `$!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 [Selkie::Widget::Image](Selkie--Widget--Image.md)'s blit-plane sizing path.
### method set-viewport
```raku
method set-viewport(
:$abs-y! is raw,
:$abs-x! is raw,
Int :$rows! where { ... },
Int :$cols! where { ... }
) returns Mu
```
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 `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 theme
```raku
method theme() returns Selkie::Theme
```
The effective theme for this widget. Walks up the parent chain until it finds a widget with an explicit theme, falling back to `Selkie::Theme.default`. Use this in `render` rather than caching a theme reference, so theme changes propagate correctly.
### method set-theme
```raku
method set-theme(
Selkie::Theme $t
) returns Mu
```
Override the theme for this widget and its subtree. Repaints this widget's plane base, marks it dirty, then recurses into `.children` and `.content` so every descendant's plane base is repainted too. The recursion matters: `ncplane_erase` on a child plane fills with that child's base cell, which was set the first time `set-theme` or `set-store` ran on it. Without the cascade here, only the root on which the caller invoked `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 `set-store` just below — same `self.can` detection so containers (children) and decorators (content) are both reached without coupling `Widget` to either role.
### method base-style
```raku
method base-style() returns Selkie::Style
```
The style painted onto this widget's plane base cell — the colours (and alpha modes) that `ncplane_erase` and every cell the widget never writes will carry. Defaults to the active theme's `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 `AlphaTransparent` or `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-egc
```raku
method base-egc() returns Str
```
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 `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-channels
```raku
method base-channels() returns UInt
```
The packed 64-bit notcurses `channels` word this widget's base cell is primed with, built from `base-style`. Pure — no plane needed, nothing written — so it's the thing to assert against when you want to know what a custom `base-style` will actually produce. Colours and alpha share the word. `ncchannel_set` preserves the two alpha bits and `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 `NCALPHA_OPAQUE`, which keeps the produced word bit-identical to what Selkie emitted before alpha existed. An undefined `fg` or `bg` leaves that channel's "use the default colour" bit clear, exactly as before. The packing itself lives on `Selkie::Style.channels`, so every place Selkie primes a base cell — here, `Modal`'s scrim, the greyed base `apply-disabled-effect` writes — produces the word the same way. Returns 0 if `base-style` is undefined.
### method sync-plane-base
```raku
method sync-plane-base() returns Mu
```
Paint this widget's plane base cell from `base-style` and `base-egc` so `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 refresh-plane-base
```raku
method refresh-plane-base() returns Nil
```
Re-stamp this widget's plane base cell from `base-style` / `base-egc`, and every descendant's too. The base cell is sticky: `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 — `set-disabled(False)` uses it to undo the greyed bases `apply-disabled-effect` stamped. Same `self.can` cascade shape as `set-theme`, so containers (children) and decorators (content) are both reached. Does not mark anything dirty on its own: pair it with `mark-dirty-tree` when you want the new base to actually reach the screen this frame.
### method set-disabled
```raku
method set-disabled(
Bool $disabled
) returns Nil
```
Enable or disable this widget and everything under it. Disabled widgets render greyed out and drop out of the Tab cycle, mouse focus, and `Selkie::App.focus`. Both directions force a full subtree repaint, for different reasons, and both are load-bearing: =item **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 **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 (`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 apply-disabled-effect
```raku
method apply-disabled-effect() returns Nil
```
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; `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 **framebuffer** — every cell the widget actually wrote — is greyed cell by cell (see `greyscale-plane`). =item The **base cell** is not in the framebuffer, so it is re-stamped from `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 `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 greyscale-plane
```raku
method greyscale-plane() returns Nil
```
Grey this widget's own plane's framebuffer, cell by cell. Does not recurse and does not touch the base cell — `apply-disabled-effect` is the entry point that does both. No-op without a plane. **Why not `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 `0, 0, 0` — so it writes that cell out as an **explicit black**, permanently. Selkie leans hard on default channels: several theme slots (`text`, `text-dim`, `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 (`ncplane_at_yx_cell`, no allocation), run them through `Selkie::Style.greyscale-channels`, and write the result back with a 1x1 `ncplane_stain`. `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. **Cost.** This is O(rows x cols) native calls, against `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 set-sizing
```raku
method set-sizing(
Selkie::Sizing::Sizing $s
) returns Mu
```
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 `Sizing.fixed(0)` to collapse out of the flow without removing it from the widget tree. Subclasses with height-driven content (e.g. `MultiLineInput` growing as the user types) call this from inside their own re-measure logic.
### method store
```raku
method store() returns Mu
```
The [Selkie::Store](Selkie--Store.md) attached to this widget, or `Nil` if no store has been set yet. Propagates automatically from parent to child.
### method set-store
```raku
method set-store(
$store
) returns Mu
```
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 `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 unsubscribe-subtree
```raku
method unsubscribe-subtree(
$fallback-store = Nil
) returns Nil
```
Drop every store subscription held by this widget **and its entire subtree**, without destroying anything. Call it immediately before tearing a subtree down when the teardown isn't already routed through `Selkie::Container.remove` / `.clear` — most notably `Selkie::ScreenManager.remove-screen`, which destroys a whole screen root. The walk reaches children through the same duck-typed `children` / `content` pair `set-store` uses, deliberately **not** a `~~ Selkie::Container` test: `Selkie::Widget::CardList` and `ViewportedCardList` expose their cards through a `children` override without composing `Container`, and Border / Modal hold their child under `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. `$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 — `Selkie::Store.unsubscribe-widget` defers during a walk.
### method dispatch
```raku
method dispatch(
Str:D $event,
*%payload
) returns Mu
```
Convenience for dispatching a store event. Equivalent to `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 subscribe
```raku
method subscribe(
Str:D $id,
*@path
) returns Mu
```
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 [Selkie::Store](Selkie--Store.md) for details. Typically called from `on-store-attached`.
### method subscribe-computed
```raku
method subscribe-computed(
Str:D $id,
&compute
) returns Mu
```
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 [Selkie::Store](Selkie--Store.md).
### method once-subscribe
```raku
method once-subscribe(
Str:D $id,
*@path
) returns Mu
```
Idempotent version of `subscribe`. Tracks per-id registration for the currently attached store so repeated same-store `set-store` calls don't create duplicate subscriptions. Prefer this over `subscribe` when registering from `on-store-attached`.
### method once-subscribe-computed
```raku
method once-subscribe-computed(
Str:D $id,
&compute
) returns Mu
```
Idempotent version of `subscribe-computed`. See `once-subscribe`.
### method update-sizing
```raku
method update-sizing(
Selkie::Sizing::Sizing $s
) returns Mu
```
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-cross-sizing
```raku
method update-cross-sizing(
Selkie::Sizing::Sizing $s
) returns Mu
```
Update the widget's **cross**-axis sizing declaration at runtime and request a re-layout — the `cross-sizing` counterpart of `update-sizing`. Pass an undefined `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 set-align-self
```raku
method set-align-self(
CrossAlign $a
) returns Mu
```
Override the container's `align-items` for this widget alone, and request a re-layout. Pass an undefined `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 init-plane
```raku
method init-plane(
Notcurses::Native::Types::NcplaneHandle $parent-plane,
Int :$y where { ... } = 0,
Int :$x where { ... } = 0,
Int :$rows where { ... } = 1,
Int :$cols where { ... } = 1
) returns Mu
```
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 adopt-plane
```raku
method adopt-plane(
Notcurses::Native::Types::NcplaneHandle $plane,
Int :$rows where { ... },
Int :$cols where { ... }
) returns Mu
```
Borrow an existing plane (owned elsewhere) as this widget's plane. Used by `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 mark-dirty
```raku
method mark-dirty() returns Mu
```
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-tree
```raku
method mark-dirty-tree() returns Mu
```
Recursively mark this widget and every descendant dirty. Use when a state change has layout implications that the default up-propagating `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 mark-screen-dirty for the common "start from the root of the attached tree" case.
### method mark-screen-dirty
```raku
method mark-screen-dirty() returns Mu
```
Walk up to the root of the attached tree and flag the whole screen for a full render pass (via `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 `mark-dirty` propagation and let each render walk figure out what actually needs redrawing.
### method clear-dirty
```raku
method clear-dirty() returns Mu
```
Clear the dirty flag. Call this as the last line of your `render` method so the widget is skipped on subsequent frames until something changes.
### method apply-style
```raku
method apply-style(
Selkie::Style $style
) returns Mu
```
Apply a `Selkie::Style` (fg, bg, bold/italic/underline, alpha) to the widget's plane so subsequent `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 `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 sync-plane-alpha
```raku
method sync-plane-alpha(
Selkie::Style $style
) returns Nil
```
Push `$style`'s alpha modes onto the plane, skipping any channel that is already in the requested state. `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 **Selkie** put on the plane — if you call `ncplane_set_fg_alpha` behind the framework's back, call `reset-style-cache` afterwards.
### method apply-plane-alpha
```raku
method apply-plane-alpha(
AlphaMode $fg-alpha,
AlphaMode $bg-alpha
) returns Nil
```
The one place Selkie hands an alpha mode to notcurses. An undefined argument means "leave that channel alone", which is what `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 reset-style-cache
```raku
method reset-style-cache() returns Nil
```
Forget what `apply-style` believes the plane's alpha state to be, so the next `apply-style` pushes both channels unconditionally. The escape hatch for anything that changes the plane's channels outside the framework — a raw `ncplane_set_bg_alpha` call, a plane swapped in from elsewhere, a `ncplane_erase_region` variant that resets state. Cheap and always safe: the worst case is two redundant native calls on the next render.
### method resize
```raku
method resize(
Int $rows where { ... },
Int $cols where { ... }
) returns Mu
```
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 handle-resize
```raku
method handle-resize(
Int $rows where { ... },
Int $cols where { ... }
) returns Mu
```
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 `resize()`; that's enough for leaf widgets. Containers override to cascade to their own children/content. Prefer this over `resize()` when propagating a resize event from outside the layout pass. The built-in containers call `handle-resize` on children from their `layout-children` pass so the `on-resize` hook fires for any widget whose dims actually changed. **Custom containers** should override to cascade to their own children/content. If you hold child widgets in something other than `self.children` (e.g. `CardList`'s item hashes, `Border`'s `content`), your override is the only way the cascade reaches them.
### method on-resize
```raku
method on-resize() returns Mu
```
Optional hook called from `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 `mark-dirty` has fired.
### method reposition
```raku
method reposition(
Int $y where { ... },
Int $x where { ... }
) returns Mu
```
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 render
```raku
method render() returns Mu
```
Render this widget to its plane. **Required override**: the composing class must provide a body. Always guard with `return without self.plane`, and call `self.clear-dirty` at the end.
### method park-y
```raku
method park-y() returns Int
```
Park the widget off-screen — used by container swap operations (e.g. `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. **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 `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-children
```raku
method park-children(
@kids
) returns Nil
```
Park each widget in `@kids`. Used by `Container.park` and `Selkie::Widget::CardList.park` — both walk a list of child widgets calling `.park` on each, but they store their children differently (Container in `@!children`, CardList in `@!items`), so the iteration is the only thing they share. Private to the Widget role; consumers compose Widget so they can call `self!park-children(@kids)`.
### method on-key
```raku
method on-key(
Str:D $spec,
&handler,
Str :$description = ""
) returns Mu
```
Register a keybind for this widget. Fires when the widget has focus and an unconsumed event matches the spec. See [Selkie::Event](Selkie--Event.md) for the spec syntax (`'a'`, `'ctrl+q'`, `'shift+tab'`, etc). Pass `:description` to surface the bind in [Selkie::Widget::HelpOverlay](Selkie--Widget--HelpOverlay.md). 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-click
```raku
method on-click(
&handler,
Int :$button where { ... } = 1,
Str :$description = ""
) returns Mu
```
Register a click handler. Fires on a mouse button press whose cell falls within this widget's on-screen rectangle (per `abs-y`, `abs-x`, viewport extents). Default `button` is 1 (primary). The handler receives the `Selkie::Event`; use `self.local-row($ev)` and `self.local-col($ev)` for widget-local coordinates. Click handlers receive press events only — release is delivered via `on-mouse-up` if you need it. The `click-count` field on the event distinguishes single (1), double (2), and triple (3) clicks within the framework's 300 ms window.
### method on-scroll
```raku
method on-scroll(
&handler,
Str :$description = ""
) returns Mu
```
Register a scroll-wheel handler. Fires on scroll-up (`NCKEY_SCROLL_UP`) and scroll-down (`NCKEY_SCROLL_DOWN`) events whose cell falls within this widget's on-screen rectangle. The handler receives the `Selkie::Event`; check `$ev.id` for direction.
### method on-drag
```raku
method on-drag(
&handler,
Int :$button where { ... } = 1,
Str :$description = ""
) returns Mu
```
Register a drag handler. Fires on motion events while the given button is held — the press that started the drag is delivered to `on-click` (or `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 `on-mouse-up` and automatically clears the drag capture.
### method on-mouse-down
```raku
method on-mouse-down(
&handler,
Int :$button where { ... } = 1,
Str :$description = ""
) returns Mu
```
Register a low-level mouse-down handler. Fires on every press, regardless of button (defaults to 1 — pass `: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-up
```raku
method on-mouse-up(
&handler,
Int :$button where { ... } = 1,
Str :$description = ""
) returns Mu
```
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 `button` is 1.
### method mouse-handlers
```raku
method mouse-handlers() returns List
```
Read-only access to this widget's registered mouse handlers. Used by the framework's mouse dispatcher.
### method local-row
```raku
method local-row(
Selkie::Event $ev
) returns Int
```
Translate an absolute-screen mouse event into this widget's local Y coordinate (0-based, top-down). Returns `-1` when the event falls outside the widget's viewport, so callers can guard with a single check.
### method local-col
```raku
method local-col(
Selkie::Event $ev
) returns Int
```
Translate an absolute-screen mouse event into this widget's local X coordinate. See `local-row`.
### method contains-point
```raku
method contains-point(
Int $y,
Int $x
) returns Bool
```
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 claims-overlay-at
```raku
method claims-overlay-at(
Int $y,
Int $x
) returns Bool
```
True iff this widget paints an overlay region that extends past its nominal rect (per `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 [Selkie::Widget::Select](Selkie--Widget--Select.md): 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 `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 dispatch-mouse-handlers
```raku
method dispatch-mouse-handlers(
Selkie::Event $ev
) returns Bool
```
Internal: dispatch a `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 `handle-event` when the event is a `MouseEvent`; widgets that override `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 `'click'` and `'mouse-down'` handlers in registration order; first to return True consumes. Release events fire `'mouse-up'`. Drag motion (buttons held) and pure motion (when a drag capture is active upstream) fire `'drag'`. Scroll wheel fires `'scroll'`.
### method keybinds
```raku
method keybinds() returns List
```
Read-only access to this widget's registered keybinds. Used by HelpOverlay to render a listing for the focused widget chain.
### method keybind-chain
```raku
method keybind-chain() returns List
```
Walk this widget's ancestor chain (self → parent → … → root) collecting every keybind whose `:description` is non-empty. Returns a list of `{ 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 [Selkie::Widget::HelpOverlay](Selkie--Widget--HelpOverlay.md) is a separate private helper because the overlay wants section headers per widget class; flat consumers want a flat list.
### method handle-event
```raku
method handle-event(
Selkie::Event $ev
) returns Bool
```
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 `MouseEvent`s through any handlers registered via `on-click`, `on-scroll`, `on-drag`, `on-mouse-down`, `on-mouse-up`, and falls through to the keybind table (registered via `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 `self!dispatch-mouse-handlers` reuses the same registration API the base uses (handlers registered via `on-click`, `on-scroll`, `on-drag`, `on-mouse-down`, `on-mouse-up`); your override doesn't need to re-implement mouse-event classification. Override only the keyboard branch. A note on `nextsame`: it does **not** work for delegating Mouse handling back to this default. `Selkie::Widget` is a role, and Raku flattens role methods into the consuming class — so a subclass override of `handle-event` shadows the role version rather than inheriting it as a separate dispatch candidate, and `nextsame` from the override has no role-supplied candidate to fall through to. Always call `self!dispatch-mouse-handlers($ev)` explicitly when you need the registration-API behaviour.
### method destroy
```raku
method destroy() returns Mu
```
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 `self.destroy-plane` (or `self!destroy-plane` if you're inside the same role/class) as the last step.