1use crate::color::Color;
2use crate::palette::Palette;
3use crate::resolved::ResolvedPalette;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum ContrastLevel {
8 AaNormal,
10 AaLarge,
12 AaaNormal,
14 AaaLarge,
16}
17
18impl ContrastLevel {
19 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 pub fn passes(self, ratio: f64) -> bool {
30 ratio >= self.threshold()
31 }
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct ContrastViolation {
37 pub foreground_label: Box<str>,
39 pub background_label: Box<str>,
41 pub foreground: Color,
43 pub background: Color,
45 pub ratio: f64,
47 pub level: ContrastLevel,
49}
50
51pub fn contrast_ratio(fg: &Color, bg: &Color) -> f64 {
53 contrast_ratio_with_lum(fg.relative_luminance(), bg.relative_luminance())
54}
55
56fn 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
65pub fn meets_level(fg: &Color, bg: &Color, level: ContrastLevel) -> bool {
67 level.passes(contrast_ratio(fg, bg))
68}
69
70impl Color {
71 pub fn contrast_ratio(&self, other: &Color) -> f64 {
73 contrast_ratio(self, other)
74 }
75
76 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
109macro_rules! for_each_static_pair {
114 ($callback:ident ! ($($ctx:tt)*)) => {
115 $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 $callback!($($ctx)*, base.foreground, surface.focus);
122 $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 $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 $callback!($($ctx)*, typography.comment, base.background);
133 $callback!($($ctx)*, typography.line_number, base.background);
134 };
135}
136
137pub 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 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 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
193pub fn nudge_foreground(fg: Color, bg: Color, level: ContrastLevel) -> Color {
199 nudge_foreground_with_bg_lum(fg, bg.relative_luminance(), level)
200}
201
202pub(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 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 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 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
271pub 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 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}