diff --git a/crates/component/src/tab/tab_bar.rs b/crates/component/src/tab/tab_bar.rs index 9db5debd11..bfc21ae1d0 100644 --- a/crates/component/src/tab/tab_bar.rs +++ b/crates/component/src/tab/tab_bar.rs @@ -1,9 +1,10 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - Anchor, AnyElement, App, Background, Bounds, Edges, ElementId, InteractiveElement, IntoElement, - ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement as _, - StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, px, + Anchor, AnyElement, App, Background, Bounds, Edges, ElementId, Hsla, InteractiveElement, + IntoElement, ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, + StatefulInteractiveElement as _, StyleRefinement, Styled, Window, canvas, div, fill, + linear_color_stop, linear_gradient, prelude::FluentBuilder as _, px, }; use gpui_base::spring; use rust_i18n::t; @@ -42,6 +43,7 @@ pub struct TabBar { base: gpui_base::Tabs, style: StyleRefinement, scroll_handle: Option, + edge_fade: Option, prefix: Option, suffix: Option, children: SmallVec<[Tab; 2]>, @@ -64,6 +66,7 @@ impl TabBar { style: StyleRefinement::default(), children: SmallVec::new(), scroll_handle: None, + edge_fade: None, prefix: None, suffix: None, variant: TabVariant::default(), @@ -129,6 +132,15 @@ impl TabBar { self } + /// Fade overflowing tabs into the color of the surface behind the bar. + /// + /// Only edges that hide more tabs are faded. Prefixes, suffixes and the + /// overflow menu remain clear, and the fades do not intercept pointer events. + pub fn with_edge_fade(mut self, color: impl Into) -> Self { + self.edge_fade = Some(color.into()); + self + } + /// Set the prefix element of the TabBar pub fn prefix(mut self, prefix: impl IntoElement) -> Self { self.prefix = Some(prefix.into_any_element()); @@ -358,6 +370,18 @@ impl Sizable for TabBar { impl RenderOnce for TabBar { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tab_count = self.children.len(); + let scroll_handle = self.scroll_handle.clone().or_else(|| { + self.edge_fade.map(|_| { + window + .use_keyed_state((self.id.clone(), "edge-fade-scroll"), cx, |_, _| { + ScrollHandle::new() + }) + .read(cx) + .clone() + }) + }); + let edge_fade = self.edge_fade.zip(scroll_handle.clone()); let default_gap = match self.size { Size::Small | Size::XSmall => px(8.), Size::Large => px(16.), @@ -520,6 +544,7 @@ impl RenderOnce for TabBar { .child( h_flex() .id("tabs") + .when(edge_fade.is_some(), |this| this.relative()) .flex_1() .min_w_0() .mx(-padding_x) @@ -544,12 +569,66 @@ impl RenderOnce for TabBar { .gap(gap) .overflow_x_scroll() .lock_scroll_axis() - .when_some(self.scroll_handle, |this, scroll_handle| { + .when_some(scroll_handle, |this, scroll_handle| { this.track_scroll(&scroll_handle) }) .children(rendered_tabs) .when(has_suffix_or_menu, |this| this.child(self.last_empty_space)), - ), + ) + .when_some(edge_fade, |this, (color, handle)| { + this.child( + canvas( + |_, _, _| (), + move |bounds: Bounds, (), window, _| { + // Read after the scroll container's prepaint, so initial + // layout, resize and explicit reveals work in this frame. + let offset = handle.offset().x; + let viewport = handle.bounds(); + let width = + (window.rem_size() * 1.5).min(bounds.size.width / 2.); + for leading in [true, false] { + // Ignore the optional trailing spacer: a fade + // promises hidden tabs, not hidden empty space. + let hidden = if leading { + handle.bounds_for_item(0).is_some_and(|tab| { + tab.left() + offset < viewport.left() - px(1.) + }) + } else { + tab_count + .checked_sub(1) + .and_then(|ix| handle.bounds_for_item(ix)) + .is_some_and(|tab| { + tab.right() + offset > viewport.right() + px(1.) + }) + }; + if !hidden { + continue; + } + let mut edge = bounds; + edge.size.width = width; + if !leading { + edge.origin.x = bounds.right() - width; + } + let (from, to) = if leading { + (color, color.opacity(0.)) + } else { + (color.opacity(0.), color) + }; + window.paint_quad(fill( + edge, + linear_gradient( + 90., + linear_color_stop(from, 0.), + linear_color_stop(to, 1.), + ), + )); + } + }, + ) + .absolute() + .inset_0(), + ) + }), ) .when(self.menu, |this| { this.child( @@ -707,6 +786,107 @@ mod tests { scroll_handle: ScrollHandle, } + struct FadeHarness { + handle: ScrollHandle, + width: Pixels, + clicks: Rc>, + } + + impl Render for FadeHarness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + TabBar::new("fading-tabs") + .outline() + .w(self.width) + .with_edge_fade(cx.theme().background) + .track_scroll(&self.handle) + .children((0..5).map(|ix| { + let clicks = self.clicks.clone(); + Tab::new() + .w(px(80.)) + .label(format!("Tab {ix}")) + .debug_selector(move || format!("fading-tab-{ix}")) + .on_click(move |_, _, _| clicks.set(clicks.get() + 1)) + })) + .suffix( + div() + .w(px(20.)) + .h(px(20.)) + .debug_selector(|| "fade-suffix".into()), + ) + } + } + + fn painted_fades(cx: &mut gpui::VisualTestContext) -> (usize, usize) { + cx.update(|window, cx| { + let color = cx.theme().background; + let gradient = |leading| { + let (from, to) = if leading { + (color, color.opacity(0.)) + } else { + (color.opacity(0.), color) + }; + linear_gradient(90., linear_color_stop(from, 0.), linear_color_stop(to, 1.)) + }; + let quads = window.painted_quads(); + ( + quads + .iter() + .filter(|q| q.background == gradient(true)) + .count(), + quads + .iter() + .filter(|q| q.background == gradient(false)) + .count(), + ) + }) + } + + #[gpui::test] + fn edge_fades_follow_layout_scroll_reveal_and_resize_without_blocking_tabs( + cx: &mut TestAppContext, + ) { + cx.update(crate::theme::init); + let handle = ScrollHandle::new(); + let clicks = Rc::new(Cell::new(0)); + let (view, cx) = cx.add_window_view({ + let handle = handle.clone(); + let clicks = clicks.clone(); + move |_, _| FadeHarness { + handle, + clicks, + width: px(220.), + } + }); + draw(cx); + assert_eq!( + painted_fades(cx), + (0, 1), + "first layout fades only the trailing edge" + ); + // The visible part of the first tab remains clickable beneath the leading fade. + handle.set_offset(gpui::point(px(-20.), px(0.))); + draw(cx); + assert_eq!(painted_fades(cx), (1, 1)); + let tab = cx.debug_bounds("fading-tab-0").unwrap(); + cx.simulate_click(gpui::point(px(8.), tab.center().y), Modifiers::default()); + assert_eq!(clicks.get(), 1); + handle.scroll_to_item(4); + draw(cx); + assert_eq!( + painted_fades(cx), + (1, 0), + "explicit reveal updates fades in the same frame" + ); + let suffix = cx.debug_bounds("fade-suffix").unwrap(); + assert!(suffix.left() >= handle.bounds().right()); + view.update(cx, |view, cx| { + view.width = px(640.); + cx.notify(); + }); + draw(cx); + assert_eq!(painted_fades(cx), (0, 0), "fitting tabs need no fades"); + } + struct DynamicScrollHarness { scroll_handle: ScrollHandle, menu: bool, diff --git a/website/component/tabs.md b/website/component/tabs.md index c6989ab4b9..2022bdebfa 100644 --- a/website/component/tabs.md +++ b/website/component/tabs.md @@ -142,6 +142,22 @@ TabBar::new("tabs-with-controls") .child(Tab::new().label("Settings")) ``` +### Overflow Edge Fades + +Use `with_edge_fade` to soften the clipped ends of a scrolling tab row. Pass the color of the surface behind the bar. An edge fades only while it hides more tabs; prefixes, suffixes and menu buttons stay clear. The fades do not intercept clicks. + +```rust +use gpui_kit::component::ActiveTheme; + +TabBar::new("document-tabs") + .outline() + .with_edge_fade(cx.theme().background) + .child(Tab::new().label("Report")) + .child(Tab::new().label("Notes")) +``` + +Combine it with `track_scroll` when the application needs to reveal a tab programmatically. Otherwise the bar retains its own scroll handle. + ### Disabled Tabs ```rust diff --git a/website/zh-CN/component/tabs.md b/website/zh-CN/component/tabs.md index 4c67398313..d5b1a84526 100644 --- a/website/zh-CN/component/tabs.md +++ b/website/zh-CN/component/tabs.md @@ -114,6 +114,22 @@ TabBar::new("icon-tabs") .child(Tab::default().icon(IconName::Mail).with_variant(TabVariant::Tab)) ``` +### 标签栏两端渐隐 + +用 `with_edge_fade` 让滚动标签栏的裁切处逐渐淡出,参数传标签栏背后的背景色。只有边缘还藏着标签时才显示渐隐;前缀、后缀和菜单按钮保持清晰。渐隐区域仍能点击标签。 + +```rust +use gpui_kit::component::ActiveTheme; + +TabBar::new("document-tabs") + .outline() + .with_edge_fade(cx.theme().background) + .child(Tab::new().label("Report")) + .child(Tab::new().label("Notes")) +``` + +应用需要主动滚动到某个标签时,可以同时使用 `track_scroll`。没有传滚动句柄时,标签栏会保存自己的滚动状态。 + ### 前缀和后缀 ```rust