1use gpui::Hsla;
2
3/// APCA (Accessible Perceptual Contrast Algorithm) constants
4/// Based on APCA 0.0.98G-4g W3 compatible constants
5/// https://github.com/Myndex/apca-w3
6struct APCAConstants {
7 // Main TRC exponent for monitor perception
8 main_trc: f32,
9
10 // sRGB coefficients
11 s_rco: f32,
12 s_gco: f32,
13 s_bco: f32,
14
15 // G-4g constants for use with 2.4 exponent
16 norm_bg: f32,
17 norm_txt: f32,
18 rev_txt: f32,
19 rev_bg: f32,
20
21 // G-4g Clamps and Scalers
22 blk_thrs: f32,
23 blk_clmp: f32,
24 scale_bow: f32,
25 scale_wob: f32,
26 lo_bow_offset: f32,
27 lo_wob_offset: f32,
28 delta_y_min: f32,
29 lo_clip: f32,
30}
31
32impl Default for APCAConstants {
33 fn default() -> Self {
34 Self {
35 main_trc: 2.4,
36 s_rco: 0.2126729,
37 s_gco: 0.7151522,
38 s_bco: 0.0721750,
39 norm_bg: 0.56,
40 norm_txt: 0.57,
41 rev_txt: 0.62,
42 rev_bg: 0.65,
43 blk_thrs: 0.022,
44 blk_clmp: 1.414,
45 scale_bow: 1.14,
46 scale_wob: 1.14,
47 lo_bow_offset: 0.027,
48 lo_wob_offset: 0.027,
49 delta_y_min: 0.0005,
50 lo_clip: 0.1,
51 }
52 }
53}
54
55/// Calculates the perceptual lightness contrast using APCA.
56/// Returns a value between approximately -108 and 106.
57/// Negative values indicate light text on dark background.
58/// Positive values indicate dark text on light background.
59///
60/// The APCA algorithm is more perceptually accurate than WCAG 2.x,
61/// especially for dark mode interfaces. Key improvements include:
62/// - Better accuracy for dark backgrounds
63/// - Polarity-aware (direction matters)
64/// - Perceptually uniform across the range
65///
66/// Common APCA Lc thresholds per ARC Bronze Simple Mode:
67/// https://readtech.org/ARC/tests/bronze-simple-mode/
68/// - Lc 45: Minimum for large fluent text (36px+)
69/// - Lc 60: Minimum for other content text
70/// - Lc 75: Minimum for body text
71/// - Lc 90: Preferred for body text
72///
73/// Most terminal themes use colors with APCA values of 40-70.
74///
75/// https://github.com/Myndex/apca-w3
76pub fn apca_contrast(text_color: Hsla, background_color: Hsla) -> f32 {
77 let constants = APCAConstants::default();
78
79 let text_y = srgb_to_y(text_color, &constants);
80 let bg_y = srgb_to_y(background_color, &constants);
81
82 // Apply soft clamp to near-black colors
83 let text_y_clamped = if text_y > constants.blk_thrs {
84 text_y
85 } else {
86 text_y + (constants.blk_thrs - text_y).powf(constants.blk_clmp)
87 };
88
89 let bg_y_clamped = if bg_y > constants.blk_thrs {
90 bg_y
91 } else {
92 bg_y + (constants.blk_thrs - bg_y).powf(constants.blk_clmp)
93 };
94
95 // Return 0 for extremely low delta Y
96 if (bg_y_clamped - text_y_clamped).abs() < constants.delta_y_min {
97 return 0.0;
98 }
99
100 let sapc;
101 let output_contrast;
102
103 if bg_y_clamped > text_y_clamped {
104 // Normal polarity: dark text on light background
105 sapc = (bg_y_clamped.powf(constants.norm_bg) - text_y_clamped.powf(constants.norm_txt))
106 * constants.scale_bow;
107
108 // Low contrast smooth rollout to prevent polarity reversal
109 output_contrast = if sapc < constants.lo_clip {
110 0.0
111 } else {
112 sapc - constants.lo_bow_offset
113 };
114 } else {
115 // Reverse polarity: light text on dark background
116 sapc = (bg_y_clamped.powf(constants.rev_bg) - text_y_clamped.powf(constants.rev_txt))
117 * constants.scale_wob;
118
119 output_contrast = if sapc > -constants.lo_clip {
120 0.0
121 } else {
122 sapc + constants.lo_wob_offset
123 };
124 }
125
126 // Return Lc (lightness contrast) scaled to percentage
127 output_contrast * 100.0
128}
129
130/// Converts sRGB color to Y (luminance) for APCA calculation
131fn srgb_to_y(color: Hsla, constants: &APCAConstants) -> f32 {
132 let rgba = color.to_rgb();
133
134 // Linearize and apply coefficients
135 let r_linear = (rgba.r).powf(constants.main_trc);
136 let g_linear = (rgba.g).powf(constants.main_trc);
137 let b_linear = (rgba.b).powf(constants.main_trc);
138
139 constants.s_rco * r_linear + constants.s_gco * g_linear + constants.s_bco * b_linear
140}
141
142/// Adjusts the foreground color to meet the minimum APCA contrast against the background.
143/// The minimum_apca_contrast should be an absolute value (e.g., 75 for Lc 75).
144///
145/// This implementation gradually adjusts the lightness while preserving the hue and
146/// saturation as much as possible, only falling back to black/white when necessary.
147pub fn ensure_minimum_contrast(
148 foreground: Hsla,
149 background: Hsla,
150 minimum_apca_contrast: f32,
151) -> Hsla {
152 if minimum_apca_contrast <= 0.0 {
153 return foreground;
154 }
155
156 let current_contrast = apca_contrast(foreground, background).abs();
157
158 if current_contrast >= minimum_apca_contrast {
159 return foreground;
160 }
161
162 // First, try to adjust lightness while preserving hue and saturation
163 let adjusted = adjust_lightness_for_contrast(foreground, background, minimum_apca_contrast);
164
165 let adjusted_contrast = apca_contrast(adjusted, background).abs();
166 if adjusted_contrast >= minimum_apca_contrast {
167 return adjusted;
168 }
169
170 // If that's not enough, gradually reduce saturation while adjusting lightness
171 let desaturated =
172 adjust_lightness_and_saturation_for_contrast(foreground, background, minimum_apca_contrast);
173
174 let desaturated_contrast = apca_contrast(desaturated, background).abs();
175 if desaturated_contrast >= minimum_apca_contrast {
176 return desaturated;
177 }
178
179 // Last resort: use black or white
180 let black = Hsla {
181 h: 0.0,
182 s: 0.0,
183 l: 0.0,
184 a: foreground.a,
185 };
186
187 let white = Hsla {
188 h: 0.0,
189 s: 0.0,
190 l: 1.0,
191 a: foreground.a,
192 };
193
194 let black_contrast = apca_contrast(black, background).abs();
195 let white_contrast = apca_contrast(white, background).abs();
196
197 if white_contrast > black_contrast {
198 white
199 } else {
200 black
201 }
202}
203
204/// Adjusts only the lightness to meet the minimum contrast, preserving hue and saturation
205fn adjust_lightness_for_contrast(
206 foreground: Hsla,
207 background: Hsla,
208 minimum_apca_contrast: f32,
209) -> Hsla {
210 // Determine if we need to go lighter or darker
211 let bg_luminance = srgb_to_y(background, &APCAConstants::default());
212 let should_go_darker = bg_luminance > 0.5;
213
214 // Binary search for the optimal lightness
215 let mut low = if should_go_darker { 0.0 } else { foreground.l };
216 let mut high = if should_go_darker { foreground.l } else { 1.0 };
217 let mut best_l = foreground.l;
218
219 for _ in 0..20 {
220 let mid = (low + high) / 2.0;
221 let test_color = Hsla {
222 h: foreground.h,
223 s: foreground.s,
224 l: mid,
225 a: foreground.a,
226 };
227
228 let contrast = apca_contrast(test_color, background).abs();
229
230 if contrast >= minimum_apca_contrast {
231 best_l = mid;
232 // Try to get closer to the minimum
233 if should_go_darker {
234 low = mid;
235 } else {
236 high = mid;
237 }
238 } else if should_go_darker {
239 high = mid;
240 } else {
241 low = mid;
242 }
243
244 // If we're close enough to the target, stop
245 if (contrast - minimum_apca_contrast).abs() < 1.0 {
246 best_l = mid;
247 break;
248 }
249 }
250
251 Hsla {
252 h: foreground.h,
253 s: foreground.s,
254 l: best_l,
255 a: foreground.a,
256 }
257}
258
259/// Adjusts both lightness and saturation to meet the minimum contrast
260fn adjust_lightness_and_saturation_for_contrast(
261 foreground: Hsla,
262 background: Hsla,
263 minimum_apca_contrast: f32,
264) -> Hsla {
265 // Try different saturation levels
266 let saturation_steps = [1.0, 0.8, 0.6, 0.4, 0.2, 0.0];
267
268 for &sat_multiplier in &saturation_steps {
269 let test_color = Hsla {
270 h: foreground.h,
271 s: foreground.s * sat_multiplier,
272 l: foreground.l,
273 a: foreground.a,
274 };
275
276 let adjusted = adjust_lightness_for_contrast(test_color, background, minimum_apca_contrast);
277 let contrast = apca_contrast(adjusted, background).abs();
278
279 if contrast >= minimum_apca_contrast {
280 return adjusted;
281 }
282 }
283
284 // If we get here, even grayscale didn't work, so return the grayscale attempt
285 Hsla {
286 h: foreground.h,
287 s: 0.0,
288 l: foreground.l,
289 a: foreground.a,
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla {
298 Hsla { h, s, l, a }
299 }
300
301 fn hsla_from_hex(hex: u32) -> Hsla {
302 let r = ((hex >> 16) & 0xFF) as f32 / 255.0;
303 let g = ((hex >> 8) & 0xFF) as f32 / 255.0;
304 let b = (hex & 0xFF) as f32 / 255.0;
305
306 let max = r.max(g).max(b);
307 let min = r.min(g).min(b);
308 let l = (max + min) / 2.0;
309
310 if max == min {
311 // Achromatic
312 Hsla {
313 h: 0.0,
314 s: 0.0,
315 l,
316 a: 1.0,
317 }
318 } else {
319 let d = max - min;
320 let s = if l > 0.5 {
321 d / (2.0 - max - min)
322 } else {
323 d / (max + min)
324 };
325
326 let h = if max == r {
327 (g - b) / d + if g < b { 6.0 } else { 0.0 }
328 } else if max == g {
329 (b - r) / d + 2.0
330 } else {
331 (r - g) / d + 4.0
332 } / 6.0;
333
334 Hsla { h, s, l, a: 1.0 }
335 }
336 }
337
338 #[test]
339 fn test_apca_contrast() {
340 // Test black text on white background (should be positive)
341 let black = hsla(0.0, 0.0, 0.0, 1.0);
342 let white = hsla(0.0, 0.0, 1.0, 1.0);
343 let contrast = apca_contrast(black, white);
344 assert!(
345 contrast > 100.0,
346 "Black on white should have high positive contrast, got {}",
347 contrast
348 );
349
350 // Test white text on black background (should be negative)
351 let contrast_reversed = apca_contrast(white, black);
352 assert!(
353 contrast_reversed < -100.0,
354 "White on black should have high negative contrast, got {}",
355 contrast_reversed
356 );
357
358 // Same color should have zero contrast
359 let gray = hsla(0.0, 0.0, 0.5, 1.0);
360 let contrast_same = apca_contrast(gray, gray);
361 assert!(
362 contrast_same.abs() < 1.0,
363 "Same color should have near-zero contrast, got {}",
364 contrast_same
365 );
366
367 // APCA is NOT commutative - polarity matters
368 assert!(
369 (contrast + contrast_reversed).abs() > 1.0,
370 "APCA should not be commutative"
371 );
372 }
373
374 #[test]
375 fn test_srgb_to_y() {
376 let constants = APCAConstants::default();
377
378 // Test known Y values
379 let black = hsla(0.0, 0.0, 0.0, 1.0);
380 let y_black = srgb_to_y(black, &constants);
381 assert!(
382 y_black.abs() < 0.001,
383 "Black should have Y near 0, got {}",
384 y_black
385 );
386
387 let white = hsla(0.0, 0.0, 1.0, 1.0);
388 let y_white = srgb_to_y(white, &constants);
389 assert!(
390 (y_white - 1.0).abs() < 0.001,
391 "White should have Y near 1, got {}",
392 y_white
393 );
394 }
395
396 #[test]
397 fn test_srgb_to_y_nan_issue() {
398 let dark_red = hsla_from_hex(0x5f0000);
399 let y_dark_red = srgb_to_y(dark_red, &APCAConstants::default());
400 assert!(!y_dark_red.is_nan());
401 }
402
403 #[test]
404 fn test_ensure_minimum_contrast() {
405 let white_bg = hsla(0.0, 0.0, 1.0, 1.0);
406 let light_gray = hsla(0.0, 0.0, 0.9, 1.0);
407
408 // Light gray on white has poor contrast
409 let initial_contrast = apca_contrast(light_gray, white_bg).abs();
410 assert!(
411 initial_contrast < 15.0,
412 "Initial contrast should be low, got {}",
413 initial_contrast
414 );
415
416 // Should be adjusted to black for better contrast (using APCA Lc 45 as minimum)
417 let adjusted = ensure_minimum_contrast(light_gray, white_bg, 45.0);
418 assert_eq!(adjusted.l, 0.0); // Should be black
419 assert_eq!(adjusted.a, light_gray.a); // Alpha preserved
420
421 // Test with dark background
422 let black_bg = hsla(0.0, 0.0, 0.0, 1.0);
423 let dark_gray = hsla(0.0, 0.0, 0.1, 1.0);
424
425 // Dark gray on black has poor contrast
426 let initial_contrast = apca_contrast(dark_gray, black_bg).abs();
427 assert!(
428 initial_contrast < 15.0,
429 "Initial contrast should be low, got {}",
430 initial_contrast
431 );
432
433 // Should be adjusted to white for better contrast
434 let adjusted = ensure_minimum_contrast(dark_gray, black_bg, 45.0);
435 assert_eq!(adjusted.l, 1.0); // Should be white
436
437 // Test when contrast is already sufficient
438 let black = hsla(0.0, 0.0, 0.0, 1.0);
439 let adjusted = ensure_minimum_contrast(black, white_bg, 45.0);
440 assert_eq!(adjusted, black); // Should remain unchanged
441 }
442
443 #[test]
444 fn test_one_light_theme_exact_colors() {
445 // Test with exact colors from One Light theme
446 // terminal.background and terminal.ansi.white are both #fafafaff
447 let fafafa = hsla_from_hex(0xfafafa);
448
449 // They should be identical
450 let bg = fafafa;
451 let fg = fafafa;
452
453 // Contrast should be 0 (no contrast)
454 let contrast = apca_contrast(fg, bg);
455 assert!(
456 contrast.abs() < 1.0,
457 "Same color should have near-zero APCA contrast, got {}",
458 contrast
459 );
460
461 // With minimum APCA contrast of 15 (very low, but detectable), it should adjust
462 let adjusted = ensure_minimum_contrast(fg, bg, 15.0);
463 // The new algorithm preserves colors, so we just need to check contrast
464 let new_contrast = apca_contrast(adjusted, bg).abs();
465 assert!(
466 new_contrast >= 15.0,
467 "Adjusted contrast {} should be >= 15.0",
468 new_contrast
469 );
470
471 // The adjusted color should have sufficient contrast
472 let new_contrast = apca_contrast(adjusted, bg).abs();
473 assert!(
474 new_contrast >= 15.0,
475 "Adjusted APCA contrast {} should be >= 15.0",
476 new_contrast
477 );
478 }
479}