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:
Richard Feldman 2025-07-07 18:39:11 -04:00 committed by GitHub
parent 877ef5e1b1
commit 9b7632d5f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 669 additions and 8 deletions

View file

@ -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
);
}
}