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}