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