Selkie.git | t/ | 97-motion-fade.rakutest
use Test;
use lib 'lib';
use Selkie::Alpha;
use Selkie::App;
use Selkie::App::Internal::Animation;
use Selkie::App::Internal::OverlayTree;
use Selkie::App::Internal::ScreenModalLifecycle;
use Selkie::Style;
use Selkie::Theme;
use Selkie::Tween;
use Selkie::Widget::Modal;
use Selkie::Widget::Toast;
=begin pod
The two pieces of motion the framework owns: the scrimmed modal's
backdrop fade and the toast's fade in / out. Both are B<opt-in> and both
are colour ramps, never alpha ramps.
Three things carry the feature, and each of them is a subtest below.
=item B<The flag is off by default, and off means nothing happens at all.> Not "a tween that finishes immediately" — no tween, no group, no per-frame ticker. Every existing consumer composites the identical cells it always did, which is the only reason this could land in a framework with downstream apps pinned to golden snapshots.
=item B<Every tween is owned and cancellable.> A fade that outlives the plane it paints into is the one genuinely dangerous failure mode here: the modal is destroyed synchronously by C<close-modal> and the toast's plane is destroyed the moment its duration expires. So C<destroy>, C<set-backdrop>, a second C<show>, and a second C<fade-backdrop-in> all cancel what is in flight.
=item B<Nothing leaks.> The group must be empty and idle after every cycle. A stranded tween holds the app's animation refcount, which pins the render loop at 60 Hz forever — the exact "ambient animation" failure L<Selkie::Tween>'s Pod forbids.
Everything is plane-free and driven with explicit C<Instant>s: no
terminal, no sleeps, no wall-clock flakiness.
The channel words are hand-derived from the notcurses layout in the same
style as C<t/89-modal-backdrop>, so they guard the real output rather
than restating the code that produced it.
=end pod
plan 16;
# fg/bg halves of `Selkie::Theme.default.base` and `modal-scrim`. 0x40 in
# the top byte of a half is "explicit RGB, not the terminal default";
# 0x50 is that plus NCALPHA_BLEND (0x10000000).
constant NEUTRAL-SCRIM-CHANNELS = 0x501A1A2E501A1A2E; # base.bg, blended
constant FULL-SCRIM-CHANNELS = 0x5000000050000000; # black, blended
# ---------------------------------------------------------------------
# Modal backdrop fade
# ---------------------------------------------------------------------
subtest 'fade endpoints exist only where there is a scrim to fade' => {
plan 5;
my $none = Selkie::Widget::Modal.new(backdrop => BackdropNone);
is $none.backdrop-fade-endpoints.elems, 0,
'BackdropNone has nothing to ramp';
my $opaque = Selkie::Widget::Modal.new;
is $opaque.backdrop-fade-endpoints.elems, 0,
'BackdropOpaque paints a solid plane, not a tint — nothing to ramp';
my $scrim = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
is $scrim.backdrop-fade-endpoints.elems, 2,
'BackdropScrim yields a (from, to) pair';
# A theme whose base carries no background gives the ramp no colour
# to start from. Better to skip the animation than to invent one.
my $bare = Selkie::Theme.default.clone(
base => Selkie::Style.new(fg => 0xFFFFFF),
);
my $bare-modal = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
$bare-modal.set-theme($bare);
is $bare-modal.backdrop-fade-endpoints.elems, 0,
'a theme with no base background has no neutral to fade from';
is $bare-modal.scrim-channels, $bare.modal-scrim.channels,
'and the scrim still renders at full strength';
};
subtest 'the ramp moves colour and holds alpha' => {
plan 6;
my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
my ($from, $to) = $m.backdrop-fade-endpoints;
is $from.bg, Selkie::Theme.default.base.bg,
'the ramp starts at the screen background';
is $to.bg, Selkie::Theme.default.modal-scrim.bg,
'and ends at the theme slot';
# This is the whole reason a fade is a colour ramp: alpha is a
# two-bit enum, so a moving alpha would snap at the midpoint and
# read as a pop rather than a fade.
is $from.fg-alpha, AlphaBlend, 'fg alpha is blended at the start';
is $from.bg-alpha, AlphaBlend, 'bg alpha is blended at the start';
is $from.fg-alpha, $to.fg-alpha, 'and never moves on the fg channel';
is $from.bg-alpha, $to.bg-alpha, 'nor on the bg channel';
};
subtest 'the fade lands exactly on the theme slot' => {
plan 6;
my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
my $g = TweenGroup.new;
my $t0 = Instant.from-posix(1_000);
# Pinned rather than left on the widget default: `Selkie::Theme.default`
# builds a fresh Theme on every call, and the last assertion here is
# about object identity.
my $theme = Selkie::Theme.default;
$m.set-theme($theme);
is $m.scrim-channels, FULL-SCRIM-CHANNELS,
'before any fade the scrim is the theme slot';
my $tween = $m.fade-backdrop-in($g, duration => 0.12, at => $t0);
isa-ok $tween, Tween, 'fade-backdrop-in hands back the tween';
$g.tick($t0);
is $m.scrim-channels, NEUTRAL-SCRIM-CHANNELS,
'the first frame tints the screen with its own background — no visible dim yet';
$g.tick($t0 + 0.06);
ok NEUTRAL-SCRIM-CHANNELS > $m.scrim-channels > FULL-SCRIM-CHANNELS,
'the midpoint sits between the two ends';
$g.tick($t0 + 0.12);
is $m.scrim-channels, FULL-SCRIM-CHANNELS,
'and it settles on the slot exactly, not a rounding step short';
ok $m.effective-scrim-style === $theme.modal-scrim,
'the override is dropped, not left holding an equal-looking copy';
};
subtest 'a finished fade leaves nothing behind' => {
plan 4;
my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
my $g = TweenGroup.new;
my $t0 = Instant.from-posix(2_000);
$m.fade-backdrop-in($g, duration => 0.12, at => $t0);
ok $g.running, 'the group is busy while it runs';
$g.tick($t0 + 0.12);
is $g.elems, 0, 'the group has dropped the tween';
nok $g.running, 'and reports itself idle';
nok $m.backdrop-fading, 'the modal no longer thinks it is fading';
};
subtest 'destroying a modal mid-fade cancels it' => {
plan 4;
my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
my $g = TweenGroup.new;
my $t0 = Instant.from-posix(3_000);
$m.fade-backdrop-in($g, duration => 0.12, at => $t0);
$g.tick($t0 + 0.03);
ok $m.backdrop-fading, 'mid-flight';
# This is the close-modal path: App pops the modal and destroys it
# on the spot. A surviving tween would keep calling set-scrim-style
# on a widget whose planes are gone.
$m.destroy;
nok $m.backdrop-fading, 'destroy cancelled the fade';
is $m.scrim-channels, FULL-SCRIM-CHANNELS,
'and dropped the half-way colour override';
$g.tick($t0 + 0.04);
is $g.elems, 0, 'the group drops the cancelled tween on its next tick';
};
subtest 'set-backdrop mid-fade cancels it' => {
plan 3;
my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
my $g = TweenGroup.new;
my $t0 = Instant.from-posix(4_000);
$m.fade-backdrop-in($g, duration => 0.12, at => $t0);
$g.tick($t0 + 0.03);
$m.set-backdrop(BackdropOpaque);
nok $m.backdrop-fading,
'switching modes abandons a ramp toward a scrim that is no longer drawn';
is $m.backdrop, BackdropOpaque, 'and the mode actually changed';
$g.tick($t0 + 0.04);
nok $g.running, 'the group is released';
};
subtest 'a second fade replaces the first rather than stacking' => {
plan 3;
my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
my $g = TweenGroup.new;
my $t0 = Instant.from-posix(5_000);
my $first = $m.fade-backdrop-in($g, duration => 0.12, at => $t0);
$g.tick($t0 + 0.03);
my $second = $m.fade-backdrop-in($g, duration => 0.12, at => $t0 + 0.03);
nok $first.running, 'the first tween was cancelled';
ok $second.running, 'the second is the live one';
$g.tick($t0 + 0.15);
is $g.elems, 0, 'and only one of them survives to finish';
};
subtest 'fade-backdrop-in is inert outside scrim mode' => {
plan 4;
my $g = TweenGroup.new;
my $t0 = Instant.from-posix(6_000);
for BackdropOpaque, BackdropNone -> $mode {
my $m = Selkie::Widget::Modal.new(backdrop => $mode);
is $m.fade-backdrop-in($g, at => $t0), Nil,
"$mode has no scrim, so no tween";
}
is $g.elems, 0, 'nothing was added to the group';
nok $g.running, 'and it never went busy';
};
subtest 'the scrim override is a public knob in its own right' => {
plan 4;
my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
my $custom = Selkie::Style.new(
fg => 0x101018, bg => 0x101018,
fg-alpha => AlphaBlend, bg-alpha => AlphaBlend,
);
$m.clear-dirty;
$m.set-scrim-style($custom);
ok $m.is-dirty,
'set-scrim-style marks dirty — the backdrop is only re-primed by render';
is $m.scrim-channels, $custom.channels, 'and the override is what renders';
$m.clear-dirty;
$m.clear-scrim-style;
ok $m.is-dirty, 'clearing marks dirty too';
is $m.scrim-channels, FULL-SCRIM-CHANNELS, 'and restores the theme slot';
};
# ---------------------------------------------------------------------
# Toast fade
# ---------------------------------------------------------------------
subtest 'a toast that was never given a group does not fade' => {
plan 4;
my $t = Selkie::Widget::Toast.new;
my $t0 = Instant.from-posix(7_000);
nok $t.fade-enabled, 'fading is off by default';
$t.show('hello', duration => 2e0, at => $t0);
is $t.render-style, $t.style,
'render-style is the toast style verbatim — the historical paint path';
nok $t.tick($t0 + 1.9), 'still up just before the duration';
ok $t.tick($t0 + 2.0), 'and dismissed on the tick that crosses it';
};
subtest 'the toast fades in from the screen background' => {
plan 5;
my $t = Selkie::Widget::Toast.new;
my $g = TweenGroup.new;
my $t0 = Instant.from-posix(8_000);
$t.enable-fade($g);
$t.show('saved', duration => 2e0, at => $t0);
ok $t.fading, 'a fade is running from the moment show returns';
my $neutral = Selkie::Theme.default.base.bg;
$g.tick($t0);
is $t.render-style.bg, $neutral,
'the first frame is the screen background — invisible against the screen';
is $t.render-style.fg, $neutral, 'on both channels';
$g.tick($t0 + 0.1);
is $t.render-style, $t.style,
'and it settles on the toast style object itself, not a copy';
is $g.elems, 0, 'with the group drained';
};
subtest 'the out-fade is armed once and lands as the toast dies' => {
plan 7;
my $t = Selkie::Widget::Toast.new;
my $g = TweenGroup.new;
my $t0 = Instant.from-posix(9_000);
$t.enable-fade($g);
$t.show('saved', duration => 2e0, at => $t0);
$g.tick($t0 + 0.1); # fade-in completes
nok $t.fading, 'nothing animating through the middle of the toast life';
$t.tick($t0 + 1.0);
nok $t.fading, 'still nothing at the halfway mark';
$t.tick($t0 + 1.81); # duration - fade-out-seconds = 1.8
ok $t.fading, 'the out-fade arms as the tail begins';
is $g.elems, 1, 'exactly one tween';
# Arming is idempotent: every subsequent frame must not stack
# another fade on top.
$t.tick($t0 + 1.85);
$t.tick($t0 + 1.90);
is $g.elems, 1, 'later frames in the tail do not re-arm it';
$g.tick($t0 + 2.0);
is $t.render-style.bg, Selkie::Theme.default.base.bg,
'the ramp ends back at the screen background';
ok $t.tick($t0 + 2.0), 'and the toast dismisses on schedule';
};
subtest 'nothing survives the toast it belonged to' => {
plan 6;
my $g = TweenGroup.new;
my $t0 = Instant.from-posix(10_000);
# Dismissal mid-fade.
my $t = Selkie::Widget::Toast.new;
$t.enable-fade($g);
$t.show('one', duration => 2e0, at => $t0);
$t.tick($t0 + 1.9);
ok $t.fading, 'out-fade running';
$t.tick($t0 + 2.0);
nok $t.fading, 'dismissal cancels it — the plane is about to go';
$g.tick($t0 + 2.01);
is $g.elems, 0, 'and the group drains';
# A replacement toast while the previous one is still fading in.
$t.show('two', duration => 2e0, at => $t0 + 3);
my $first-in = $g.elems;
$t.show('three', duration => 2e0, at => $t0 + 3.02);
$g.tick($t0 + 3.02);
is $first-in, 1, 'the first show armed one fade';
is $g.elems, 1, 'and the second replaced it rather than stacking';
# Teardown.
$t.destroy;
nok $t.fading, 'destroy cancels whatever was in flight';
};
# ---------------------------------------------------------------------
# The opt-in flags
# ---------------------------------------------------------------------
# The host contract the two roles need for the motion decision, and
# nothing else. `Selkie::App.new` calls notcurses_init, so the decision
# is exercised here exactly the way t/95 exercises the refcount.
class StubApp
does Selkie::App::Internal::Animation
does Selkie::App::Internal::OverlayTree
does Selkie::App::Internal::ScreenModalLifecycle
{
has @.frame-callbacks;
has Bool $.animate-backdrop is rw = False;
has Bool $.animate-toast is rw = False;
method on-frame(&callback, Str :$name = '') {
@!frame-callbacks.push({ callback => &callback, name => $name });
}
# Re-expose the private seams the way App's own show paths reach
# them.
method maybe-fade-backdrop($modal) { self!maybe-fade-backdrop($modal) }
method sync-toast-fade($toast) { self!sync-toast-fade($toast) }
method animating(--> Bool) { self!animating }
}
subtest 'flag off means no tween, no group, no ticker' => {
plan 8;
my $app = StubApp.new;
my $modal = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
my $toast = Selkie::Widget::Toast.new;
is $app.maybe-fade-backdrop($modal), Nil, 'no backdrop tween';
nok $modal.backdrop-fading, 'the modal is not animating';
is $modal.scrim-channels, FULL-SCRIM-CHANNELS,
'and its scrim is the theme slot, exactly as before this feature';
$app.sync-toast-fade($toast);
nok $toast.fade-enabled, 'the toast was handed no group';
# The load-bearing assertion for back-compat: an app that never opts
# in must not even allocate the tween group, because allocating it
# registers a per-frame callback on every consumer that never asked
# for motion.
is $app.frame-callbacks.elems, 0, 'no frame callback was registered';
nok $app.animating, 'and the render loop is never pinned';
# Flipping the flag off again after it was on must actually take
# the fade away, not just stop renewing it.
$app.animate-toast = True;
$app.sync-toast-fade($toast);
$app.animate-toast = False;
$app.sync-toast-fade($toast);
nok $toast.fade-enabled, 'turning the flag back off disables the fade';
is $app.frame-callbacks.elems, 1,
'the group, once allocated, is still registered exactly once';
};
subtest 'flag on wires both overlays to the app group' => {
plan 6;
my $app = StubApp.new;
$app.animate-backdrop = True;
$app.animate-toast = True;
my $modal = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
my $tween = $app.maybe-fade-backdrop($modal);
isa-ok $tween, Tween, 'show-modal gets a tween back';
ok $app.animating, 'which pins the render loop to the hot budget';
my $toast = Selkie::Widget::Toast.new;
$app.sync-toast-fade($toast);
ok $toast.fade-enabled, 'and the toast is wired to the same group';
is $app.frame-callbacks.elems, 1,
'both overlays share one ticker';
is $app.frame-callbacks[0]<name>, 'selkie-tweens',
'the documented one';
# Drain it the way the render loop would, and confirm the refcount
# comes back down: an animation that never releases is the failure
# this whole design is arranged to prevent.
$tween.finish;
$app.frame-callbacks[0]<callback>();
nok $app.animating, 'and the loop is released once nothing is running';
};
subtest 'App exposes the motion flags with motion off by default' => {
plan 6;
for <animate-backdrop animate-toast
set-animate-backdrop set-animate-toast> -> $name {
ok Selkie::App.^can($name).elems > 0,
"$name is available on Selkie::App";
}
# Read the declared default without constructing an App (which would
# need a terminal). A literal default is stored as the value itself;
# a computed one as a closure — handle both so this doesn't become a
# Rakudo-internals tripwire.
for <animate-backdrop animate-toast> -> $name {
my $attr = Selkie::App.^attributes.first({ .name eq '$!' ~ $name });
my $build = $attr.build;
my $default = $build ~~ Callable ?? $build.(Selkie::App, Nil) !! $build;
nok $default, "$name defaults to False — motion is opt-in";
}
};