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