Skip to main content

rosace_widgets/tree/
toast.rs

1use rosace_core::types::{Point, Size};
2use rosace_render::Color;
3use rosace_state::Atom;
4use super::{Widget, LayoutCtx, PaintCtx};
5use super::container::draw_rounded_rect_pub;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum ToastKind {
9    Info,
10    Success,
11    Error,
12}
13
14/// A transient notification pill. Pair with [`OverlayApi::toast`], which
15/// floats it above the bottom edge, and [`Toast::show`] for auto-dismiss:
16///
17/// ```rust,ignore
18/// let saved = ctx.state(false);
19/// Button::new("Save")
20///     .on_press({ let s = saved.clone(); move || { /* save */ Toast::show(&s, 2.5); } })
21///     .toast(saved.clone(), || Box::new(Toast::success("Saved!")))
22/// ```
23///
24/// [`OverlayApi::toast`]: super::overlay_api::OverlayApi::toast
25pub struct Toast {
26    pub message: String,
27    pub kind: ToastKind,
28    /// `None` = read from the active theme's `typography.body_medium`
29    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
30    /// for the reasoning).
31    pub font_size: Option<f32>,
32    background: Option<Color>,
33    color: Option<Color>,
34    accent: Option<Color>,
35    radius: Option<f32>,
36}
37
38impl Toast {
39    pub fn info(message: impl Into<String>) -> Self {
40        Self { message: message.into(), kind: ToastKind::Info, font_size: None, background: None, color: None, accent: None, radius: None }
41    }
42
43    pub fn success(message: impl Into<String>) -> Self {
44        Self { message: message.into(), kind: ToastKind::Success, font_size: None, background: None, color: None, accent: None, radius: None }
45    }
46
47    pub fn error(message: impl Into<String>) -> Self {
48        Self { message: message.into(), kind: ToastKind::Error, font_size: None, background: None, color: None, accent: None, radius: None }
49    }
50
51    /// Pill fill color (theme's `surface_variant` if unset).
52    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
53    /// Message text color (theme's `on_surface` if unset).
54    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
55    /// Leading dot color (overrides the info/success/error default).
56    pub fn accent(mut self, c: Color) -> Self { self.accent = Some(c); self }
57    /// Corner radius (half the pill's height — a full pill shape — if unset).
58    pub fn radius(mut self, r: f32) -> Self { self.radius = Some(r); self }
59
60    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
61        self.font_size.unwrap_or(theme.typography.body_medium.size)
62    }
63
64    /// Open the toast and auto-dismiss after `secs` seconds.
65    ///
66    /// Spawns a timer thread; the closing `atom.set(false)` wakes the event
67    /// loop via the registered frame-request hook, so the toast disappears
68    /// without any user input.
69    pub fn show(open: &Atom<bool>, secs: f32) {
70        open.set(true);
71        let open = open.clone();
72        // Web-safe timer (setTimeout on wasm; a timer thread on native) — a raw
73        // `thread::spawn` aborts the whole module on wasm32.
74        rosace_state::fire_after_ms((secs * 1000.0) as u64, move || {
75            open.set(false);
76        });
77    }
78
79    fn resolve_accent(&self, ctx: &PaintCtx) -> Color {
80        if let Some(c) = self.accent { return c; }
81        let t = &ctx.theme.colors;
82        match self.kind {
83            ToastKind::Info    => ctx.tc(t.primary),
84            ToastKind::Success => Color::rgb(72, 199, 116),
85            ToastKind::Error   => ctx.tc(t.error),
86        }
87    }
88}
89
90const PAD_H: f32 = 16.0;
91const PAD_V: f32 = 10.0;
92const DOT: f32 = 8.0;
93const GAP: f32 = 10.0;
94
95impl Widget for Toast {
96    fn layout(&self, ctx: &LayoutCtx) -> Size {
97        let font_size = self.resolved_font_size(ctx.theme);
98        let text_w = ctx.font.measure_text(&self.message, font_size);
99        let line_h = ctx.font.line_height(font_size);
100        ctx.constraints.constrain(Size {
101            width: PAD_H * 2.0 + DOT + GAP + text_w,
102            height: PAD_V * 2.0 + line_h.max(DOT),
103        })
104    }
105
106    fn paint(&self, ctx: &mut PaintCtx) {
107        ctx.semantics(super::Semantics::new(rosace_core::Role::Alert).label(&self.message));
108        let r = ctx.rect;
109        let radius = self.radius.unwrap_or(r.size.height / 2.0);
110        let (bg, fg) = {
111            let t = &ctx.theme.colors;
112            (self.background.unwrap_or_else(|| ctx.tc(t.surface_variant)),
113             self.color.unwrap_or_else(|| ctx.tc(t.on_surface)))
114        };
115        ctx.fill_shadow_rrect(r, radius, Color::rgba(0, 0, 0, 90), 10.0);
116        draw_rounded_rect_pub(ctx, r, bg, radius);
117
118        let accent = self.resolve_accent(ctx);
119        ctx.fill_circle(Point {
120            x: r.origin.x + PAD_H + DOT / 2.0,
121            y: r.origin.y + r.size.height / 2.0,
122        }, DOT / 2.0, accent);
123
124        let font_size = self.resolved_font_size(&ctx.theme);
125        let line_h = ctx.font.line_height(font_size);
126        ctx.draw_text_at(
127            &self.message,
128            Point {
129                x: r.origin.x + PAD_H + DOT + GAP,
130                y: r.origin.y + (r.size.height - line_h) / 2.0,
131            },
132            fg,
133            font_size,
134        );
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use rosace_layout::Constraints;
142
143    #[test]
144    fn customization_builders_do_not_change_layout_size() {
145        let font = rosace_render::FontCache::embedded();
146        let theme = rosace_theme::built_in::dark_theme();
147        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
148        let base = Toast::info("Saved");
149        let customized = Toast::info("Saved")
150            .background(Color::rgb(10, 10, 10))
151            .color(Color::rgb(255, 255, 255))
152            .accent(Color::rgb(0, 200, 0))
153            .radius(4.0);
154        assert_eq!(base.layout(&ctx), customized.layout(&ctx));
155    }
156}