apca_contrast.rs

  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}