First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

305
src/widget/toast.rs Normal file
View 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 );
}
}