Skip to main content

frust_theme/
glass.rs

1//! Glass material tokens: a three-tier [`GlassScale`]
2//! (`chrome`/`bar`/`control`), each carrying a [`GlassMaterial`] recipe —
3//! background-blur intent, translucent fill washes for over-light and
4//! over-dark content, a specular hairline alpha, and a drop [`ShadowSpec`].
5//!
6//! This is **pure data**: no blur is rendered here. A
7//! [`Theme`](crate::theme::Theme) carries one `GlassScale` regardless of
8//! design language so a widget can read a single API on either — a widget
9//! branches on [`GlassMaterial::is_opaque`], never on a design-language tag.
10//!
11//! This crate constructs exactly one recipe,
12//! [`GlassScale::opaque_material`]: zero blur intent, empty fill stacks,
13//! shadows reusing the [`Elevation::neutral`] table. It is what
14//! [`Theme::neutral`](crate::theme::Theme::neutral) carries, and the value a
15//! translucency-free design system keeps. A design system with real glass
16//! chrome (an iOS-style "Liquid Glass" recipe, say) authors its own
17//! `GlassScale` from its own mined source values and installs it through
18//! [`ThemeBuilder::glass`](crate::builder::ThemeBuilder::glass) — recipes are
19//! design-system data, not framework data.
20//!
21//! `hairline_alpha` and `shadow` are **tuned Frust policy** on the opaque
22//! path (documented, not load-bearing), in the same spirit as
23//! [`crate::elevation`]'s shadow math.
24
25use peniko::Color;
26
27use crate::elevation::{Elevation, ShadowSpec};
28
29/// One translucent wash in a glass fill stack. The `color`'s alpha channel is
30/// the wash opacity; stacks are composited bottom-to-top (first element
31/// painted first) over the blurred backdrop.
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct GlassFill {
34    /// The wash color, alpha included.
35    pub color: Color,
36}
37
38impl GlassFill {
39    /// A wash from straight-line-sRGB components `[r, g, b, a]` (0-1), matching
40    /// the `glass-recipes.json` `rgb`/`a` encoding.
41    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
42        Self {
43            color: Color::new([r, g, b, a]),
44        }
45    }
46}
47
48/// One glass tier's recipe: how much background blur it intends, the
49/// translucent washes to composite over light and over dark content, its
50/// specular hairline alpha, and its drop shadow.
51///
52/// `blur_radius_intent` is an *intent* in the kit's blur units, not a rendered
53/// pixel radius — a later painting task maps it to the platform's blur
54/// primitive. `0.0` means "no lens": the opaque-material path (see
55/// [`GlassMaterial::is_opaque`]).
56#[derive(Clone, Debug, PartialEq)]
57pub struct GlassMaterial {
58    /// Background-blur intent in a design system's own blur units; `0.0` is
59    /// "no lens", the opaque path [`GlassScale::opaque_material`] takes.
60    pub blur_radius_intent: f64,
61    /// Washes composited over light content, bottom-to-top.
62    pub fills_light: Vec<GlassFill>,
63    /// Washes composited over dark content, bottom-to-top.
64    pub fills_dark: Vec<GlassFill>,
65    /// Alpha of the specular hairline edge (white over light, drawn by the
66    /// consuming widget). `0.0` on the opaque path.
67    pub hairline_alpha: f32,
68    /// The tier's drop shadow.
69    pub shadow: ShadowSpec,
70}
71
72impl GlassMaterial {
73    /// `true` when this material carries no background-blur intent — the
74    /// opaque-material path a widget takes on the Material design language,
75    /// where it paints its surface-container role instead of a lens. The
76    /// single-API branch that lets one widget read either design language.
77    pub fn is_opaque(&self) -> bool {
78        self.blur_radius_intent == 0.0
79    }
80}
81
82/// The three glass tiers a [`Theme`](crate::theme::Theme) carries:
83/// `chrome` (popovers/sheets/menus), `bar` (tab/nav/toolbars), and `control`
84/// (buttons/toggles). See the module docs for each tier's source recipe.
85#[derive(Clone, Debug, PartialEq)]
86pub struct GlassScale {
87    /// Popovers, sheets, menus — the most opaque, highest-blur tier.
88    pub chrome: GlassMaterial,
89    /// Tab bars, nav bars, toolbars — a subtle mid-blur tier.
90    pub bar: GlassMaterial,
91    /// Buttons, toggles — a pure low-blur lens, no fill.
92    pub control: GlassMaterial,
93}
94
95impl GlassScale {
96    /// The opaque scale: every tier is **opaque** —
97    /// `blur_radius_intent == 0.0` and empty fill stacks — so a widget reading
98    /// [`GlassMaterial::is_opaque`] paints its surface-container role instead
99    /// of a translucent lens. This is the "one API on either design language"
100    /// seam: the same widget code reads `theme.glass.<tier>` and branches on
101    /// `is_opaque()`.
102    ///
103    /// Shadows reuse the [`Elevation::neutral`] table so the opaque path
104    /// lines up with that elevation ladder: chrome = level 3
105    /// (menus/dialogs), bar = level 2 (nav bar), control = level 1.
106    pub fn opaque_material() -> Self {
107        let elevation = Elevation::neutral();
108        let opaque = |shadow: ShadowSpec| GlassMaterial {
109            blur_radius_intent: 0.0,
110            fills_light: Vec::new(),
111            fills_dark: Vec::new(),
112            hairline_alpha: 0.0,
113            shadow,
114        };
115        Self {
116            // The v1 shadow mapping doesn't branch by brightness (see
117            // `elevation`'s module docs), so `shadow_light` and
118            // `shadow_dark` are identical here — pick either.
119            chrome: opaque(elevation.level3.shadow_light),
120            bar: opaque(elevation.level2.shadow_light),
121            control: opaque(elevation.level1.shadow_light),
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn opaque_material_is_opaque_in_every_tier() {
132        let g = GlassScale::opaque_material();
133        for m in [&g.chrome, &g.bar, &g.control] {
134            assert_eq!(m.blur_radius_intent, 0.0);
135            assert!(m.is_opaque());
136            assert!(m.fills_light.is_empty());
137            assert!(m.fills_dark.is_empty());
138        }
139    }
140
141    #[test]
142    fn opaque_material_shadows_track_the_neutral_elevation_table() {
143        let g = GlassScale::opaque_material();
144        let elevation = Elevation::neutral();
145        assert_eq!(g.chrome.shadow, elevation.level3.shadow_light);
146        assert_eq!(g.bar.shadow, elevation.level2.shadow_light);
147        assert_eq!(g.control.shadow, elevation.level1.shadow_light);
148    }
149
150    #[test]
151    fn a_design_system_scale_stays_a_lens_on_every_tier() {
152        // The other half of the `is_opaque` branch, built the way a design
153        // system with real glass chrome would build it (this crate ships no
154        // translucent recipe of its own): non-zero blur intent per tier is
155        // what makes a widget take the lens path instead of the opaque one.
156        let lens = |blur: f64| GlassMaterial {
157            blur_radius_intent: blur,
158            fills_light: vec![GlassFill::new(1.0, 1.0, 1.0, 0.34)],
159            fills_dark: vec![GlassFill::new(0.0, 0.0, 0.0, 0.41)],
160            hairline_alpha: 0.5,
161            shadow: ShadowSpec {
162                y_offset: 18.0,
163                blur_std_dev: 24.0,
164                color_alpha: 0.30,
165            },
166        };
167        let glass = GlassScale {
168            chrome: lens(75.0),
169            bar: lens(45.0),
170            control: lens(15.0),
171        };
172        for m in [&glass.chrome, &glass.bar, &glass.control] {
173            assert!(!m.is_opaque(), "a non-zero blur intent is never opaque");
174        }
175        assert!(GlassScale::opaque_material().chrome.is_opaque());
176    }
177}