1use std::{cell::Cell, rc::Rc};
7
8use cranpose_ui::{Modifier, current_density};
9use cranpose_ui_graphics::{
10 Color, GLASS_ACTIVITY_UNIFORM, GLASS_BLUR_RADIUS_UNIFORM, GLASS_DISPERSION_UNIFORM,
11 GLASS_EFFECT_DENSITY_UNIFORM, GLASS_FOLD_DEPTH_UNIFORM, GLASS_LIGHT_DIRECTION_UNIFORM,
12 GLASS_MENISCUS_ABSORPTION_UNIFORM, GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM,
13 GLASS_OPTICAL_ZOOM_UNIFORM, GLASS_REFRACTION_CURVE_UNIFORM, GLASS_RESTING_TINT_UNIFORM,
14 GLASS_TRANSMISSION_REFRACTION_UNIFORM, GraphicsLayer, LIQUID_GLASS_WGSL, LayerShape,
15 RenderEffect, RoundedCornerShape, RuntimeShader, TileMode,
16};
17
18use crate::theme::LiquidColors;
19
20thread_local! {
30 static GLASS_LIGHT_RETURN: Cell<(f32, f32)> = const { Cell::new((0.0, 1.0)) };
31}
32
33pub fn set_glass_light_direction(direction: (f32, f32)) {
36 GLASS_LIGHT_RETURN.with(|cell| cell.set(direction));
37}
38
39pub fn glass_light_direction() -> (f32, f32) {
41 GLASS_LIGHT_RETURN.with(|cell| cell.get())
42}
43
44const CAPSULE_CLIP_RADIUS: f32 = 1.0e6;
47
48const CAPSULE_SHADER_RADIUS: f32 = -1.0;
51
52const WCKSRD_OPTICAL_BLUR_RADIUS_PX: f32 = 2.0;
56
57#[derive(Clone, Copy, Debug, PartialEq, Default)]
59pub enum LiquidShape {
60 #[default]
62 Capsule,
63 RoundedRect(f32),
65 Circle,
67}
68
69impl LiquidShape {
70 pub fn clip_shape(&self) -> RoundedCornerShape {
72 match self {
73 LiquidShape::Capsule | LiquidShape::Circle => {
74 RoundedCornerShape::uniform(CAPSULE_CLIP_RADIUS)
75 }
76 LiquidShape::RoundedRect(radius) => RoundedCornerShape::uniform(*radius),
77 }
78 }
79
80 pub fn layer_shape(&self) -> LayerShape {
82 LayerShape::Rounded(self.clip_shape())
83 }
84
85 fn shader_radius_px(&self, density: f32) -> f32 {
87 match self {
88 LiquidShape::Capsule | LiquidShape::Circle => CAPSULE_SHADER_RADIUS,
89 LiquidShape::RoundedRect(radius) => radius * density,
90 }
91 }
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
97pub enum GlassVariant {
98 #[default]
100 Regular,
101 Clear,
103 Lens,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq)]
113pub struct GlassShadow {
114 pub color: Color,
115 pub radius: f32,
116 pub offset_y: f32,
117 pub spread: f32,
118}
119
120impl GlassShadow {
121 pub fn new(color: Color, radius: f32, offset_y: f32, spread: f32) -> Self {
122 Self {
123 color,
124 radius: radius.max(0.0),
125 offset_y,
126 spread,
127 }
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Default)]
134pub struct GlassDynamics {
135 pub activity: Option<f32>,
139 pub resting_tint: Option<Color>,
142 pub highlight_boost: f32,
144 pub saturation_boost: f32,
147 pub tint_alpha_multiplier: Option<f32>,
150 pub morph: Option<GlassMorph>,
153 pub touch: Option<(f32, f32, f32)>,
157 pub press_depth: Option<f32>,
163}
164
165impl GlassDynamics {
166 pub fn touched_up(
173 mut self,
174 press: f32,
175 finger: Option<(f32, f32)>,
176 center: (f32, f32),
177 ) -> Self {
178 let press = press.clamp(0.0, 1.0);
179 if press <= 0.0 {
180 return self;
181 }
182 self.highlight_boost += 0.85 * press;
183 self.saturation_boost += 0.45 * press;
184 let (fx, fy) = finger.unwrap_or(center);
185 let carried = self.touch.map(|(_, _, i)| i).unwrap_or(0.0);
186 self.touch = Some((fx, fy, (carried + press).clamp(0.0, 1.0)));
187 self
188 }
189}
190
191fn foreground_is_dark(foreground: Color) -> bool {
192 let foreground_luma =
193 0.2126 * foreground.r() + 0.7152 * foreground.g() + 0.0722 * foreground.b();
194 foreground_luma < 0.5
195}
196
197fn boost_tint_saturation(tint: Color, boost: f32) -> Color {
198 if boost.abs() <= f32::EPSILON {
199 return tint;
200 }
201 let luma = 0.2126 * tint.r() + 0.7152 * tint.g() + 0.0722 * tint.b();
202 let saturation = (1.0 + boost).max(0.0);
203 Color::rgba(
204 (luma + (tint.r() - luma) * saturation).clamp(0.0, 1.0),
205 (luma + (tint.g() - luma) * saturation).clamp(0.0, 1.0),
206 (luma + (tint.b() - luma) * saturation).clamp(0.0, 1.0),
207 tint.a(),
208 )
209}
210
211pub(crate) fn neutral_surface_tint(foreground: Color, light_alpha: f32, dark_alpha: f32) -> Color {
212 if foreground_is_dark(foreground) {
213 Color::BLACK.with_alpha(light_alpha.clamp(0.0, 1.0))
214 } else {
215 Color::WHITE.with_alpha(dark_alpha.clamp(0.0, 1.0))
216 }
217}
218
219pub(crate) fn neutral_surface_lift(foreground: Color, light_lift: f32, dark_lift: f32) -> f32 {
220 if foreground_is_dark(foreground) {
221 light_lift
222 } else {
223 dark_lift
224 }
225}
226
227#[derive(Clone, Debug, PartialEq, Default)]
238pub struct GlassMorph {
239 pub node_size: (f32, f32),
245 pub primary: (f32, f32, f32, f32, f32),
246 pub shapes: Vec<(f32, f32, f32, f32, f32)>,
248 pub glue: f32,
250 pub wobble_amplitude: f32,
252 pub wobble_phase: f32,
253 pub bulge_amplitude: f32,
257 pub bulge_direction: f32,
258 pub ellipse_blend: f32,
262 pub deformation: Option<GlassDeformation>,
266 pub zoom_anchor: (f32, f32),
272}
273
274#[derive(Clone, Copy, Debug, PartialEq)]
278pub struct GlassDeformation {
279 axis: (f32, f32),
280 along: f32,
281}
282
283impl GlassDeformation {
284 pub fn incompressible(axis: (f32, f32), along: f32) -> Self {
285 let length = (axis.0 * axis.0 + axis.1 * axis.1).sqrt();
286 let axis = if length > f32::EPSILON {
287 (axis.0 / length, axis.1 / length)
288 } else {
289 (1.0, 0.0)
290 };
291 Self {
292 axis,
293 along: along.max(f32::EPSILON),
294 }
295 }
296
297 pub fn axis(self) -> (f32, f32) {
298 self.axis
299 }
300
301 pub fn along(self) -> f32 {
302 self.along
303 }
304
305 pub fn across(self) -> f32 {
306 1.0 / self.along
307 }
308}
309
310impl GlassMorph {
311 pub const MAX_SHAPES: usize = 8;
313}
314
315#[derive(Clone, Debug, PartialEq)]
318pub struct Glass {
319 pub variant: GlassVariant,
320 pub shape: LiquidShape,
321 pub tint: Option<Color>,
323 pub blur_radius: Option<f32>,
325 pub saturation: Option<f32>,
327 pub refraction_depth: f32,
329 pub refraction_curve: f32,
331 pub dispersion: f32,
333 pub transmission_refraction: f32,
336 pub meniscus_absorption: f32,
339 pub fold_depth: f32,
343 pub optical_zoom: f32,
347 pub rim_reflection: f32,
350 pub ink_recolor: Option<(Color, f32)>,
354 pub highlight: f32,
356 pub lift: Option<f32>,
359 pub contrast: Option<f32>,
364 pub shadow: bool,
366 pub shadow_style: Option<GlassShadow>,
368 pub clip: bool,
371 pub foreground: Option<Color>,
374 pub adaptive_frost: f32,
376}
377
378impl Glass {
379 pub fn regular() -> Self {
380 Self {
381 variant: GlassVariant::Regular,
382 shape: LiquidShape::Capsule,
383 tint: None,
384 blur_radius: None,
385 saturation: None,
386 refraction_depth: 0.34,
387 refraction_curve: 0.25,
388 dispersion: 0.0,
389 transmission_refraction: 1.0,
390 meniscus_absorption: 1.0,
391 fold_depth: 0.0,
392 optical_zoom: 1.0,
393 rim_reflection: 1.0,
394 ink_recolor: None,
395 highlight: 0.9,
396 lift: None,
397 contrast: None,
398 shadow: true,
399 shadow_style: None,
400 clip: true,
401 foreground: None,
402 adaptive_frost: 0.65,
403 }
404 }
405
406 pub fn clear() -> Self {
407 Self {
408 variant: GlassVariant::Clear,
409 ..Self::regular()
410 }
411 }
412
413 pub fn lens() -> Self {
417 Self {
418 variant: GlassVariant::Lens,
419 shape: LiquidShape::Capsule,
420 tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.07)),
421 blur_radius: None,
422 saturation: None,
423 refraction_depth: 0.34,
424 refraction_curve: 1.0,
425 dispersion: 0.30,
426 transmission_refraction: 1.0,
427 meniscus_absorption: 1.0,
428 fold_depth: 0.0,
429 optical_zoom: 1.0,
430 rim_reflection: 1.0,
431 ink_recolor: None,
432 highlight: 1.15,
433 lift: None,
434 contrast: None,
435 shadow: true,
436 shadow_style: None,
437 clip: true,
438 foreground: None,
439 adaptive_frost: 0.0,
440 }
441 }
442
443 pub fn shape(mut self, shape: LiquidShape) -> Self {
444 self.shape = shape;
445 self
446 }
447
448 pub fn tint(mut self, tint: Color) -> Self {
449 self.tint = Some(tint);
450 self
451 }
452
453 pub fn blur_radius(mut self, radius_dp: f32) -> Self {
454 self.blur_radius = Some(radius_dp);
455 self
456 }
457
458 pub fn saturation(mut self, saturation: f32) -> Self {
459 self.saturation = Some(saturation);
460 self
461 }
462
463 pub fn refraction_depth(mut self, fraction: f32) -> Self {
464 self.refraction_depth = fraction.clamp(0.0, 2.0);
465 self
466 }
467
468 pub fn refraction_curve(mut self, curve: f32) -> Self {
469 self.refraction_curve = curve.clamp(0.05, 1.0);
470 self
471 }
472
473 pub fn dispersion(mut self, strength: f32) -> Self {
474 self.dispersion = strength.clamp(0.0, 1.0);
475 self
476 }
477
478 pub fn transmission_refraction(mut self, strength: f32) -> Self {
479 self.transmission_refraction = strength.clamp(0.0, 1.0);
480 self
481 }
482
483 pub fn meniscus_absorption(mut self, strength: f32) -> Self {
484 self.meniscus_absorption = strength.clamp(0.0, 1.0);
485 self
486 }
487
488 pub fn fold_depth(mut self, depth_dp: f32) -> Self {
490 self.fold_depth = depth_dp.max(0.0);
491 self
492 }
493
494 pub fn optical_zoom(mut self, zoom: f32) -> Self {
496 self.optical_zoom = zoom.max(1.0);
497 self
498 }
499
500 pub fn rim_reflection(mut self, reflectivity: f32) -> Self {
502 self.rim_reflection = reflectivity.clamp(0.0, 2.0);
503 self
504 }
505
506 pub fn ink_recolor(mut self, color: Color, strength: f32) -> Self {
509 self.ink_recolor = Some((color, strength.clamp(0.0, 1.0)));
510 self
511 }
512
513 pub fn highlight(mut self, highlight: f32) -> Self {
514 self.highlight = highlight;
515 self
516 }
517
518 pub fn lift(mut self, lift: f32) -> Self {
521 self.lift = Some(lift);
522 self
523 }
524
525 pub fn contrast(mut self, contrast: f32) -> Self {
528 self.contrast = Some(contrast.max(0.05));
529 self
530 }
531
532 pub fn adaptive_frost(mut self, foreground: Color, strength: f32) -> Self {
533 self.foreground = Some(foreground);
534 self.adaptive_frost = strength.clamp(0.0, 1.0);
535 self
536 }
537
538 pub fn shadow(mut self, shadow: bool) -> Self {
539 self.shadow = shadow;
540 self
541 }
542
543 pub fn shadow_style(mut self, shadow: GlassShadow) -> Self {
544 self.shadow_style = Some(shadow);
545 self
546 }
547
548 pub fn no_clip(mut self) -> Self {
551 self.clip = false;
552 self
553 }
554
555 fn default_blur_radius(&self) -> f32 {
556 match self.variant {
557 GlassVariant::Regular => 8.0,
558 GlassVariant::Clear => 3.0,
559 GlassVariant::Lens => 0.0,
560 }
561 }
562
563 fn default_saturation(&self) -> f32 {
564 match self.variant {
565 GlassVariant::Regular => 1.5,
566 GlassVariant::Clear => 1.25,
567 GlassVariant::Lens => 1.0,
568 }
569 }
570
571 pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
573 let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
580 (GlassVariant::Regular, false) => 0.42,
581 (GlassVariant::Regular, true) => -0.38,
582 (GlassVariant::Clear, false) => 0.12,
583 (GlassVariant::Clear, true) => -0.12,
584 (GlassVariant::Lens, false) => 0.10,
585 (GlassVariant::Lens, true) => -0.08,
586 });
587 let foreground = self.foreground.unwrap_or(colors.label);
588 let shadow = self.shadow_style.unwrap_or_else(|| {
589 GlassShadow::new(
590 Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
591 (GlassVariant::Lens, false) => 0.14,
592 (GlassVariant::Lens, true) => 0.28,
593 (_, false) => 0.16,
594 (_, true) => 0.5,
595 }),
596 if self.variant == GlassVariant::Lens {
597 10.0
598 } else {
599 22.0
600 },
601 if self.variant == GlassVariant::Lens {
602 3.0
603 } else {
604 8.0
605 },
606 if self.variant == GlassVariant::Lens {
607 -6.0
608 } else {
609 -2.0
610 },
611 )
612 });
613 ResolvedGlass {
614 shape: self.shape,
615 tint: self.tint.unwrap_or(colors.glass_tint),
616 blur_radius_dp: self
617 .blur_radius
618 .unwrap_or_else(|| self.default_blur_radius()),
619 saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
620 refraction_depth: self.refraction_depth,
621 refraction_curve: self.refraction_curve,
622 dispersion: self.dispersion,
623 transmission_refraction: self.transmission_refraction,
624 meniscus_absorption: self.meniscus_absorption,
625 fold_depth: self.fold_depth,
626 optical_zoom: self.optical_zoom,
627 rim_reflection: self.rim_reflection,
628 ink_recolor: self.ink_recolor,
629 highlight: self.highlight,
630 lift,
631 contrast: self.contrast.unwrap_or(match self.variant {
632 GlassVariant::Lens => 1.0,
633 _ => 1.03,
634 }),
635 shadow: self.shadow,
636 clip: self.clip,
637 foreground_luma: 0.2126 * foreground.r()
638 + 0.7152 * foreground.g()
639 + 0.0722 * foreground.b(),
640 adaptive_frost: self.adaptive_frost,
641 rim_style: if self.variant == GlassVariant::Lens {
642 1.0
643 } else {
644 0.0
645 },
646 shadow_color: shadow.color,
647 shadow_radius: shadow.radius,
648 shadow_offset_y: shadow.offset_y,
649 shadow_spread: shadow.spread,
650 }
651 }
652}
653
654impl Default for Glass {
655 fn default() -> Self {
656 Self::regular()
657 }
658}
659
660#[derive(Clone, Debug, PartialEq)]
663pub(crate) struct ResolvedGlass {
664 pub shape: LiquidShape,
665 pub tint: Color,
666 pub blur_radius_dp: f32,
667 pub saturation: f32,
668 pub refraction_depth: f32,
669 pub refraction_curve: f32,
670 pub dispersion: f32,
671 pub transmission_refraction: f32,
672 pub meniscus_absorption: f32,
673 pub fold_depth: f32,
674 pub optical_zoom: f32,
675 pub rim_reflection: f32,
676 pub ink_recolor: Option<(Color, f32)>,
677 pub highlight: f32,
678 pub lift: f32,
679 pub contrast: f32,
680 pub shadow: bool,
681 pub clip: bool,
682 pub rim_style: f32,
685 pub foreground_luma: f32,
686 pub adaptive_frost: f32,
687 pub shadow_color: Color,
688 pub shadow_radius: f32,
691 pub shadow_offset_y: f32,
692 pub shadow_spread: f32,
693}
694
695impl ResolvedGlass {
696 pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
700 self.runtime_effect(density, dynamics, false)
701 }
702
703 fn content_mask_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
704 self.runtime_effect(density, dynamics, true)
705 }
706
707 fn runtime_effect(
708 &self,
709 density: f32,
710 dynamics: GlassDynamics,
711 content_mask: bool,
712 ) -> RenderEffect {
713 let density = density.max(f32::EPSILON);
714 let activity = dynamics
715 .activity
716 .filter(|value| value.is_finite())
717 .unwrap_or(1.0)
718 .clamp(0.0, 1.0);
719 let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
720 if let Some(morph) = dynamics.morph.as_ref() {
721 let (node_w, node_h) = morph.node_size;
727 let (cx, cy, w, h, radius) = morph.primary;
728 shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
729 shader.set_float2(2, cx, cy);
730 shader.set_float2(4, w, h);
731 shader.set_float(6, radius);
732 let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
733 shader.set_float(30, count as f32);
734 for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
735 let base = 36 + index * 5;
736 shader.set_float(base, *sx);
737 shader.set_float(base + 1, *sy);
738 shader.set_float(base + 2, *sw);
739 shader.set_float(base + 3, *sh);
740 shader.set_float(base + 4, *sr);
741 }
742 shader.set_float(31, morph.glue);
743 shader.set_float(32, morph.wobble_amplitude * activity);
744 shader.set_float(33, morph.wobble_phase);
745 shader.set_float(26, morph.bulge_amplitude * activity);
746 shader.set_float(27, morph.bulge_direction);
747 shader.set_float(110, morph.ellipse_blend.clamp(0.0, 1.0) * activity);
748 if let Some(deformation) = morph.deformation {
749 let axis = deformation.axis();
750 let along = 1.0 + (deformation.along() - 1.0) * activity;
751 shader.set_float2(106, axis.0, axis.1);
752 shader.set_float(108, along);
753 shader.set_float(109, 1.0 / along);
754 } else {
755 shader.set_float2(106, 1.0, 0.0);
756 shader.set_float2(108, 1.0, 1.0);
757 }
758 } else {
760 shader.set_float2(0, 0.0, 0.0);
763 shader.set_float(6, self.shape.shader_radius_px(density));
764 }
765 let press_depth = dynamics.press_depth.unwrap_or(1.0).clamp(0.0, 1.0);
766 shader.set_float(9, self.refraction_depth * activity * press_depth);
767 shader.set_float(
768 GLASS_REFRACTION_CURVE_UNIFORM,
769 self.refraction_curve * activity,
770 );
771 shader.set_float(
772 GLASS_DISPERSION_UNIFORM,
773 self.dispersion * activity * press_depth,
774 );
775 shader.set_float(
776 GLASS_TRANSMISSION_REFRACTION_UNIFORM,
777 self.transmission_refraction * activity,
778 );
779 shader.set_float(
782 GLASS_MENISCUS_ABSORPTION_UNIFORM,
783 self.meniscus_absorption * press_depth,
784 );
785 shader.set_float(
791 GLASS_FOLD_DEPTH_UNIFORM,
792 self.fold_depth.max(0.0) * press_depth,
793 );
794 shader.set_float(
795 GLASS_OPTICAL_ZOOM_UNIFORM,
796 1.0 + (self.optical_zoom - 1.0).max(0.0) * activity,
797 );
798 let zoom_anchor = dynamics
799 .morph
800 .as_ref()
801 .map(|morph| morph.zoom_anchor)
802 .unwrap_or((0.0, 0.0));
803 shader.set_float2(
804 GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM,
805 zoom_anchor.0,
806 zoom_anchor.1,
807 );
808 shader.set_float(121, self.rim_reflection.max(0.001));
809 let (ink_color, ink_strength) = self
810 .ink_recolor
811 .map(|(color, strength)| (color, strength * activity))
812 .unwrap_or((Color::TRANSPARENT, 0.0));
813 shader.set_float(124, ink_color.r());
814 shader.set_float(125, ink_color.g());
815 shader.set_float(126, ink_color.b());
816 shader.set_float(127, ink_strength);
817 let (light_x, light_y) = glass_light_direction();
818 shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM, light_x);
819 shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM + 1, light_y);
820 let (touch_x, touch_y, touch_intensity) = dynamics.touch.unwrap_or((0.0, 0.0, 0.0));
821 shader.set_float(118, touch_x);
822 shader.set_float(119, touch_y);
823 shader.set_float(120, touch_intensity.clamp(0.0, 1.0));
824 shader.set_float(GLASS_EFFECT_DENSITY_UNIFORM, density);
825 shader.set_float(
826 11,
827 (self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0) * activity,
828 );
829 let dynamic_tint = boost_tint_saturation(self.tint, dynamics.saturation_boost);
830 let dynamic_tint_alpha = (dynamic_tint.a()
831 * dynamics
832 .tint_alpha_multiplier
833 .unwrap_or(1.0)
834 .clamp(0.0, 2.0)
835 * activity)
836 .clamp(0.0, 1.0);
837 shader.set_float4(
838 14,
839 dynamic_tint.r(),
840 dynamic_tint.g(),
841 dynamic_tint.b(),
842 dynamic_tint_alpha,
843 );
844 let saturation = (self.saturation + dynamics.saturation_boost).max(0.0);
845 shader.set_float(18, 1.0 + (saturation - 1.0) * activity);
846 shader.set_float(20, self.lift * activity);
847 shader.set_float(21, 0.5 * activity);
848 shader.set_float2(22, 0.0, 1.0);
849 shader.set_float(24, 1.0 + (self.contrast - 1.0) * activity);
850 shader.set_float(28, self.rim_style * activity);
851 let requested_blur_radius_px = if content_mask {
852 0.0
853 } else {
854 self.blur_radius_dp * density * activity
855 };
856 let (wcksrd_blur_radius, gaussian_blur_radius) =
863 if requested_blur_radius_px > WCKSRD_OPTICAL_BLUR_RADIUS_PX {
864 (0.0, requested_blur_radius_px)
865 } else {
866 (requested_blur_radius_px, 0.0)
867 };
868 shader.set_float(GLASS_BLUR_RADIUS_UNIFORM, wcksrd_blur_radius);
869 shader.set_float(GLASS_ACTIVITY_UNIFORM, activity);
870 let resting_tint = dynamics.resting_tint.unwrap_or(Color::TRANSPARENT);
871 shader.set_float4(
872 GLASS_RESTING_TINT_UNIFORM,
873 resting_tint.r(),
874 resting_tint.g(),
875 resting_tint.b(),
876 resting_tint.a(),
877 );
878 shader.set_float(112, if content_mask { 1.0 } else { 0.0 });
879 shader.set_float(91, self.adaptive_frost * activity);
880 shader.set_float(97, self.foreground_luma);
881 let dynamic_shadow = !self.clip && self.shadow;
882 shader.set_float(
883 102,
884 if dynamic_shadow {
885 self.shadow_color.a() * 0.55 * activity
886 } else {
887 0.0
888 },
889 );
890 shader.set_float(103, self.shadow_radius);
891 shader.set_float(104, self.shadow_offset_y);
892 shader.set_float(105, self.shadow_spread);
893 let morph_pad = dynamics
897 .morph
898 .as_ref()
899 .map(|morph| {
900 let (px, py, pw, ph, _) = morph.primary;
901 let (left, top) = (px - pw * 0.5, py - ph * 0.5);
902 let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
903 let mut shape_reach = 0.0f32;
904 for (sx, sy, sw, sh, _) in &morph.shapes {
905 let reach_x = ((sx + sw * 0.5) - right)
906 .max(left - (sx - sw * 0.5))
907 .max(0.0);
908 let reach_y = ((sy + sh * 0.5) - bottom)
909 .max(top - (sy - sh * 0.5))
910 .max(0.0);
911 shape_reach = shape_reach.max(reach_x.max(reach_y));
912 }
913 let glue_pad = if morph.shapes.is_empty() {
914 0.0
915 } else {
916 morph.glue * 2.0
917 };
918 morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
919 })
920 .unwrap_or(0.0);
921 shader.set_input_padding(self.input_padding() + morph_pad + wcksrd_blur_radius / density);
924 if dynamics.morph.is_some() {
928 let shadow_reach = if dynamic_shadow {
929 self.shadow_radius + self.shadow_offset_y.abs() + self.shadow_spread.max(0.0)
930 } else {
931 0.0
932 };
933 shader.set_output_padding(morph_pad + shadow_reach + 4.0);
934 }
935
936 let optical_effect = RenderEffect::runtime_shader(shader);
937 if gaussian_blur_radius > f32::EPSILON {
938 RenderEffect::blur_with_edge_treatment(gaussian_blur_radius, TileMode::Mirror)
945 .then(optical_effect)
946 } else {
947 optical_effect
948 }
949 }
950
951 fn input_padding(&self) -> f32 {
955 2.0
960 }
961}
962
963pub trait LiquidModifierExt {
965 fn glass_effect(self, glass: Glass) -> Modifier;
971
972 fn glass_effect_with(
976 self,
977 glass: Glass,
978 dynamics: impl Fn() -> GlassDynamics + 'static,
979 ) -> Modifier;
980}
981
982impl LiquidModifierExt for Modifier {
983 fn glass_effect(self, glass: Glass) -> Modifier {
984 self.glass_effect_with(glass, GlassDynamics::default)
985 }
986
987 fn glass_effect_with(
988 self,
989 glass: Glass,
990 dynamics: impl Fn() -> GlassDynamics + 'static,
991 ) -> Modifier {
992 let colors = crate::theme::liquid_colors();
993 let resolved = Rc::new(glass.resolve(&colors));
994 let shape = resolved.shape;
995
996 let mut modifier = self;
997 if resolved.shadow && resolved.clip {
998 let shadow_color = resolved.shadow_color;
999 let (radius, offset_y, spread) = (
1000 resolved.shadow_radius,
1001 resolved.shadow_offset_y,
1002 resolved.shadow_spread,
1003 );
1004 modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
1005 scope.radius = radius;
1006 scope.spread = spread;
1007 scope.offset.y = offset_y;
1008 scope.color = shadow_color;
1009 scope.cutout = true;
1012 });
1013 }
1014
1015 let layer_resolved = Rc::clone(&resolved);
1016 let clip = resolved.clip;
1017 modifier.graphics_layer(move || {
1018 let density = current_density();
1019 let frame = dynamics();
1020 let render_effect = (!clip && frame.morph.is_some())
1021 .then(|| layer_resolved.content_mask_effect(density, frame.clone()));
1022 GraphicsLayer {
1023 backdrop_effect: Some(layer_resolved.backdrop_effect(density, frame)),
1024 render_effect,
1025 shape: shape.layer_shape(),
1026 clip,
1027 ..Default::default()
1028 }
1029 })
1030 }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035 use super::*;
1036
1037 fn light_colors() -> LiquidColors {
1038 LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
1039 }
1040
1041 fn terminal_shader(effect: RenderEffect) -> RuntimeShader {
1042 match effect {
1043 RenderEffect::Shader { shader } => shader,
1044 RenderEffect::Chain { second, .. } => terminal_shader(*second),
1045 effect => panic!("expected runtime shader, got {effect:?}"),
1046 }
1047 }
1048
1049 #[test]
1050 fn glass_light_direction_defaults_overhead_and_reaches_the_shader() {
1051 assert_eq!(glass_light_direction(), (0.0, 1.0));
1052 let resolved = Glass::regular().resolve(&light_colors());
1053 let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1054 let shader = terminal_shader(effect);
1055 let u = shader.uniforms();
1056 assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM], 0.0);
1057 assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 1.0);
1058
1059 set_glass_light_direction((1.0, 0.0));
1062 let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1063 let u_rotated = terminal_shader(effect);
1064 let u_rotated = u_rotated.uniforms();
1065 assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM], 1.0);
1066 assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 0.0);
1067 set_glass_light_direction((0.0, 1.0));
1068 }
1069
1070 #[test]
1071 fn liquid_shape_builds_matching_clip_and_layer_shapes() {
1072 for shape in [
1073 LiquidShape::Capsule,
1074 LiquidShape::Circle,
1075 LiquidShape::RoundedRect(12.0),
1076 ] {
1077 assert_eq!(shape.layer_shape(), LayerShape::Rounded(shape.clip_shape()));
1078 }
1079 }
1080
1081 #[test]
1082 fn glass_shadow_clamps_negative_radius() {
1083 let shadow = GlassShadow::new(Color::BLACK, -2.0, 3.0, -1.0);
1084 assert_eq!(shadow.radius, 0.0);
1085 assert_eq!(shadow.offset_y, 3.0);
1086 assert_eq!(shadow.spread, -1.0);
1087 }
1088
1089 #[test]
1090 fn incompressible_deformation_normalizes_axis_and_conserves_area() {
1091 let deformation = GlassDeformation::incompressible((3.0, 4.0), 1.25);
1092 assert_eq!(deformation.axis(), (0.6, 0.8));
1093 assert_eq!(deformation.along(), 1.25);
1094 assert!((deformation.along() * deformation.across() - 1.0).abs() < 1.0e-6);
1095 assert_eq!(
1096 GlassDeformation::incompressible((0.0, 0.0), 0.0).axis(),
1097 (1.0, 0.0)
1098 );
1099 }
1100
1101 #[test]
1102 fn glass_builders_clamp_physical_inputs() {
1103 let glass = Glass::lens()
1104 .shape(LiquidShape::Circle)
1105 .tint(Color::BLACK)
1106 .blur_radius(-2.0)
1107 .saturation(1.2)
1108 .refraction_depth(3.0)
1109 .refraction_curve(2.0)
1110 .dispersion(2.0)
1111 .transmission_refraction(2.0)
1112 .meniscus_absorption(2.0)
1113 .highlight(0.4)
1114 .lift(-0.2)
1115 .adaptive_frost(Color::WHITE, 2.0)
1116 .shadow(false)
1117 .no_clip();
1118 assert_eq!(glass.shape, LiquidShape::Circle);
1119 assert_eq!(glass.tint, Some(Color::BLACK));
1120 assert_eq!(glass.blur_radius, Some(-2.0));
1121 assert_eq!(glass.saturation, Some(1.2));
1122 assert_eq!(glass.refraction_depth, 2.0);
1123 assert_eq!(glass.refraction_curve, 1.0);
1124 assert_eq!(glass.dispersion, 1.0);
1125 assert_eq!(glass.transmission_refraction, 1.0);
1126 assert_eq!(glass.meniscus_absorption, 1.0);
1127 assert_eq!(glass.highlight, 0.4);
1128 assert_eq!(glass.lift, Some(-0.2));
1129 assert_eq!(glass.adaptive_frost, 1.0);
1130 assert!(!glass.shadow);
1131 assert!(!glass.clip);
1132 }
1133
1134 #[test]
1135 fn material_variants_resolve_distinct_frost_levels() {
1136 let regular = Glass::regular().resolve(&light_colors());
1137 let clear = Glass::clear().resolve(&light_colors());
1138 let lens = Glass::lens().resolve(&light_colors());
1139 assert!(regular.blur_radius_dp > clear.blur_radius_dp);
1140 assert!(clear.blur_radius_dp > lens.blur_radius_dp);
1141 assert!(regular.saturation > clear.saturation);
1142 assert_eq!(lens.rim_style, 1.0);
1143 assert_eq!(regular.refraction_curve, 0.25);
1144 assert_eq!(lens.refraction_curve, 1.0);
1145 assert_eq!(regular.dispersion, 0.0);
1146 assert_eq!(lens.dispersion, 0.30);
1147 }
1148
1149 #[test]
1150 fn neutral_surface_helpers_follow_foreground_polarity() {
1151 assert_eq!(
1152 neutral_surface_tint(Color::BLACK, 0.08, 0.10),
1153 Color::BLACK.with_alpha(0.08)
1154 );
1155 assert_eq!(
1156 neutral_surface_tint(Color::WHITE, 0.08, 0.10),
1157 Color::WHITE.with_alpha(0.10)
1158 );
1159 assert_eq!(neutral_surface_lift(Color::BLACK, 0.7, -0.3), 0.7);
1160 assert_eq!(neutral_surface_lift(Color::WHITE, 0.7, -0.3), -0.3);
1161 }
1162
1163 #[test]
1164 fn dynamic_saturation_reaches_the_material_tint() {
1165 let resting = Color::from_rgb_u8(0, 199, 208);
1166 let raised = boost_tint_saturation(resting, 0.55);
1167 assert_eq!(raised.r(), 0.0);
1168 assert!(raised.g() > resting.g());
1169 assert!(raised.b() > resting.b());
1170 assert_eq!(raised.a(), resting.a());
1171 }
1172
1173 #[test]
1174 fn resolved_material_packs_wcksrd_and_dynamic_tint() {
1175 let resolved = Glass::lens()
1176 .refraction_depth(0.72)
1177 .refraction_curve(0.8)
1178 .dispersion(0.42)
1179 .transmission_refraction(0.35)
1180 .meniscus_absorption(0.3)
1181 .blur_radius(3.0)
1182 .tint(Color::BLACK.with_alpha(0.8))
1183 .resolve(&light_colors());
1184 let effect = resolved.backdrop_effect(
1185 2.0,
1186 GlassDynamics {
1187 highlight_boost: 0.2,
1188 saturation_boost: 0.35,
1189 tint_alpha_multiplier: Some(0.25),
1190 ..Default::default()
1191 },
1192 );
1193 let RenderEffect::Chain { first, .. } = &effect else {
1194 panic!("macroscopic frost must precede the wcKSRD optical pass");
1195 };
1196 assert!(matches!(
1197 first.as_ref(),
1198 RenderEffect::Blur {
1199 radius_x: 6.0,
1200 radius_y: 6.0,
1201 ..
1202 }
1203 ));
1204 let shader = terminal_shader(effect);
1205 assert_eq!(shader.uniforms()[9], 0.72);
1206 assert_eq!(shader.uniforms()[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1207 assert_eq!(shader.uniforms()[GLASS_DISPERSION_UNIFORM], 0.42);
1208 assert_eq!(
1209 shader.uniforms()[GLASS_TRANSMISSION_REFRACTION_UNIFORM],
1210 0.35
1211 );
1212 assert_eq!(shader.uniforms()[GLASS_MENISCUS_ABSORPTION_UNIFORM], 0.3);
1213 assert_eq!(shader.uniforms()[11], resolved.highlight + 0.2);
1214 assert_eq!(shader.uniforms()[18], resolved.saturation + 0.35);
1215 assert!((shader.uniforms()[17] - 0.2).abs() < 1.0e-6);
1216 assert_eq!(shader.uniforms()[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1217 assert_eq!(shader.uniforms()[GLASS_EFFECT_DENSITY_UNIFORM], 2.0);
1218 }
1219
1220 #[test]
1221 fn heavy_backdrop_blur_rides_the_gaussian_pass_alone() {
1222 let effect = Glass::regular()
1227 .resolve(&light_colors())
1228 .backdrop_effect(3.0, GlassDynamics::default());
1229 let RenderEffect::Chain { first, .. } = &effect else {
1230 panic!("heavy blur must ride the separable Gaussian pre-pass");
1231 };
1232 assert!(matches!(
1233 first.as_ref(),
1234 RenderEffect::Blur {
1235 radius_x: 24.0,
1236 radius_y: 24.0,
1237 ..
1238 }
1239 ));
1240 assert_eq!(
1241 terminal_shader(effect).uniforms()[GLASS_BLUR_RADIUS_UNIFORM],
1242 0.0
1243 );
1244 }
1245
1246 #[test]
1247 fn featherweight_backdrop_blur_stays_in_the_optical_tap() {
1248 let RenderEffect::Shader { shader } = Glass::regular()
1251 .blur_radius(0.5)
1252 .resolve(&light_colors())
1253 .backdrop_effect(3.0, GlassDynamics::default())
1254 else {
1255 panic!("a sub-cap blur must not build a Gaussian chain");
1256 };
1257 assert_eq!(shader.uniforms()[GLASS_BLUR_RADIUS_UNIFORM], 1.5);
1258 }
1259
1260 #[test]
1261 fn raised_tint_density_stays_premultiplied_alpha_safe() {
1262 let effect = Glass::lens()
1263 .tint(Color::WHITE.with_alpha(0.8))
1264 .resolve(&light_colors())
1265 .backdrop_effect(
1266 1.0,
1267 GlassDynamics {
1268 tint_alpha_multiplier: Some(2.0),
1269 ..Default::default()
1270 },
1271 );
1272 assert_eq!(terminal_shader(effect).uniforms()[17], 1.0);
1273 }
1274
1275 #[test]
1276 fn optical_activity_reaches_identity_without_removing_the_glass_geometry() {
1277 let resolved = Glass::lens()
1278 .refraction_depth(0.72)
1279 .refraction_curve(0.8)
1280 .dispersion(0.42)
1281 .blur_radius(3.0)
1282 .saturation(1.4)
1283 .highlight(0.6)
1284 .lift(0.2)
1285 .tint(Color::BLACK.with_alpha(0.8))
1286 .no_clip()
1287 .resolve(&light_colors());
1288 let morph = GlassMorph {
1289 node_size: (120.0, 72.0),
1290 primary: (60.0, 36.0, 96.0, 52.0, -1.0),
1291 wobble_amplitude: 3.0,
1292 bulge_amplitude: 4.0,
1293 deformation: Some(GlassDeformation::incompressible((1.0, 0.0), 1.25)),
1294 ..Default::default()
1295 };
1296
1297 let RenderEffect::Shader { shader } = resolved.backdrop_effect(
1298 2.0,
1299 GlassDynamics {
1300 activity: Some(0.0),
1301 morph: Some(morph),
1302 ..Default::default()
1303 },
1304 ) else {
1305 panic!("material must resolve to the shared wcKSRD shader");
1306 };
1307 let uniforms = shader.uniforms();
1308 assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 0.0);
1309 assert_eq!(uniforms[9], 0.0);
1310 assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.0);
1311 assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.0);
1312 assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 0.0);
1313 assert_eq!(uniforms[11], 0.0);
1314 assert_eq!(uniforms[17], 0.0);
1315 assert_eq!(uniforms[18], 1.0);
1316 assert_eq!(uniforms[20], 0.0);
1317 assert_eq!(uniforms[21], 0.0);
1318 assert_eq!(uniforms[24], 1.0);
1319 assert_eq!(uniforms[28], 0.0);
1320 assert_eq!(uniforms[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1321 assert_eq!(uniforms[91], 0.0);
1322 assert_eq!(uniforms[102], 0.0);
1323 assert_eq!(
1324 &uniforms[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1325 &[0.0, 0.0, 0.0, 0.0]
1326 );
1327 assert_eq!(uniforms[32], 0.0);
1328 assert_eq!(uniforms[26], 0.0);
1329 assert_eq!(&uniforms[108..110], &[1.0, 1.0]);
1330 assert_eq!(&uniforms[2..6], &[60.0, 36.0, 96.0, 52.0]);
1331 }
1332
1333 #[test]
1334 fn resting_surface_tint_survives_zero_optical_activity() {
1335 let tint = Color::BLACK.with_alpha(0.11);
1336 let RenderEffect::Shader { shader } = Glass::lens()
1337 .no_clip()
1338 .resolve(&light_colors())
1339 .backdrop_effect(
1340 1.0,
1341 GlassDynamics {
1342 activity: Some(0.0),
1343 resting_tint: Some(tint),
1344 ..Default::default()
1345 },
1346 )
1347 else {
1348 panic!("resting surface must use the shared wcKSRD shader");
1349 };
1350 assert_eq!(
1351 &shader.uniforms()[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1352 &[tint.r(), tint.g(), tint.b(), tint.a()]
1353 );
1354 assert_eq!(shader.uniforms()[GLASS_ACTIVITY_UNIFORM], 0.0);
1355 }
1356
1357 #[test]
1358 fn full_optical_activity_preserves_the_resolved_material() {
1359 let resolved = Glass::lens()
1360 .refraction_depth(0.72)
1361 .refraction_curve(0.8)
1362 .dispersion(0.42)
1363 .blur_radius(3.0)
1364 .resolve(&light_colors());
1365 let shader = terminal_shader(resolved.backdrop_effect(
1366 2.0,
1367 GlassDynamics {
1368 activity: Some(1.0),
1369 ..Default::default()
1370 },
1371 ));
1372 let uniforms = shader.uniforms();
1373 assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 1.0);
1374 assert_eq!(uniforms[9], 0.72);
1375 assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1376 assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.42);
1377 assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 1.0);
1378 assert_eq!(uniforms[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1381 }
1382
1383 #[test]
1384 fn morph_geometry_and_incompressible_strain_are_packed() {
1385 let deformation = GlassDeformation::incompressible((0.0, 2.0), 1.25);
1386 let morph = GlassMorph {
1387 node_size: (78.0, 59.0),
1388 primary: (39.0, 29.5, 58.0, 39.0, -1.0),
1389 shapes: vec![(70.0, 29.5, 40.0, 40.0, -1.0)],
1390 glue: 8.0,
1391 wobble_amplitude: 1.0,
1392 wobble_phase: 0.5,
1393 bulge_amplitude: 2.0,
1394 bulge_direction: 0.25,
1395 ellipse_blend: 0.3,
1396 deformation: Some(deformation),
1397 zoom_anchor: (0.0, 0.0),
1398 };
1399 let RenderEffect::Shader { shader } = Glass::lens()
1400 .no_clip()
1401 .resolve(&light_colors())
1402 .backdrop_effect(
1403 1.0,
1404 GlassDynamics {
1405 morph: Some(morph),
1406 ..Default::default()
1407 },
1408 )
1409 else {
1410 panic!("morph must use the shared wcKSRD shader");
1411 };
1412 let uniforms = shader.uniforms();
1413 assert_eq!(&uniforms[0..6], &[78.0, 59.0, 39.0, 29.5, 58.0, 39.0]);
1414 assert_eq!(uniforms[30], 1.0);
1415 assert_eq!(&uniforms[106..110], &[0.0, 1.0, 1.25, 0.8]);
1416 assert_eq!(uniforms[110], 0.3);
1417 assert!(shader.output_padding() > 0.0);
1418 }
1419
1420 #[test]
1421 fn optical_builders_hold_their_own_legal_range() {
1422 let glass = Glass::regular()
1423 .fold_depth(-4.0)
1424 .optical_zoom(0.5)
1425 .rim_reflection(9.0);
1426 assert_eq!(glass.fold_depth, 0.0, "a fold cannot be negative deep");
1427 assert_eq!(glass.optical_zoom, 1.0, "a lens never shrinks its face");
1428 assert_eq!(
1429 glass.rim_reflection, 2.0,
1430 "the rim tops out at twice the line"
1431 );
1432
1433 let glass = Glass::regular()
1434 .fold_depth(6.0)
1435 .optical_zoom(1.4)
1436 .rim_reflection(0.25);
1437 assert_eq!(glass.fold_depth, 6.0);
1438 assert_eq!(glass.optical_zoom, 1.4);
1439 assert_eq!(glass.rim_reflection, 0.25);
1440 }
1441
1442 #[test]
1443 fn ink_recolor_records_its_color_at_a_clamped_strength() {
1444 let red = Color::from_rgb_u8(255, 0, 0);
1445 let glass = Glass::regular().ink_recolor(red, 3.0);
1446 assert_eq!(glass.ink_recolor, Some((red, 1.0)));
1447
1448 let glass = Glass::regular().ink_recolor(red, -1.0);
1449 assert_eq!(glass.ink_recolor, Some((red, 0.0)));
1450 assert_eq!(Glass::regular().ink_recolor, None, "no recolor by default");
1451 }
1452
1453 #[test]
1454 fn a_touched_up_surface_amplifies_what_it_already_is() {
1455 let dynamics = GlassDynamics::default().touched_up(1.0, None, (10.0, 4.0));
1456 assert!(
1457 dynamics.highlight_boost > 0.0,
1458 "a pressed surface goes brighter"
1459 );
1460 assert!(dynamics.saturation_boost > 0.0, "and more saturated");
1461 assert_eq!(
1462 dynamics.touch,
1463 Some((10.0, 4.0, 1.0)),
1464 "with no finger the glow sits at the surface's own heart"
1465 );
1466 assert_eq!(
1467 dynamics.resting_tint, None,
1468 "a touch never repaints the surface with another color"
1469 );
1470 }
1471
1472 #[test]
1473 fn a_touch_glow_follows_the_finger_and_accumulates_to_full() {
1474 let dynamics = GlassDynamics::default()
1475 .touched_up(0.5, Some((3.0, 7.0)), (10.0, 4.0))
1476 .touched_up(0.8, Some((3.0, 7.0)), (10.0, 4.0));
1477 let (x, y, intensity) = dynamics.touch.expect("a pressed surface glows");
1478 assert_eq!(
1479 (x, y),
1480 (3.0, 7.0),
1481 "the glow tracks the finger, not the center"
1482 );
1483 assert_eq!(intensity, 1.0, "carried press saturates at full");
1484 }
1485
1486 #[test]
1487 fn an_unpressed_surface_is_left_exactly_as_it_was() {
1488 let resting = GlassDynamics::default();
1489 let untouched = resting
1490 .clone()
1491 .touched_up(0.0, Some((3.0, 7.0)), (10.0, 4.0));
1492 assert_eq!(untouched.highlight_boost, resting.highlight_boost);
1493 assert_eq!(untouched.saturation_boost, resting.saturation_boost);
1494 assert_eq!(untouched.touch, None, "no press, no glow");
1495 }
1496}