diff --git a/assets/settings/default.json b/assets/settings/default.json index 48cdd665e1..924b970084 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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. diff --git a/crates/repl/src/outputs/plain.rs b/crates/repl/src/outputs/plain.rs index 237c86d8cc..515bc654f0 100644 --- a/crates/repl/src/outputs/plain.rs +++ b/crates/repl/src/outputs/plain.rs @@ -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()); diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index f1b729987a..31c32dbdca 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -49,6 +49,7 @@ pub struct TerminalSettings { pub max_scroll_history_lines: Option, 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, /// Scrollbar-related settings pub scrollbar: Option, + /// 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, } impl settings::Settings for TerminalSettings { @@ -237,7 +253,18 @@ impl settings::Settings for TerminalSettings { type FileContent = TerminalSettingsContent; fn load(sources: SettingsSources, _: &mut App) -> anyhow::Result { - 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) { diff --git a/crates/terminal_view/src/color_contrast.rs b/crates/terminal_view/src/color_contrast.rs new file mode 100644 index 0000000000..fe4a881cea --- /dev/null +++ b/crates/terminal_view/src/color_contrast.rs @@ -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 + ); + } +} diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 3439a5b7f8..f34dc8f009 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -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, start_line_offset: i32, text_style: &TextStyle, - // terminal_theme: &TerminalStyle, text_system: &WindowTextSystem, hyperlink: Option<(HighlightStyle, &RangeInclusive)>, + minimum_contrast: f32, window: &Window, cx: &App, ) -> (Vec, Vec) { @@ -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)>, + 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 + ); + } +} diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index be167d820d..76ec9dcb25 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1,3 +1,4 @@ +mod color_contrast; mod persistence; pub mod terminal_element; pub mod terminal_panel;