Skip to main content

cranpose_liquid/
material.rs

1//! The Liquid Glass material: a backdrop lens (blur → refraction/vibrancy
2//! shader) applied to a composable's own bounds through
3//! [`LiquidModifierExt::glass_effect`] — the analogue of SwiftUI's
4//! `.glassEffect(_:in:)`.
5
6use crate::theme::LiquidColors;
7use cranpose_ui::current_density;
8use cranpose_ui::Modifier;
9use cranpose_ui_graphics::{
10    Color, GraphicsLayer, LayerShape, RenderEffect, RoundedCornerShape, RuntimeShader,
11    LIQUID_GLASS_WGSL,
12};
13use std::rc::Rc;
14
15/// Corner radius large enough that [`cranpose_ui_graphics::CornerRadii::resolve`]
16/// clamps it to half the shape's size — i.e. a capsule.
17const CAPSULE_CLIP_RADIUS: f32 = 1.0e6;
18
19/// Shader sentinel requesting the capsule radius (resolved against the node's
20/// size at render time; see `liquid_glass.wgsl` cover mode).
21const CAPSULE_SHADER_RADIUS: f32 = -1.0;
22
23/// The shape of a glass element (also its clip and shadow shape).
24#[derive(Clone, Copy, Debug, PartialEq, Default)]
25pub enum LiquidShape {
26    /// A pill: corner radius follows the smaller half-extent.
27    #[default]
28    Capsule,
29    /// Rounded rectangle with the radius in dp.
30    RoundedRect(f32),
31    /// A circle (capsule of a square node).
32    Circle,
33}
34
35impl LiquidShape {
36    /// The clip shape handed to the graphics layer.
37    pub fn clip_shape(&self) -> RoundedCornerShape {
38        match self {
39            LiquidShape::Capsule | LiquidShape::Circle => {
40                RoundedCornerShape::uniform(CAPSULE_CLIP_RADIUS)
41            }
42            LiquidShape::RoundedRect(radius) => RoundedCornerShape::uniform(*radius),
43        }
44    }
45
46    /// The layer shape (clip + shadow geometry).
47    pub fn layer_shape(&self) -> LayerShape {
48        LayerShape::Rounded(self.clip_shape())
49    }
50
51    /// The radius uniform for the lens shader, in px (negative = capsule).
52    fn shader_radius_px(&self, density: f32) -> f32 {
53        match self {
54            LiquidShape::Capsule | LiquidShape::Circle => CAPSULE_SHADER_RADIUS,
55            LiquidShape::RoundedRect(radius) => radius * density,
56        }
57    }
58}
59
60/// Material variant, mirroring SwiftUI's `.regular` / `.clear` glass, plus
61/// the interactive lens.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
63pub enum GlassVariant {
64    /// Frosted: strong backdrop blur, vibrancy boost, scheme-adaptive lift.
65    #[default]
66    Regular,
67    /// Transparent: minimal blur, mild vibrancy — for media-rich backdrops.
68    Clear,
69    /// The interactive magnifying bubble (drag lenses, flying selection):
70    /// no frost, no tone shift, a full-element dome and pronounced rainbow
71    /// dispersion at the rim.
72    Lens,
73}
74
75/// Per-frame motion inputs for an interactive glass element, read lazily at
76/// scene-build time (no recomposition per frame).
77#[derive(Clone, Debug, PartialEq, Default)]
78pub struct GlassDynamics {
79    /// Motion tilt (finger/pointer/device motion), roughly −1..1 per axis.
80    pub tilt: (f32, f32),
81    /// Extra specular intensity (0 = spec default; e.g. press boost).
82    pub highlight_boost: f32,
83    /// Extra magnification over the variant's base (the moving lens
84    /// magnifies harder than a resting one).
85    pub magnify_boost: f32,
86    /// Shape morph: when set, the glass geometry is these node-local rects
87    /// instead of the node cover — the shapeshift channel.
88    pub morph: Option<GlassMorph>,
89}
90
91/// A liquid shapeshift frame: the primary shape plus any number of nearby
92/// glass shapes, ALL smooth-unioned into one field — liquid glass glues to
93/// whatever glass it passes near (a growing menu necks with a neighboring
94/// button, the drag lens merges with the search circle). Up to
95/// [`GlassMorph::MAX_SHAPES`] extra shapes; an angular wobble makes the
96/// mid-flight field bubble like a droplet. All geometry is node-local dp:
97/// `(center_x, center_y, width, height, corner_radius)`; radius sentinel
98/// `-1` means capsule; `-2` means SUBTRACT capsule — the shape carves a
99/// smooth hole in the field (the growing menu leaves its anchor button
100/// crisp on top until it swallows it).
101#[derive(Clone, Debug, PartialEq, Default)]
102pub struct GlassMorph {
103    /// The glass node's own size in dp. The shader receives all morph
104    /// geometry in dp and derives px-per-dp from the renderer-injected node
105    /// pixel rect divided by this — geometry then lands correctly at ANY
106    /// render scale (live window density, robot captures at 1.0, fractional
107    /// desktop scales). The authoring widget always knows its node size.
108    pub node_size: (f32, f32),
109    pub primary: (f32, f32, f32, f32, f32),
110    /// Nearby glass shapes participating in the field.
111    pub shapes: Vec<(f32, f32, f32, f32, f32)>,
112    /// Smooth-union glue radius (dp): shapes within it neck together.
113    pub glue: f32,
114    /// Wobble amplitude (dp) and phase (radians).
115    pub wobble_amplitude: f32,
116    pub wobble_phase: f32,
117    /// Viscous leading-edge bulge: while the shape travels or inflates, its
118    /// side facing `bulge_direction` (radians, math convention) swells like a
119    /// pulled droplet. Amplitude in dp, usually driven by morph velocity.
120    pub bulge_amplitude: f32,
121    pub bulge_direction: f32,
122}
123
124impl GlassMorph {
125    /// Shader budget for extra scene shapes.
126    pub const MAX_SHAPES: usize = 8;
127}
128
129/// Builder describing a glass material. Resolved against the theme at the
130/// composition site, then evaluated per frame for density and dynamics.
131#[derive(Clone, Debug, PartialEq)]
132pub struct Glass {
133    pub variant: GlassVariant,
134    pub shape: LiquidShape,
135    /// Tint over the refracted backdrop; defaults to the theme's glass tint.
136    pub tint: Option<Color>,
137    /// Backdrop blur radius in dp (defaults per variant).
138    pub blur_radius: Option<f32>,
139    /// Saturation boost (defaults per variant).
140    pub saturation: Option<f32>,
141    /// Chromatic aberration spread at the bezel.
142    pub chromatic_aberration: f32,
143    /// Lens strength: refraction displacement in px.
144    pub displacement: f32,
145    /// Bezel width in dp (the refractive rim zone).
146    pub bezel_width: f32,
147    /// Specular rim intensity.
148    pub highlight: f32,
149    /// Screen-lift override (brightening toward white; negative darkens).
150    /// Defaults per variant.
151    pub lift: Option<f32>,
152    /// Drop shadow below the glass.
153    pub shadow: bool,
154    /// Clip the layer to `shape`. Morphing glass disables this — coverage
155    /// comes entirely from the shader's SDF.
156    pub clip: bool,
157}
158
159impl Glass {
160    pub fn regular() -> Self {
161        Self {
162            variant: GlassVariant::Regular,
163            shape: LiquidShape::Capsule,
164            tint: None,
165            blur_radius: None,
166            saturation: None,
167            chromatic_aberration: 0.5,
168            displacement: 22.0,
169            bezel_width: 12.0,
170            highlight: 0.9,
171            lift: None,
172            shadow: true,
173            clip: true,
174        }
175    }
176
177    pub fn clear() -> Self {
178        Self {
179            variant: GlassVariant::Clear,
180            ..Self::regular()
181        }
182    }
183
184    /// The interactive lens bubble (reference: the iOS toggle/tab-bar drag
185    /// lens): fully transparent, magnifying dome across the whole element,
186    /// strong rainbow rim.
187    pub fn lens() -> Self {
188        Self {
189            variant: GlassVariant::Lens,
190            shape: LiquidShape::Capsule,
191            tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.0)),
192            blur_radius: None,
193            saturation: None,
194            chromatic_aberration: 3.2,
195            displacement: 30.0,
196            // Rim-hugging: refraction, dispersion and sheen live in a thin
197            // band at the edge; the interior is pure crisp magnification
198            // (a whole-element bezel made the edge term fight the magnify
199            // pull mid-face — double images of whatever sat under the lens).
200            bezel_width: 16.0,
201            highlight: 1.15,
202            lift: None,
203            shadow: true,
204            clip: true,
205        }
206    }
207
208    pub fn shape(mut self, shape: LiquidShape) -> Self {
209        self.shape = shape;
210        self
211    }
212
213    pub fn tint(mut self, tint: Color) -> Self {
214        self.tint = Some(tint);
215        self
216    }
217
218    pub fn blur_radius(mut self, radius_dp: f32) -> Self {
219        self.blur_radius = Some(radius_dp);
220        self
221    }
222
223    pub fn saturation(mut self, saturation: f32) -> Self {
224        self.saturation = Some(saturation);
225        self
226    }
227
228    pub fn chromatic_aberration(mut self, spread: f32) -> Self {
229        self.chromatic_aberration = spread;
230        self
231    }
232
233    pub fn displacement(mut self, displacement_px: f32) -> Self {
234        self.displacement = displacement_px;
235        self
236    }
237
238    pub fn highlight(mut self, highlight: f32) -> Self {
239        self.highlight = highlight;
240        self
241    }
242
243    /// Overrides the screen-lift (how hard the glass brightens what it
244    /// shows; the bar lens uses a near-zero lift to stay transmissive).
245    pub fn lift(mut self, lift: f32) -> Self {
246        self.lift = Some(lift);
247        self
248    }
249
250    pub fn shadow(mut self, shadow: bool) -> Self {
251        self.shadow = shadow;
252        self
253    }
254
255    /// Disables the layer clip: the shader's SDF coverage is the only shape
256    /// (required while morphing across geometry the clip can't follow).
257    pub fn no_clip(mut self) -> Self {
258        self.clip = false;
259        self
260    }
261
262    fn default_blur_radius(&self) -> f32 {
263        match self.variant {
264            GlassVariant::Regular => 18.0,
265            GlassVariant::Clear => 3.0,
266            GlassVariant::Lens => 0.0,
267        }
268    }
269
270    fn default_saturation(&self) -> f32 {
271        match self.variant {
272            GlassVariant::Regular => 1.5,
273            GlassVariant::Clear => 1.25,
274            GlassVariant::Lens => 1.0,
275        }
276    }
277
278    /// Theme-resolved material constants (captured at composition time).
279    pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
280        // Screen-lift keeps the blurred backdrop's colors alive while reading
281        // bright (the reference material is far whiter than an alpha tint
282        // could get without going milky).
283        // The reference menu/bar glass shows blurred content smudges through
284        // its body — lift bright but never opaque; the lens lightens what it
285        // magnifies noticeably (the pressed toggle's green reads lifted).
286        let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
287            (GlassVariant::Regular, false) => 0.42,
288            (GlassVariant::Regular, true) => -0.38,
289            (GlassVariant::Clear, false) => 0.12,
290            (GlassVariant::Clear, true) => -0.12,
291            (GlassVariant::Lens, false) => 0.10,
292            (GlassVariant::Lens, true) => -0.08,
293        });
294        ResolvedGlass {
295            shape: self.shape,
296            tint: self.tint.unwrap_or(colors.glass_tint),
297            blur_radius_dp: self
298                .blur_radius
299                .unwrap_or_else(|| self.default_blur_radius()),
300            saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
301            chromatic_aberration: self.chromatic_aberration,
302            displacement: self.displacement,
303            bezel_width_dp: self.bezel_width,
304            highlight: self.highlight,
305            lift,
306            contrast: if self.variant == GlassVariant::Lens {
307                1.0
308            } else {
309                1.03
310            },
311            edge_band: if self.variant == GlassVariant::Lens {
312                0.35
313            } else {
314                0.4
315            },
316            shadow: self.shadow,
317            clip: self.clip,
318            dome_direction: if self.variant == GlassVariant::Lens {
319                -1.0
320            } else {
321                1.0
322            },
323            magnify: if self.variant == GlassVariant::Lens {
324                1.35
325            } else {
326                1.0
327            },
328            sheen: if self.variant == GlassVariant::Lens {
329                0.05
330            } else {
331                1.0
332            },
333            rim_style: if self.variant == GlassVariant::Lens {
334                1.0
335            } else {
336                0.0
337            },
338            shadow_color: Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
339                (GlassVariant::Lens, false) => 0.14,
340                (GlassVariant::Lens, true) => 0.28,
341                (_, false) => 0.16,
342                (_, true) => 0.5,
343            }),
344            // The lens is a small floating bubble — its shadow is a tight,
345            // barely-there contact hint; large surfaces get the soft wide
346            // ambient. The reference thumb shadows are almost invisible.
347            shadow_radius: if self.variant == GlassVariant::Lens {
348                10.0
349            } else {
350                22.0
351            },
352            shadow_offset_y: if self.variant == GlassVariant::Lens {
353                3.0
354            } else {
355                8.0
356            },
357            // The lens node is padded well past the visible bubble; a strong
358            // negative spread pulls the shadow back to hug the glass (the
359            // node-sized shadow read as an oversized halo).
360            shadow_spread: if self.variant == GlassVariant::Lens {
361                -6.0
362            } else {
363                -2.0
364            },
365        }
366    }
367}
368
369impl Default for Glass {
370    fn default() -> Self {
371        Self::regular()
372    }
373}
374
375/// A theme-resolved glass material; density and dynamics are applied per
376/// frame in the lazy graphics-layer resolver.
377#[derive(Clone, Debug, PartialEq)]
378pub(crate) struct ResolvedGlass {
379    pub shape: LiquidShape,
380    pub tint: Color,
381    pub blur_radius_dp: f32,
382    pub saturation: f32,
383    pub chromatic_aberration: f32,
384    pub displacement: f32,
385    pub bezel_width_dp: f32,
386    pub highlight: f32,
387    pub lift: f32,
388    pub contrast: f32,
389    pub edge_band: f32,
390    pub shadow: bool,
391    pub clip: bool,
392    /// +1 = rim stretch (bars, menus); −1 = magnifying dome (the lens).
393    pub dome_direction: f32,
394    /// True center magnification (1 = off); the interactive lens uses ~1.35.
395    pub magnify: f32,
396    /// Broad bezel-glow strength (1 = default; the lens dials it to ~0.12
397    /// for the crisp interior of the reference frames).
398    pub sheen: f32,
399    /// 0 = surface glass (soft white spec rim); 1 = interactive lens (thin
400    /// bright line + stronger dark outline, chroma does the color).
401    pub rim_style: f32,
402    pub shadow_color: Color,
403    /// Variant-scaled drop shadow geometry: the lens bubble carries a tight
404    /// contact hint, large surfaces a soft wide ambient.
405    pub shadow_radius: f32,
406    pub shadow_offset_y: f32,
407    pub shadow_spread: f32,
408}
409
410impl ResolvedGlass {
411    /// Builds the backdrop effect chain (blur → lens shader) for the current
412    /// density and per-frame dynamics. Cover mode: geometry in px, container
413    /// uniform zeroed, the renderer injects the node's rect.
414    pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
415        let density = density.max(f32::EPSILON);
416        let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
417        if let Some(morph) = dynamics.morph.as_ref() {
418            // Morph glass: the container carries the node size in dp and ALL
419            // geometry is dp — the shader divides the renderer-injected node
420            // pixel rect by the container, so the field lands correctly at
421            // any render scale (density-scaled packing broke every capture
422            // whose render scale differed from the platform density).
423            let (node_w, node_h) = morph.node_size;
424            let (cx, cy, w, h, radius) = morph.primary;
425            shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
426            shader.set_float2(2, cx, cy);
427            shader.set_float2(4, w, h);
428            shader.set_float(6, radius);
429            let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
430            shader.set_float(30, count as f32);
431            for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
432                let base = 36 + index * 5;
433                shader.set_float(base, *sx);
434                shader.set_float(base + 1, *sy);
435                shader.set_float(base + 2, *sw);
436                shader.set_float(base + 3, *sh);
437                shader.set_float(base + 4, *sr);
438            }
439            shader.set_float(31, morph.glue);
440            shader.set_float(32, morph.wobble_amplitude);
441            shader.set_float(33, morph.wobble_phase);
442            shader.set_float(26, morph.bulge_amplitude);
443            shader.set_float(27, morph.bulge_direction);
444            // Bezel in dp: the shader scales by px-per-dp.
445            shader.set_float(7, self.bezel_width_dp);
446        } else {
447            // Cover mode marker: container size stays zero. Geometry is px at
448            // the platform density (node size only known at render time).
449            shader.set_float2(0, 0.0, 0.0);
450            shader.set_float(6, self.shape.shader_radius_px(density));
451            shader.set_float(7, self.bezel_width_dp * density);
452        }
453        shader.set_float(8, self.displacement);
454        shader.set_float(9, 1.5);
455        shader.set_float(10, 0.6);
456        shader.set_float(
457            11,
458            (self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0),
459        );
460        shader.set_float2(12, dynamics.tilt.0, dynamics.tilt.1);
461        shader.set_float4(
462            14,
463            self.tint.r(),
464            self.tint.g(),
465            self.tint.b(),
466            self.tint.a(),
467        );
468        shader.set_float(18, self.saturation);
469        shader.set_float(19, self.chromatic_aberration);
470        shader.set_float(20, self.lift);
471        shader.set_float(21, 0.5);
472        shader.set_float2(22, 0.0, 1.0);
473        shader.set_float(24, self.contrast);
474        shader.set_float(25, self.edge_band);
475        shader.set_float(29, self.sheen);
476        shader.set_float(28, self.rim_style);
477        shader.set_float(34, self.dome_direction);
478        shader.set_float(35, self.magnify + dynamics.magnify_boost.max(0.0));
479        // Morph padding: wobble reach plus how far any scene shape (plus its
480        // glue neck) extends beyond the primary rect — the capture and the
481        // composite surface must cover the whole glued field.
482        let morph_pad = dynamics
483            .morph
484            .as_ref()
485            .map(|morph| {
486                let (px, py, pw, ph, _) = morph.primary;
487                let (left, top) = (px - pw * 0.5, py - ph * 0.5);
488                let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
489                let mut shape_reach = 0.0f32;
490                for (sx, sy, sw, sh, _) in &morph.shapes {
491                    let reach_x = ((sx + sw * 0.5) - right)
492                        .max(left - (sx - sw * 0.5))
493                        .max(0.0);
494                    let reach_y = ((sy + sh * 0.5) - bottom)
495                        .max(top - (sy - sh * 0.5))
496                        .max(0.0);
497                    shape_reach = shape_reach.max(reach_x.max(reach_y));
498                }
499                let glue_pad = if morph.shapes.is_empty() {
500                    0.0
501                } else {
502                    morph.glue * 2.0
503                };
504                morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
505            })
506            .unwrap_or(0.0);
507        // Paddings are consumed in LOGICAL units by the backdrop capture and
508        // output rects — dp, never density-scaled.
509        shader.set_input_padding(self.input_padding() + morph_pad);
510        // Morphing glass WRITES outside the node rect (wobble, bulge, glued
511        // neighbors, plus the ~2px antialiased rim); declare it so the
512        // composite scissor doesn't clip the field at the node edge.
513        if dynamics.morph.is_some() {
514            shader.set_output_padding(morph_pad + 4.0);
515        }
516
517        let lens = RenderEffect::runtime_shader(shader);
518        let blur_px = self.blur_radius_dp * density;
519        if blur_px > 0.5 {
520            RenderEffect::blur(blur_px).then(lens)
521        } else {
522            lens
523        }
524    }
525
526    /// Backdrop capture padding (px) covering the largest refracted sample
527    /// (see `liquid_glass_input_padding` for the explicit-rect twin). Padded
528    /// for tilt up to ±1 per axis so per-frame tilt never outruns the capture.
529    fn input_padding(&self) -> f32 {
530        let bend = 1.0 - 1.0 / 1.5_f32;
531        let reach = 1.0 + std::f32::consts::SQRT_2;
532        // Edge band peaks at 4; the dome adds 0.35 × its own slope.
533        let slope = 4.0 + 0.35 * (2.0 + 2.0 * 0.6);
534        let spread = 1.0 + self.chromatic_aberration.max(0.0) * 0.5;
535        let displacement = reach * bend * self.displacement.max(0.0) * slope * spread;
536        displacement.ceil() + 2.0
537    }
538}
539
540/// Modifier extension installing the Liquid Glass material.
541pub trait LiquidModifierExt {
542    /// Applies the glass material to this composable's bounds: backdrop blur +
543    /// lens shader, clipped to `glass.shape`, with a soft drop shadow.
544    ///
545    /// Must be called in composable context (the material resolves theme
546    /// colors at the call site).
547    fn glass_effect(self, glass: Glass) -> Modifier;
548
549    /// [`glass_effect`](Self::glass_effect) with per-frame motion inputs; the
550    /// closure is read at scene-build time, so animating tilt or highlight
551    /// does not recompose.
552    fn glass_effect_with(
553        self,
554        glass: Glass,
555        dynamics: impl Fn() -> GlassDynamics + 'static,
556    ) -> Modifier;
557}
558
559impl LiquidModifierExt for Modifier {
560    fn glass_effect(self, glass: Glass) -> Modifier {
561        self.glass_effect_with(glass, GlassDynamics::default)
562    }
563
564    fn glass_effect_with(
565        self,
566        glass: Glass,
567        dynamics: impl Fn() -> GlassDynamics + 'static,
568    ) -> Modifier {
569        let colors = crate::theme::liquid_colors();
570        let resolved = Rc::new(glass.resolve(&colors));
571        let shape = resolved.shape;
572
573        let mut modifier = self;
574        if resolved.shadow {
575            let shadow_color = resolved.shadow_color;
576            let (radius, offset_y, spread) = (
577                resolved.shadow_radius,
578                resolved.shadow_offset_y,
579                resolved.shadow_spread,
580            );
581            modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
582                scope.radius = radius;
583                scope.spread = spread;
584                scope.offset.y = offset_y;
585                scope.color = shadow_color;
586                // Glass samples the backdrop behind itself — knock the shape
587                // out of its own shadow so the material stays bright.
588                scope.cutout = true;
589            });
590        }
591
592        let layer_resolved = Rc::clone(&resolved);
593        let clip = resolved.clip;
594        modifier.graphics_layer(move || GraphicsLayer {
595            backdrop_effect: Some(layer_resolved.backdrop_effect(current_density(), dynamics())),
596            shape: shape.layer_shape(),
597            clip,
598            ..Default::default()
599        })
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    fn light_colors() -> LiquidColors {
608        LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
609    }
610
611    #[test]
612    fn regular_glass_resolves_frosted_defaults() {
613        let resolved = Glass::regular().resolve(&light_colors());
614        assert!(resolved.blur_radius_dp > 5.0, "regular material is frosted");
615        assert!(resolved.saturation > 1.3, "regular material is vibrant");
616        assert!(resolved.lift > 0.0, "light scheme lifts toward white");
617    }
618
619    #[test]
620    fn clear_glass_is_barely_frosted() {
621        let resolved = Glass::clear().resolve(&light_colors());
622        assert!(resolved.blur_radius_dp < 5.0);
623        assert!(resolved.saturation < 1.3);
624    }
625
626    #[test]
627    fn dark_scheme_sinks_lift_and_tint() {
628        let dark = LiquidColors::dark(Color::from_rgb_u8(0, 122, 255));
629        let resolved = Glass::regular().resolve(&dark);
630        assert!(resolved.lift < 0.0, "dark scheme sinks toward black");
631        assert_eq!(resolved.tint, dark.glass_tint);
632    }
633
634    #[test]
635    fn explicit_tint_overrides_theme() {
636        let tint = Color::from_rgba_u8(0, 122, 255, 60);
637        let resolved = Glass::regular().tint(tint).resolve(&light_colors());
638        assert_eq!(resolved.tint, tint);
639    }
640
641    #[test]
642    fn cover_mode_effect_chains_blur_then_lens() {
643        let resolved = Glass::regular().resolve(&light_colors());
644        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
645        let RenderEffect::Chain { first, second } = effect else {
646            panic!("regular glass must chain blur → lens");
647        };
648        assert!(matches!(*first, RenderEffect::Blur { .. }));
649        let RenderEffect::Shader { shader } = *second else {
650            panic!("second stage must be the lens shader");
651        };
652        let uniforms = shader.uniforms();
653        assert_eq!(uniforms[0], 0.0, "cover mode zeroes the container size");
654        assert_eq!(uniforms[6], CAPSULE_SHADER_RADIUS, "capsule sentinel");
655        assert!(shader.input_padding() > 0.0);
656    }
657
658    #[test]
659    fn morph_glass_packs_dp_geometry_with_node_size_container() {
660        // The density-vs-render-scale contract: morph geometry is dp and the
661        // container carries the node size in dp, so the shader derives
662        // px-per-dp from the renderer-injected node pixel rect. Packing
663        // density-scaled px here broke every capture whose render scale
664        // differed from the platform density (robot captures at 1.0 on a
665        // 1.354-density desktop rendered the lens displaced and unscaled).
666        let resolved = Glass::lens().resolve(&light_colors());
667        let dynamics = GlassDynamics {
668            morph: Some(GlassMorph {
669                node_size: (78.0, 59.0),
670                primary: (39.0, 29.5, 58.0, 39.0, -1.0),
671                shapes: vec![(100.0, 29.5, 44.0, 44.0, -1.0)],
672                glue: 12.0,
673                ..Default::default()
674            }),
675            ..Default::default()
676        };
677        // Density must NOT leak into the packed geometry.
678        let effect = resolved.backdrop_effect(2.0, dynamics);
679        let RenderEffect::Shader { shader } = effect else {
680            panic!("lens glass must be a bare shader (no frost blur)");
681        };
682        let uniforms = shader.uniforms();
683        assert_eq!(&uniforms[0..2], &[78.0, 59.0], "container = node size dp");
684        assert_eq!(&uniforms[2..6], &[39.0, 29.5, 58.0, 39.0], "primary dp");
685        assert_eq!(uniforms[6], -1.0, "capsule sentinel unscaled");
686        assert_eq!(uniforms[31], 12.0, "glue dp");
687        assert_eq!(&uniforms[36..40], &[100.0, 29.5, 44.0, 44.0], "shape dp");
688        assert!(
689            shader.output_padding() > 0.0,
690            "morph glass declares output padding"
691        );
692    }
693
694    #[test]
695    fn clear_variant_skips_heavy_blur() {
696        let resolved = Glass::clear().blur_radius(0.0).resolve(&light_colors());
697        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
698        assert!(matches!(effect, RenderEffect::Shader { .. }));
699    }
700
701    #[test]
702    fn rounded_rect_radius_scales_with_density() {
703        let resolved = Glass::regular()
704            .shape(LiquidShape::RoundedRect(16.0))
705            .resolve(&light_colors());
706        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
707        let RenderEffect::Chain { second, .. } = effect else {
708            panic!("expected chain");
709        };
710        let RenderEffect::Shader { shader } = *second else {
711            panic!("expected lens");
712        };
713        assert_eq!(shader.uniforms()[6], 32.0, "16dp at 2x density = 32px");
714    }
715}