Skip to main content

native_theme_iced/
extended.rs

1//! Extended palette overrides from [`native_theme::ResolvedThemeVariant`] fields.
2//!
3//! After iced generates an `Extended` palette from the base `Palette`,
4//! this module overrides specific sub-palette entries with native-theme
5//! values. All fields are guaranteed populated in ResolvedThemeVariant, so
6//! overrides are always applied unconditionally.
7
8use crate::palette::to_color;
9use native_theme::Rgba;
10
11/// Captured color values for Extended palette overrides.
12///
13/// Holds the `Rgba` values extracted from `ResolvedThemeVariant` that
14/// `to_theme()` captures into its closure. Using a struct instead of
15/// individual parameters keeps the API clean.
16///
17/// The `success_bg`, `danger_bg`, and `warning_bg` fields are needed for
18/// WCAG contrast enforcement: we check each status foreground against its
19/// corresponding background to ensure 4.5:1 contrast.
20#[derive(Clone, Copy)]
21pub(crate) struct OverrideColors {
22    pub btn_bg: Rgba,
23    pub btn_fg: Rgba,
24    pub surface: Rgba,
25    pub foreground: Rgba,
26    pub accent_fg: Rgba,
27    pub success_fg: Rgba,
28    pub danger_fg: Rgba,
29    pub warning_fg: Rgba,
30    pub success_bg: Rgba,
31    pub danger_bg: Rgba,
32    pub warning_bg: Rgba,
33}
34
35/// Minimum WCAG contrast ratio for status foreground against its background.
36/// 4.5:1 is AA for normal text.
37const MIN_STATUS_CONTRAST: f32 = 4.5;
38
39/// WCAG 2.1 relative luminance from an iced Color.
40///
41/// Uses sRGB linearization and ITU-R BT.709 coefficients, matching the
42/// algorithm in the gpui connector's `derive::relative_luminance()`.
43fn relative_luminance(c: iced_core::Color) -> f32 {
44    let linearize = |v: f32| -> f32 {
45        let v = v.clamp(0.0, 1.0);
46        if v <= 0.04045 {
47            v / 12.92
48        } else {
49            ((v + 0.055) / 1.055).powf(2.4)
50        }
51    };
52    0.2126 * linearize(c.r) + 0.7152 * linearize(c.g) + 0.0722 * linearize(c.b)
53}
54
55/// Compute the WCAG 2.1 contrast ratio between two colors.
56///
57/// Returns a value in [1.0, 21.0]. Ratios below 4.5 indicate insufficient
58/// contrast for normal text (AA), below 3.0 for large text.
59fn contrast_ratio(a: iced_core::Color, b: iced_core::Color) -> f32 {
60    let la = relative_luminance(a);
61    let lb = relative_luminance(b);
62    let (lighter, darker) = if la > lb { (la, lb) } else { (lb, la) };
63    (lighter + 0.05) / (darker + 0.05)
64}
65
66/// Ensure a status foreground color has sufficient contrast against its background.
67///
68/// If the foreground has less than 4.5:1 contrast against the background,
69/// falls back to white (for dark backgrounds) or black (for light backgrounds).
70///
71/// Uses `relative_luminance(bg) < 0.5` instead of HSL lightness because iced
72/// `Color` has no `.l` field, and luminance is more perceptually accurate for
73/// determining whether a background is "dark" or "light".
74fn ensure_status_contrast(fg: iced_core::Color, bg: iced_core::Color) -> iced_core::Color {
75    if contrast_ratio(fg, bg) >= MIN_STATUS_CONTRAST {
76        fg
77    } else if relative_luminance(bg) < 0.5 {
78        iced_core::Color::WHITE
79    } else {
80        iced_core::Color::BLACK
81    }
82}
83
84/// Override auto-generated Extended palette entries with resolved theme fields.
85///
86/// Always applies these overrides (all fields guaranteed populated):
87/// - `secondary.base.color` <- button background
88/// - `secondary.base.text` <- button foreground
89/// - `background.weak.color` <- surface color
90/// - `background.weak.text` <- foreground text color
91/// - `primary.base.text` <- accent foreground (text on accent bg)
92/// - `success.base.text` <- success foreground (text on success bg, contrast-enforced)
93/// - `danger.base.text` <- danger foreground (text on danger bg, contrast-enforced)
94/// - `warning.base.text` <- warning foreground (text on warning bg, contrast-enforced)
95///
96/// Status foreground colors (success, danger, warning) are passed through
97/// WCAG AA contrast enforcement: if the foreground has less than 4.5:1 contrast
98/// against its status background, it falls back to white or black.
99///
100/// Note: `.base.color` overrides for primary/success/danger/warning are
101/// redundant because `Extended::generate()` already sets them correctly
102/// from the base palette. Only the `.base.text` fields need overriding
103/// because the auto-generation uses `defaults.text_color` instead of the
104/// per-status foreground colors.
105pub(crate) fn apply_overrides(
106    extended: &mut iced_core::theme::palette::Extended,
107    colors: &OverrideColors,
108) {
109    extended.secondary.base.color = to_color(colors.btn_bg);
110    extended.secondary.base.text = to_color(colors.btn_fg);
111    extended.background.weak.color = to_color(colors.surface);
112    extended.background.weak.text = to_color(colors.foreground);
113    extended.primary.base.text = to_color(colors.accent_fg);
114    extended.success.base.text =
115        ensure_status_contrast(to_color(colors.success_fg), to_color(colors.success_bg));
116    extended.danger.base.text =
117        ensure_status_contrast(to_color(colors.danger_fg), to_color(colors.danger_bg));
118    extended.warning.base.text =
119        ensure_status_contrast(to_color(colors.warning_fg), to_color(colors.warning_bg));
120}
121
122#[cfg(test)]
123#[allow(clippy::unwrap_used, clippy::expect_used)]
124mod tests {
125    use super::*;
126    use iced_core::theme::palette::Extended;
127    use native_theme::ThemeSpec;
128
129    fn make_extended() -> Extended {
130        let palette = iced_core::theme::Palette::DARK;
131        Extended::generate(palette)
132    }
133
134    fn make_resolved_preset(name: &str, is_dark: bool) -> native_theme::ResolvedThemeVariant {
135        ThemeSpec::preset(name)
136            .unwrap()
137            .into_variant(is_dark)
138            .unwrap()
139            .into_resolved()
140            .unwrap()
141    }
142
143    fn make_resolved(is_dark: bool) -> native_theme::ResolvedThemeVariant {
144        make_resolved_preset("catppuccin-mocha", is_dark)
145    }
146
147    fn colors_from_resolved(r: &native_theme::ResolvedThemeVariant) -> OverrideColors {
148        OverrideColors {
149            btn_bg: r.button.background_color,
150            btn_fg: r.button.font.color,
151            surface: r.defaults.surface_color,
152            foreground: r.defaults.text_color,
153            accent_fg: r.defaults.accent_text_color,
154            success_fg: r.defaults.success_text_color,
155            danger_fg: r.defaults.danger_text_color,
156            warning_fg: r.defaults.warning_text_color,
157            success_bg: r.defaults.success_color,
158            danger_bg: r.defaults.danger_color,
159            warning_bg: r.defaults.warning_color,
160        }
161    }
162
163    fn apply_from_resolved(ext: &mut Extended, r: &native_theme::ResolvedThemeVariant) {
164        apply_overrides(ext, &colors_from_resolved(r));
165    }
166
167    #[test]
168    fn apply_overrides_sets_secondary_base_color() {
169        let mut extended = make_extended();
170        let resolved = make_resolved(false);
171
172        apply_from_resolved(&mut extended, &resolved);
173
174        let expected = to_color(resolved.button.background_color);
175        assert_eq!(
176            extended.secondary.base.color, expected,
177            "secondary.base.color should match resolved.button.background"
178        );
179    }
180
181    #[test]
182    fn apply_overrides_sets_secondary_base_text() {
183        let mut extended = make_extended();
184        let resolved = make_resolved(false);
185
186        apply_from_resolved(&mut extended, &resolved);
187
188        let expected = to_color(resolved.button.font.color);
189        assert_eq!(
190            extended.secondary.base.text, expected,
191            "secondary.base.text should match resolved.button.foreground"
192        );
193    }
194
195    #[test]
196    fn apply_overrides_sets_background_weak_color() {
197        let mut extended = make_extended();
198        let resolved = make_resolved(false);
199
200        apply_from_resolved(&mut extended, &resolved);
201
202        let expected = to_color(resolved.defaults.surface_color);
203        assert_eq!(
204            extended.background.weak.color, expected,
205            "background.weak.color should match resolved.defaults.surface"
206        );
207    }
208
209    #[test]
210    fn apply_overrides_sets_background_weak_text() {
211        let mut extended = make_extended();
212        let resolved = make_resolved(false);
213
214        apply_from_resolved(&mut extended, &resolved);
215
216        let expected = to_color(resolved.defaults.text_color);
217        assert_eq!(
218            extended.background.weak.text, expected,
219            "background.weak.text should match resolved.defaults.text_color"
220        );
221    }
222
223    #[test]
224    fn apply_overrides_sets_primary_base_text() {
225        let mut extended = make_extended();
226        let resolved = make_resolved(false);
227
228        apply_from_resolved(&mut extended, &resolved);
229
230        let expected = to_color(resolved.defaults.accent_text_color);
231        assert_eq!(
232            extended.primary.base.text, expected,
233            "primary.base.text should match resolved.defaults.accent_foreground"
234        );
235    }
236
237    #[test]
238    fn apply_overrides_sets_success_base_text() {
239        let mut extended = make_extended();
240        let resolved = make_resolved(false);
241
242        apply_from_resolved(&mut extended, &resolved);
243
244        let expected = super::ensure_status_contrast(
245            to_color(resolved.defaults.success_text_color),
246            to_color(resolved.defaults.success_color),
247        );
248        assert_eq!(
249            extended.success.base.text, expected,
250            "success.base.text should match contrast-enforced success foreground"
251        );
252    }
253
254    #[test]
255    fn apply_overrides_sets_danger_base_text() {
256        let mut extended = make_extended();
257        let resolved = make_resolved(false);
258
259        apply_from_resolved(&mut extended, &resolved);
260
261        // The raw danger foreground may be contrast-corrected if it has
262        // insufficient contrast against the danger background. Compute
263        // the expected value through the same enforcement path.
264        let expected = super::ensure_status_contrast(
265            to_color(resolved.defaults.danger_text_color),
266            to_color(resolved.defaults.danger_color),
267        );
268        assert_eq!(
269            extended.danger.base.text, expected,
270            "danger.base.text should match contrast-enforced danger foreground"
271        );
272    }
273
274    #[test]
275    fn apply_overrides_sets_warning_base_text() {
276        let mut extended = make_extended();
277        let resolved = make_resolved(false);
278
279        apply_from_resolved(&mut extended, &resolved);
280
281        let expected = super::ensure_status_contrast(
282            to_color(resolved.defaults.warning_text_color),
283            to_color(resolved.defaults.warning_color),
284        );
285        assert_eq!(
286            extended.warning.base.text, expected,
287            "warning.base.text should match contrast-enforced warning foreground"
288        );
289    }
290
291    #[test]
292    fn apply_overrides_dark_variant() {
293        let mut extended = make_extended();
294        let resolved = make_resolved(true);
295
296        apply_from_resolved(&mut extended, &resolved);
297
298        let expected = to_color(resolved.button.background_color);
299        assert_eq!(
300            extended.secondary.base.color, expected,
301            "dark variant: secondary.base.color should match"
302        );
303    }
304
305    #[test]
306    fn apply_overrides_multiple_presets() {
307        for name in ["catppuccin-mocha", "dracula", "nord"] {
308            let resolved = ThemeSpec::preset(name)
309                .unwrap()
310                .into_variant(true)
311                .unwrap()
312                .into_resolved()
313                .unwrap();
314            let mut extended = make_extended();
315            apply_from_resolved(&mut extended, &resolved);
316
317            assert_eq!(
318                extended.secondary.base.color,
319                to_color(resolved.button.background_color),
320                "{name}: secondary.base.color mismatch"
321            );
322        }
323    }
324
325    #[test]
326    fn apply_overrides_with_adwaita() {
327        let resolved = make_resolved_preset("adwaita", false);
328        let mut extended = make_extended();
329        apply_from_resolved(&mut extended, &resolved);
330
331        assert_eq!(
332            extended.secondary.base.color,
333            to_color(resolved.button.background_color),
334            "adwaita: secondary.base.color mismatch"
335        );
336        assert_eq!(
337            extended.primary.base.text,
338            to_color(resolved.defaults.accent_text_color),
339            "adwaita: primary.base.text mismatch"
340        );
341    }
342
343    #[test]
344    fn ensure_status_contrast_corrects_low_contrast() {
345        // Dark background with dark foreground = low contrast
346        let dark_bg = iced_core::Color::from_rgb(0.1, 0.1, 0.1);
347        let dark_fg = iced_core::Color::from_rgb(0.15, 0.15, 0.15);
348        let result = super::ensure_status_contrast(dark_fg, dark_bg);
349        // Should fall back to white since bg is dark
350        assert_eq!(result, iced_core::Color::WHITE);
351
352        // Light background with light foreground = low contrast
353        let light_bg = iced_core::Color::from_rgb(0.9, 0.9, 0.9);
354        let light_fg = iced_core::Color::from_rgb(0.85, 0.85, 0.85);
355        let result = super::ensure_status_contrast(light_fg, light_bg);
356        // Should fall back to black since bg is light
357        assert_eq!(result, iced_core::Color::BLACK);
358    }
359
360    #[test]
361    fn ensure_status_contrast_preserves_sufficient() {
362        let bg = iced_core::Color::from_rgb(0.1, 0.1, 0.1);
363        let fg = iced_core::Color::WHITE;
364        let result = super::ensure_status_contrast(fg, bg);
365        assert_eq!(result, fg, "sufficient contrast should preserve original");
366    }
367
368    #[test]
369    fn contrast_ratio_black_white() {
370        let ratio = super::contrast_ratio(iced_core::Color::BLACK, iced_core::Color::WHITE);
371        assert!(
372            ratio > 20.0,
373            "black/white contrast should be ~21, got {ratio}"
374        );
375    }
376}