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
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue