From bcd54d0adf13bf261c2d148c80941c51bd82ffa7 Mon Sep 17 00:00:00 2001 From: Kai Kozlov <37962720+kaikozlov@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:36:23 -0500 Subject: [PATCH] editor: Add intelligent bracket colorization (#51580) Closes #50210 Bracket colorization currently runs theme accents through `ensure_minimum_contrast`, which can significantly alter authored palette colors in order to satisfy a single foreground-vs-background target. In practice, that makes rainbow brackets drift away from the theme colors they are supposed to reflect. This PR keeps bracket colorization as a single automatic behavior, but changes how the palette is derived: - preserve authored accent colors when they already work - adjust only the specific accents that are too weak against the editor background - only reorder the palette when adjacent bracket levels are still too similar after that The implementation is staged because bracket colorization has to handle two separate problems: - **Bracket-vs-Background Readability**: is an individual bracket visible against the editor background? - **Adjacent Bracket Separation**: can you still distinguish one nesting level from the next? Those two constraints are also why the implementation cannot simply defer to whatever accent colors the theme provides. Some themes, including the bundled One and Ayu, do not define explicit accents at all, so bracket colorization falls back to Zed's shared default accent palette. That palette was not designed with any particular theme's background in mind, which makes the background contrast check especially important for those cases. That is why the previous `ensure_minimum_contrast(..., 55.0)` path is not enough on its own. It addresses foreground-vs-background readability, but if lightness-only adjustment cannot satisfy that target it escalates to saturation reduction and then black/white fallback. This PR instead keeps the bracket-specific background fix bounded and lightness-only in OKLCH, so the resulting color stays much closer to the authored accent. ### How it works - **Background Contrast Correction**: accents that fall below the APCA floor against the editor background are adjusted by changing OKLCH lightness only, preserving hue and chroma - **Adjacency Correction**: if the resulting palette is still too weak between neighboring nesting levels, the palette is reordered conservatively to improve adjacent separation That ordering matters. Doing the reorder first can easily be undone by the background-fix step. ### Color measurement - background contrast uses APCA, via the existing `apca_contrast` path - adjacent bracket separation uses OKLab Euclidean distance APCA is the right tool for bracket-vs-background readability, but it is not designed to compare two foreground colors against each other. OKLab distance is a better fit for detecting when neighboring bracket levels are perceptually too close. ### Thresholds - background APCA floor: `30` on dark themes, `35` on light themes - adjacent OKLab target: `0.08` on dark themes, `0.10` on light themes - light-theme reorder tolerance band: reorder only triggers below `0.095`, avoiding threshold-artifact reordering on palettes that are visually fine but narrowly miss `0.10` ### Validation - `cargo test -p editor bracket_colorization` - manual comparison on bundled One, Ayu, and Gruvbox theme families Release Notes: - Improved bracket colorization by preserving theme accent colors when possible and applying targeted contrast fixes only when needed. --------- Co-authored-by: Kirill Bulatov --- crates/editor/src/bracket_colorization.rs | 535 ++++++++++++++++++---- crates/editor/src/editor.rs | 9 +- crates/theme/src/color_space.rs | 76 +++ crates/theme/src/theme.rs | 2 + 4 files changed, 530 insertions(+), 92 deletions(-) create mode 100644 crates/theme/src/color_space.rs diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 8c8c3a36e9..3b90d1e2bc 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -2,15 +2,18 @@ //! Uses tree-sitter queries from brackets.scm to capture bracket pairs, //! and theme accents to colorize those. +use std::cmp::Ordering; use std::ops::Range; +use std::sync::Arc; use crate::{Editor, HighlightKey}; use collections::{HashMap, HashSet}; -use gpui::{AppContext as _, Context, HighlightStyle}; +use gpui::{AppContext as _, Context, HighlightStyle, Hsla}; use language::{BufferRow, BufferSnapshot, language_settings::LanguageSettings}; use multi_buffer::{Anchor, BufferOffset, ExcerptRange, MultiBufferSnapshot}; use text::OffsetRangeExt as _; -use ui::{ActiveTheme, utils::ensure_minimum_contrast}; +use theme::{Appearance, Oklab, Oklch, hsla_to_oklab, hsla_to_oklch, oklch_to_hsla}; +use ui::utils::apca_contrast; impl Editor { pub(crate) fn colorize_brackets(&mut self, invalidate: bool, cx: &mut Context) { @@ -22,7 +25,10 @@ impl Editor { self.bracket_fetched_tree_sitter_chunks.clear(); } - let accents_count = cx.theme().accents().0.len(); + let Some(accent_data) = self.accent_data.as_ref() else { + return; + }; + let accents = accent_data.colors.0.clone(); let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); let visible_excerpts = self.visible_buffer_ranges(cx); @@ -52,7 +58,12 @@ impl Editor { }) .collect::, HashSet>>>(); + let accents_count = accents.len(); let bracket_matches_by_accent = cx.background_spawn(async move { + if accents_count == 0 { + return (HashMap::default(), fetched_tree_sitter_chunks); + } + let bracket_matches_by_accent: HashMap>> = excerpt_data.into_iter().fold( HashMap::default(), @@ -92,9 +103,6 @@ impl Editor { (bracket_matches_by_accent, fetched_tree_sitter_chunks) }); - let editor_background = cx.theme().colors().editor_background; - let accents = cx.theme().accents().clone(); - self.colorize_brackets_task = cx.spawn(async move |editor, cx| { if invalidate { editor @@ -115,11 +123,11 @@ impl Editor { .bracket_fetched_tree_sitter_chunks .extend(updated_chunks); for (accent_number, bracket_highlights) in bracket_matches_by_accent { - let bracket_color = accents.color_for_index(accent_number as u32); - let adjusted_color = - ensure_minimum_contrast(bracket_color, editor_background, 55.0); + let Some(&bracket_color) = accents.get(accent_number) else { + continue; + }; let style = HighlightStyle { - color: Some(adjusted_color), + color: Some(bracket_color), ..HighlightStyle::default() }; @@ -137,6 +145,194 @@ impl Editor { } } +const BACKGROUND_APCA_LIGHT: f32 = 35.0; +const BACKGROUND_APCA_DARK: f32 = 30.0; +const ADJACENT_OKLAB_LIGHT: f32 = 0.10; +const ADJACENT_OKLAB_DARK: f32 = 0.08; +const ADJACENT_OKLAB_LIGHT_INTERVENTION: f32 = 0.095; +const ADJACENT_OKLAB_DARK_INTERVENTION: f32 = 0.08; +const LIGHTNESS_CLAMP_MIN: f32 = 0.18; +const LIGHTNESS_CLAMP_MAX: f32 = 0.92; + +pub(crate) fn bracket_colorization_accents( + accents: &[Hsla], + appearance: Appearance, + background: Hsla, +) -> Arc<[Hsla]> { + let (intervention_distance, comfortable_distance, min_background_contrast) = match appearance { + Appearance::Light => ( + ADJACENT_OKLAB_LIGHT_INTERVENTION, + ADJACENT_OKLAB_LIGHT, + BACKGROUND_APCA_LIGHT, + ), + Appearance::Dark => ( + ADJACENT_OKLAB_DARK_INTERVENTION, + ADJACENT_OKLAB_DARK, + BACKGROUND_APCA_DARK, + ), + }; + let background_adjusted = accents + .iter() + .copied() + .map(|accent| adjust_color_for_background(accent, background, min_background_contrast)) + .collect::>(); + let adjusted_min_adj = min_adjacent_oklab_distance(&background_adjusted, background); + + if accents.len() < 3 || adjusted_min_adj >= intervention_distance { + return Arc::from(background_adjusted); + } + + let reordered = maximize_adjacent_separation(&background_adjusted, background); + if min_adjacent_oklab_distance(&reordered, background) >= comfortable_distance { + Arc::from(reordered) + } else { + Arc::from(background_adjusted) + } +} + +fn maximize_adjacent_separation(accents: &[Hsla], background: Hsla) -> Vec { + let Some((&first, rest)) = accents.split_first() else { + return Vec::new(); + }; + let mut remaining = rest.to_vec(); + let mut order = Vec::with_capacity(accents.len()); + order.push(first); + let mut last = first; + + while !remaining.is_empty() { + let Some((position, &next)) = + remaining + .iter() + .enumerate() + .max_by(|&(_, &left), &(_, &right)| { + compare_candidates(background, last, first, left, right) + }) + else { + break; + }; + remaining.swap_remove(position); + order.push(next); + last = next; + } + + order +} + +fn compare_candidates( + background: Hsla, + last: Hsla, + first: Hsla, + left: Hsla, + right: Hsla, +) -> Ordering { + adjacent_distance(last, left, background) + .partial_cmp(&adjacent_distance(last, right, background)) + .unwrap_or(Ordering::Equal) + .then_with(|| { + adjacent_distance(first, left, background) + .partial_cmp(&adjacent_distance(first, right, background)) + .unwrap_or(Ordering::Equal) + }) +} + +fn min_adjacent_oklab_distance(accents: &[Hsla], background: Hsla) -> f32 { + if accents.len() < 2 { + return f32::MAX; + } + accents + .iter() + .copied() + .zip(accents.iter().copied().cycle().skip(1)) + .take(accents.len()) + .map(|(left, right)| adjacent_distance(left, right, background)) + .fold(f32::MAX, f32::min) +} + +fn oklab_distance(left: Oklab, right: Oklab) -> f32 { + let dl = left.l - right.l; + let da = left.a - right.a; + let db = left.b - right.b; + (dl * dl + da * da + db * db).sqrt() +} + +fn adjacent_distance(left: Hsla, right: Hsla, background: Hsla) -> f32 { + oklab_distance( + hsla_to_oklab(background.blend(left)), + hsla_to_oklab(background.blend(right)), + ) +} + +fn adjust_color_for_background( + color: Hsla, + background: Hsla, + minimum_background_contrast: f32, +) -> Hsla { + if background_contrast(color, background) >= minimum_background_contrast { + return color; + } + + let original = hsla_to_oklab(color); + let darker_candidate = adjusted_lightness_candidate( + color, + background, + minimum_background_contrast, + LIGHTNESS_CLAMP_MIN, + ); + let lighter_candidate = adjusted_lightness_candidate( + color, + background, + minimum_background_contrast, + LIGHTNESS_CLAMP_MAX, + ); + + match (darker_candidate, lighter_candidate) { + (Some(darker_candidate), Some(lighter_candidate)) => { + let darker_distance = oklab_distance(original, hsla_to_oklab(darker_candidate)); + let lighter_distance = oklab_distance(original, hsla_to_oklab(lighter_candidate)); + if darker_distance <= lighter_distance { + darker_candidate + } else { + lighter_candidate + } + } + (Some(darker_candidate), None) => darker_candidate, + (None, Some(lighter_candidate)) => lighter_candidate, + (None, None) => color, + } +} + +fn adjusted_lightness_candidate( + color: Hsla, + background: Hsla, + minimum_background_contrast: f32, + target_lightness: f32, +) -> Option { + let original = hsla_to_oklch(color); + let lightness_delta = target_lightness - original.l; + + if lightness_delta.abs() <= f32::EPSILON { + return None; + } + + (1..=128).find_map(|step| { + let amount = step as f32 / 128.0; + let candidate = oklch_to_hsla( + Oklch { + l: (original.l + lightness_delta * amount).clamp(0.0, 1.0), + chroma: original.chroma, + hue: original.hue, + }, + color.a, + ); + (background_contrast(candidate, background) >= minimum_background_contrast) + .then_some(candidate) + }) +} + +fn background_contrast(foreground: Hsla, background: Hsla) -> f32 { + apca_contrast(background.blend(foreground), background).abs() +} + fn compute_bracket_ranges( multi_buffer_snapshot: &MultiBufferSnapshot, buffer_snapshot: &BufferSnapshot, @@ -201,7 +397,7 @@ mod tests { }; use collections::HashSet; use fs::FakeFs; - use gpui::UpdateGlobal as _; + use gpui::{Rgba, UpdateGlobal as _, hsla}; use indoc::indoc; use itertools::Itertools; use language::{Capability, markdown_lang}; @@ -213,10 +409,154 @@ mod tests { use serde_json::json; use settings::{AccentContent, SettingsStore}; use text::{Bias, OffsetRangeExt, ToOffset}; + use theme::Appearance; use theme_settings::ThemeStyleContent; + use ui::ActiveTheme; use util::{path, post_inc}; + fn light_editor_background() -> Hsla { + hsla(0.0, 0.0, 0.98, 1.0) + } + + fn dark_editor_background() -> Hsla { + hsla(0.0, 0.0, 0.12, 1.0) + } + + #[test] + fn test_auto_bracket_colorization_mode_reorders_weak_palette() { + let accents = vec![ + hsla(0.0, 1.0, 0.68, 1.0), + hsla(0.02, 1.0, 0.68, 1.0), + hsla(0.34, 1.0, 0.68, 1.0), + hsla(0.36, 1.0, 0.68, 1.0), + ]; + + let original_min_adj = min_adjacent_oklab_distance(&accents, dark_editor_background()); + let reordered = maximize_adjacent_separation(&accents, dark_editor_background()); + let reordered_min_adj = min_adjacent_oklab_distance(&reordered, dark_editor_background()); + + assert_ne!(reordered.as_slice(), accents.as_slice()); + assert!(reordered_min_adj > original_min_adj); + } + + #[test] + fn test_preserves_strong_palette() { + let accents = vec![ + hsla(0.0, 1.0, 0.78, 1.0), + hsla(0.16, 1.0, 0.78, 1.0), + hsla(0.33, 1.0, 0.78, 1.0), + hsla(0.66, 1.0, 0.78, 1.0), + ]; + + let palette = + bracket_colorization_accents(&accents, Appearance::Dark, dark_editor_background()); + + assert_eq!(palette.as_ref(), accents.as_slice()); + } + + #[test] + fn test_adjusts_background_failures_preserving_hue_and_chroma() { + let accents = vec![ + hsla(0.58, 1.0, 0.28, 1.0), + hsla(0.12, 1.0, 0.28, 1.0), + hsla(0.22, 0.9, 0.76, 1.0), + ]; + + let palette = + bracket_colorization_accents(&accents, Appearance::Light, light_editor_background()); + let original = hsla_to_oklch(accents[2]); + let adjusted = hsla_to_oklch(palette[2]); + + assert_ne!(palette.as_ref(), accents.as_slice()); + assert_eq!(palette.len(), accents.len()); + assert_eq!(palette[0], accents[0]); + assert_eq!(palette[1], accents[1]); + assert_ne!(palette[2], accents[2]); + assert!((original.chroma - adjusted.chroma).abs() < 0.0001); + assert!((original.hue - adjusted.hue).abs() < 0.001); + assert_ne!(original.l, adjusted.l); + assert!( + background_contrast(palette[2], light_editor_background()) >= BACKGROUND_APCA_LIGHT + ); + } + + #[test] + fn test_preserves_light_near_miss_palette() { + let accents = vec![ + Hsla::from(Rgba::try_from("#CC241D").expect("valid color")), + Hsla::from(Rgba::try_from("#98971A").expect("valid color")), + Hsla::from(Rgba::try_from("#D79921").expect("valid color")), + Hsla::from(Rgba::try_from("#458588").expect("valid color")), + Hsla::from(Rgba::try_from("#B16286").expect("valid color")), + Hsla::from(Rgba::try_from("#689D6A").expect("valid color")), + Hsla::from(Rgba::try_from("#D65D0E").expect("valid color")), + ]; + let background = Hsla::from(Rgba::try_from("#FBF1C7").expect("valid color")); + + let palette = bracket_colorization_accents(&accents, Appearance::Light, background); + let original_min_adj = min_adjacent_oklab_distance(&accents, background); + + assert_eq!(palette.as_ref(), accents.as_slice()); + assert!(original_min_adj < ADJACENT_OKLAB_LIGHT); + assert!(original_min_adj >= ADJACENT_OKLAB_LIGHT_INTERVENTION); + } + + #[test] + fn test_adjust_color_for_background_prefers_closest_passing_candidate() { + // Verify that when both darker and lighter candidates exist, + // we pick the one with minimum OKLab distance from the original. + let background = hsla(0.0, 0.0, 0.50, 1.0); + let min_contrast = 30.0; + let color = hsla(0.33, 0.7, 0.46, 1.0); + + assert!( + background_contrast(color, background) < min_contrast, + "test color must fail contrast check; got {}", + background_contrast(color, background) + ); + + let original = hsla_to_oklab(color); + let darker = + adjusted_lightness_candidate(color, background, min_contrast, LIGHTNESS_CLAMP_MIN) + .expect("fixture must produce a darker passing candidate"); + let lighter = + adjusted_lightness_candidate(color, background, min_contrast, LIGHTNESS_CLAMP_MAX) + .expect("fixture must produce a lighter passing candidate"); + + let darker_dist = oklab_distance(original, hsla_to_oklab(darker)); + let lighter_dist = oklab_distance(original, hsla_to_oklab(lighter)); + let expected = if darker_dist <= lighter_dist { + darker + } else { + lighter + }; + assert_eq!( + adjust_color_for_background(color, background, min_contrast), + expected + ); + } + + #[test] + fn test_background_adjustment_edge_cases() { + let color = hsla(0.22, 0.9, 0.76, 1.0); + let original_contrast = background_contrast(color, light_editor_background()); + assert!(original_contrast < 20.0); + let palette = + bracket_colorization_accents(&[color], Appearance::Light, light_editor_background()); + assert_ne!(palette.as_ref(), &[color][..]); + assert!( + background_contrast(palette[0], light_editor_background()) >= BACKGROUND_APCA_LIGHT + ); + + let impossible_color = hsla(0.58, 1.0, 0.47, 1.0); + let impossible_bg = hsla(0.0, 0.0, 0.50, 1.0); + assert_eq!( + adjust_color_for_background(impossible_color, impossible_bg, 200.0), + impossible_color + ); + } + #[gpui::test] async fn test_basic_bracket_colorization(cx: &mut gpui::TestAppContext) { init_test(cx, |language_settings| { @@ -291,11 +631,11 @@ where 2 }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) 6 hsla(95.00, 38.00%, 62.00%, 1.00) 7 hsla(39.00, 67.00%, 69.00%, 1.00) "#, @@ -327,7 +667,7 @@ where assert_eq!( "fn main«1()1» «1{}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) ", editor .update(cx, |editor, window, cx| { @@ -356,7 +696,7 @@ where assert_eq!( r#"«1[LLM-powered features]1»«1(./ai/overview.md)1», «1[bring and configure your own API keys]1»«1(./ai/llm-providers.md#use-your-own-keys)1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) "#, &bracket_colors_markup(&mut cx), "All markdown brackets should be colored based on their depth" @@ -368,8 +708,8 @@ where assert_eq!( r#"«1{«2{}2»}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) "#, &bracket_colors_markup(&mut cx), "All markdown brackets should be colored based on their depth, again" @@ -384,7 +724,7 @@ where cx.executor().run_until_parked(); assert_eq!( - "«1('')1»«1('')1»\n\n«1(«2('')2»)1»«1('')1»\n\n«1('')1»«1(«2('')2»)1»\n1 hsla(207.80, 16.20%, 69.19%, 1.00)\n2 hsla(29.00, 54.00%, 65.88%, 1.00)\n", + "«1('')1»«1('')1»\n\n«1(«2('')2»)1»«1('')1»\n\n«1('')1»«1(«2('')2»)1»\n1 hsla(207.80, 81.00%, 66.00%, 1.00)\n2 hsla(29.00, 54.00%, 61.00%, 1.00)\n", &bracket_colors_markup(&mut cx), "Markdown quote pairs should not interfere with parenthesis pairing" ); @@ -403,7 +743,7 @@ where .await; let rows = 100; - let footer = "1 hsla(207.80, 16.20%, 69.19%, 1.00)\n"; + let footer = "1 hsla(207.80, 81.00%, 66.00%, 1.00)\n"; let simple_brackets = (0..rows).map(|_| "ˇ[]\n").collect::(); let simple_brackets_highlights = (0..rows).map(|_| "«1[]1»\n").collect::(); @@ -477,8 +817,8 @@ where let v: Vec = vec!«2[]2»; }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) "#, &bracket_colors_markup(&mut cx), "Markdown does not colorize <> brackets" @@ -495,8 +835,8 @@ where let v: Vec«22» = vec!«2[]2»; }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) "#, &bracket_colors_markup(&mut cx), "After switching to Rust, <> brackets are now colorized" @@ -540,9 +880,9 @@ fn process_data«1()1» «1{ let map: Result< }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) "#}, &bracket_colors_markup(&mut cx), "Brackets without pairs should be ignored and not colored" @@ -563,9 +903,9 @@ fn process_data«1()1» «1{ let map: Result2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) "#}, &bracket_colors_markup(&mut cx), "When brackets start to get closed, inner brackets are re-colored based on their depth" @@ -608,10 +948,10 @@ fn process_data«1()1» «1{ let map: Result3»>2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) "#}, &bracket_colors_markup(&mut cx), ); @@ -631,11 +971,11 @@ fn process_data«1()1» «1{ let map: Result«24»>3», «3()3»>2» = unimplemented!«2()2»; }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, &bracket_colors_markup(&mut cx), ); @@ -687,11 +1027,11 @@ mod foo «1{ } }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, comment_lines, ), @@ -719,11 +1059,11 @@ mod foo «1{ }2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, comment_lines, ), @@ -750,11 +1090,11 @@ mod foo «1{ } «3{«4{}4»}3»}2»}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, comment_lines, ), @@ -781,11 +1121,11 @@ mod foo «1{ } «3{«4{}4»}3»}2»}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, comment_lines, ), @@ -842,11 +1182,11 @@ mod foo «1{ } {{}}}}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#, comment_lines, ), @@ -1353,11 +1693,11 @@ mod foo «1{ }2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#,}, &editor_bracket_colors_markup(&editor_snapshot), "Multi buffers should have their brackets colored even if no excerpts contain the bracket counterpart (after fn `process_data_2()`) \ @@ -1393,11 +1733,11 @@ mod foo «1{ }2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#,}, &editor_bracket_colors_markup(&editor_snapshot), ); @@ -1424,7 +1764,18 @@ mod foo «1{ let editor_snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); - assert_eq!( + let adjusted_palette = cx.update(|cx| { + bracket_colorization_accents( + &[ + Hsla::from(Rgba::try_from("#ff0000").expect("valid override accent")), + Hsla::from(Rgba::try_from("#0000ff").expect("valid override accent")), + ], + cx.theme().appearance, + cx.theme().colors().editor_background, + ) + }); + let expected_markup = format!( + "{}\n1 {}\n2 {}\n", indoc! {r#" @@ -1442,11 +1793,13 @@ mod foo «1{ let other_map: Option«12»>1» = None; }2» }1» - -1 hsla(0.00, 100.00%, 78.12%, 1.00) -2 hsla(240.00, 100.00%, 82.81%, 1.00) "#,}, - &editor_bracket_colors_markup(&editor_snapshot), + adjusted_palette[0], + adjusted_palette[1], + ); + assert_eq!( + expected_markup, + editor_bracket_colors_markup(&editor_snapshot), "After updating theme accents, the editor should update the bracket coloring" ); } @@ -1536,10 +1889,10 @@ mod foo «1{ " let other_map: Option\u{00ab}23\u{00bb}>2\u{00bb} = None;\n", "}1\u{00bb}\n", "\n", - "1 hsla(207.80, 16.20%, 69.19%, 1.00)\n", - "2 hsla(29.00, 54.00%, 65.88%, 1.00)\n", - "3 hsla(286.00, 51.00%, 75.25%, 1.00)\n", - "4 hsla(187.00, 47.00%, 59.22%, 1.00)\n", + "1 hsla(207.80, 81.00%, 66.00%, 1.00)\n", + "2 hsla(29.00, 54.00%, 61.00%, 1.00)\n", + "3 hsla(286.00, 51.00%, 64.00%, 1.00)\n", + "4 hsla(187.00, 47.00%, 55.00%, 1.00)\n", ), &editor_bracket_colors_markup(&editor_snapshot), "Two close excerpts from the same buffer (within same tree-sitter chunk) should both have bracket colors" @@ -1592,9 +1945,9 @@ fn small_function«1()1» «1{ let x = «2(1, «3(2, 3)3»)2»; }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) "#,}, bracket_colors_markup(&mut cx), ); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 12138cb7f9..70b4911e17 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -9640,6 +9640,13 @@ impl Editor { let theme_settings = theme_settings::ThemeSettings::get_global(cx); let theme = cx.theme(); let accent_colors = theme.accents().clone(); + let editor_background = theme.colors().editor_background; + let auto_accent_colors = + AccentColors(crate::bracket_colorization::bracket_colorization_accents( + &accent_colors.0, + theme.appearance, + editor_background, + )); let accent_overrides = theme_settings .theme_overrides @@ -9659,7 +9666,7 @@ impl Editor { .collect(); Some(AccentData { - colors: accent_colors, + colors: auto_accent_colors, overrides: accent_overrides, }) } diff --git a/crates/theme/src/color_space.rs b/crates/theme/src/color_space.rs new file mode 100644 index 0000000000..125dfa6d9d --- /dev/null +++ b/crates/theme/src/color_space.rs @@ -0,0 +1,76 @@ +//! Conversions between gpui's [`Hsla`] and the OKLab / OKLCh perceptual color +//! spaces, backed by the `palette` crate. +//! +//! These are exposed so consumers can reason about perceptual color distance +//! (e.g. bracket colorization) without taking a direct dependency on `palette`. + +use gpui::{Hsla, Rgba}; +use palette::{ + FromColor, OklabHue, + rgb::{LinSrgba, Srgba}, +}; + +/// A color in the OKLab perceptual color space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Oklab { + /// Perceptual lightness, in `0.0..=1.0`. + pub l: f32, + /// Green/red opponent axis. + pub a: f32, + /// Blue/yellow opponent axis. + pub b: f32, +} + +/// A color in the OKLCh perceptual color space (the cylindrical form of OKLab). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Oklch { + /// Perceptual lightness, in `0.0..=1.0`. + pub l: f32, + /// Chroma (colorfulness). + pub chroma: f32, + /// Hue, in degrees (`0.0..360.0`). + pub hue: f32, +} + +/// Converts an [`Hsla`] color into the OKLab color space. +pub fn hsla_to_oklab(color: Hsla) -> Oklab { + let oklab = palette::Oklab::from_color(hsla_to_linear(color)); + Oklab { + l: oklab.l, + a: oklab.a, + b: oklab.b, + } +} + +/// Converts an [`Hsla`] color into the OKLCh color space. +pub fn hsla_to_oklch(color: Hsla) -> Oklch { + let oklch = palette::Oklch::from_color(hsla_to_linear(color)); + Oklch { + l: oklch.l, + chroma: oklch.chroma, + hue: oklch.hue.into_positive_degrees(), + } +} + +/// Converts an [`Oklch`] color back into [`Hsla`], using `alpha` for the +/// resulting alpha channel. Channels outside the sRGB gamut are clamped. +pub fn oklch_to_hsla(color: Oklch, alpha: f32) -> Hsla { + let oklch = palette::Oklch { + l: color.l, + chroma: color.chroma, + hue: OklabHue::from_degrees(color.hue), + }; + let rgba: Srgba = Srgba::from_linear(LinSrgba::from_color(oklch)); + let (red, green, blue, _) = rgba.into_components(); + Hsla::from(Rgba { + r: red.clamp(0.0, 1.0), + g: green.clamp(0.0, 1.0), + b: blue.clamp(0.0, 1.0), + a: alpha, + }) +} + +fn hsla_to_linear(color: Hsla) -> LinSrgba { + let rgba = Rgba::from(color); + Srgba::new(rgba.r, rgba.g, rgba.b, rgba.a).into_linear() +} diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index ab49877c6b..b973d8f25c 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -8,6 +8,7 @@ //! //! A theme is a collection of colors used to build a consistent appearance for UI components across the application. +mod color_space; mod default_colors; mod fallback_themes; mod font_family_cache; @@ -30,6 +31,7 @@ use gpui::{ }; use serde::Deserialize; +pub use crate::color_space::*; pub use crate::default_colors::*; pub use crate::fallback_themes::{apply_status_color_defaults, apply_theme_color_defaults}; pub use crate::font_family_cache::*;