// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! 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 } //! # impl App { //! fn overlays( &self ) -> Vec> //! { //! 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 { /// 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, /// Optional override for the pill background colour. pub bg: Option, /// Optional override for the body text colour. pub fg: Option, } impl Toast { /// 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 ) -> 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 /// { /// 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 { 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 = 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::() .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 { 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 = 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 = 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( message: impl Into ) -> Toast { Toast::new( message ) } #[ cfg( test ) ] mod tests { use super::*; #[ derive( Clone, Debug, PartialEq, Eq ) ] enum Msg { Dismiss } #[ test ] fn defaults() { let t: Toast = 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 = 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 = toast( "x" ).id( WidgetId( "toast/a" ) ).overlay(); let b: OverlaySpec = toast( "y" ).id( WidgetId( "toast/a" ) ).overlay(); assert_eq!( a.id, b.id, "same WidgetId must yield same OverlayId" ); let c: OverlaySpec = 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 = toast( "x" ).overlay(); assert!( o.anchor_widget_id.is_none() ); assert_eq!( o.layer, Layer::Overlay ); } }