Automatically adjust ANSI color contrast (#34033)
Closes #33253 in a way that doesn't regress #32175 - namely, automatically adjusts the contrast between the foreground and background text in the terminal such that it's above a certain threshold. The threshold is configurable in settings, and can be set to 0 to turn off this feature and use exactly the colors the theme specifies even if they are illegible. ## One Light Theme Before <img width="220" alt="Screenshot 2025-07-07 at 6 00 47 PM" src="https://github.com/user-attachments/assets/096754a6-f79f-4fea-a86e-cb7b8ff45d60" /> (Last row is highlighted because otherwise the text is unreadable; the foreground and background are the same color.) ## One Light Theme After (This is with the new default contrast adjustment setting.) <img width="215" alt="Screenshot 2025-07-07 at 6 22 02 PM" src="https://github.com/user-attachments/assets/b082fefe-76f5-4231-b704-ff387983a3cb" /> This approach was inspired by @mitchellh's use of automatic contrast adjustment in [Ghostty](https://ghostty.org/) - thanks, Mitchell! The main difference is that we're using APCA's formula instead of WCAG for [these reasons](https://khan-tw.medium.com/wcag2-are-you-still-using-it-ui-contrast-visibility-standard-readability-contrast-f34eb73e89ee). Release Notes: - Added automatic dynamic contrast adjustment for terminal foreground and background colors
This commit is contained in:
parent
877ef5e1b1
commit
9b7632d5f6
6 changed files with 669 additions and 8 deletions
|
@ -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) {
|
||||
|
|
474
crates/terminal_view/src/color_contrast.rs
Normal file
474
crates/terminal_view/src/color_contrast.rs
Normal file
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue