Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion crates/ui/src/animation.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -80,6 +80,20 @@ impl Lerp for Point<Pixels> {
}
}

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.
Expand Down
133 changes: 93 additions & 40 deletions crates/ui/src/tab/tab.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -626,6 +632,86 @@ 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
};

// 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()
Expand All @@ -642,12 +728,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| {
Expand Down Expand Up @@ -682,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.
Expand Down
14 changes: 11 additions & 3 deletions crates/ui/src/tab/tab_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rc<RefCell<TabIndicatorBounds>>>,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
) -> Option<(AnyElement, u64)> {
let has_indicator = matches!(
self.variant,
TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Loading