feat: out-of-box experience (#2)

Reviewed-on: https://git.reya.su/reya/signed/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2026-08-25 13:23:07 +00:00
parent 7249a323f8
commit dacfd49cdf
180 changed files with 16763 additions and 1042 deletions
+902
View File
@@ -0,0 +1,902 @@
//! The Signed appearance for a tab group.
//!
//! `gpui_base::dock::TabGroup` owns the behavior — membership, the displayed
//! tab, drag hit-testing, the zoom flag — and draws none of it. Everything
//! visible is here, ported from the vendored dock: the pill tab bar that
//! doubles as the window title bar (with window controls, title-bar
//! dragging, and previous/next tab buttons), the toolbar, the ellipsis menu,
//! the dock collapse affordance, the drop placeholder, and the styled drag
//! preview.
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
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,
Styled as _, Window, div, px, size,
};
use gpui_base::dock::{
AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef,
PanelView as BasePanelView, TabGroupContext, TabGroupRenderer,
};
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::menu::DropdownMenu as _;
use gpui_component::{
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
};
use crate::dock_area::SkinShared;
use crate::{
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, title_bar_drag_handlers,
window_controls,
};
/// The size the styled drag preview occupies, reported to base so a drop
/// placeholder knows where to fly in from.
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
/// A panel's title, or its registered name when it reached base without this
/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
pub(crate) fn panel_title(
panel: &Arc<dyn BasePanelView>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
match PanelHandle::of(panel) {
Some(handle) => handle.title(window, cx),
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
}
}
/// The preview that follows the cursor while a panel is dragged.
///
/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
/// appearance half, reintroduced here.
struct DragPanelPreview {
panel: Arc<dyn BasePanelView>,
}
impl Render for DragPanelPreview {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.id("drag-panel")
.cursor_grab()
.py_1()
.px_3()
.w_24()
.overflow_hidden()
.whitespace_nowrap()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.text_color(cx.theme().tab_foreground)
.bg(cx.theme().tokens.tab_active)
.opacity(0.75)
.child(panel_title(&self.panel, window, cx))
}
}
/// Where the zoom affordance goes for the group's displayed panel, or `None`
/// when there is none to offer.
///
/// Two questions, and both have to be asked. [`Panel::zoom_control`] says
/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says
/// whether zooming happens at all, and base refuses a zoom that fails it.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
let panel = group.active_panel()?;
panel
.zoomable(cx)
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
.flatten()
}
/// The left-most, top-most tab group in a container — where a left dock's
/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
PaneRef::Tiles { .. } => None,
}
}
/// The right-most, top-most tab group. A vertical split stacks its children,
/// so its *first* child is the top one; a horizontal split's last child is
/// the right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { axis, children, .. } => match axis {
gpui::Axis::Vertical => children.first(),
gpui::Axis::Horizontal => children.last(),
}
.and_then(right_top_group),
PaneRef::Tiles { .. } => None,
}
}
/// One tab group's appearance.
///
/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once
/// per container — so the tab bar's scroll position and the measured
/// title-bar geometry belong to the group they describe.
pub(crate) struct SignedTabGroupSkin {
shared: Rc<SkinShared>,
scroll_handle: ScrollHandle,
/// The displayed tab the last frame drew, so a change scrolls the new tab
/// into view.
last_active_ix: Cell<Option<usize>>,
/// Bounds of the title bar row (the wrapper around the tab bar), in
/// window coordinates. Measured via `on_prepaint` to position the
/// title-bar drag overlay.
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the tab bar's trailing empty space (right after the last
/// tab), which marks where the draggable region starts.
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the tab bar's suffix (toolbar) area, which marks where the
/// draggable region ends.
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
}
impl SignedTabGroupSkin {
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
Self {
shared,
scroll_handle: ScrollHandle::default(),
last_active_ix: Cell::new(None),
title_bar_bounds: Rc::new(Cell::new(None)),
title_bar_strip_bounds: Rc::new(Cell::new(None)),
title_bar_suffix_bounds: Rc::new(Cell::new(None)),
}
}
/// A group that is the left dock's whole content with a single panel
/// draws no chrome at all — the vendored dock rendered such a panel bare,
/// and the sidebar is one.
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
let Some(area) = self.shared.area().upgrade() else {
return false;
};
let area = area.read(cx);
let Some(left) = area
.layout(DockPlacement::Left)
.map(|tree| tree.root().id())
else {
return false;
};
left == group.node() && group.panels().len() == 1
}
/// The bottom or right dock whose root tab group this group is, if any.
///
/// Base bars a dock's only group from being dragged or closed, so the
/// dock cannot be emptied. A bottom/right panel is supposed to be
/// closable and movable, though — the vendored dock allowed exactly that
/// — so the skin recognizes the group and routes around the bar.
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 drag payload for the tab at `ix`, or `None` when this group must
/// not be rearranged. A locked group is never draggable; a group that is
/// a bottom/right dock's only content still is, because the center is
/// always there to land in.
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
if group.is_locked() {
return None;
}
if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() {
return None;
}
group.drag_panel(ix, cx)
}
/// Whether a dock's collapse affordance belongs in *this* group's tab
/// bar, and which way it points. `None` means this group draws none.
fn dock_toggle_button(
&self,
placement: DockPlacement,
group: &TabGroupContext,
cx: &mut App,
) -> Option<Button> {
if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
return None;
}
let area = self.shared.area().upgrade()?;
let area = area.read(cx);
// A dock that does not exist is not collapsible, so this covers the
// old `left_dock.is_some()` test too.
if !area.is_dock_collapsible(placement) {
return None;
}
let designated = match placement {
DockPlacement::Left => area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Right => area
.layout(DockPlacement::Center)
.and_then(|tree| right_top_group(tree.root())),
DockPlacement::Bottom => area
.layout(DockPlacement::Bottom)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Center => None,
};
if designated != Some(group.node()) {
return None;
}
let is_open = area.is_dock_open(placement);
let icon = match (placement, is_open) {
(DockPlacement::Left, true) => IconName::PanelLeft,
(DockPlacement::Left, false) => IconName::PanelLeftOpen,
(DockPlacement::Right, true) => IconName::PanelRight,
(DockPlacement::Right, false) => IconName::PanelRightOpen,
(DockPlacement::Bottom, true) => IconName::PanelBottom,
(DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
(DockPlacement::Center, _) => return None,
};
let area = self.shared.area().clone();
Some(
Button::new(SharedString::from(format!("toggle-dock:{placement:?}")))
.icon(icon)
.small()
.ghost()
.tab_stop(false)
.tooltip(match is_open {
true => t("Dock.Collapse"),
false => t("Dock.Expand"),
})
.on_click(move |_, window, cx| {
_ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
}),
)
}
/// The previous/next tab buttons shown in the tab bar's leading prefix.
///
/// Unlike the dock toggle button they always render, but are disabled at
/// the ends of the tab strip (or while the panel is collapsed).
fn render_prev_next_tab_buttons(
&self,
group: &TabGroupContext,
_cx: &mut App,
) -> impl IntoElement {
let collapsed = group.is_collapsed();
let active_ix = group.active_ix();
let panels_len = group.panels().len();
let prev_enabled = !collapsed && active_ix > 0;
let next_enabled = !collapsed && active_ix + 1 < panels_len;
h_flex()
.gap_1()
.child(
Button::new("tab:prev")
.icon(IconName::ArrowLeft)
.small()
.ghost()
.tab_stop(false)
.tooltip("Previous tab")
.disabled(!prev_enabled)
.on_click({
let group = group.clone();
move |_, window, cx| group.select_tab(active_ix - 1, window, cx)
}),
)
.child(
Button::new("tab:next")
.icon(IconName::ArrowRight)
.small()
.ghost()
.tab_stop(false)
.tooltip("Next tab")
.disabled(!next_enabled)
.on_click({
let group = group.clone();
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
}),
)
}
/// The trailing controls: the panel's own buttons, the zoom affordance,
/// 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/right dock's only panel cannot be closed through the
// group (base keeps a dock's last group), but the skin handles that
// close by removing the whole dock, so the item is offered.
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, and
/// the strip becomes the way a closed bottom dock is opened again.
#[allow(clippy::too_many_arguments)]
fn render_tab(
&self,
group: &TabGroupContext,
ix: usize,
panel: Arc<dyn BasePanelView>,
active: bool,
is_bottom_dock: bool,
window: &mut Window,
cx: &mut App,
) -> Tab {
let collapsed = group.is_collapsed();
let droppable = group.is_droppable();
let drag = self.tab_drag(group, ix, cx);
let handle = PanelHandle::of(&panel);
Tab::new(ix)
.h_6()
.px_3()
.text_sm()
.whitespace_nowrap()
.flex()
.items_center()
.justify_center()
.gap_1()
.flex_shrink_0()
.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)),
})
// Pill presentation: the selected tab is the filled pill, the
// rest are transparent until hovered.
.styles(|styles| {
styles.selected(|style| {
style
.text_color(cx.theme().tab_active_foreground)
.bg(cx.theme().tab_active)
})
})
.hover(|this| {
if active {
this
} else {
this.text_color(cx.theme().secondary_foreground)
.bg(cx.theme().secondary_hover)
}
})
.selected(active)
.on_click({
let group = group.clone();
let area = self.shared.area().clone();
move |_, window, cx| {
group.select_tab(ix, window, cx);
// Clicking the strip of a collapsed bottom dock is how it
// is opened again.
if is_bottom_dock && collapsed {
_ = area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Bottom, window, cx);
});
}
}
})
.when(!collapsed, |this| {
this.when_some(drag, |this, drag| {
this.on_drag(drag, {
let panel = panel.clone();
move |drag, offset, _, cx| {
cx.stop_propagation();
drag.set_drag_offset(offset);
drag.set_preview_size(DRAG_PREVIEW_SIZE);
cx.new(|_| DragPanelPreview {
panel: panel.clone(),
})
}
})
})
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drag_border)
})
.on_drop({
let group = group.clone();
move |drag: &DragPanel, window, cx| {
group.drop_panel(drag.clone(), Some(ix), true, window, cx);
}
})
.drag_over::<AnyDrag>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drag_border)
})
.on_drop({
let group = group.clone();
move |item: &AnyDrag, window, cx| {
group.drop_item(item.clone(), None, window, cx);
}
})
})
})
}
/// The strip after the last tab: a drop target for panels and host-owned
/// drag items. Its left edge (right after the last tab) marks the start
/// of the title-bar drag overlay.
fn render_empty_space(
&self,
group: &TabGroupContext,
tabs_count: usize,
_cx: &mut App,
) -> AnyElement {
let strip_bounds = self.title_bar_strip_bounds.clone();
let shared = self.shared.clone();
let droppable = group.is_droppable();
let mut empty = div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.on_prepaint(move |bounds, _, cx| {
if strip_bounds.get() != Some(bounds) {
strip_bounds.set(Some(bounds));
_ = shared.area().update(cx, |_, cx| cx.notify());
}
});
if droppable {
empty = empty
.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
.on_drop({
let group = group.clone();
let node = group.node();
move |drag: &DragPanel, window, cx| {
// A panel dropped past its own last tab lands in the
// final slot; one from elsewhere is appended in the
// background.
let ix = (drag.source() == node).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
.drag_over::<AnyDrag>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
.on_drop({
let group = group.clone();
move |item: &AnyDrag, window, cx| {
group.drop_item(item.clone(), None, window, cx);
}
});
}
empty.into_any_element()
}
}
impl TabGroupRenderer for SignedTabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let control = zoom_control(group, cx);
// An emptied group — its last panel was dragged away — draws nothing,
// so an emptied dock does not leave a bare tab bar behind.
if group.panels().is_empty() {
return div().id("tab-panel");
}
// Closing the only panel of a bottom/right dock would leave an empty
// dock, which base refuses through the group. The skin removes the
// whole dock instead — the vendored dock's close took its split
// group away just the same.
let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx))
.flatten();
let shared = self.shared.clone();
// `v_flex`, not `div`: gpui's default display is Block, and in block
// layout a child's `flex_grow` is ignored — the content region below
// the tab bar would resolve to zero height.
v_flex()
.id("tab-panel")
.size_full()
.overflow_hidden()
.bg(cx.theme().tokens.background)
// A collapsed group is a strip of tabs with no content, and the
// actions act on content.
.when(!group.is_collapsed(), |this| {
this.on_action({
let group = group.clone();
move |_: &ToggleZoom, window, cx| {
// The affordance decides the control, so a panel that
// offers none is not zoomed *in* by the keybinding
// either. Zooming out is never refused: a panel that
// stopped offering the control while zoomed would
// otherwise strand the user with no way back.
if !group.is_zoomed() && control.is_none() {
return;
}
group.toggle_zoom(window, cx);
}
})
.on_action({
let group = group.clone();
let shared = shared.clone();
move |_: &ClosePanel, window, cx| {
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),
}
}
})
})
}
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
v_flex()
.id("active-panel")
// A collapsed group draws its tab strip and nothing else, so the
// content region must not claim any space.
.when(!group.is_collapsed(), |this| this.flex_1())
}
fn render_tab_bar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// An emptied group draws no tab bar; the app prunes the emptied
// bottom/right dock a moment later.
if group.panels().is_empty() {
return Empty.into_any_element();
}
// The sidebar group draws no chrome at all, like the vendored dock's
// bare `DockItem::Panel`.
if self.is_plain_sidebar_group(group, cx) {
return Empty.into_any_element();
}
let collapsed = group.is_collapsed();
let active_ix = group.active_ix();
let tabs_count = group.panels().len();
let left_dock_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
let bottom_dock_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. Only
// the group whose tab bar actually sits under them must reserve the
// space: the left dock (sidebar) normally clears them, and when it is
// closed or absent it is the center's left-most, top-most tab group
// that is in the corner. A bottom or right dock is never there, and
// neither is the right panel of a center split.
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())
});
// Bring a newly displayed tab into view. The group owns selection
// now, so the skin notices the change rather than being told about it.
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
let visible: Vec<usize> = group
.panels()
.iter()
.enumerate()
.filter(|(_, panel)| panel.visible(cx))
.map(|(ix, _)| ix)
.collect();
if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix)
&& let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix)
{
self.scroll_handle.scroll_to_item(visible_ix);
}
// The tab strip lays out its scrollable content at content width, so
// the area after the last tab only spans `min_w_16` — the rest of the
// tab bar has no element at all. Cover that dead zone with a
// measured overlay so the whole non-interactive area can drag the
// window. Its span is [last tab's right edge, suffix's left edge].
let drag_overlay = match (
self.title_bar_bounds.get(),
self.title_bar_strip_bounds.get(),
self.title_bar_suffix_bounds.get(),
) {
(Some(title), Some(strip), Some(suffix)) => {
let left = strip.left() - title.left();
let right = title.right() - suffix.left();
(left + right < title.size.width).then(|| {
title_bar_drag_handlers(
div()
.id(("title-bar-drag", group.node().as_u64()))
.absolute()
.top_0()
.bottom_0()
.left(left)
.right(right),
window,
cx,
)
})
}
_ => None,
};
let tabs: Vec<_> = group
.panels()
.iter()
.enumerate()
.filter_map(|(ix, panel)| {
let mut active = displayed == Some(panel.panel_id(cx));
if !panel.visible(cx) {
return None;
}
// A collapsed group shows no tab as active: the strip is a
// way back in, not a selection.
if collapsed {
active = false;
}
Some(self.render_tab(group, ix, panel.clone(), active, is_bottom_dock, window, cx))
})
.collect();
let empty_space = self.render_empty_space(group, tabs_count, cx);
let title_bar_bounds = self.title_bar_bounds.clone();
let title_bar_suffix_bounds = self.title_bar_suffix_bounds.clone();
let shared = self.shared.clone();
let suffix_shared = self.shared.clone();
div()
.flex()
.flex_row()
.child(
div()
.relative()
.flex_1()
.min_w_0()
.on_prepaint(move |bounds, _, cx| {
if title_bar_bounds.get() != Some(bounds) {
title_bar_bounds.set(Some(bounds));
_ = shared.area().update(cx, |_, cx| cx.notify());
}
})
.child(
Tabs::new("tab-bar")
.px(px(-1.))
.h(TAB_BAR_HEIGHT)
.flex()
.items_center()
.text_color(cx.theme().tab_foreground)
.child(
h_flex()
.items_center()
.top_0()
// Right -1 for avoid border overlap with the first tab
.right(-px(1.))
.h_full()
.gap_2()
.px_2()
.when(needs_traffic_light_padding, |this| this.pl(px(80.)))
.children(left_dock_button)
.children(bottom_dock_button)
.child(self.render_prev_next_tab_buttons(group, cx)),
)
.child(
h_flex().id("tabs").flex_1().overflow_x_hidden().child(
h_flex()
.id("tabs-inner")
.relative()
.gap(px(4.))
.overflow_x_scroll()
.lock_scroll_axis()
.track_scroll(&self.scroll_handle)
.children(tabs)
.when(!collapsed, |this| this.child(empty_space)),
),
)
.when(!collapsed, |this| {
this.child(
h_flex()
.items_center()
.top_0()
.right_0()
.h_full()
.px_2()
.gap_1()
.on_prepaint(move |bounds, _, cx| {
if title_bar_suffix_bounds.get() != Some(bounds) {
title_bar_suffix_bounds.set(Some(bounds));
_ = suffix_shared
.area()
.update(cx, |_, cx| cx.notify());
}
})
.children(
group
.active_panel()
.and_then(PanelHandle::of)
.and_then(|handle| handle.title_suffix(window, cx)),
)
.child(self.render_toolbar(group, window, cx))
.children(right_dock_button),
)
}),
)
.when_some(drag_overlay, |this, overlay| this.child(overlay)),
)
.child(window_controls::window_controls(window, cx))
.into_any_element()
}
fn render_active_panel(
&self,
panel: AnyView,
group: &TabGroupContext,
_: &mut Window,
_: &mut App,
) -> AnyElement {
if group.is_collapsed() {
return Empty.into_any_element();
}
div()
.id("tab-content")
.overflow_y_scroll()
.overflow_x_hidden()
.flex_1()
.child(panel.cached(StyleRefinement::default().absolute().size_full()))
.into_any_element()
}
fn render_drop_indicator(
&self,
indicator: DropIndicator,
_: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let (from, to) = (indicator.from(), indicator.to());
// The placeholder animates from wherever it was to where the drop
// would land, so its own element is positioned at the destination and
// the animation only has to walk the difference back to zero.
let offset = from.origin() - to.origin();
Some(
div()
.absolute()
.left(to.origin().x)
.top(to.origin().y)
.w(to.size().width)
.h(to.size().height)
.child(
div()
.absolute()
.bg(cx.theme().tokens.drop_target)
.with_animation(
gpui::ElementId::NamedInteger(
"drop-placeholder".into(),
indicator.epoch(),
),
Animation::new(Duration::from_millis(150)).with_easing(ease_out_cubic),
move |this, delta| {
let origin = offset.lerp(&Point::default(), delta);
let width = from.size().width.lerp(&to.size().width, delta);
let height = from.size().height.lerp(&to.size().height, delta);
this.left(origin.x).top(origin.y).w(width).h(height)
},
),
)
.into_any_element(),
)
}
}