Detailed changes
@@ -1352,7 +1352,7 @@
// 5. Never show the scrollbar:
// "never"
"show": null
- }
+ },
// Set the terminal's font size. If this option is not included,
// the terminal will default to matching the buffer's font size.
// "font_size": 15,
@@ -1369,6 +1369,21 @@
// Default: 10_000, maximum: 100_000 (all bigger values set will be treated as 100_000), 0 disables the scrolling.
// Existing terminals will not pick up this change until they are recreated.
// "max_scroll_history_lines": 10000,
+ // The minimum APCA perceptual contrast between foreground and background colors.
+ // APCA (Accessible Perceptual Contrast Algorithm) is more accurate than WCAG 2.x,
+ // especially for dark mode. Values range from 0 to 106.
+ //
+ // Based on APCA Readability Criterion (ARC) Bronze Simple Mode:
+ // https://readtech.org/ARC/tests/bronze-simple-mode/
+ // - 0: No contrast adjustment
+ // - 45: Minimum for large fluent text (36px+)
+ // - 60: Minimum for other content text
+ // - 75: Minimum for body text
+ // - 90: Preferred for body text
+ //
+ // Most terminal themes have APCA values of 40-70.
+ // A value of 45 preserves colorful themes while ensuring legibility.
+ "minimum_contrast": 45
},
"code_actions_on_format": {},
// Settings related to running tasks.
@@ -25,6 +25,7 @@ use alacritty_terminal::{
use gpui::{Bounds, ClipboardItem, Entity, FontStyle, TextStyle, WhiteSpace, canvas, size};
use language::Buffer;
use settings::Settings as _;
+use terminal::terminal_settings::TerminalSettings;
use terminal_view::terminal_element::TerminalElement;
use theme::ThemeSettings;
use ui::{IntoElement, prelude::*};
@@ -257,8 +258,17 @@ impl Render for TerminalOutput {
point: ic.point,
cell: ic.cell.clone(),
});
- let (cells, rects) =
- TerminalElement::layout_grid(grid, 0, &text_style, text_system, None, window, cx);
+ let minimum_contrast = TerminalSettings::get_global(cx).minimum_contrast;
+ let (cells, rects) = TerminalElement::layout_grid(
+ grid,
+ 0,
+ &text_style,
+ text_system,
+ None,
+ minimum_contrast,
+ window,
+ cx,
+ );
// lines are 0-indexed, so we must add 1 to get the number of lines
let text_line_height = text_style.line_height_in_pixels(window.rem_size());
@@ -49,6 +49,7 @@ pub struct TerminalSettings {
pub max_scroll_history_lines: Option<usize>,
pub toolbar: Toolbar,
pub scrollbar: ScrollbarSettings,
+ pub minimum_contrast: f32,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -229,6 +230,21 @@ pub struct TerminalSettingsContent {
pub toolbar: Option<ToolbarContent>,
/// Scrollbar-related settings
pub scrollbar: Option<ScrollbarSettingsContent>,
+ /// The minimum APCA perceptual contrast between foreground and background colors.
+ ///
+ /// APCA (Accessible Perceptual Contrast Algorithm) is more accurate than WCAG 2.x,
+ /// especially for dark mode. Values range from 0 to 106.
+ ///
+ /// Based on APCA Readability Criterion (ARC) Bronze Simple Mode:
+ /// https://readtech.org/ARC/tests/bronze-simple-mode/
+ /// - 0: No contrast adjustment
+ /// - 45: Minimum for large fluent text (36px+)
+ /// - 60: Minimum for other content text
+ /// - 75: Minimum for body text
+ /// - 90: Preferred for body text
+ ///
+ /// Default: 0 (no adjustment)
+ pub minimum_contrast: Option<f32>,
}
impl settings::Settings for TerminalSettings {
@@ -237,7 +253,18 @@ impl settings::Settings for TerminalSettings {
type FileContent = TerminalSettingsContent;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
- sources.json_merge()
+ let settings: Self = sources.json_merge()?;
+
+ // Validate minimum_contrast for APCA
+ if settings.minimum_contrast < 0.0 || settings.minimum_contrast > 106.0 {
+ anyhow::bail!(
+ "terminal.minimum_contrast must be between 0 and 106, but got {}. \
+ APCA values: 0 = no adjustment, 75 = recommended for body text, 106 = maximum contrast.",
+ settings.minimum_contrast
+ );
+ }
+
+ Ok(settings)
}
fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
@@ -0,0 +1,474 @@
+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
+ );
+ }
+}
@@ -1,3 +1,4 @@
+use crate::color_contrast;
use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
use gpui::{
AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, Element,
@@ -204,9 +205,9 @@ impl TerminalElement {
grid: impl Iterator<Item = IndexedCell>,
start_line_offset: i32,
text_style: &TextStyle,
- // terminal_theme: &TerminalStyle,
text_system: &WindowTextSystem,
hyperlink: Option<(HighlightStyle, &RangeInclusive<AlacPoint>)>,
+ minimum_contrast: f32,
window: &Window,
cx: &App,
) -> (Vec<LayoutCell>, Vec<LayoutRect>) {
@@ -285,8 +286,15 @@ impl TerminalElement {
{
if !is_blank(&cell) {
let cell_text = cell.c.to_string();
- let cell_style =
- TerminalElement::cell_style(&cell, fg, theme, text_style, hyperlink);
+ let cell_style = TerminalElement::cell_style(
+ &cell,
+ fg,
+ bg,
+ theme,
+ text_style,
+ hyperlink,
+ minimum_contrast,
+ );
let layout_cell = text_system.shape_line(
cell_text.into(),
@@ -341,13 +349,17 @@ impl TerminalElement {
fn cell_style(
indexed: &IndexedCell,
fg: terminal::alacritty_terminal::vte::ansi::Color,
- // bg: terminal::alacritty_terminal::ansi::Color,
+ bg: terminal::alacritty_terminal::vte::ansi::Color,
colors: &Theme,
text_style: &TextStyle,
hyperlink: Option<(HighlightStyle, &RangeInclusive<AlacPoint>)>,
+ minimum_contrast: f32,
) -> TextRun {
let flags = indexed.cell.flags;
let mut fg = convert_color(&fg, colors);
+ let bg = convert_color(&bg, colors);
+
+ fg = color_contrast::ensure_minimum_contrast(fg, bg, minimum_contrast);
// Ghostty uses (175/255) as the multiplier (~0.69), Alacritty uses 0.66, Kitty
// uses 0.75. We're using 0.7 because it's pretty well in the middle of that.
@@ -680,6 +692,7 @@ impl Element for TerminalElement {
let buffer_font_size = settings.buffer_font_size(cx);
let terminal_settings = TerminalSettings::get_global(cx);
+ let minimum_contrast = terminal_settings.minimum_contrast;
let font_family = terminal_settings.font_family.as_ref().map_or_else(
|| settings.buffer_font.family.clone(),
@@ -853,6 +866,7 @@ impl Element for TerminalElement {
last_hovered_word
.as_ref()
.map(|last_hovered_word| (link_style, &last_hovered_word.word_match)),
+ minimum_contrast,
window,
cx,
),
@@ -874,6 +888,7 @@ impl Element for TerminalElement {
last_hovered_word.as_ref().map(|last_hovered_word| {
(link_style, &last_hovered_word.word_match)
}),
+ minimum_contrast,
window,
cx,
)
@@ -1390,3 +1405,122 @@ pub fn convert_color(fg: &terminal::alacritty_terminal::vte::ansi::Color, theme:
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_contrast_adjustment_logic() {
+ // Test the core contrast adjustment logic without needing full app context
+
+ // Test case 1: Light colors (poor contrast)
+ let white_fg = gpui::Hsla {
+ h: 0.0,
+ s: 0.0,
+ l: 1.0,
+ a: 1.0,
+ };
+ let light_gray_bg = gpui::Hsla {
+ h: 0.0,
+ s: 0.0,
+ l: 0.95,
+ a: 1.0,
+ };
+
+ // Should have poor contrast
+ let actual_contrast = color_contrast::apca_contrast(white_fg, light_gray_bg).abs();
+ assert!(
+ actual_contrast < 30.0,
+ "White on light gray should have poor APCA contrast: {}",
+ actual_contrast
+ );
+
+ // 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);
+ 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();
+ assert!(adjusted_contrast >= 45.0, "Should meet minimum contrast");
+
+ // Test case 2: Dark colors (poor contrast)
+ let black_fg = gpui::Hsla {
+ h: 0.0,
+ s: 0.0,
+ l: 0.0,
+ a: 1.0,
+ };
+ let dark_gray_bg = gpui::Hsla {
+ h: 0.0,
+ s: 0.0,
+ l: 0.05,
+ a: 1.0,
+ };
+
+ // Should have poor contrast
+ let actual_contrast = color_contrast::apca_contrast(black_fg, dark_gray_bg).abs();
+ assert!(
+ actual_contrast < 30.0,
+ "Black on dark gray should have poor APCA contrast: {}",
+ actual_contrast
+ );
+
+ // 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);
+ 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();
+ 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);
+ assert_eq!(
+ good_contrast, black_fg,
+ "Good contrast should not be adjusted"
+ );
+ }
+
+ #[test]
+ fn test_white_on_white_contrast_issue() {
+ // This test reproduces the exact issue from the bug report
+ // where white ANSI text on white background should be adjusted
+
+ // Simulate One Light theme colors
+ let white_fg = gpui::Hsla {
+ h: 0.0,
+ s: 0.0,
+ l: 0.98, // #fafafaff is approximately 98% lightness
+ a: 1.0,
+ };
+ let white_bg = gpui::Hsla {
+ h: 0.0,
+ s: 0.0,
+ l: 0.98, // Same as foreground - this is the problem!
+ a: 1.0,
+ };
+
+ // With minimum contrast of 0.0, no adjustment should happen
+ let no_adjust = color_contrast::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);
+ assert!(
+ adjusted.l < white_fg.l,
+ "White on white should become darker, got l={}",
+ adjusted.l
+ );
+
+ // Verify the contrast is now acceptable
+ let new_contrast = color_contrast::apca_contrast(adjusted, white_bg).abs();
+ assert!(
+ new_contrast >= 15.0,
+ "Adjusted APCA contrast {} should be >= 15.0",
+ new_contrast
+ );
+ }
+}
@@ -1,3 +1,4 @@
+mod color_contrast;
mod persistence;
pub mod terminal_element;
pub mod terminal_panel;