Selkie--Tween

NAME

Selkie::Tween - Wall-clock interpolation for bounded UI animation

SYNOPSIS

use Selkie::Tween;

# Flash a list row green and let it decay back to the normal style.
my $normal = $app.theme.text;
my $flash  = Selkie::Style.new(fg => 0x9ECE6A, bold => True);

$app.tweens.add: Tween.new(
    duration  => 0.15,                       # seconds
    easing    => EaseOutQuad,
    on-update => -> Num $t {
        $list.set-row-style(3, lerp-style($flash, $normal, $t));
        $list.mark-dirty;                    # ← REQUIRED, see below
    },
    on-complete => { $list.clear-row-style(3) },
);

# The pure helpers are usable on their own.
ease(EaseInOutQuad, 0.25);          # 0.125
lerp-rgb(0x000000, 0xFFFFFF, 0.5);  # 0x808080
lerp-style($a, $b, 0.75);           # colours lerp, flags/alpha snapped to $b

DESCRIPTION

A Tween maps elapsed wall-clock time onto a number in 0..1 and hands it to your callback once per frame. That is the whole idea. It owns no widgets, paints nothing, and knows nothing about the render loop — it converts "0.15 seconds have a shape" into "here is where you are in that shape right now".

Wall-clock is the load-bearing word. Selkie's event loop does not run at a fixed rate: the idle ladder steps it down from the hot rate (60 Hz by default) to 30 Hz, 12 Hz, and finally 4 Hz as the user goes quiet, and any single frame can also run long because a store handler did real work. A frame-counting animation ("advance 1/9th per tick") therefore plays at a different speed depending on how bored the user was thirty seconds ago, and can end up sampling a 150 ms flash exactly once. Tween divides elapsed seconds by duration, so a tween that gets one single tick 10 seconds late lands on exactly 1.0, fires on-update(1) once, fires on-complete once, and stops. The existing frame-counting Selkie::Widget::ProgressBar.tick is the anti-pattern this class exists to replace for anything timed.

Selkie::App closes the loop: while any tween is live, the app's animation refcount is non-zero and the render loop pins itself to the hot budget regardless of how idle the ladder thinks it is. See Selkie::App's tweens, begin-animation, end-animation, and animating.

Rule 1: every on-update must mark something dirty

Selkie only composites when a widget actually rendered. Widgets only render when they are dirty. A tween that mutates a widget's state without calling mark-dirty on it produces a perfect, invisible animation — the values change, nothing repaints, and the new state appears the next time something unrelated forces a frame.

# WRONG — silently does nothing on screen.
on-update => -> Num $t { $bar.set-value($t) },

# RIGHT.
on-update => -> Num $t { $bar.set-value($t); $bar.mark-dirty },

Widgets whose own setters already mark dirty (most of the prebuilt ones do) are fine as-is; when in doubt, call mark-dirty yourself — it is idempotent and free.

If the change reshapes the layout rather than repainting one widget, use mark-screen-dirty instead — same rule, bigger hammer.

Rule 2: tweens are bounded, never ambient

Every tween has a duration and ends. Do not start a tween that restarts itself forever to get a pulsing cursor or a breathing border: that is an ambient animation, it holds the render loop at the hot rate for the entire lifetime of the process, and on a laptop it is the difference between an app you can leave open all day and one you can't. The idle ladder exists precisely so that a Selkie app sitting untouched costs ~nothing; an ambient tween disables it.

The supported shape is: something happened (a key, a store event, a completed job), so play a short, terminating animation. Flashes, fades, slides, count-ups. When it finishes, the refcount drops, the ladder resumes, and the app goes back to sleep.

Bounded also means short. 100–300 ms covers essentially every useful UI transition; past ~500 ms an animation stops reading as polish and starts reading as latency.

Rule 3: fades interpolate colour, not alpha

Notcurses alpha is a two-bit enum — opaque, blend, transparent, high-contrast — with nothing in between (see Selkie::Alpha). There is no 30% state to land on, so AlphaOpaque → AlphaBlend is not animatable and lerp-style deliberately snaps both alpha modes at the midpoint rather than pretending otherwise.

A fade is therefore a colour ramp toward whatever you are fading into:

# Fade a panel out toward the app background.
my $panel-fg = 0xC0CAF5;
my $bg       = $app.theme.base.bg;

on-update => -> Num $t {
    $panel.set-style(Selkie::Style.new(fg => lerp-rgb($panel-fg, $bg, $t)));
    $panel.mark-dirty;
},

The same limit applies to "make this scrim fade in deeper": one AlphaBlend layer is an exact 50/50 mix and that is as far as one layer goes. Deeper means a darker scrim colour, not more alpha — so a scrim fade-in ramps the scrim's RGB from the backdrop colour toward black, holding AlphaBlend fixed the whole time.

EXAMPLES

A row flash on completion

The canonical "something just happened here" cue. Note that the flash is fired at the keypress, alongside the dispatch — by the time the store has processed a completion the row may have been filtered out of the list entirely.

use Selkie::Tween;

method flash-row(Int $idx, Selkie::Style $from, Selkie::Style $to, Real $seconds = 0.15) {
    return unless 0 <= $idx < @!rows.elems;

    # One flash per row: a second keypress restarts rather than stacks.
    %!row-tweens{$idx}.cancel with %!row-tweens{$idx};

    %!row-tweens{$idx} = $!app.tweens.add: Tween.new(
        duration    => $seconds,
        easing      => EaseOutQuad,
        on-update   => -> Num $t {
            @!row-styles[$idx] = lerp-style($from, $to, $t);
            self.mark-dirty;
        },
        on-complete => {
            @!row-styles[$idx] = $to;
            %!row-tweens{$idx}:delete;
            self.mark-dirty;
        },
    );
}

# set-items replaces the rows the tweens are indexing into, so any
# flash still in flight is stale — cancel them all.
method set-items(@items) {
    .cancel for %!row-tweens.values;
    %!row-tweens = ();
    # ... existing set-items body ...
}

Three things earn their keep there: the out-of-range guard (rows come and go), cancelling the previous tween for the same row (cancel fires no on-complete, so it cannot clobber the newer flash's bookkeeping), and cancelling on set-items.

Fading a modal in

Modals are cheap to animate because the whole dialog shares one frame colour. Fade the frame and title up from the backdrop colour so the dialog resolves out of the dimmed screen instead of appearing.

method open-editor() {
    my $modal = build-editor-modal();
    $!app.show-modal($modal);

    my $target  = $!app.theme.slot('modal-frame');
    my $backdrop = Selkie::Style.new(fg => 0x1A1B26, bg => $target.bg);

    $!app.tweens.add: Tween.new(
        duration  => 0.12,
        easing    => EaseOutQuad,
        on-update => -> Num $t {
            $modal.set-frame-style(lerp-style($backdrop, $target, $t));
            $modal.mark-dirty;
        },
    );
}

# Closing runs the same tween backwards, and only tears the modal
# down once the fade has actually finished.
method close-editor() {
    $!app.tweens.add: Tween.new(
        duration    => 0.12,
        easing      => EaseOutQuad,
        reverse     => True,
        on-update   => -> Num $t {
            $modal.set-frame-style(lerp-style($backdrop, $target, $t));
            $modal.mark-dirty;
        },
        on-complete => { $!app.close-modal },
    );
}

reverse inverts the value, not the clock: on-update receives 1 → 0 while the easing curve keeps its acceleration profile, so an EaseOutQuad fade-out still decelerates as it lands rather than becoming an ease-in.

Do not animate a modal's geometry this way unless you have measured it. Resizing a plane per frame re-runs the whole layout cascade and, over an Selkie::Widget::Image, forces a sprixel re-blit every frame.

Chaining

on-complete may add another tween to the same group. The new tween starts on the frame after the one that completed — the group snapshots its member list before ticking, so a tween added mid-tick is never ticked twice in the same frame:

my $out = Tween.new(
    duration    => 0.1, on-update => &fade-out,
    on-complete => { $app.tweens.add: Tween.new(duration => 0.2, on-update => &fade-in) },
);
$app.tweens.add($out);

The animation refcount does not drop between the two: the group only reports itself idle once no members remain.

Driving a tween by hand

Every time-taking method takes an explicit Instant, defaulting to now. That is what makes tweens testable without sleeping:

my @seen;
my $t0 = Instant.from-posix(1000);
my $tw = Tween.new(duration => 1, on-update => -> Num $v { @seen.push($v) });

$tw.start($t0);
$tw.tick($t0);                  # @seen[0] == 0e0
$tw.tick($t0 + 0.5);            # @seen[1] == 0.5e0
$tw.tick($t0 + 999);            # @seen[2] == 1e0, on-complete fired, running now False
$tw.tick($t0 + 1000);           # False — a finished tween never fires again

Animating without Tween

If you are driving something that genuinely isn't an interpolation — a spinner while a job runs, say — keep the render loop hot for its duration with the refcount directly, and be sure the end is on a path that always runs:

$app.begin-animation;
LEAVE $app.end-animation;

end-animation clamps at zero, so a stray extra call cannot drive the count negative and wedge the app permanently at the hot rate. An unmatched begin-animation will wedge it, though, which is why the refcount is best left to TweenGroup.

SEE ALSO

The easing curves Selkie ships. All four map 0 → 0 and 1 → 1 and are monotonically non-decreasing in between, so a tween always starts at its from value and ends at its to value. =item EaseLinear — constant rate. Correct for progress readouts and colour ramps you want to read as mechanical. =item EaseInQuad — starts still, accelerates. Good for things leaving. =item EaseOutQuad — starts fast, decelerates into place. The default choice for almost every UI transition; it reads as "responsive". =item EaseInOutQuad — accelerates then decelerates, symmetric about the midpoint. Good for moves between two resting states.

sub ease

sub ease(
    Easing:D $easing,
    Num(Any) $t
) returns Num

Apply an easing curve to a normalised time $t, clamped to 0..1. Pure; no state, no allocation beyond the returned Num. ease(EaseLinear, 0.25); # 0.25 ease(EaseInQuad, 0.5); # 0.25 ease(EaseOutQuad, 0.5); # 0.75 ease(EaseInOutQuad, 0.5); # 0.5 — the two halves meet here

sub lerp-rgb

sub lerp-rgb(
    Int:D $from where { ... },
    Int:D $to where { ... },
    Num(Any) $t
) returns UInt

Interpolate between two 0xRRGGBB colours, component by component, in plain sRGB. Each of the three components is interpolated independently and rounded half-up to the nearest integer, then clamped to 0..255. $t == 0 returns $from exactly and $t == 1 returns $to exactly — the endpoints are never off by a rounding step. Bits above the low 24 are ignored on input and never set on output. lerp-rgb(0x000000, 0xFFFFFF, 0e0); # 0x000000 lerp-rgb(0x000000, 0xFFFFFF, 0.5e0); # 0x808080 (127.5 rounds up) lerp-rgb(0xFF0000, 0x0000FF, 0.5e0); # 0x800080 sRGB is not a perceptually uniform space, so a long ramp between saturated complements passes through a muddy midpoint. For the short, low-contrast transitions UI animation actually uses (a highlight decaying into a row background) it is indistinguishable from the linear-light alternative and costs three multiplies.

sub lerp-style

sub lerp-style(
    Selkie::Style:D $from,
    Selkie::Style:D $to,
    Num(Any) $t
) returns Selkie::Style

Interpolate between two Selkie::Styles. fg and bg ramp through lerp-rgb. Everything else on a style is discrete and therefore B<snaps at C<from's discrete attributes, at and above it you get a = Selkie::Style.new(fg => 0xFF0000); # bg inherits my a, a, a, $b, 0.4e0).fg; # 0x996600 — both defined, so it ramps If you want a background to fade rather than pop, give both styles an explicit bg. Two undefined sides stay undefined throughout. The result is a fresh style; neither input is mutated.

class Selkie::Tween::Tween

One bounded animation: a duration, a curve, and a callback that receives the eased position each time the tween is ticked. A tween is inert until started and does nothing on its own — something has to call tick. Hand it to $app.tweens.add and the app ticks it once per frame (and holds the render loop at the hot budget while it runs); drive it yourself if you have your own clock. All three time-taking methods (start, restart, tick) take an explicit Instant defaulting to now, so tests never sleep.

has Real $.duration

How long the tween runs, in seconds. Required. Must not be negative; 0 is legal and means "complete on the first tick", which is the sensible degenerate for an animation-duration setting turned down to nothing.

has Easing $.easing

The easing curve. Defaults to EaseLinear.

has Bool $.reverse

Run the value backwards: on-update receives 1 → 0 instead of 0 → 1. The clock is not reversed — the easing curve keeps its acceleration profile, so an EaseOutQuad reverse still decelerates as it lands on 0.

has Callable &.on-update

Called with the eased position (a Num in 0..1) every time the tween ticks while running, including once with the start value on the first tick and once with the end value on the last. It must mark something dirty — see the module Pod.

has Callable &.on-complete

Called once, immediately after the final on-update, when the tween reaches the end of its duration or is finished. Not called when the tween is cancelled. Optional.

method start

method start(
    Instant $at = Code.new
) returns Selkie::Tween::Tween

Arm the tween, with $at as its zero point. A no-op on a tween that is already running (so re-adding a live tween to a group cannot restart it mid-flight); on a finished or cancelled tween it re-arms, exactly like restart. Returns self.

method restart

method restart(
    Instant $at = Code.new
) returns Selkie::Tween::Tween

Re-arm unconditionally from $at, discarding whatever the tween was doing. No on-complete fires for the abandoned run. Returns self.

method cancel

method cancel() returns Nil

Stop the tween where it stands. on-complete does not fire, and done stays False — a cancelled tween did not finish. Whatever the last on-update painted stays on screen, so the caller is responsible for restoring the resting state if a half-faded widget is not acceptable. A no-op if not running.

method finish

method finish() returns Nil

Jump straight to the end: fire on-update with the final value and then on-complete, exactly once. Use it to skip an animation while still landing on the state it would have produced (an "instant" preference, or a widget being torn down mid-fade). A no-op on a tween that has already completed, so on-complete can never fire twice.

method running

method running() returns Bool

True between start and completion / cancellation.

method done

method done() returns Bool

True once the tween has run to its end (or been finished). Cancelling does not set this.

method progress

method progress() returns Num

Raw, un-eased position in 0..1 as of the last tick — clamped, so it never exceeds 1 however far past the end the clock has run. Not the value handed to on-update: that one has the easing (and reverse) applied.

method tick

method tick(
    Instant $at = Code.new
) returns Bool

Advance to wall-clock $at and fire on-update with the eased position. Returns True iff on-update was called, which is what lets the render loop tell an animating frame from an idle one. Position is (now āˆ’ start) / duration, clamped — not a frame count. A tween that is only sampled once, long after it should have ended, still gets exactly one on-update(1) and one on-complete and then stops; a tween sampled at 4 Hz plays in the right amount of wall-clock time, just choppily. Ticking a tween that is not running returns False and does nothing.

class Selkie::Tween::TweenGroup

A set of tweens ticked together, which drops its members as they finish and reports when it goes from idle to busy and back. That last part is the point: Selkie::App builds one group, wires on-active to begin-animation and on-idle to end-animation, and ticks it from a single per-frame callback. So handing a tween to $app.tweens.add is what pins the render loop to the hot budget, and the group letting go of it is what releases the loop back to the idle ladder. Nothing else has to remember to balance the refcount. A group is not thread-safe; tick it from the app thread.

has Callable &.on-active

Called when the group goes from holding no tweens to holding at least one.

has Callable &.on-idle

Called when the group's last tween completes, is cancelled, or is cleared. Fires after that tween's own on-complete.

method add

method add(
    Selkie::Tween::Tween:D $tween,
    Instant :$at = Code.new
) returns Selkie::Tween::Tween

Add a tween and start it at $at unless it is already running, then return it (so the call site can keep a handle for a later cancel). Starting on add is deliberate: the group's membership is what drives the app's animation refcount, so a member that never started would hold the render loop at the hot rate forever while doing nothing. Adding a finished or cancelled tween re-arms it.

method tick

method tick(
    Instant $at = Code.new
) returns Bool

Tick every member at $at and drop the ones that are no longer running. Returns True iff at least one member actually updated. The member list is snapshotted before the walk, so an on-complete that adds a follow-up tween to this same group (the chaining idiom) is safe: the newcomer is kept, but it first ticks on the following frame rather than twice in this one.

method running

method running() returns Bool

True while the group holds at least one running tween.

method elems

method elems() returns Int

How many tweens the group is holding. Members are dropped on the tick after they finish, so this settles back to 0 one frame after the last animation ends.

method clear

method clear() returns Nil

Cancel and drop every member. No on-complete fires (this is an abandonment, not a completion), but on-idle does, so the refcount is released. Use it when the thing being animated is going away — a screen switch, a list being replaced.

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.