Selkie.git | lib/Selkie/Widget/ | Toast.rakumod
=begin pod
=head1 NAME
Selkie::Widget::Toast - Transient overlay notification
=head1 SYNOPSIS
You normally use C<$app.toast(...)> which manages the widget for you:
=begin code :lang<raku>
$app.toast('Settings saved');
$app.toast('Connection lost', duration => 5e0);
=end code
Direct construction is rarely needed.
=head1 DESCRIPTION
A centered single-line message bar that auto-dismisses. By convention
rendered near the bottom of the screen.
Unlike most widgets, Toast does B<not> own a backing plane covering its
full area — that would obscure the widgets behind it. Instead it
manages a small inline plane, created on C<show> and destroyed on hide,
attached directly to the parent stdplane via C<attach>.
The C<Selkie::App.toast> wrapper hides these details: it lazily
constructs the widget, calls C<attach>, and ensures the correct size
on each invocation.
=head2 Fading
Off by default. C«Selkie::App.new(:animate-toast)» turns it on, at
which point C<Selkie::App> hands the toast its tween group via
C<enable-fade> and every C<show> resolves the bar up out of the screen
background over C<fade-in-seconds> and dissolves it back over
C<fade-out-seconds> before it disappears.
=begin code :lang<raku>
my $app = Selkie::App.new(theme => $theme, :animate-toast);
$app.toast('Saved'); # fades in, holds, fades out
# Driving a detached Toast yourself:
$toast.enable-fade($app.tweens);
$toast.show('Saved', duration => 2e0);
=end code
What ramps is B<colour>, not alpha — notcurses alpha is a two-bit enum
with no intermediate states (L<Selkie::Alpha>), so a fade is a walk
between two RGB values. The far end is C«theme.base.bg» for both the
foreground and the background, i.e. a bar the same colour as the screen
behind it. A theme whose C<base> carries no C<bg> has no colour to fade
from, and the toast simply appears and disappears as it always has.
The out-fade is armed from inside C<tick>, which C<Selkie::App> already
calls once per frame: when the remaining lifetime drops below
C<fade-out-seconds> the toast adds one reversed tween to the group and
never looks at the clock again. Both tweens are bounded, and the toast
cancels whatever is in flight on the next C<show>, on dismissal, and in
C<destroy> — so a fade can never outlive the plane it paints into.
Timings are C<fade-in-seconds> (0.1) and C<fade-out-seconds> (0.2),
settable at construction. A toast whose whole duration is shorter than
C<fade-out-seconds> gets an out-fade clamped to the time it actually
has left rather than one that would run past its own dismissal.
=head1 EXAMPLES
=head2 Custom styling
=begin code :lang<raku>
# Red warning style
$app.store.subscribe-with-callback(
'errors',
-> $s { $s.get-in('error') // '' },
-> $msg {
if $msg.chars > 0 {
$app.toast($msg); # default blue-highlight style
}
},
$some-widget,
);
=end code
=head1 SEE ALSO
=item L<Selkie::App> — C<toast(...)> wrapper is the normal entry point
=item L<Selkie::Tween> — the interpolation the fade runs on
=item L<Selkie::Alpha> — why a fade is a colour ramp and never an alpha ramp
=end pod
use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;
use Selkie::Widget;
use Selkie::Style;
use Selkie::Tween;
unit class Selkie::Widget::Toast does Selkie::Widget;
has Str $.message = '';
has Selkie::Style $.style;
has Num $.duration = 2e0; # seconds
has Instant $!show-time;
has Bool $!visible = False;
has NcplaneHandle $!parent-plane;
has NcplaneHandle $!toast-plane;
has UInt $!screen-rows = 0;
has UInt $!screen-cols = 0;
#| Seconds the toast takes to resolve up out of the screen background.
#| Only consulted once C<enable-fade> has been called.
has Num $.fade-in-seconds = 0.1e0;
#| Seconds the toast takes to dissolve back into it, ending as the
#| duration expires. Clamped to the lifetime actually remaining.
has Num $.fade-out-seconds = 0.2e0;
# The group the fades run on, or the type object when fading is off —
# which is what every consumer that hasn't opted in stays at, and what
# makes the fade code below cost one undefined-check per show/tick.
has TweenGroup $!fade-group;
# The live fade (in or out), so the next show / the dismissal / destroy
# can cancel it. A cancelled tween fires no on-complete, so nothing it
# left behind can clobber the state that replaced it.
has Tween $!fade-tween;
# Set once the out-fade has been armed for the current toast, so `tick`
# arms it exactly once rather than on every frame of the tail.
has Bool $!fade-out-armed = False;
# The style `render` actually paints, while a fade is mid-flight.
# Undefined the rest of the time, which is when the toast paints from
# `$!style` exactly as it always has.
has Selkie::Style $!render-style;
#| Attach to the standard plane. Called once by C<Selkie::App> in
#| place of the usual C<init-plane> — Toast lives outside the widget
#| tree (so it can paint on top of any screen and any modal) so it
#| doesn't adopt a plane of its own; the toast-plane is created lazily
#| in C<render> on first show.
method attach(NcplaneHandle $parent-plane, UInt :$rows, UInt :$cols) {
$!parent-plane = $parent-plane;
$!screen-rows = $rows;
$!screen-cols = $cols;
}
#|( Toast lives at screen-top, outside the widget tree, so it doesn't
receive the normal handle-resize cascade from containers. App
calls this directly when the terminal resizes so the toast-plane
sits at the correct width. )
method handle-resize(UInt $rows, UInt $cols) {
$!screen-rows = $rows;
$!screen-cols = $cols;
}
#| Back-compat alias. Deprecated — prefer handle-resize.
method resize-screen(UInt $rows, UInt $cols) {
self.handle-resize($rows, $cols);
}
#|( Show a toast message for C<:duration> seconds. Re-callable while a
toast is already showing — the new message replaces the old and
the duration restarts from C<:at>. Apps don't usually call this
directly; prefer C<$app.toast(...)> which routes here.
C<:at> is the toast's zero point, defaulting to C<now>; pass it
explicitly to drive the lifetime (and the fades) from a test clock
rather than the wall clock. )
method show(Str:D $message, Num :$duration = 2e0,
Selkie::Style :$style, Instant :$at = now) {
$!message = $message;
$!duration = $duration;
$!style = $style // self.theme.toast;
$!show-time = $at;
$!visible = True;
self!cancel-fade;
$!fade-out-armed = False;
self!start-fade-in($at) if $!fade-group;
self.mark-dirty;
}
#| True while a toast is currently being shown (between C<show> and
#| the next C<tick> that observes the duration has expired).
method is-visible(--> Bool) { $!visible }
#|( Turn fading on and hand the toast the C<Selkie::Tween::TweenGroup>
its fades run on — normally C<Selkie::App.tweens>, wired
automatically when the app was built with C«:animate-toast».
Idempotent, and takes effect from the next C<show>: a toast already
on screen keeps whatever it is doing. )
method enable-fade(TweenGroup:D $group --> Nil) {
$!fade-group = $group;
Nil;
}
#|( Go back to appearing and vanishing. Any fade in flight is cancelled
and the toast drops straight to its own style, so a "reduce motion"
preference flipped mid-fade lands somewhere sane rather than
freezing a half-transparent bar on screen. )
method disable-fade(--> Nil) {
self!cancel-fade;
$!fade-group = TweenGroup;
self.mark-dirty if $!visible;
Nil;
}
#| True once C<enable-fade> has been called and C<disable-fade> hasn't.
method fade-enabled(--> Bool) { $!fade-group.defined }
#| True while a fade-in or fade-out is actually running. A testing hook.
method fading(--> Bool) { $!fade-tween.defined && $!fade-tween.running }
#|( The style C<render> paints with right now: the fade's current
interpolation while one is in flight, and the toast's own C<style>
otherwise. Public so a fade can be asserted without a plane. )
method render-style(--> Selkie::Style) { $!render-style // $!style }
#|( The colour a fade starts from (and ends at): the theme's C<base>
background on both channels, with every discrete attribute copied
from C<$to> so nothing snaps at the midpoint. Returns C<$to>
unchanged when the theme's C<base> has no background — there is no
colour to fade from, so there is no fade. )
method !fade-from(Selkie::Style:D $to --> Selkie::Style) {
my $neutral = self.theme.base.bg;
return $to without $neutral;
Selkie::Style.new(
fg => $neutral,
bg => $neutral,
bold => $to.bold,
italic => $to.italic,
underline => $to.underline,
strikethrough => $to.strikethrough,
fg-alpha => $to.fg-alpha,
bg-alpha => $to.bg-alpha,
);
}
method !start-fade-in(Instant:D $at --> Nil) {
my $to = $!style;
return without $to;
my $from = self!fade-from($to);
$!render-style = $from;
$!fade-tween = $!fade-group.add(
Tween.new(
duration => $!fade-in-seconds,
easing => EaseOutQuad,
on-update => -> Num $t {
self!set-render-style(lerp-style($from, $to, $t));
},
# Settle on the toast's own style rather than the tween's
# last sample: `render` then takes the untouched historical
# path for the whole middle of the toast's life.
on-complete => { self!end-fade },
),
:$at,
);
Nil;
}
method !start-fade-out(Instant:D $at, Num:D $seconds --> Nil) {
my $to = $!style;
return without $to;
my $from = self!fade-from($to);
$!fade-tween = $!fade-group.add(
Tween.new(
duration => $seconds,
easing => EaseInQuad,
# `reverse` inverts the value, not the clock: on-update
# walks 1 → 0, so the same lerp expression as the fade-in
# runs backwards while the curve keeps accelerating away.
reverse => True,
on-update => -> Num $t {
self!set-render-style(lerp-style($from, $to, $t));
},
# No on-complete reset: the toast is a frame or two from
# being dismissed and the background-coloured bar it lands
# on is exactly what should be on screen until it is.
),
:$at,
);
Nil;
}
method !set-render-style(Selkie::Style:D $s --> Nil) {
$!render-style = $s;
self.mark-dirty;
Nil;
}
method !end-fade(--> Nil) {
$!render-style = Selkie::Style;
$!fade-tween = Tween;
self.mark-dirty;
Nil;
}
# Stop whatever is animating and drop the interpolated style. Cancel
# fires no on-complete, so the reset has to happen here.
method !cancel-fade(--> Nil) {
with $!fade-tween {
.cancel;
$!fade-tween = Tween;
}
$!render-style = Selkie::Style;
Nil;
}
#|( Advance the toast's lifetime clock. Called once per frame by
C<Selkie::App>. When the duration has elapsed, the toast flips to
invisible and its plane is destroyed.
Returns C<True> when visibility I<just transitioned> from visible
to invisible this tick — the caller (C<Selkie::App>) treats that
as a signal to force one more composite render so the toast is
actually erased from the terminal. Returns C<False> otherwise
(toast is still visible, or was never visible this tick).
C<$at> defaults to C<now>; pass it to drive the lifetime from a test
clock. This is also where the out-fade is armed, once, when the
remaining lifetime drops under C<fade-out-seconds> — a check that
costs one subtraction on a toast that isn't fading. )
method tick(Instant $at = now --> Bool) {
return False unless $!visible;
my Num $elapsed = ($at - $!show-time).Num;
if $elapsed >= $!duration {
$!visible = False;
self!cancel-fade;
self!destroy-toast-plane;
return True;
}
if $!fade-group && !$!fade-out-armed
&& $elapsed >= $!duration - $!fade-out-seconds {
$!fade-out-armed = True;
# Clamp to the lifetime actually left: a toast shorter than
# fade-out-seconds would otherwise arm a fade that outlives its
# own dismissal, holding the render loop hot past the point
# anything is on screen.
my Num $left = ($!duration - $elapsed) max 0e0;
self!start-fade-out($at, $!fade-out-seconds min $left);
}
False;
}
method render() {
return unless $!visible;
return without $!parent-plane;
return unless $!screen-cols > 4;
my $display = " {$!message} ";
my $toast-w = ($display.chars + 4) min $!screen-cols;
my $toast-x = ($!screen-cols - $toast-w) div 2;
my $toast-y = $!screen-rows - 2;
$toast-y = 0 if $toast-y < 0;
if $!toast-plane {
ncplane_move_yx($!toast-plane, $toast-y, $toast-x);
ncplane_resize_simple($!toast-plane, 1, $toast-w);
} else {
my $opts = NcplaneOptions.new(
y => $toast-y, x => $toast-x,
rows => 1, cols => $toast-w,
);
$!toast-plane = ncplane_create($!parent-plane, $opts);
}
return without $!toast-plane;
# `render-style` is `$!style` verbatim unless a fade is in flight,
# so the non-fading path is byte-for-byte what it always was.
my $paint = self.render-style;
ncplane_set_fg_rgb($!toast-plane, $paint.fg) if $paint.fg.defined;
ncplane_set_bg_rgb($!toast-plane, $paint.bg) if $paint.bg.defined;
ncplane_set_styles($!toast-plane, $paint.styles);
ncplane_erase($!toast-plane);
my $pad = ($toast-w - $display.chars) max 0;
my $left = $pad div 2;
ncplane_putstr_yx($!toast-plane, 0, $left, $display);
self.clear-dirty;
}
method !destroy-toast-plane() {
if $!toast-plane {
ncplane_destroy($!toast-plane);
$!toast-plane = NcplaneHandle;
}
}
#| Tear down the toast plane and cancel any fade still running against
#| it. Called by C<Selkie::App.shutdown>; apps don't usually call this
#| directly.
method destroy() {
self!cancel-fade;
self!destroy-toast-plane;
}