use crate::theme::LiquidColors;
use cranpose_ui::current_density;
use cranpose_ui::Modifier;
use cranpose_ui_graphics::{
Color, GraphicsLayer, LayerShape, RenderEffect, RoundedCornerShape, RuntimeShader,
LIQUID_GLASS_WGSL,
};
use std::rc::Rc;
const CAPSULE_CLIP_RADIUS: f32 = 1.0e6;
const CAPSULE_SHADER_RADIUS: f32 = -1.0;
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum LiquidShape {
#[default]
Capsule,
RoundedRect(f32),
Circle,
}
impl LiquidShape {
pub fn clip_shape(&self) -> RoundedCornerShape {
match self {
LiquidShape::Capsule | LiquidShape::Circle => {
RoundedCornerShape::uniform(CAPSULE_CLIP_RADIUS)
}
LiquidShape::RoundedRect(radius) => RoundedCornerShape::uniform(*radius),
}
}
pub fn layer_shape(&self) -> LayerShape {
LayerShape::Rounded(self.clip_shape())
}
fn shader_radius_px(&self, density: f32) -> f32 {
match self {
LiquidShape::Capsule | LiquidShape::Circle => CAPSULE_SHADER_RADIUS,
LiquidShape::RoundedRect(radius) => radius * density,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GlassVariant {
#[default]
Regular,
Clear,
Lens,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct GlassDynamics {
pub tilt: (f32, f32),
pub highlight_boost: f32,
pub magnify_boost: f32,
pub morph: Option<GlassMorph>,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct GlassMorph {
pub node_size: (f32, f32),
pub primary: (f32, f32, f32, f32, f32),
pub shapes: Vec<(f32, f32, f32, f32, f32)>,
pub glue: f32,
pub wobble_amplitude: f32,
pub wobble_phase: f32,
pub bulge_amplitude: f32,
pub bulge_direction: f32,
}
impl GlassMorph {
pub const MAX_SHAPES: usize = 8;
}
#[derive(Clone, Debug, PartialEq)]
pub struct Glass {
pub variant: GlassVariant,
pub shape: LiquidShape,
pub tint: Option<Color>,
pub blur_radius: Option<f32>,
pub saturation: Option<f32>,
pub chromatic_aberration: f32,
pub displacement: f32,
pub bezel_width: f32,
pub highlight: f32,
pub lift: Option<f32>,
pub shadow: bool,
pub clip: bool,
}
impl Glass {
pub fn regular() -> Self {
Self {
variant: GlassVariant::Regular,
shape: LiquidShape::Capsule,
tint: None,
blur_radius: None,
saturation: None,
chromatic_aberration: 0.5,
displacement: 22.0,
bezel_width: 12.0,
highlight: 0.9,
lift: None,
shadow: true,
clip: true,
}
}
pub fn clear() -> Self {
Self {
variant: GlassVariant::Clear,
..Self::regular()
}
}
pub fn lens() -> Self {
Self {
variant: GlassVariant::Lens,
shape: LiquidShape::Capsule,
tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.0)),
blur_radius: None,
saturation: None,
chromatic_aberration: 3.2,
displacement: 30.0,
bezel_width: 16.0,
highlight: 1.15,
lift: None,
shadow: true,
clip: true,
}
}
pub fn shape(mut self, shape: LiquidShape) -> Self {
self.shape = shape;
self
}
pub fn tint(mut self, tint: Color) -> Self {
self.tint = Some(tint);
self
}
pub fn blur_radius(mut self, radius_dp: f32) -> Self {
self.blur_radius = Some(radius_dp);
self
}
pub fn saturation(mut self, saturation: f32) -> Self {
self.saturation = Some(saturation);
self
}
pub fn chromatic_aberration(mut self, spread: f32) -> Self {
self.chromatic_aberration = spread;
self
}
pub fn displacement(mut self, displacement_px: f32) -> Self {
self.displacement = displacement_px;
self
}
pub fn highlight(mut self, highlight: f32) -> Self {
self.highlight = highlight;
self
}
pub fn lift(mut self, lift: f32) -> Self {
self.lift = Some(lift);
self
}
pub fn shadow(mut self, shadow: bool) -> Self {
self.shadow = shadow;
self
}
pub fn no_clip(mut self) -> Self {
self.clip = false;
self
}
fn default_blur_radius(&self) -> f32 {
match self.variant {
GlassVariant::Regular => 18.0,
GlassVariant::Clear => 3.0,
GlassVariant::Lens => 0.0,
}
}
fn default_saturation(&self) -> f32 {
match self.variant {
GlassVariant::Regular => 1.5,
GlassVariant::Clear => 1.25,
GlassVariant::Lens => 1.0,
}
}
pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
(GlassVariant::Regular, false) => 0.42,
(GlassVariant::Regular, true) => -0.38,
(GlassVariant::Clear, false) => 0.12,
(GlassVariant::Clear, true) => -0.12,
(GlassVariant::Lens, false) => 0.10,
(GlassVariant::Lens, true) => -0.08,
});
ResolvedGlass {
shape: self.shape,
tint: self.tint.unwrap_or(colors.glass_tint),
blur_radius_dp: self
.blur_radius
.unwrap_or_else(|| self.default_blur_radius()),
saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
chromatic_aberration: self.chromatic_aberration,
displacement: self.displacement,
bezel_width_dp: self.bezel_width,
highlight: self.highlight,
lift,
contrast: if self.variant == GlassVariant::Lens {
1.0
} else {
1.03
},
edge_band: if self.variant == GlassVariant::Lens {
0.35
} else {
0.4
},
shadow: self.shadow,
clip: self.clip,
dome_direction: if self.variant == GlassVariant::Lens {
-1.0
} else {
1.0
},
magnify: if self.variant == GlassVariant::Lens {
1.35
} else {
1.0
},
sheen: if self.variant == GlassVariant::Lens {
0.05
} else {
1.0
},
rim_style: if self.variant == GlassVariant::Lens {
1.0
} else {
0.0
},
shadow_color: Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
(GlassVariant::Lens, false) => 0.14,
(GlassVariant::Lens, true) => 0.28,
(_, false) => 0.16,
(_, true) => 0.5,
}),
shadow_radius: if self.variant == GlassVariant::Lens {
10.0
} else {
22.0
},
shadow_offset_y: if self.variant == GlassVariant::Lens {
3.0
} else {
8.0
},
shadow_spread: if self.variant == GlassVariant::Lens {
-6.0
} else {
-2.0
},
}
}
}
impl Default for Glass {
fn default() -> Self {
Self::regular()
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ResolvedGlass {
pub shape: LiquidShape,
pub tint: Color,
pub blur_radius_dp: f32,
pub saturation: f32,
pub chromatic_aberration: f32,
pub displacement: f32,
pub bezel_width_dp: f32,
pub highlight: f32,
pub lift: f32,
pub contrast: f32,
pub edge_band: f32,
pub shadow: bool,
pub clip: bool,
pub dome_direction: f32,
pub magnify: f32,
pub sheen: f32,
pub rim_style: f32,
pub shadow_color: Color,
pub shadow_radius: f32,
pub shadow_offset_y: f32,
pub shadow_spread: f32,
}
impl ResolvedGlass {
pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
let density = density.max(f32::EPSILON);
let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
if let Some(morph) = dynamics.morph.as_ref() {
let (node_w, node_h) = morph.node_size;
let (cx, cy, w, h, radius) = morph.primary;
shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
shader.set_float2(2, cx, cy);
shader.set_float2(4, w, h);
shader.set_float(6, radius);
let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
shader.set_float(30, count as f32);
for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
let base = 36 + index * 5;
shader.set_float(base, *sx);
shader.set_float(base + 1, *sy);
shader.set_float(base + 2, *sw);
shader.set_float(base + 3, *sh);
shader.set_float(base + 4, *sr);
}
shader.set_float(31, morph.glue);
shader.set_float(32, morph.wobble_amplitude);
shader.set_float(33, morph.wobble_phase);
shader.set_float(26, morph.bulge_amplitude);
shader.set_float(27, morph.bulge_direction);
shader.set_float(7, self.bezel_width_dp);
} else {
shader.set_float2(0, 0.0, 0.0);
shader.set_float(6, self.shape.shader_radius_px(density));
shader.set_float(7, self.bezel_width_dp * density);
}
shader.set_float(8, self.displacement);
shader.set_float(9, 1.5);
shader.set_float(10, 0.6);
shader.set_float(
11,
(self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0),
);
shader.set_float2(12, dynamics.tilt.0, dynamics.tilt.1);
shader.set_float4(
14,
self.tint.r(),
self.tint.g(),
self.tint.b(),
self.tint.a(),
);
shader.set_float(18, self.saturation);
shader.set_float(19, self.chromatic_aberration);
shader.set_float(20, self.lift);
shader.set_float(21, 0.5);
shader.set_float2(22, 0.0, 1.0);
shader.set_float(24, self.contrast);
shader.set_float(25, self.edge_band);
shader.set_float(29, self.sheen);
shader.set_float(28, self.rim_style);
shader.set_float(34, self.dome_direction);
shader.set_float(35, self.magnify + dynamics.magnify_boost.max(0.0));
let morph_pad = dynamics
.morph
.as_ref()
.map(|morph| {
let (px, py, pw, ph, _) = morph.primary;
let (left, top) = (px - pw * 0.5, py - ph * 0.5);
let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
let mut shape_reach = 0.0f32;
for (sx, sy, sw, sh, _) in &morph.shapes {
let reach_x = ((sx + sw * 0.5) - right)
.max(left - (sx - sw * 0.5))
.max(0.0);
let reach_y = ((sy + sh * 0.5) - bottom)
.max(top - (sy - sh * 0.5))
.max(0.0);
shape_reach = shape_reach.max(reach_x.max(reach_y));
}
let glue_pad = if morph.shapes.is_empty() {
0.0
} else {
morph.glue * 2.0
};
morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
})
.unwrap_or(0.0);
shader.set_input_padding(self.input_padding() + morph_pad);
if dynamics.morph.is_some() {
shader.set_output_padding(morph_pad + 4.0);
}
let lens = RenderEffect::runtime_shader(shader);
let blur_px = self.blur_radius_dp * density;
if blur_px > 0.5 {
RenderEffect::blur(blur_px).then(lens)
} else {
lens
}
}
fn input_padding(&self) -> f32 {
let bend = 1.0 - 1.0 / 1.5_f32;
let reach = 1.0 + std::f32::consts::SQRT_2;
let slope = 4.0 + 0.35 * (2.0 + 2.0 * 0.6);
let spread = 1.0 + self.chromatic_aberration.max(0.0) * 0.5;
let displacement = reach * bend * self.displacement.max(0.0) * slope * spread;
displacement.ceil() + 2.0
}
}
pub trait LiquidModifierExt {
fn glass_effect(self, glass: Glass) -> Modifier;
fn glass_effect_with(
self,
glass: Glass,
dynamics: impl Fn() -> GlassDynamics + 'static,
) -> Modifier;
}
impl LiquidModifierExt for Modifier {
fn glass_effect(self, glass: Glass) -> Modifier {
self.glass_effect_with(glass, GlassDynamics::default)
}
fn glass_effect_with(
self,
glass: Glass,
dynamics: impl Fn() -> GlassDynamics + 'static,
) -> Modifier {
let colors = crate::theme::liquid_colors();
let resolved = Rc::new(glass.resolve(&colors));
let shape = resolved.shape;
let mut modifier = self;
if resolved.shadow {
let shadow_color = resolved.shadow_color;
let (radius, offset_y, spread) = (
resolved.shadow_radius,
resolved.shadow_offset_y,
resolved.shadow_spread,
);
modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
scope.radius = radius;
scope.spread = spread;
scope.offset.y = offset_y;
scope.color = shadow_color;
scope.cutout = true;
});
}
let layer_resolved = Rc::clone(&resolved);
let clip = resolved.clip;
modifier.graphics_layer(move || GraphicsLayer {
backdrop_effect: Some(layer_resolved.backdrop_effect(current_density(), dynamics())),
shape: shape.layer_shape(),
clip,
..Default::default()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn light_colors() -> LiquidColors {
LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
}
#[test]
fn regular_glass_resolves_frosted_defaults() {
let resolved = Glass::regular().resolve(&light_colors());
assert!(resolved.blur_radius_dp > 5.0, "regular material is frosted");
assert!(resolved.saturation > 1.3, "regular material is vibrant");
assert!(resolved.lift > 0.0, "light scheme lifts toward white");
}
#[test]
fn clear_glass_is_barely_frosted() {
let resolved = Glass::clear().resolve(&light_colors());
assert!(resolved.blur_radius_dp < 5.0);
assert!(resolved.saturation < 1.3);
}
#[test]
fn dark_scheme_sinks_lift_and_tint() {
let dark = LiquidColors::dark(Color::from_rgb_u8(0, 122, 255));
let resolved = Glass::regular().resolve(&dark);
assert!(resolved.lift < 0.0, "dark scheme sinks toward black");
assert_eq!(resolved.tint, dark.glass_tint);
}
#[test]
fn explicit_tint_overrides_theme() {
let tint = Color::from_rgba_u8(0, 122, 255, 60);
let resolved = Glass::regular().tint(tint).resolve(&light_colors());
assert_eq!(resolved.tint, tint);
}
#[test]
fn cover_mode_effect_chains_blur_then_lens() {
let resolved = Glass::regular().resolve(&light_colors());
let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
let RenderEffect::Chain { first, second } = effect else {
panic!("regular glass must chain blur → lens");
};
assert!(matches!(*first, RenderEffect::Blur { .. }));
let RenderEffect::Shader { shader } = *second else {
panic!("second stage must be the lens shader");
};
let uniforms = shader.uniforms();
assert_eq!(uniforms[0], 0.0, "cover mode zeroes the container size");
assert_eq!(uniforms[6], CAPSULE_SHADER_RADIUS, "capsule sentinel");
assert!(shader.input_padding() > 0.0);
}
#[test]
fn morph_glass_packs_dp_geometry_with_node_size_container() {
let resolved = Glass::lens().resolve(&light_colors());
let dynamics = GlassDynamics {
morph: Some(GlassMorph {
node_size: (78.0, 59.0),
primary: (39.0, 29.5, 58.0, 39.0, -1.0),
shapes: vec![(100.0, 29.5, 44.0, 44.0, -1.0)],
glue: 12.0,
..Default::default()
}),
..Default::default()
};
let effect = resolved.backdrop_effect(2.0, dynamics);
let RenderEffect::Shader { shader } = effect else {
panic!("lens glass must be a bare shader (no frost blur)");
};
let uniforms = shader.uniforms();
assert_eq!(&uniforms[0..2], &[78.0, 59.0], "container = node size dp");
assert_eq!(&uniforms[2..6], &[39.0, 29.5, 58.0, 39.0], "primary dp");
assert_eq!(uniforms[6], -1.0, "capsule sentinel unscaled");
assert_eq!(uniforms[31], 12.0, "glue dp");
assert_eq!(&uniforms[36..40], &[100.0, 29.5, 44.0, 44.0], "shape dp");
assert!(
shader.output_padding() > 0.0,
"morph glass declares output padding"
);
}
#[test]
fn clear_variant_skips_heavy_blur() {
let resolved = Glass::clear().blur_radius(0.0).resolve(&light_colors());
let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
assert!(matches!(effect, RenderEffect::Shader { .. }));
}
#[test]
fn rounded_rect_radius_scales_with_density() {
let resolved = Glass::regular()
.shape(LiquidShape::RoundedRect(16.0))
.resolve(&light_colors());
let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
let RenderEffect::Chain { second, .. } = effect else {
panic!("expected chain");
};
let RenderEffect::Shader { shader } = *second else {
panic!("expected lens");
};
assert_eq!(shader.uniforms()[6], 32.0, "16dp at 2x density = 32px");
}
}