306 lines
9.0 KiB
Rust
306 lines
9.0 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! Toast / Snackbar — short-lived bottom-anchored message overlay.
|
|
//!
|
|
//! [`Toast`] is **not** an [`Element`]; it is a builder that produces
|
|
//! an [`OverlaySpec`] for the application to return from
|
|
//! [`App::overlays`](crate::App::overlays). The widget itself is
|
|
//! stateless: the application owns the visibility / timer state and
|
|
//! returns the overlay only while a toast is pending.
|
|
//!
|
|
//! Auto-dismissal is the application's responsibility. A typical
|
|
//! pattern is to spawn a `calloop` timer (or use the channel sender
|
|
//! from [`App::set_channel_sender`](crate::App::set_channel_sender))
|
|
//! that fires a "toast expired" message after [`Toast::duration`]
|
|
//! elapses.
|
|
//!
|
|
//! ```rust,no_run
|
|
//! # use ltk::{ toast, OverlaySpec, WidgetId };
|
|
//! # #[ derive( Clone ) ] enum Msg {}
|
|
//! # struct ToastState { message: String }
|
|
//! # struct App { toast: Option<ToastState> }
|
|
//! # impl App {
|
|
//! fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
|
|
//! {
|
|
//! match &self.toast
|
|
//! {
|
|
//! Some( t ) => vec![ toast( &t.message ).id( WidgetId( "toast/main" ) ).overlay() ],
|
|
//! None => vec![],
|
|
//! }
|
|
//! }
|
|
//! # }
|
|
//! ```
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::hash::{ Hash, Hasher };
|
|
use std::time::Duration;
|
|
|
|
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
|
|
use crate::types::{ Color, WidgetId };
|
|
use super::Element;
|
|
|
|
mod theme
|
|
{
|
|
use crate::types::Color;
|
|
/// Toast pill background — the theme's elevated surface token so
|
|
/// the toast reads as a "card" against the page below in both
|
|
/// light and dark modes.
|
|
pub fn bg() -> Color { crate::theme::palette().surface_alt }
|
|
/// Body text colour — primary text token from the theme.
|
|
pub fn text() -> Color { crate::theme::palette().text_primary }
|
|
pub const HEIGHT: u32 = 56;
|
|
pub const BOTTOM_MARGIN: i32 = 32;
|
|
pub const PAD_H: f32 = 24.0;
|
|
pub const FONT_SIZE: f32 = 16.0;
|
|
pub const RADIUS: f32 = 28.0;
|
|
}
|
|
|
|
/// Builder for a transient bottom-anchored notification overlay.
|
|
///
|
|
/// `Toast` does not own its own timer — the application is responsible
|
|
/// for clearing the toast (typically by storing its `message` in an
|
|
/// `Option` and resetting it via a delayed message).
|
|
pub struct Toast<Msg: Clone>
|
|
{
|
|
/// Stable id for the overlay. Used to derive the [`OverlayId`] so
|
|
/// the same toast persists across frames when the message stays the
|
|
/// same.
|
|
pub id: WidgetId,
|
|
/// Body text rendered inside the pill.
|
|
pub message: String,
|
|
/// Display duration. The runtime does **not** consume this — it is
|
|
/// returned through [`Toast::duration_value`] so the app's timer
|
|
/// scheduler can read it back.
|
|
pub duration: Duration,
|
|
/// Optional message fired when the user taps anywhere outside the
|
|
/// pill. Convenient for "tap anywhere to dismiss" behaviour.
|
|
pub on_dismiss: Option<Msg>,
|
|
/// Optional override for the pill background colour.
|
|
pub bg: Option<Color>,
|
|
/// Optional override for the body text colour.
|
|
pub fg: Option<Color>,
|
|
}
|
|
|
|
impl<Msg: Clone + 'static> Toast<Msg>
|
|
{
|
|
/// Create a toast with the given message text. The default id is
|
|
/// `"toast/default"`; override with [`Self::id`] when more than one
|
|
/// toast variant might coexist.
|
|
pub fn new( message: impl Into<String> ) -> Self
|
|
{
|
|
Self
|
|
{
|
|
id: WidgetId( "toast/default" ),
|
|
message: message.into(),
|
|
duration: Duration::from_secs( 2 ),
|
|
on_dismiss: None,
|
|
bg: None,
|
|
fg: None,
|
|
}
|
|
}
|
|
|
|
/// Override the stable id used to derive the [`OverlayId`].
|
|
pub fn id( mut self, id: WidgetId ) -> Self
|
|
{
|
|
self.id = id;
|
|
self
|
|
}
|
|
|
|
/// Set the display duration. The toast itself does not auto-hide;
|
|
/// this value is stored so the application's timer scheduler can
|
|
/// read it back via [`Self::duration_value`].
|
|
pub fn duration( mut self, secs: f32 ) -> Self
|
|
{
|
|
self.duration = Duration::from_secs_f32( secs.max( 0.0 ) );
|
|
self
|
|
}
|
|
|
|
/// Read back the configured duration. Lets the app delegate timer
|
|
/// setup to a helper that does not need to know how the toast was
|
|
/// configured.
|
|
pub fn duration_value( &self ) -> Duration
|
|
{
|
|
self.duration
|
|
}
|
|
|
|
/// Set the dismiss message fired on a tap outside the pill.
|
|
pub fn on_dismiss( mut self, msg: Msg ) -> Self
|
|
{
|
|
self.on_dismiss = Some( msg );
|
|
self
|
|
}
|
|
|
|
/// Override the pill background colour.
|
|
pub fn background( mut self, c: Color ) -> Self
|
|
{
|
|
self.bg = Some( c );
|
|
self
|
|
}
|
|
|
|
/// Override the body text colour.
|
|
pub fn color( mut self, c: Color ) -> Self
|
|
{
|
|
self.fg = Some( c );
|
|
self
|
|
}
|
|
|
|
/// Build an [`Element`] that paints the toast pill bottom-anchored
|
|
/// inside its parent rect. The intended pattern is to push it on
|
|
/// top of the main view via a [`Stack`](crate::stack):
|
|
///
|
|
/// ```text
|
|
/// fn view( &self ) -> Element<Msg>
|
|
/// {
|
|
/// let body = column().push( … );
|
|
/// let mut s = stack().push( body );
|
|
/// if self.toast_until.is_some()
|
|
/// {
|
|
/// s = s.push( toast( "Saved" ).view() );
|
|
/// }
|
|
/// s.into()
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// Works on every compositor — no `wlr-layer-shell` needed —
|
|
/// because the pill is just a regular widget tree drawn on top of
|
|
/// the existing canvas. Use [`Self::overlay`] only when you
|
|
/// specifically need the toast to render *above other windows*
|
|
/// (notification-style), which requires `wlr-layer-shell`.
|
|
pub fn view( self ) -> Element<Msg>
|
|
{
|
|
use super::{ container, text };
|
|
use crate::layout::stack::stack;
|
|
use crate::layout::stack::{ HAlign, VAlign };
|
|
|
|
let bg = self.bg.unwrap_or_else( theme::bg );
|
|
let fg = self.fg.unwrap_or_else( theme::text );
|
|
|
|
let pill: Element<Msg> = container(
|
|
text( self.message.clone() )
|
|
.size( theme::FONT_SIZE )
|
|
.color( fg )
|
|
)
|
|
.background( bg )
|
|
.padding_h( theme::PAD_H )
|
|
.padding_v( 12.0 )
|
|
.radius( theme::RADIUS )
|
|
.into();
|
|
|
|
stack::<Msg>()
|
|
.push_aligned_margin( pill, HAlign::Center, VAlign::Bottom, theme::BOTTOM_MARGIN as f32 )
|
|
.into()
|
|
}
|
|
|
|
/// Build the [`OverlaySpec`] the application returns from
|
|
/// [`App::overlays`](crate::App::overlays). The overlay is anchored
|
|
/// to the bottom edge of the screen with a small breathing margin.
|
|
///
|
|
/// Requires the compositor to advertise `wlr-layer-shell`. For a
|
|
/// portable variant that works everywhere — at the cost of being
|
|
/// confined to the application's surface — use [`Self::view`].
|
|
pub fn overlay( self ) -> OverlaySpec<Msg>
|
|
{
|
|
use super::{ container, text };
|
|
use crate::layout::stack::stack;
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
self.id.0.hash( &mut hasher );
|
|
let overlay_id = OverlayId( hasher.finish() as u32 );
|
|
|
|
let bg = self.bg.unwrap_or_else( theme::bg );
|
|
let fg = self.fg.unwrap_or_else( theme::text );
|
|
|
|
let pill: Element<Msg> = container(
|
|
text( self.message.clone() )
|
|
.size( theme::FONT_SIZE )
|
|
.color( fg )
|
|
)
|
|
.background( bg )
|
|
.padding_h( theme::PAD_H )
|
|
.padding_v( 12.0 )
|
|
.radius( theme::RADIUS )
|
|
.into();
|
|
|
|
// Centre horizontally, anchor to the bottom with a small margin.
|
|
let body: Element<Msg> = stack()
|
|
.push_aligned_margin(
|
|
pill,
|
|
crate::layout::stack::HAlign::Center,
|
|
crate::layout::stack::VAlign::Bottom,
|
|
theme::BOTTOM_MARGIN as f32,
|
|
)
|
|
.into();
|
|
|
|
OverlaySpec
|
|
{
|
|
id: overlay_id,
|
|
layer: Layer::Overlay,
|
|
anchor: Anchor::BOTTOM,
|
|
size: ( 0, theme::HEIGHT + theme::BOTTOM_MARGIN as u32 + 24 ),
|
|
exclusive_zone: 0,
|
|
keyboard_exclusive: false,
|
|
input_region: None,
|
|
view: body,
|
|
on_dismiss: self.on_dismiss,
|
|
anchor_widget_id: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a [`Toast`] with the given message.
|
|
pub fn toast<Msg: Clone + 'static>( message: impl Into<String> ) -> Toast<Msg>
|
|
{
|
|
Toast::new( message )
|
|
}
|
|
|
|
#[ cfg( test ) ]
|
|
mod tests
|
|
{
|
|
use super::*;
|
|
|
|
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
|
enum Msg { Dismiss }
|
|
|
|
#[ test ]
|
|
fn defaults()
|
|
{
|
|
let t: Toast<Msg> = toast( "Saved" );
|
|
assert_eq!( t.message, "Saved" );
|
|
assert_eq!( t.duration, Duration::from_secs( 2 ) );
|
|
assert!( t.on_dismiss.is_none() );
|
|
}
|
|
|
|
#[ test ]
|
|
fn duration_builder_clamps_negative()
|
|
{
|
|
let t: Toast<Msg> = toast( "x" ).duration( -1.0 );
|
|
assert_eq!( t.duration_value(), Duration::ZERO );
|
|
}
|
|
|
|
#[ test ]
|
|
fn on_dismiss_stored()
|
|
{
|
|
let t = toast( "x" ).on_dismiss( Msg::Dismiss );
|
|
assert_eq!( t.on_dismiss, Some( Msg::Dismiss ) );
|
|
}
|
|
|
|
#[ test ]
|
|
fn overlay_uses_stable_id()
|
|
{
|
|
let a: OverlaySpec<Msg> = toast( "x" ).id( WidgetId( "toast/a" ) ).overlay();
|
|
let b: OverlaySpec<Msg> = toast( "y" ).id( WidgetId( "toast/a" ) ).overlay();
|
|
assert_eq!( a.id, b.id, "same WidgetId must yield same OverlayId" );
|
|
let c: OverlaySpec<Msg> = toast( "x" ).id( WidgetId( "toast/b" ) ).overlay();
|
|
assert_ne!( a.id, c.id, "different WidgetIds must yield different OverlayIds" );
|
|
}
|
|
|
|
#[ test ]
|
|
fn overlay_is_layer_shell_not_xdg_popup()
|
|
{
|
|
let o: OverlaySpec<Msg> = toast( "x" ).overlay();
|
|
assert!( o.anchor_widget_id.is_none() );
|
|
assert_eq!( o.layer, Layer::Overlay );
|
|
}
|
|
}
|