From d415b35d1bc0974c951bfcd4283e8fd42b38f6e1 Mon Sep 17 00:00:00 2001 From: obito <1255116997@qq.com> Date: Thu, 25 Jun 2026 14:03:34 +0800 Subject: [PATCH 1/2] theme: Fix invisible active/selection backgrounds from double opacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Background::opacity` multiplies the existing alpha, but the token clamping passed the absolute target alpha as the factor. When the base color already carried alpha (e.g. `#bfdbfe33` == 0.2 for `table.active.background`), it was attenuated twice (0.2 × 0.2 = 0.04) and the highlight became nearly invisible. Pass a factor (target / base) instead, so the final alpha hits the clamped target regardless of the base alpha, and gradient stops keep their relative opacity. Applied consistently to list_active, table_active, and selection via a shared helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ui/src/theme/schema.rs | 42 +++++++++++++++++------------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/ui/src/theme/schema.rs b/crates/ui/src/theme/schema.rs index 6249648dd..f334ce695 100644 --- a/crates/ui/src/theme/schema.rs +++ b/crates/ui/src/theme/schema.rs @@ -1,6 +1,6 @@ use std::{rc::Rc, sync::Arc}; -use gpui::{SharedString, px}; +use gpui::{Background, Hsla, SharedString, px}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -852,27 +852,27 @@ impl ThemeColor { // TODO: Apply default fallback colors to highlight. - // Ensure opacity for list_active, table_active - let list_active_alpha = self.list_active.a.min(0.2); - self.list_active = self.list_active.alpha(list_active_alpha); - tokens.list_active = ThemeToken::new( - self.list_active, - tokens.list_active.background.opacity(list_active_alpha), - ); - - let table_active_alpha = self.table_active.a.min(0.2); - self.table_active = self.table_active.alpha(table_active_alpha); - tokens.table_active = ThemeToken::new( - self.table_active, - tokens.table_active.background.opacity(table_active_alpha), - ); + // Ensure opacity for list_active, table_active, selection. + // + // `Background::opacity` multiplies the existing alpha, so we must pass a + // *factor* (target / base) instead of the absolute target alpha. + // Otherwise a base color that already carries alpha (e.g. `#bfdbfe33` + // == 0.2) gets attenuated twice and the highlight becomes nearly + // invisible. This also keeps gradient stops at their relative opacity. + let clamp_alpha = |color: Hsla, background: Background, max: f32| { + let base = color.a; + let target = base.min(max); + let factor = if base > 0. { target / base } else { 1. }; + let color = color.alpha(target); + (color, ThemeToken::new(color, background.opacity(factor))) + }; - let selection_alpha = self.selection.a.min(0.3); - self.selection = self.selection.alpha(selection_alpha); - tokens.selection = ThemeToken::new( - self.selection, - tokens.selection.background.opacity(selection_alpha), - ); + (self.list_active, tokens.list_active) = + clamp_alpha(self.list_active, tokens.list_active.background, 0.2); + (self.table_active, tokens.table_active) = + clamp_alpha(self.table_active, tokens.table_active.background, 0.2); + (self.selection, tokens.selection) = + clamp_alpha(self.selection, tokens.selection.background, 0.3); tokens } From 11a282eee2270751e2d2da551fa4c9faa948295c Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Thu, 25 Jun 2026 15:54:33 +0800 Subject: [PATCH 2/2] theme: Clamp each gradient stop's alpha for active/selection backgrounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `clamp_alpha` derived a single opacity factor from the representative (`from`-stop) color and applied it to the whole `Background`. For a gradient config that left the `to` stop uncapped, and a transparent `from` stop hit the `base == 0` fallback (factor 1.0) so an opaque `to` stop rendered at full alpha — re-introducing the invisible-content regression for custom gradient themes. `Background` no longer exposes its per-stop colors once built, so add `try_parse_background_clamped` to re-derive the background from the raw config value and cap each stop individually. Solid/fallback tokens keep the exact single-factor path. Co-Authored-By: Claude Opus 4.8 --- crates/ui/src/theme/color.rs | 21 ++++++++ crates/ui/src/theme/schema.rs | 96 +++++++++++++++++++++++++++++------ 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/crates/ui/src/theme/color.rs b/crates/ui/src/theme/color.rs index a1e746d97..418c10a51 100644 --- a/crates/ui/src/theme/color.rs +++ b/crates/ui/src/theme/color.rs @@ -753,6 +753,27 @@ pub fn try_parse_background(background: &str) -> Result { Ok(linear_gradient(gradient.angle, gradient.from, gradient.to)) } +/// Parse a background, clamping every color stop's alpha to at most `max`. +/// +/// Unlike [`Background::opacity`], which scales all stops by a single factor, +/// this caps each gradient stop independently, so a bright `to` stop (or a +/// transparent `from` stop) can never push the rendered highlight past `max`. +pub(crate) fn try_parse_background_clamped(background: &str, max: f32) -> Result { + if let Ok(color) = try_parse_color(background) { + return Ok(color.alpha(color.a.min(max)).into()); + } + + let gradient = parse_linear_gradient(background)?; + let clamp = |stop: LinearColorStop| { + linear_color_stop(stop.color.alpha(stop.color.a.min(max)), stop.percentage) + }; + Ok(linear_gradient( + gradient.angle, + clamp(gradient.from), + clamp(gradient.to), + )) +} + pub(crate) fn try_parse_theme_color(color: &str) -> Result { if let Ok(color) = try_parse_color(color) { return Ok(color); diff --git a/crates/ui/src/theme/schema.rs b/crates/ui/src/theme/schema.rs index f334ce695..b7df2b839 100644 --- a/crates/ui/src/theme/schema.rs +++ b/crates/ui/src/theme/schema.rs @@ -6,7 +6,9 @@ use serde::{Deserialize, Serialize}; use crate::highlighter::{HighlightTheme, HighlightThemeStyle}; -use super::color::{try_parse_background, try_parse_color, try_parse_theme_color}; +use super::color::{ + try_parse_background, try_parse_background_clamped, try_parse_color, try_parse_theme_color, +}; use super::{Colorize, Theme, ThemeColor, ThemeMode, ThemeToken, ThemeTokens}; fn try_parse_theme_token(value: &str) -> anyhow::Result { @@ -853,26 +855,37 @@ impl ThemeColor { // TODO: Apply default fallback colors to highlight. // Ensure opacity for list_active, table_active, selection. - // - // `Background::opacity` multiplies the existing alpha, so we must pass a - // *factor* (target / base) instead of the absolute target alpha. - // Otherwise a base color that already carries alpha (e.g. `#bfdbfe33` - // == 0.2) gets attenuated twice and the highlight becomes nearly - // invisible. This also keeps gradient stops at their relative opacity. - let clamp_alpha = |color: Hsla, background: Background, max: f32| { + let clamp_alpha = |raw: Option<&str>, color: Hsla, background: Background, max: f32| { let base = color.a; let target = base.min(max); - let factor = if base > 0. { target / base } else { 1. }; let color = color.alpha(target); - (color, ThemeToken::new(color, background.opacity(factor))) + let background = raw + .and_then(|value| try_parse_background_clamped(value, max).ok()) + .unwrap_or_else(|| { + let factor = if base > 0. { target / base } else { 1. }; + background.opacity(factor) + }); + (color, ThemeToken::new(color, background)) }; - (self.list_active, tokens.list_active) = - clamp_alpha(self.list_active, tokens.list_active.background, 0.2); - (self.table_active, tokens.table_active) = - clamp_alpha(self.table_active, tokens.table_active.background, 0.2); - (self.selection, tokens.selection) = - clamp_alpha(self.selection, tokens.selection.background, 0.3); + (self.list_active, tokens.list_active) = clamp_alpha( + colors.list_active.as_deref(), + self.list_active, + tokens.list_active.background, + 0.2, + ); + (self.table_active, tokens.table_active) = clamp_alpha( + colors.table_active.as_deref(), + self.table_active, + tokens.table_active.background, + 0.2, + ); + (self.selection, tokens.selection) = clamp_alpha( + colors.selection.as_deref(), + self.selection, + tokens.selection.background, + 0.3, + ); tokens } @@ -1015,4 +1028,55 @@ mod tests { assert_ne!(theme.tokens.title_bar.background, theme.title_bar.into()); assert_ne!(theme.tokens.status_bar.background, theme.status_bar.into()); } + + #[test] + fn test_apply_config_clamps_highlight_alpha_per_gradient_stop() { + let config = serde_json::from_value::(serde_json::json!({ + "name": "Highlight", + "mode": "light", + "colors": { + // Solid above the cap: must be capped to 0.2, not attenuated twice. + "list.active.background": "#3b82f6", + // Gradient with a faint `from` stop and an opaque `to` stop: the + // `to` stop must be clamped independently, not left at full alpha. + "table.active.background": "linear-gradient(#bfdbfe33, #3b82f6)", + // Gradient with a transparent `from` stop: the opaque `to` stop + // must still be clamped (the `base == 0` factor fallback used to + // leave it untouched). + "selection.background": "linear-gradient(#3b82f600, #3b82f6)", + } + })) + .unwrap(); + + let mut theme = Theme::default(); + theme.apply_config(&std::rc::Rc::new(config)); + + // Solid: representative color and rendered background both capped at 0.2. + let blue = try_parse_color("#3b82f6").unwrap(); + assert_eq!(theme.list_active, blue.alpha(0.2)); + assert_eq!(theme.tokens.list_active.background, blue.alpha(0.2).into()); + + // Gradient: the opaque `to` stop is clamped to 0.2, not left fully opaque. + let faint = try_parse_color("#bfdbfe33").unwrap(); + assert_eq!( + theme.tokens.table_active.background, + linear_gradient( + 180., + linear_color_stop(faint.alpha(faint.a.min(0.2)), 0.), + linear_color_stop(blue.alpha(0.2), 1.), + ) + ); + + // Gradient: a transparent `from` stop stays transparent while the opaque + // `to` stop is still clamped to 0.3 (selection cap). + let clear = try_parse_color("#3b82f600").unwrap(); + assert_eq!( + theme.tokens.selection.background, + linear_gradient( + 180., + linear_color_stop(clear.alpha(clear.a.min(0.3)), 0.), + linear_color_stop(blue.alpha(0.3), 1.), + ) + ); + } }