update tab

This commit is contained in:
2026-09-25 10:25:15 +07:00
parent 669ca2d57b
commit a0a70980c7
2 changed files with 155 additions and 160 deletions
+1
View File
@@ -9,6 +9,7 @@
### Changed ### Changed
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins - 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 ### Fixed
+146 -152
View File
@@ -5,9 +5,9 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
Context, Div, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
Styled as _, Window, div, px, size, Styled as _, Window, div, px, size,
}; };
use gpui_base::dock::{ 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::animation::{Lerp as _, ease_out_cubic};
use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom}; use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
use gpui_component::menu::DropdownMenu as _; use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
use gpui_component::{
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
};
use signed_ui::title_bar_drag_handlers; use signed_ui::title_bar_drag_handlers;
use crate::dock_area::SkinShared; use crate::dock_area::SkinShared;
use crate::{TAB_BAR_HEIGHT, t, window_controls}; 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<gpui::Pixels> = size(px(96.), px(30.)); const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = 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 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. /// A panel's title, or its registered name when the panel has no handle.
pub(crate) fn panel_title( 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. /// 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. /// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
struct DragPanelPreview { struct DragPanelPreview {
panel: Arc<dyn BasePanelView>, panel: Arc<dyn BasePanelView>,
@@ -73,9 +70,14 @@ impl Render for DragPanelPreview {
} }
/// The zoom affordance for the group's displayed panel, if it offers one. /// 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<PanelControl> { fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
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<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
panel panel
.zoomable(cx) .zoomable(cx)
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx))) .then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
@@ -83,6 +85,7 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
} }
/// The left-most, top-most tab group in a container. /// The left-most, top-most tab group in a container.
///
/// A left dock's collapse button lives in this group. /// A left dock's collapse button lives in this group.
fn left_top_group(node: &PaneNode) -> Option<NodeId> { fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() { match node.kind() {
@@ -92,6 +95,7 @@ fn left_top_group(node: &PaneNode) -> Option<NodeId> {
} }
/// The right-most, top-most tab group. /// The right-most, top-most tab group.
///
/// A vertical split picks its first child, a horizontal split picks its last. /// A vertical split picks its first child, a horizontal split picks its last.
fn right_top_group(node: &PaneNode) -> Option<NodeId> { fn right_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() { match node.kind() {
@@ -104,6 +108,53 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
} }
} }
/// 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<DockPlacement> {
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<dyn BasePanelView>,
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. /// One tab group's appearance, built once per container so its geometry is its own.
pub(crate) struct SignedTabGroupSkin { pub(crate) struct SignedTabGroupSkin {
shared: Rc<SkinShared>, shared: Rc<SkinShared>,
@@ -146,26 +197,14 @@ impl SignedTabGroupSkin {
left == group.node() && group.panels().len() == 1 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<DockPlacement> {
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. /// 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. /// A locked group never is, a bottom or right dock root always is.
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> { fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
if group.is_locked() { if group.is_locked() {
return None; 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; return None;
} }
group.drag_panel(ix, cx) 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. /// One tab of the pill strip.
/// While collapsed, tabs lose the active style and all interactions. /// While collapsed, tabs lose the active style and all interactions.
/// The strip is also how a closed bottom dock is opened again. /// The strip is also how a closed bottom dock is opened again.
@@ -384,37 +334,87 @@ impl SignedTabGroupSkin {
let droppable = group.is_droppable(); let droppable = group.is_droppable();
let drag = self.tab_drag(group, ix, cx); let drag = self.tab_drag(group, ix, cx);
let handle = PanelHandle::of(&panel); 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) Tab::new(ix)
.h_6() .group(TAB_HOVER_GROUP)
.px_3() .when(active, |this| {
.text_sm() this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
.whitespace_nowrap() })
.flex() .flex()
.items_center() .items_center()
.justify_center()
.gap_1()
.flex_shrink_0() .flex_shrink_0()
.h_7()
.min_w_32()
.gap_1()
.px_1p5()
.text_sm()
.overflow_hidden() .overflow_hidden()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.text_color(cx.theme().foreground) .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)) { .map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
Some(tab_name) => this.child(tab_name), Some(tab_name) => this.child(tab_name),
None => this.child(panel_title(&panel, window, cx)), 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(|styles| {
styles.selected(|style| { styles.selected(|style| {
style style
.text_color(cx.theme().tab_active_foreground) .text_color(cx.theme().foreground)
.bg(cx.theme().tab_active) .bg(cx.theme().secondary)
}) })
}) })
.hover(|this| { .hover(|this| {
if active { if active {
this this
} else { } else {
this.text_color(cx.theme().secondary_foreground) this.text_color(cx.theme().foreground)
.bg(cx.theme().secondary_hover) .bg(cx.theme().secondary_hover)
} }
}) })
@@ -422,7 +422,7 @@ impl SignedTabGroupSkin {
.on_click({ .on_click({
let group = group.clone(); let group = group.clone();
let area = self.shared.area().clone(); let area = self.shared.area().clone();
move |_, window, cx| { move |event, window, cx| {
group.select_tab(ix, window, cx); group.select_tab(ix, window, cx);
// Clicking the strip of a collapsed bottom dock reopens it. // Clicking the strip of a collapsed bottom dock reopens it.
@@ -430,7 +430,15 @@ impl SignedTabGroupSkin {
_ = area.update(cx, |area, cx| { _ = area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Bottom, window, 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| { .when(!collapsed, |this| {
@@ -533,10 +541,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
if group.panels().is_empty() { if group.panels().is_empty() {
return div().id("tab-panel"); 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(); let shared = self.shared.clone();
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse. // `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
@@ -545,14 +549,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
.size_full() .size_full()
.overflow_hidden() .overflow_hidden()
.bg(cx.theme().tokens.background) .bg(cx.theme().tokens.background)
// A collapsed group has no content, so these actions are not registered.
.when(!group.is_collapsed(), |this| { .when(!group.is_collapsed(), |this| {
this.on_action({ this.on_action({
let group = group.clone(); let group = group.clone();
move |_: &ToggleZoom, window, cx| { 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() { if !group.is_zoomed() && control.is_none() {
return; return;
} }
@@ -566,18 +566,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let Some(panel) = group.active_panel() else { let Some(panel) = group.active_panel() else {
return; return;
}; };
if !panel.closable(cx) { close_panel(&shared, &group, panel, window, 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),
}
} }
}) })
}) })
@@ -615,17 +604,23 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some(); let is_bottom_dock = bottom_dock_button.is_some();
// On macOS the traffic lights overlay the window's top-left corner. // On macOS the traffic lights overlay the window's top-left corner, so the
// Only the tab bar that sits under them reserves the space. // tab bar under them reserves the space. That is the center's top-left group
// That is the center's top-left group when the left dock is closed or absent. // 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") let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self.shared.area().upgrade().is_some_and(|area| { && self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx); let area = area.read(cx);
match area.zoomed_group() {
Some(zoomed) => zoomed == group.node(),
None => {
!area.is_dock_open(DockPlacement::Left) !area.is_dock_open(DockPlacement::Left)
&& area && area
.layout(DockPlacement::Center) .layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root())) .and_then(|tree| left_top_group(tree.root()))
== Some(group.node()) == Some(group.node())
}
}
}); });
// Bring a newly displayed tab into view. // Bring a newly displayed tab into view.
@@ -774,7 +769,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
.and_then(PanelHandle::of) .and_then(PanelHandle::of)
.and_then(|handle| handle.title_suffix(window, cx)), .and_then(|handle| handle.title_suffix(window, cx)),
) )
.child(self.render_toolbar(group, window, cx))
.children(right_dock_button), .children(right_dock_button),
) )
}), }),