Selkie--Tree

NAME

Selkie::Tree - Tree-walking helpers used by widgets that need to reach beyond their own subtree

SYNOPSIS

use Selkie::Tree;

# Mark every widget whose plane intersects this absolute screen rect
# as dirty — used by Image.destroy-blit-plane to repaint cells under
# the removed sprixel.
mark-widgets-in-rect-dirty(
    abs-y => 5,  abs-x => 10,
    rows  => 4,  cols  => 16,
);

# The active modal (or Nil), used by widgets that need to skip
# rendering when occluded.
my $modal = current-active-modal;

# Every walk up a parent chain takes its hops through next-ancestor,
# which turns a cyclic tree into a loud death instead of a frozen
# render thread.
my $node = $some-widget;
my int $hops = 0;
while $node.defined {
    last if $node === $wanted;
    $node = next-ancestor($node, $hops);
    $hops = $hops + 1;
}

DESCRIPTION

A small set of free subs that bridge between a widget and the wider tree it lives in, without requiring the widget to walk up to the Selkie::App instance manually. Selkie::App at init populates two class-level provider closures — one returning the live list of tree roots (active screen + modal stack + toast), the other returning the active modal — and the helpers here read through them.

This pattern keeps widgets like Selkie::Widget::Image from needing a circular import on Selkie::App while still letting them participate in app-level coordination (cell cleanup after sprixel destroy, modal occlusion checks, etc.).

next-ancestor is the other half of that job: the single hop primitive every parent-chain walk in Selkie goes through. It is a guard, not a convenience — see "Cycle-safe parent walks" below.

mark-widgets-in-rect-dirty walks whole trees, so it is written to stay cheap on the trees it actually meets: it prunes any subtree whose root is parked (see Selkie::Widget.is-parked — notcurses carries bound child planes with their parent, so a parked subtree owns no on-screen cells), and it duck-types children / content through nqp::can. Both details matter for correctness as much as speed; see the sub's own documentation.

Cycle-safe parent walks

A widget tree is a tree by convention, not by construction: parent is a plain writable attribute that layouts set when they adopt a child. One mis-ordered reparent — a container adopting a widget that is already one of its own ancestors — turns every while $node.defined { $node = $node.parent } loop into an infinite spin. Several of those loops run on the frame path (focus validation, dirty propagation, event bubbling), so the symptom is the worst shape a TUI failure can take: the screen freezes, every key is dead, nothing crashes, and there is no stack to look at.

next-ancestor is the hop primitive that makes that impossible. Take each step through it, passing the number of hops taken so far, and a cyclic chain dies at PARENT-CHAIN-LIMIT hops with an X::Selkie::WidgetCycle naming the widgets in the loop:

my $node = $widget;
my int $hops = 0;
while $node.defined {
    return True if $node === $root;
    $node = next-ancestor($node, $hops);
    $hops = $hops + 1;
}

X::Selkie::WidgetCycle is a compunit of its own, so CATCH { when X::Selkie::WidgetCycle { … } } names it exactly as written.

The limit is deliberately far above any real nesting depth (a deep production layout is tens of widgets, not hundreds), so tripping it is never a false positive and always an upstream bug. It fails loudly on purpose: a silently truncated walk would leave focus, dirty marking and event routing quietly wrong for the rest of the session.

The guard costs one integer comparison per hop and allocates nothing — the walks it protects run per frame.

Widget teardown notifications

Selkie::Widget's plane-destroy chokepoint calls notify-widget-destroyed, so app-level bookkeeping that holds widget references (mouse capture, for one) can drop them instead of routing later events into a torn-down widget. Selkie::App installs the observer at init through set-widget-destroyed-observer.

The observer runs on whatever thread destroyed the widget — including the GC finalizer thread, since Widget.DESTROY routes to destroy. Observers must therefore be thread-safe and must not touch app data structures the render thread owns; the supported shape is to set an atomic flag and let the render thread do the actual pruning.

sub set-tree-roots-provider

sub set-tree-roots-provider(
    &p
) returns Nil

Set the tree-roots provider — a closure returning an iterable of widget roots. Selkie::App calls this during init so tree-walking helpers can find the live trees without each helper needing a direct reference to the app.

sub current-tree-roots

sub current-tree-roots() returns List

The current list of widget tree roots. Used internally by helpers in this module; apps don't typically call this directly.

sub set-modal-provider

sub set-modal-provider(
    &p
) returns Nil

Set the active-modal provider — a closure returning the topmost open modal widget or Nil. Selkie::App calls this on init.

sub current-active-modal

sub current-active-modal() returns Mu

The topmost open modal widget, or Nil if no modal is open.

sub set-widget-destroyed-observer

sub set-widget-destroyed-observer(
    &p
) returns Nil

Set the widget-destroyed observer — a closure called with each widget as its plane is destroyed. Selkie::App calls this on init. The observer may run on the GC finalizer thread; keep it thread-safe and allocation-light.

sub notify-widget-destroyed

sub notify-widget-destroyed(
    Mu $widget
) returns Nil

Notify the registered observer that $widget is being destroyed. Called by Selkie::Widget; apps don't call this directly. Never lets an observer failure escape into a teardown path: a destroy that throws would abandon the rest of the widget's cleanup (and, from a finalizer, take down the GC thread's work item).

sub next-ancestor

sub next-ancestor(
    Mu $node,
    int $hops
) returns Mu

One hop up a parent chain, with the cycle guard applied. $hops is the number of hops already taken on this walk; pass 0 on the first call and increment from there. Returns $node.parent — the walk's own .defined test is what ends it at the root. Throws X::Selkie::WidgetCycle once a single walk has taken PARENT-CHAIN-LIMIT hops. See the "Cycle-safe parent walks" section above for why this fails loudly rather than truncating.

sub widget-occluded-by-active-modal

sub widget-occluded-by-active-modal(
    Mu $widget
) returns Bool

True when $widget is outside the active modal tree and therefore should suppress out-of-band painting such as sprixels. Widgets inside the active modal, including descendants of its content tree, are not occluded.

sub mark-widgets-in-rect-dirty

sub mark-widgets-in-rect-dirty(
    Int :$abs-y!,
    Int :$abs-x!,
    Int :$rows! where { ... },
    Int :$cols! where { ... }
) returns Nil

Walk every tree root and mark dirty any widget whose absolute screen bounds intersect the given rectangle. Used by sprixel-bearing widgets after they destroy a blit-plane: the cells under the removed sprixel may belong to a widget that has nothing else changing this frame, so without an explicit dirty mark the widget won't repaint and the cells will continue to show whatever was cached pre-sprixel-removal. Called once per blit teardown. Two properties keep this affordable on the trees it actually runs against (a long chat transcript in a consumer app is ~10k widgets): =item Parked subtrees are pruned. A widget whose is-parked latch is set has been moved to park-y, and notcurses carries every bound descendant plane along with it — so neither it nor anything beneath it owns an on-screen cell, and none of them can intersect an on-screen rect. Skipping the subtree is not an approximation. It also breaks a feedback loop: without the prune, tearing a sprixel down re-dirties the parked cards whose stale abs-y still overlaps the rect, which re-renders them, which tears more sprixels down. =item Capability tests go through nqp::can. children and content are duck-typed here on purpose — Selkie::Widget::CardList and ViewportedCardList expose children without composing Selkie::Container, so a ~~ Selkie::Container test would walk past every card in the list. nqp::can answers the same question as .^can from the method cache, without building the candidate list .^can returns.

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.