Selkie--Widget

NAME

Selkie::Widget - Base role composed by every Selkie widget

SYNOPSIS

A minimal custom widget that renders a fixed string:

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:

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, 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 CrossAlign saying where the resolved extent sits. Undefined inherits the container's align-items.

# 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

Alpha and the plane base

apply-style pushes a style's fg-alpha / bg-alpha (see 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 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.

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.

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:

$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.

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:

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.

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.

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;
}

SEE ALSO

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 Selkie::Sizing::Sizing $.sizing

How this widget wants to be sized by its parent layout. See Selkie::Sizing: 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, resolved against the container's cross extent by resolve-cross-extent in Selkie::Layout::Allocate: 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 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 //= 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 is computed as the intersection of its plane with every ancestor's plane and the terminal viewport. Sprixel-bearing widgets (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.

method plane

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

method rows() returns UInt

Current row height of the widget's plane.

method cols

method cols() returns UInt

Current column width of the widget's plane.

method y

method y() returns UInt

Y offset relative to the parent plane.

method x

method x() returns UInt

X offset relative to the parent plane.

method is-dirty

method is-dirty() returns Bool

True if this widget needs to be re-rendered on the next frame.

method abs-y

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

method abs-x() returns Int

Absolute X position on the screen. See abs-y.

method viewport-rows

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

method viewport-cols() returns UInt

Number of columns actually visible on screen. See viewport-rows.

method effective-bounds

method effective-bounds() returns Selkie::EffectiveBounds

Compute this widget's 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 $!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's blit-plane sizing path.

method set-viewport

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

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

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

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

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

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

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

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

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

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

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

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

method store() returns Mu

The Selkie::Store attached to this widget, or Nil if no store has been set yet. Propagates automatically from parent to child.

method set-store

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 dispatch

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

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 for details. Typically called from on-store-attached.

method subscribe-computed

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.

method once-subscribe

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

method once-subscribe-computed(
    Str:D $id,
    &compute
) returns Mu

Idempotent version of subscribe-computed. See once-subscribe.

method update-sizing

method update-sizing(
    Selkie::Sizing::Sizing $s
) returns Mu

method update-cross-sizing

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); # back to full width

method set-align-self

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(CrossAlign); # whatever the row says

method init-plane

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 for the spec syntax ('a', 'ctrl+q', 'shift+tab', etc). Pass :description to surface the bind in Selkie::Widget::HelpOverlay. Binds without a description still work — they just don't appear in the help listing. Example: { delete-item }, :description('Delete'); { refresh }, :description('Refresh');

method on-click

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

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

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

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

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

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

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

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

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

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: 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

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

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

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 is a separate private helper because the overlay wants section headers per widget class; flat consumers want a flat list.

method handle-event

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 MouseEvents 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 method handle-event(Selkie::Event ev.event-type ~~ MouseEvent { return True if self!dispatch-mouse-handlers(!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

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.

Selkie v0.11.1

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.4.1+>:auth<zef:apogee>

Test Dependencies

Provides

  • Selkie
  • Selkie::Align
  • Selkie::Alpha
  • Selkie::App
  • Selkie::App::Internal::Animation
  • Selkie::App::Internal::Dispatch
  • Selkie::App::Internal::ErrorLog
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalSequences
  • Selkie::BorderStyle
  • Selkie::Container
  • Selkie::EffectiveBounds
  • Selkie::Event
  • Selkie::Gradient
  • Selkie::Layout::Allocate
  • Selkie::Layout::HBox
  • Selkie::Layout::Split
  • Selkie::Layout::VBox
  • Selkie::Plot::Palette
  • Selkie::Plot::Scaler
  • Selkie::Plot::Ticks
  • Selkie::ScreenManager
  • Selkie::Sizing
  • Selkie::Store
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::Store
  • Selkie::Test::Supply
  • Selkie::Test::Tree
  • Selkie::Theme
  • Selkie::Trace
  • Selkie::Tree
  • Selkie::Tween
  • Selkie::Widget
  • Selkie::Widget::Axis
  • Selkie::Widget::BarChart
  • Selkie::Widget::Border
  • Selkie::Widget::Button
  • Selkie::Widget::CardList
  • Selkie::Widget::Checkbox
  • Selkie::Widget::CommandPalette
  • Selkie::Widget::ConfirmModal
  • Selkie::Widget::FileBrowser
  • Selkie::Widget::FocusableByDefault
  • Selkie::Widget::GradientFill
  • Selkie::Widget::Heatmap
  • Selkie::Widget::HelpOverlay
  • Selkie::Widget::Histogram
  • Selkie::Widget::Image
  • Selkie::Widget::Legend
  • Selkie::Widget::LineChart
  • Selkie::Widget::ListView
  • Selkie::Widget::Modal
  • Selkie::Widget::MultiLineInput
  • Selkie::Widget::PasswordStrength
  • Selkie::Widget::Plot
  • Selkie::Widget::ProgressBar
  • Selkie::Widget::RadioGroup
  • Selkie::Widget::RichText
  • Selkie::Widget::RichText::Span
  • Selkie::Widget::ScatterPlot
  • Selkie::Widget::ScrollView
  • Selkie::Widget::Select
  • Selkie::Widget::Sparkline
  • Selkie::Widget::Spinner
  • Selkie::Widget::TabBar
  • Selkie::Widget::Table
  • Selkie::Widget::Text
  • Selkie::Widget::TextInput
  • Selkie::Widget::TextInput::HighlightSpan
  • Selkie::Widget::TextStream
  • Selkie::Widget::Toast
  • Selkie::Widget::ViewportedCardList

Documentation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.