Skip to main content

frust_material/overlay/
mod.rs

1// Ported from `frust-shadcn`'s overlay seam (`plugins/shadcn/src/overlay/`
2// `{mod,anchored,modal}.rs`), itself a port of shadcn/ui v4 rev
3// `d4fc45b1fbabfccb7a6a4333d8004cf19481caa9` (MIT, © shadcn).
4// A **sibling port, never a dependency**: a design-system plugin may not depend
5// on another plugin (`docs/PLUGINS_ARCHITECTURE.md`'s Design-System Plugins
6// charter), so the proven geometry math and dismiss semantics are carried over
7// verbatim while every token read is re-folded onto a Material role.
8// Porting decisions per module: see each module's own header.
9
10//! The shared overlay hosting seam: the two patterns every Material panel that
11//! leaves its parent's box is built on, plus the token accessors those panels
12//! paint from.
13//!
14//! A widget paints inside its parent's box, so an overlay needs a host that
15//! stands in for the web's portal: a **full-area top-layer widget** that fills
16//! whatever area it is given and positions its own content inside it.
17//!
18//! 1. [`mod@anchored`] — the non-modal, trigger-relative host (menu, dropdown,
19//!    tooltip, rich tooltip, exposed-dropdown text field). It paints no scrim,
20//!    positions its content against an anchor rect captured by the trigger, and
21//!    light-dismisses on a press outside that content.
22//! 2. [`mod@modal`] — the scrim + panel host (dialog, alert dialog, bottom
23//!    sheet, side sheet, full-screen search, picker). It paints the M3 32%
24//!    scrim over the whole area, centres or edge-pins its panel, and swallows
25//!    every input the panel's own content did not take.
26//!
27//! # Mounting a host: navigator page (primary) or `Stack` layer
28//!
29//! Either host is a plain widget, so an app can mount it two ways:
30//!
31//! - **As a transparent navigator page** — [`show_overlay_modal`] pushes one
32//!   through
33//!   [`NavigatorController::push_with_options`](frust::NavigatorController::push_with_options),
34//!   so the page below stays visible under the scrim, dismissal is
35//!   `controller.pop()`, a value travels back through the navigator's own
36//!   pop-result machinery, and an Android back press routes through the page's
37//!   [`BackPolicy`](frust::BackPolicy) (see [`mod@modal`]'s back-dismiss
38//!   section). This is the **primary, documented path** — the
39//!   [`mod@crate::dialog`]/[`crate::sheet`] precedent, Flutter's
40//!   dialogs-are-routes model — and the navigator routes input to the top page
41//!   only, so everything below goes inert for pointers *and* for assistive
42//!   technology for free.
43//! - **As the top child of a full-area [`frust::Stack`]** — the host fills the
44//!   stack, `Stack` hit-tests topmost-first, and the app owns the "is it open"
45//!   flag itself. This is the supported route for an app with no navigator,
46//!   and the only sensible one for a hover-driven overlay, whose lifetime is
47//!   far shorter than a navigation.
48//!
49//! Both hosts assume they are that top layer: they fill their constraints and
50//! treat their own box as the window the content is fitted into. That makes
51//! **bounded constraints part of the mounting contract**: neither the navigator
52//! nor the `Stack` may itself sit inside a [`frust::scroll_view`], whose child
53//! constraints are unbounded on the scroll axis — see [`finite_or_zero`]'s
54//! scroll-view trap for what collapses when one does.
55//!
56//! # Claim ordering
57//!
58//! Every host here **routes to its content first and claims nothing before
59//! that** — the container-claims-after-routing rule
60//! (`docs/CODE_STANDARDS.md`'s Interaction Semantics). A host's own chrome (the
61//! modal's close button and drag handle) hit-tests and claims hover only after
62//! the content pod has had the pass, so a hovered control inside the panel wins
63//! the claim and the host still reads hovered through the path.
64//!
65//! # Token accessors
66//!
67//! The free functions below are this seam's read side of the Material token
68//! fold: each resolves one `ColorScheme`/`ShapeScale` role, with the M3
69//! baseline **light** value as its unthemed fallback — the same values
70//! [`crate::baseline`] carries, so a pass with no theme threaded paints
71//! Material's own defaults rather than a hand-picked hex per accessor. They
72//! stay namespaced (`frust_material::overlay::scrim`) rather than flat
73//! re-exported, the same carve-out [`crate::icons`] takes: they are token
74//! plumbing, not catalog surface.
75
76use frust::{Color, Theme};
77
78pub mod anchored;
79pub mod modal;
80
81pub use anchored::{
82    ANCHORED_ENTER_SCALE, AnchoredOverlayView, AnchoredOverlayWidget, OVERLAY_ANCHOR_GAP,
83    OverlayAlign, OverlayAnchor, OverlayAnchorView, OverlayAnchorWidget, OverlayPlacement,
84    OverlaySide, anchored_overlay, overlay_anchor, place_anchored,
85};
86pub use modal::{
87    ModalDismiss, OVERLAY_DIALOG_MAX_WIDTH, OVERLAY_DIALOG_MIN_WIDTH, OVERLAY_EDGE_FRACTION,
88    OVERLAY_FLING_VELOCITY, OVERLAY_HANDLE_RESERVE, OVERLAY_SHEET_MAX_HEIGHT_FRACTION,
89    OVERLAY_SIDE_SHEET_MAX_WIDTH, OverlayBorder, OverlayCorners, OverlayEntrance, OverlayExtent,
90    OverlayGeometry, OverlayLimit, OverlayModalConfig, OverlayModalContent, OverlayModalView,
91    OverlayModalWidget, OverlayRole, overlay_modal, show_overlay_modal,
92};
93
94/// Scrim opacity behind a modal overlay — M3 spec: 32%, the value
95/// [`mod@crate::dialog`] and [`crate::sheet`] already paint their own scrims at.
96pub const OVERLAY_SCRIM_ALPHA: f32 = 0.32;
97
98/// Unthemed-fallback scrim base color (`colors.scrim`), applied at
99/// [`OVERLAY_SCRIM_ALPHA`].
100const FALLBACK_SCRIM: Color = Color::from_rgb8(0x00, 0x00, 0x00);
101/// Unthemed-fallback `surfaceContainerLow`.
102const FALLBACK_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
103/// Unthemed-fallback `surfaceContainer`.
104const FALLBACK_CONTAINER: Color = Color::from_rgb8(0xF3, 0xED, 0xF7);
105/// Unthemed-fallback `surfaceContainerHigh`.
106const FALLBACK_CONTAINER_HIGH: Color = Color::from_rgb8(0xEC, 0xE6, 0xF0);
107/// Unthemed-fallback `surfaceContainerHighest`.
108const FALLBACK_CONTAINER_HIGHEST: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
109/// Unthemed-fallback `onSurface` — a panel's own ink (the close affordance).
110const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
111/// Unthemed-fallback `onSurfaceVariant` — the M3 drag-handle color role.
112const FALLBACK_ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
113/// Unthemed-fallback `outlineVariant` — a panel's hairline border.
114const FALLBACK_OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
115/// Unthemed-fallback `shadow`, applied at the elevation table's own alpha.
116const FALLBACK_SHADOW: Color = Color::from_rgb8(0x00, 0x00, 0x00);
117/// Unthemed-fallback panel corner radius (`shape.extra_large`, 28dp).
118const FALLBACK_RADIUS: f64 = 28.0;
119/// Unthemed-fallback shadow alpha — the `elevation_level` table's own 0.3.
120const FALLBACK_SHADOW_ALPHA: f32 = 0.3;
121
122/// Which `surfaceContainer*` role a panel fills with.
123///
124/// M3 assigns a different container rung per overlay family; a component
125/// picks its own rather than the seam guessing one for it.
126#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
127pub enum OverlayContainer {
128    /// `surfaceContainerLow` — the M3 bottom-sheet / side-sheet container.
129    Low,
130    /// `surfaceContainer` — the M3 menu container.
131    Standard,
132    /// `surfaceContainerHigh` — the M3 dialog container (the default).
133    #[default]
134    High,
135    /// `surfaceContainerHighest` — the deepest container rung.
136    Highest,
137}
138
139/// A panel's elevation rung, resolved from [`frust::Theme::elevation`] at
140/// paint (the [`mod@crate::card`] shadow-resolution precedent).
141///
142/// M3 puts a dialog at level 3 and a sheet at level 1; a component declares
143/// its own rung rather than the seam guessing one for it. The shadow math
144/// itself is `crate::tokens`' documented v1 mapping, not an M3-published
145/// spec — see that module's `elevation_level`.
146#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
147pub enum OverlayElevation {
148    /// No shadow at all (a full-screen panel has no edge to lift off).
149    #[default]
150    None,
151    /// Level 1 (1dp) — the M3 bottom-sheet / side-sheet rung.
152    Level1,
153    /// Level 2 (3dp) — the M3 menu rung.
154    Level2,
155    /// Level 3 (6dp) — the M3 dialog rung.
156    Level3,
157}
158
159/// The shadow to paint for `level`, as `(blur_std_dev, y_offset, color)`;
160/// `None` for [`OverlayElevation::None`].
161pub fn shadow(theme: Option<&Theme>, level: OverlayElevation) -> Option<(f64, f64, Color)> {
162    let dp = match level {
163        OverlayElevation::None => return None,
164        OverlayElevation::Level1 => 1.0,
165        OverlayElevation::Level2 => 3.0,
166        OverlayElevation::Level3 => 6.0,
167    };
168    Some(match theme {
169        Some(theme) => {
170            let table = &theme.elevation;
171            let entry = match level {
172                OverlayElevation::None => unreachable!("returned above"),
173                OverlayElevation::Level1 => table.level1,
174                OverlayElevation::Level2 => table.level2,
175                OverlayElevation::Level3 => table.level3,
176            };
177            let spec = entry.shadow(theme.brightness);
178            (
179                spec.blur_std_dev,
180                spec.y_offset,
181                with_alpha(theme.scheme().shadow, spec.color_alpha),
182            )
183        }
184        // The same `y_offset = dp / 2 + 1`, `blur = dp` mapping the themed
185        // table is built from (`crate::tokens`' `elevation_level`).
186        None => (
187            dp,
188            dp / 2.0 + 1.0,
189            with_alpha(FALLBACK_SHADOW, FALLBACK_SHADOW_ALPHA),
190        ),
191    })
192}
193
194/// Return `color` with its alpha channel replaced by `alpha` (the same helper
195/// [`mod@crate::dialog`] and [`crate::sheet`] each carry).
196pub(crate) fn with_alpha(color: Color, alpha: f32) -> Color {
197    let c = color.components;
198    Color::new([c[0], c[1], c[2], alpha])
199}
200
201/// The modal scrim: `colors.scrim` at [`OVERLAY_SCRIM_ALPHA`].
202pub fn scrim(theme: Option<&Theme>) -> Color {
203    let base = theme.map_or(FALLBACK_SCRIM, |t| t.scheme().scrim);
204    with_alpha(base, OVERLAY_SCRIM_ALPHA)
205}
206
207/// A panel's container fill, for the [`OverlayContainer`] rung it declared.
208pub fn container(theme: Option<&Theme>, role: OverlayContainer) -> Color {
209    match (theme, role) {
210        (Some(t), OverlayContainer::Low) => t.scheme().surface_container_low,
211        (Some(t), OverlayContainer::Standard) => t.scheme().surface_container,
212        (Some(t), OverlayContainer::High) => t.scheme().surface_container_high,
213        (Some(t), OverlayContainer::Highest) => t.scheme().surface_container_highest,
214        (None, OverlayContainer::Low) => FALLBACK_CONTAINER_LOW,
215        (None, OverlayContainer::Standard) => FALLBACK_CONTAINER,
216        (None, OverlayContainer::High) => FALLBACK_CONTAINER_HIGH,
217        (None, OverlayContainer::Highest) => FALLBACK_CONTAINER_HIGHEST,
218    }
219}
220
221/// A panel's own ink (`colors.on_surface`) — the close affordance's color.
222pub fn on_surface(theme: Option<&Theme>) -> Color {
223    theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface)
224}
225
226/// The M3 drag-handle color role (`colors.on_surface_variant`).
227pub fn on_surface_variant(theme: Option<&Theme>) -> Color {
228    theme.map_or(FALLBACK_ON_SURFACE_VARIANT, |t| {
229        t.scheme().on_surface_variant
230    })
231}
232
233/// A panel's hairline border (`colors.outline_variant`).
234pub fn outline_variant(theme: Option<&Theme>) -> Color {
235    theme.map_or(FALLBACK_OUTLINE_VARIANT, |t| t.scheme().outline_variant)
236}
237
238/// A panel's corner radius (`shape.extra_large`, the 28dp token both the M3
239/// dialog and the M3 sheet round to).
240pub fn radius(theme: Option<&Theme>) -> f64 {
241    theme.map_or(FALLBACK_RADIUS, |t| t.shape.extra_large)
242}
243
244/// Whether this pass runs under `Theme.motion.reduce_motion` (unthemed: no).
245pub(crate) fn reduce_motion(theme: Option<&Theme>) -> bool {
246    theme.is_some_and(|t| t.motion.reduce_motion)
247}
248
249/// Coerce a possibly-infinite constraint dimension to a finite value.
250///
251/// A host fills its area, so it expects bounded constraints — a navigator page
252/// or a full-screen [`frust::Stack`] always gives it those; this is the guard
253/// for the degenerate case (the same one [`mod@crate::dialog`] and [`crate::sheet`]
254/// each apply).
255///
256/// # The scroll-view trap
257///
258/// The degenerate case has one common source: [`frust::scroll_view`] lays its
259/// child out with an *infinite* max on the scroll axis. A host mounted anywhere
260/// inside one — directly, or under a navigator/`Stack` that passes its own
261/// constraints through — therefore reads a zero-length area on that axis and
262/// collapses: a modal panel shrinks to a hairline while its barrier still
263/// swallows every pointer press, and an anchored panel clamps to the area's
264/// origin far from its trigger. Each host's test module pins that outcome so
265/// the shape stays visible.
266///
267/// The remedy is at the mount, not here: keep the host outside the scroll view
268/// and scroll the page's own body inside it instead. This coercion cannot
269/// invent an extent nobody offered, and picking an arbitrary one would place
270/// panels against a box that does not exist.
271pub(crate) fn finite_or_zero(v: f64) -> f64 {
272    if v.is_finite() { v } else { 0.0 }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use frust::Brightness;
279
280    #[test]
281    fn every_accessor_folds_onto_its_material_role() {
282        let t = crate::baseline().with_brightness(Brightness::Light);
283        let s = t.scheme();
284        assert_eq!(scrim(Some(&t)), with_alpha(s.scrim, OVERLAY_SCRIM_ALPHA));
285        assert_eq!(
286            container(Some(&t), OverlayContainer::Low),
287            s.surface_container_low
288        );
289        assert_eq!(
290            container(Some(&t), OverlayContainer::Standard),
291            s.surface_container
292        );
293        assert_eq!(
294            container(Some(&t), OverlayContainer::High),
295            s.surface_container_high
296        );
297        assert_eq!(
298            container(Some(&t), OverlayContainer::Highest),
299            s.surface_container_highest
300        );
301        assert_eq!(on_surface(Some(&t)), s.on_surface);
302        assert_eq!(on_surface_variant(Some(&t)), s.on_surface_variant);
303        assert_eq!(outline_variant(Some(&t)), s.outline_variant);
304        assert_eq!(radius(Some(&t)), t.shape.extra_large);
305    }
306
307    #[test]
308    fn the_accessors_follow_a_live_brightness_flip() {
309        let light = crate::baseline().with_brightness(Brightness::Light);
310        let dark = crate::baseline().with_brightness(Brightness::Dark);
311        assert_ne!(
312            container(Some(&light), OverlayContainer::High),
313            container(Some(&dark), OverlayContainer::High)
314        );
315        assert_ne!(on_surface(Some(&light)), on_surface(Some(&dark)));
316    }
317
318    #[test]
319    fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
320        let light = crate::baseline().with_brightness(Brightness::Light);
321        let s = light.scheme();
322        assert_eq!(
323            container(None, OverlayContainer::Low),
324            s.surface_container_low
325        );
326        assert_eq!(
327            container(None, OverlayContainer::High),
328            s.surface_container_high
329        );
330        assert_eq!(on_surface(None), s.on_surface);
331        assert_eq!(on_surface_variant(None), s.on_surface_variant);
332        assert_eq!(outline_variant(None), s.outline_variant);
333        assert_eq!(radius(None), light.shape.extra_large);
334        // The scrim is the one role carried pre-alpha'd, at the M3 32%.
335        let unthemed = scrim(None);
336        assert_eq!(unthemed.components[..3], [0.0, 0.0, 0.0]);
337        assert!((unthemed.components[3] - OVERLAY_SCRIM_ALPHA).abs() < 1e-6);
338        assert_eq!(unthemed, scrim(Some(&light)));
339    }
340
341    #[test]
342    fn the_elevation_rungs_resolve_off_the_themes_own_table() {
343        let t = crate::baseline();
344        assert_eq!(shadow(Some(&t), OverlayElevation::None), None);
345        let (blur, y, color) = shadow(Some(&t), OverlayElevation::Level3).expect("a level-3 rung");
346        let spec = t.elevation.level3.shadow(t.brightness);
347        assert_eq!(blur, spec.blur_std_dev);
348        assert_eq!(y, spec.y_offset);
349        assert_eq!(color, with_alpha(t.scheme().shadow, spec.color_alpha));
350        // A dialog's rung casts a longer shadow than a sheet's.
351        let (sheet_blur, _, _) =
352            shadow(Some(&t), OverlayElevation::Level1).expect("a level-1 rung");
353        assert!(sheet_blur < blur);
354    }
355
356    #[test]
357    fn the_unthemed_elevation_fallback_matches_the_tables_own_mapping() {
358        // `y_offset = dp / 2 + 1`, `blur = dp` — `crate::tokens`' mapping.
359        let (blur, y, _) = shadow(None, OverlayElevation::Level3).expect("a level-3 rung");
360        assert_eq!(blur, 6.0);
361        assert_eq!(y, 4.0);
362        assert_eq!(shadow(None, OverlayElevation::None), None);
363    }
364
365    #[test]
366    fn a_non_material_theme_still_resolves_every_role() {
367        // An app is free to thread a bare theme; nothing here may panic.
368        let bare = frust::Theme::neutral();
369        let _ = scrim(Some(&bare));
370        let _ = container(Some(&bare), OverlayContainer::High);
371        let _ = on_surface(Some(&bare));
372        let _ = radius(Some(&bare));
373        assert!(!reduce_motion(None));
374    }
375
376    #[test]
377    fn finite_or_zero_collapses_an_unbounded_axis() {
378        assert_eq!(finite_or_zero(400.0), 400.0);
379        assert_eq!(finite_or_zero(f64::INFINITY), 0.0);
380    }
381}