Skip to main content

gpui_kit_material/tokens/
state.rs

1//! M3 state layers and disabled emphasis, resolved over the container so a
2//! selected control keeps its color under hover and press.
3//!
4//! Reference priority is Figma kit > md3.io > m3e.kyralabs.dev. The Figma
5//! Material 3 Design Kit defines the layer opacities as its `M3` variables
6//! `State Layers/<role>/Opacity-08|10|16` (file key ozddDB2Z1v51f1IFZlUuJ2),
7//! used by its Buttons page as hover 08, focus 10 and a pressed ripple at 10;
8//! disabled content is on-surface under a 0.38 layer and disabled button
9//! containers are `State Layers/On Surface/Opacity-10`. Fixture:
10//! `tests/fixtures/figma-sys-tokens.json`. md3.io and m3e
11//! (`tests/fixtures/m3e-sys-tokens.json`) agree. [`DISABLED_CONTAINER`] is the
12//! classic 12% default for components whose kit page does not override it
13//! (buttons use 10%).
14use gpui::Rgba;
15
16pub const HOVER: f32 = 0.08;
17pub const FOCUS: f32 = 0.10;
18pub const PRESS: f32 = 0.10;
19pub const DRAG: f32 = 0.16;
20/// Disabled containers and content: on-surface at 12% and 38%.
21pub const DISABLED_CONTAINER: f32 = 0.12;
22pub const DISABLED_CONTENT: f32 = 0.38;
23/// The text selection highlight: primary at 40% (Compose Material 3
24/// `TextSelectionColors.kt`, `TextSelectionBackgroundOpacity = 0.4f`).
25pub const TEXT_SELECTION: f32 = 0.40;
26
27/// Composite a state layer of `content` at `opacity` over `container`
28/// (source-over alpha compositing).
29///
30/// This is the one state-layer recipe for every component. The layer's
31/// opacity multiplies `content`'s own alpha, and RGB is weighted by alpha,
32/// so a layer over a transparent container keeps its own hue instead of
33/// darkening toward the container's stored RGB.
34pub fn layer(container: Rgba, content: Rgba, opacity: f32) -> Rgba {
35    let top = content.a * opacity.clamp(0., 1.);
36    let a = top + container.a * (1. - top);
37    if a <= 0. {
38        return Rgba {
39            r: 0.,
40            g: 0.,
41            b: 0.,
42            a: 0.,
43        };
44    }
45    let c = |x: f32, y: f32| (x * top + y * container.a * (1. - top)) / a;
46    Rgba {
47        r: c(content.r, container.r),
48        g: c(content.g, container.g),
49        b: c(content.b, container.b),
50        a,
51    }
52}
53
54/// `container` under a hover layer of its `content` color.
55pub fn hovered(container: Rgba, content: Rgba) -> Rgba {
56    layer(container, content, HOVER)
57}
58
59/// `container` under a press layer of its `content` color.
60pub fn pressed(container: Rgba, content: Rgba) -> Rgba {
61    layer(container, content, PRESS)
62}
63
64/// `color` at disabled content emphasis. The emphasis multiplies the
65/// color's own alpha, so an already translucent role stays proportionally
66/// fainter.
67pub fn disabled(color: Rgba) -> Rgba {
68    Rgba {
69        a: color.a * DISABLED_CONTENT,
70        ..color
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn rgba(r: f32, g: f32, b: f32, a: f32) -> Rgba {
79        Rgba { r, g, b, a }
80    }
81
82    fn close(left: Rgba, right: Rgba) -> bool {
83        [
84            (left.r, right.r),
85            (left.g, right.g),
86            (left.b, right.b),
87            (left.a, right.a),
88        ]
89        .into_iter()
90        .all(|(x, y)| (x - y).abs() < 1e-6)
91    }
92
93    #[test]
94    fn layer_opacities_match_the_figma_kit() {
95        let fixture: serde_json::Value =
96            serde_json::from_str(include_str!("../../tests/fixtures/figma-sys-tokens.json"))
97                .unwrap();
98        let reference = &fixture["state_layer_opacity"];
99        let defined = reference["defined_opacities"].as_array().unwrap();
100        for (opacity, name) in [
101            (HOVER, "hover"),
102            (FOCUS, "focus"),
103            (PRESS, "pressed"),
104            (DRAG, "dragged"),
105        ] {
106            assert_eq!(opacity, reference[name].as_f64().unwrap() as f32, "{name}");
107            assert!(
108                defined
109                    .iter()
110                    .any(|v| v.as_f64().unwrap() as f32 == opacity),
111                "{name} is one of the kit's State Layers opacities"
112            );
113        }
114        assert_eq!(
115            DISABLED_CONTENT,
116            fixture["disabled"]["content_opacity"].as_f64().unwrap() as f32
117        );
118    }
119
120    #[test]
121    fn disabled_multiplies_alpha() {
122        assert_eq!(disabled(rgba(1., 0., 0., 1.)).a, DISABLED_CONTENT);
123        assert_eq!(disabled(rgba(1., 0., 0., 0.5)).a, 0.5 * DISABLED_CONTENT);
124        assert_eq!(disabled(rgba(1., 0., 0., 0.)).a, 0.);
125    }
126
127    #[test]
128    fn layer_over_an_opaque_container_is_a_linear_mix() {
129        let container = rgba(1., 1., 1., 1.);
130        let content = rgba(0., 0., 0., 1.);
131        assert!(close(
132            hovered(container, content),
133            rgba(0.92, 0.92, 0.92, 1.)
134        ));
135        assert!(close(pressed(container, content), rgba(0.9, 0.9, 0.9, 1.)));
136    }
137
138    #[test]
139    fn layer_over_a_transparent_container_keeps_its_hue() {
140        let content = rgba(0.2, 0.4, 0.6, 1.);
141        let result = layer(rgba(0., 0., 0., 0.), content, HOVER);
142        assert!(close(result, rgba(0.2, 0.4, 0.6, HOVER)));
143        assert_eq!(layer(rgba(0., 0., 0., 0.), content, 0.).a, 0.);
144    }
145
146    #[test]
147    fn layer_opacity_multiplies_content_alpha() {
148        let result = layer(rgba(0., 0., 0., 0.), rgba(1., 1., 1., 0.5), PRESS);
149        assert!((result.a - 0.05).abs() < 1e-6);
150    }
151}