Skip to main content

rosace_widgets/tree/
snackbar.rs

1//! `Snackbar` (D115/Phase 32 Step 1) — [`Toast`]'s action-bearing
2//! sibling: a bottom-anchored message WITH an action button ("UNDO",
3//! "RETRY"), the Material convention for reversible operations.
4//!
5//! Same visibility model as `Toast`: the app owns an `Atom<bool>` and
6//! conditionally includes the snackbar in its build;
7//! [`Snackbar::show`] opens it with an auto-dismiss timer. Fully
8//! customizable per the Phase 32 sweep (background, text/action colors,
9//! radius, font size), theme-token defaults.
10
11use std::sync::Arc;
12
13use rosace_core::types::{Point, Rect, Size};
14use rosace_render::Color;
15use rosace_state::Atom;
16
17use super::container::draw_rounded_rect_pub;
18use super::{LayoutCtx, PaintCtx, Widget};
19
20const PAD_H: f32 = 16.0;
21const GAP: f32 = 16.0;
22
23pub struct Snackbar {
24    message: String,
25    action_label: Option<String>,
26    on_action: Option<Arc<dyn Fn() + Send + Sync>>,
27    height: f32,
28    background: Option<Color>,
29    text_color: Option<Color>,
30    action_color: Option<Color>,
31    radius: f32,
32    /// `None` = read from the active theme's `typography.body_medium`
33    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
34    /// for the reasoning).
35    font_size: Option<f32>,
36}
37
38impl Snackbar {
39    pub fn new(message: impl Into<String>) -> Self {
40        Self {
41            message: message.into(),
42            action_label: None,
43            on_action: None,
44            height: 46.0,
45            background: None,
46            text_color: None,
47            action_color: None,
48            radius: 0.0,
49            font_size: None,
50        }
51    }
52    /// The action button ("UNDO", "RETRY") and its callback.
53    pub fn action(mut self, label: impl Into<String>, f: impl Fn() + Send + Sync + 'static) -> Self {
54        self.action_label = Some(label.into());
55        self.on_action = Some(Arc::new(f));
56        self
57    }
58    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
59    /// Panel fill — defaults to an inverse-surface look derived from the
60    /// theme (`on_background` at high opacity, the Material convention).
61    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
62    /// Message color — defaults to the theme's `background` (inverse text).
63    pub fn color(mut self, c: Color) -> Self { self.text_color = Some(c); self }
64    /// Action label color — defaults to the theme's `primary`.
65    pub fn action_color(mut self, c: Color) -> Self { self.action_color = Some(c); self }
66    pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
67    pub fn font_size(mut self, s: f32) -> Self { self.font_size = Some(s); self }
68
69    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
70        self.font_size.unwrap_or(theme.typography.body_medium.size)
71    }
72
73    /// Present as a floating overlay pinned bottom-center, above ALL
74    /// content (the Scaffold-level surface the platform convention
75    /// demands — a snackbar is never an inline child of where it was
76    /// declared). Call while your open-atom is true; same per-frame
77    /// re-push convention as `Drawer::emit`. Clicks outside it pass
78    /// through; the action button still receives its own hits.
79    pub fn emit(self) {
80        use super::overlay::{push_overlay, InputBehavior, FocusBehavior, LayerPosition, OverlayEntry};
81        // Android-convention docked bar (user-specified): full width,
82        // flush with the Scaffold's bottom — the engine raises
83        // BottomAnchored overlays above the bottom nav bar when one is
84        // present (bottom-overlay-inset channel).
85        push_overlay(
86            OverlayEntry::new(LayerPosition::BottomAnchored, self)
87                .input(InputBehavior::PassThrough)
88                .focus(FocusBehavior::Inert),
89        );
90    }
91
92    /// Open the snackbar and auto-dismiss after `secs` seconds — same
93    /// timer model as [`super::Toast::show`].
94    pub fn show(open: &Atom<bool>, secs: f32) {
95        open.set(true);
96        let open = open.clone();
97        // Web-safe timer (see [`super::Toast::show`]) — `thread::spawn` aborts
98        // the module on wasm32.
99        rosace_state::fire_after_ms((secs * 1000.0) as u64, move || {
100            open.set(false);
101        });
102    }
103}
104
105impl Widget for Snackbar {
106    fn layout(&self, ctx: &LayoutCtx) -> Size {
107        // A snackbar is a BAR, not a pill (user-reported: intrinsic width
108        // read as a Toast): fill the available width minus margins,
109        // capped for very wide windows, never narrower than its content.
110        let font_size = self.resolved_font_size(ctx.theme);
111        let text_w = ctx.font.measure_text(&self.message, font_size);
112        let action_w = self
113            .action_label
114            .as_ref()
115            .map(|a| GAP + ctx.font.measure_text(a, font_size))
116            .unwrap_or(0.0);
117        let content_w = PAD_H * 2.0 + text_w + action_w;
118        let avail = ctx.constraints.max_width_f32();
119        // Android-style docked bar: edge-to-edge full width.
120        let w = avail.max(content_w.min(avail));
121        Size { width: w, height: self.height }
122    }
123
124    fn paint(&self, ctx: &mut PaintCtx) {
125        // Hoisted theme reads (borrow must end before mutable painting).
126        let (bg, fg, action_fg) = {
127            let t = &ctx.theme.colors;
128            let inv = ctx.tc(t.on_background);
129            (
130                self.background.unwrap_or(Color::rgba(inv.r, inv.g, inv.b, 235)),
131                self.text_color.unwrap_or_else(|| ctx.tc(t.background)),
132                self.action_color.unwrap_or_else(|| ctx.tc(t.primary)),
133            )
134        };
135        let r = ctx.rect;
136
137        ctx.semantics(super::Semantics::new(rosace_core::Role::Alert).label(&self.message));
138        draw_rounded_rect_pub(ctx, r, bg, self.radius);
139
140        let font_size = self.resolved_font_size(&ctx.theme);
141        let line_h = ctx.font.line_height(font_size);
142        let ty = r.origin.y + (r.size.height - line_h) / 2.0;
143        ctx.draw_text_at(
144            &self.message,
145            Point { x: r.origin.x + PAD_H, y: ty },
146            fg,
147            font_size,
148        );
149
150        if let Some(label) = &self.action_label {
151            let aw = ctx.font.measure_text(label, font_size);
152            let ax = r.origin.x + r.size.width - PAD_H - aw;
153            // The action gets its own hit slot (a button inside an alert).
154            let hit = Rect {
155                origin: Point { x: ax - 8.0, y: r.origin.y },
156                size: Size { width: aw + 16.0, height: r.size.height },
157            };
158            let mut action_ctx = ctx.child(hit);
159            action_ctx.semantics(super::Semantics::new(rosace_core::Role::Button).label(label));
160            action_ctx.draw_text_at(label, Point { x: ax, y: ty }, action_fg, font_size);
161            if let Some(cb) = &self.on_action {
162                action_ctx.register_hit(Arc::clone(cb));
163            }
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use rosace_layout::Constraints;
172
173    #[test]
174    fn snackbar_is_a_bar_filling_available_width_within_margins() {
175        let font = rosace_render::FontCache::embedded();
176        let theme = rosace_theme::built_in::dark_theme();
177        let ctx = LayoutCtx::new(Constraints::loose(500.0, 400.0), &font, &theme);
178
179        // Android-convention docked bar (user-specified): edge-to-edge
180        // full width regardless of content or action.
181        let plain = Snackbar::new("Saved").layout(&ctx);
182        let with_action = Snackbar::new("Saved").action("UNDO", || {}).layout(&ctx);
183        assert_eq!(plain.width, 500.0);
184        assert_eq!(with_action.width, plain.width);
185
186        let narrow = LayoutCtx::new(Constraints::loose(120.0, 400.0), &font, &theme);
187        assert!(Snackbar::new("A very long message that cannot fit").layout(&narrow).width <= 120.0);
188    }
189}