From a0a70980c79d3e808ad22af99bb5458aa41bc3ae Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 25 Sep 2026 10:25:15 +0700 Subject: [PATCH] update tab --- CHANGELOG.md | 1 + crates/dock/src/tab_panel.rs | 314 +++++++++++++++++------------------ 2 files changed, 155 insertions(+), 160 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ea0f1..c6a2e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins +- Redesign the dock tab bar: the active tab is a muted grey, each tab holds a close button that shows on hover or when active, double-clicking a tab zooms its panel, and the trailing panel toolbar is gone ### Fixed diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index 00e4463..c847fe2 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -5,9 +5,9 @@ use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ - Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, - Context, Div, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, - Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement, + Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, + Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render, + ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, div, px, size, }; use gpui_base::dock::{ @@ -18,21 +18,17 @@ use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs}; use gpui_component::animation::{Lerp as _, ease_out_cubic}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom}; -use gpui_component::menu::DropdownMenu as _; -use gpui_component::{ - ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex, -}; +use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex}; use signed_ui::title_bar_drag_handlers; use crate::dock_area::SkinShared; use crate::{TAB_BAR_HEIGHT, t, window_controls}; -/// The drag preview's size, reported to base for the drop placeholder. const DRAG_PREVIEW_SIZE: gpui::Size = size(px(96.), px(30.)); - -/// The key a collapsed dock's stand-in tab bar is tagged with, so a test can -/// measure that it fits the strip base reserves. A no-op outside test builds. const COLLAPSED_TAB_BAR_SELECTOR: &str = "signed-dock-collapsed-tab-bar"; +const ACTIVE_TAB_SELECTOR: &str = "signed-dock-active-tab"; +const ACTIVE_TAB_CLOSE_SELECTOR: &str = "signed-dock-active-tab-close"; +const TAB_HOVER_GROUP: &str = "signed-dock-tab"; /// A panel's title, or its registered name when the panel has no handle. pub(crate) fn panel_title( @@ -47,6 +43,7 @@ pub(crate) fn panel_title( } /// The preview that follows the cursor while a panel is dragged. +/// /// Base's `DragPanel` is the payload and draws nothing, this is the appearance half. struct DragPanelPreview { panel: Arc, @@ -73,9 +70,14 @@ impl Render for DragPanelPreview { } /// The zoom affordance for the group's displayed panel, if it offers one. -/// The panel must offer a control and be zoomable, base refuses a zoom otherwise. fn zoom_control(group: &TabGroupContext, cx: &App) -> Option { - let panel = group.active_panel()?; + panel_zoom_control(group.active_panel()?, cx) +} + +/// The zoom affordance `panel` offers, if any. +/// +/// The panel must offer a control and be zoomable, base refuses a zoom otherwise. +fn panel_zoom_control(panel: &Arc, cx: &App) -> Option { panel .zoomable(cx) .then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx))) @@ -83,6 +85,7 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option { } /// The left-most, top-most tab group in a container. +/// /// A left dock's collapse button lives in this group. fn left_top_group(node: &PaneNode) -> Option { match node.kind() { @@ -92,6 +95,7 @@ fn left_top_group(node: &PaneNode) -> Option { } /// The right-most, top-most tab group. +/// /// A vertical split picks its first child, a horizontal split picks its last. fn right_top_group(node: &PaneNode) -> Option { match node.kind() { @@ -104,6 +108,53 @@ fn right_top_group(node: &PaneNode) -> Option { } } +/// The bottom or right dock whose root tab group is this one, if any. +/// +/// Base keeps a dock's last group, so the skin removes these docks as a whole. +fn dock_root_group( + shared: &SkinShared, + group: &TabGroupContext, + cx: &App, +) -> Option { + let area = shared.area().upgrade()?; + let area = area.read(cx); + + [DockPlacement::Bottom, DockPlacement::Right] + .into_iter() + .find(|placement| { + area.layout(*placement) + .is_some_and(|tree| tree.root().id() == group.node()) + }) +} + +/// Close `panel`, removing its dock instead when base would refuse an empty one. +/// +/// A bottom or right dock's only panel is that case, so the whole dock goes. +fn close_panel( + shared: &SkinShared, + group: &TabGroupContext, + panel: &Arc, + window: &mut Window, + cx: &mut App, +) { + if !panel.closable(cx) { + return; + } + + let dock = (group.panels().len() <= 1) + .then(|| dock_root_group(shared, group, cx)) + .flatten(); + + match dock { + Some(placement) => { + _ = shared.area().update(cx, |area, cx| { + area.remove_dock(placement, window, cx); + }); + } + None => group.close(panel.panel_id(cx), window, cx), + } +} + /// One tab group's appearance, built once per container so its geometry is its own. pub(crate) struct SignedTabGroupSkin { shared: Rc, @@ -146,26 +197,14 @@ impl SignedTabGroupSkin { left == group.node() && group.panels().len() == 1 } - /// The bottom or right dock whose root tab group is this one, if any. - /// Base keeps a dock's last group, so the skin removes these docks as a whole. - fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option { - let area = self.shared.area().upgrade()?; - let area = area.read(cx); - [DockPlacement::Bottom, DockPlacement::Right] - .into_iter() - .find(|placement| { - area.layout(*placement) - .is_some_and(|tree| tree.root().id() == group.node()) - }) - } - /// The tab's drag payload, or `None` when the group must not be rearranged. + /// /// A locked group never is, a bottom or right dock root always is. fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option { if group.is_locked() { return None; } - if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() { + if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() { return None; } group.drag_panel(ix, cx) @@ -277,95 +316,6 @@ impl SignedTabGroupSkin { ) } - /// The trailing controls, the panel's own buttons, zoom and the ellipsis menu. - fn render_toolbar( - &self, - group: &TabGroupContext, - window: &mut Window, - cx: &mut App, - ) -> impl IntoElement { - if group.is_collapsed() { - return div(); - } - - let zoomed = group.is_zoomed(); - let handle = group.active_panel().and_then(PanelHandle::of); - let control = zoom_control(group, cx); - let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible()); - let menu_zoom = control.is_some_and(|control| control.menu_visible()); - // A bottom or right dock's only panel cannot close through the group. - // The close item is offered, the skin removes the whole dock instead. - let closable = group.is_closable() - || (self.is_dock_root_group(group, cx).is_some() - && group.active_panel().is_some_and(|panel| panel.closable(cx))); - let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx)); - let panel = handle.map(|handle| handle.panel()); - - h_flex() - .gap_1() - .occlude() - .when_some(buttons, |this, buttons| { - this.children( - buttons - .into_iter() - .map(|button| button.small().ghost().tab_stop(false)), - ) - }) - .map(|this| { - let value = if zoomed { - Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out"))) - } else if toolbar_zoom { - Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In"))) - } else { - None - }; - - if let Some((id, icon, tooltip)) = value { - this.child( - Button::new(id) - .icon(icon) - .small() - .ghost() - .tab_stop(false) - .tooltip_with_action(tooltip, &ToggleZoom, None) - .selected(zoomed) - .on_click({ - let group = group.clone(); - move |_, window, cx| group.toggle_zoom(window, cx) - }), - ) - } else { - this - } - }) - .child( - Button::new("menu") - .icon(IconName::Ellipsis) - .small() - .ghost() - .tab_stop(false) - .dropdown_menu(move |menu, window, cx| { - menu.when_some(panel.clone(), |menu, panel| { - panel.dropdown_menu(menu, window, cx) - }) - .separator() - .menu_with_disabled( - if zoomed { - t("Dock.Zoom Out") - } else { - t("Dock.Zoom In") - }, - Box::new(ToggleZoom), - !menu_zoom, - ) - .when(closable, |menu| { - menu.separator().menu(t("Dock.Close"), Box::new(ClosePanel)) - }) - }) - .anchor(Anchor::TopRight), - ) - } - /// One tab of the pill strip. /// While collapsed, tabs lose the active style and all interactions. /// The strip is also how a closed bottom dock is opened again. @@ -384,37 +334,87 @@ impl SignedTabGroupSkin { let droppable = group.is_droppable(); let drag = self.tab_drag(group, ix, cx); let handle = PanelHandle::of(&panel); + let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64())); + + let closable = !collapsed + && (group.is_closable() + || (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx))); + + // Whether a double-click zooms this tab in. + let can_zoom_in = panel_zoom_control(&panel, cx).is_some(); Tab::new(ix) - .h_6() - .px_3() - .text_sm() - .whitespace_nowrap() + .group(TAB_HOVER_GROUP) + .when(active, |this| { + this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string()) + }) .flex() .items_center() - .justify_center() - .gap_1() .flex_shrink_0() + .h_7() + .min_w_32() + .gap_1() + .px_1p5() + .text_sm() .overflow_hidden() .rounded(cx.theme().radius) - .text_color(cx.theme().foreground) - .map(|this| match handle.and_then(|handle| handle.tab_name(cx)) { - Some(tab_name) => this.child(tab_name), - None => this.child(panel_title(&panel, window, cx)), + .text_color(cx.theme().tab_foreground) + // The title claims the free space and starts at the left, so a name + // narrower than the tab leaves blank space before the close button. + .child( + h_flex() + .flex_1() + .h_6() + .items_center() + .overflow_hidden() + .whitespace_nowrap() + .map(|this| match handle.and_then(|handle| handle.tab_name(cx)) { + Some(tab_name) => this.child(tab_name), + None => this.child(panel_title(&panel, window, cx)), + }), + ) + .when(closable, |this| { + this.child( + div() + .flex_shrink_0() + .when(active, |this| { + this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string()) + }) + .when(!active, |this| { + this.invisible() + .group_hover(TAB_HOVER_GROUP, |this| this.visible()) + }) + .child( + Button::new(id) + .icon(IconName::Close) + .tooltip(t("Dock.Close")) + .ghost() + .xsmall() + .tab_stop(false) + .on_click({ + let group = group.clone(); + let shared = self.shared.clone(); + let panel = panel.clone(); + move |_, window, cx| { + cx.stop_propagation(); + close_panel(&shared, &group, &panel, window, cx); + } + }), + ), + ) }) - // Pill style, the selected tab is the filled pill, others show only on hover. .styles(|styles| { styles.selected(|style| { style - .text_color(cx.theme().tab_active_foreground) - .bg(cx.theme().tab_active) + .text_color(cx.theme().foreground) + .bg(cx.theme().secondary) }) }) .hover(|this| { if active { this } else { - this.text_color(cx.theme().secondary_foreground) + this.text_color(cx.theme().foreground) .bg(cx.theme().secondary_hover) } }) @@ -422,7 +422,7 @@ impl SignedTabGroupSkin { .on_click({ let group = group.clone(); let area = self.shared.area().clone(); - move |_, window, cx| { + move |event, window, cx| { group.select_tab(ix, window, cx); // Clicking the strip of a collapsed bottom dock reopens it. @@ -430,7 +430,15 @@ impl SignedTabGroupSkin { _ = area.update(cx, |area, cx| { area.toggle_dock(DockPlacement::Bottom, window, cx); }); + return; } + + // A double-click zooms the tab's panel + if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) { + return; + } + + group.toggle_zoom(window, cx); } }) .when(!collapsed, |this| { @@ -533,10 +541,6 @@ impl TabGroupRenderer for SignedTabGroupSkin { if group.panels().is_empty() { return div().id("tab-panel"); } - // Base refuses an empty dock, so closing its only panel removes the dock. - let dock_to_remove = (group.panels().len() <= 1) - .then(|| self.is_dock_root_group(group, cx)) - .flatten(); let shared = self.shared.clone(); // `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse. @@ -545,14 +549,10 @@ impl TabGroupRenderer for SignedTabGroupSkin { .size_full() .overflow_hidden() .bg(cx.theme().tokens.background) - // A collapsed group has no content, so these actions are not registered. .when(!group.is_collapsed(), |this| { this.on_action({ let group = group.clone(); move |_: &ToggleZoom, window, cx| { - // A panel with no zoom control is not zoomed in by the keybinding. - // Zooming out is never refused. - // Otherwise a zoomed panel that lost its control would strand the user. if !group.is_zoomed() && control.is_none() { return; } @@ -566,18 +566,7 @@ impl TabGroupRenderer for SignedTabGroupSkin { let Some(panel) = group.active_panel() else { return; }; - if !panel.closable(cx) { - return; - } - let panel = panel.panel_id(cx); - match dock_to_remove { - Some(placement) => { - _ = shared.area().update(cx, |area, cx| { - area.remove_dock(placement, window, cx); - }); - } - None => group.close(panel, window, cx), - } + close_panel(&shared, &group, panel, window, cx); } }) }) @@ -615,17 +604,23 @@ impl TabGroupRenderer for SignedTabGroupSkin { let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let is_bottom_dock = bottom_dock_button.is_some(); - // On macOS the traffic lights overlay the window's top-left corner. - // Only the tab bar that sits under them reserves the space. - // That is the center's top-left group when the left dock is closed or absent. + // On macOS the traffic lights overlay the window's top-left corner, so the + // tab bar under them reserves the space. That is the center's top-left group + // when the left dock is closed or absent, and the zoomed group otherwise: + // a zoom fills the whole area, hiding the docks the frame would draw. let needs_traffic_light_padding = cfg!(target_os = "macos") && self.shared.area().upgrade().is_some_and(|area| { let area = area.read(cx); - !area.is_dock_open(DockPlacement::Left) - && area - .layout(DockPlacement::Center) - .and_then(|tree| left_top_group(tree.root())) - == Some(group.node()) + match area.zoomed_group() { + Some(zoomed) => zoomed == group.node(), + None => { + !area.is_dock_open(DockPlacement::Left) + && area + .layout(DockPlacement::Center) + .and_then(|tree| left_top_group(tree.root())) + == Some(group.node()) + } + } }); // Bring a newly displayed tab into view. @@ -774,7 +769,6 @@ impl TabGroupRenderer for SignedTabGroupSkin { .and_then(PanelHandle::of) .and_then(|handle| handle.title_suffix(window, cx)), ) - .child(self.render_toolbar(group, window, cx)) .children(right_dock_button), ) }),