1use crate::theme::LiquidColors;
7use cranpose_ui::current_density;
8use cranpose_ui::Modifier;
9use cranpose_ui_graphics::{
10 Color, GraphicsLayer, LayerShape, RenderEffect, RoundedCornerShape, RuntimeShader,
11 GLASS_ACTIVITY_UNIFORM, GLASS_BLUR_RADIUS_UNIFORM, GLASS_DISPERSION_UNIFORM,
12 GLASS_EFFECT_DENSITY_UNIFORM, GLASS_FOLD_DEPTH_UNIFORM, GLASS_MENISCUS_ABSORPTION_UNIFORM,
13 GLASS_OPTICAL_ZOOM_UNIFORM, GLASS_REFRACTION_CURVE_UNIFORM, GLASS_RESTING_TINT_UNIFORM,
14 GLASS_TRANSMISSION_REFRACTION_UNIFORM, LIQUID_GLASS_WGSL,
15};
16use std::rc::Rc;
17
18const CAPSULE_CLIP_RADIUS: f32 = 1.0e6;
21
22const CAPSULE_SHADER_RADIUS: f32 = -1.0;
25
26const WCKSRD_OPTICAL_BLUR_RADIUS_PX: f32 = 2.0;
30
31#[derive(Clone, Copy, Debug, PartialEq, Default)]
33pub enum LiquidShape {
34 #[default]
36 Capsule,
37 RoundedRect(f32),
39 Circle,
41}
42
43impl LiquidShape {
44 pub fn clip_shape(&self) -> RoundedCornerShape {
46 match self {
47 LiquidShape::Capsule | LiquidShape::Circle => {
48 RoundedCornerShape::uniform(CAPSULE_CLIP_RADIUS)
49 }
50 LiquidShape::RoundedRect(radius) => RoundedCornerShape::uniform(*radius),
51 }
52 }
53
54 pub fn layer_shape(&self) -> LayerShape {
56 LayerShape::Rounded(self.clip_shape())
57 }
58
59 fn shader_radius_px(&self, density: f32) -> f32 {
61 match self {
62 LiquidShape::Capsule | LiquidShape::Circle => CAPSULE_SHADER_RADIUS,
63 LiquidShape::RoundedRect(radius) => radius * density,
64 }
65 }
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
71pub enum GlassVariant {
72 #[default]
74 Regular,
75 Clear,
77 Lens,
81}
82
83#[derive(Clone, Copy, Debug, PartialEq)]
87pub struct GlassShadow {
88 pub color: Color,
89 pub radius: f32,
90 pub offset_y: f32,
91 pub spread: f32,
92}
93
94impl GlassShadow {
95 pub fn new(color: Color, radius: f32, offset_y: f32, spread: f32) -> Self {
96 Self {
97 color,
98 radius: radius.max(0.0),
99 offset_y,
100 spread,
101 }
102 }
103}
104
105#[derive(Clone, Debug, PartialEq, Default)]
108pub struct GlassDynamics {
109 pub activity: Option<f32>,
113 pub resting_tint: Option<Color>,
116 pub highlight_boost: f32,
118 pub saturation_boost: f32,
121 pub tint_alpha_multiplier: Option<f32>,
124 pub morph: Option<GlassMorph>,
127 pub touch: Option<(f32, f32, f32)>,
131}
132
133fn foreground_is_dark(foreground: Color) -> bool {
134 let foreground_luma =
135 0.2126 * foreground.r() + 0.7152 * foreground.g() + 0.0722 * foreground.b();
136 foreground_luma < 0.5
137}
138
139fn boost_tint_saturation(tint: Color, boost: f32) -> Color {
140 if boost.abs() <= f32::EPSILON {
141 return tint;
142 }
143 let luma = 0.2126 * tint.r() + 0.7152 * tint.g() + 0.0722 * tint.b();
144 let saturation = (1.0 + boost).max(0.0);
145 Color::rgba(
146 (luma + (tint.r() - luma) * saturation).clamp(0.0, 1.0),
147 (luma + (tint.g() - luma) * saturation).clamp(0.0, 1.0),
148 (luma + (tint.b() - luma) * saturation).clamp(0.0, 1.0),
149 tint.a(),
150 )
151}
152
153pub(crate) fn neutral_surface_tint(foreground: Color, light_alpha: f32, dark_alpha: f32) -> Color {
154 if foreground_is_dark(foreground) {
155 Color::BLACK.with_alpha(light_alpha.clamp(0.0, 1.0))
156 } else {
157 Color::WHITE.with_alpha(dark_alpha.clamp(0.0, 1.0))
158 }
159}
160
161pub(crate) fn neutral_surface_lift(foreground: Color, light_lift: f32, dark_lift: f32) -> f32 {
162 if foreground_is_dark(foreground) {
163 light_lift
164 } else {
165 dark_lift
166 }
167}
168
169#[derive(Clone, Debug, PartialEq, Default)]
180pub struct GlassMorph {
181 pub node_size: (f32, f32),
187 pub primary: (f32, f32, f32, f32, f32),
188 pub shapes: Vec<(f32, f32, f32, f32, f32)>,
190 pub glue: f32,
192 pub wobble_amplitude: f32,
194 pub wobble_phase: f32,
195 pub bulge_amplitude: f32,
199 pub bulge_direction: f32,
200 pub ellipse_blend: f32,
204 pub deformation: Option<GlassDeformation>,
208}
209
210#[derive(Clone, Copy, Debug, PartialEq)]
214pub struct GlassDeformation {
215 axis: (f32, f32),
216 along: f32,
217}
218
219impl GlassDeformation {
220 pub fn incompressible(axis: (f32, f32), along: f32) -> Self {
221 let length = (axis.0 * axis.0 + axis.1 * axis.1).sqrt();
222 let axis = if length > f32::EPSILON {
223 (axis.0 / length, axis.1 / length)
224 } else {
225 (1.0, 0.0)
226 };
227 Self {
228 axis,
229 along: along.max(f32::EPSILON),
230 }
231 }
232
233 pub fn axis(self) -> (f32, f32) {
234 self.axis
235 }
236
237 pub fn along(self) -> f32 {
238 self.along
239 }
240
241 pub fn across(self) -> f32 {
242 1.0 / self.along
243 }
244}
245
246impl GlassMorph {
247 pub const MAX_SHAPES: usize = 8;
249}
250
251#[derive(Clone, Debug, PartialEq)]
254pub struct Glass {
255 pub variant: GlassVariant,
256 pub shape: LiquidShape,
257 pub tint: Option<Color>,
259 pub blur_radius: Option<f32>,
261 pub saturation: Option<f32>,
263 pub refraction_depth: f32,
265 pub refraction_curve: f32,
267 pub dispersion: f32,
269 pub transmission_refraction: f32,
272 pub meniscus_absorption: f32,
275 pub fold_depth: f32,
279 pub optical_zoom: f32,
283 pub rim_reflection: f32,
286 pub highlight: f32,
288 pub lift: Option<f32>,
291 pub shadow: bool,
293 pub shadow_style: Option<GlassShadow>,
295 pub clip: bool,
298 pub foreground: Option<Color>,
301 pub adaptive_frost: f32,
303}
304
305impl Glass {
306 pub fn regular() -> Self {
307 Self {
308 variant: GlassVariant::Regular,
309 shape: LiquidShape::Capsule,
310 tint: None,
311 blur_radius: None,
312 saturation: None,
313 refraction_depth: 0.34,
314 refraction_curve: 0.25,
315 dispersion: 0.0,
316 transmission_refraction: 1.0,
317 meniscus_absorption: 1.0,
318 fold_depth: 0.0,
319 optical_zoom: 1.0,
320 rim_reflection: 1.0,
321 highlight: 0.9,
322 lift: None,
323 shadow: true,
324 shadow_style: None,
325 clip: true,
326 foreground: None,
327 adaptive_frost: 0.65,
328 }
329 }
330
331 pub fn clear() -> Self {
332 Self {
333 variant: GlassVariant::Clear,
334 ..Self::regular()
335 }
336 }
337
338 pub fn lens() -> Self {
342 Self {
343 variant: GlassVariant::Lens,
344 shape: LiquidShape::Capsule,
345 tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.07)),
346 blur_radius: None,
347 saturation: None,
348 refraction_depth: 0.34,
349 refraction_curve: 1.0,
350 dispersion: 0.30,
351 transmission_refraction: 1.0,
352 meniscus_absorption: 1.0,
353 fold_depth: 0.0,
354 optical_zoom: 1.0,
355 rim_reflection: 1.0,
356 highlight: 1.15,
357 lift: None,
358 shadow: true,
359 shadow_style: None,
360 clip: true,
361 foreground: None,
362 adaptive_frost: 0.0,
363 }
364 }
365
366 pub fn shape(mut self, shape: LiquidShape) -> Self {
367 self.shape = shape;
368 self
369 }
370
371 pub fn tint(mut self, tint: Color) -> Self {
372 self.tint = Some(tint);
373 self
374 }
375
376 pub fn blur_radius(mut self, radius_dp: f32) -> Self {
377 self.blur_radius = Some(radius_dp);
378 self
379 }
380
381 pub fn saturation(mut self, saturation: f32) -> Self {
382 self.saturation = Some(saturation);
383 self
384 }
385
386 pub fn refraction_depth(mut self, fraction: f32) -> Self {
387 self.refraction_depth = fraction.clamp(0.0, 2.0);
388 self
389 }
390
391 pub fn refraction_curve(mut self, curve: f32) -> Self {
392 self.refraction_curve = curve.clamp(0.05, 1.0);
393 self
394 }
395
396 pub fn dispersion(mut self, strength: f32) -> Self {
397 self.dispersion = strength.clamp(0.0, 1.0);
398 self
399 }
400
401 pub fn transmission_refraction(mut self, strength: f32) -> Self {
402 self.transmission_refraction = strength.clamp(0.0, 1.0);
403 self
404 }
405
406 pub fn meniscus_absorption(mut self, strength: f32) -> Self {
407 self.meniscus_absorption = strength.clamp(0.0, 1.0);
408 self
409 }
410
411 pub fn fold_depth(mut self, depth_dp: f32) -> Self {
413 self.fold_depth = depth_dp.max(0.0);
414 self
415 }
416
417 pub fn optical_zoom(mut self, zoom: f32) -> Self {
419 self.optical_zoom = zoom.max(1.0);
420 self
421 }
422
423 pub fn rim_reflection(mut self, reflectivity: f32) -> Self {
425 self.rim_reflection = reflectivity.clamp(0.0, 2.0);
426 self
427 }
428
429 pub fn highlight(mut self, highlight: f32) -> Self {
430 self.highlight = highlight;
431 self
432 }
433
434 pub fn lift(mut self, lift: f32) -> Self {
437 self.lift = Some(lift);
438 self
439 }
440
441 pub fn adaptive_frost(mut self, foreground: Color, strength: f32) -> Self {
442 self.foreground = Some(foreground);
443 self.adaptive_frost = strength.clamp(0.0, 1.0);
444 self
445 }
446
447 pub fn shadow(mut self, shadow: bool) -> Self {
448 self.shadow = shadow;
449 self
450 }
451
452 pub fn shadow_style(mut self, shadow: GlassShadow) -> Self {
453 self.shadow_style = Some(shadow);
454 self
455 }
456
457 pub fn no_clip(mut self) -> Self {
460 self.clip = false;
461 self
462 }
463
464 fn default_blur_radius(&self) -> f32 {
465 match self.variant {
466 GlassVariant::Regular => 8.0,
467 GlassVariant::Clear => 3.0,
468 GlassVariant::Lens => 0.0,
469 }
470 }
471
472 fn default_saturation(&self) -> f32 {
473 match self.variant {
474 GlassVariant::Regular => 1.5,
475 GlassVariant::Clear => 1.25,
476 GlassVariant::Lens => 1.0,
477 }
478 }
479
480 pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
482 let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
489 (GlassVariant::Regular, false) => 0.42,
490 (GlassVariant::Regular, true) => -0.38,
491 (GlassVariant::Clear, false) => 0.12,
492 (GlassVariant::Clear, true) => -0.12,
493 (GlassVariant::Lens, false) => 0.10,
494 (GlassVariant::Lens, true) => -0.08,
495 });
496 let foreground = self.foreground.unwrap_or(colors.label);
497 let shadow = self.shadow_style.unwrap_or_else(|| {
498 GlassShadow::new(
499 Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
500 (GlassVariant::Lens, false) => 0.14,
501 (GlassVariant::Lens, true) => 0.28,
502 (_, false) => 0.16,
503 (_, true) => 0.5,
504 }),
505 if self.variant == GlassVariant::Lens {
506 10.0
507 } else {
508 22.0
509 },
510 if self.variant == GlassVariant::Lens {
511 3.0
512 } else {
513 8.0
514 },
515 if self.variant == GlassVariant::Lens {
516 -6.0
517 } else {
518 -2.0
519 },
520 )
521 });
522 ResolvedGlass {
523 shape: self.shape,
524 tint: self.tint.unwrap_or(colors.glass_tint),
525 blur_radius_dp: self
526 .blur_radius
527 .unwrap_or_else(|| self.default_blur_radius()),
528 saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
529 refraction_depth: self.refraction_depth,
530 refraction_curve: self.refraction_curve,
531 dispersion: self.dispersion,
532 transmission_refraction: self.transmission_refraction,
533 meniscus_absorption: self.meniscus_absorption,
534 fold_depth: self.fold_depth,
535 optical_zoom: self.optical_zoom,
536 rim_reflection: self.rim_reflection,
537 highlight: self.highlight,
538 lift,
539 contrast: if self.variant == GlassVariant::Lens {
540 1.0
541 } else {
542 1.03
543 },
544 shadow: self.shadow,
545 clip: self.clip,
546 foreground_luma: 0.2126 * foreground.r()
547 + 0.7152 * foreground.g()
548 + 0.0722 * foreground.b(),
549 adaptive_frost: self.adaptive_frost,
550 rim_style: if self.variant == GlassVariant::Lens {
551 1.0
552 } else {
553 0.0
554 },
555 shadow_color: shadow.color,
556 shadow_radius: shadow.radius,
557 shadow_offset_y: shadow.offset_y,
558 shadow_spread: shadow.spread,
559 }
560 }
561}
562
563impl Default for Glass {
564 fn default() -> Self {
565 Self::regular()
566 }
567}
568
569#[derive(Clone, Debug, PartialEq)]
572pub(crate) struct ResolvedGlass {
573 pub shape: LiquidShape,
574 pub tint: Color,
575 pub blur_radius_dp: f32,
576 pub saturation: f32,
577 pub refraction_depth: f32,
578 pub refraction_curve: f32,
579 pub dispersion: f32,
580 pub transmission_refraction: f32,
581 pub meniscus_absorption: f32,
582 pub fold_depth: f32,
583 pub optical_zoom: f32,
584 pub rim_reflection: f32,
585 pub highlight: f32,
586 pub lift: f32,
587 pub contrast: f32,
588 pub shadow: bool,
589 pub clip: bool,
590 pub rim_style: f32,
593 pub foreground_luma: f32,
594 pub adaptive_frost: f32,
595 pub shadow_color: Color,
596 pub shadow_radius: f32,
599 pub shadow_offset_y: f32,
600 pub shadow_spread: f32,
601}
602
603impl ResolvedGlass {
604 pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
608 self.runtime_effect(density, dynamics, false)
609 }
610
611 fn content_mask_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
612 self.runtime_effect(density, dynamics, true)
613 }
614
615 fn runtime_effect(
616 &self,
617 density: f32,
618 dynamics: GlassDynamics,
619 content_mask: bool,
620 ) -> RenderEffect {
621 let density = density.max(f32::EPSILON);
622 let activity = dynamics
623 .activity
624 .filter(|value| value.is_finite())
625 .unwrap_or(1.0)
626 .clamp(0.0, 1.0);
627 let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
628 if let Some(morph) = dynamics.morph.as_ref() {
629 let (node_w, node_h) = morph.node_size;
635 let (cx, cy, w, h, radius) = morph.primary;
636 shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
637 shader.set_float2(2, cx, cy);
638 shader.set_float2(4, w, h);
639 shader.set_float(6, radius);
640 let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
641 shader.set_float(30, count as f32);
642 for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
643 let base = 36 + index * 5;
644 shader.set_float(base, *sx);
645 shader.set_float(base + 1, *sy);
646 shader.set_float(base + 2, *sw);
647 shader.set_float(base + 3, *sh);
648 shader.set_float(base + 4, *sr);
649 }
650 shader.set_float(31, morph.glue);
651 shader.set_float(32, morph.wobble_amplitude * activity);
652 shader.set_float(33, morph.wobble_phase);
653 shader.set_float(26, morph.bulge_amplitude * activity);
654 shader.set_float(27, morph.bulge_direction);
655 shader.set_float(110, morph.ellipse_blend.clamp(0.0, 1.0) * activity);
656 if let Some(deformation) = morph.deformation {
657 let axis = deformation.axis();
658 let along = 1.0 + (deformation.along() - 1.0) * activity;
659 shader.set_float2(106, axis.0, axis.1);
660 shader.set_float(108, along);
661 shader.set_float(109, 1.0 / along);
662 } else {
663 shader.set_float2(106, 1.0, 0.0);
664 shader.set_float2(108, 1.0, 1.0);
665 }
666 } else {
668 shader.set_float2(0, 0.0, 0.0);
671 shader.set_float(6, self.shape.shader_radius_px(density));
672 }
673 shader.set_float(9, self.refraction_depth * activity);
674 shader.set_float(
675 GLASS_REFRACTION_CURVE_UNIFORM,
676 self.refraction_curve * activity,
677 );
678 shader.set_float(GLASS_DISPERSION_UNIFORM, self.dispersion * activity);
679 shader.set_float(
680 GLASS_TRANSMISSION_REFRACTION_UNIFORM,
681 self.transmission_refraction * activity,
682 );
683 shader.set_float(GLASS_MENISCUS_ABSORPTION_UNIFORM, self.meniscus_absorption);
684 shader.set_float(GLASS_FOLD_DEPTH_UNIFORM, self.fold_depth.max(0.0));
688 shader.set_float(
689 GLASS_OPTICAL_ZOOM_UNIFORM,
690 1.0 + (self.optical_zoom - 1.0).max(0.0) * activity,
691 );
692 shader.set_float(121, self.rim_reflection.max(0.001));
693 let (touch_x, touch_y, touch_intensity) = dynamics.touch.unwrap_or((0.0, 0.0, 0.0));
694 shader.set_float(118, touch_x);
695 shader.set_float(119, touch_y);
696 shader.set_float(120, touch_intensity.clamp(0.0, 1.0));
697 shader.set_float(GLASS_EFFECT_DENSITY_UNIFORM, density);
698 shader.set_float(
699 11,
700 (self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0) * activity,
701 );
702 let dynamic_tint = boost_tint_saturation(self.tint, dynamics.saturation_boost);
703 let dynamic_tint_alpha = (dynamic_tint.a()
704 * dynamics
705 .tint_alpha_multiplier
706 .unwrap_or(1.0)
707 .clamp(0.0, 2.0)
708 * activity)
709 .clamp(0.0, 1.0);
710 shader.set_float4(
711 14,
712 dynamic_tint.r(),
713 dynamic_tint.g(),
714 dynamic_tint.b(),
715 dynamic_tint_alpha,
716 );
717 let saturation = (self.saturation + dynamics.saturation_boost).max(0.0);
718 shader.set_float(18, 1.0 + (saturation - 1.0) * activity);
719 shader.set_float(20, self.lift * activity);
720 shader.set_float(21, 0.5 * activity);
721 shader.set_float2(22, 0.0, 1.0);
722 shader.set_float(24, 1.0 + (self.contrast - 1.0) * activity);
723 shader.set_float(28, self.rim_style * activity);
724 let requested_blur_radius_px = if content_mask {
725 0.0
726 } else {
727 self.blur_radius_dp * density * activity
728 };
729 let wcksrd_blur_radius = requested_blur_radius_px.min(WCKSRD_OPTICAL_BLUR_RADIUS_PX);
730 let gaussian_blur_radius = (requested_blur_radius_px - wcksrd_blur_radius).max(0.0);
731 shader.set_float(GLASS_BLUR_RADIUS_UNIFORM, wcksrd_blur_radius);
732 shader.set_float(GLASS_ACTIVITY_UNIFORM, activity);
733 let resting_tint = dynamics.resting_tint.unwrap_or(Color::TRANSPARENT);
734 shader.set_float4(
735 GLASS_RESTING_TINT_UNIFORM,
736 resting_tint.r(),
737 resting_tint.g(),
738 resting_tint.b(),
739 resting_tint.a(),
740 );
741 shader.set_float(112, if content_mask { 1.0 } else { 0.0 });
742 shader.set_float(91, self.adaptive_frost * activity);
743 shader.set_float(97, self.foreground_luma);
744 let dynamic_shadow = !self.clip && self.shadow;
745 shader.set_float(
746 102,
747 if dynamic_shadow {
748 self.shadow_color.a() * 0.55 * activity
749 } else {
750 0.0
751 },
752 );
753 shader.set_float(103, self.shadow_radius);
754 shader.set_float(104, self.shadow_offset_y);
755 shader.set_float(105, self.shadow_spread);
756 let morph_pad = dynamics
760 .morph
761 .as_ref()
762 .map(|morph| {
763 let (px, py, pw, ph, _) = morph.primary;
764 let (left, top) = (px - pw * 0.5, py - ph * 0.5);
765 let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
766 let mut shape_reach = 0.0f32;
767 for (sx, sy, sw, sh, _) in &morph.shapes {
768 let reach_x = ((sx + sw * 0.5) - right)
769 .max(left - (sx - sw * 0.5))
770 .max(0.0);
771 let reach_y = ((sy + sh * 0.5) - bottom)
772 .max(top - (sy - sh * 0.5))
773 .max(0.0);
774 shape_reach = shape_reach.max(reach_x.max(reach_y));
775 }
776 let glue_pad = if morph.shapes.is_empty() {
777 0.0
778 } else {
779 morph.glue * 2.0
780 };
781 morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
782 })
783 .unwrap_or(0.0);
784 shader.set_input_padding(self.input_padding() + morph_pad + wcksrd_blur_radius / density);
787 if dynamics.morph.is_some() {
791 let shadow_reach = if dynamic_shadow {
792 self.shadow_radius + self.shadow_offset_y.abs() + self.shadow_spread.max(0.0)
793 } else {
794 0.0
795 };
796 shader.set_output_padding(morph_pad + shadow_reach + 4.0);
797 }
798
799 let optical_effect = RenderEffect::runtime_shader(shader);
800 if gaussian_blur_radius > f32::EPSILON {
801 RenderEffect::blur(gaussian_blur_radius).then(optical_effect)
802 } else {
803 optical_effect
804 }
805 }
806
807 fn input_padding(&self) -> f32 {
811 2.0
816 }
817}
818
819pub trait LiquidModifierExt {
821 fn glass_effect(self, glass: Glass) -> Modifier;
827
828 fn glass_effect_with(
832 self,
833 glass: Glass,
834 dynamics: impl Fn() -> GlassDynamics + 'static,
835 ) -> Modifier;
836}
837
838impl LiquidModifierExt for Modifier {
839 fn glass_effect(self, glass: Glass) -> Modifier {
840 self.glass_effect_with(glass, GlassDynamics::default)
841 }
842
843 fn glass_effect_with(
844 self,
845 glass: Glass,
846 dynamics: impl Fn() -> GlassDynamics + 'static,
847 ) -> Modifier {
848 let colors = crate::theme::liquid_colors();
849 let resolved = Rc::new(glass.resolve(&colors));
850 let shape = resolved.shape;
851
852 let mut modifier = self;
853 if resolved.shadow && resolved.clip {
854 let shadow_color = resolved.shadow_color;
855 let (radius, offset_y, spread) = (
856 resolved.shadow_radius,
857 resolved.shadow_offset_y,
858 resolved.shadow_spread,
859 );
860 modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
861 scope.radius = radius;
862 scope.spread = spread;
863 scope.offset.y = offset_y;
864 scope.color = shadow_color;
865 scope.cutout = true;
868 });
869 }
870
871 let layer_resolved = Rc::clone(&resolved);
872 let clip = resolved.clip;
873 modifier.graphics_layer(move || {
874 let density = current_density();
875 let frame = dynamics();
876 let render_effect = (!clip && frame.morph.is_some())
877 .then(|| layer_resolved.content_mask_effect(density, frame.clone()));
878 GraphicsLayer {
879 backdrop_effect: Some(layer_resolved.backdrop_effect(density, frame)),
880 render_effect,
881 shape: shape.layer_shape(),
882 clip,
883 ..Default::default()
884 }
885 })
886 }
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892
893 fn light_colors() -> LiquidColors {
894 LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
895 }
896
897 fn terminal_shader(effect: RenderEffect) -> RuntimeShader {
898 match effect {
899 RenderEffect::Shader { shader } => shader,
900 RenderEffect::Chain { second, .. } => terminal_shader(*second),
901 effect => panic!("expected runtime shader, got {effect:?}"),
902 }
903 }
904
905 #[test]
906 fn liquid_shape_builds_matching_clip_and_layer_shapes() {
907 for shape in [
908 LiquidShape::Capsule,
909 LiquidShape::Circle,
910 LiquidShape::RoundedRect(12.0),
911 ] {
912 assert_eq!(shape.layer_shape(), LayerShape::Rounded(shape.clip_shape()));
913 }
914 }
915
916 #[test]
917 fn glass_shadow_clamps_negative_radius() {
918 let shadow = GlassShadow::new(Color::BLACK, -2.0, 3.0, -1.0);
919 assert_eq!(shadow.radius, 0.0);
920 assert_eq!(shadow.offset_y, 3.0);
921 assert_eq!(shadow.spread, -1.0);
922 }
923
924 #[test]
925 fn incompressible_deformation_normalizes_axis_and_conserves_area() {
926 let deformation = GlassDeformation::incompressible((3.0, 4.0), 1.25);
927 assert_eq!(deformation.axis(), (0.6, 0.8));
928 assert_eq!(deformation.along(), 1.25);
929 assert!((deformation.along() * deformation.across() - 1.0).abs() < 1.0e-6);
930 assert_eq!(
931 GlassDeformation::incompressible((0.0, 0.0), 0.0).axis(),
932 (1.0, 0.0)
933 );
934 }
935
936 #[test]
937 fn glass_builders_clamp_physical_inputs() {
938 let glass = Glass::lens()
939 .shape(LiquidShape::Circle)
940 .tint(Color::BLACK)
941 .blur_radius(-2.0)
942 .saturation(1.2)
943 .refraction_depth(3.0)
944 .refraction_curve(2.0)
945 .dispersion(2.0)
946 .transmission_refraction(2.0)
947 .meniscus_absorption(2.0)
948 .highlight(0.4)
949 .lift(-0.2)
950 .adaptive_frost(Color::WHITE, 2.0)
951 .shadow(false)
952 .no_clip();
953 assert_eq!(glass.shape, LiquidShape::Circle);
954 assert_eq!(glass.tint, Some(Color::BLACK));
955 assert_eq!(glass.blur_radius, Some(-2.0));
956 assert_eq!(glass.saturation, Some(1.2));
957 assert_eq!(glass.refraction_depth, 2.0);
958 assert_eq!(glass.refraction_curve, 1.0);
959 assert_eq!(glass.dispersion, 1.0);
960 assert_eq!(glass.transmission_refraction, 1.0);
961 assert_eq!(glass.meniscus_absorption, 1.0);
962 assert_eq!(glass.highlight, 0.4);
963 assert_eq!(glass.lift, Some(-0.2));
964 assert_eq!(glass.adaptive_frost, 1.0);
965 assert!(!glass.shadow);
966 assert!(!glass.clip);
967 }
968
969 #[test]
970 fn material_variants_resolve_distinct_frost_levels() {
971 let regular = Glass::regular().resolve(&light_colors());
972 let clear = Glass::clear().resolve(&light_colors());
973 let lens = Glass::lens().resolve(&light_colors());
974 assert!(regular.blur_radius_dp > clear.blur_radius_dp);
975 assert!(clear.blur_radius_dp > lens.blur_radius_dp);
976 assert!(regular.saturation > clear.saturation);
977 assert_eq!(lens.rim_style, 1.0);
978 assert_eq!(regular.refraction_curve, 0.25);
979 assert_eq!(lens.refraction_curve, 1.0);
980 assert_eq!(regular.dispersion, 0.0);
981 assert_eq!(lens.dispersion, 0.30);
982 }
983
984 #[test]
985 fn neutral_surface_helpers_follow_foreground_polarity() {
986 assert_eq!(
987 neutral_surface_tint(Color::BLACK, 0.08, 0.10),
988 Color::BLACK.with_alpha(0.08)
989 );
990 assert_eq!(
991 neutral_surface_tint(Color::WHITE, 0.08, 0.10),
992 Color::WHITE.with_alpha(0.10)
993 );
994 assert_eq!(neutral_surface_lift(Color::BLACK, 0.7, -0.3), 0.7);
995 assert_eq!(neutral_surface_lift(Color::WHITE, 0.7, -0.3), -0.3);
996 }
997
998 #[test]
999 fn dynamic_saturation_reaches_the_material_tint() {
1000 let resting = Color::from_rgb_u8(0, 199, 208);
1001 let raised = boost_tint_saturation(resting, 0.55);
1002 assert_eq!(raised.r(), 0.0);
1003 assert!(raised.g() > resting.g());
1004 assert!(raised.b() > resting.b());
1005 assert_eq!(raised.a(), resting.a());
1006 }
1007
1008 #[test]
1009 fn resolved_material_packs_wcksrd_and_dynamic_tint() {
1010 let resolved = Glass::lens()
1011 .refraction_depth(0.72)
1012 .refraction_curve(0.8)
1013 .dispersion(0.42)
1014 .transmission_refraction(0.35)
1015 .meniscus_absorption(0.3)
1016 .blur_radius(3.0)
1017 .tint(Color::BLACK.with_alpha(0.8))
1018 .resolve(&light_colors());
1019 let effect = resolved.backdrop_effect(
1020 2.0,
1021 GlassDynamics {
1022 highlight_boost: 0.2,
1023 saturation_boost: 0.35,
1024 tint_alpha_multiplier: Some(0.25),
1025 ..Default::default()
1026 },
1027 );
1028 let RenderEffect::Chain { first, .. } = &effect else {
1029 panic!("macroscopic frost must precede the wcKSRD optical pass");
1030 };
1031 assert!(matches!(
1032 first.as_ref(),
1033 RenderEffect::Blur {
1034 radius_x: 4.0,
1035 radius_y: 4.0,
1036 ..
1037 }
1038 ));
1039 let shader = terminal_shader(effect);
1040 assert_eq!(shader.uniforms()[9], 0.72);
1041 assert_eq!(shader.uniforms()[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1042 assert_eq!(shader.uniforms()[GLASS_DISPERSION_UNIFORM], 0.42);
1043 assert_eq!(
1044 shader.uniforms()[GLASS_TRANSMISSION_REFRACTION_UNIFORM],
1045 0.35
1046 );
1047 assert_eq!(shader.uniforms()[GLASS_MENISCUS_ABSORPTION_UNIFORM], 0.3);
1048 assert_eq!(shader.uniforms()[11], resolved.highlight + 0.2);
1049 assert_eq!(shader.uniforms()[18], resolved.saturation + 0.35);
1050 assert!((shader.uniforms()[17] - 0.2).abs() < 1.0e-6);
1051 assert_eq!(
1052 shader.uniforms()[GLASS_BLUR_RADIUS_UNIFORM],
1053 WCKSRD_OPTICAL_BLUR_RADIUS_PX
1054 );
1055 assert_eq!(shader.uniforms()[GLASS_EFFECT_DENSITY_UNIFORM], 2.0);
1056 }
1057
1058 #[test]
1059 fn raised_tint_density_stays_premultiplied_alpha_safe() {
1060 let effect = Glass::lens()
1061 .tint(Color::WHITE.with_alpha(0.8))
1062 .resolve(&light_colors())
1063 .backdrop_effect(
1064 1.0,
1065 GlassDynamics {
1066 tint_alpha_multiplier: Some(2.0),
1067 ..Default::default()
1068 },
1069 );
1070 assert_eq!(terminal_shader(effect).uniforms()[17], 1.0);
1071 }
1072
1073 #[test]
1074 fn optical_activity_reaches_identity_without_removing_the_glass_geometry() {
1075 let resolved = Glass::lens()
1076 .refraction_depth(0.72)
1077 .refraction_curve(0.8)
1078 .dispersion(0.42)
1079 .blur_radius(3.0)
1080 .saturation(1.4)
1081 .highlight(0.6)
1082 .lift(0.2)
1083 .tint(Color::BLACK.with_alpha(0.8))
1084 .no_clip()
1085 .resolve(&light_colors());
1086 let morph = GlassMorph {
1087 node_size: (120.0, 72.0),
1088 primary: (60.0, 36.0, 96.0, 52.0, -1.0),
1089 wobble_amplitude: 3.0,
1090 bulge_amplitude: 4.0,
1091 deformation: Some(GlassDeformation::incompressible((1.0, 0.0), 1.25)),
1092 ..Default::default()
1093 };
1094
1095 let RenderEffect::Shader { shader } = resolved.backdrop_effect(
1096 2.0,
1097 GlassDynamics {
1098 activity: Some(0.0),
1099 morph: Some(morph),
1100 ..Default::default()
1101 },
1102 ) else {
1103 panic!("material must resolve to the shared wcKSRD shader");
1104 };
1105 let uniforms = shader.uniforms();
1106 assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 0.0);
1107 assert_eq!(uniforms[9], 0.0);
1108 assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.0);
1109 assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.0);
1110 assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 0.0);
1111 assert_eq!(uniforms[11], 0.0);
1112 assert_eq!(uniforms[17], 0.0);
1113 assert_eq!(uniforms[18], 1.0);
1114 assert_eq!(uniforms[20], 0.0);
1115 assert_eq!(uniforms[21], 0.0);
1116 assert_eq!(uniforms[24], 1.0);
1117 assert_eq!(uniforms[28], 0.0);
1118 assert_eq!(uniforms[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1119 assert_eq!(uniforms[91], 0.0);
1120 assert_eq!(uniforms[102], 0.0);
1121 assert_eq!(
1122 &uniforms[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1123 &[0.0, 0.0, 0.0, 0.0]
1124 );
1125 assert_eq!(uniforms[32], 0.0);
1126 assert_eq!(uniforms[26], 0.0);
1127 assert_eq!(&uniforms[108..110], &[1.0, 1.0]);
1128 assert_eq!(&uniforms[2..6], &[60.0, 36.0, 96.0, 52.0]);
1129 }
1130
1131 #[test]
1132 fn resting_surface_tint_survives_zero_optical_activity() {
1133 let tint = Color::BLACK.with_alpha(0.11);
1134 let RenderEffect::Shader { shader } = Glass::lens()
1135 .no_clip()
1136 .resolve(&light_colors())
1137 .backdrop_effect(
1138 1.0,
1139 GlassDynamics {
1140 activity: Some(0.0),
1141 resting_tint: Some(tint),
1142 ..Default::default()
1143 },
1144 )
1145 else {
1146 panic!("resting surface must use the shared wcKSRD shader");
1147 };
1148 assert_eq!(
1149 &shader.uniforms()[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1150 &[tint.r(), tint.g(), tint.b(), tint.a()]
1151 );
1152 assert_eq!(shader.uniforms()[GLASS_ACTIVITY_UNIFORM], 0.0);
1153 }
1154
1155 #[test]
1156 fn full_optical_activity_preserves_the_resolved_material() {
1157 let resolved = Glass::lens()
1158 .refraction_depth(0.72)
1159 .refraction_curve(0.8)
1160 .dispersion(0.42)
1161 .blur_radius(3.0)
1162 .resolve(&light_colors());
1163 let shader = terminal_shader(resolved.backdrop_effect(
1164 2.0,
1165 GlassDynamics {
1166 activity: Some(1.0),
1167 ..Default::default()
1168 },
1169 ));
1170 let uniforms = shader.uniforms();
1171 assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 1.0);
1172 assert_eq!(uniforms[9], 0.72);
1173 assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1174 assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.42);
1175 assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 1.0);
1176 assert_eq!(
1177 uniforms[GLASS_BLUR_RADIUS_UNIFORM],
1178 WCKSRD_OPTICAL_BLUR_RADIUS_PX
1179 );
1180 }
1181
1182 #[test]
1183 fn morph_geometry_and_incompressible_strain_are_packed() {
1184 let deformation = GlassDeformation::incompressible((0.0, 2.0), 1.25);
1185 let morph = GlassMorph {
1186 node_size: (78.0, 59.0),
1187 primary: (39.0, 29.5, 58.0, 39.0, -1.0),
1188 shapes: vec![(70.0, 29.5, 40.0, 40.0, -1.0)],
1189 glue: 8.0,
1190 wobble_amplitude: 1.0,
1191 wobble_phase: 0.5,
1192 bulge_amplitude: 2.0,
1193 bulge_direction: 0.25,
1194 ellipse_blend: 0.3,
1195 deformation: Some(deformation),
1196 };
1197 let RenderEffect::Shader { shader } = Glass::lens()
1198 .no_clip()
1199 .resolve(&light_colors())
1200 .backdrop_effect(
1201 1.0,
1202 GlassDynamics {
1203 morph: Some(morph),
1204 ..Default::default()
1205 },
1206 )
1207 else {
1208 panic!("morph must use the shared wcKSRD shader");
1209 };
1210 let uniforms = shader.uniforms();
1211 assert_eq!(&uniforms[0..6], &[78.0, 59.0, 39.0, 29.5, 58.0, 39.0]);
1212 assert_eq!(uniforms[30], 1.0);
1213 assert_eq!(&uniforms[106..110], &[0.0, 1.0, 1.25, 0.8]);
1214 assert_eq!(uniforms[110], 0.3);
1215 assert!(shader.output_padding() > 0.0);
1216 }
1217}