Skip to main content

palette_core/
contrast.rs

1use crate::color::Color;
2use crate::palette::Palette;
3use crate::resolved::ResolvedPalette;
4
5/// WCAG 2.1 conformance level for contrast checking.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum ContrastLevel {
8    /// AA for normal text (≥ 4.5:1).
9    AaNormal,
10    /// AA for large text (≥ 3.0:1).
11    AaLarge,
12    /// AAA for normal text (≥ 7.0:1).
13    AaaNormal,
14    /// AAA for large text (≥ 4.5:1).
15    AaaLarge,
16}
17
18impl ContrastLevel {
19    /// Minimum contrast ratio required for this level.
20    pub fn threshold(self) -> f64 {
21        match self {
22            Self::AaNormal | Self::AaaLarge => 4.5,
23            Self::AaLarge => 3.0,
24            Self::AaaNormal => 7.0,
25        }
26    }
27
28    /// Whether the given ratio meets this conformance level.
29    pub fn passes(self, ratio: f64) -> bool {
30        ratio >= self.threshold()
31    }
32}
33
34/// A foreground/background pair that fails a contrast check.
35#[derive(Debug, Clone, PartialEq)]
36pub struct ContrastViolation {
37    /// Dot-path label of the foreground slot (e.g. `"base.foreground"`).
38    pub foreground_label: Box<str>,
39    /// Dot-path label of the background slot (e.g. `"base.background"`).
40    pub background_label: Box<str>,
41    /// The foreground color that was tested.
42    pub foreground: Color,
43    /// The background color that was tested.
44    pub background: Color,
45    /// Measured contrast ratio.
46    pub ratio: f64,
47    /// The conformance level that was not met.
48    pub level: ContrastLevel,
49}
50
51/// WCAG 2.1 contrast ratio between two colors. Returns `[1.0, 21.0]`.
52pub fn contrast_ratio(fg: &Color, bg: &Color) -> f64 {
53    contrast_ratio_with_lum(fg.relative_luminance(), bg.relative_luminance())
54}
55
56/// Contrast ratio from pre-computed relative luminance values.
57fn contrast_ratio_with_lum(l_fg: f64, l_bg: f64) -> f64 {
58    let (lighter, darker) = match l_fg >= l_bg {
59        true => (l_fg, l_bg),
60        false => (l_bg, l_fg),
61    };
62    (lighter + 0.05) / (darker + 0.05)
63}
64
65/// Whether `fg` over `bg` meets the given [`ContrastLevel`].
66pub fn meets_level(fg: &Color, bg: &Color, level: ContrastLevel) -> bool {
67    level.passes(contrast_ratio(fg, bg))
68}
69
70impl Color {
71    /// WCAG 2.1 contrast ratio against another color.
72    pub fn contrast_ratio(&self, other: &Color) -> f64 {
73        contrast_ratio(self, other)
74    }
75
76    /// Whether contrast against `other` meets the given [`ContrastLevel`].
77    pub fn meets_level(&self, other: &Color, level: ContrastLevel) -> bool {
78        meets_level(self, other, level)
79    }
80}
81
82fn check_pair(
83    fg_prefix: &str,
84    fg_name: &str,
85    bg_prefix: &str,
86    bg_name: &str,
87    fg: Option<&Color>,
88    bg: Option<&Color>,
89    level: ContrastLevel,
90) -> Option<ContrastViolation> {
91    let (fg_color, bg_color) = match (fg, bg) {
92        (Some(f), Some(b)) => (*f, *b),
93        _ => return None,
94    };
95    let ratio = contrast_ratio(&fg_color, &bg_color);
96    match level.passes(ratio) {
97        true => None,
98        false => Some(ContrastViolation {
99            foreground_label: format!("{fg_prefix}.{fg_name}").into_boxed_str(),
100            background_label: format!("{bg_prefix}.{bg_name}").into_boxed_str(),
101            foreground: fg_color,
102            background: bg_color,
103            ratio,
104            level,
105        }),
106    }
107}
108
109/// Single source of truth for static foreground/background contrast pairs.
110///
111/// Semantic and syntax slots use dynamic iteration (`populated_slots` /
112/// `all_slots_mut`) and are handled separately in each consumer.
113macro_rules! for_each_static_pair {
114    ($callback:ident ! ($($ctx:tt)*)) => {
115        // Core readability
116        $callback!($($ctx)*, base.foreground, base.background);
117        $callback!($($ctx)*, base.foreground_dark, base.background);
118        $callback!($($ctx)*, base.foreground, base.background_dark);
119        $callback!($($ctx)*, base.foreground, base.background_highlight);
120        // Focus surface
121        $callback!($($ctx)*, base.foreground, surface.focus);
122        // Editor pairs
123        $callback!($($ctx)*, editor.selection_fg, editor.selection_bg);
124        $callback!($($ctx)*, editor.inlay_hint_fg, editor.inlay_hint_bg);
125        $callback!($($ctx)*, editor.search_fg, editor.search_bg);
126        $callback!($($ctx)*, editor.cursor_text, editor.cursor);
127        // Diff pairs
128        $callback!($($ctx)*, diff.added_fg, diff.added_bg);
129        $callback!($($ctx)*, diff.modified_fg, diff.modified_bg);
130        $callback!($($ctx)*, diff.removed_fg, diff.removed_bg);
131        // Typography over background
132        $callback!($($ctx)*, typography.comment, base.background);
133        $callback!($($ctx)*, typography.line_number, base.background);
134    };
135}
136
137/// Check all semantically paired slots in a palette for contrast violations.
138///
139/// Returns an empty slice when every tested pair meets the given level.
140pub fn validate_palette(palette: &Palette, level: ContrastLevel) -> Box<[ContrastViolation]> {
141    let mut violations = Vec::with_capacity(16);
142    let mut push = |v: Option<ContrastViolation>| {
143        if let Some(v) = v {
144            violations.push(v);
145        }
146    };
147
148    macro_rules! validate_static_pair {
149        ($palette:ident, $level:ident, $fg_section:ident . $fg_field:ident, $bg_section:ident . $bg_field:ident) => {
150            push(check_pair(
151                stringify!($fg_section),
152                stringify!($fg_field),
153                stringify!($bg_section),
154                stringify!($bg_field),
155                $palette.$fg_section.$fg_field.as_ref(),
156                $palette.$bg_section.$bg_field.as_ref(),
157                $level,
158            ));
159        };
160    }
161
162    for_each_static_pair!(validate_static_pair!(palette, level));
163
164    // Semantic over background (dynamic iteration)
165    for (name, color) in palette.semantic.populated_slots() {
166        push(check_pair(
167            "semantic",
168            name,
169            "base",
170            "background",
171            Some(color),
172            palette.base.background.as_ref(),
173            level,
174        ));
175    }
176
177    // Syntax over background (dynamic iteration)
178    for (name, color) in palette.syntax.populated_slots() {
179        push(check_pair(
180            "syntax",
181            name,
182            "base",
183            "background",
184            Some(color),
185            palette.base.background.as_ref(),
186            level,
187        ));
188    }
189
190    violations.into_boxed_slice()
191}
192
193/// Nudge a foreground color's lightness until it meets the given contrast level
194/// against `bg`. Returns `fg` unchanged if the pair already passes or if no
195/// lightness adjustment can reach the target.
196///
197/// Only HSL lightness is modified; hue and saturation are preserved.
198pub fn nudge_foreground(fg: Color, bg: Color, level: ContrastLevel) -> Color {
199    nudge_foreground_with_bg_lum(fg, bg.relative_luminance(), level)
200}
201
202/// Like [`nudge_foreground`] but accepts a pre-computed background luminance,
203/// avoiding redundant calls to `relative_luminance()` in hot loops.
204pub(crate) fn nudge_foreground_with_bg_lum(fg: Color, bg_lum: f64, level: ContrastLevel) -> Color {
205    let threshold = level.threshold();
206    let fg_lum = fg.relative_luminance();
207    match contrast_ratio_with_lum(fg_lum, bg_lum) >= threshold {
208        true => return fg,
209        false => {}
210    }
211
212    // Try the natural direction first: lighter fg if fg is lighter, darker otherwise.
213    let primary_lighten = fg_lum >= bg_lum;
214    match nudge_direction(fg, bg_lum, threshold, primary_lighten) {
215        Some(result) => result,
216        None => nudge_direction(fg, bg_lum, threshold, !primary_lighten).unwrap_or(fg),
217    }
218}
219
220fn nudge_direction(fg: Color, bg_lum: f64, threshold: f64, lighten: bool) -> Option<Color> {
221    use crate::manipulation::{Hsl, hsl_to_rgb, rgb_to_hsl};
222
223    let base_hsl = rgb_to_hsl(fg);
224
225    // HSL lightness extremes: full white (1.0) or full black (0.0).
226    let extreme_l = match lighten {
227        true => 1.0,
228        false => 0.0,
229    };
230    let extreme = hsl_to_rgb(Hsl {
231        h: base_hsl.h,
232        s: base_hsl.s,
233        l: extreme_l,
234    });
235    match contrast_ratio_with_lum(extreme.relative_luminance(), bg_lum) >= threshold {
236        true => {}
237        false => return None,
238    }
239
240    let mut lo: f64 = 0.0;
241    let mut hi: f64 = 1.0;
242    let mut best = extreme;
243
244    // Binary search for the minimal lightness shift that meets the threshold.
245    // HSL h/s and bg luminance are invariant across iterations.
246    for _ in 0..20 {
247        if hi - lo < 1e-4 {
248            break;
249        }
250        let mid = (lo + hi) / 2.0;
251        let shifted_l = match lighten {
252            true => (base_hsl.l + mid).clamp(0.0, 1.0),
253            false => (base_hsl.l - mid).clamp(0.0, 1.0),
254        };
255        let candidate = hsl_to_rgb(Hsl {
256            h: base_hsl.h,
257            s: base_hsl.s,
258            l: shifted_l,
259        });
260        match contrast_ratio_with_lum(candidate.relative_luminance(), bg_lum) >= threshold {
261            true => {
262                best = candidate;
263                hi = mid;
264            }
265            false => lo = mid,
266        }
267    }
268    Some(best)
269}
270
271/// Adjust all semantically paired foreground slots on a resolved palette so
272/// they meet the given contrast level. Mirrors the pairs checked by
273/// [`validate_palette`].
274pub fn adjust_contrast(resolved: &mut ResolvedPalette, level: ContrastLevel) {
275    macro_rules! adjust_static_pair {
276        ($resolved:ident, $level:ident, $fg_section:ident . $fg_field:ident, $bg_section:ident . $bg_field:ident) => {
277            $resolved.$fg_section.$fg_field = nudge_foreground(
278                $resolved.$fg_section.$fg_field,
279                $resolved.$bg_section.$bg_field,
280                $level,
281            );
282        };
283    }
284
285    for_each_static_pair!(adjust_static_pair!(resolved, level));
286
287    // Semantic and syntax over background — cache bg luminance once.
288    let bg_lum = resolved.base.background.relative_luminance();
289    for (_, slot) in resolved.semantic.all_slots_mut() {
290        *slot = nudge_foreground_with_bg_lum(*slot, bg_lum, level);
291    }
292    for (_, slot) in resolved.syntax.all_slots_mut() {
293        *slot = nudge_foreground_with_bg_lum(*slot, bg_lum, level);
294    }
295}