1use 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
15const CAPSULE_CLIP_RADIUS: f32 = 1.0e6;
18
19const CAPSULE_SHADER_RADIUS: f32 = -1.0;
22
23#[derive(Clone, Copy, Debug, PartialEq, Default)]
25pub enum LiquidShape {
26 #[default]
28 Capsule,
29 RoundedRect(f32),
31 Circle,
33}
34
35impl LiquidShape {
36 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 pub fn layer_shape(&self) -> LayerShape {
48 LayerShape::Rounded(self.clip_shape())
49 }
50
51 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
63pub enum GlassVariant {
64 #[default]
66 Regular,
67 Clear,
69 Lens,
73}
74
75#[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#[derive(Clone, Debug, PartialEq, Default)]
100pub struct GlassDynamics {
101 pub tilt: (f32, f32),
103 pub highlight_boost: f32,
105 pub tint_alpha_multiplier: Option<f32>,
108 pub surface_depth_boost: f32,
111 pub optical_strength: f32,
116 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#[derive(Clone, Debug, PartialEq, Default)]
142pub struct GlassMorph {
143 pub node_size: (f32, f32),
149 pub primary: (f32, f32, f32, f32, f32),
150 pub shapes: Vec<(f32, f32, f32, f32, f32)>,
152 pub glue: f32,
154 pub wobble_amplitude: f32,
156 pub wobble_phase: f32,
157 pub bulge_amplitude: f32,
161 pub bulge_direction: f32,
162 pub ellipse_blend: f32,
166 pub deformation: Option<GlassDeformation>,
170}
171
172#[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 pub const MAX_SHAPES: usize = 8;
211}
212
213#[derive(Clone, Debug, PartialEq)]
216pub struct Glass {
217 pub variant: GlassVariant,
218 pub shape: LiquidShape,
219 pub tint: Option<Color>,
221 pub blur_radius: Option<f32>,
223 pub saturation: Option<f32>,
225 pub chromatic_aberration: f32,
227 pub dispersion_axes: (f32, f32),
229 pub displacement: f32,
231 pub bezel_width: f32,
233 pub highlight: f32,
235 pub surface_profile: GlassSurfaceProfile,
237 pub sheen: Option<f32>,
239 pub lift: Option<f32>,
242 pub shadow: bool,
244 pub shadow_style: Option<GlassShadow>,
246 pub clip: bool,
249 pub foreground: Option<Color>,
252 pub adaptive_frost: f32,
254 pub content_recolor: Option<(Color, f32)>,
256 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 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 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 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 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 pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
439 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#[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 pub sheen: f32,
550 pub rim_style: f32,
553 pub foreground_luma: f32,
554 pub adaptive_frost: f32,
555 pub content_recolor: (Color, f32),
556 pub edge_fold: f32,
558 pub shadow_color: Color,
559 pub shadow_radius: f32,
562 pub shadow_offset_y: f32,
563 pub shadow_spread: f32,
564}
565
566impl ResolvedGlass {
567 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 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 shader.set_float(7, self.bezel_width_dp);
625 } else {
626 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 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 shader.set_input_padding(self.input_padding() + morph_pad);
725 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 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
763pub trait LiquidModifierExt {
765 fn glass_effect(self, glass: Glass) -> Modifier;
771
772 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 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 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 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}