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


=begin pod

=head1 NAME

Selkie::App::Internal::RenderLoop - internal render, resize, and event-loop role for Selkie::App

=head1 DESCRIPTION

Implementation detail composed by C<Selkie::App>. Use C<Selkie::App.run>,
C<Selkie::App.quit>, C<Selkie::App.on-frame>, and C<Selkie::App.on-resize>
from application code.

=end pod

unit role Selkie::App::Internal::RenderLoop;

use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Input;

use Selkie::App::Internal::IdleBudget;
use Selkie::App::Internal::TerminalPlatform;
use Selkie::Container;
use Selkie::Event;
use Selkie::Trace;
use Selkie::Widget;

has @!frame-callbacks;
has @!resize-callbacks;
has Bool $!running = False;
has UInt $!rows = 0;
has UInt $!cols = 0;
has Instant $!last-resize-check = Instant.from-posix(0);
has Instant $!last-sprixel-refresh = Instant.from-posix(0);

# Set asynchronously by the SIGWINCH tap and consumed by
# !maybe-check-terminal-resize from the application thread.
has atomicint $!resize-pending = 0;

# Stored so shutdown can close the Supply tap before notcurses teardown.
has $!resize-tap;

method !set-terminal-size($rows, $cols --> Nil) {
    $!rows = $rows;
    $!cols = $cols;
}

method !terminal-size(--> List) {
    ($!rows, $!cols);
}

method !install-resize-tap(--> Nil) {
    my $resize-signal = terminal-resize-signals().head;
    return without $resize-signal;
    $!resize-tap = try signal($resize-signal).tap: {
        cas($!resize-pending, 0, 1)
    };
}

method !close-resize-tap(--> Nil) {
    $!resize-tap.?close;
    $!resize-tap = Nil;
}

method !register-frame-callback(&callback, Str :$name = '' --> Nil) {
    @!frame-callbacks.push({
        callback => &callback,
        name     => $name.chars ?? $name !! "frame-callback-{@!frame-callbacks.elems}",
    });
}

method !register-resize-callback(&callback --> Nil) {
    @!resize-callbacks.push(&callback);
}

method !request-quit(--> Nil) {
    $!running = False;
}

method !run-loop(--> Nil) {
    $!running = True;
    LEAVE self.shutdown;
    self!render-frame;

    # Scale the notcurses_get timespec to match the hot frame budget
    # so platforms where it DOES block (Linux) still cap at the right
    # rate. On macOS it returns immediately regardless; the sleep
    # fallback below is what actually caps the cadence there.
    my Int $hot-ns = (1_000_000_000 / self.hot-hz).Int;
    my $timeout = Timespec.new(tv_sec => 0, tv_nsec => $hot-ns);

    # --- Idle ladder ---------------------------------------------------
    # Tick-rate selection lives in pick-frame-budget. Two idle clocks
    # feed it:
    #
    #   user  — input event, resize, toast visibility change. Drives
    #           the full ladder: hot rate (default 60 Hz, configurable
    #           via hot-hz) for the first 30 s, then 30 Hz (30-60 s),
    #           12 Hz (60-120 s), 4 Hz (120 s+).
    #   store — store events / writes processed by tick(). Floored at
    #           30 Hz so background dispatch traffic keeps the queue
    #           draining promptly without pinning the loop at the hot
    #           rate for the worker's whole lifetime.
    #
    # App.animating overrides both: while the animation refcount is up
    # (a live Selkie::Tween, or a hand-rolled begin-animation) the
    # ladder is bypassed entirely, because an animation needs frames
    # whether or not the user has touched anything recently.
    my Num $hot-budget = (1e0 / self.hot-hz.Num);
    my Instant $last-user-activity  = now;
    my Instant $last-store-activity = now;
    while $!running {
        # Belt-and-braces: if the focused widget got detached between
        # ticks, rebind focus before dispatching input into a dangling ref.
        self.check-focus-invariant;

        my Instant $frame-start = now;
        my Bool $trace-on = Selkie::Trace.enabled;
        my $frame-span = $trace-on
            ?? Selkie::Trace.start('ui.frame', cat => 'ui', args => %(
                rows => $!rows, cols => $!cols, hot-hz => self.hot-hz,
            ))
            !! Nil;
        my Bool $user-activity  = False;
        my Bool $store-activity = False;
        my Int $input-events = 0;
        my Num $sleep-seconds = 0e0;

        my $ni = Ncinput.new;
        my $poll-span = $trace-on
            ?? Selkie::Trace.start('ui.input.poll', cat => 'ui')
            !! Nil;
        my $id = notcurses_get(self!notcurses-handle, $timeout, $ni);
        with $poll-span {
            .finish(result => $id);
        }
        if $id > 0 {
            my $input-span = $trace-on
                ?? Selkie::Trace.start('ui.input.dispatch', cat => 'ui')
                !! Nil;
            # Terminal-tab-switch sprixel recovery: when input arrives
            # after a long-enough user idle, force every Image to re-emit
            # its sprixel on the next render before dispatching the input.
            self!maybe-refresh-sprixels-after-idle($last-user-activity);
            $user-activity = True;
            my $ev = Selkie::Event.from-ncinput($ni);
            $input-events += self!dispatch-input-burst(self!notcurses-handle, $ev, $frame-start);
            with $input-span {
                .finish(events => $input-events);
            }
        }

        # Drain a pending SIGWINCH if our signal tap flagged one since the
        # last loop iteration.
        if $trace-on {
            my $resize-span = Selkie::Trace.start('ui.resize.check', cat => 'ui');
            my Bool $resized = self!maybe-check-terminal-resize;
            $resize-span.finish(resized => $resized);
            $user-activity = True if $resized;
        } else {
            $user-activity = True if self!maybe-check-terminal-resize;
        }

        self!maybe-refresh-sprixels-after-idle($last-user-activity)
            unless $user-activity;

        for @!frame-callbacks -> %entry {
            my $cb-span = $trace-on
                ?? Selkie::Trace.start('ui.frame-callback', cat => 'ui',
                    args => %(name => %entry<name>))
                !! Nil;
            %entry<callback>();
            with $cb-span {
                .finish;
            }
        }
        my $store-span = $trace-on
            ?? Selkie::Trace.start('ui.store.tick', cat => 'store')
            !! Nil;
        $store-activity = self.store.tick.Bool;
        with $store-span {
            .finish(activity => $store-activity);
        }

        my $focus-span = $trace-on
            ?? Selkie::Trace.start('ui.focus-actions', cat => 'ui')
            !! Nil;
        self!process-focus-actions;
        with $focus-span {
            .finish;
        }
        # Toast.tick returns True when visibility just flipped to False this
        # tick; force one fresh composite so the stale toast disappears.
        my $toast = self!toast-widget;
        my Bool $toast-hid = $toast ?? $toast.tick !! False;
        $user-activity = True if $toast-hid;
        self!maybe-unpark-toast($toast-hid);
        self!render-frame(:force($toast-hid));

        my Instant $frame-end = now;
        $last-user-activity  = $frame-end if $user-activity;
        $last-store-activity = $frame-end if $store-activity;

        my Num $budget = pick-app-frame-budget(
            $hot-budget,
            ($frame-end - $last-user-activity).Num,
            ($frame-end - $last-store-activity).Num,
            animating => self.animating,
        );
        my Num $remaining = $budget - ($frame-end - $frame-start).Num;
        while $remaining > 0e0 {
            last if $!resize-pending;
            my Num $chunk = $remaining min APP-SLEEP-CHUNK-MAX;
            sleep $chunk;
            $sleep-seconds += $chunk;
            $remaining -= $chunk;
        }
        with $frame-span {
            my Num $elapsed = (now - $frame-start).Num;
            .finish(
                elapsed_ms     => ($elapsed * 1000e0),
                budget_ms      => ($budget * 1000e0),
                work_ms        => (($frame-end - $frame-start).Num * 1000e0),
                sleep_ms       => ($sleep-seconds * 1000e0),
                over_budget    => $elapsed > $budget,
                user_activity  => $user-activity,
                store_activity => $store-activity,
                input_events   => $input-events,
            );
        }
    }

    CATCH {
        default {
            self.shutdown;
            $*ERR.say("Selkie crashed: {.message}");
            $*ERR.say(.backtrace.full);
            exit 1;
        }
    }
}

method !maybe-refresh-sprixels-after-idle(Instant:D $last-user-activity --> Bool) {
    my $threshold = self.sprixel-refresh-idle-threshold // 0e0;
    return False unless $threshold > 0e0;

    my $now = now;
    return False unless ($now - $last-user-activity).Num >= $threshold;

    my $interval = self.sprixel-refresh-idle-interval // 0.5e0;
    return False if $interval > 0e0
                 && ($now - $!last-sprixel-refresh).Num < $interval;

    self.force-refresh-sprixels;
    $!last-sprixel-refresh = $now;
    True;
}

method !mark-all-dirty(Selkie::Widget $w --> Nil) {
    $w.mark-dirty;
    if $w ~~ Selkie::Container {
        for $w.children -> $child {
            self!mark-all-dirty($child);
        }
    }
    if $w.can('content') && $w.content.defined {
        self!mark-all-dirty($w.content);
    }
}

method !check-terminal-resize(--> Bool) {
    my uint32 $r = 0;
    my uint32 $c = 0;
    notcurses_refresh(self!notcurses-handle, $r, $c);
    return False if $r == $!rows && $c == $!cols;

    $!rows = $r;
    $!cols = $c;

    # Propagate dims synchronously through all screens, every modal, and
    # the toast so parked or hidden surfaces are correct when revealed.
    self.screen-manager.handle-resize($!rows, $!cols);
    .handle-resize($!rows, $!cols) for self!modal-stack-list;
    my $toast = self!toast-widget;
    $toast.handle-resize($!rows, $!cols) if $toast;

    # Mark-dirty cascade: handle-resize short-circuits on unchanged child
    # allocations, so force every surface through the next render.
    self!mark-all-dirty(self.root) if self.root;
    self!mark-all-dirty($_)        for self!modal-stack-list;

    # Every Image must re-diff geometry and re-emit sprixels after a resize.
    self!mark-all-images-dirty;

    # Render synchronously so the terminal updates immediately.
    self!render-frame;

    # Re-sync notcurses's internal "what's on screen" state with the freshly
    # composited frame.
    my uint32 $rr = 0;
    my uint32 $cc = 0;
    notcurses_refresh(self!notcurses-handle, $rr, $cc);

    # App-level resize callbacks fire after the post-resize frame is on
    # screen so consumers can safely park or inspect live trees.
    .($!rows, $!cols) for @!resize-callbacks;
    True;
}

method !maybe-check-terminal-resize(--> Bool) {
    return False unless cas($!resize-pending, 1, 0) == 1;
    $!last-resize-check = now;
    self!check-terminal-resize;
}

method !raise-active-overlays($w --> Nil) {
    return unless $w.defined;

    if $w.can('children') {
        for $w.children -> $child {
            self!raise-active-overlays($child);
        }
    }

    if $w.can('content') {
        my $content = $w.content;
        self!raise-active-overlays($content) if $content.defined;
    }

    $w.raise-active-overlay if $w.can('raise-active-overlay');
}

method !render-frame(Bool :$force = False --> Nil) {
    my Bool $trace-on = Selkie::Trace.enabled;
    my $span = $trace-on
        ?? Selkie::Trace.start('ui.render-frame', cat => 'render',
            args => %(force => $force))
        !! Nil;
    my Bool $any-rendered = False;
    my $root = self.root;
    if $root && $root.is-dirty {
        if $trace-on {
            my $root-span = Selkie::Trace.start('ui.render.root', cat => 'render');
            $root.render;
            $root-span.finish;
        } else {
            $root.render;
        }
        # Greyscale post-pass for a whole disabled screen. Descendants
        # are greyed by their own container's hook; this one exists so
        # the root itself isn't a blind spot (nothing else renders it).
        $root.apply-disabled-effect if $root.disabled;
        $any-rendered = True;
    }

    # Only the topmost modal renders; hidden modals pay out their dirty marks
    # when close-modal reveals them.
    my $top = self!active-modal;
    if $top.defined && $top.is-dirty {
        if $trace-on {
            my $modal-span = Selkie::Trace.start('ui.render.modal', cat => 'render');
            $top.render;
            $modal-span.finish;
        } else {
            $top.render;
        }
        # Same blind-spot cover for the top-most modal: it renders from
        # here, not from a container, so nothing else would grey it.
        $top.apply-disabled-effect if $top.disabled;
        $any-rendered = True;
    }

    my $overlay-root = $top.defined ?? $top !! $root;
    self!raise-active-overlays($overlay-root) if $any-rendered && $overlay-root.defined;

    my $toast = self!toast-widget;
    if $toast && $toast.is-visible {
        if $trace-on {
            my $toast-span = Selkie::Trace.start('ui.render.toast', cat => 'render');
            $toast.render;
            $toast-span.finish;
        } else {
            $toast.render;
        }
        $any-rendered = True;
    }

    if $any-rendered || $force {
        if $trace-on {
            my $nc-span = Selkie::Trace.start('ui.notcurses-render', cat => 'render');
            notcurses_render(self!notcurses-handle);
            $nc-span.finish;
        } else {
            notcurses_render(self!notcurses-handle);
        }
    }
    with $span {
        .finish(any_rendered => $any-rendered);
    }
}