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    apply_glass_surface_profile, glass_surface_max_slope, Color, GlassSurfaceProfile,
11    GraphicsLayer, LayerShape, RenderEffect, RoundedCornerShape, RuntimeShader, 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/// Shadow owned by a glass surface. Morphing glass evaluates the same live
76/// SDF for this shadow; clipped static glass forwards the values to the layer
77/// shadow primitive.
78#[derive(Clone, Copy, Debug, PartialEq)]
79pub struct GlassShadow {
80    pub color: Color,
81    pub radius: f32,
82    pub offset_y: f32,
83    pub spread: f32,
84}
85
86impl GlassShadow {
87    pub fn new(color: Color, radius: f32, offset_y: f32, spread: f32) -> Self {
88        Self {
89            color,
90            radius: radius.max(0.0),
91            offset_y,
92            spread,
93        }
94    }
95}
96
97/// Per-frame motion inputs for an interactive glass element, read lazily at
98/// scene-build time (no recomposition per frame).
99#[derive(Clone, Debug, PartialEq, Default)]
100pub struct GlassDynamics {
101    /// Motion tilt (finger/pointer/device motion), roughly −1..1 per axis.
102    pub tilt: (f32, f32),
103    /// Extra specular intensity (0 = spec default; e.g. press boost).
104    pub highlight_boost: f32,
105    /// Optional per-frame multiplier for the material tint alpha. This lets a
106    /// resting selection wash clear continuously as its lens enters flight.
107    pub tint_alpha_multiplier: Option<f32>,
108    /// Extra physical Z depth over the material profile's base depth. Motion
109    /// can deepen the same surface without switching to a separate zoom law.
110    pub surface_depth_boost: f32,
111    /// Motion-state multiplier for displacement and spectral separation.
112    /// Zero keeps the material's full resolved strength; positive values
113    /// explicitly scale it, allowing one persistent body to become quieter
114    /// at rest without cross-fading to another component.
115    pub optical_strength: f32,
116    /// Shape morph: when set, the glass geometry is these node-local rects
117    /// instead of the node cover — the shapeshift channel.
118    pub morph: Option<GlassMorph>,
119}
120
121pub(crate) fn neutral_surface_tint(foreground: Color, light_alpha: f32, dark_alpha: f32) -> Color {
122    let foreground_luma =
123        0.2126 * foreground.r() + 0.7152 * foreground.g() + 0.0722 * foreground.b();
124    if foreground_luma < 0.5 {
125        Color::BLACK.with_alpha(light_alpha.clamp(0.0, 1.0))
126    } else {
127        Color::WHITE.with_alpha(dark_alpha.clamp(0.0, 1.0))
128    }
129}
130
131/// A liquid shapeshift frame: the primary shape plus any number of nearby
132/// glass shapes, ALL smooth-unioned into one field — liquid glass glues to
133/// whatever glass it passes near (a growing menu necks with a neighboring
134/// button, the drag lens merges with the search circle). Up to
135/// [`GlassMorph::MAX_SHAPES`] extra shapes; an angular wobble makes the
136/// mid-flight field bubble like a droplet. All geometry is node-local dp:
137/// `(center_x, center_y, width, height, corner_radius)`; radius sentinel
138/// `-1` means capsule; `-2` means SUBTRACT capsule — the shape carves a
139/// smooth hole in the field (the growing menu leaves its anchor button
140/// crisp on top until it swallows it).
141#[derive(Clone, Debug, PartialEq, Default)]
142pub struct GlassMorph {
143    /// The glass node's own size in dp. The shader receives all morph
144    /// geometry in dp and derives px-per-dp from the renderer-injected node
145    /// pixel rect divided by this — geometry then lands correctly at ANY
146    /// render scale (live window density, robot captures at 1.0, fractional
147    /// desktop scales). The authoring widget always knows its node size.
148    pub node_size: (f32, f32),
149    pub primary: (f32, f32, f32, f32, f32),
150    /// Nearby glass shapes participating in the field.
151    pub shapes: Vec<(f32, f32, f32, f32, f32)>,
152    /// Smooth-union glue radius (dp): shapes within it neck together.
153    pub glue: f32,
154    /// Wobble amplitude (dp) and phase (radians).
155    pub wobble_amplitude: f32,
156    pub wobble_phase: f32,
157    /// Viscous leading-edge bulge: while the shape travels or inflates, its
158    /// side facing `bulge_direction` (radians, math convention) swells like a
159    /// pulled droplet. Amplitude in dp, usually driven by morph velocity.
160    pub bulge_amplitude: f32,
161    pub bulge_direction: f32,
162    /// Blends the primary rounded-rectangle field toward an ellipse. This is
163    /// used by expanding droplets whose broad phase has continuous curvature
164    /// rather than the straight sides of a capsule.
165    pub ellipse_blend: f32,
166    /// Area-preserving affine strain applied to the primary shape. Extra
167    /// scene shapes remain fixed so nearby glass can join the travelling
168    /// droplet without being dragged through its local deformation.
169    pub deformation: Option<GlassDeformation>,
170}
171
172/// A normalized motion axis and reciprocal scales for incompressible glass.
173/// Construction derives the cross-axis scale, so callers cannot describe a
174/// deformation that changes the bubble's area.
175#[derive(Clone, Copy, Debug, PartialEq)]
176pub struct GlassDeformation {
177    axis: (f32, f32),
178    along: f32,
179}
180
181impl GlassDeformation {
182    pub fn incompressible(axis: (f32, f32), along: f32) -> Self {
183        let length = (axis.0 * axis.0 + axis.1 * axis.1).sqrt();
184        let axis = if length > f32::EPSILON {
185            (axis.0 / length, axis.1 / length)
186        } else {
187            (1.0, 0.0)
188        };
189        Self {
190            axis,
191            along: along.max(f32::EPSILON),
192        }
193    }
194
195    pub fn axis(self) -> (f32, f32) {
196        self.axis
197    }
198
199    pub fn along(self) -> f32 {
200        self.along
201    }
202
203    pub fn across(self) -> f32 {
204        1.0 / self.along
205    }
206}
207
208impl GlassMorph {
209    /// Shader budget for extra scene shapes.
210    pub const MAX_SHAPES: usize = 8;
211}
212
213/// Builder describing a glass material. Resolved against the theme at the
214/// composition site, then evaluated per frame for density and dynamics.
215#[derive(Clone, Debug, PartialEq)]
216pub struct Glass {
217    pub variant: GlassVariant,
218    pub shape: LiquidShape,
219    /// Tint over the refracted backdrop; defaults to the theme's glass tint.
220    pub tint: Option<Color>,
221    /// Backdrop blur radius in dp (defaults per variant).
222    pub blur_radius: Option<f32>,
223    /// Saturation boost (defaults per variant).
224    pub saturation: Option<f32>,
225    /// Chromatic aberration spread at the bezel.
226    pub chromatic_aberration: f32,
227    /// Principal-axis wavelength response for the X-Z and Y-Z profiles.
228    pub dispersion_axes: (f32, f32),
229    /// Lens strength: refraction displacement in px.
230    pub displacement: f32,
231    /// Bezel width in dp (the refractive rim zone).
232    pub bezel_width: f32,
233    /// Specular rim intensity.
234    pub highlight: f32,
235    /// Physical X-Z/Y-Z cross-sections for the surface.
236    pub surface_profile: GlassSurfaceProfile,
237    /// Broad Fresnel sheen across the bezel. `None` uses the variant default.
238    pub sheen: Option<f32>,
239    /// Screen-lift override (brightening toward white; negative darkens).
240    /// Defaults per variant.
241    pub lift: Option<f32>,
242    /// Drop shadow below the glass.
243    pub shadow: bool,
244    /// Per-surface shadow override.
245    pub shadow_style: Option<GlassShadow>,
246    /// Clip the layer to `shape`. Morphing glass disables this — coverage
247    /// comes entirely from the shader's SDF.
248    pub clip: bool,
249    /// Foreground color whose contrast the frost must protect. `None` uses
250    /// the theme label color.
251    pub foreground: Option<Color>,
252    /// Strength of backdrop+foreground frost adaptation.
253    pub adaptive_frost: f32,
254    /// Accent applied only to dark foreground detail sampled inside the glass.
255    pub content_recolor: Option<(Color, f32)>,
256    /// Top-edge fold strength (0 = off): content just above the glass
257    /// renders vertically mirrored inside the top band (the reference bar's
258    /// meniscus folds section headers upside-down into it).
259    pub edge_fold: f32,
260}
261
262impl Glass {
263    pub fn regular() -> Self {
264        Self {
265            variant: GlassVariant::Regular,
266            shape: LiquidShape::Capsule,
267            tint: None,
268            blur_radius: None,
269            saturation: None,
270            chromatic_aberration: 0.5,
271            dispersion_axes: (1.0, 1.0),
272            displacement: 22.0,
273            bezel_width: 12.0,
274            highlight: 0.9,
275            surface_profile: GlassSurfaceProfile::regular(),
276            sheen: None,
277            lift: None,
278            shadow: true,
279            shadow_style: None,
280            clip: true,
281            foreground: None,
282            adaptive_frost: 0.65,
283            content_recolor: None,
284            edge_fold: 0.0,
285        }
286    }
287
288    pub fn clear() -> Self {
289        Self {
290            variant: GlassVariant::Clear,
291            ..Self::regular()
292        }
293    }
294
295    /// The interactive lens bubble (reference: the iOS toggle/tab-bar drag
296    /// lens): fully transparent, magnifying dome across the whole element,
297    /// strong rainbow rim.
298    pub fn lens() -> Self {
299        Self {
300            variant: GlassVariant::Lens,
301            shape: LiquidShape::Capsule,
302            tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.07)),
303            blur_radius: None,
304            saturation: None,
305            chromatic_aberration: 3.2,
306            dispersion_axes: (1.0, 1.0),
307            displacement: 30.0,
308            // One compact signed cross-section owns the raised lip and the
309            // recessed-face return. A wide band reads as two concentric
310            // bevels and consumes most of the smaller toggle lens.
311            bezel_width: 10.0,
312            highlight: 1.15,
313            surface_profile: GlassSurfaceProfile::lens(),
314            sheen: None,
315            lift: None,
316            shadow: true,
317            shadow_style: None,
318            clip: true,
319            foreground: None,
320            adaptive_frost: 0.0,
321            content_recolor: None,
322            edge_fold: 0.0,
323        }
324    }
325
326    pub fn shape(mut self, shape: LiquidShape) -> Self {
327        self.shape = shape;
328        self
329    }
330
331    pub fn tint(mut self, tint: Color) -> Self {
332        self.tint = Some(tint);
333        self
334    }
335
336    pub fn blur_radius(mut self, radius_dp: f32) -> Self {
337        self.blur_radius = Some(radius_dp);
338        self
339    }
340
341    pub fn saturation(mut self, saturation: f32) -> Self {
342        self.saturation = Some(saturation);
343        self
344    }
345
346    pub fn chromatic_aberration(mut self, spread: f32) -> Self {
347        self.chromatic_aberration = spread;
348        self
349    }
350
351    pub fn dispersion_axes(mut self, x: f32, y: f32) -> Self {
352        self.dispersion_axes = (x.clamp(0.0, 4.0), y.clamp(0.0, 4.0));
353        self
354    }
355
356    pub fn displacement(mut self, displacement_px: f32) -> Self {
357        self.displacement = displacement_px;
358        self
359    }
360
361    pub fn bezel_width(mut self, width_dp: f32) -> Self {
362        self.bezel_width = width_dp.max(0.0);
363        self
364    }
365
366    pub fn highlight(mut self, highlight: f32) -> Self {
367        self.highlight = highlight;
368        self
369    }
370
371    pub fn surface_profile(mut self, profile: GlassSurfaceProfile) -> Self {
372        self.surface_profile = profile;
373        self
374    }
375
376    pub fn sheen(mut self, sheen: f32) -> Self {
377        self.sheen = Some(sheen);
378        self
379    }
380
381    /// Overrides the screen-lift (how hard the glass brightens what it
382    /// shows; the bar lens uses a near-zero lift to stay transmissive).
383    pub fn lift(mut self, lift: f32) -> Self {
384        self.lift = Some(lift);
385        self
386    }
387
388    pub fn adaptive_frost(mut self, foreground: Color, strength: f32) -> Self {
389        self.foreground = Some(foreground);
390        self.adaptive_frost = strength.clamp(0.0, 1.0);
391        self
392    }
393
394    pub fn content_recolor(mut self, color: Color, strength: f32) -> Self {
395        self.content_recolor = Some((color, strength.clamp(0.0, 1.0)));
396        self
397    }
398
399    pub fn edge_fold(mut self, strength: f32) -> Self {
400        self.edge_fold = strength;
401        self
402    }
403
404    pub fn shadow(mut self, shadow: bool) -> Self {
405        self.shadow = shadow;
406        self
407    }
408
409    pub fn shadow_style(mut self, shadow: GlassShadow) -> Self {
410        self.shadow_style = Some(shadow);
411        self
412    }
413
414    /// Disables the layer clip: the shader's SDF coverage is the only shape
415    /// (required while morphing across geometry the clip can't follow).
416    pub fn no_clip(mut self) -> Self {
417        self.clip = false;
418        self
419    }
420
421    fn default_blur_radius(&self) -> f32 {
422        match self.variant {
423            GlassVariant::Regular => 18.0,
424            GlassVariant::Clear => 3.0,
425            GlassVariant::Lens => 0.0,
426        }
427    }
428
429    fn default_saturation(&self) -> f32 {
430        match self.variant {
431            GlassVariant::Regular => 1.5,
432            GlassVariant::Clear => 1.25,
433            GlassVariant::Lens => 1.0,
434        }
435    }
436
437    /// Theme-resolved material constants (captured at composition time).
438    pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
439        // Screen-lift keeps the blurred backdrop's colors alive while reading
440        // bright (the reference material is far whiter than an alpha tint
441        // could get without going milky).
442        // The reference menu/bar glass shows blurred content smudges through
443        // its body — lift bright but never opaque; the lens lightens what it
444        // magnifies noticeably (the pressed toggle's green reads lifted).
445        let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
446            (GlassVariant::Regular, false) => 0.42,
447            (GlassVariant::Regular, true) => -0.38,
448            (GlassVariant::Clear, false) => 0.12,
449            (GlassVariant::Clear, true) => -0.12,
450            (GlassVariant::Lens, false) => 0.10,
451            (GlassVariant::Lens, true) => -0.08,
452        });
453        let foreground = self.foreground.unwrap_or(colors.label);
454        let shadow = self.shadow_style.unwrap_or_else(|| {
455            GlassShadow::new(
456                Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
457                    (GlassVariant::Lens, false) => 0.14,
458                    (GlassVariant::Lens, true) => 0.28,
459                    (_, false) => 0.16,
460                    (_, true) => 0.5,
461                }),
462                if self.variant == GlassVariant::Lens {
463                    10.0
464                } else {
465                    22.0
466                },
467                if self.variant == GlassVariant::Lens {
468                    3.0
469                } else {
470                    8.0
471                },
472                if self.variant == GlassVariant::Lens {
473                    -6.0
474                } else {
475                    -2.0
476                },
477            )
478        });
479        ResolvedGlass {
480            shape: self.shape,
481            tint: self.tint.unwrap_or(colors.glass_tint),
482            blur_radius_dp: self
483                .blur_radius
484                .unwrap_or_else(|| self.default_blur_radius()),
485            saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
486            chromatic_aberration: self.chromatic_aberration,
487            dispersion_axes: self.dispersion_axes,
488            displacement: self.displacement,
489            bezel_width_dp: self.bezel_width,
490            highlight: self.highlight,
491            surface_profile: self.surface_profile,
492            lift,
493            contrast: if self.variant == GlassVariant::Lens {
494                1.0
495            } else {
496                1.03
497            },
498            shadow: self.shadow,
499            clip: self.clip,
500            foreground_luma: 0.2126 * foreground.r()
501                + 0.7152 * foreground.g()
502                + 0.0722 * foreground.b(),
503            adaptive_frost: self.adaptive_frost,
504            content_recolor: self.content_recolor.unwrap_or((Color::BLACK, 0.0)),
505            edge_fold: self.edge_fold,
506            sheen: self.sheen.unwrap_or(if self.variant == GlassVariant::Lens {
507                0.05
508            } else {
509                1.0
510            }),
511            rim_style: if self.variant == GlassVariant::Lens {
512                1.0
513            } else {
514                0.0
515            },
516            shadow_color: shadow.color,
517            shadow_radius: shadow.radius,
518            shadow_offset_y: shadow.offset_y,
519            shadow_spread: shadow.spread,
520        }
521    }
522}
523
524impl Default for Glass {
525    fn default() -> Self {
526        Self::regular()
527    }
528}
529
530/// A theme-resolved glass material; density and dynamics are applied per
531/// frame in the lazy graphics-layer resolver.
532#[derive(Clone, Debug, PartialEq)]
533pub(crate) struct ResolvedGlass {
534    pub shape: LiquidShape,
535    pub tint: Color,
536    pub blur_radius_dp: f32,
537    pub saturation: f32,
538    pub chromatic_aberration: f32,
539    pub dispersion_axes: (f32, f32),
540    pub displacement: f32,
541    pub bezel_width_dp: f32,
542    pub highlight: f32,
543    pub surface_profile: GlassSurfaceProfile,
544    pub lift: f32,
545    pub contrast: f32,
546    pub shadow: bool,
547    pub clip: bool,
548    /// Broad bezel-glow strength selected by the resolved material.
549    pub sheen: f32,
550    /// 0 = surface glass (soft white spec rim); 1 = interactive lens (thin
551    /// bright line + stronger dark outline, chroma does the color).
552    pub rim_style: f32,
553    pub foreground_luma: f32,
554    pub adaptive_frost: f32,
555    pub content_recolor: (Color, f32),
556    /// Top-edge fold strength (see [`Glass::edge_fold`]).
557    pub edge_fold: f32,
558    pub shadow_color: Color,
559    /// Variant-scaled drop shadow geometry: the lens bubble carries a tight
560    /// contact hint, large surfaces a soft wide ambient.
561    pub shadow_radius: f32,
562    pub shadow_offset_y: f32,
563    pub shadow_spread: f32,
564}
565
566impl ResolvedGlass {
567    /// Builds the backdrop effect chain (blur → lens shader) for the current
568    /// density and per-frame dynamics. Cover mode: geometry in px, container
569    /// uniform zeroed, the renderer injects the node's rect.
570    pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
571        self.runtime_effect(density, dynamics, false)
572    }
573
574    fn content_mask_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
575        self.runtime_effect(density, dynamics, true)
576    }
577
578    fn runtime_effect(
579        &self,
580        density: f32,
581        dynamics: GlassDynamics,
582        content_mask: bool,
583    ) -> RenderEffect {
584        let density = density.max(f32::EPSILON);
585        let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
586        if let Some(morph) = dynamics.morph.as_ref() {
587            // Morph glass: the container carries the node size in dp and ALL
588            // geometry is dp — the shader divides the renderer-injected node
589            // pixel rect by the container, so the field lands correctly at
590            // any render scale (density-scaled packing broke every capture
591            // whose render scale differed from the platform density).
592            let (node_w, node_h) = morph.node_size;
593            let (cx, cy, w, h, radius) = morph.primary;
594            shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
595            shader.set_float2(2, cx, cy);
596            shader.set_float2(4, w, h);
597            shader.set_float(6, radius);
598            let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
599            shader.set_float(30, count as f32);
600            for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
601                let base = 36 + index * 5;
602                shader.set_float(base, *sx);
603                shader.set_float(base + 1, *sy);
604                shader.set_float(base + 2, *sw);
605                shader.set_float(base + 3, *sh);
606                shader.set_float(base + 4, *sr);
607            }
608            shader.set_float(31, morph.glue);
609            shader.set_float(32, morph.wobble_amplitude);
610            shader.set_float(33, morph.wobble_phase);
611            shader.set_float(26, morph.bulge_amplitude);
612            shader.set_float(27, morph.bulge_direction);
613            shader.set_float(110, morph.ellipse_blend.clamp(0.0, 1.0));
614            if let Some(deformation) = morph.deformation {
615                let axis = deformation.axis();
616                shader.set_float2(106, axis.0, axis.1);
617                shader.set_float(108, deformation.along());
618                shader.set_float(109, deformation.across());
619            } else {
620                shader.set_float2(106, 1.0, 0.0);
621                shader.set_float2(108, 1.0, 1.0);
622            }
623            // Bezel in dp: the shader scales by px-per-dp.
624            shader.set_float(7, self.bezel_width_dp);
625        } else {
626            // Cover mode marker: container size stays zero. Geometry is px at
627            // the platform density (node size only known at render time).
628            shader.set_float2(0, 0.0, 0.0);
629            shader.set_float(6, self.shape.shader_radius_px(density));
630            shader.set_float(7, self.bezel_width_dp * density);
631        }
632        shader.set_float(8, self.displacement);
633        shader.set_float(9, 1.5);
634        shader.set_float(
635            11,
636            (self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0),
637        );
638        shader.set_float2(12, dynamics.tilt.0, dynamics.tilt.1);
639        shader.set_float4(
640            14,
641            self.tint.r(),
642            self.tint.g(),
643            self.tint.b(),
644            self.tint.a()
645                * dynamics
646                    .tint_alpha_multiplier
647                    .unwrap_or(1.0)
648                    .clamp(0.0, 1.0),
649        );
650        shader.set_float(18, self.saturation);
651        shader.set_float(19, self.chromatic_aberration);
652        shader.set_float2(118, self.dispersion_axes.0, self.dispersion_axes.1);
653        shader.set_float(20, self.lift);
654        shader.set_float(21, 0.5);
655        shader.set_float2(22, 0.0, 1.0);
656        shader.set_float(24, self.contrast);
657        shader.set_float(29, self.sheen);
658        shader.set_float(28, self.rim_style);
659        shader.set_float(111, dynamics.optical_strength.clamp(0.0, 1.0));
660        shader.set_float(112, if content_mask { 1.0 } else { 0.0 });
661        let profile_unit_scale = if dynamics.morph.is_some() {
662            1.0
663        } else {
664            density
665        };
666        apply_glass_surface_profile(
667            &mut shader,
668            self.surface_profile,
669            profile_unit_scale * (1.0 + dynamics.surface_depth_boost).max(0.0),
670        );
671        shader.set_float(91, self.adaptive_frost);
672        shader.set_float(92, self.edge_fold);
673        shader.set_float(97, self.foreground_luma);
674        shader.set_float(98, self.content_recolor.1);
675        shader.set_float4(
676            99,
677            self.content_recolor.0.r(),
678            self.content_recolor.0.g(),
679            self.content_recolor.0.b(),
680            0.0,
681        );
682        let dynamic_shadow = !self.clip && self.shadow;
683        shader.set_float(
684            102,
685            if dynamic_shadow {
686                self.shadow_color.a() * 0.55
687            } else {
688                0.0
689            },
690        );
691        shader.set_float(103, self.shadow_radius);
692        shader.set_float(104, self.shadow_offset_y);
693        shader.set_float(105, self.shadow_spread);
694        // Morph padding: wobble reach plus how far any scene shape (plus its
695        // glue neck) extends beyond the primary rect — the capture and the
696        // composite surface must cover the whole glued field.
697        let morph_pad = dynamics
698            .morph
699            .as_ref()
700            .map(|morph| {
701                let (px, py, pw, ph, _) = morph.primary;
702                let (left, top) = (px - pw * 0.5, py - ph * 0.5);
703                let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
704                let mut shape_reach = 0.0f32;
705                for (sx, sy, sw, sh, _) in &morph.shapes {
706                    let reach_x = ((sx + sw * 0.5) - right)
707                        .max(left - (sx - sw * 0.5))
708                        .max(0.0);
709                    let reach_y = ((sy + sh * 0.5) - bottom)
710                        .max(top - (sy - sh * 0.5))
711                        .max(0.0);
712                    shape_reach = shape_reach.max(reach_x.max(reach_y));
713                }
714                let glue_pad = if morph.shapes.is_empty() {
715                    0.0
716                } else {
717                    morph.glue * 2.0
718                };
719                morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
720            })
721            .unwrap_or(0.0);
722        // Paddings are consumed in LOGICAL units by the backdrop capture and
723        // output rects — dp, never density-scaled.
724        shader.set_input_padding(self.input_padding() + morph_pad);
725        // Morphing glass WRITES outside the node rect (wobble, bulge, glued
726        // neighbors, plus the ~2px antialiased rim); declare it so the
727        // composite scissor doesn't clip the field at the node edge.
728        if dynamics.morph.is_some() {
729            let shadow_reach = if dynamic_shadow {
730                self.shadow_radius + self.shadow_offset_y.abs() + self.shadow_spread.max(0.0)
731            } else {
732                0.0
733            };
734            shader.set_output_padding(morph_pad + shadow_reach + 4.0);
735        }
736
737        let lens = RenderEffect::runtime_shader(shader);
738        if content_mask {
739            return lens;
740        }
741        let blur_px = self.blur_radius_dp * density;
742        if blur_px > 0.5 {
743            RenderEffect::blur(blur_px).then(lens)
744        } else {
745            lens
746        }
747    }
748
749    /// Backdrop capture padding (px) covering the largest refracted sample
750    /// (see `liquid_glass_input_padding` for the explicit-rect twin). Padded
751    /// for tilt up to ±1 per axis so per-frame tilt never outruns the capture.
752    fn input_padding(&self) -> f32 {
753        let bend = 1.0 - 1.0 / 1.5_f32;
754        let slope = glass_surface_max_slope(self.surface_profile, self.bezel_width_dp.max(1.0));
755        let reach = slope + std::f32::consts::SQRT_2 * 0.18;
756        let axis_scale = self.dispersion_axes.0.max(self.dispersion_axes.1);
757        let spread = 1.0 + axis_scale * self.chromatic_aberration.max(0.0) * 0.5;
758        let displacement = reach * bend * self.displacement.max(0.0) * slope * spread;
759        displacement.ceil() + 2.0
760    }
761}
762
763/// Modifier extension installing the Liquid Glass material.
764pub trait LiquidModifierExt {
765    /// Applies the glass material to this composable's bounds: backdrop blur +
766    /// lens shader, clipped to `glass.shape`, with a soft drop shadow.
767    ///
768    /// Must be called in composable context (the material resolves theme
769    /// colors at the call site).
770    fn glass_effect(self, glass: Glass) -> Modifier;
771
772    /// [`glass_effect`](Self::glass_effect) with per-frame motion inputs; the
773    /// closure is read at scene-build time, so animating tilt or highlight
774    /// does not recompose.
775    fn glass_effect_with(
776        self,
777        glass: Glass,
778        dynamics: impl Fn() -> GlassDynamics + 'static,
779    ) -> Modifier;
780}
781
782impl LiquidModifierExt for Modifier {
783    fn glass_effect(self, glass: Glass) -> Modifier {
784        self.glass_effect_with(glass, GlassDynamics::default)
785    }
786
787    fn glass_effect_with(
788        self,
789        glass: Glass,
790        dynamics: impl Fn() -> GlassDynamics + 'static,
791    ) -> Modifier {
792        let colors = crate::theme::liquid_colors();
793        let resolved = Rc::new(glass.resolve(&colors));
794        let shape = resolved.shape;
795
796        let mut modifier = self;
797        if resolved.shadow && resolved.clip {
798            let shadow_color = resolved.shadow_color;
799            let (radius, offset_y, spread) = (
800                resolved.shadow_radius,
801                resolved.shadow_offset_y,
802                resolved.shadow_spread,
803            );
804            modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
805                scope.radius = radius;
806                scope.spread = spread;
807                scope.offset.y = offset_y;
808                scope.color = shadow_color;
809                // Glass samples the backdrop behind itself — knock the shape
810                // out of its own shadow so the material stays bright.
811                scope.cutout = true;
812            });
813        }
814
815        let layer_resolved = Rc::clone(&resolved);
816        let clip = resolved.clip;
817        modifier.graphics_layer(move || {
818            let density = current_density();
819            let frame = dynamics();
820            let render_effect = (!clip && frame.morph.is_some())
821                .then(|| layer_resolved.content_mask_effect(density, frame.clone()));
822            GraphicsLayer {
823                backdrop_effect: Some(layer_resolved.backdrop_effect(density, frame)),
824                render_effect,
825                shape: shape.layer_shape(),
826                clip,
827                ..Default::default()
828            }
829        })
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    fn light_colors() -> LiquidColors {
838        LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
839    }
840
841    #[test]
842    fn regular_glass_resolves_frosted_defaults() {
843        let colors = light_colors();
844        let resolved = Glass::regular().resolve(&colors);
845        assert!(resolved.blur_radius_dp > 5.0, "regular material is frosted");
846        assert!(resolved.saturation > 1.3, "regular material is vibrant");
847        assert!(resolved.lift > 0.0, "light scheme lifts toward white");
848        assert_eq!(resolved.adaptive_frost, 0.65);
849        let label_luma =
850            0.2126 * colors.label.r() + 0.7152 * colors.label.g() + 0.0722 * colors.label.b();
851        assert!((resolved.foreground_luma - label_luma).abs() < 1e-6);
852    }
853
854    #[test]
855    fn neutral_surface_tint_follows_foreground_polarity() {
856        let light_surface = neutral_surface_tint(Color::BLACK, 0.08, 0.10);
857        assert_eq!(light_surface, Color::BLACK.with_alpha(0.08));
858        let dark_surface = neutral_surface_tint(Color::WHITE, 0.08, 0.10);
859        assert_eq!(dark_surface, Color::WHITE.with_alpha(0.10));
860    }
861
862    #[test]
863    fn dynamics_can_clear_a_material_tint_without_switching_bodies() {
864        let tint = Color::BLACK.with_alpha(0.8);
865        let resolved = Glass::lens().tint(tint).resolve(&light_colors());
866        let effect = resolved.backdrop_effect(
867            1.0,
868            GlassDynamics {
869                tint_alpha_multiplier: Some(0.25),
870                ..Default::default()
871            },
872        );
873        let RenderEffect::Shader { shader } = effect else {
874            panic!("lens glass must be a bare shader");
875        };
876        assert!((shader.uniforms()[17] - 0.2).abs() < 1.0e-6);
877    }
878
879    #[test]
880    fn clear_glass_is_barely_frosted() {
881        let resolved = Glass::clear().resolve(&light_colors());
882        assert!(resolved.blur_radius_dp < 5.0);
883        assert!(resolved.saturation < 1.3);
884    }
885
886    #[test]
887    fn interactive_lens_uses_a_compact_signed_meniscus() {
888        let glass = Glass::lens();
889        assert!(
890            (9.0..=11.0).contains(&glass.bezel_width),
891            "the target lens has one thin raised-lip/recess section, got {}dp",
892            glass.bezel_width
893        );
894        let resolved = glass.resolve(&light_colors());
895        let RenderEffect::Shader { shader } =
896            resolved.backdrop_effect(1.0, GlassDynamics::default())
897        else {
898            panic!("lens glass must be a bare shader");
899        };
900        assert_eq!(shader.uniforms()[7], glass.bezel_width);
901
902        assert_eq!(Glass::lens().bezel_width(7.5).bezel_width, 7.5);
903        assert_eq!(Glass::lens().bezel_width(-1.0).bezel_width, 0.0);
904        assert_eq!(
905            Glass::lens().dispersion_axes(-1.0, 5.0).dispersion_axes,
906            (0.0, 4.0)
907        );
908    }
909
910    #[test]
911    fn dark_scheme_sinks_lift_and_tint() {
912        let dark = LiquidColors::dark(Color::from_rgb_u8(0, 122, 255));
913        let resolved = Glass::regular().resolve(&dark);
914        assert!(resolved.lift < 0.0, "dark scheme sinks toward black");
915        assert_eq!(resolved.tint, dark.glass_tint);
916    }
917
918    #[test]
919    fn explicit_tint_overrides_theme() {
920        let tint = Color::from_rgba_u8(0, 122, 255, 60);
921        let resolved = Glass::regular().tint(tint).resolve(&light_colors());
922        assert_eq!(resolved.tint, tint);
923    }
924
925    #[test]
926    fn explicit_sheen_overrides_the_variant_default() {
927        let resolved = Glass::lens().sheen(0.85).resolve(&light_colors());
928        assert_eq!(resolved.sheen, 0.85);
929        let effect = resolved.backdrop_effect(1.0, GlassDynamics::default());
930        let RenderEffect::Shader { shader } = effect else {
931            panic!("lens glass must be a bare shader");
932        };
933        assert_eq!(shader.uniforms()[29], 0.85);
934    }
935
936    #[test]
937    fn physical_surface_profile_is_packed_for_the_shader() {
938        let x = cranpose_ui_graphics::GlassProfileCurve::from_points(&[
939            (0.0, 0.1),
940            (0.5, 0.2),
941            (1.0, 0.8),
942        ])
943        .expect("X-Z profile");
944        let y = cranpose_ui_graphics::GlassProfileCurve::from_points(&[
945            (0.0, 0.1),
946            (0.6, 0.3),
947            (1.0, 0.7),
948        ])
949        .expect("Y-Z profile");
950        let profile = GlassSurfaceProfile::new(x, y, 4.0, 3.0)
951            .and_then(|profile| profile.with_axis_coupling(0.3))
952            .expect("surface profile");
953        let resolved = Glass::lens()
954            .surface_profile(profile)
955            .dispersion_axes(0.25, 2.0)
956            .resolve(&light_colors());
957        assert_eq!(resolved.surface_profile, profile);
958        assert_eq!(resolved.dispersion_axes, (0.25, 2.0));
959        let effect = resolved.backdrop_effect(
960            1.0,
961            GlassDynamics {
962                surface_depth_boost: 0.25,
963                ..Default::default()
964            },
965        );
966        let RenderEffect::Shader { shader } = effect else {
967            panic!("lens glass must be a bare shader");
968        };
969        let uniforms = shader.uniforms();
970        assert_eq!(&uniforms[113..118], &[1.0, 3.0, 3.0, 3.0, 5.0]);
971        assert_eq!(&uniforms[118..120], &[0.25, 2.0]);
972        assert_eq!(uniforms[120], 0.0);
973        assert_eq!(uniforms[121], 0.1);
974        assert_eq!(uniforms[126], 1.0);
975        assert_eq!(uniforms[127], 0.8);
976        assert_eq!(uniforms[140], 0.0);
977        assert_eq!(uniforms[141], 0.1);
978        assert_eq!(uniforms[146], 1.0);
979        assert_eq!(uniforms[147], 0.7);
980        assert_eq!(uniforms[158], 0.3);
981    }
982
983    #[test]
984    fn adaptive_frost_and_inside_recolor_are_packed() {
985        let foreground = Color::WHITE;
986        let accent = Color::from_rgb_u8(0, 122, 255);
987        let resolved = Glass::lens()
988            .adaptive_frost(foreground, 0.75)
989            .content_recolor(accent, 0.9)
990            .resolve(&light_colors());
991        let effect = resolved.backdrop_effect(1.0, GlassDynamics::default());
992        let RenderEffect::Shader { shader } = effect else {
993            panic!("lens glass must be a bare shader");
994        };
995        let uniforms = shader.uniforms();
996        assert_eq!(uniforms[91], 0.75);
997        assert!((uniforms[97] - 1.0).abs() < 1e-6);
998        assert_eq!(uniforms[98], 0.9);
999        assert_eq!(&uniforms[99..102], &[accent.r(), accent.g(), accent.b()]);
1000
1001        let clamped = Glass::lens()
1002            .adaptive_frost(foreground, 2.0)
1003            .content_recolor(accent, -1.0)
1004            .resolve(&light_colors());
1005        assert_eq!(clamped.adaptive_frost, 1.0);
1006        assert_eq!(clamped.content_recolor.1, 0.0);
1007    }
1008
1009    #[test]
1010    fn cover_mode_effect_chains_blur_then_lens() {
1011        let resolved = Glass::regular().resolve(&light_colors());
1012        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1013        let RenderEffect::Chain { first, second } = effect else {
1014            panic!("regular glass must chain blur → lens");
1015        };
1016        assert!(matches!(*first, RenderEffect::Blur { .. }));
1017        let RenderEffect::Shader { shader } = *second else {
1018            panic!("second stage must be the lens shader");
1019        };
1020        let uniforms = shader.uniforms();
1021        assert_eq!(uniforms[0], 0.0, "cover mode zeroes the container size");
1022        assert_eq!(uniforms[6], CAPSULE_SHADER_RADIUS, "capsule sentinel");
1023        assert!(shader.input_padding() > 0.0);
1024    }
1025
1026    #[test]
1027    fn morph_glass_packs_dp_geometry_with_node_size_container() {
1028        // The density-vs-render-scale contract: morph geometry is dp and the
1029        // container carries the node size in dp, so the shader derives
1030        // px-per-dp from the renderer-injected node pixel rect. Packing
1031        // density-scaled px here broke every capture whose render scale
1032        // differed from the platform density (robot captures at 1.0 on a
1033        // 1.354-density desktop rendered the lens displaced and unscaled).
1034        let resolved = Glass::lens().no_clip().resolve(&light_colors());
1035        let dynamics = GlassDynamics {
1036            optical_strength: 0.3,
1037            morph: Some(GlassMorph {
1038                node_size: (78.0, 59.0),
1039                primary: (39.0, 29.5, 58.0, 39.0, -1.0),
1040                shapes: vec![(100.0, 29.5, 44.0, 44.0, -1.0)],
1041                glue: 12.0,
1042                ellipse_blend: 0.75,
1043                deformation: Some(GlassDeformation::incompressible((3.0, 4.0), 1.25)),
1044                ..Default::default()
1045            }),
1046            ..Default::default()
1047        };
1048        // Density must NOT leak into the packed geometry.
1049        let effect = resolved.backdrop_effect(2.0, dynamics.clone());
1050        let RenderEffect::Shader { shader } = effect else {
1051            panic!("lens glass must be a bare shader (no frost blur)");
1052        };
1053        let uniforms = shader.uniforms();
1054        assert_eq!(&uniforms[0..2], &[78.0, 59.0], "container = node size dp");
1055        assert_eq!(&uniforms[2..6], &[39.0, 29.5, 58.0, 39.0], "primary dp");
1056        assert_eq!(uniforms[6], -1.0, "capsule sentinel unscaled");
1057        assert_eq!(uniforms[31], 12.0, "glue dp");
1058        assert_eq!(&uniforms[36..40], &[100.0, 29.5, 44.0, 44.0], "shape dp");
1059        assert_eq!(&uniforms[106..108], &[0.6, 0.8], "normalized strain axis");
1060        assert_eq!(&uniforms[108..110], &[1.25, 0.8], "reciprocal scales");
1061        assert_eq!(uniforms[110], 0.75, "primary ellipse blend");
1062        assert_eq!(uniforms[111], 0.3, "dynamic optical strength");
1063        assert_eq!(uniforms[112], 0.0, "backdrop mode must preserve the lens");
1064        assert!(uniforms[102] > 0.0, "morph shadow is generated by the SDF");
1065        assert_eq!(uniforms[103], resolved.shadow_radius);
1066        assert_eq!(uniforms[104], resolved.shadow_offset_y);
1067        assert_eq!(uniforms[105], resolved.shadow_spread);
1068        assert!(
1069            shader.output_padding() > resolved.shadow_radius,
1070            "morph output padding must include the dynamic shadow"
1071        );
1072
1073        let mask = resolved.content_mask_effect(2.0, dynamics);
1074        let RenderEffect::Shader {
1075            shader: mask_shader,
1076        } = mask
1077        else {
1078            panic!("the exact morph content mask must be one shader stage");
1079        };
1080        let mask_uniforms = mask_shader.uniforms();
1081        assert_eq!(&mask_uniforms[0..6], &uniforms[0..6]);
1082        assert_eq!(&mask_uniforms[30..40], &uniforms[30..40]);
1083        assert_eq!(&mask_uniforms[106..111], &uniforms[106..111]);
1084        assert_eq!(mask_uniforms[112], 1.0, "content mask mode is explicit");
1085    }
1086
1087    #[test]
1088    fn explicit_shadow_style_controls_static_and_morph_shadow_geometry() {
1089        let style = GlassShadow::new(Color::BLACK.with_alpha(0.07), 32.0, 10.0, 1.5);
1090        let resolved = Glass::regular()
1091            .shadow_style(style)
1092            .no_clip()
1093            .resolve(&light_colors());
1094        assert_eq!(resolved.shadow_color, style.color);
1095        assert_eq!(resolved.shadow_radius, 32.0);
1096        assert_eq!(resolved.shadow_offset_y, 10.0);
1097        assert_eq!(resolved.shadow_spread, 1.5);
1098        assert_eq!(GlassShadow::new(Color::BLACK, -2.0, 0.0, 0.0).radius, 0.0);
1099    }
1100
1101    #[test]
1102    fn clear_variant_skips_heavy_blur() {
1103        let resolved = Glass::clear().blur_radius(0.0).resolve(&light_colors());
1104        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1105        assert!(matches!(effect, RenderEffect::Shader { .. }));
1106    }
1107
1108    #[test]
1109    fn rounded_rect_radius_scales_with_density() {
1110        let resolved = Glass::regular()
1111            .shape(LiquidShape::RoundedRect(16.0))
1112            .resolve(&light_colors());
1113        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1114        let RenderEffect::Chain { second, .. } = effect else {
1115            panic!("expected chain");
1116        };
1117        let RenderEffect::Shader { shader } = *second else {
1118            panic!("expected lens");
1119        };
1120        assert_eq!(shader.uniforms()[6], 32.0, "16dp at 2x density = 32px");
1121    }
1122}