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