First commit. Version 0.1.0
This commit is contained in:
305
src/widget/toast.rs
Normal file
305
src/widget/toast.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
// 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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user