Move APCA contrast from terminal_view to ui utils (#36731)

In prep for using this in the editor search/select highlighting. 

Release Notes:

- N/A
This commit is contained in:
Smit Barmase 2025-08-22 10:17:37 +05:30 committed by GitHub
parent 852439452c
commit e15856a37f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 15 additions and 13 deletions

View file

@ -1,472 +0,0 @@
use gpui::Hsla;
/// APCA (Accessible Perceptual Contrast Algorithm) constants
/// Based on APCA 0.0.98G-4g W3 compatible constants
/// https://github.com/Myndex/apca-w3
struct 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-w3
pub 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 calculation
fn 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 saturation
fn 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 contrast
fn 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_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
);
}
}

View file

@ -1,4 +1,3 @@
use crate::color_contrast;
use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
use gpui::{
AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase,
@ -27,6 +26,7 @@ use terminal::{
terminal_settings::TerminalSettings,
};
use theme::{ActiveTheme, Theme, ThemeSettings};
use ui::utils::ensure_minimum_contrast;
use ui::{ParentElement, Tooltip};
use util::ResultExt;
use workspace::Workspace;
@ -534,7 +534,7 @@ impl TerminalElement {
// Only apply contrast adjustment to non-decorative characters
if !Self::is_decorative_character(indexed.c) {
fg = color_contrast::ensure_minimum_contrast(fg, bg, minimum_contrast);
fg = ensure_minimum_contrast(fg, bg, minimum_contrast);
}
// Ghostty uses (175/255) as the multiplier (~0.69), Alacritty uses 0.66, Kitty
@ -1598,6 +1598,7 @@ pub fn convert_color(fg: &terminal::alacritty_terminal::vte::ansi::Color, theme:
mod tests {
use super::*;
use gpui::{AbsoluteLength, Hsla, font};
use ui::utils::apca_contrast;
#[test]
fn test_is_decorative_character() {
@ -1713,7 +1714,7 @@ mod tests {
};
// Should have poor contrast
let actual_contrast = color_contrast::apca_contrast(white_fg, light_gray_bg).abs();
let actual_contrast = apca_contrast(white_fg, light_gray_bg).abs();
assert!(
actual_contrast < 30.0,
"White on light gray should have poor APCA contrast: {}",
@ -1721,12 +1722,12 @@ mod tests {
);
// After adjustment with minimum APCA contrast of 45, should be darker
let adjusted = color_contrast::ensure_minimum_contrast(white_fg, light_gray_bg, 45.0);
let adjusted = ensure_minimum_contrast(white_fg, light_gray_bg, 45.0);
assert!(
adjusted.l < white_fg.l,
"Adjusted color should be darker than original"
);
let adjusted_contrast = color_contrast::apca_contrast(adjusted, light_gray_bg).abs();
let adjusted_contrast = apca_contrast(adjusted, light_gray_bg).abs();
assert!(adjusted_contrast >= 45.0, "Should meet minimum contrast");
// Test case 2: Dark colors (poor contrast)
@ -1744,7 +1745,7 @@ mod tests {
};
// Should have poor contrast
let actual_contrast = color_contrast::apca_contrast(black_fg, dark_gray_bg).abs();
let actual_contrast = apca_contrast(black_fg, dark_gray_bg).abs();
assert!(
actual_contrast < 30.0,
"Black on dark gray should have poor APCA contrast: {}",
@ -1752,16 +1753,16 @@ mod tests {
);
// After adjustment with minimum APCA contrast of 45, should be lighter
let adjusted = color_contrast::ensure_minimum_contrast(black_fg, dark_gray_bg, 45.0);
let adjusted = ensure_minimum_contrast(black_fg, dark_gray_bg, 45.0);
assert!(
adjusted.l > black_fg.l,
"Adjusted color should be lighter than original"
);
let adjusted_contrast = color_contrast::apca_contrast(adjusted, dark_gray_bg).abs();
let adjusted_contrast = apca_contrast(adjusted, dark_gray_bg).abs();
assert!(adjusted_contrast >= 45.0, "Should meet minimum contrast");
// Test case 3: Already good contrast
let good_contrast = color_contrast::ensure_minimum_contrast(black_fg, white_fg, 45.0);
let good_contrast = ensure_minimum_contrast(black_fg, white_fg, 45.0);
assert_eq!(
good_contrast, black_fg,
"Good contrast should not be adjusted"
@ -1788,11 +1789,11 @@ mod tests {
};
// With minimum contrast of 0.0, no adjustment should happen
let no_adjust = color_contrast::ensure_minimum_contrast(white_fg, white_bg, 0.0);
let no_adjust = ensure_minimum_contrast(white_fg, white_bg, 0.0);
assert_eq!(no_adjust, white_fg, "No adjustment with min_contrast 0.0");
// With minimum APCA contrast of 15, it should adjust to a darker color
let adjusted = color_contrast::ensure_minimum_contrast(white_fg, white_bg, 15.0);
let adjusted = ensure_minimum_contrast(white_fg, white_bg, 15.0);
assert!(
adjusted.l < white_fg.l,
"White on white should become darker, got l={}",
@ -1800,7 +1801,7 @@ mod tests {
);
// Verify the contrast is now acceptable
let new_contrast = color_contrast::apca_contrast(adjusted, white_bg).abs();
let new_contrast = apca_contrast(adjusted, white_bg).abs();
assert!(
new_contrast >= 15.0,
"Adjusted APCA contrast {} should be >= 15.0",

View file

@ -1,4 +1,3 @@
mod color_contrast;
mod persistence;
pub mod terminal_element;
pub mod terminal_panel;