479 lines · 14.2 KB · Rust
RawDownload
use gpui::Hsla; /// APCA (Accessible Perceptual Contrast Algorithm) constants/// Based on APCA 0.0.98G-4g W3 compatible constants/// https://github.com/Myndex/apca-w3struct APCAConstants {    // Main TRC exponent for monitor perception    main_trc: f32,     // sRGB coefficients    s_rco: f32,    s_gco: f32,    s_bco: f32,     // G-4g constants for use with 2.4 exponent    norm_bg: f32,    norm_txt: f32,    rev_txt: f32,    rev_bg: f32,     // G-4g Clamps and Scalers    blk_thrs: f32,    blk_clmp: f32,    scale_bow: f32,    scale_wob: f32,    lo_bow_offset: f32,    lo_wob_offset: f32,    delta_y_min: f32,    lo_clip: f32,} impl Default for APCAConstants {    fn default() -> Self {        Self {            main_trc: 2.4,            s_rco: 0.2126729,            s_gco: 0.7151522,            s_bco: 0.0721750,            norm_bg: 0.56,            norm_txt: 0.57,            rev_txt: 0.62,            rev_bg: 0.65,            blk_thrs: 0.022,            blk_clmp: 1.414,            scale_bow: 1.14,            scale_wob: 1.14,            lo_bow_offset: 0.027,            lo_wob_offset: 0.027,            delta_y_min: 0.0005,            lo_clip: 0.1,        }    }} /// Calculates the perceptual lightness contrast using APCA./// Returns a value between approximately -108 and 106./// Negative values indicate light text on dark background./// Positive values indicate dark text on light background.////// The APCA algorithm is more perceptually accurate than WCAG 2.x,/// especially for dark mode interfaces. Key improvements include:/// - Better accuracy for dark backgrounds/// - Polarity-aware (direction matters)/// - Perceptually uniform across the range////// Common APCA Lc thresholds per ARC Bronze Simple Mode:/// https://readtech.org/ARC/tests/bronze-simple-mode//// - Lc 45: Minimum for large fluent text (36px+)/// - Lc 60: Minimum for other content text/// - Lc 75: Minimum for body text/// - Lc 90: Preferred for body text////// Most terminal themes use colors with APCA values of 40-70.////// https://github.com/Myndex/apca-w3pub fn apca_contrast(text_color: Hsla, background_color: Hsla) -> f32 {    let constants = APCAConstants::default();     let text_y = srgb_to_y(text_color, &constants);    let bg_y = srgb_to_y(background_color, &constants);     // Apply soft clamp to near-black colors    let text_y_clamped = if text_y > constants.blk_thrs {        text_y    } else {        text_y + (constants.blk_thrs - text_y).powf(constants.blk_clmp)    };     let bg_y_clamped = if bg_y > constants.blk_thrs {        bg_y    } else {        bg_y + (constants.blk_thrs - bg_y).powf(constants.blk_clmp)    };     // Return 0 for extremely low delta Y    if (bg_y_clamped - text_y_clamped).abs() < constants.delta_y_min {        return 0.0;    }     let sapc;    let output_contrast;     if bg_y_clamped > text_y_clamped {        // Normal polarity: dark text on light background        sapc = (bg_y_clamped.powf(constants.norm_bg) - text_y_clamped.powf(constants.norm_txt))            * constants.scale_bow;         // Low contrast smooth rollout to prevent polarity reversal        output_contrast = if sapc < constants.lo_clip {            0.0        } else {            sapc - constants.lo_bow_offset        };    } else {        // Reverse polarity: light text on dark background        sapc = (bg_y_clamped.powf(constants.rev_bg) - text_y_clamped.powf(constants.rev_txt))            * constants.scale_wob;         output_contrast = if sapc > -constants.lo_clip {            0.0        } else {            sapc + constants.lo_wob_offset        };    }     // Return Lc (lightness contrast) scaled to percentage    output_contrast * 100.0} /// Converts sRGB color to Y (luminance) for APCA calculationfn srgb_to_y(color: Hsla, constants: &APCAConstants) -> f32 {    let rgba = color.to_rgb();     // Linearize and apply coefficients    let r_linear = (rgba.r).powf(constants.main_trc);    let g_linear = (rgba.g).powf(constants.main_trc);    let b_linear = (rgba.b).powf(constants.main_trc);     constants.s_rco * r_linear + constants.s_gco * g_linear + constants.s_bco * b_linear} /// Adjusts the foreground color to meet the minimum APCA contrast against the background./// The minimum_apca_contrast should be an absolute value (e.g., 75 for Lc 75).////// This implementation gradually adjusts the lightness while preserving the hue and/// saturation as much as possible, only falling back to black/white when necessary.pub fn ensure_minimum_contrast(    foreground: Hsla,    background: Hsla,    minimum_apca_contrast: f32,) -> Hsla {    if minimum_apca_contrast <= 0.0 {        return foreground;    }     let current_contrast = apca_contrast(foreground, background).abs();     if current_contrast >= minimum_apca_contrast {        return foreground;    }     // First, try to adjust lightness while preserving hue and saturation    let adjusted = adjust_lightness_for_contrast(foreground, background, minimum_apca_contrast);     let adjusted_contrast = apca_contrast(adjusted, background).abs();    if adjusted_contrast >= minimum_apca_contrast {        return adjusted;    }     // If that's not enough, gradually reduce saturation while adjusting lightness    let desaturated =        adjust_lightness_and_saturation_for_contrast(foreground, background, minimum_apca_contrast);     let desaturated_contrast = apca_contrast(desaturated, background).abs();    if desaturated_contrast >= minimum_apca_contrast {        return desaturated;    }     // Last resort: use black or white    let black = Hsla {        h: 0.0,        s: 0.0,        l: 0.0,        a: foreground.a,    };     let white = Hsla {        h: 0.0,        s: 0.0,        l: 1.0,        a: foreground.a,    };     let black_contrast = apca_contrast(black, background).abs();    let white_contrast = apca_contrast(white, background).abs();     if white_contrast > black_contrast {        white    } else {        black    }} /// Adjusts only the lightness to meet the minimum contrast, preserving hue and saturationfn adjust_lightness_for_contrast(    foreground: Hsla,    background: Hsla,    minimum_apca_contrast: f32,) -> Hsla {    // Determine if we need to go lighter or darker    let bg_luminance = srgb_to_y(background, &APCAConstants::default());    let should_go_darker = bg_luminance > 0.5;     // Binary search for the optimal lightness    let mut low = if should_go_darker { 0.0 } else { foreground.l };    let mut high = if should_go_darker { foreground.l } else { 1.0 };    let mut best_l = foreground.l;     for _ in 0..20 {        let mid = (low + high) / 2.0;        let test_color = Hsla {            h: foreground.h,            s: foreground.s,            l: mid,            a: foreground.a,        };         let contrast = apca_contrast(test_color, background).abs();         if contrast >= minimum_apca_contrast {            best_l = mid;            // Try to get closer to the minimum            if should_go_darker {                low = mid;            } else {                high = mid;            }        } else if should_go_darker {            high = mid;        } else {            low = mid;        }         // If we're close enough to the target, stop        if (contrast - minimum_apca_contrast).abs() < 1.0 {            best_l = mid;            break;        }    }     Hsla {        h: foreground.h,        s: foreground.s,        l: best_l,        a: foreground.a,    }} /// Adjusts both lightness and saturation to meet the minimum contrastfn adjust_lightness_and_saturation_for_contrast(    foreground: Hsla,    background: Hsla,    minimum_apca_contrast: f32,) -> Hsla {    // Try different saturation levels    let saturation_steps = [1.0, 0.8, 0.6, 0.4, 0.2, 0.0];     for &sat_multiplier in &saturation_steps {        let test_color = Hsla {            h: foreground.h,            s: foreground.s * sat_multiplier,            l: foreground.l,            a: foreground.a,        };         let adjusted = adjust_lightness_for_contrast(test_color, background, minimum_apca_contrast);        let contrast = apca_contrast(adjusted, background).abs();         if contrast >= minimum_apca_contrast {            return adjusted;        }    }     // If we get here, even grayscale didn't work, so return the grayscale attempt    Hsla {        h: foreground.h,        s: 0.0,        l: foreground.l,        a: foreground.a,    }} #[cfg(test)]mod tests {    use super::*;     fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla {        Hsla { h, s, l, a }    }     fn hsla_from_hex(hex: u32) -> Hsla {        let r = ((hex >> 16) & 0xFF) as f32 / 255.0;        let g = ((hex >> 8) & 0xFF) as f32 / 255.0;        let b = (hex & 0xFF) as f32 / 255.0;         let max = r.max(g).max(b);        let min = r.min(g).min(b);        let l = (max + min) / 2.0;         if max == min {            // Achromatic            Hsla {                h: 0.0,                s: 0.0,                l,                a: 1.0,            }        } else {            let d = max - min;            let s = if l > 0.5 {                d / (2.0 - max - min)            } else {                d / (max + min)            };             let h = if max == r {                (g - b) / d + if g < b { 6.0 } else { 0.0 }            } else if max == g {                (b - r) / d + 2.0            } else {                (r - g) / d + 4.0            } / 6.0;             Hsla { h, s, l, a: 1.0 }        }    }     #[test]    fn test_apca_contrast() {        // Test black text on white background (should be positive)        let black = hsla(0.0, 0.0, 0.0, 1.0);        let white = hsla(0.0, 0.0, 1.0, 1.0);        let contrast = apca_contrast(black, white);        assert!(            contrast > 100.0,            "Black on white should have high positive contrast, got {}",            contrast        );         // Test white text on black background (should be negative)        let contrast_reversed = apca_contrast(white, black);        assert!(            contrast_reversed < -100.0,            "White on black should have high negative contrast, got {}",            contrast_reversed        );         // Same color should have zero contrast        let gray = hsla(0.0, 0.0, 0.5, 1.0);        let contrast_same = apca_contrast(gray, gray);        assert!(            contrast_same.abs() < 1.0,            "Same color should have near-zero contrast, got {}",            contrast_same        );         // APCA is NOT commutative - polarity matters        assert!(            (contrast + contrast_reversed).abs() > 1.0,            "APCA should not be commutative"        );    }     #[test]    fn test_srgb_to_y() {        let constants = APCAConstants::default();         // Test known Y values        let black = hsla(0.0, 0.0, 0.0, 1.0);        let y_black = srgb_to_y(black, &constants);        assert!(            y_black.abs() < 0.001,            "Black should have Y near 0, got {}",            y_black        );         let white = hsla(0.0, 0.0, 1.0, 1.0);        let y_white = srgb_to_y(white, &constants);        assert!(            (y_white - 1.0).abs() < 0.001,            "White should have Y near 1, got {}",            y_white        );    }     #[test]    fn test_srgb_to_y_nan_issue() {        let dark_red = hsla_from_hex(0x5f0000);        let y_dark_red = srgb_to_y(dark_red, &APCAConstants::default());        assert!(!y_dark_red.is_nan());    }     #[test]    fn test_ensure_minimum_contrast() {        let white_bg = hsla(0.0, 0.0, 1.0, 1.0);        let light_gray = hsla(0.0, 0.0, 0.9, 1.0);         // Light gray on white has poor contrast        let initial_contrast = apca_contrast(light_gray, white_bg).abs();        assert!(            initial_contrast < 15.0,            "Initial contrast should be low, got {}",            initial_contrast        );         // Should be adjusted to black for better contrast (using APCA Lc 45 as minimum)        let adjusted = ensure_minimum_contrast(light_gray, white_bg, 45.0);        assert_eq!(adjusted.l, 0.0); // Should be black        assert_eq!(adjusted.a, light_gray.a); // Alpha preserved         // Test with dark background        let black_bg = hsla(0.0, 0.0, 0.0, 1.0);        let dark_gray = hsla(0.0, 0.0, 0.1, 1.0);         // Dark gray on black has poor contrast        let initial_contrast = apca_contrast(dark_gray, black_bg).abs();        assert!(            initial_contrast < 15.0,            "Initial contrast should be low, got {}",            initial_contrast        );         // Should be adjusted to white for better contrast        let adjusted = ensure_minimum_contrast(dark_gray, black_bg, 45.0);        assert_eq!(adjusted.l, 1.0); // Should be white         // Test when contrast is already sufficient        let black = hsla(0.0, 0.0, 0.0, 1.0);        let adjusted = ensure_minimum_contrast(black, white_bg, 45.0);        assert_eq!(adjusted, black); // Should remain unchanged    }     #[test]    fn test_one_light_theme_exact_colors() {        // Test with exact colors from One Light theme        // terminal.background and terminal.ansi.white are both #fafafaff        let fafafa = hsla_from_hex(0xfafafa);         // They should be identical        let bg = fafafa;        let fg = fafafa;         // Contrast should be 0 (no contrast)        let contrast = apca_contrast(fg, bg);        assert!(            contrast.abs() < 1.0,            "Same color should have near-zero APCA contrast, got {}",            contrast        );         // With minimum APCA contrast of 15 (very low, but detectable), it should adjust        let adjusted = ensure_minimum_contrast(fg, bg, 15.0);        // The new algorithm preserves colors, so we just need to check contrast        let new_contrast = apca_contrast(adjusted, bg).abs();        assert!(            new_contrast >= 15.0,            "Adjusted contrast {} should be >= 15.0",            new_contrast        );         // The adjusted color should have sufficient contrast        let new_contrast = apca_contrast(adjusted, bg).abs();        assert!(            new_contrast >= 15.0,            "Adjusted APCA contrast {} should be >= 15.0",            new_contrast        );    }}