Skip to main content

frust_material/
snackbar.rs

1//! The Material 3 Expressive `Snackbar`: a transient, low-emphasis bottom
2//! message with an optional single action and an optional close affordance.
3//!
4//! Ported from `material_3_expressive` v1.0.8's `M3ESnackbar`/
5//! `M3ESnackbarTheme`/`M3ESnackbarHost` (MIT, © 2026 Paa Developments;
6//! `tmp/material_3_expressive/lib/components/snackbar/{m3e_snackbar.dart,
7//! styles/m3e_snackbar_theme.dart,components/m3e_snackbar_host.dart}`,
8//! retrieved 2026-08-20).
9//! Upstream: <https://github.com/paadevelopments/material_3_expressive>
10//!
11//! # Presentation: kept-mounted host, not a navigator page or modal
12//!
13//! A snackbar never blocks the app: input outside its own small bottom-anchored
14//! box must keep reaching whatever is under it. That rules out both of this
15//! crate's existing presentation seams — [`mod@crate::dialog`]/[`crate::sheet`]
16//! push a transparent, input-gating navigator page, and
17//! [`mod@crate::overlay::modal`] paints a swallow-everything scrim — neither of
18//! which a *non-modal* transient message may use. This module is the tier's
19//! first host with no scrim and no navigator page at all: [`snackbar_host`]
20//! wraps the app's own root view (the [`overlay::mod@self`]-documented
21//! "kept-mounted" mount contract, one level up — see [`SnackbarHostView`]'s own
22//! doc for the exact wiring) and paints its transient bar as a bottom-anchored
23//! sibling layer over that content, swallowing only its own small box.
24//!
25//! # Mount contract
26//!
27//! An app keeps exactly one [`SnackbarController`] in its `Component::State`
28//! and mounts [`snackbar_host`] **once, wrapping the whole app root** — the
29//! same position [`frust::overlay_host`]/[`frust::navigator`] take:
30//!
31//! ```no_run
32//! use frust::{AnyView, Component, any, text};
33//! use frust_material::{SnackbarController, snackbar, snackbar_host};
34//!
35//! struct AppState {
36//!     toasts: SnackbarController<AppState>,
37//! }
38//!
39//! #[derive(Default)]
40//! struct App;
41//!
42//! impl Component for App {
43//!     type State = AppState;
44//!
45//!     fn init(&self) -> AppState {
46//!         AppState { toasts: SnackbarController::new() }
47//!     }
48//!
49//!     fn build(&self, state: &mut AppState) -> AnyView<AppState> {
50//!         any(snackbar_host(&state.toasts, text("home")))
51//!     }
52//! }
53//!
54//! frust::app!(App);
55//! # fn main() {}
56//! ```
57//!
58//! From anywhere the app already has `&State` (an `on_press` handler, a
59//! background task's result), call [`SnackbarController::show`]:
60//!
61//! ```no_run
62//! # use frust_material::{SnackbarController, snackbar};
63//! # struct AppState { toasts: SnackbarController<AppState> }
64//! fn on_save(state: &mut AppState) {
65//!     state.toasts.show(
66//!         snackbar("Saved")
67//!             .action("Undo", |s: &mut AppState| { /* revert */ let _ = s; })
68//!             .dismissible(),
69//!     );
70//! }
71//! ```
72//!
73//! `show` only *records* the request — [`SnackbarHostWidget`] drains it at its
74//! own next rebuild, the same recorded-op shape
75//! [`frust::NavigatorController::push`] uses (see that type's own module docs).
76//! Calling `show` is enough on its own: the host's paint-driven ramp/timer loop
77//! (below) keeps requesting frames for the whole visible lifetime, so no
78//! further app action is needed to carry the message through its entrance,
79//! hold, and exit.
80//!
81//! # Queue/replace rule
82//!
83//! At most one message is ever visible; a second [`SnackbarController::show`]
84//! while one is showing does not interrupt it. This crate's own upstream
85//! reference has **no** queue at all — `M3ESnackbar.show` inserts a fresh,
86//! independent `OverlayEntry` on every call, so two calls made close together
87//! paint two overlapping bars rather than one after another. Rather than port
88//! that gap, this host follows the well-established platform convention for a
89//! transient bottom message — Android Material Components' `SnackbarManager`
90//! (`com.google.android.material.snackbar`) and Flutter's
91//! `ScaffoldMessenger.showSnackBar` both hold **one pending slot** behind the
92//! currently-visible message:
93//!
94//! - Nothing currently showing → the new message shows immediately.
95//! - One message showing, no pending message queued → the new message becomes
96//!   the pending one, shown once the current message's whole lifecycle
97//!   (entrance, hold, exit) finishes.
98//! - One message showing **and** one already pending → the new message
99//!   **replaces** the pending one; the discarded pending message's `on_action`
100//!   never fires.
101//!
102//! A single `Option` slot ([`SnackbarController`]'s `pending` field) implements
103//! this precisely: an assignment always replaces whatever was there, and the
104//! host only ever promotes it once [`SnackbarHostWidget`] has nothing of its
105//! own currently showing.
106//!
107//! # Motion and color
108//!
109//! Slide-up + fade entrance (`crate::tokens::MaterialMotion::MEDIUM_2`, 300ms)
110//! and a matching, shorter exit (`SHORT_4`, 200ms), both
111//! `MaterialMotion::EMPHASIZED`-eased — the upstream host's own
112//! `AnimationController` timings (`m3e_snackbar_host.dart`'s `duration`/
113//! `reverseDuration`), reused for both the slide offset and an opacity fade
114//! this module adds on top (the upstream host only slides). Container
115//! `inverseSurface`, message ink `onInverseSurface`, action ink
116//! `inversePrimary`, elevation level 3, corner radius `shape.extra_small` —
117//! all transcribed from `m3e_snackbar_theme.dart`'s `M3ESnackbarTheme.defaults`.
118//!
119//! # Width
120//!
121//! The bar is `min(host_width - 32, 600)` wide and horizontally centered — on
122//! a phone that is full-width-minus-16dp-margins (`600` never binds); on a wide
123//! viewport it caps at 600dp and centers with more than 16dp of margin either
124//! side. This is `m3e_snackbar_theme.dart`'s own shape: a `maxWidth: 600`
125//! container inside a `Positioned(left: 16, right: 16, ...)` slot, aligned
126//! `bottomCenter` — a child narrower than its aligned box centers within it
127//! rather than staying pinned to the 16dp edges.
128//!
129//! # Deliberate v1 simplifications
130//!
131//! - **Single-line message, no wrap or ellipsis.** Overlong text is clipped to
132//!   the bar's own rounded rect rather than wrapping or truncating with `…` —
133//!   a stated scope cut, not a defect.
134//! - **No hover chrome on the action/close affordances**, only a pressed-state
135//!   tint. `PaintCtx::is_hovered`/`EventCtx::claim_hover` report *path*
136//!   membership for the **whole** host (`docs/CODE_STANDARDS.md`'s Interaction
137//!   Semantics), and this host also wraps arbitrary app content — a hover
138//!   claimed by a button deep inside that content would read as "hovered" here
139//!   too, so a self-corrected hover flag on the action/close region would be
140//!   wrong more often than right. [`mod@crate::chips`]'s own delete icon
141//!   carries the same no-hover-chrome simplification for an analogous reason
142//!   (a small icon affordance beside a labeled control).
143//! - **No window-inset awareness.** The bottom margin is a fixed constant, not
144//!   folded against `WindowInsets` (the on-screen-keyboard/gesture-bar
145//!   clearance the upstream host reads from `MediaQuery`). A future revision
146//!   can fold `LayoutCtx::window_insets`/`PaintCtx::window_insets` in
147//!   additively.
148//!
149//! # Hit region
150//!
151//! Hit-testing (the initial `Down` classification and every subsequent
152//! `to_local` call while captured) reads [`ActiveSnackbar::visual_bar_rect`]
153//! — [`ActiveSnackbar::bar_rect`]'s resting position shifted by the same
154//! vertical offset [`Widget::paint`] applies — never the raw resting rect. A
155//! press during the ~200-300ms entrance/exit ramp is therefore tested against
156//! where the bar is actually painted (including nowhere at all, at
157//! `progress <= 0.0`, when nothing is drawn yet), the same "hit-testing
158//! follows the painted position, not a paint-only transform" contract
159//! [`mod@crate::overlay::modal`]'s slide entrance documents for its own
160//! panel.
161
162use std::cell::RefCell;
163use std::rc::Rc;
164use std::time::Duration;
165
166use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
167use frust::authoring::{
168    Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedCallback, EventCtx,
169    EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerEvent, PointerPhase, Role,
170    SemanticsCtx, View, Widget, any, erase_callback,
171};
172use frust::{AnimationController, IconData, Theme};
173use kurbo::{Affine, Point, Rect, Size, Vec2};
174use peniko::{Brush, Color};
175
176use crate::icons;
177use crate::press::presses;
178use crate::tokens::MaterialMotion;
179
180// ---- Theme-token constants (m3e_snackbar_theme.dart's `M3ESnackbarTheme.defaults`) --
181
182/// The bar's minimum height, in logical px (`minHeight` default).
183const MIN_HEIGHT: f64 = 48.0;
184/// The bar's maximum width, in logical px (`maxWidth` default).
185const MAX_WIDTH: f64 = 600.0;
186/// Horizontal content padding inside the bar (`contentPadding` default).
187const CONTENT_PAD_H: f64 = 16.0;
188/// Vertical content padding inside the bar (`contentPadding` default).
189const CONTENT_PAD_V: f64 = 8.0;
190/// Horizontal margin from the host's edges to the bar (`overlayHorizontalInset`
191/// default).
192const H_INSET: f64 = 16.0;
193/// Vertical margin from the host's bottom edge to the bar
194/// (`overlayBottomInset` default).
195const BOTTOM_INSET: f64 = 16.0;
196/// Padding inside the action's own tap target (`actionPadding` default).
197const ACTION_PAD_H: f64 = 8.0;
198const ACTION_PAD_V: f64 = 4.0;
199/// Gap between the message and the action (`actionGap` default).
200const ACTION_GAP: f64 = 8.0;
201/// The default auto-dismiss hold duration (`defaultDuration` default, 4s).
202const DEFAULT_DURATION: Duration = Duration::from_millis(4000);
203
204/// The close icon's side length, in logical px. Not part of the upstream
205/// theme (`m3e_snackbar_theme.dart` has no close affordance at all — see the
206/// module docs) — 18dp mirrors this catalog's own delete-icon convention
207/// ([`mod@crate::chips`]'s `CHIP_ICON_SIZE`).
208const CLOSE_ICON_SIZE: f64 = 18.0;
209/// Gap between the action (or the message, with no action) and the close icon.
210const CLOSE_GAP: f64 = 8.0;
211
212/// Entrance duration — the upstream host's `AnimationController.duration`
213/// (`m3e_snackbar_host.dart`).
214const ENTER_DURATION: Duration = MaterialMotion::MEDIUM_2;
215/// Exit duration — the upstream host's `AnimationController.reverseDuration`.
216const EXIT_DURATION: Duration = MaterialMotion::SHORT_4;
217
218/// Extra clearance below the bar's resting position for the fully-hidden
219/// (entrance start / exit end) slide offset, beyond the bar's own height plus
220/// [`BOTTOM_INSET`] — enough that the bar is never partially visible at
221/// `progress == 0`. This module's own addition (the upstream host's `Offset(0,
222/// 1.5)` tween is tuned to its specific `SlideTransition` box and does not
223/// port literally); the exact value is cosmetic, not spec-sourced.
224const HIDDEN_MARGIN: f64 = 8.0;
225
226/// Message type-scale token (M3 `bodyMedium`; `crate::tokens::type_scale`'s
227/// `body_medium` row).
228const MESSAGE_SIZE: f32 = 14.0;
229const MESSAGE_LINE_HEIGHT: f32 = 20.0;
230const MESSAGE_LETTER_SPACING: f32 = 0.25;
231/// Action type-scale token (M3 `labelLarge`).
232const ACTION_SIZE: f32 = 14.0;
233const ACTION_LINE_HEIGHT: f32 = 20.0;
234const ACTION_LETTER_SPACING: f32 = 0.1;
235
236/// Unthemed-fallback container fill (`colors.inverse_surface`, M3 baseline
237/// light).
238const FALLBACK_CONTAINER: Color = Color::from_rgb8(0x32, 0x2F, 0x35);
239/// Unthemed-fallback message/close ink (`colors.inverse_on_surface`, M3
240/// baseline light).
241const FALLBACK_ON_CONTAINER: Color = Color::from_rgb8(0xF5, 0xEF, 0xF7);
242/// Unthemed-fallback action ink (`colors.inverse_primary`, M3 baseline light).
243const FALLBACK_ACTION: Color = Color::from_rgb8(0xD0, 0xBC, 0xFF);
244/// Unthemed-fallback corner radius (`shape.extra_small`, 4dp).
245const FALLBACK_RADIUS: f64 = 4.0;
246/// Unthemed-fallback shadow geometry/alpha, M3 elevation level 3
247/// (`crate::tokens::elevation()`'s `y_offset = dp/2+1`, `blur = dp` mapping —
248/// the same fallback convention [`mod@crate::overlay`] uses).
249const FALLBACK_SHADOW_BLUR: f64 = 6.0;
250const FALLBACK_SHADOW_Y: f64 = 4.0;
251const FALLBACK_SHADOW_ALPHA: f32 = 0.3;
252
253/// A pressed-state tint's opacity, taking this catalog's one state-layer
254/// source (`docs/WIDGETS_CODE_STANDARDS.md`'s Theming & Animation
255/// Conventions).
256use crate::interaction::PRESSED_OPACITY;
257
258/// A small allowance below which a settling ramp is treated as fully at rest —
259/// the same epsilon [`mod@crate::overlay::modal`] uses for its own ramp
260/// settle check.
261const PROGRESS_EPSILON: f64 = 1e-4;
262/// How far a layer rect is inflated beyond the bar's own box so the
263/// elevation shadow (which reaches outside it) isn't clipped by the fade
264/// layer.
265const SHADOW_SPILL: f64 = 24.0;
266
267/// Return `color` with its alpha channel replaced by `alpha` (mirrors every
268/// other module's own copy of this helper, e.g. [`mod@crate::dialog`]'s).
269fn with_alpha(color: Color, alpha: f32) -> Color {
270    let c = color.components;
271    Color::new([c[0], c[1], c[2], alpha])
272}
273
274/// The message's resolved style: the `bodyMedium` token literals above, in
275/// the live theme's `bodyMedium` family when a theme reaches layout (unthemed:
276/// the platform system UI family).
277fn message_style(theme: Option<&Theme>) -> TextStyle {
278    let mut style = TextStyle {
279        weight: FontWeight::REGULAR,
280        letter_spacing: MESSAGE_LETTER_SPACING,
281        line_height: LineHeight::Absolute(MESSAGE_LINE_HEIGHT),
282        ..TextStyle::new(MESSAGE_SIZE, Color::BLACK)
283    };
284    if let Some(theme) = theme {
285        style.family = theme.type_scale.body_medium.family.clone();
286    }
287    style
288}
289
290/// The action label's resolved style: the `labelLarge` token literals above,
291/// in the live theme's `labelLarge` family when a theme reaches layout.
292fn action_style(theme: Option<&Theme>) -> TextStyle {
293    let mut style = TextStyle {
294        weight: FontWeight::MEDIUM,
295        letter_spacing: ACTION_LETTER_SPACING,
296        line_height: LineHeight::Absolute(ACTION_LINE_HEIGHT),
297        ..TextStyle::new(ACTION_SIZE, Color::BLACK)
298    };
299    if let Some(theme) = theme {
300        style.family = theme.type_scale.label_large.family.clone();
301    }
302    style
303}
304
305// ---- Text shaping helper -------------------------------------------------
306
307/// A lazily-shaped, paint-time-rebrushed text run — the same idiom
308/// [`mod@crate::badge`]'s `LabelRun` uses, shared here by the message and the
309/// action, each shaped in the [`TextStyle`] its caller resolves. The cache is
310/// keyed on the content *and* that style, so a theme swap that changes the
311/// family reshapes instead of serving the old face.
312struct TextRun {
313    content: String,
314    /// The style the cached layout was shaped with.
315    style: TextStyle,
316    layout: Option<TextLayout>,
317}
318
319impl TextRun {
320    fn new() -> Self {
321        Self {
322            content: String::new(),
323            style: TextStyle::default(),
324            layout: None,
325        }
326    }
327
328    fn set_content(&mut self, content: &str) {
329        if self.content != content {
330            self.content = content.to_string();
331            self.layout = None;
332        }
333    }
334
335    /// Shape (or reuse) the run, returning its measured (unwrapped,
336    /// single-line — see the module docs' v1 simplifications) size.
337    fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
338        if self.style != *style {
339            self.style = style.clone();
340            self.layout = None;
341        }
342        if let Some(layout) = &self.layout {
343            return layout.size();
344        }
345        let text_ctx = ctx.text_context::<TextContext>();
346        let laid = text_ctx.layout(&self.content, &self.style, None);
347        let size = laid.size();
348        self.layout = Some(laid);
349        size
350    }
351
352    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
353        let Some(layout) = &self.layout else {
354            return;
355        };
356        for mut run in layout.to_scene_runs(origin) {
357            run.brush = Brush::Solid(color);
358            scene.draw_glyph_run(run);
359        }
360    }
361}
362
363// ---- Message builder + controller ----------------------------------------
364
365/// A view-held, typed action callback (erased on drain) — mirrors
366/// [`mod@crate::dialog`]'s own `OnDismiss` alias of the same shape.
367type OnAction<State> = Rc<dyn Fn(&mut State)>;
368
369/// A declarative snackbar message. Built via [`snackbar`], shown via
370/// [`SnackbarController::show`]. See the [module docs](self).
371pub struct SnackbarMessage<State: 'static> {
372    message: String,
373    action_label: Option<String>,
374    on_action: Option<OnAction<State>>,
375    duration: Duration,
376    dismissible: bool,
377}
378
379/// Start building a snackbar message with body text `message`. Chain
380/// [`SnackbarMessage::action`]/[`SnackbarMessage::duration`]/
381/// [`SnackbarMessage::dismissible`], then pass the result to
382/// [`SnackbarController::show`].
383pub fn snackbar<State: 'static>(message: impl Into<String>) -> SnackbarMessage<State> {
384    SnackbarMessage {
385        message: message.into(),
386        action_label: None,
387        on_action: None,
388        duration: DEFAULT_DURATION,
389        dismissible: false,
390    }
391}
392
393impl<State: 'static> SnackbarMessage<State> {
394    /// Attach a single action button, labeled `label`. Pressing it fires
395    /// `on_action` **and** dismisses the message immediately — Flutter's own
396    /// stock `SnackBarAction` behavior (`flutter/material/snack_bar.dart`'s
397    /// `_SnackBarActionState._handlePressed`, which always closes the
398    /// snackbar right after invoking its callback); this crate's own upstream
399    /// reference has no action-press dismiss logic of its own to port (its
400    /// `onAction` callback is the only thing it fires — see the module docs'
401    /// Queue/replace rule for the parallel gap in *that* file), so this
402    /// follows the wider platform convention instead.
403    pub fn action<F: Fn(&mut State) + 'static>(
404        mut self,
405        label: impl Into<String>,
406        on_action: F,
407    ) -> Self {
408        self.action_label = Some(label.into());
409        self.on_action = Some(Rc::new(on_action));
410        self
411    }
412
413    /// Override the auto-dismiss hold duration. [`DEFAULT_DURATION`] (4s)
414    /// unless set.
415    pub fn duration(mut self, duration: Duration) -> Self {
416        self.duration = duration;
417        self
418    }
419
420    /// Show a close (×) icon that dismisses the message without firing
421    /// `on_action`. Off unless called — the upstream reference has no close
422    /// affordance at all (see the module docs).
423    pub fn dismissible(mut self) -> Self {
424        self.dismissible = true;
425        self
426    }
427}
428
429/// The app-state handle to a [`snackbar_host`]: a cloneable single-slot queue
430/// an app keeps in its `Component::State` and drives with
431/// [`show`](Self::show). Every clone shares one slot (`Rc`), mirroring
432/// [`frust::NavigatorController`]'s own recorded-op shape — a call here only
433/// *records* the request; [`SnackbarHostWidget`] drains it at its next
434/// rebuild. See the [module docs](self) for the full mount contract and the
435/// queue/replace rule this slot implements.
436pub struct SnackbarController<State: 'static> {
437    pending: Rc<RefCell<Option<SnackbarMessage<State>>>>,
438}
439
440impl<State: 'static> Clone for SnackbarController<State> {
441    fn clone(&self) -> Self {
442        Self {
443            pending: Rc::clone(&self.pending),
444        }
445    }
446}
447
448impl<State: 'static> Default for SnackbarController<State> {
449    fn default() -> Self {
450        Self::new()
451    }
452}
453
454impl<State: 'static> SnackbarController<State> {
455    /// A controller with nothing pending.
456    pub fn new() -> Self {
457        Self {
458            pending: Rc::new(RefCell::new(None)),
459        }
460    }
461
462    /// Request `message` be shown. See the [module docs](self)' Queue/replace
463    /// rule for exactly what happens when one is already visible or already
464    /// pending.
465    pub fn show(&self, message: SnackbarMessage<State>) {
466        *self.pending.borrow_mut() = Some(message);
467    }
468
469    /// Take whatever is pending, leaving the slot empty. Only
470    /// [`SnackbarHostView::build`]/[`SnackbarHostView::rebuild`] call this —
471    /// both run inside a `View` pass generic over `State`, the one place this
472    /// crate's callback-erasure rule
473    /// (`docs/CODE_STANDARDS.md`'s Type-erase idiom) allows touching a typed
474    /// closure before it is erased into the non-generic retained widget.
475    fn take_pending(&self) -> Option<SnackbarMessage<State>> {
476        self.pending.borrow_mut().take()
477    }
478}
479
480// ---- The erased, retained-widget-facing message shape --------------------
481
482/// [`SnackbarMessage`] with its typed `on_action` erased into an
483/// [`ErasedCallback`] — what [`SnackbarHostWidget`] (which is never generic
484/// over `State`, per `frust-widgets::authoring`'s callback-erasure rule)
485/// actually stores.
486struct ErasedSnackbar {
487    message: String,
488    action_label: Option<String>,
489    on_action: Option<ErasedCallback>,
490    duration: Duration,
491    dismissible: bool,
492}
493
494fn erase<State: 'static>(msg: SnackbarMessage<State>) -> ErasedSnackbar {
495    ErasedSnackbar {
496        message: msg.message,
497        action_label: msg.action_label,
498        on_action: msg.on_action.map(|f| erase_callback(&f)),
499        duration: msg.duration,
500        dismissible: msg.dismissible,
501    }
502}
503
504// ---- The host --------------------------------------------------------------
505
506/// A declarative snackbar host wrapping `app`. See the [module docs](self).
507pub struct SnackbarHostView<State: 'static> {
508    controller: SnackbarController<State>,
509    app: AnyView<State>,
510}
511
512/// Wrap `app` (the app's own root view) in a kept-mounted snackbar host driven
513/// by `controller`. See the [module docs](self) for the full mount contract.
514pub fn snackbar_host<State: 'static, V: View<State>>(
515    controller: &SnackbarController<State>,
516    app: V,
517) -> SnackbarHostView<State> {
518    SnackbarHostView {
519        controller: controller.clone(),
520        app: any(app),
521    }
522}
523
524impl<State: 'static> View<State> for SnackbarHostView<State> {
525    type Element = SnackbarHostWidget;
526
527    fn build(&self, ctx: &mut BuildCtx<'_>) -> SnackbarHostWidget {
528        let mut widget = SnackbarHostWidget {
529            app: frust::authoring::build_child(&self.app, ctx),
530            current: None,
531        };
532        if let Some(msg) = self.controller.take_pending() {
533            widget.begin_show(erase(msg));
534        }
535        widget
536    }
537
538    fn rebuild(
539        &self,
540        prev: &Self,
541        element: &mut SnackbarHostWidget,
542        ctx: &mut BuildCtx<'_>,
543    ) -> ChangeFlags {
544        let mut flags =
545            frust::authoring::rebuild_child(&prev.app, &self.app, &mut element.app, ctx);
546        if element.current.is_none()
547            && let Some(msg) = self.controller.take_pending()
548        {
549            element.begin_show(erase(msg));
550            flags |= ChangeFlags::LAYOUT;
551        }
552        flags
553    }
554
555    fn teardown(&self, element: &mut SnackbarHostWidget, ctx: &mut BuildCtx<'_>) {
556        frust::authoring::teardown_child(&self.app, &mut element.app, ctx);
557    }
558}
559
560/// Which part of the bar a captured press landed on.
561#[derive(Clone, Copy, Debug, PartialEq, Eq)]
562enum BarRegion {
563    Action,
564    Close,
565    /// The bar's own background — swallows the press (never dismisses; a
566    /// modal-panel-background precedent, see [`mod@crate::dialog`]'s own
567    /// module docs), never reaches `app`.
568    Background,
569}
570
571/// One message's full lifecycle state — entrance, hold, exit. Never generic
572/// over `State`: `on_action` is already an [`ErasedCallback`] by the time this
573/// exists (see [`erase`]).
574struct ActiveSnackbar {
575    message: String,
576    message_run: TextRun,
577    action_label: Option<String>,
578    action_run: Option<TextRun>,
579    on_action: Option<ErasedCallback>,
580    dismissible: bool,
581    hold: AnimationController,
582
583    /// Overall slide/fade progress, `0.0` (fully hidden) to `1.0` (fully
584    /// shown) — authoritative between ramps, updated from `ramp_value()`
585    /// while one is running. Mirrors `OverlayModalWidget::progress`'s own
586    /// shape (`overlay::modal`).
587    progress: f64,
588    ramp_from: f64,
589    ramp_to: f64,
590    /// This leg's own eased `0.0..=1.0` fraction; `ramp_value()` interpolates
591    /// `ramp_from..ramp_to` by it.
592    anim: AnimationController,
593    exiting: bool,
594
595    captured_region: Option<BarRegion>,
596    action_pressed: bool,
597    close_pressed: bool,
598
599    // Geometry from the last `Widget::layout`, in host-local coordinates.
600    bar_rect: Rect,
601    message_origin: Point,
602    /// `None` when there is no action.
603    action_rect: Option<Rect>,
604    /// `None` when not [`ActiveSnackbar::dismissible`].
605    close_rect: Option<Rect>,
606}
607
608impl ActiveSnackbar {
609    fn new(msg: ErasedSnackbar) -> Self {
610        let mut message_run = TextRun::new();
611        message_run.set_content(&msg.message);
612        let mut action_run = msg.action_label.as_ref().map(|_| TextRun::new());
613        if let (Some(run), Some(label)) = (action_run.as_mut(), msg.action_label.as_ref()) {
614            run.set_content(label);
615        }
616        let mut hold = AnimationController::new(msg.duration);
617        hold.forward();
618        let mut active = Self {
619            message: msg.message,
620            message_run,
621            action_label: msg.action_label,
622            action_run,
623            on_action: msg.on_action,
624            dismissible: msg.dismissible,
625            hold,
626            progress: 0.0,
627            ramp_from: 0.0,
628            ramp_to: 0.0,
629            anim: AnimationController::new(ENTER_DURATION),
630            exiting: false,
631            captured_region: None,
632            action_pressed: false,
633            close_pressed: false,
634            bar_rect: Rect::ZERO,
635            message_origin: Point::ZERO,
636            action_rect: None,
637            close_rect: None,
638        };
639        active.begin_ramp(1.0, false, ENTER_DURATION);
640        active
641    }
642
643    /// Start a fresh leg toward `to` over `duration`, from wherever
644    /// [`ActiveSnackbar::progress`] currently sits — so a dismiss triggered
645    /// mid-entrance reverses smoothly rather than jumping. Mirrors
646    /// `OverlayModalWidget::begin_ramp` (`overlay::modal`).
647    fn begin_ramp(&mut self, to: f64, exiting: bool, duration: Duration) {
648        self.ramp_from = self.progress;
649        self.ramp_to = to;
650        self.exiting = exiting;
651        self.anim = AnimationController::new(duration).with_curve(MaterialMotion::EMPHASIZED);
652        self.anim.forward();
653    }
654
655    fn ramp_value(&self) -> f64 {
656        self.ramp_from + (self.ramp_to - self.ramp_from) * self.anim.value_clamped()
657    }
658
659    /// Advance the running ramp and, if not already exiting, the auto-dismiss
660    /// hold timer — starting the exit ramp itself the moment the timer
661    /// expires. Returns whether another frame is needed to keep this message
662    /// alive.
663    fn advance(&mut self, now: frust::FrameTime) -> bool {
664        let mut needs_frame = false;
665        if self.anim.is_animating() {
666            if self.anim.advance(now) {
667                needs_frame = true;
668            }
669            self.progress = self.ramp_value();
670        }
671        if !self.exiting {
672            if self.hold.advance(now) {
673                needs_frame = true;
674            } else {
675                self.begin_ramp(0.0, true, EXIT_DURATION);
676                needs_frame = true;
677            }
678        }
679        needs_frame
680    }
681
682    /// Whether the exit ramp has fully settled (message ready to be dropped).
683    fn settled_exit(&self) -> bool {
684        self.exiting && !self.anim.is_animating() && self.progress <= PROGRESS_EPSILON
685    }
686
687    /// Fire the action's `on_action` callback, if one is attached. No haptic
688    /// hook of its own — unlike the button family, an action press here has
689    /// no upstream-cited haptic signal to route (see the module docs).
690    fn fire_action(&mut self, ctx: &mut EventCtx) {
691        if let Some(cb) = self.on_action.as_mut() {
692            cb(ctx);
693        }
694    }
695
696    /// The vertical slide offset [`Widget::paint`] currently applies on top of
697    /// [`Self::bar_rect`]'s resting position — `0.0` at rest, growing toward
698    /// the bar's own [`HIDDEN_MARGIN`]-past-fully-hidden offset as `progress`
699    /// falls during the entrance/exit ramp (mirrors `paint`'s own `dy`
700    /// formula exactly, so [`Self::visual_bar_rect`] never drifts from what is
701    /// actually drawn).
702    fn visual_dy(&self) -> f64 {
703        let hidden_offset = self.bar_rect.height() + BOTTOM_INSET + HIDDEN_MARGIN;
704        hidden_offset * (1.0 - self.progress.clamp(0.0, 1.0))
705    }
706
707    /// [`Self::bar_rect`] shifted by [`Self::visual_dy`] — the bar's actual
708    /// on-screen box for this frame. Both [`Widget::paint`] and every
709    /// hit-testing entry point ([`begin_press`]/[`to_local`]) read this rather
710    /// than the raw resting rect, so a press during the entrance/exit ramp is
711    /// tested against where the bar visually is (fully or partially off its
712    /// resting position, or off-screen entirely at `progress <= 0.0`) instead
713    /// of lagging behind at its final position — see the module docs' Hit
714    /// region note.
715    fn visual_bar_rect(&self) -> Rect {
716        self.bar_rect + Vec2::new(0.0, self.visual_dy())
717    }
718}
719
720/// The retained widget for a [`SnackbarHostView`]. See the [module docs](self).
721pub struct SnackbarHostWidget {
722    app: ChildPod,
723    current: Option<ActiveSnackbar>,
724}
725
726impl SnackbarHostWidget {
727    fn begin_show(&mut self, msg: ErasedSnackbar) {
728        self.current = Some(ActiveSnackbar::new(msg));
729    }
730}
731
732fn finite_or_zero(v: f64) -> f64 {
733    if v.is_finite() { v } else { 0.0 }
734}
735
736impl Widget for SnackbarHostWidget {
737    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
738        let area = Size::new(
739            finite_or_zero(bc.max().width),
740            finite_or_zero(bc.max().height),
741        );
742        self.app.layout_child(ctx, &BoxConstraints::tight(area));
743        self.app.set_origin(Point::ZERO);
744
745        if let Some(active) = &mut self.current {
746            let theme = Theme::from_layout_ctx(ctx);
747            let (message_style, action_style) = (message_style(theme), action_style(theme));
748            let message_size = active.message_run.shape(ctx, &message_style);
749            let action_content = active.action_run.as_mut().map(|run| {
750                let size = run.shape(ctx, &action_style);
751                Size::new(
752                    size.width + ACTION_PAD_H * 2.0,
753                    size.height + ACTION_PAD_V * 2.0,
754                )
755            });
756            let close_content = active
757                .dismissible
758                .then_some(Size::new(CLOSE_ICON_SIZE, CLOSE_ICON_SIZE));
759
760            let available = (area.width - 2.0 * H_INSET).max(0.0);
761            let bar_width = available.min(MAX_WIDTH);
762
763            let mut content_height = message_size.height;
764            if let Some(a) = action_content {
765                content_height = content_height.max(a.height);
766            }
767            if let Some(c) = close_content {
768                content_height = content_height.max(c.height);
769            }
770            let bar_height = (content_height + CONTENT_PAD_V * 2.0).max(MIN_HEIGHT);
771
772            let mut right_x = bar_width - CONTENT_PAD_H;
773            active.close_rect = close_content.map(|c| {
774                right_x -= c.width;
775                let y = (bar_height - c.height) / 2.0;
776                let rect = Rect::new(right_x, y, right_x + c.width, y + c.height);
777                right_x -= CLOSE_GAP;
778                rect
779            });
780            active.action_rect = action_content.map(|a| {
781                right_x -= a.width;
782                let y = (bar_height - a.height) / 2.0;
783                let rect = Rect::new(right_x, y, right_x + a.width, y + a.height);
784                right_x -= ACTION_GAP;
785                rect
786            });
787
788            let message_y = (bar_height - message_size.height) / 2.0;
789            active.message_origin = Point::new(CONTENT_PAD_H, message_y);
790
791            let bar_size = Size::new(bar_width, bar_height);
792            let bar_x = (area.width - bar_width) / 2.0;
793            let bar_y = area.height - BOTTOM_INSET - bar_height;
794            active.bar_rect = Rect::from_origin_size(Point::new(bar_x, bar_y), bar_size);
795        }
796
797        bc.constrain(area)
798    }
799
800    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
801        self.app.paint_child(ctx, scene);
802
803        let now = ctx.frame_time();
804        let Some(active) = self.current.as_mut() else {
805            return;
806        };
807
808        if active.advance(now) {
809            ctx.request_frame();
810        }
811        if active.settled_exit() {
812            self.current = None;
813            // The next rebuild is what can promote a queued message — ask for
814            // the frame that carries it (the same drain-gotcha `overlay::modal`
815            // documents for its own settle-and-fire path).
816            ctx.request_frame();
817            return;
818        }
819
820        let active = self.current.as_mut().expect("checked above");
821        let progress = active.progress.clamp(0.0, 1.0);
822        if progress <= 0.0 {
823            return;
824        }
825
826        let theme = Theme::from_paint_ctx(ctx);
827        let bar_size = active.bar_rect.size();
828        let origin = ctx.origin() + active.visual_bar_rect().origin().to_vec2();
829
830        let radius = theme.map_or(FALLBACK_RADIUS, |t| t.shape.extra_small);
831        let (blur, y_off, shadow_color) = theme.map_or(
832            (
833                FALLBACK_SHADOW_BLUR,
834                FALLBACK_SHADOW_Y,
835                with_alpha(Color::BLACK, FALLBACK_SHADOW_ALPHA),
836            ),
837            |t| {
838                let spec = t.elevation.level3.shadow(t.brightness);
839                (
840                    spec.blur_std_dev,
841                    spec.y_offset,
842                    with_alpha(t.scheme().shadow, spec.color_alpha),
843                )
844            },
845        );
846        let container = theme.map_or(FALLBACK_CONTAINER, |t| t.scheme().inverse_surface);
847        let on_container = theme.map_or(FALLBACK_ON_CONTAINER, |t| t.scheme().inverse_on_surface);
848        let action_ink = theme.map_or(FALLBACK_ACTION, |t| t.scheme().inverse_primary);
849
850        let layer_rect =
851            Rect::from_origin_size(origin, bar_size).inflate(SHADOW_SPILL, SHADOW_SPILL);
852        scene.push_layer(layer_rect.origin(), layer_rect.size(), progress as f32);
853
854        scene.draw_shadow(
855            Point::new(origin.x, origin.y + y_off),
856            bar_size,
857            radius,
858            blur,
859            shadow_color,
860        );
861        scene.fill_rounded_rect(origin, bar_size, radius, container);
862
863        scene.push_clip_rounded(origin, bar_size, radius);
864        active.message_run.paint(
865            origin + active.message_origin.to_vec2(),
866            on_container,
867            scene,
868        );
869
870        if let Some(rect) = active.action_rect {
871            if active.action_pressed {
872                scene.fill_rounded_rect(
873                    origin + rect.origin().to_vec2(),
874                    rect.size(),
875                    radius,
876                    with_alpha(action_ink, PRESSED_OPACITY),
877                );
878            }
879            if let Some(run) = &active.action_run {
880                let text_origin =
881                    origin + rect.origin().to_vec2() + Vec2::new(ACTION_PAD_H, ACTION_PAD_V);
882                run.paint(text_origin, action_ink, scene);
883            }
884        }
885
886        if let Some(rect) = active.close_rect {
887            if active.close_pressed {
888                scene.fill_rounded_rect(
889                    origin + rect.origin().to_vec2(),
890                    rect.size(),
891                    rect.width() / 2.0,
892                    with_alpha(on_container, PRESSED_OPACITY),
893                );
894            }
895            let (base_path, design) = IconData::from(icons::CLOSE).resolve();
896            let scale = if design > 0.0 {
897                CLOSE_ICON_SIZE / design
898            } else {
899                1.0
900            };
901            let scaled = Affine::scale(scale) * base_path;
902            let icon_origin = origin + rect.origin().to_vec2();
903            scene.fill_path(icon_origin, &scaled, &Brush::Solid(on_container));
904        }
905
906        scene.pop_clip();
907        scene.pop_layer();
908    }
909
910    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
911        if event.is_broadcast() || event.is_focus_routed() {
912            return frust::authoring::route_event_single(&mut self.app, ctx, event);
913        }
914        let InputEvent::Pointer(p) = event else {
915            return frust::authoring::route_event_single(&mut self.app, ctx, event);
916        };
917
918        if let Some(active) = self.current.as_mut()
919            && active.captured_region.is_some()
920        {
921            return handle_captured(active, ctx, p);
922        }
923        if self.app.is_active() {
924            return frust::authoring::route_event_single(&mut self.app, ctx, event);
925        }
926        if p.phase == PointerPhase::Down
927            && presses(p)
928            && let Some(active) = self.current.as_mut()
929            && active.visual_bar_rect().contains(p.position)
930        {
931            return begin_press(active, ctx, p.position);
932        }
933        frust::authoring::route_event_single(&mut self.app, ctx, event)
934    }
935
936    fn semantics(&self, ctx: &mut SemanticsCtx) {
937        self.app.semantics_child(ctx);
938        if let Some(active) = &self.current {
939            let message = active.message.clone();
940            let action_label = active.action_label.clone();
941            let dismissible = active.dismissible;
942            ctx.push_container(
943                Role::Alert,
944                move |node| node.set_label(message.as_str()),
945                move |ctx| {
946                    if let Some(label) = &action_label {
947                        ctx.push_node(Role::Button, |node| {
948                            node.set_label(label.as_str());
949                            node.add_action(Action::Click);
950                        });
951                    }
952                    if dismissible {
953                        ctx.push_node(Role::Button, |node| {
954                            node.set_label("Dismiss");
955                            node.add_action(Action::Click);
956                        });
957                    }
958                },
959            );
960        }
961    }
962
963    frust::authoring::visit_children!(app);
964}
965
966/// The bar's own local box (relative to whichever origin [`to_local`] is
967/// resolving positions against) — the hit region for [`BarRegion::Background`].
968fn local_bar_rect(active: &ActiveSnackbar) -> Rect {
969    Rect::from_origin_size(Point::ZERO, active.bar_rect.size())
970}
971
972/// Translate a host-local `position` into bar-local coordinates, against
973/// [`ActiveSnackbar::visual_bar_rect`] rather than the raw resting
974/// [`ActiveSnackbar::bar_rect`] — so `action_rect`/`close_rect` (already
975/// bar-local, unaffected by the vertical slide) line up with a press landing
976/// on the bar wherever it is currently painted, not where it will rest. Used
977/// by both [`begin_press`] and [`handle_captured`], so a captured drag
978/// interrupted by an auto-dismiss mid-gesture still tracks the bar sliding
979/// out from under it.
980fn to_local(active: &ActiveSnackbar, position: Point) -> Point {
981    let rect = active.visual_bar_rect();
982    Point::new(position.x - rect.x0, position.y - rect.y0)
983}
984
985/// A fresh `Down` inside [`ActiveSnackbar::visual_bar_rect`]: classify which
986/// region it landed in and capture the gesture.
987fn begin_press(active: &mut ActiveSnackbar, ctx: &mut EventCtx, position: Point) -> EventResult {
988    let local = to_local(active, position);
989    let region = if active.close_rect.is_some_and(|r| r.contains(local)) {
990        BarRegion::Close
991    } else if active.action_rect.is_some_and(|r| r.contains(local)) {
992        BarRegion::Action
993    } else {
994        BarRegion::Background
995    };
996    active.captured_region = Some(region);
997    match region {
998        BarRegion::Action => active.action_pressed = true,
999        BarRegion::Close => active.close_pressed = true,
1000        BarRegion::Background => {}
1001    }
1002    ctx.capture_pointer();
1003    ctx.request_redraw();
1004    EventResult::Handled
1005}
1006
1007/// Route a `Move`/`Up`/`Cancel` for an already-captured region. Fires on
1008/// up-inside only (`docs/CODE_STANDARDS.md`'s Interaction Semantics); `Cancel`
1009/// never touches `ctx.state_mut` — it may only clear flags.
1010fn handle_captured(
1011    active: &mut ActiveSnackbar,
1012    ctx: &mut EventCtx,
1013    p: &PointerEvent,
1014) -> EventResult {
1015    let region = active
1016        .captured_region
1017        .expect("handle_captured only runs while captured");
1018    let local = to_local(active, p.position);
1019    let target = match region {
1020        BarRegion::Action => active.action_rect,
1021        BarRegion::Close => active.close_rect,
1022        BarRegion::Background => Some(local_bar_rect(active)),
1023    };
1024    let over = target.is_some_and(|r| r.contains(local));
1025
1026    match p.phase {
1027        PointerPhase::Move => {
1028            match region {
1029                BarRegion::Action => {
1030                    if active.action_pressed != over {
1031                        active.action_pressed = over;
1032                        ctx.request_redraw();
1033                    }
1034                }
1035                BarRegion::Close => {
1036                    if active.close_pressed != over {
1037                        active.close_pressed = over;
1038                        ctx.request_redraw();
1039                    }
1040                }
1041                BarRegion::Background => {}
1042            }
1043            EventResult::Handled
1044        }
1045        PointerPhase::Up => {
1046            if over {
1047                match region {
1048                    BarRegion::Action => {
1049                        active.fire_action(ctx);
1050                        active.begin_ramp(0.0, true, EXIT_DURATION);
1051                    }
1052                    BarRegion::Close => {
1053                        active.begin_ramp(0.0, true, EXIT_DURATION);
1054                    }
1055                    BarRegion::Background => {}
1056                }
1057            }
1058            active.captured_region = None;
1059            active.action_pressed = false;
1060            active.close_pressed = false;
1061            ctx.request_redraw();
1062            EventResult::Handled
1063        }
1064        PointerPhase::Cancel => {
1065            active.captured_region = None;
1066            active.action_pressed = false;
1067            active.close_pressed = false;
1068            ctx.request_redraw();
1069            EventResult::Handled
1070        }
1071        PointerPhase::Down => EventResult::Handled,
1072    }
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use std::any::Any;
1078
1079    use frust::FrameTime;
1080    use frust::authoring::text::FontFamily;
1081    use frust::authoring::{Key, NamedKey, PointerButton};
1082    use frust_widgets::test_support::{RecordingScene, probe};
1083
1084    use super::*;
1085
1086    const WINDOW: Size = Size::new(400.0, 800.0);
1087
1088    fn build<S: 'static>(view: &SnackbarHostView<S>) -> SnackbarHostWidget {
1089        let mut counter = 0u64;
1090        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1091    }
1092
1093    fn layout(w: &mut SnackbarHostWidget) -> Size {
1094        let mut tcx = TextContext::new();
1095        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1096        w.layout(&mut lctx, &BoxConstraints::tight(WINDOW))
1097    }
1098
1099    fn ft_ms(ms: f64) -> FrameTime {
1100        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1101    }
1102
1103    fn frame(w: &mut SnackbarHostWidget, ms: f64) -> RecordingScene {
1104        let mut rec = RecordingScene::default();
1105        let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(ms));
1106        w.paint(&mut ctx, &mut rec);
1107        rec
1108    }
1109
1110    fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1111        InputEvent::Pointer(PointerEvent {
1112            phase,
1113            position: Point::new(x, y),
1114            button: PointerButton::Primary,
1115        })
1116    }
1117
1118    fn dispatch<S: 'static>(
1119        w: &mut SnackbarHostWidget,
1120        state: &mut S,
1121        event: &InputEvent,
1122    ) -> EventResult {
1123        let state_any: &mut dyn Any = state;
1124        let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
1125        w.event(&mut ctx, event)
1126    }
1127
1128    // ---- show -> visible -> auto-dismiss timeline (frame clock) -----------
1129
1130    #[test]
1131    fn show_before_build_is_visible_at_construction() {
1132        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1133        controller.show(snackbar("hi"));
1134        let view = snackbar_host(&controller, probe(0).into_any());
1135        let w = build(&view);
1136        assert!(w.current.is_some());
1137        assert_eq!(w.current.as_ref().unwrap().message, "hi");
1138        assert_eq!(w.current.as_ref().unwrap().progress, 0.0, "not yet ramped");
1139    }
1140
1141    #[test]
1142    fn the_entrance_ramp_reaches_full_progress_within_its_own_duration() {
1143        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1144        controller.show(snackbar("hi"));
1145        let view = snackbar_host(&controller, probe(0).into_any());
1146        let mut w = build(&view);
1147        layout(&mut w);
1148
1149        frame(&mut w, 0.0);
1150        frame(&mut w, 350.0); // past ENTER_DURATION (300ms)
1151
1152        assert_eq!(w.current.as_ref().unwrap().progress, 1.0);
1153    }
1154
1155    #[test]
1156    fn with_no_interaction_the_message_auto_dismisses_after_its_hold_duration() {
1157        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1158        controller.show(snackbar("hi").duration(Duration::from_millis(1000)));
1159        let view = snackbar_host(&controller, probe(0).into_any());
1160        let mut w = build(&view);
1161        layout(&mut w);
1162
1163        frame(&mut w, 0.0);
1164        frame(&mut w, 350.0);
1165        assert_eq!(w.current.as_ref().unwrap().progress, 1.0, "fully shown");
1166
1167        // Past the 1000ms hold: the exit ramp starts.
1168        frame(&mut w, 1100.0);
1169        assert!(w.current.as_ref().unwrap().exiting, "hold expired");
1170
1171        // Seed the fresh exit ramp, then let it run past EXIT_DURATION (200ms).
1172        frame(&mut w, 1150.0);
1173        frame(&mut w, 1400.0);
1174
1175        assert!(w.current.is_none(), "settled exit drops the message");
1176    }
1177
1178    #[test]
1179    fn an_action_press_fires_the_callback_and_dismisses_the_message() {
1180        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1181        controller.show(snackbar("Saved").action("Undo", |s: &mut Vec<u32>| s.push(99)));
1182        let view = snackbar_host(&controller, probe(0).into_any());
1183        let mut w = build(&view);
1184        layout(&mut w);
1185        frame(&mut w, 0.0);
1186        frame(&mut w, 350.0); // fully shown
1187
1188        let action_rect = w.current.as_ref().unwrap().action_rect.unwrap();
1189        let bar_origin = w.current.as_ref().unwrap().bar_rect.origin();
1190        let press_at = bar_origin + action_rect.center().to_vec2();
1191
1192        let mut state: Vec<u32> = Vec::new();
1193        dispatch(
1194            &mut w,
1195            &mut state,
1196            &pointer(PointerPhase::Down, press_at.x, press_at.y),
1197        );
1198        assert_eq!(
1199            dispatch(
1200                &mut w,
1201                &mut state,
1202                &pointer(PointerPhase::Up, press_at.x, press_at.y)
1203            ),
1204            EventResult::Handled
1205        );
1206
1207        assert_eq!(state, vec![99], "on_action fired exactly once");
1208        assert!(
1209            w.current.as_ref().unwrap().exiting,
1210            "action press dismisses"
1211        );
1212
1213        frame(&mut w, 380.0); // seed the fresh exit ramp
1214        frame(&mut w, 650.0); // past EXIT_DURATION
1215        assert!(w.current.is_none());
1216    }
1217
1218    #[test]
1219    fn a_close_press_dismisses_without_firing_on_action() {
1220        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1221        controller.show(
1222            snackbar("Saved")
1223                .action("Undo", |s: &mut Vec<u32>| s.push(99))
1224                .dismissible(),
1225        );
1226        let view = snackbar_host(&controller, probe(0).into_any());
1227        let mut w = build(&view);
1228        layout(&mut w);
1229        frame(&mut w, 0.0);
1230        frame(&mut w, 350.0);
1231
1232        let close_rect = w.current.as_ref().unwrap().close_rect.unwrap();
1233        let bar_origin = w.current.as_ref().unwrap().bar_rect.origin();
1234        let press_at = bar_origin + close_rect.center().to_vec2();
1235
1236        let mut state: Vec<u32> = Vec::new();
1237        dispatch(
1238            &mut w,
1239            &mut state,
1240            &pointer(PointerPhase::Down, press_at.x, press_at.y),
1241        );
1242        dispatch(
1243            &mut w,
1244            &mut state,
1245            &pointer(PointerPhase::Up, press_at.x, press_at.y),
1246        );
1247
1248        assert!(state.is_empty(), "close never fires on_action");
1249        assert!(w.current.as_ref().unwrap().exiting);
1250    }
1251
1252    #[test]
1253    fn a_cancel_while_captured_clears_press_state_without_touching_state() {
1254        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1255        controller.show(snackbar("Saved").action("Undo", |s: &mut Vec<u32>| s.push(99)));
1256        let view = snackbar_host(&controller, probe(0).into_any());
1257        let mut w = build(&view);
1258        layout(&mut w);
1259        frame(&mut w, 0.0);
1260        frame(&mut w, 350.0);
1261
1262        let action_rect = w.current.as_ref().unwrap().action_rect.unwrap();
1263        let bar_origin = w.current.as_ref().unwrap().bar_rect.origin();
1264        let press_at = bar_origin + action_rect.center().to_vec2();
1265
1266        // Cancel is delivered over a throwaway `()` state at a structural
1267        // rebuild in production; a `()` state here proves the handler never
1268        // reaches for `ctx.state_mut::<Vec<u32>>()`.
1269        dispatch(
1270            &mut w,
1271            &mut Vec::<u32>::new(),
1272            &pointer(PointerPhase::Down, press_at.x, press_at.y),
1273        );
1274        let mut unit_state = ();
1275        let state_any: &mut dyn Any = &mut unit_state;
1276        let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
1277        w.event(
1278            &mut ctx,
1279            &pointer(PointerPhase::Cancel, press_at.x, press_at.y),
1280        );
1281
1282        assert!(
1283            !w.current.as_ref().unwrap().exiting,
1284            "a Cancel never dismisses"
1285        );
1286        assert!(w.current.as_ref().unwrap().captured_region.is_none());
1287    }
1288
1289    // ---- Queue/replace ------------------------------------------------------
1290
1291    #[test]
1292    fn a_second_show_before_the_first_drains_replaces_it() {
1293        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1294        controller.show(snackbar("first"));
1295        controller.show(snackbar("second"));
1296        let view = snackbar_host(&controller, probe(0).into_any());
1297        let w = build(&view);
1298        assert_eq!(w.current.as_ref().unwrap().message, "second");
1299    }
1300
1301    #[test]
1302    fn a_show_while_one_is_current_waits_for_it_to_fully_exit() {
1303        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1304        controller.show(snackbar("first").duration(Duration::from_millis(100)));
1305        let view1 = snackbar_host(&controller, probe(0).into_any());
1306        let mut w = build(&view1);
1307        layout(&mut w);
1308        assert_eq!(w.current.as_ref().unwrap().message, "first");
1309
1310        // A show() while "first" is current must not replace it.
1311        controller.show(snackbar("second"));
1312        let view2 = snackbar_host(&controller, probe(0).into_any());
1313        let mut counter = 0u64;
1314        view2.rebuild(&view1, &mut w, &mut BuildCtx::new(&mut counter));
1315        assert_eq!(
1316            w.current.as_ref().unwrap().message,
1317            "first",
1318            "a pending show must not interrupt the visible message"
1319        );
1320
1321        // Run "first"'s full lifecycle to completion (entrance, 100ms hold,
1322        // exit) via the frame clock alone.
1323        frame(&mut w, 0.0);
1324        frame(&mut w, 350.0); // entrance settles
1325        frame(&mut w, 500.0); // past the 100ms hold -> exit begins
1326        assert!(w.current.as_ref().unwrap().exiting);
1327        frame(&mut w, 550.0); // seed the fresh exit ramp
1328        frame(&mut w, 800.0); // past EXIT_DURATION
1329        assert!(w.current.is_none());
1330
1331        // Only *now*, on the next rebuild, is "second" promoted.
1332        let view3 = snackbar_host(&controller, probe(0).into_any());
1333        view3.rebuild(&view2, &mut w, &mut BuildCtx::new(&mut counter));
1334        assert_eq!(w.current.as_ref().unwrap().message, "second");
1335    }
1336
1337    // ---- Non-modal: events outside the snackbar still route ----------------
1338
1339    #[test]
1340    fn a_press_outside_the_bar_still_reaches_app_content_beneath_it() {
1341        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1342        controller.show(snackbar("hi"));
1343        let view = snackbar_host(&controller, probe(7).into_any());
1344        let mut w = build(&view);
1345        layout(&mut w);
1346
1347        // Near the top of the window — clearly outside the bottom-anchored bar.
1348        let mut state: Vec<u32> = Vec::new();
1349        let result = dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
1350        assert_eq!(result, EventResult::Handled);
1351        assert_eq!(
1352            state,
1353            vec![7],
1354            "app content beneath the bar still receives input"
1355        );
1356    }
1357
1358    #[test]
1359    fn a_press_on_the_bar_s_own_background_is_swallowed_not_forwarded_to_app() {
1360        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1361        controller.show(snackbar("a message with no action"));
1362        let view = snackbar_host(&controller, probe(7).into_any());
1363        let mut w = build(&view);
1364        layout(&mut w);
1365        // Settle the entrance ramp first: an un-advanced widget is still at
1366        // `progress == 0.0` (nothing painted yet), which is itself the ramp
1367        // window the "Hit region tracks the paint-time translation" tests
1368        // below cover — their whole point is that a press lands nowhere
1369        // during it.
1370        frame(&mut w, 0.0);
1371        frame(&mut w, 350.0); // fully shown
1372
1373        let bar_rect = w.current.as_ref().unwrap().bar_rect;
1374        let press_at = Point::new(bar_rect.center().x, bar_rect.center().y);
1375
1376        let mut state: Vec<u32> = Vec::new();
1377        dispatch(
1378            &mut w,
1379            &mut state,
1380            &pointer(PointerPhase::Down, press_at.x, press_at.y),
1381        );
1382        dispatch(
1383            &mut w,
1384            &mut state,
1385            &pointer(PointerPhase::Up, press_at.x, press_at.y),
1386        );
1387
1388        assert!(
1389            state.is_empty(),
1390            "the bar's own background swallows the press"
1391        );
1392        assert!(!w.current.as_ref().unwrap().exiting, "and does not dismiss");
1393    }
1394
1395    #[test]
1396    fn a_focus_routed_key_event_is_routed_by_focus_not_claimed_by_the_bar() {
1397        // The host is non-modal: it never calls `EventCtx::request_focus`
1398        // itself, so a Key event (focus-routed, never hit-tested) is routed
1399        // exactly like any single-child wrapper's — to whichever descendant
1400        // holds focus, never intercepted here. With nothing focused (this
1401        // test's `Probe` never claims it) that is an `Ignored` no-op, not a
1402        // panic or a swallow.
1403        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1404        controller.show(snackbar("hi"));
1405        let view = snackbar_host(&controller, probe(3).into_any());
1406        let mut w = build(&view);
1407        layout(&mut w);
1408
1409        let mut state: Vec<u32> = Vec::new();
1410        let key = InputEvent::Key(frust::authoring::KeyEvent {
1411            key: Key::Named(NamedKey::Escape),
1412            modifiers: Default::default(),
1413            repeat: false,
1414        });
1415        let result = dispatch(&mut w, &mut state, &key);
1416        assert_eq!(result, EventResult::Ignored);
1417        assert!(state.is_empty(), "nothing was focused, so nothing fired");
1418    }
1419
1420    // ---- Hit region tracks the paint-time translation -----------------------
1421
1422    /// How far `progress` must fall below `1.0` before the resting rect's own
1423    /// center point lands outside [`ActiveSnackbar::visual_bar_rect`] — derived
1424    /// from the same geometry [`ActiveSnackbar::visual_dy`] uses, so this stays
1425    /// correct regardless of the ramp's easing curve.
1426    fn miss_threshold(bar_rect: Rect) -> f64 {
1427        1.0 - (bar_rect.height() / 2.0) / (bar_rect.height() + BOTTOM_INSET + HIDDEN_MARGIN)
1428    }
1429
1430    #[test]
1431    fn a_press_at_the_resting_rect_mid_entrance_is_not_captured_and_reaches_app_content() {
1432        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1433        controller.show(snackbar("hi"));
1434        let view = snackbar_host(&controller, probe(7).into_any());
1435        let mut w = build(&view);
1436        layout(&mut w);
1437
1438        let bar_rect = w.current.as_ref().unwrap().bar_rect;
1439        let threshold = miss_threshold(bar_rect);
1440
1441        // Seed the ramp, then sample it well inside ENTER_DURATION (300ms) —
1442        // progress is well below rest, so the bar is painted far from (or, on
1443        // the very first frame, not painted anywhere near) its resting box.
1444        frame(&mut w, 0.0);
1445        frame(&mut w, 60.0);
1446        let progress = w.current.as_ref().unwrap().progress;
1447        assert!(
1448            progress < threshold,
1449            "sanity: still well into the entrance ramp (progress={progress}, threshold={threshold})"
1450        );
1451
1452        let press_at = bar_rect.center();
1453        let mut state: Vec<u32> = Vec::new();
1454        let result = dispatch(
1455            &mut w,
1456            &mut state,
1457            &pointer(PointerPhase::Down, press_at.x, press_at.y),
1458        );
1459
1460        assert_eq!(
1461            result,
1462            EventResult::Handled,
1463            "the press falls through to the probe beneath, which claims it"
1464        );
1465        assert_eq!(
1466            state,
1467            vec![7],
1468            "a press at the resting rect mid-entrance reaches app content, not the bar"
1469        );
1470        assert!(
1471            w.current.as_ref().unwrap().captured_region.is_none(),
1472            "the bar must not capture a press landing on its future, not current, position"
1473        );
1474        assert!(
1475            !w.current.as_ref().unwrap().action_pressed,
1476            "no sub-region latched a press either"
1477        );
1478    }
1479
1480    #[test]
1481    fn a_press_at_the_resting_rect_during_the_exit_ramp_is_not_captured_and_reaches_app_content() {
1482        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1483        controller.show(snackbar("hi").dismissible());
1484        let view = snackbar_host(&controller, probe(7).into_any());
1485        let mut w = build(&view);
1486        layout(&mut w);
1487        frame(&mut w, 0.0);
1488        frame(&mut w, 350.0); // fully shown
1489
1490        let bar_rect = w.current.as_ref().unwrap().bar_rect;
1491        let close_rect = w.current.as_ref().unwrap().close_rect.unwrap();
1492        let close_at = bar_rect.origin() + close_rect.center().to_vec2();
1493
1494        let mut state: Vec<u32> = Vec::new();
1495        dispatch(
1496            &mut w,
1497            &mut state,
1498            &pointer(PointerPhase::Down, close_at.x, close_at.y),
1499        );
1500        dispatch(
1501            &mut w,
1502            &mut state,
1503            &pointer(PointerPhase::Up, close_at.x, close_at.y),
1504        );
1505        assert!(
1506            w.current.as_ref().unwrap().exiting,
1507            "the close press starts the exit ramp"
1508        );
1509
1510        // Seed the fresh exit ramp, then sample it well inside EXIT_DURATION
1511        // (200ms).
1512        frame(&mut w, 380.0);
1513        frame(&mut w, 450.0);
1514        let progress = w.current.as_ref().unwrap().progress;
1515        let threshold = miss_threshold(bar_rect);
1516        assert!(
1517            progress < threshold,
1518            "sanity: still well into the exit ramp (progress={progress}, threshold={threshold})"
1519        );
1520
1521        let press_at = bar_rect.center();
1522        let result = dispatch(
1523            &mut w,
1524            &mut state,
1525            &pointer(PointerPhase::Down, press_at.x, press_at.y),
1526        );
1527
1528        assert_eq!(
1529            result,
1530            EventResult::Handled,
1531            "the press falls through to the probe beneath, which claims it"
1532        );
1533        assert_eq!(
1534            state,
1535            vec![7],
1536            "a press at the resting rect during the exit ramp reaches app content, not the bar"
1537        );
1538        assert!(w.current.as_ref().unwrap().captured_region.is_none());
1539    }
1540
1541    #[test]
1542    fn a_settled_press_still_hits_the_bar_action_and_close_as_before() {
1543        // A control against the two tests above: once the entrance ramp has
1544        // fully settled, the resting rect and the visual rect coincide again,
1545        // so ordinary presses on the action/close sub-rects keep working
1546        // exactly like `an_action_press_fires_the_callback_and_dismisses_the_message`
1547        // and `a_close_press_dismisses_without_firing_on_action` already cover
1548        // end-to-end — this only re-asserts the geometry those rely on.
1549        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1550        controller.show(snackbar("hi").dismissible());
1551        let view = snackbar_host(&controller, probe(7).into_any());
1552        let mut w = build(&view);
1553        layout(&mut w);
1554        frame(&mut w, 0.0);
1555        frame(&mut w, 350.0); // fully shown
1556
1557        let active = w.current.as_ref().unwrap();
1558        assert_eq!(active.progress, 1.0);
1559        assert_eq!(
1560            active.visual_bar_rect(),
1561            active.bar_rect,
1562            "at rest, the visual and resting rects coincide"
1563        );
1564    }
1565
1566    // ---- Width behavior ------------------------------------------------------
1567
1568    #[test]
1569    fn phone_width_is_full_width_minus_margins() {
1570        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1571        controller.show(snackbar("hi"));
1572        let view = snackbar_host(&controller, probe(0).into_any());
1573        let mut w = build(&view);
1574        let mut tcx = TextContext::new();
1575        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1576        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(360.0, 800.0)));
1577        assert_eq!(
1578            w.current.as_ref().unwrap().bar_rect.width(),
1579            360.0 - 2.0 * H_INSET
1580        );
1581    }
1582
1583    // ---- Typeface: the runs' families follow the live theme ------------------
1584
1585    /// A host showing a message with an action, built but not yet laid out.
1586    fn host_with_action() -> SnackbarHostWidget {
1587        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1588        controller.show(snackbar("Saved").action("Undo", |s: &mut Vec<u32>| s.push(99)));
1589        build(&snackbar_host(&controller, probe(0).into_any()))
1590    }
1591
1592    /// A baseline theme whose `bodyMedium`/`labelLarge` roles name `message`/
1593    /// `action`.
1594    fn theme_with(message: FontFamily, action: FontFamily) -> Theme {
1595        let mut theme = crate::baseline();
1596        theme.type_scale.body_medium.family = message;
1597        theme.type_scale.label_large.family = action;
1598        theme
1599    }
1600
1601    /// Lay `w` out against `tcx`, threading `theme` the way the render root
1602    /// does.
1603    fn layout_themed(w: &mut SnackbarHostWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
1604        let mut ctx =
1605            LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1606        w.layout(&mut ctx, &BoxConstraints::tight(WINDOW));
1607    }
1608
1609    #[test]
1610    fn layout_takes_the_message_and_action_families_from_their_roles() {
1611        let mut w = host_with_action();
1612        let mut tcx = TextContext::new();
1613        let (message, action) = (
1614            FontFamily::named("Message Role Probe"),
1615            FontFamily::named("Action Role Probe"),
1616        );
1617        layout_themed(
1618            &mut w,
1619            &mut tcx,
1620            Some(&theme_with(message.clone(), action.clone())),
1621        );
1622        let active = w.current.as_ref().expect("a message is showing");
1623        assert_eq!(active.message_run.style.family, message, "bodyMedium");
1624        let action_run = active.action_run.as_ref().expect("an action is set");
1625        assert_eq!(action_run.style.family, action, "labelLarge");
1626        // Only the family is themed: the token literals stay.
1627        assert_eq!(active.message_run.style.size, MESSAGE_SIZE);
1628        assert_eq!(action_run.style.size, ACTION_SIZE);
1629    }
1630
1631    #[test]
1632    fn without_a_theme_the_runs_keep_the_unthemed_styles() {
1633        assert_eq!(
1634            message_style(None),
1635            TextStyle {
1636                weight: FontWeight::REGULAR,
1637                letter_spacing: MESSAGE_LETTER_SPACING,
1638                line_height: LineHeight::Absolute(MESSAGE_LINE_HEIGHT),
1639                ..TextStyle::new(MESSAGE_SIZE, Color::BLACK)
1640            }
1641        );
1642        assert_eq!(
1643            action_style(None),
1644            TextStyle {
1645                weight: FontWeight::MEDIUM,
1646                letter_spacing: ACTION_LETTER_SPACING,
1647                line_height: LineHeight::Absolute(ACTION_LINE_HEIGHT),
1648                ..TextStyle::new(ACTION_SIZE, Color::BLACK)
1649            }
1650        );
1651
1652        let mut w = host_with_action();
1653        let mut tcx = TextContext::new();
1654        layout_themed(&mut w, &mut tcx, None);
1655        let active = w.current.as_ref().expect("a message is showing");
1656        assert_eq!(active.message_run.style, message_style(None));
1657        let action_run = active.action_run.as_ref().expect("an action is set");
1658        assert_eq!(action_run.style, action_style(None));
1659    }
1660
1661    #[test]
1662    fn a_theme_swap_reshapes_the_cached_runs() {
1663        let mut w = host_with_action();
1664        let mut tcx = TextContext::new();
1665        let probe_a = || FontFamily::named("Snackbar Swap Probe A");
1666        let probe_b = || FontFamily::named("Snackbar Swap Probe B");
1667        let first = theme_with(probe_a(), probe_a());
1668        layout_themed(&mut w, &mut tcx, Some(&first));
1669
1670        // Control: the same theme again reuses both cached runs outright.
1671        let settled = tcx.shape_cache_stats();
1672        layout_themed(&mut w, &mut tcx, Some(&first));
1673        assert_eq!(
1674            tcx.shape_cache_stats(),
1675            settled,
1676            "an unchanged theme reshapes nothing"
1677        );
1678
1679        layout_themed(&mut w, &mut tcx, Some(&theme_with(probe_b(), probe_b())));
1680        assert_eq!(
1681            tcx.shape_cache_stats().shapes,
1682            settled.shapes + 2,
1683            "a family swap must reshape the message and the action"
1684        );
1685    }
1686
1687    #[test]
1688    fn wide_viewport_caps_at_max_width_and_centers() {
1689        let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1690        controller.show(snackbar("hi"));
1691        let view = snackbar_host(&controller, probe(0).into_any());
1692        let mut w = build(&view);
1693        let mut tcx = TextContext::new();
1694        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1695        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(1200.0, 800.0)));
1696        let bar = w.current.as_ref().unwrap().bar_rect;
1697        assert_eq!(bar.width(), MAX_WIDTH);
1698        assert!(
1699            (bar.center().x - 600.0).abs() < 1e-9,
1700            "centered, not edge-pinned"
1701        );
1702    }
1703}