From 672ff8df10e44723c2a7d97e515c2c3abae62110 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 24 Jun 2026 10:17:20 +0800 Subject: [PATCH 1/2] tab: Fix `Pill`/`Underline` double active background on switch animation The sliding indicator is enabled for the `Segmented`, `Pill` and `Underline` variants, but only `Segmented` made the tab's own active background transparent while the indicator is shown. For `Pill` (active color lives in the outer `bg`) and `Underline` (active color is the bottom `border_color`), the newly selected tab painted its own active color immediately while the indicator was still sliding in, so the two overlapped during the transition. Suppress the selected tab's own active background/border once the indicator is active and ready, so the indicator alone represents the active state during the slide. Disabled tabs keep their own dimmed styling. Closes #2499 Co-Authored-By: Claude Opus 4.8 --- crates/ui/src/tab/tab.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/tab/tab.rs b/crates/ui/src/tab/tab.rs index d33e68c4c..330cd06d5 100644 --- a/crates/ui/src/tab/tab.rs +++ b/crates/ui/src/tab/tab.rs @@ -626,6 +626,28 @@ impl RenderOnce for Tab { }; let inner_shadow = tab_style.shadow && !segmented_indicator_active; + // When a sliding indicator is active and ready, it alone represents the + // selected state. Suppress the selected tab's own active background/border + // so the two don't overlap during the switch animation (Segmented already + // does this for its `inner_bg` above). Skip disabled tabs so a + // disabled-selected tab keeps its dimmed styling instead of the + // full-strength indicator color. + let suppress_active_visual = + self.selected && !self.disabled && self.indicator_active && self.indicator_ready; + // Pill paints its active state via the outer `bg`. + let outer_bg = if suppress_active_visual && self.variant == TabVariant::Pill { + cx.theme().transparent.into() + } else { + tab_style.bg + }; + // Underline paints its active state via the bottom `border_color`. + let outer_border_color = if suppress_active_visual && self.variant == TabVariant::Underline + { + cx.theme().transparent + } else { + tab_style.border_color + }; + self.base .id(self.ix) .relative() @@ -642,12 +664,12 @@ impl RenderOnce for Tab { Size::Large => this.text_base(), _ => this.text_sm(), }) - .bg(tab_style.bg) + .bg(outer_bg) .border_l(tab_style.borders.left) .border_r(tab_style.borders.right) .border_t(tab_style.borders.top) .border_b(tab_style.borders.bottom) - .border_color(tab_style.border_color) + .border_color(outer_border_color) .rounded(radius) .when(!self.selected && !self.disabled, |this| { this.hover(|this| { From 3f1959f9b47a0660632bd7e15b23036baf0231d3 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 24 Jun 2026 10:40:45 +0800 Subject: [PATCH 2/2] tab: Fade `Pill` selected text color in sync with indicator slide When switching tabs, the newly selected `Pill` tab's text color snapped to `primary_foreground` instantly while the indicator pill was still sliding into place, leaving white text floating over the not-yet-filled background. Animate the selected tab's text color from the normal color to `primary_foreground` using the same duration and easing as the indicator, keyed on the indicator's animation epoch so it restarts in sync with each switch. Adds an `Hsla` implementation of the `Lerp` trait for the color interpolation. Co-Authored-By: Claude Opus 4.8 --- crates/ui/src/animation.rs | 16 +++++- crates/ui/src/tab/tab.rs | 107 ++++++++++++++++++++++------------- crates/ui/src/tab/tab_bar.rs | 14 ++++- 3 files changed, 95 insertions(+), 42 deletions(-) diff --git a/crates/ui/src/animation.rs b/crates/ui/src/animation.rs index 25ac6c9a3..72546e3b2 100644 --- a/crates/ui/src/animation.rs +++ b/crates/ui/src/animation.rs @@ -1,7 +1,7 @@ use std::{rc::Rc, time::Duration}; use gpui::{ - Animation, AnimationExt, ElementId, IntoElement, Pixels, Point, Styled, point, + Animation, AnimationExt, ElementId, Hsla, IntoElement, Pixels, Point, Styled, point, prelude::FluentBuilder, px, }; use smallvec::SmallVec; @@ -80,6 +80,20 @@ impl Lerp for Point { } } +impl Lerp for Hsla { + /// Interpolate each channel linearly. Intended for transitions between + /// near-grayscale UI colors (e.g. text colors), where hue interpolation is + /// irrelevant. + fn lerp(&self, target: &Self, t: f32) -> Self { + Hsla { + h: self.h.lerp(&target.h, t), + s: self.s.lerp(&target.s, t), + l: self.l.lerp(&target.l, t), + a: self.a.lerp(&target.a, t), + } + } +} + // ── Transition combinator ─────────────────────────────────────────────────── /// A composable transition that describes animated style changes. diff --git a/crates/ui/src/tab/tab.rs b/crates/ui/src/tab/tab.rs index 330cd06d5..d5d360ca6 100644 --- a/crates/ui/src/tab/tab.rs +++ b/crates/ui/src/tab/tab.rs @@ -1,11 +1,12 @@ -use std::rc::Rc; +use std::{rc::Rc, time::Duration}; +use crate::animation::{Lerp, ease_in_out_cubic}; use crate::{ActiveTheme, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex}; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, Background, ClickEvent, Div, Edges, Hsla, InteractiveElement, IntoElement, - MouseButton, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, - Styled, Window, div, px, relative, + Animation, AnimationExt as _, AnyElement, App, Background, ClickEvent, Div, Edges, ElementId, + Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, px, relative, }; /// Tab variants. @@ -405,6 +406,10 @@ pub struct Tab { pub(super) selected: bool, pub(super) indicator_active: bool, pub(super) indicator_ready: bool, + /// Animation epoch of the [`super::TabBar`] indicator; increments on every + /// tab switch. Used to key the selected tab's text color fade so it + /// restarts in sync with the indicator slide. + pub(super) indicator_epoch: u64, on_click: Option>, } @@ -451,6 +456,7 @@ impl Default for Tab { selected: false, indicator_active: false, indicator_ready: true, + indicator_epoch: 0, prefix: None, suffix: None, variant: TabVariant::default(), @@ -648,6 +654,64 @@ impl RenderOnce for Tab { tab_style.border_color }; + // For Pill, the newly selected tab's text color (`primary_foreground`) + // would otherwise snap to white instantly while the indicator is still + // sliding into place. Fade it from the normal color in sync with the + // indicator slide (keyed on the indicator epoch so it restarts on each + // switch). `epoch == 0` is the initial layout (no slide), so we skip it. + let animate_fg = self.selected + && !self.disabled + && self.variant == TabVariant::Pill + && self.indicator_active + && self.indicator_ready + && self.indicator_epoch > 0; + let fg_from = self.variant.normal(cx).fg; + let fg_to = tab_style.fg; + + let inner_content = h_flex() + .flex_1() + .h(inner_height) + .line_height(relative(1.)) + .whitespace_nowrap() + .items_center() + .justify_center() + .overflow_hidden() + .margins(inner_margins) + .flex_shrink_0() + .map(|this| match self.icon { + Some(icon) => this + .w(inner_height * 1.25) + .child(icon.map(|this| match self.size { + Size::XSmall => this.size_2p5(), + Size::Small => this.size_3p5(), + Size::Large => this.size_4(), + _ => this.size_4(), + })), + None => this + .paddings(inner_paddings) + .map(|this| match self.label { + Some(label) => this.child(label), + None => this, + }) + .children(self.children), + }) + .bg(inner_bg) + .rounded(inner_radius) + .when(inner_shadow, |this| this.shadow_xs()) + .hover(|this| this.bg(hover_inner_bg).rounded(inner_radius)); + + let inner_element = if animate_fg { + inner_content + .with_animation( + ElementId::NamedInteger("tab-fg".into(), self.indicator_epoch), + Animation::new(Duration::from_millis(200)).with_easing(ease_in_out_cubic), + move |this, delta| this.text_color(Lerp::lerp(&fg_from, &fg_to, delta)), + ) + .into_any_element() + } else { + inner_content.into_any_element() + }; + self.base .id(self.ix) .relative() @@ -704,40 +768,7 @@ impl RenderOnce for Tab { ) }) .when_some(self.prefix, |this, prefix| this.child(prefix)) - .child( - h_flex() - .flex_1() - .h(inner_height) - .line_height(relative(1.)) - .whitespace_nowrap() - .items_center() - .justify_center() - .overflow_hidden() - .margins(inner_margins) - .flex_shrink_0() - .map(|this| match self.icon { - Some(icon) => { - this.w(inner_height * 1.25) - .child(icon.map(|this| match self.size { - Size::XSmall => this.size_2p5(), - Size::Small => this.size_3p5(), - Size::Large => this.size_4(), - _ => this.size_4(), - })) - } - None => this - .paddings(inner_paddings) - .map(|this| match self.label { - Some(label) => this.child(label), - None => this, - }) - .children(self.children), - }) - .bg(inner_bg) - .rounded(inner_radius) - .when(inner_shadow, |this| this.shadow_xs()) - .hover(|this| this.bg(hover_inner_bg).rounded(inner_radius)), - ) + .child(inner_element) .when_some(self.suffix, |this, suffix| this.child(suffix)) .on_mouse_down(MouseButton::Left, |_, _, cx| { // Stop propagation behavior, for works on TitleBar. diff --git a/crates/ui/src/tab/tab_bar.rs b/crates/ui/src/tab/tab_bar.rs index 8a0d0bd6b..0372b0a36 100644 --- a/crates/ui/src/tab/tab_bar.rs +++ b/crates/ui/src/tab/tab_bar.rs @@ -164,12 +164,17 @@ impl TabBar { } /// Render the sliding indicator element for animated tab switching. + /// + /// Returns the indicator element together with the current animation + /// `epoch`, which increments on every tab switch. Tabs key their own + /// transitions (e.g. text color fade) on this epoch so they restart in sync + /// with the indicator slide. fn render_indicator( &self, bounds_rc: &Option>>, window: &mut Window, cx: &mut App, - ) -> Option { + ) -> Option<(AnyElement, u64)> { let has_indicator = matches!( self.variant, TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline @@ -248,7 +253,7 @@ impl TabBar { }, ); - Some(indicator.into_any_element()) + Some((indicator.into_any_element(), epoch)) } /// Update animation parameters based on current and previous selection. @@ -400,7 +405,9 @@ impl RenderOnce for TabBar { None }; - let indicator_element = self.render_indicator(&bounds_rc, window, cx); + let indicator = self.render_indicator(&bounds_rc, window, cx); + let indicator_epoch = indicator.as_ref().map(|(_, epoch)| *epoch).unwrap_or(0); + let indicator_element = indicator.map(|(el, _)| el); let indicator_ready = indicator_element.is_some(); let has_suffix_or_menu = self.suffix.is_some() || self.menu; @@ -464,6 +471,7 @@ impl RenderOnce for TabBar { .with_size(self.size); tab.indicator_active = has_indicator; tab.indicator_ready = indicator_ready; + tab.indicator_epoch = indicator_epoch; let tab = tab .when_some(self.selected_index, |this, selected_ix| { this.selected(selected_ix == ix)