Skip to main content

native_theme_gpui/
derive.rs

1//! Shade derivation helpers for status/chart colors and fallbacks.
2//!
3//! For widget-specific hover/active states (button, list, link), `colors.rs`
4//! reads directly from the resolved theme. This module is retained for:
5//! - `hover_color` / `active_color`: status color hover/active pairs (danger,
6//!   success, warning, info) and primary button states
7//! - `active_color`: link active fallback
8//! - `light_variant`: chart `_light` color derivation
9//! - `contrast_ratio` / `ensure_status_contrast`: WCAG enforcement
10//!
11//! Uses the [`Colorize`] trait from gpui-component for lightness adjustments.
12
13use gpui::Hsla;
14use gpui_component::Colorize;
15
16/// Derive a hover state from a base color.
17///
18/// Blends the background with the base at 90% opacity, producing a color
19/// slightly closer to the background. Works identically for light and dark themes.
20///
21/// This matches gpui-component's internal `apply_config` hover derivation:
22/// `background.blend(base.opacity(0.9))`.
23pub fn hover_color(base: Hsla, bg: Hsla) -> Hsla {
24    bg.blend(base.opacity(0.9))
25}
26
27/// Derive an active/pressed state from a base color.
28///
29/// For light themes (is_dark=false): darkens by 10%.
30/// For dark themes (is_dark=true): darkens by 20%.
31///
32/// When the base color is very dark (lightness < 0.15), darkening produces
33/// near-invisible feedback. In that case we lighten instead, ensuring the
34/// active state is always visually distinct from the base.
35///
36/// The 20% factor matches gpui-component's internal `apply_config` derivation
37/// and provides sufficient contrast shift without overshooting on mid-range colors.
38/// Includes a near-black safety net.
39pub fn active_color(base: Hsla, is_dark: bool) -> Hsla {
40    let factor = if is_dark { 0.2 } else { 0.1 };
41    // Near-black colors have no room to darken -- lighten instead
42    // so the pressed state is visible.
43    if base.l < 0.15 {
44        base.lighten(factor)
45    } else {
46        base.darken(factor)
47    }
48}
49
50/// Compute the WCAG 2.1 relative luminance contrast ratio between two colors.
51///
52/// Returns a value in [1.0, 21.0]. Ratios below 4.5 indicate insufficient
53/// contrast for normal text (AA), below 3.0 for large text.
54pub fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
55    let la = relative_luminance(a);
56    let lb = relative_luminance(b);
57    let (lighter, darker) = if la > lb { (la, lb) } else { (lb, la) };
58    (lighter + 0.05) / (darker + 0.05)
59}
60
61/// WCAG 2.1 relative luminance from an Hsla color.
62fn relative_luminance(c: Hsla) -> f32 {
63    let rgba: gpui::Rgba = c.into();
64    let linearize = |v: f32| -> f32 {
65        let v = v.clamp(0.0, 1.0);
66        if v <= 0.04045 {
67            v / 12.92
68        } else {
69            ((v + 0.055) / 1.055).powf(2.4)
70        }
71    };
72    0.2126 * linearize(rgba.r) + 0.7152 * linearize(rgba.g) + 0.0722 * linearize(rgba.b)
73}
74
75/// Light variant of a base color, mode-aware.
76///
77/// For dark themes: increases lightness (because the background is dark,
78/// a tinted background must be lighter than the pure color).
79/// For light themes: blends toward the background (gpui-component convention).
80pub fn light_variant(bg: Hsla, color: Hsla, is_dark: bool) -> Hsla {
81    if is_dark {
82        Hsla {
83            l: (color.l + 0.15).min(0.95),
84            ..color
85        }
86    } else {
87        bg.blend(color.opacity(0.8))
88    }
89}
90
91#[cfg(test)]
92#[allow(clippy::unwrap_used, clippy::expect_used)]
93mod tests {
94    use super::*;
95    use gpui::hsla;
96
97    #[test]
98    fn hover_color_differs_from_base() {
99        let base = hsla(0.6, 0.7, 0.5, 1.0);
100        let bg = hsla(0.0, 0.0, 1.0, 1.0); // white background
101        let result = hover_color(base, bg);
102        // hover blends base at 0.9 opacity on bg -- should differ from base
103        assert_ne!(result, base, "hover should differ from base");
104    }
105
106    #[test]
107    fn active_color_light_theme_darkens() {
108        let base = hsla(0.6, 0.7, 0.5, 1.0);
109        let result = active_color(base, false);
110        assert!(
111            result.l < base.l,
112            "active (light) l={} should be < base l={}",
113            result.l,
114            base.l
115        );
116    }
117
118    #[test]
119    fn active_color_dark_theme_darkens_more() {
120        let base = hsla(0.6, 0.7, 0.5, 1.0);
121        let light_result = active_color(base, false);
122        let dark_result = active_color(base, true);
123        assert!(
124            dark_result.l < light_result.l,
125            "dark active l={} should darken more than light active l={}",
126            dark_result.l,
127            light_result.l
128        );
129    }
130
131    // Issue 52: near-black colors should lighten instead of darken
132    #[test]
133    fn active_color_near_black_lightens() {
134        let near_black = hsla(0.6, 0.7, 0.05, 1.0);
135        let result = active_color(near_black, true);
136        assert!(
137            result.l > near_black.l,
138            "near-black active l={} should be > base l={} (lighten, not darken)",
139            result.l,
140            near_black.l
141        );
142    }
143
144    #[test]
145    fn active_color_near_black_light_mode_also_lightens() {
146        let near_black = hsla(0.3, 0.5, 0.10, 1.0);
147        let result = active_color(near_black, false);
148        assert!(
149            result.l > near_black.l,
150            "near-black active (light) l={} should be > base l={}",
151            result.l,
152            near_black.l
153        );
154    }
155
156    #[test]
157    fn contrast_ratio_black_white() {
158        let black = hsla(0.0, 0.0, 0.0, 1.0);
159        let white = hsla(0.0, 0.0, 1.0, 1.0);
160        let ratio = contrast_ratio(black, white);
161        assert!(
162            ratio > 20.0,
163            "black/white contrast should be ~21, got {}",
164            ratio
165        );
166    }
167
168    #[test]
169    fn contrast_ratio_same_color_is_one() {
170        let c = hsla(0.5, 0.5, 0.5, 1.0);
171        let ratio = contrast_ratio(c, c);
172        assert!(
173            (ratio - 1.0).abs() < 0.01,
174            "same-color contrast should be 1.0, got {}",
175            ratio
176        );
177    }
178
179    #[test]
180    fn light_variant_dark_theme_increases_lightness() {
181        let bg = hsla(0.0, 0.0, 0.1, 1.0);
182        let color = hsla(0.0, 0.8, 0.4, 1.0);
183        let result = light_variant(bg, color, true);
184        assert!(
185            result.l > color.l,
186            "dark theme light_variant l={} should be > base l={}",
187            result.l,
188            color.l
189        );
190    }
191
192    #[test]
193    fn light_variant_light_theme_blends_toward_bg() {
194        let bg = hsla(0.0, 0.0, 0.95, 1.0);
195        let color = hsla(0.0, 0.8, 0.4, 1.0);
196        let result = light_variant(bg, color, false);
197        // Should be closer to bg than the original
198        assert!(
199            result.l > color.l,
200            "light theme light_variant l={} should be > base l={}",
201            result.l,
202            color.l
203        );
204    }
205
206    // Issue 67: hover_color near white boundary
207    #[test]
208    fn hover_color_near_white() {
209        let near_white = hsla(0.6, 0.5, 0.95, 1.0);
210        let bg = hsla(0.0, 0.0, 1.0, 1.0); // white background
211        let result = hover_color(near_white, bg);
212        assert_ne!(
213            result, near_white,
214            "hover of near-white color should still differ from input"
215        );
216    }
217
218    // Issue 67: active_color just above the near-black threshold should darken
219    #[test]
220    fn active_color_near_boundary() {
221        // l=0.16 is just above the 0.15 threshold — should darken, not lighten
222        let base = hsla(0.6, 0.7, 0.16, 1.0);
223        let result = active_color(base, true);
224        assert!(
225            result.l < base.l,
226            "l=0.16 (above 0.15 threshold) active l={} should darken (< base l={})",
227            result.l,
228            base.l
229        );
230    }
231
232    // Issue 67: hover_color with zero saturation should still produce a different color
233    #[test]
234    fn hover_color_zero_saturation() {
235        let gray = hsla(0.0, 0.0, 0.5, 1.0); // pure gray
236        let bg = hsla(0.0, 0.0, 0.1, 1.0); // dark background
237        let result = hover_color(gray, bg);
238        assert_ne!(
239            result, gray,
240            "hover of zero-saturation gray should still differ from input"
241        );
242    }
243
244    // Issue 67: hover_color with fully transparent base (alpha=0.0)
245    #[test]
246    fn hover_color_transparent_base() {
247        let transparent = hsla(0.5, 0.5, 0.5, 0.0);
248        let bg = hsla(0.0, 0.0, 1.0, 1.0);
249        let result = hover_color(transparent, bg);
250        // Blending a 0-alpha color at 0.9 opacity onto bg should produce
251        // a color very close to bg (the transparent base contributes nothing).
252        assert!(
253            (result.l - bg.l).abs() < 0.05,
254            "hover of transparent base l={} should be close to bg l={}",
255            result.l,
256            bg.l
257        );
258    }
259
260    // Issue 67: active_color with pure black (l=0.0)
261    // Documents a Colorize trait limitation: lighten(0.2) on l=0.0 stays 0.0
262    // because the multiplication `l * (1 + factor) = 0`. The near-black safety
263    // net works for l > 0 but not exactly zero. This is acceptable since pure
264    // black rarely occurs in real themes.
265    #[test]
266    fn active_color_pure_black_stays_black() {
267        let pure_black = hsla(0.0, 0.0, 0.0, 1.0);
268        let result = active_color(pure_black, true);
269        // l=0.0 enters the lighten path but multiplicative lighten can't
270        // increase zero — result stays at l=0.0.
271        assert!(
272            (result.l - 0.0).abs() < f32::EPSILON,
273            "pure black active l={} stays at 0.0 (Colorize limitation)",
274            result.l,
275        );
276    }
277
278    #[test]
279    fn light_variant_clamped_to_095() {
280        let bg = hsla(0.0, 0.0, 0.1, 1.0);
281        let bright = hsla(0.5, 0.5, 0.9, 1.0);
282        let result = light_variant(bg, bright, true);
283        assert!(
284            result.l <= 0.95,
285            "light_variant should clamp to 0.95, got {}",
286            result.l
287        );
288    }
289}