Selkie.git | lib/Selkie/ | Tween.rakumod


=begin pod

=head1 NAME

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

=head1 SYNOPSIS

=begin code :lang<raku>

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

=end code

=head1 DESCRIPTION

A C<Tween> maps B<elapsed wall-clock time> onto a number in C<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 B<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. C<Tween> divides elapsed seconds by C<duration>, so a tween that
gets one single tick 10 seconds late lands on exactly C<1.0>, fires
C<on-update(1)> once, fires C<on-complete> once, and stops. The
existing frame-counting C<Selkie::Widget::ProgressBar.tick> is the
anti-pattern this class exists to replace for anything timed.

C<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
C<Selkie::App>'s C<tweens>, C<begin-animation>, C<end-animation>, and
C<animating>.

=head2 Rule 1: every C<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 C<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.

=begin code :lang<raku>

# 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 },

=end code

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

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

=head2 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 B<short>. 100–300 ms covers essentially every
useful UI transition; past ~500 ms an animation stops reading as
polish and starts reading as latency.

=head2 Rule 3: fades interpolate colour, not alpha

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

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

=begin code :lang<raku>

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

=end code

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

=head1 EXAMPLES

=head2 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.

=begin code :lang<raku>

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

=end code

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

=head2 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.

=begin code :lang<raku>

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

=end code

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

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

=head2 Chaining

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

=begin code :lang<raku>

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

=end code

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

=head2 Driving a tween by hand

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

=begin code :lang<raku>

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

=end code

=head2 Animating without C<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 C<end> is on a
path that always runs:

=begin code :lang<raku>

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

=end code

C<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 C<begin-animation> B<will> wedge it, though, which is why the
refcount is best left to C<TweenGroup>.

=head1 SEE ALSO

=item L<Selkie::App> — C<tweens>, C<begin-animation>, C<end-animation>, C<animating>
=item L<Selkie::Alpha> — why fades interpolate colour and never alpha
=item L<Selkie::Style> — what C<lerp-style> is interpolating
=item L<Selkie::Gradient> — spatial colour ramps (this module is the temporal one)

=end pod

unit module Selkie::Tween;

use Selkie::Alpha;
use Selkie::Style;

#|( The easing curves Selkie ships. All four map C<0 → 0> and C<1 → 1>
    and are monotonically non-decreasing in between, so a tween always
    starts at its C<from> value and ends at its C<to> value.

    =item B<C<EaseLinear>> — constant rate. Correct for progress readouts and colour ramps you want to read as mechanical.
    =item B<C<EaseInQuad>> — starts still, accelerates. Good for things leaving.
    =item B<C<EaseOutQuad>> — starts fast, decelerates into place. The default choice for almost every UI transition; it reads as "responsive".
    =item B<C<EaseInOutQuad>> — accelerates then decelerates, symmetric about the midpoint. Good for moves between two resting states. )
enum Easing is export (
    EaseLinear    => 'linear',
    EaseInQuad    => 'in-quad',
    EaseOutQuad   => 'out-quad',
    EaseInOutQuad => 'in-out-quad',
);

# Everything public here clamps its input rather than extrapolating:
# an out-of-range t is always a bug in the caller, and a clamped
# animation is a cosmetic glitch where an extrapolated one is an
# out-of-gamut colour or a widget positioned off-plane.
sub clamp01(Num() $t --> Num) {
    my Num $n = $t.Num;
    return 0e0 if $n < 0e0 || $n != $n;   # NaN is not ordered; treat it as 0
    return 1e0 if $n > 1e0;
    $n;
}

#|( Apply an easing curve to a normalised time C<$t>, clamped to
    C<0..1>. Pure; no state, no allocation beyond the returned C<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 ease(Easing:D $easing, Num() $t --> Num) is export {
    my Num $c = clamp01($t);
    given $easing {
        when EaseLinear  { $c }
        when EaseInQuad  { $c * $c }
        when EaseOutQuad { 1e0 - (1e0 - $c) * (1e0 - $c) }
        when EaseInOutQuad {
            $c < 0.5e0
                ?? 2e0 * $c * $c
                !! 1e0 - (-2e0 * $c + 2e0) * (-2e0 * $c + 2e0) / 2e0;
        }
        default { die "Selkie::Tween: unknown easing '$easing'" }
    }
}

#|( Interpolate between two C<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 C<0..255>.
    C<$t == 0> returns C<$from> exactly and C<$t == 1> returns C<$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-rgb(UInt:D $from, UInt:D $to, Num() $t --> UInt) is export {
    my Num $c = clamp01($t);
    my UInt $out = 0;
    for 16, 8, 0 -> $shift {
        my Int $f = ($from +> $shift) +& 0xFF;
        my Int $g = ($to   +> $shift) +& 0xFF;
        my Int $v = ($f + ($g - $f) * $c).round.Int;
        $v = 0   if $v < 0;
        $v = 255 if $v > 255;
        $out = $out +| ($v +< $shift);
    }
    $out;
}

# The colour rule for lerp-style, factored out because fg and bg use it
# identically. Two defined colours ramp; two undefined stay undefined;
# one of each has no ramp to run (there is no RGB value for "inherit"),
# so it switches at the midpoint exactly like the flags do.
sub lerp-colour($from, $to, Num:D $c, Bool:D $snap --> UInt) {
    return lerp-rgb($from, $to, $c) if $from.defined && $to.defined;
    $snap ?? $to !! $from;
}

#|( Interpolate between two L<Selkie::Style>s.

    C<fg> and C<bg> ramp through C<lerp-rgb>. Everything else on a
    style is discrete and therefore B<snaps at C<$t E<gt>= 0.5>>: the
    four boolean flags (bold, italic, underline, strikethrough) and —
    importantly — C<fg-alpha> and C<bg-alpha>, which are two-bit enums
    with no intermediate states (L<Selkie::Alpha>). Below the midpoint
    you get C<$from>'s discrete attributes, at and above it you get
    C<$to>'s.

    The same midpoint rule covers a colour that is defined on one side
    and undefined on the other. An undefined colour means "inherit from
    the surrounding context" — there is no RGB value to ramp toward, so
    a ramp is not expressible and the value switches once, halfway:

        my $a = Selkie::Style.new(fg => 0xFF0000);            # bg inherits
        my $b = Selkie::Style.new(fg => 0x00FF00, bg => 0x000000);

        lerp-style($a, $b, 0.4e0).bg;    # (UInt)   — still inheriting
        lerp-style($a, $b, 0.5e0).bg;    # 0x000000 — switched
        lerp-style($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 C<bg>. Two undefined sides stay undefined throughout.

    The result is a fresh style; neither input is mutated. )
sub lerp-style(Selkie::Style:D $from, Selkie::Style:D $to, Num() $t --> Selkie::Style) is export {
    my Num  $c    = clamp01($t);
    my Bool $snap = $c >= 0.5e0;
    Selkie::Style.new(
        fg            => lerp-colour($from.fg, $to.fg, $c, $snap),
        bg            => lerp-colour($from.bg, $to.bg, $c, $snap),
        bold          => $snap ?? $to.bold          !! $from.bold,
        italic        => $snap ?? $to.italic        !! $from.italic,
        underline     => $snap ?? $to.underline     !! $from.underline,
        strikethrough => $snap ?? $to.strikethrough !! $from.strikethrough,
        fg-alpha      => $snap ?? $to.fg-alpha      !! $from.fg-alpha,
        bg-alpha      => $snap ?? $to.bg-alpha      !! $from.bg-alpha,
    );
}

#|( One bounded animation: a duration, a curve, and a callback that
    receives the eased position each time the tween is C<tick>ed.

    A tween is inert until C<start>ed and does nothing on its own —
    something has to call C<tick>. Hand it to C<$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 (C<start>, C<restart>, C<tick>) take
    an explicit C<Instant> defaulting to C<now>, so tests never sleep. )
class Tween is export {
    #|( How long the tween runs, in seconds. Required. Must not be
        negative; C<0> is legal and means "complete on the first
        C<tick>", which is the sensible degenerate for an
        animation-duration setting turned down to nothing. )
    has Real $.duration is required;

    #| The easing curve. Defaults to C<EaseLinear>.
    has Easing $.easing = EaseLinear;

    #|( Run the value backwards: C<on-update> receives C<1 → 0> instead
        of C<0 → 1>. The clock is not reversed — the easing curve keeps
        its acceleration profile, so an C<EaseOutQuad> reverse still
        decelerates as it lands on C<0>. )
    has Bool $.reverse = False;

    #|( Called with the eased position (a C<Num> in C<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. B<It must mark something dirty> — see the module Pod. )
    has &.on-update is required;

    #|( Called once, immediately after the final C<on-update>, when the
        tween reaches the end of its duration or is C<finish>ed.
        B<Not> called when the tween is C<cancel>led. Optional. )
    has &.on-complete;

    has Instant $!started-at;
    has Bool    $!running  = False;
    has Bool    $!done     = False;
    has Num     $!progress = 0e0;

    submethod TWEAK() {
        die "Selkie::Tween: duration must not be negative (got {$!duration})"
            if $!duration < 0;
    }

    # The value handed to on-update for a given raw (un-eased) position.
    method !value-at(Num:D $raw --> Num) {
        my Num $e = ease($!easing, $raw);
        $!reverse ?? 1e0 - $e !! $e;
    }

    #|( Arm the tween, with C<$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 C<restart>. Returns C<self>. )
    method start(Instant $at = now --> Tween) {
        return self if $!running;
        self.restart($at);
    }

    #|( Re-arm unconditionally from C<$at>, discarding whatever the
        tween was doing. No C<on-complete> fires for the abandoned run.
        Returns C<self>. )
    method restart(Instant $at = now --> Tween) {
        $!started-at = $at;
        $!running    = True;
        $!done       = False;
        $!progress   = 0e0;
        self;
    }

    #|( Stop the tween where it stands. C<on-complete> does B<not>
        fire, and C<done> stays C<False> — a cancelled tween did not
        finish. Whatever the last C<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 cancel(--> Nil) {
        $!running = False;
        Nil;
    }

    #|( Jump straight to the end: fire C<on-update> with the final
        value and then C<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
        C<on-complete> can never fire twice. )
    method finish(--> Nil) {
        return if $!done;
        $!progress = 1e0;
        $!running  = False;
        $!done     = True;
        &!on-update(self!value-at(1e0));
        &!on-complete() if &!on-complete;
        Nil;
    }

    #| True between C<start> and completion / cancellation.
    method running(--> Bool) { $!running }

    #| True once the tween has run to its end (or been C<finish>ed).
    #| Cancelling does not set this.
    method done(--> Bool) { $!done }

    #|( Raw, un-eased position in C<0..1> as of the last C<tick> —
        clamped, so it never exceeds C<1> however far past the end the
        clock has run. Not the value handed to C<on-update>: that one
        has the easing (and C<reverse>) applied. )
    method progress(--> Num) { $!progress }

    #|( Advance to wall-clock C<$at> and fire C<on-update> with the
        eased position. Returns C<True> iff C<on-update> was called,
        which is what lets the render loop tell an animating frame from
        an idle one.

        Position is C<(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 C<on-update(1)> and one
        C<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 C<False> and does nothing. )
    method tick(Instant $at = now --> Bool) {
        return False unless $!running;

        my Num $raw = $!duration <= 0
            ?? 1e0
            !! clamp01((($at - $!started-at) / $!duration).Num);
        $!progress = $raw;

        &!on-update(self!value-at($raw));

        if $raw >= 1e0 {
            $!running = False;
            $!done    = True;
            &!on-complete() if &!on-complete;
        }
        True;
    }
}

#|( 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: C<Selkie::App> builds one group, wires
    C<on-active> to C<begin-animation> and C<on-idle> to
    C<end-animation>, and ticks it from a single per-frame callback. So
    handing a tween to C<$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. )
class TweenGroup is export {
    #|( Called when the group goes from holding no tweens to holding at
        least one. )
    has &.on-active;

    #|( Called when the group's last tween completes, is cancelled, or
        is cleared. Fires after that tween's own C<on-complete>. )
    has &.on-idle;

    has Tween @!tweens;
    has Bool  $!active = False;

    method !refresh-active(--> Nil) {
        my Bool $live = ?@!tweens.elems;
        return if $live == $!active;
        $!active = $live;
        if $live {
            &!on-active() if &!on-active;
        } else {
            &!on-idle() if &!on-idle;
        }
        Nil;
    }

    #|( Add a tween and C<start> it at C<$at> unless it is already
        running, then return it (so the call site can keep a handle for
        a later C<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 add(Tween:D $tween, Instant :$at = now --> Tween) {
        $tween.start($at) unless $tween.running;
        @!tweens.push($tween);
        self!refresh-active;
        $tween;
    }

    #|( Tick every member at C<$at> and drop the ones that are no
        longer running. Returns C<True> iff at least one member
        actually updated.

        The member list is snapshotted before the walk, so an
        C<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 tick(Instant $at = now --> Bool) {
        return False unless @!tweens.elems;

        my Bool $updated = False;
        for @!tweens.List -> $tween {
            $updated = True if $tween.tick($at);
        }

        @!tweens = @!tweens.grep(*.running);
        self!refresh-active;
        $updated;
    }

    #| True while the group holds at least one running tween.
    method running(--> Bool) { ?@!tweens.grep(*.running).elems }

    #|( How many tweens the group is holding. Members are dropped on
        the C<tick> after they finish, so this settles back to C<0> one
        frame after the last animation ends. )
    method elems(--> Int) { @!tweens.elems }

    #|( Cancel and drop every member. No C<on-complete> fires (this is
        an abandonment, not a completion), but C<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. )
    method clear(--> Nil) {
        .cancel for @!tweens;
        @!tweens = ();
        self!refresh-active;
        Nil;
    }
}