Selkie.git | docs/api/ | Selkie--Tween.md


NAME
====

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

SYNOPSIS
========

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

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.

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

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](Selkie--Alpha.md)). 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:

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

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.

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

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.

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

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

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

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:

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

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:

```raku
$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
========

  * [Selkie::App](Selkie--App.md) — `tweens`, `begin-animation`, `end-animation`, `animating`

  * [Selkie::Alpha](Selkie--Alpha.md) — why fades interpolate colour and never alpha

  * [Selkie::Style](Selkie--Style.md) — what `lerp-style` is interpolating

  * [Selkie::Gradient](Selkie--Gradient.md) — spatial colour ramps (this module is the temporal one)



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

```raku
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

```raku
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

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

Interpolate between two [Selkie::Style](Selkie--Style.md)s. `fg` and `bg` ramp through `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 — `fg-alpha` and `bg-alpha`, which are two-bit enums with no intermediate states ([Selkie::Alpha](Selkie--Alpha.md)). Below the midpoint you get `$from`'s discrete attributes, at and above it you get `$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 `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 `tick`ed. A tween is inert until `start`ed 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 `finish`ed. **Not** called when the tween is `cancel`led. Optional.

### method start

```raku
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

```raku
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

```raku
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

```raku
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

```raku
method running() returns Bool
```

True between `start` and completion / cancellation.

### method done

```raku
method done() returns Bool
```

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

### method progress

```raku
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

```raku
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

```raku
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

```raku
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

```raku
method running() returns Bool
```

True while the group holds at least one running tween.

### method elems

```raku
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

```raku
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.