Selkie--Store

NAME

Selkie::Store - Reactive state store with dispatch, effects, and subscriptions

SYNOPSIS

my $store = $app.store;

# Register a handler that returns effects (never mutate state directly)
$store.register-handler('counter/inc', -> $st, %ev {
    my $current = $st.get-in('count') // 0;
    (db => { count => $current + 1 },);
});

# Subscribe a widget to a path — marks it dirty when the value changes
$store.subscribe('my-counter', ['count'], $widget);

# Or: subscribe with a callback that does something on change
$store.subscribe-with-callback(
    'counter-text',
    -> $s { "Count: {$s.get-in('count') // 0}" },
    -> $text { $label.set-text($text) },
    $label,
);

# Fire an event from anywhere — any thread, even — it's queued and
# processed on the next tick. The queue is lock-protected.
$store.dispatch('counter/inc');

DESCRIPTION

Selkie's store is inspired by re-frame — one centralized state atom with a one-way data flow:

user action → dispatch event
event       → handlers return effects
effects     → mutate state (via registered effect handlers)
state       → subscriptions notify widgets
widgets     → re-render

Handlers are pure functions. Given a store and event payload, they return a list of effects. Effects are where side effects live — built-in ones (db, db-replace, db-delete, db-update, dispatch, async) cover most needs; you can register your own with register-fx.

Why?

Why go through all this ceremony versus just mutating state? Because:

  • State changes are auditable — every mutation is a named event with a payload

  • Handlers are testable — no notcurses, no widgets, just pure functions

  • Time-travel / logging / middleware become possible without changing app code

  • Subscriptions derive UI from state — you don't manually sync widgets with state

You don't have to use the store. For small apps, widget Supplies taped directly to side-effecting code works fine. The store shines when shared state grows — multiple widgets reading the same data, actions that cascade, async workflows with multiple steps.

Thread safety

dispatch is the only store method that may be called from a thread other than the main loop. The event queue is lock-protected, so worker threads — the async effect's on-success / on-failure dispatches, or app-owned start blocks reporting progress — can enqueue concurrently with the main loop's drain without losing events. Everything else (handlers, effects, subscriptions, tick, state reads and writes) runs on the main loop thread; off-thread dispatches are simply picked up on the next tick. To get work onto a worker thread, use the async effect; to get results back, dispatch — never touch state directly from a worker.

EXAMPLES

A counter

Two handlers, one subscription:

$store.register-handler('counter/inc', -> $st, %ev {
    (db => { count => ($st.get-in('count') // 0) + 1 },);
});
$store.register-handler('counter/reset', -> $st, %ev {
    (db => { count => 0 },);
});

$store.subscribe-with-callback(
    'counter-display',
    -> $s { "Count: {$s.get-in('count') // 0}" },
    -> $text { $display.set-text($text) },
    $display,
);

$inc-button.on-press.tap:   -> $ { $store.dispatch('counter/inc') };
$reset-button.on-press.tap: -> $ { $store.dispatch('counter/reset') };

Chaining events

The dispatch effect lets a handler trigger another event. Use it when one action implies another:

$store.register-handler('user/logged-in', -> $st, %ev {
    (
        db       => { user => %ev<user> },
        dispatch => { event => 'inbox/fetch' },
    );
});

Replacing or deleting a subtree

db is a deep-merge effect. Use db-replace when you need an exact replacement, including replacing a populated Hash with an empty Hash:

$store.register-handler('image-gen/clear', -> $st, %ev {
    (db-delete => { path => <ui image-gen> },);
});

$store.register-handler('filters/reset', -> $st, %ev {
    (db-replace => { path => <ui filters>, value => %() },);
});

Both effects notify subscriptions watching the target path or overlapping ancestor/descendant paths.

Async work

The async effect runs work on a worker thread, then dispatches a follow-up event with the result (or error). The handler itself returns immediately — the store doesn't block:

$store.register-handler('inbox/fetch', -> $st, %ev {
    (async => {
        work       => -> { fetch-messages-from-api() },
        on-success => 'inbox/loaded',
        on-failure => 'inbox/load-failed',
    },);
});

$store.register-handler('inbox/loaded', -> $st, %ev {
    (db => { inbox => %ev<result> },);
});

$store.register-handler('inbox/load-failed', -> $st, %ev {
    (db => { error => %ev<error> },);
});

Computed subscriptions

subscribe-computed watches a derived value. Fires only when the computed result changes, not every time the underlying state does:

$store.subscribe-computed(
    'unread-count',
    -> $s { $s.get-in('inbox').grep(*<read> == False).elems },
    $badge-widget,
);

Debugging

Turn on logging during development to see the data flow:

$app.store.enable-debug;           # logs to $*ERR
# or to a file:
$app.store.enable-debug(log => open('store.log', :w));

Output:

[1776073200.123] dispatch task/add text=Buy milk
[1776073200.123]   → db: {tasks => [...], next-id => 5}
[1776073200.124]   sub[task-list] fired: [...]

EFFECTS

Handlers return effects rather than mutating state directly. Each effect is a Pair of name = params>, or an Associative with multiple pairs.

Built-in effects

  • db => { ... } — deep-merge into the state tree. Nested hashes are merged recursively; non-hash values are set directly. This is the workhorse effect — most handlers return one. An empty Hash merged into a populated Hash is a no-op because there are no keys to merge; use db-replace or db-delete when you need to clear a subtree.

  • db-replace => { path => <ui filters>, value => %() } — replace the value at path exactly, auto-creating intermediate Hashes like assoc-in. Use this when the new value must replace the old subtree rather than merge into it.

  • db-delete => { path => <ui image-gen> } — delete the key at path. Missing paths are ignored. Use this to represent absence rather than storing a sentinel such as Nil.

  • db-update => { fn => &fn } — transform the whole state with a pure function: &fn receives the current state and returns the new state. On a typed store (see TYPED STORE below) this is the primary write effect — &fn takes the root schema instance and returns a patched clone, and only the actually-changed paths notify subscribers. On the untyped store &fn takes the state Hash, must return an Associative, and the whole tree is marked dirty (the digest gate still stops no-op subscriber fires).

  • dispatch => { event => 'name', ...payload } — enqueue another event. Processed in the same tick.

  • async => { work => &fn, on-success => 'name', on-failure => 'name' } — run &fn on a worker thread. On return, dispatch on-success with a result payload. On throw, dispatch on-failure with an error payload.

Event and effect payload contracts

Event names are Str:D. dispatch($event, *%payload) enqueues the event name plus exactly the named payload pairs the caller supplied. Handlers registered with register-handler are called as &handler($store, %payload) in registration order. Missing event handlers are a no-op.

Handlers return effects. Each returned effect must be either a Pair whose key is the effect name and whose value is the parameter payload, or an Associative whose key/value pairs are effect names and parameter payloads. Effect parameter payloads must be Associative; scalar payloads are rejected and routed through __effect-error. Unknown effect names are ignored.

Built-in effect payloads:

  • db: the payload itself is a Hash-shaped update tree. Nested Associatives are deep-merged into existing Associatives; any other value replaces the target key. Empty Associatives merge no keys.

  • db-replace: requires path and value. path may be a Positional path or a single scalar key and must not be empty. Missing intermediate paths are created as Hashes. value replaces the target subtree exactly.

  • db-delete: requires non-empty path, with the same path shape as db-replace. Missing paths are ignored. Existing target keys are deleted.

  • db-update: requires fn, a Callable called with the current state and returning the new state. On a typed store the return must be an instance of the store's state-class; on the untyped store it must be Associative. A wrong-shaped return dies and routes through __effect-error like any other effect failure.

  • dispatch: requires event. All other keys are forwarded as the payload for that queued event.

  • async: requires work, a Callable run on a worker thread. on-success is optional; when present it receives a payload with result. on-failure is optional; when present it receives a payload with error (a Str), exception (the original exception object), and backtrace (a Str).

Effect handler exceptions and event handler exceptions do not escape the dispatch loop. They enqueue __effect-error unless the failing event is already __effect-error. Its payload is:

  • effect-name — the effect name, or event-handler[$event] for an event-handler exception

  • event — the event whose handler produced the failing effect (or whose handler threw), so a validation failure names the dispatch that caused it; empty string when the effect ran outside an event context

  • error — the exception message

  • exception — the original exception object

  • backtrace — the full backtrace string when available

  • params — the effect params, or the original event payload for event-handler exceptions

Custom effects

Register your own with register-fx for repeated side effects that you want named:

$store.register-fx('log', -> $store, %params {
    my $line = %params<line>;
    $log-file.say("[{DateTime.now}] $line");
});

# Then use from any handler:
$store.register-handler('task/deleted', -> $st, %ev {
    (
        db  => { tasks => %ev<remaining> },
        log => { line => "deleted task {%ev<id>}" },
    );
});

SUBSCRIPTIONS

Four kinds, chosen by what you want to do on change:

  • subscribe($id, @path, $widget) — watch a state path. The widget is marked dirty when the value at that path changes. Simplest form.

  • subscribe-path-callback($id, @path, &callback, $widget) — watch a state path and call &callback($new-value) when it changes. Push-based like subscribe; use this when the path itself is the derived value and you need a side effect as well as a dirty mark.

  • subscribe-computed($id, &compute, $widget) — watch a derived value. Runs &compute every tick; marks the widget dirty when the return value changes.

  • subscribe-with-callback($id, &compute, &callback, $widget) — same as computed, but also invokes &callback with the new value. Use when the widget needs to be re-configured (e.g. set-items on a list), not just re-rendered.

All three use $id as a unique key — re-subscribing with the same id overwrites the previous subscription. unsubscribe($id) removes one by key; unsubscribe-widget($w) removes every subscription bound to a widget (used during widget destruction, where it fires once per widget of the torn-down subtree — it reads a widget-keyed reverse index, so its cost tracks that widget's own subscriptions rather than the size of the store).

Mutation safety

It's safe for a subscription's callback to call unsubscribe, unsubscribe-widget, or any cascade thereof (closing a modal, clearing a container, swapping a pane) while a tick is in progress. unsubscribe calls made from inside !check-subscriptions or !flush-push-subs are queued into a pending set; the actual hash deletions are applied after the walk exits, via a LEAVE block that runs whether the walk returns normally or unwinds via an exception. During the walk, queued ids are skipped in the dispatch loop, so a sub unsubscribed by an earlier callback in the same tick does not fire later in that same tick — matching the observable behaviour callers depended on before the defer mechanism existed.

Re-subscribing the same id during a walk (unsubscribe('foo') followed by subscribe-with-callback('foo', ...) in the same callback) cancels the queued removal and tears down the old entry cleanly before installing the new one, so the deferred flush never clobbers the new registration.

Mutations from outside a walk — the common case, a handler running in !process-queue or app code calling unsubscribe directly — take effect immediately as before. The defer mechanism is invisible to that path.

The invariant the walks maintain: %!subscriptions, %!push-subs-by-key, and the by-widget reverse index are never mutated while a walk is iterating them — all three are written from the same !do-unsubscribe chokepoint, so the deferred flush keeps them consistent with each other rather than only with the queue. Earlier implementations relied on :exists guards plus .keys.List snapshots, but neither survived realistic load (App::Cantina character changes cascade ~120 unsubscribe calls through one callback firing); the typed eager Array snapshot (my Str:D @ids = %!subscriptions.keys) plus the queued-mutation discipline above is what actually holds.

Equality semantics — content digests

Change detection compares a content digest of the watched value against the previous one (see !value-digest / !sub-changed). The digest folds Hashes (key-sorted) and Arrays structurally and keys most leaves — value type or object — by .WHICH. Blob/Buf values are also leaves, but include only type, byte count, and .WHICH so a parent Hash containing image bytes never folds the bytes themselves. Comparing two digests is then a flat string compare, which replaced a per-field structural eqv walk: on a chat Message carrying ~1 K tokens of text that is ~1000Ɨ cheaper, because eqv pays reflective per-attribute dispatch.

.WHICH at a leaf cuts both ways correctly. For a value type it is content-derived (two equal Strs share it), so a fingerprint Str recomputed each tick compares by content. For a reference it is identity: a wholesale-replaced record reads as changed, the same instance as unchanged.

Object attributes are not folded. The store holds more than plain data records — e.g. the focused widget, whose attribute graph has native plane handles (which surface as the NQP null) and parent/child cycles that would explode a recursive fold. Identity is the right comparison there, and for Cantina's immutable, replace-wholesale records it is also exact (a content change is always a fresh instance). The only cost is a no-op write that re-reads identical rows into fresh objects firing one cheap re-render.

Mutable nested data and :identity-check-only

The default ("structural") regime recomputes the digest on every fire. That sidesteps the in-place-mutation trap: a subscription on path a, plus a caller doing $store.assoc-in('a', 'b', 'c', :value(42)). assoc-in mutates a leaf inside the live nested Hash, so the Hash at a is the same object before and after — a bare === identity check would match and silently suppress the fire. Re-digesting catches it, because the mutated leaf's .WHICH moved.

# Watch a mutable nested hash. The default just works — deep
# assoc-in writes under <prefs> are detected even though the prefs
# Hash reference itself is unchanged.
$store.subscribe('prefs-changed', <prefs>, $widget);
$store.assoc-in('prefs', 'theme', :value('dark'));   # fires
$store.assoc-in('prefs', 'lang',  :value('en'));     # fires

Pass :identity-check-only when the watched value is replaced wholesale on every real change — never mutated in place. It adds an === pre-check: an unchanged tick (the same instance / value as last fire) short-circuits without computing a digest at all — the hot path during streaming, where a watched Array keeps its identity across many no-op ticks. On an identity change the digest is still computed, so an equal-content replacement does not fire. Do not use it on a value you mutate in place (assoc-in on a deep child) — the === shortcut would miss the write.

The flag exists on subscribe, subscribe-path-callback, subscribe-computed, and subscribe-with-callback.

Migration from pre-0.8.0 Selkie: the old :deep-equality-check flag is removed. Drop the flag — the safe behaviour is now the default. Pass :identity-check-only at any call site whose value is replaced wholesale and that fires on a hot path.

TYPED STORE

The state tree is an untyped Hash by default — and stays that way forever; the typed store is opt-in per store, not a migration target. Passing a Selkie::Store::Schema subclass at construction swaps the Hash for a typed, immutable schema tree:

class MyApp::Db is Selkie::Store::Schema {
    has Selkie::Store::Schema::UI $.ui .= new;   # required framework slot
    has Str $.user-name = '';
    has     %.prefs;
}

my $store = Selkie::Store.new(state-class => MyApp::Db);
# or, with a pre-built root (tests, restored sessions):
my $store2 = Selkie::Store.new(initial-state => MyApp::Db.new(user-name => 'Kai'));

What changes, and what doesn't:

  • Same effects, now validated. Handlers keep returning db / db-replace / db-delete effects with the same shapes; keys and paths are validated against the schema's slots at dispatch time, and values are type-checked by the slot's constraint. A typo'd key or wrong-typed value routes through __effect-error — with the offending event named in the payload — instead of silently malforming the tree.

  • db-update is the typed workhorse. db-update => { fn => -> MyApp::Db $s { $s.with(user-name => 'Kai') } } transforms the root through the schema's clone-with methods. Because clones share unchanged branches, the store diffs old vs new root by identity and notifies exactly the changed paths.

  • Reads: .state or get-in. .state returns the root schema instance (typed accessors: $store.state.user-name); get-in keeps working with slot names as path segments and keeps its "missing step reads as Nil" contract. .db dies on a typed store — there is no state Hash to hand out.

  • Subscriptions are unchanged. Path subscriptions address slots the way they addressed Hash keys; computed subscriptions can use typed accessors in their compute blocks. No signature changed. Schema instances compare by identity in the digest (see "Equality semantics") — exact, because no-op writes return the identical instance.

  • Plain-Hash slots stay untyped territory. A %.-slot keeps classic Hash semantics (deep-merge, auto-vivify, :delete) inside that region — handy for incremental migration.

  • The ui slot is required. The framework's focus state lives at ui.focused-widget / ui.focus-action; the root schema must carry a defined Selkie::Store::Schema::UI (or subclass) as ui, and construction dies with the exact line to add if it doesn't.

When to use which: a 200-line tool with a handful of state keys loses nothing to the Hash store; define a schema when the state tree is big enough that a typo'd path or a wrong-shaped write costs real debugging time — the schema converts those from silent corruption into loud dispatch-time errors. Full slot/write semantics live in Selkie::Store::Schema's Pod.

SEE ALSO

has Associative %!subs-by-widget

Reverse index: $widget.WHICH → SetHash of the sub-ids bound to that widget. Exists so unsubscribe-widget — which fires once per widget of a torn-down subtree, i.e. hundreds of times per modal or screen teardown — is a hash lookup instead of a grep over every live subscription (measured at 38-40ms per dialog close in a consumer app running ~850 live subs). Keyed on .WHICH rather than the widget itself so the index never pins a destroyed widget alive, and because .WHICH equality is exactly the === test the grep used — including for the undefined-widget case, where subscriptions registered with a type object all share one bucket. Maintained in lockstep with %!subscriptions: written by !index-widget-sub from every subscribe* variant and cleared by !do-unsubscribe, which is the single point where an entry actually leaves %!subscriptions — including the deferred flush at the end of a subscription walk. Updating it at the unsubscribe request site instead would desync the index from the hash for the remainder of any walk.

method enable-debug

method enable-debug(
    IO::Handle :$log = Code.new,
    Bool :$dispatches = Bool::True,
    Bool :$effects = Bool::True,
    Bool :$subscriptions = Bool::True
) returns Mu

Enable logging of dispatches, effects, and subscription fires. Output lines go to $log (defaults to $*ERR). Pass :!dispatches, :!effects, or :!subscriptions to silence a specific category. Overhead when disabled is a single Bool check per hook.

method disable-debug

method disable-debug() returns Mu

Turn off debug logging.

method db

method db() returns Hash

Access the raw state Hash. Read-only in practice — mutate via dispatch. On a typed store there is no state Hash; this dies with a pointer to state.

method state

method state() returns Mu

The current state: the root schema instance on a typed store, the state Hash otherwise. The uniform read entry point — .state works on both store kinds where .db is Hash-only. Read-only in practice — mutate via dispatch.

method get-in

method get-in(
    *@path
) returns Mu

Deep-read a value at a path. Returns Nil if any step in the path is missing or not a Hash. Useful in handlers and subscription computes: my store.get-in('app', 'user', 'name'); # may be Nil

method assoc-in

method assoc-in(
    *@path,
    :$value!
) returns Mu

Deep-write a value at a path, auto-creating intermediate Hashes as needed. Prefer dispatch-and-handle over calling this directly from app code — direct mutations bypass the auditability that the dispatch pattern gives you. This is public for legitimate framework use (e.g. App's internal focus-action flag). $store.assoc-in('app', 'user', 'name', value => 'Alice');

method dispatch

method dispatch(
    Str:D $event,
    *%payload
) returns Mu

Enqueue an event for processing on the next tick. The event name is routed to every handler registered for that name; their returned effects are applied in order. Safe to call from any thread: the queue is lock-protected, so worker threads (async fx callbacks, app-owned start blocks) can dispatch concurrently with the main loop's drain without losing events. Off-thread dispatches are picked up on the next main-loop tick.

method register-handler

method register-handler(
    Str:D $event,
    &handler
) returns Mu

Register a handler for an event name. Multiple handlers per event are supported — all are called and their effects merged. The handler signature is sub ($store, %payload -- @effects)>. Return a single effect Pair, a list of Pairs, or a Hash of effects. Return an empty list or () to apply no effects.

method register-fx

method register-fx(
    Str:D $fx-name,
    &handler
) returns Mu

Register a custom effect handler. The handler receives the store and a Hash of params, and performs side effects. See EFFECTS above for the built-in ones and an example of registering your own.

sub path-key

sub path-key(
    @path
) returns Str

Canonical string encoding of a path for use as a Hash key in the push-subscription reverse index. We join segments with \0 (NUL) because it's guaranteed never to appear in realistic path keys — all our keys are user-chosen Str. The decode helper is the inverse. Empty path encodes to the empty string.

method subscribe

method subscribe(
    Str:D $id,
    @path,
    Selkie::Widget $widget,
    Bool :$identity-check-only = Bool::False
) returns Mu

Subscribe a widget to a state path. When the value at the path changes, the widget is marked dirty on the next tick. The $id identifies the subscription for later unsubscribe — use a unique name per subscription. Push-based: the store pushes a notification to this subscription only when a write touches the path (exact, ancestor, or descendant — see the push-sub dispatch block at the top of the file). Idle ticks with no writes do zero work per subscription. Initial prime (marking the widget dirty so its first render happens) runs synchronously here. Change detection defaults to a content digest (see Equality semantics in this module's Pod) — correct for mutable containers (Hash / Array) updated in place by assoc-in on a deep child, where the ancestor's reference is unchanged across the write. The historical default was identity-only (===), which silently suppressed those writes; opt back into an === pre-check via :identity-check-only for paths whose value is replaced wholesale on each write. See MUTABLE NESTED DATA in this module's Pod.

method subscribe-path-callback

method subscribe-path-callback(
    Str:D $id,
    @path,
    &callback,
    Selkie::Widget $widget,
    Bool :$identity-check-only = Bool::False
) returns Mu

Subscribe to a state path with a callback — fires &callback($new-value) on any real change to the path (exact, ancestor write, or descendant write that replaced the subtree). No compute function needed: the path IS the watched expression. Also marks the owning widget dirty so it re-renders after the callback configures it. Use when your widget needs reconfiguration on change (e.g. set-items on a list, set-text on a label) rather than just re-rendering the same computed output. Equivalent to subscribe-with-callback with a trivial compute closure, but without the per-tick closure invocation cost — push-based like subscribe. Change detection is digest-based, mirroring subscribe. Pass :identity-check-only when the watched path's value is replaced wholesale per write. The callback always receives the live current value; only the change-gate computes the digest. The prime calls your callback synchronously, before this method returns. Do not call subscribe-path-callback again from inside that callback for an id that is currently priming — a callback that re-arms its own subscription on some condition (a cache miss, an empty payload) would otherwise recurse without bound. The store guards this: a prime re-entered for an id still inside its own prime (directly or through a cycle of subscriptions) registers the subscription but skips the nested prime, and logs the skip when debug logging is enabled.

method subscribe-computed

method subscribe-computed(
    Str:D $id,
    &compute,
    Selkie::Widget $widget,
    Bool :$identity-check-only = Bool::False
) returns Mu

Subscribe a widget to a computed value. &compute receives the store each tick and should return the value. The widget is marked dirty when the return value changes. Change detection compares a content digest of the result (see Equality semantics in this module's Pod). This correctly handles compute functions that return references into mutable nested state — the common case of $store.get-in('chat', 'messages') after an assoc-in deep-write, where identity-only comparison sees the same Hash reference and silently suppresses the fire. Set :identity-check-only when &compute's result is replaced wholesale per change (a fresh Str, a derived count, a freshly-built Hash) — this adds an === pre-check that skips the digest on unchanged ticks.

method subscribe-with-callback

method subscribe-with-callback(
    Str:D $id,
    &compute,
    &callback,
    Selkie::Widget $widget,
    Bool :$identity-check-only = Bool::False
) returns Mu

Like subscribe-computed, but also invokes &callback with the new value whenever it changes. Use this when your widget needs to be re-configured (e.g. set-items on a list, set-text on a label), not just re-rendered. Change detection is digest-based; see subscribe-computed. Pass :identity-check-only when the compute result is replaced wholesale per change. The callback always receives the live compute result.

method unsubscribe

method unsubscribe(
    Str:D $id
) returns Mu

Remove a subscription by its id. No-op if the id isn't registered. During a subscription walk (!check-subscriptions / !flush-push-subs), the actual removal is deferred to walk exit; the queued id is skipped in the dispatch loop, so an earlier callback's unsubscribe is observed in the same tick. Outside a walk, removal is immediate as before.

method index-widget-sub

method index-widget-sub(
    Str:D $id,
    $widget
) returns Mu

Bind $id to $widget in the reverse index, first releasing whatever widget the id was previously bound to. Re-subscribing an existing id to a different widget is legal (it overwrites the %!subscriptions entry), so without the release step the old widget's bucket would keep a stale id and unsubscribe-widget would tear down a subscription that no longer belongs to it.

method unsubscribe-widget

method unsubscribe-widget(
    Selkie::Widget $widget
) returns Mu

Remove every subscription bound to a widget. Called automatically by Selkie::Container when a child is removed — you rarely call this yourself. O(subs on this widget), not O(all subs), via the %!subs-by-widget reverse index; a widget that never subscribed has no bucket and costs one failed hash lookup. Removal itself goes through unsubscribe, so calling this from inside a subscription callback defers exactly like a direct unsubscribe does — the bucket keeps the ids until the walk's flush actually removes them, which makes a repeat call in the same walk a harmless no-op rather than a double-removal.

method subscription-count

method subscription-count() returns Int

Total number of active subscriptions. Excludes ids that have been queued for deferred removal but not yet flushed (so counts reflect the post-walk state). Primarily useful for tests asserting that destroy / unsubscribe-widget cleanup ran.

method tick

method tick() returns Bool

Process one tick of the store. Drains the event queue (invoking handlers, applying effects, possibly enqueuing more events — capped at 100 iterations to prevent infinite loops), then walks every subscription and compares its current value against the previous one. The subscription walk is skipped on ticks where the event queue was empty and every subscription has already been primed with its initial value. With nothing new in the store, no subscription value can have changed — walking them would be wasted work. Registering a new subscription flips the prime flag so the next tick initializes it regardless of event activity. Called automatically by Selkie::App.run each frame. Call explicitly only when you need to force state resolution outside the main loop (e.g. bootstrapping state before run starts).

method flush-push-subs

method flush-push-subs() returns Mu

Drain @!dirty-paths and fire every push subscription whose bound path overlaps with any written path — where "overlaps" means either path is a prefix of the other (ancestor / descendant / exact). Dedupe: a sub only fires once per flush even if multiple writes match it. After dispatching, the dirty-path set is cleared. Firing still respects value-change semantics: !sub-changed gates whether &callback / mark-dirty actually runs, so no-op writes don't produce spurious fires.

method value-digest

method value-digest(
    $v,
    Int $depth = 0
) returns Str

Content digest of a subscription value, used for change detection. Folds Hashes (canonical, key-sorted) and Arrays structurally, and keys most leaves — value type OR object — by .WHICH. Blob/Buf leaves are compact type/byte-count/.WHICH tokens so large image binaries are never traversed byte-by-byte through a parent structure. The result is a single Str, so comparing two digests is a flat string compare instead of eqv's reflective per-field walk — ~1000Ɨ cheaper on a rich object like a chat Message carrying ~1 K tokens of text. .WHICH at a leaf does the right thing both ways: for a value type it is content-derived (two equal Strs share it, so a fingerprint Str recomputed each tick compares by content), and for a reference it is identity (a wholesale-replaced record reads as changed; the same instance reads as unchanged). We deliberately do NOT fold an object's attributes. The store holds not only plain data records but references like the focused widget, whose attribute graph contains native plane handles (which read back as the NQP null and can't be passed on) and parent<->child cycles (which would explode the walk). Identity is the correct comparison for those, and for an immutable, replace-wholesale record it is also exact — a content change is always a fresh instance. The only cost is that a no-op write re-reading identical rows into fresh objects fires one cheap re-render. DIGEST-MAX-DEPTH bounds a pathologically deep (or self-referential) Hash / Array.

method sub-changed

method sub-changed(
    %sub,
    $current
) returns Bool

Decide whether a subscription's watched value changed since its last fire, updating the subscription's cached comparison state in place. Returns True when the caller should fire (mark dirty / run callback). Two regimes, keyed on C<%sub>: =item Structural (the default): the value may be mutated in place (e.g. assoc-in into a nested Hash), so we always recompute the content digest and compare. A deep mutation that leaves the container's identity intact is still caught — the mutated leaf's .WHICH changes the digest. =item Replace-only (:identity-check-only): the value is guaranteed to be replaced wholesale on every real change, never mutated in place. An === pre-check then short-circuits unchanged ticks (same instance / same value) without computing a digest at all — the hot path during streaming, where the watched Array keeps its identity. On an identity change we still digest, so an equal-content replacement (a no-op reload re-reading the same rows into fresh objects) does NOT spuriously fire. This replaces the old snapshot-and-eqv machinery with the same observable behaviour but a flat .WHICH-token string compare.

method shutting-down

method shutting-down() returns Bool

True once drain-async has flipped the store into shutdown mode. Callers (typically the async fx) can poll this to short-circuit dispatches that would race against a tearing-down App.

method drain-async

method drain-async(
    :$timeout = 5
) returns Mu

Wait for every in-flight async-effect worker to complete (or for the timeout to elapse), flip the store into shutdown mode so any further async dispatches no-op, and clear the tracking list. Called from Selkie::App.shutdown before notcurses is torn down so completing workers can't dispatch into handlers whose native deps are gone. Idempotent — a second call is a fast no-op (the list is empty and $!shutting-down is already True).

Selkie v0.16.0

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.6.5+>: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::ErrorLogPlatform
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::PosixFD
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalPlatform
  • Selkie::App::Internal::TerminalReport
  • 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::Store::Schema
  • Selkie::Store::Schema::UI
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::SnapshotPlatform
  • 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
  • X::Selkie::WidgetCycle

Documentation

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

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