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 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, Debug, PartialEq, Default)]
78pub struct GlassDynamics {
79 pub tilt: (f32, f32),
81 pub highlight_boost: f32,
83 pub magnify_boost: f32,
86 pub morph: Option<GlassMorph>,
89}
90
91#[derive(Clone, Debug, PartialEq, Default)]
102pub struct GlassMorph {
103 pub node_size: (f32, f32),
109 pub primary: (f32, f32, f32, f32, f32),
110 pub shapes: Vec<(f32, f32, f32, f32, f32)>,
112 pub glue: f32,
114 pub wobble_amplitude: f32,
116 pub wobble_phase: f32,
117 pub bulge_amplitude: f32,
121 pub bulge_direction: f32,
122}
123
124impl GlassMorph {
125 pub const MAX_SHAPES: usize = 8;
127}
128
129#[derive(Clone, Debug, PartialEq)]
132pub struct Glass {
133 pub variant: GlassVariant,
134 pub shape: LiquidShape,
135 pub tint: Option<Color>,
137 pub blur_radius: Option<f32>,
139 pub saturation: Option<f32>,
141 pub chromatic_aberration: f32,
143 pub displacement: f32,
145 pub bezel_width: f32,
147 pub highlight: f32,
149 pub lift: Option<f32>,
152 pub shadow: bool,
154 pub clip: bool,
157}
158
159impl Glass {
160 pub fn regular() -> Self {
161 Self {
162 variant: GlassVariant::Regular,
163 shape: LiquidShape::Capsule,
164 tint: None,
165 blur_radius: None,
166 saturation: None,
167 chromatic_aberration: 0.5,
168 displacement: 22.0,
169 bezel_width: 12.0,
170 highlight: 0.9,
171 lift: None,
172 shadow: true,
173 clip: true,
174 }
175 }
176
177 pub fn clear() -> Self {
178 Self {
179 variant: GlassVariant::Clear,
180 ..Self::regular()
181 }
182 }
183
184 pub fn lens() -> Self {
188 Self {
189 variant: GlassVariant::Lens,
190 shape: LiquidShape::Capsule,
191 tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.0)),
192 blur_radius: None,
193 saturation: None,
194 chromatic_aberration: 3.2,
195 displacement: 30.0,
196 bezel_width: 16.0,
201 highlight: 1.15,
202 lift: None,
203 shadow: true,
204 clip: true,
205 }
206 }
207
208 pub fn shape(mut self, shape: LiquidShape) -> Self {
209 self.shape = shape;
210 self
211 }
212
213 pub fn tint(mut self, tint: Color) -> Self {
214 self.tint = Some(tint);
215 self
216 }
217
218 pub fn blur_radius(mut self, radius_dp: f32) -> Self {
219 self.blur_radius = Some(radius_dp);
220 self
221 }
222
223 pub fn saturation(mut self, saturation: f32) -> Self {
224 self.saturation = Some(saturation);
225 self
226 }
227
228 pub fn chromatic_aberration(mut self, spread: f32) -> Self {
229 self.chromatic_aberration = spread;
230 self
231 }
232
233 pub fn displacement(mut self, displacement_px: f32) -> Self {
234 self.displacement = displacement_px;
235 self
236 }
237
238 pub fn highlight(mut self, highlight: f32) -> Self {
239 self.highlight = highlight;
240 self
241 }
242
243 pub fn lift(mut self, lift: f32) -> Self {
246 self.lift = Some(lift);
247 self
248 }
249
250 pub fn shadow(mut self, shadow: bool) -> Self {
251 self.shadow = shadow;
252 self
253 }
254
255 pub fn no_clip(mut self) -> Self {
258 self.clip = false;
259 self
260 }
261
262 fn default_blur_radius(&self) -> f32 {
263 match self.variant {
264 GlassVariant::Regular => 18.0,
265 GlassVariant::Clear => 3.0,
266 GlassVariant::Lens => 0.0,
267 }
268 }
269
270 fn default_saturation(&self) -> f32 {
271 match self.variant {
272 GlassVariant::Regular => 1.5,
273 GlassVariant::Clear => 1.25,
274 GlassVariant::Lens => 1.0,
275 }
276 }
277
278 pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
280 let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
287 (GlassVariant::Regular, false) => 0.42,
288 (GlassVariant::Regular, true) => -0.38,
289 (GlassVariant::Clear, false) => 0.12,
290 (GlassVariant::Clear, true) => -0.12,
291 (GlassVariant::Lens, false) => 0.10,
292 (GlassVariant::Lens, true) => -0.08,
293 });
294 ResolvedGlass {
295 shape: self.shape,
296 tint: self.tint.unwrap_or(colors.glass_tint),
297 blur_radius_dp: self
298 .blur_radius
299 .unwrap_or_else(|| self.default_blur_radius()),
300 saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
301 chromatic_aberration: self.chromatic_aberration,
302 displacement: self.displacement,
303 bezel_width_dp: self.bezel_width,
304 highlight: self.highlight,
305 lift,
306 contrast: if self.variant == GlassVariant::Lens {
307 1.0
308 } else {
309 1.03
310 },
311 edge_band: if self.variant == GlassVariant::Lens {
312 0.35
313 } else {
314 0.4
315 },
316 shadow: self.shadow,
317 clip: self.clip,
318 dome_direction: if self.variant == GlassVariant::Lens {
319 -1.0
320 } else {
321 1.0
322 },
323 magnify: if self.variant == GlassVariant::Lens {
324 1.35
325 } else {
326 1.0
327 },
328 sheen: if self.variant == GlassVariant::Lens {
329 0.05
330 } else {
331 1.0
332 },
333 rim_style: if self.variant == GlassVariant::Lens {
334 1.0
335 } else {
336 0.0
337 },
338 shadow_color: Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
339 (GlassVariant::Lens, false) => 0.14,
340 (GlassVariant::Lens, true) => 0.28,
341 (_, false) => 0.16,
342 (_, true) => 0.5,
343 }),
344 shadow_radius: if self.variant == GlassVariant::Lens {
348 10.0
349 } else {
350 22.0
351 },
352 shadow_offset_y: if self.variant == GlassVariant::Lens {
353 3.0
354 } else {
355 8.0
356 },
357 shadow_spread: if self.variant == GlassVariant::Lens {
361 -6.0
362 } else {
363 -2.0
364 },
365 }
366 }
367}
368
369impl Default for Glass {
370 fn default() -> Self {
371 Self::regular()
372 }
373}
374
375#[derive(Clone, Debug, PartialEq)]
378pub(crate) struct ResolvedGlass {
379 pub shape: LiquidShape,
380 pub tint: Color,
381 pub blur_radius_dp: f32,
382 pub saturation: f32,
383 pub chromatic_aberration: f32,
384 pub displacement: f32,
385 pub bezel_width_dp: f32,
386 pub highlight: f32,
387 pub lift: f32,
388 pub contrast: f32,
389 pub edge_band: f32,
390 pub shadow: bool,
391 pub clip: bool,
392 pub dome_direction: f32,
394 pub magnify: f32,
396 pub sheen: f32,
399 pub rim_style: f32,
402 pub shadow_color: Color,
403 pub shadow_radius: f32,
406 pub shadow_offset_y: f32,
407 pub shadow_spread: f32,
408}
409
410impl ResolvedGlass {
411 pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
415 let density = density.max(f32::EPSILON);
416 let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
417 if let Some(morph) = dynamics.morph.as_ref() {
418 let (node_w, node_h) = morph.node_size;
424 let (cx, cy, w, h, radius) = morph.primary;
425 shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
426 shader.set_float2(2, cx, cy);
427 shader.set_float2(4, w, h);
428 shader.set_float(6, radius);
429 let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
430 shader.set_float(30, count as f32);
431 for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
432 let base = 36 + index * 5;
433 shader.set_float(base, *sx);
434 shader.set_float(base + 1, *sy);
435 shader.set_float(base + 2, *sw);
436 shader.set_float(base + 3, *sh);
437 shader.set_float(base + 4, *sr);
438 }
439 shader.set_float(31, morph.glue);
440 shader.set_float(32, morph.wobble_amplitude);
441 shader.set_float(33, morph.wobble_phase);
442 shader.set_float(26, morph.bulge_amplitude);
443 shader.set_float(27, morph.bulge_direction);
444 shader.set_float(7, self.bezel_width_dp);
446 } else {
447 shader.set_float2(0, 0.0, 0.0);
450 shader.set_float(6, self.shape.shader_radius_px(density));
451 shader.set_float(7, self.bezel_width_dp * density);
452 }
453 shader.set_float(8, self.displacement);
454 shader.set_float(9, 1.5);
455 shader.set_float(10, 0.6);
456 shader.set_float(
457 11,
458 (self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0),
459 );
460 shader.set_float2(12, dynamics.tilt.0, dynamics.tilt.1);
461 shader.set_float4(
462 14,
463 self.tint.r(),
464 self.tint.g(),
465 self.tint.b(),
466 self.tint.a(),
467 );
468 shader.set_float(18, self.saturation);
469 shader.set_float(19, self.chromatic_aberration);
470 shader.set_float(20, self.lift);
471 shader.set_float(21, 0.5);
472 shader.set_float2(22, 0.0, 1.0);
473 shader.set_float(24, self.contrast);
474 shader.set_float(25, self.edge_band);
475 shader.set_float(29, self.sheen);
476 shader.set_float(28, self.rim_style);
477 shader.set_float(34, self.dome_direction);
478 shader.set_float(35, self.magnify + dynamics.magnify_boost.max(0.0));
479 let morph_pad = dynamics
483 .morph
484 .as_ref()
485 .map(|morph| {
486 let (px, py, pw, ph, _) = morph.primary;
487 let (left, top) = (px - pw * 0.5, py - ph * 0.5);
488 let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
489 let mut shape_reach = 0.0f32;
490 for (sx, sy, sw, sh, _) in &morph.shapes {
491 let reach_x = ((sx + sw * 0.5) - right)
492 .max(left - (sx - sw * 0.5))
493 .max(0.0);
494 let reach_y = ((sy + sh * 0.5) - bottom)
495 .max(top - (sy - sh * 0.5))
496 .max(0.0);
497 shape_reach = shape_reach.max(reach_x.max(reach_y));
498 }
499 let glue_pad = if morph.shapes.is_empty() {
500 0.0
501 } else {
502 morph.glue * 2.0
503 };
504 morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
505 })
506 .unwrap_or(0.0);
507 shader.set_input_padding(self.input_padding() + morph_pad);
510 if dynamics.morph.is_some() {
514 shader.set_output_padding(morph_pad + 4.0);
515 }
516
517 let lens = RenderEffect::runtime_shader(shader);
518 let blur_px = self.blur_radius_dp * density;
519 if blur_px > 0.5 {
520 RenderEffect::blur(blur_px).then(lens)
521 } else {
522 lens
523 }
524 }
525
526 fn input_padding(&self) -> f32 {
530 let bend = 1.0 - 1.0 / 1.5_f32;
531 let reach = 1.0 + std::f32::consts::SQRT_2;
532 let slope = 4.0 + 0.35 * (2.0 + 2.0 * 0.6);
534 let spread = 1.0 + self.chromatic_aberration.max(0.0) * 0.5;
535 let displacement = reach * bend * self.displacement.max(0.0) * slope * spread;
536 displacement.ceil() + 2.0
537 }
538}
539
540pub trait LiquidModifierExt {
542 fn glass_effect(self, glass: Glass) -> Modifier;
548
549 fn glass_effect_with(
553 self,
554 glass: Glass,
555 dynamics: impl Fn() -> GlassDynamics + 'static,
556 ) -> Modifier;
557}
558
559impl LiquidModifierExt for Modifier {
560 fn glass_effect(self, glass: Glass) -> Modifier {
561 self.glass_effect_with(glass, GlassDynamics::default)
562 }
563
564 fn glass_effect_with(
565 self,
566 glass: Glass,
567 dynamics: impl Fn() -> GlassDynamics + 'static,
568 ) -> Modifier {
569 let colors = crate::theme::liquid_colors();
570 let resolved = Rc::new(glass.resolve(&colors));
571 let shape = resolved.shape;
572
573 let mut modifier = self;
574 if resolved.shadow {
575 let shadow_color = resolved.shadow_color;
576 let (radius, offset_y, spread) = (
577 resolved.shadow_radius,
578 resolved.shadow_offset_y,
579 resolved.shadow_spread,
580 );
581 modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
582 scope.radius = radius;
583 scope.spread = spread;
584 scope.offset.y = offset_y;
585 scope.color = shadow_color;
586 scope.cutout = true;
589 });
590 }
591
592 let layer_resolved = Rc::clone(&resolved);
593 let clip = resolved.clip;
594 modifier.graphics_layer(move || GraphicsLayer {
595 backdrop_effect: Some(layer_resolved.backdrop_effect(current_density(), dynamics())),
596 shape: shape.layer_shape(),
597 clip,
598 ..Default::default()
599 })
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 fn light_colors() -> LiquidColors {
608 LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
609 }
610
611 #[test]
612 fn regular_glass_resolves_frosted_defaults() {
613 let resolved = Glass::regular().resolve(&light_colors());
614 assert!(resolved.blur_radius_dp > 5.0, "regular material is frosted");
615 assert!(resolved.saturation > 1.3, "regular material is vibrant");
616 assert!(resolved.lift > 0.0, "light scheme lifts toward white");
617 }
618
619 #[test]
620 fn clear_glass_is_barely_frosted() {
621 let resolved = Glass::clear().resolve(&light_colors());
622 assert!(resolved.blur_radius_dp < 5.0);
623 assert!(resolved.saturation < 1.3);
624 }
625
626 #[test]
627 fn dark_scheme_sinks_lift_and_tint() {
628 let dark = LiquidColors::dark(Color::from_rgb_u8(0, 122, 255));
629 let resolved = Glass::regular().resolve(&dark);
630 assert!(resolved.lift < 0.0, "dark scheme sinks toward black");
631 assert_eq!(resolved.tint, dark.glass_tint);
632 }
633
634 #[test]
635 fn explicit_tint_overrides_theme() {
636 let tint = Color::from_rgba_u8(0, 122, 255, 60);
637 let resolved = Glass::regular().tint(tint).resolve(&light_colors());
638 assert_eq!(resolved.tint, tint);
639 }
640
641 #[test]
642 fn cover_mode_effect_chains_blur_then_lens() {
643 let resolved = Glass::regular().resolve(&light_colors());
644 let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
645 let RenderEffect::Chain { first, second } = effect else {
646 panic!("regular glass must chain blur → lens");
647 };
648 assert!(matches!(*first, RenderEffect::Blur { .. }));
649 let RenderEffect::Shader { shader } = *second else {
650 panic!("second stage must be the lens shader");
651 };
652 let uniforms = shader.uniforms();
653 assert_eq!(uniforms[0], 0.0, "cover mode zeroes the container size");
654 assert_eq!(uniforms[6], CAPSULE_SHADER_RADIUS, "capsule sentinel");
655 assert!(shader.input_padding() > 0.0);
656 }
657
658 #[test]
659 fn morph_glass_packs_dp_geometry_with_node_size_container() {
660 let resolved = Glass::lens().resolve(&light_colors());
667 let dynamics = GlassDynamics {
668 morph: Some(GlassMorph {
669 node_size: (78.0, 59.0),
670 primary: (39.0, 29.5, 58.0, 39.0, -1.0),
671 shapes: vec![(100.0, 29.5, 44.0, 44.0, -1.0)],
672 glue: 12.0,
673 ..Default::default()
674 }),
675 ..Default::default()
676 };
677 let effect = resolved.backdrop_effect(2.0, dynamics);
679 let RenderEffect::Shader { shader } = effect else {
680 panic!("lens glass must be a bare shader (no frost blur)");
681 };
682 let uniforms = shader.uniforms();
683 assert_eq!(&uniforms[0..2], &[78.0, 59.0], "container = node size dp");
684 assert_eq!(&uniforms[2..6], &[39.0, 29.5, 58.0, 39.0], "primary dp");
685 assert_eq!(uniforms[6], -1.0, "capsule sentinel unscaled");
686 assert_eq!(uniforms[31], 12.0, "glue dp");
687 assert_eq!(&uniforms[36..40], &[100.0, 29.5, 44.0, 44.0], "shape dp");
688 assert!(
689 shader.output_padding() > 0.0,
690 "morph glass declares output padding"
691 );
692 }
693
694 #[test]
695 fn clear_variant_skips_heavy_blur() {
696 let resolved = Glass::clear().blur_radius(0.0).resolve(&light_colors());
697 let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
698 assert!(matches!(effect, RenderEffect::Shader { .. }));
699 }
700
701 #[test]
702 fn rounded_rect_radius_scales_with_density() {
703 let resolved = Glass::regular()
704 .shape(LiquidShape::RoundedRect(16.0))
705 .resolve(&light_colors());
706 let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
707 let RenderEffect::Chain { second, .. } = effect else {
708 panic!("expected chain");
709 };
710 let RenderEffect::Shader { shader } = *second else {
711 panic!("expected lens");
712 };
713 assert_eq!(shader.uniforms()[6], 32.0, "16dp at 2x density = 32px");
714 }
715}