Selkie.git | lib/Selkie/App/Internal/ | Animation.rakumod


=begin pod

=head1 NAME

Selkie::App::Internal::Animation - internal tween-group and animation-refcount role for Selkie::App

=head1 DESCRIPTION

Implementation detail composed by C<Selkie::App>. Use
C<Selkie::App.tweens>, C<Selkie::App.begin-animation>,
C<Selkie::App.end-animation>, and C<Selkie::App.animating> from
application code, and L<Selkie::Tween> for the animation primitives
themselves.

Two pieces of state live here:

=item The B<animation refcount> — an C<atomicint> that the render loop reads once per frame to decide whether to bypass the idle ladder. Held separately from the tween group so that non-interpolated animation (a spinner while a job runs) can pin the loop too.
=item The B<lazy tween group> — created on first use of C<tweens>, at which point exactly one C<'selkie-tweens'> frame callback is registered. Apps that never animate never allocate the group and never pay for the callback; apps that do pay one empty-array check per frame while idle.

The refcount is atomic because C<begin-animation> / C<end-animation>
are public API and a store handler running on a worker thread is an
entirely plausible caller. The tween group is not thread-safe and is
only ever touched from the app thread's frame callback.

=end pod

unit role Selkie::App::Internal::Animation;

use Selkie::Tween;

# > 0 means "the render loop must stay at the hot budget". Owned by
# this role, read once per frame by RenderLoop via App.animating.
has atomicint $!animation-refs;

has TweenGroup $!tween-group;

method !begin-animation(--> Int) {
    ($!animation-refs⚛++) + 1;
}

method !end-animation(--> Int) {
    # Clamp at zero without ever letting a concurrent begin/end pair
    # observe a negative count: re-read and retry rather than
    # decrementing first and correcting afterwards.
    loop {
        my int $current = ⚛$!animation-refs;
        return 0 if $current <= 0;
        return $current - 1
            if cas($!animation-refs, $current, $current - 1) == $current;
    }
}

method !animating(--> Bool) {
    ⚛$!animation-refs > 0;
}

method !tween-group(--> TweenGroup) {
    $!tween-group //= do {
        my $group = TweenGroup.new(
            on-active => { self!begin-animation },
            on-idle   => { self!end-animation   },
        );
        # One callback for the whole app, registered once. Ticking an
        # empty group is an array-length check, so the cost of having
        # ever animated is negligible once everything has settled.
        self.on-frame({ $group.tick }, name => 'selkie-tweens');
        $group;
    };
}