Skip to main content

tessera_components/
shadow.rs

1//! Shadow layer definitions for Material surfaces.
2//!
3//! ## Usage
4//!
5//! Define ambient and spot shadow layers for surfaces.
6
7use tessera_ui::{Color, Dp};
8
9use crate::theme::MaterialColorScheme;
10
11/// A single shadow layer (ambient or spot).
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct ShadowLayer {
14    /// Color of the shadow (RGBA).
15    pub color: Color,
16    /// Offset of the shadow in the format [x, y].
17    pub offset: [f32; 2],
18    /// Smoothness / blur radius in pixels.
19    pub smoothness: f32,
20}
21
22/// Collection of shadow layers (ambient + spot).
23#[derive(Debug, Clone, Copy, PartialEq, Default)]
24pub struct ShadowLayers {
25    /// Ambient (diffused) shadow layer.
26    pub ambient: Option<ShadowLayer>,
27    /// Spot (directional / offset) shadow layer.
28    pub spot: Option<ShadowLayer>,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq)]
32struct ElevationGeometry {
33    key_y: f64,
34    key_blur: f64,
35    ambient_y: f64,
36    ambient_blur: f64,
37    spread: f64,
38}
39
40fn elevation_geometry(elevation: Dp) -> ElevationGeometry {
41    const LEVELS: [(f64, [f64; 5]); 6] = [
42        (0.0, [0.0, 0.0, 0.0, 0.0, 0.0]),
43        (1.0, [1.0, 2.0, 1.0, 3.0, 1.0]),
44        (3.0, [1.0, 2.0, 2.0, 6.0, 2.0]),
45        (6.0, [1.0, 3.0, 4.0, 8.0, 3.0]),
46        (8.0, [2.0, 3.0, 6.0, 10.0, 4.0]),
47        (12.0, [4.0, 4.0, 8.0, 12.0, 6.0]),
48    ];
49    let elevation = if elevation.0.is_nan() {
50        0.0
51    } else {
52        elevation.0.clamp(0.0, 12.0)
53    };
54    let mut values = LEVELS[5].1;
55    for pair in LEVELS.windows(2) {
56        let [(start, from), (end, to)] = pair else {
57            unreachable!()
58        };
59        if elevation <= *end {
60            let fraction = (elevation - start) / (end - start);
61            values =
62                std::array::from_fn(|index| from[index] + (to[index] - from[index]) * fraction);
63            break;
64        }
65    }
66    ElevationGeometry {
67        key_y: values[0],
68        key_blur: values[1],
69        ambient_y: values[2],
70        ambient_blur: values[3],
71        spread: values[4],
72    }
73}
74
75pub(crate) fn material_shadow(elevation: Dp, scheme: &MaterialColorScheme) -> (ShadowLayers, f32) {
76    let geometry = elevation_geometry(elevation);
77    if geometry.key_blur == 0.0 {
78        return (ShadowLayers::default(), 0.0);
79    }
80    // CSS blur uses sigma = blur / 2; the GPU kernel radius is three sigma.
81    let layer = |y: f64, blur: f64, alpha| ShadowLayer {
82        color: scheme.shadow.with_alpha(alpha),
83        offset: [0.0, Dp(y).to_pixels_f32()],
84        smoothness: Dp(blur * 1.5).to_pixels_f32(),
85    };
86    (
87        ShadowLayers {
88            ambient: Some(layer(geometry.ambient_y, geometry.ambient_blur, 0.15)),
89            spot: Some(layer(geometry.key_y, geometry.key_blur, 0.3)),
90        },
91        Dp(geometry.spread).to_pixels_f32(),
92    )
93}
94
95#[cfg(test)]
96mod tests {
97    use tessera_ui::Dp;
98
99    use crate::theme::MaterialColorScheme;
100
101    use super::{elevation_geometry, material_shadow};
102
103    #[test]
104    fn menu_elevation_converts_blur_and_spread_without_losing_density() {
105        let (layers, spread) = material_shadow(Dp(3.0), &MaterialColorScheme::default());
106        let pixel = Dp(1.0).to_pixels_f32();
107        let key = layers.spot.expect("key shadow");
108        let ambient = layers.ambient.expect("ambient shadow");
109        assert_eq!(key.offset, [0.0, pixel]);
110        assert_eq!(key.smoothness, 3.0 * pixel);
111        assert_eq!(key.color.a, 0.3);
112        assert_eq!(ambient.offset, [0.0, 2.0 * pixel]);
113        assert_eq!(ambient.smoothness, 9.0 * pixel);
114        assert_eq!(ambient.color.a, 0.15);
115        assert_eq!(spread, 2.0 * pixel);
116        let (zero, spread) = material_shadow(Dp::ZERO, &MaterialColorScheme::default());
117        assert!(zero.ambient.is_none() && zero.spot.is_none());
118        assert_eq!(spread, 0.0);
119    }
120
121    #[test]
122    fn elevation_interpolates_and_clamps_inputs() {
123        let midpoint = elevation_geometry(Dp(2.0));
124        assert_eq!(midpoint.ambient_y, 1.5);
125        assert_eq!(midpoint.ambient_blur, 4.5);
126        assert_eq!(midpoint.spread, 1.5);
127        assert_eq!(elevation_geometry(Dp(-1.0)), elevation_geometry(Dp::ZERO));
128        assert_eq!(
129            elevation_geometry(Dp(f64::NAN)),
130            elevation_geometry(Dp::ZERO)
131        );
132        assert_eq!(
133            elevation_geometry(Dp(f64::INFINITY)),
134            elevation_geometry(Dp(12.0))
135        );
136    }
137}