Files
signed/crates/dock/src/tab_panel.rs
T
2026-08-19 10:13:29 +07:00

2394 lines
88 KiB
Rust

use std::cell::Cell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, Animation, AnimationExt as _, App, AppContext, Bounds, Context, DismissEvent, Div,
DragMoveEvent, Empty, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels, Point, Render,
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement, StyleRefinement, Styled,
WeakEntity, Window, WindowControlArea, div, point, px, rems,
};
use gpui_component::animation::{Lerp, ease_out_cubic};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::menu::{DropdownMenu, PopupMenu};
use gpui_component::tab::{Tab, TabBar};
use gpui_component::{
ActiveTheme, AxisExt, ElementExt, IconName, Placement, Selectable, Sizable, h_flex, v_flex,
};
use super::{
AnyDrag, ClosePanel, DockArea, DockEvent, DockPlacement, DropTarget, Panel, PanelControl,
PanelEvent, PanelState, PanelStyle, PanelView, StackPanel, ToggleZoom,
};
use crate::{PanelInfo, t, window_controls};
#[derive(Clone)]
struct TabState {
closable: bool,
zoomable: Option<PanelControl>,
draggable: bool,
droppable: bool,
active_panel: Option<Arc<dyn PanelView>>,
}
#[derive(Clone)]
pub(crate) struct DragPanel {
pub(crate) panel: Arc<dyn PanelView>,
pub(crate) tab_panel: Entity<TabPanel>,
drag_offset: Rc<Cell<Point<Pixels>>>,
drag_session_id: u64,
}
static NEXT_DRAG_SESSION_ID: AtomicU64 = AtomicU64::new(1);
/// Stands in for [`DragPanel::drag_session_id`] on host-owned drag items, which
/// carry no session of their own. `NEXT_DRAG_SESSION_ID` starts at 1, so 0 never
/// collides.
const ITEM_DRAG_SESSION_ID: u64 = 0;
impl DragPanel {
pub(crate) fn new(panel: Arc<dyn PanelView>, tab_panel: Entity<TabPanel>) -> Self {
Self {
panel,
tab_panel,
drag_offset: Rc::new(Cell::new(Point::default())),
drag_session_id: NEXT_DRAG_SESSION_ID.fetch_add(1, Ordering::Relaxed),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct DropPlaceholderBounds {
origin: Point<Pixels>,
size: gpui::Size<Pixels>,
}
impl DropPlaceholderBounds {
fn for_placement(bounds: gpui::Bounds<Pixels>, placement: Option<Placement>) -> Self {
let half_width = bounds.size.width * 0.5;
let half_height = bounds.size.height * 0.5;
match placement {
Some(Placement::Left) => Self {
origin: Point::default(),
size: gpui::size(half_width, bounds.size.height),
},
Some(Placement::Right) => Self {
origin: point(half_width, px(0.)),
size: gpui::size(half_width, bounds.size.height),
},
Some(Placement::Top) => Self {
origin: Point::default(),
size: gpui::size(bounds.size.width, half_height),
},
Some(Placement::Bottom) => Self {
origin: point(px(0.), half_height),
size: gpui::size(bounds.size.width, half_height),
},
None => Self {
origin: Point::default(),
size: bounds.size,
},
}
}
}
#[derive(Clone, Copy, Debug)]
struct DropPlaceholderAnimation {
drag_session_id: u64,
placement: Option<Placement>,
from: DropPlaceholderBounds,
to: DropPlaceholderBounds,
epoch: u64,
}
impl Render for DragPanel {
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(self.panel.title(window, cx))
}
}
pub struct TabPanel {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
/// The stock_panel can be None, if is None, that means the panels can't be split or move
stack_panel: Option<WeakEntity<StackPanel>>,
pub(crate) panels: Vec<Arc<dyn PanelView>>,
pub(crate) active_ix: usize,
/// What each panel was last told via `set_active`, keyed by EntityId; absent means `false`.
notified_active: HashMap<EntityId, bool>,
/// Whether an active-state reconcile task is already queued for this frame.
active_sync_scheduled: bool,
/// If this is true, the Panel closable will follow the active panel's closable,
/// otherwise this TabPanel will not able to close
///
/// This is used for Dock to limit the last TabPanel not able to close, see [`super::Dock::new`].
pub(crate) closable: bool,
tab_bar_scroll_handle: ScrollHandle,
pending_scroll_to_ix: Option<usize>,
zoomed: bool,
collapsed: bool,
/// When drag move, will get the placement of the panel to be split
will_split_placement: Option<Placement>,
drop_placeholder_animation: Option<DropPlaceholderAnimation>,
drop_placeholder_animation_name: SharedString,
/// 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: 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: Option<Bounds<Pixels>>,
/// Bounds of the tab bar's suffix (toolbar) area, which marks where the
/// draggable region ends.
title_bar_suffix_bounds: Option<Bounds<Pixels>>,
}
impl Panel for TabPanel {
fn panel_name(&self) -> &'static str {
"TabPanel"
}
fn title(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.active_panel(cx)
.map(|panel| panel.title(window, cx))
.unwrap_or("Empty Tab".into_any_element())
}
fn closable(&self, cx: &App) -> bool {
if !self.closable {
return false;
}
// The final panel in the dock is not closable.
if !self.draggable(cx) {
return false;
}
self.active_panel(cx)
.map(|panel| panel.closable(cx))
.unwrap_or(false)
}
fn zoomable(&self, cx: &App) -> Option<PanelControl> {
self.active_panel(cx).and_then(|panel| panel.zoomable(cx))
}
fn visible(&self, cx: &App) -> bool {
self.visible_panels(cx).next().is_some()
}
fn dropdown_menu(
&mut self,
menu: PopupMenu,
window: &mut Window,
cx: &mut Context<Self>,
) -> PopupMenu {
if let Some(panel) = self.active_panel(cx) {
panel.dropdown_menu(menu, window, cx)
} else {
menu
}
}
fn toolbar_buttons(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Vec<Button>> {
self.active_panel(cx)
.and_then(|panel| panel.toolbar_buttons(window, cx))
}
fn dump(&self, cx: &App) -> PanelState {
let mut state = PanelState::new(self);
for panel in self.panels.iter() {
state.add_child(panel.dump(cx));
state.info = PanelInfo::tabs(self.active_ix);
}
state
}
fn inner_padding(&self, cx: &App) -> bool {
self.active_panel(cx)
.is_none_or(|panel| panel.inner_padding(cx))
}
}
/// State used to move the window when the title bar area is dragged.
struct WindowDragState {
should_move: bool,
}
/// Make an element behave like a window title bar: dragging it moves the
/// window, and double-clicking zooms the window (or performs the platform's
/// default title-bar double-click action on macOS).
///
/// Only the bar's non-interactive areas should get this — tabs are draggable
/// (to reorder panels) and must not move the window.
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let state = window.use_state(cx, |_, _| WindowDragState { should_move: false });
this.window_control_area(WindowControlArea::Drag)
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.on_click(|event, window, _| {
if event.click_count() == 2 {
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else {
window.zoom_window();
}
}
})
}
impl TabPanel {
pub fn new(
stack_panel: Option<WeakEntity<StackPanel>>,
dock_area: WeakEntity<DockArea>,
_: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let entity_id = cx.entity_id();
Self {
focus_handle: cx.focus_handle(),
dock_area,
stack_panel,
panels: Vec::new(),
active_ix: 0,
notified_active: HashMap::new(),
active_sync_scheduled: false,
tab_bar_scroll_handle: ScrollHandle::new(),
pending_scroll_to_ix: None,
will_split_placement: None,
drop_placeholder_animation: None,
drop_placeholder_animation_name: format!("dock-drop-placeholder-{entity_id}").into(),
zoomed: false,
collapsed: false,
closable: true,
title_bar_bounds: None,
title_bar_strip_bounds: None,
title_bar_suffix_bounds: None,
}
}
pub(super) fn set_parent(&mut self, view: WeakEntity<StackPanel>) {
self.stack_panel = Some(view);
}
/// Return current active_panel View
pub fn active_panel(&self, cx: &App) -> Option<Arc<dyn PanelView>> {
let panel = self.panels.get(self.active_ix);
if let Some(panel) = panel {
if panel.visible(cx) {
Some(panel.clone())
} else {
// Return the first visible panel
self.visible_panels(cx).next()
}
} else {
None
}
}
pub fn active_ix(&self) -> usize {
self.active_ix
}
fn set_active_ix(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
if ix == self.active_ix {
return;
}
self.active_ix = ix;
self.pending_scroll_to_ix = Some(ix);
self.focus_active_panel(window, cx);
self.schedule_active_sync(window, cx);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Queue one reconcile task per frame that notifies panels of their
/// frame-end net active state. Using a spawned task (not `defer`) is what
/// guarantees the task runs after every same-frame mutation, including
/// deferred `set_collapsed` from [`super::Dock::set_open`].
fn schedule_active_sync(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_sync_scheduled {
return;
}
self.active_sync_scheduled = true;
cx.spawn_in(window, async move |view, cx| {
_ = cx.update(|window, cx| {
let Ok(changes) = view.update(cx, |view, _| view.reconcile_active_states()) else {
return;
};
// Dispatch outside the TabPanel update so a `set_active`
// handler may call back into this TabPanel without panicking.
for (panel, active) in changes {
panel.set_active(active, window, cx);
}
});
})
.detach();
}
/// Diff every panel's target state (`ix == active_ix && !collapsed`)
/// against what it was last told, returning the deliveries to make —
/// all `false` first, the single `true` last. Panels no longer in the
/// group are pruned without a `false`: `on_removed` is their signal.
fn reconcile_active_states(&mut self) -> Vec<(Arc<dyn PanelView>, bool)> {
self.active_sync_scheduled = false;
let mut notified = HashMap::with_capacity(self.panels.len());
let mut changes = Vec::new();
let mut activated = None;
for (ix, panel) in self.panels.iter().enumerate() {
let id = panel.view().entity_id();
let target = ix == self.active_ix && !self.collapsed;
let last = self.notified_active.get(&id).copied().unwrap_or(false);
if target != last {
if target {
activated = Some((panel.clone(), true));
} else {
changes.push((panel.clone(), false));
}
}
notified.insert(id, target);
}
self.notified_active = notified;
changes.extend(activated);
changes
}
/// Add a panel to the end of the tabs
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.add_panel_with_active(panel, true, window, cx);
}
fn add_panel_with_active(
&mut self,
panel: Arc<dyn PanelView>,
active: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
assert_ne!(
panel.panel_name(cx),
"StackPanel",
"can not allows add `StackPanel` to `TabPanel`"
);
if self
.panels
.iter()
.any(|p| p.view().entity_id() == panel.view().entity_id())
{
return;
}
panel.on_added_to(cx.entity().downgrade(), window, cx);
self.panels.push(panel);
// set the active panel to the new panel
if active {
self.set_active_ix(self.panels.len() - 1, window, cx);
}
// Unconditional: set_active_ix early-returns for the first panel,
// which is displayed regardless of `active`.
self.schedule_active_sync(window, cx);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Add panel to try to split
pub fn add_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
placement: Placement,
size: Option<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.spawn_in(window, async move |view, cx| {
cx.update(|window, cx| {
view.update(cx, |view, cx| {
view.will_split_placement = Some(placement);
view.split_panel(panel, placement, size, None, window, cx)
})
.ok()
})
.ok()
})
.detach();
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
fn insert_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self
.panels
.iter()
.any(|p| p.view().entity_id() == panel.view().entity_id())
{
return;
}
panel.on_added_to(cx.entity().downgrade(), window, cx);
self.panels.insert(ix, panel);
self.set_active_ix(ix, window, cx);
// set_active_ix early-returns when ix == active_ix, yet the
// displayed panel just changed.
self.schedule_active_sync(window, cx);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Remove a panel from the tab panel
pub fn remove_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.detach_panel(panel, window, cx);
self.remove_self_if_empty(window, cx);
cx.emit(PanelEvent::ZoomOut);
cx.emit(PanelEvent::LayoutChanged);
}
/// Detach the panel, returning what it was last told via `set_active` so
/// drag-and-drop can carry that belief into the target `TabPanel`.
fn detach_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<bool> {
panel.on_removed(window, cx);
let panel_view = panel.view();
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
self.panels.retain(|p| p.view() != panel_view);
// Keep following the same displayed panel.
if removed_ix.is_some_and(|ix| ix < self.active_ix) {
self.active_ix -= 1;
}
if self.active_ix >= self.panels.len() {
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
}
self.schedule_active_sync(window, cx);
self.notified_active.remove(&panel_view.entity_id())
}
/// Check to remove self from the parent StackPanel, if there is no panel left
fn remove_self_if_empty(&self, window: &mut Window, cx: &mut Context<Self>) {
if !self.panels.is_empty() {
return;
}
let tab_view = cx.entity().clone();
if let Some(stack_panel) = self.stack_panel.as_ref() {
_ = stack_panel.update(cx, |view, cx| {
view.remove_panel(Arc::new(tab_view), window, cx);
});
}
}
pub(super) fn set_collapsed(
&mut self,
collapsed: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.collapsed = collapsed;
self.schedule_active_sync(window, cx);
cx.notify();
}
fn is_locked(&self, cx: &App) -> bool {
let Some(dock_area) = self.dock_area.upgrade() else {
return true;
};
if dock_area.read(cx).is_locked() {
return true;
}
if self.zoomed {
return true;
}
self.stack_panel.is_none()
}
/// Return true if self or parent only have last panel.
///
/// Only visible panels are counted, so a hidden panel does not keep the
/// last visible panel draggable/closable (which could otherwise leave the
/// dock visually empty and undroppable).
fn is_last_panel(&self, cx: &App) -> bool {
if let Some(parent) = &self.stack_panel
&& let Some(stack_panel) = parent.upgrade()
&& !stack_panel.read(cx).is_last_panel(cx)
{
return false;
}
self.visible_panels(cx).count() <= 1
}
/// Return all visible panels
fn visible_panels<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = Arc<dyn PanelView>> + 'a {
self.panels.iter().filter_map(|panel| {
if panel.visible(cx) {
Some(panel.clone())
} else {
None
}
})
}
/// Return true if the tab panel is draggable.
///
/// E.g. if the parent and self only have one panel, it is not draggable.
fn draggable(&self, cx: &App) -> bool {
!self.is_locked(cx) && !self.is_last_panel(cx)
}
/// Return true if the tab panel is droppable.
///
/// E.g. if the tab panel is locked, it is not droppable.
fn droppable(&self, cx: &App) -> bool {
!self.is_locked(cx)
}
fn render_toolbar(
&mut self,
state: &TabState,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
if self.collapsed {
return div();
}
let zoomed = self.zoomed;
let view = cx.entity().clone();
let zoomable_toolbar_visible = state.zoomable.is_some_and(|v| v.toolbar_visible());
h_flex()
.gap_1()
.occlude()
.when_some(self.toolbar_buttons(window, cx), |this, buttons| {
this.children(
buttons
.into_iter()
.map(|btn| btn.xsmall().ghost().tab_stop(false)),
)
})
.map(|this| {
let value = if zoomed {
Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out")))
} else if zoomable_toolbar_visible {
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)
.xsmall()
.ghost()
.tab_stop(false)
.tooltip_with_action(tooltip, &ToggleZoom, None)
.selected(zoomed)
.on_click(cx.listener(|view, _, window, cx| {
view.on_action_toggle_zoom(&ToggleZoom, window, cx)
})),
)
} else {
this
}
})
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.xsmall()
.ghost()
.tab_stop(false)
.dropdown_menu({
let zoomable = state.zoomable.is_some_and(|v| v.menu_visible());
let closable = state.closable;
move |menu, window, cx| {
view.update(cx, |this, cx| {
this.dropdown_menu(menu, window, cx)
.separator()
.menu_with_disabled(
if zoomed {
t("Dock.Zoom Out")
} else {
t("Dock.Zoom In")
},
Box::new(ToggleZoom),
!zoomable,
)
.when(closable, |this| {
this.separator().menu(t("Dock.Close"), Box::new(ClosePanel))
})
})
}
})
.anchor(Anchor::TopRight),
)
}
fn render_dock_toggle_button(
&self,
placement: DockPlacement,
_: &mut Window,
cx: &mut Context<Self>,
) -> Option<Button> {
// Only the left dock can be toggled.
if placement != DockPlacement::Left {
return None;
}
if self.zoomed {
return None;
}
let dock_area = self.dock_area.upgrade()?.read(cx);
if !dock_area.toggle_button_visible {
return None;
}
if !dock_area.is_dock_collapsible(placement, cx) {
return None;
}
let view_entity_id = cx.entity().entity_id();
let toggle_button_panels = dock_area.toggle_button_panels;
// Check if current TabPanel's entity_id matches the one stored in DockArea for this placement
if dock_area.left_dock.is_none() || toggle_button_panels.left != Some(view_entity_id) {
return None;
}
let is_open = dock_area.is_dock_open(placement, cx);
let icon = if is_open {
IconName::PanelLeft
} else {
IconName::PanelLeftOpen
};
Some(
Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
.icon(icon)
.xsmall()
.ghost()
.tab_stop(false)
.tooltip(match is_open {
true => t("Dock.Collapse"),
false => t("Dock.Expand"),
})
.on_click(cx.listener({
let dock_area = self.dock_area.clone();
move |_, _, window, cx| {
_ = dock_area.update(cx, |dock_area, cx| {
dock_area.toggle_dock(placement, window, cx);
});
}
})),
)
}
fn render_title_bar(
&mut self,
state: &TabState,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let view = cx.entity().clone();
let Some(dock_area) = self.dock_area.upgrade() else {
return div().into_any_element();
};
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
let has_extend_dock_button = left_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. The
// left dock (sidebar) normally clears them; when it is closed or
// absent, the center tab bar must reserve the space itself.
let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self
.dock_area
.upgrade()
.and_then(|area| area.read(cx).left_dock.clone())
.is_none_or(|dock| !dock.read(cx).is_open());
let panel_style = dock_area.read(cx).panel_style;
let visible_panels = self.visible_panels(cx).collect::<Vec<_>>();
if visible_panels.len() == 1 && panel_style == PanelStyle::default() {
let panel = visible_panels.first().unwrap();
if !panel.visible(cx) {
return div().into_any_element();
}
let title_style = panel.title_style(cx);
return title_bar_drag_handlers(
h_flex()
.id(("tab-panel-simple-title", cx.entity().entity_id()))
.justify_between()
.line_height(rems(1.0))
.h(px(30.))
.py_2()
.pl_3()
.pr_2()
.when(left_dock_button.is_some(), |this| this.pl_2())
.when(needs_traffic_light_padding, |this| this.pl(px(80.)))
.when_some(title_style, |this, theme| {
this.bg(theme.background).text_color(theme.foreground)
})
.when(has_extend_dock_button, |this| {
this.child(
h_flex()
.flex_shrink_0()
.mr_1()
.gap_1()
.children(left_dock_button),
)
})
.child(
div()
.id("tab")
.flex_1()
.min_w_16()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.child(panel.title(window, cx))
.when(state.draggable, |this| {
this.on_drag(
DragPanel::new(panel.clone(), view),
|drag, offset, _, cx| {
cx.stop_propagation();
drag.drag_offset.set(offset);
cx.new(|_| drag.clone())
},
)
}),
)
.children(panel.title_suffix(window, cx))
.child(
h_flex()
.flex_shrink_0()
.ml_1()
.gap_1()
.child(self.render_toolbar(state, window, cx)),
),
window,
cx,
)
.into_any_element();
}
if let Some(panel_ix) = self.pending_scroll_to_ix.take()
&& let Some(visible_ix) = self
.panels
.iter()
.enumerate()
.filter(|(_, p)| p.visible(cx))
.position(|(ix, _)| ix == panel_ix)
{
self.tab_bar_scroll_handle.scroll_to_item(visible_ix);
}
let tabs_count = self.panels.len();
// TabBar lays out its scrollable content at content width, so the
// strip 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,
self.title_bar_strip_bounds,
self.title_bar_suffix_bounds,
) {
(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", cx.entity().entity_id()))
.absolute()
.top_0()
.bottom_0()
.left(left)
.right(right)
.debug_selector(|| "title-bar-drag".into()),
window,
cx,
)
})
}
_ => None,
};
div()
.flex()
.flex_row()
.child(
div()
.relative()
.flex_1()
.min_w_0()
.on_prepaint({
let view = view.clone();
move |bounds, _, cx| {
view.update(cx, |this, cx| {
if this.title_bar_bounds != Some(bounds) {
this.title_bar_bounds = Some(bounds);
cx.notify();
}
});
}
})
.child(
TabBar::new("tab-bar")
.track_scroll(&self.tab_bar_scroll_handle)
.when(
has_extend_dock_button || needs_traffic_light_padding,
|this| {
this.prefix(
h_flex()
.items_center()
.top_0()
// Right -1 for avoid border overlap with the first tab
.right(-px(1.))
.h_full()
.bg(cx.theme().tokens.tab_bar)
.px_2()
.when(needs_traffic_light_padding, |this| {
this.pl(px(80.))
})
.children(left_dock_button),
)
},
)
.children(self.panels.iter().enumerate().filter_map(|(ix, panel)| {
let mut active = state.active_panel.as_ref() == Some(panel);
let droppable = self.collapsed;
if !panel.visible(cx) {
return None;
}
// Always not show active tab style, if the panel is collapsed
if self.collapsed {
active = false;
}
Some(
// Note: upstream also calls the crate-private `Tab::ix(ix)` and
// `Tab::tab_bar_prefix(...)` here. `TabBar` re-applies `ix` per
// child itself, and `tab_bar_prefix` only clears the first
// tab's left border when the bar has no prefix — both are
// inaccessible from outside gpui-component, so they are
// omitted.
Tab::new()
.map(|this| {
if let Some(tab_name) = panel.tab_name(cx) {
this.child(tab_name)
} else {
this.child(panel.title(window, cx))
}
})
.selected(active)
.on_click(cx.listener({
move |view, _, window, cx| {
view.set_active_ix(ix, window, cx);
}
}))
.when(!droppable, |this| {
this.when(state.draggable, |this| {
this.on_drag(
DragPanel::new(panel.clone(), view.clone()),
|drag, offset, _, cx| {
cx.stop_propagation();
drag.drag_offset.set(offset);
cx.new(|_| drag.clone())
},
)
})
.when(
state.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(cx.listener(
move |this, drag: &DragPanel, window, cx| {
this.will_split_placement = None;
this.on_drop(drag, 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(cx.listener(
|this, item: &AnyDrag, _, cx| {
this.will_split_placement = None;
this.emit_drag_drop(item, None, cx);
},
))
},
)
}),
)
}))
.last_empty_space(
// Empty space to allow dropping at the last tab. Its left
// edge (right after the last tab) marks the start of the
// title-bar drag overlay.
div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.on_prepaint({
let view = view.clone();
move |bounds, _, cx| {
view.update(cx, |this, cx| {
if this.title_bar_strip_bounds != Some(bounds) {
this.title_bar_strip_bounds = Some(bounds);
cx.notify();
}
});
}
})
.when(state.droppable, |this| {
let view = view.clone();
this.drag_over::<DragPanel>(|this, _, _, cx| {
this.bg(cx.theme().tokens.drop_target)
})
.on_drop(cx.listener(
move |this, drag: &DragPanel, window, cx| {
this.will_split_placement = None;
let ix = if drag.tab_panel == view {
Some(tabs_count - 1)
} else {
None
};
this.on_drop(drag, ix, false, window, cx)
},
))
.drag_over::<AnyDrag>(|this, _, _, cx| {
this.bg(cx.theme().tokens.drop_target)
})
.on_drop(
cx.listener(|this, item: &AnyDrag, _, cx| {
this.will_split_placement = None;
this.emit_drag_drop(item, None, cx);
}),
)
}),
)
.when(!self.collapsed, |this| {
this.suffix(
h_flex()
.items_center()
.top_0()
.right_0()
.h_full()
.bg(cx.theme().tokens.tab_bar)
.px_2()
.gap_1()
.on_prepaint({
let view = view.clone();
move |bounds, _, cx| {
view.update(cx, |this, cx| {
if this.title_bar_suffix_bounds != Some(bounds)
{
this.title_bar_suffix_bounds = Some(bounds);
cx.notify();
}
});
}
})
.children(
self.active_panel(cx)
.and_then(|panel| panel.title_suffix(window, cx)),
)
.child(self.render_toolbar(state, window, cx)),
)
}),
)
.when_some(drag_overlay, |this, overlay| this.child(overlay)),
)
.child(window_controls::window_controls(window, cx))
.into_any_element()
}
fn render_active_panel(
&self,
state: &TabState,
_: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
if self.collapsed {
return Empty {}.into_any_element();
}
let Some(active_panel) = state.active_panel.as_ref() else {
return Empty {}.into_any_element();
};
let is_render_in_tabs = self.panels.len() > 1 && self.inner_padding(cx);
let placeholder = self.drop_placeholder_animation;
let placeholder_animation_name = self.drop_placeholder_animation_name.clone();
v_flex()
.id("active-panel")
.group("")
.flex_1()
.when(is_render_in_tabs, |this| this.pt_2())
.child(
div()
.id("tab-content")
.overflow_y_scroll()
.overflow_x_hidden()
.flex_1()
.child(
active_panel
.view()
.cached(StyleRefinement::default().absolute().size_full()),
),
)
.when(state.droppable, |this| {
this.on_drag_move(cx.listener(Self::on_panel_drag_move))
.on_drag_move(cx.listener(Self::on_item_drag_move))
.child(
div()
.invisible()
.absolute()
.map(|this| match placeholder {
Some(animation) => this
.left(animation.to.origin.x)
.top(animation.to.origin.y)
.w(animation.to.size.width)
.h(animation.to.size.height),
None => this.top_0().left_0().size_full(),
})
.group_drag_over::<DragPanel>("", |this| this.visible())
.on_drop(cx.listener(|this, drag: &DragPanel, window, cx| {
this.on_drop(drag, None, true, window, cx)
}))
.group_drag_over::<AnyDrag>("", |this| this.visible())
.on_drop(cx.listener(|this, item: &AnyDrag, _, cx| {
let placement = this.will_split_placement.take();
this.emit_drag_drop(item, placement, cx);
}))
.when_some(placeholder, |this, animation| {
let from = animation.from.origin - animation.to.origin;
this.child(
div()
.absolute()
.bg(cx.theme().tokens.drop_target)
.with_animation(
gpui::ElementId::NamedInteger(
placeholder_animation_name,
animation.epoch,
),
Animation::new(Duration::from_millis(150))
.with_easing(ease_out_cubic),
move |this, delta| {
let origin = from.lerp(&Point::default(), delta);
let width = animation
.from
.size
.width
.lerp(&animation.to.size.width, delta);
let height = animation
.from
.size
.height
.lerp(&animation.to.size.height, delta);
this.left(origin.x).top(origin.y).w(width).h(height)
},
),
)
}),
)
})
.into_any_element()
}
/// Calculate the split direction based on the current mouse position
fn on_panel_drag_move(
&mut self,
drag: &DragMoveEvent<DragPanel>,
_: &mut Window,
cx: &mut Context<Self>,
) {
let bounds = drag.bounds;
let placement = split_placement_at(bounds, drag.event.position);
let dragged_panel = drag.drag(cx);
let source = DropPlaceholderBounds {
origin: drag.event.position - dragged_panel.drag_offset.get() - bounds.origin,
size: gpui::size(px(96.), px(30.)),
};
self.sync_drop_placeholder(bounds, placement, dragged_panel.drag_session_id, source, cx);
}
/// Same as [`Self::on_panel_drag_move`], for a host-owned drag item.
///
/// A drag item has no dragged tab to fly the placeholder in from, so it
/// starts at its resting position and only animates between placements.
fn on_item_drag_move(
&mut self,
drag: &DragMoveEvent<AnyDrag>,
_: &mut Window,
cx: &mut Context<Self>,
) {
let bounds = drag.bounds;
let placement = split_placement_at(bounds, drag.event.position);
let source = DropPlaceholderBounds::for_placement(bounds, placement);
self.sync_drop_placeholder(bounds, placement, ITEM_DRAG_SESSION_ID, source, cx);
}
fn sync_drop_placeholder(
&mut self,
bounds: Bounds<Pixels>,
placement: Option<Placement>,
drag_session_id: u64,
source: DropPlaceholderBounds,
cx: &mut Context<Self>,
) {
let to = DropPlaceholderBounds::for_placement(bounds, placement);
let should_restart = self.drop_placeholder_animation.is_none_or(|animation| {
animation.drag_session_id != drag_session_id || animation.placement != placement
});
if should_restart {
let (from, epoch) = self
.drop_placeholder_animation
.map(|animation| {
let from = if animation.drag_session_id == drag_session_id {
animation.to
} else {
source
};
(from, animation.epoch.wrapping_add(1))
})
.unwrap_or((source, 0));
self.drop_placeholder_animation = Some(DropPlaceholderAnimation {
drag_session_id,
placement,
from,
to,
epoch,
});
}
self.will_split_placement = placement;
cx.notify()
}
/// Report a host-owned drag landing on this TabPanel. `placement` is `None`
/// to merge into this tab group instead of splitting.
fn emit_drag_drop(
&mut self,
item: &AnyDrag,
placement: Option<Placement>,
cx: &mut Context<Self>,
) {
let item = item.clone();
let target = DropTarget::Panel {
tab_panel: cx.entity(),
placement,
};
_ = self.dock_area.update(cx, |_, cx| {
cx.emit(DockEvent::DragDrop { item, target });
});
cx.notify();
}
/// Handle the drop event when dragging a panel
///
/// - `active` - When true, the panel will be active after the drop
fn on_drop(
&mut self,
drag: &DragPanel,
ix: Option<usize>,
active: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let panel = drag.panel.clone();
let is_same_tab = drag.tab_panel == cx.entity();
// If target is same tab, and it is only one panel, do nothing.
if is_same_tab && ix.is_none() {
if self.will_split_placement.is_none() {
return;
} else {
if self.panels.len() == 1 {
return;
}
}
}
// Here is looks like remove_panel on a same item, but it difference.
//
// We must to split it to remove_panel, unless it will be crash by error:
// Cannot update ui::dock::tab_panel::TabPanel while it is already being updated
let last_notified = if is_same_tab {
self.detach_panel(panel.clone(), window, cx)
} else {
drag.tab_panel.update(cx, |view, cx| {
let last_notified = view.detach_panel(panel.clone(), window, cx);
view.remove_self_if_empty(window, cx);
last_notified
})
};
// Insert into new tabs, seeding the target map with what the panel
// was last told so the move only notifies on a real state change.
let panel_id = panel.view().entity_id();
if let Some(placement) = self.will_split_placement {
self.split_panel(panel, placement, None, last_notified, window, cx);
} else {
if let Some(ix) = ix {
self.insert_panel_at(panel, ix, window, cx)
} else {
self.add_panel_with_active(panel, active, window, cx)
}
if let Some(last_notified) = last_notified {
self.notified_active.insert(panel_id, last_notified);
}
}
self.remove_self_if_empty(window, cx);
cx.emit(PanelEvent::LayoutChanged);
}
/// Add panel with split placement
fn split_panel(
&self,
panel: Arc<dyn PanelView>,
placement: Placement,
size: Option<Pixels>,
last_notified: Option<bool>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let dock_area = self.dock_area.clone();
let panel_id = panel.view().entity_id();
// wrap the panel in a TabPanel
let new_tab_panel = cx.new(|cx| Self::new(None, dock_area.clone(), window, cx));
new_tab_panel.update(cx, |view, cx| {
view.add_panel(panel, window, cx);
if let Some(last_notified) = last_notified {
view.notified_active.insert(panel_id, last_notified);
}
});
let stack_panel = match self.stack_panel.as_ref().and_then(|panel| panel.upgrade()) {
Some(panel) => panel,
None => return,
};
let parent_axis = stack_panel.read(cx).axis;
let ix = stack_panel
.read(cx)
.index_of_panel(Arc::new(cx.entity().clone()))
.unwrap_or_default();
if parent_axis.is_vertical() && placement.is_vertical() {
stack_panel.update(cx, |view, cx| {
view.insert_panel_at(
Arc::new(new_tab_panel),
ix,
placement,
size,
dock_area.clone(),
window,
cx,
);
});
} else if parent_axis.is_horizontal() && placement.is_horizontal() {
stack_panel.update(cx, |view, cx| {
view.insert_panel_at(
Arc::new(new_tab_panel),
ix,
placement,
size,
dock_area.clone(),
window,
cx,
);
});
} else {
// 1. Create new StackPanel with new axis
// 2. Move cx.entity() from parent StackPanel to the new StackPanel
// 3. Add the new TabPanel to the new StackPanel at the correct index
// 4. Add new StackPanel to the parent StackPanel at the correct index
let tab_panel = cx.entity().clone();
// Try to use the old stack panel, not just create a new one, to avoid too many nested stack panels
let new_stack_panel = if stack_panel.read(cx).panels_len() <= 1 {
stack_panel.update(cx, |view, cx| {
view.remove_all_panels(window, cx);
view.set_axis(placement.axis(), window, cx);
});
stack_panel.clone()
} else {
cx.new(|cx| {
let mut panel = StackPanel::new(placement.axis(), window, cx);
panel.parent = Some(stack_panel.downgrade());
panel
})
};
new_stack_panel.update(cx, |view, cx| match placement {
Placement::Left | Placement::Top => {
view.add_panel(Arc::new(new_tab_panel), size, dock_area.clone(), window, cx);
view.add_panel(
Arc::new(tab_panel.clone()),
None,
dock_area.clone(),
window,
cx,
);
}
Placement::Right | Placement::Bottom => {
view.add_panel(
Arc::new(tab_panel.clone()),
None,
dock_area.clone(),
window,
cx,
);
view.add_panel(Arc::new(new_tab_panel), size, dock_area.clone(), window, cx);
}
});
if stack_panel != new_stack_panel {
stack_panel.update(cx, |view, cx| {
view.replace_panel(
Arc::new(tab_panel.clone()),
new_stack_panel.clone(),
window,
cx,
);
});
}
cx.spawn_in(window, async move |_, cx| {
cx.update(|window, cx| {
tab_panel.update(cx, |view, cx| view.remove_self_if_empty(window, cx))
})
})
.detach()
}
cx.emit(PanelEvent::LayoutChanged);
}
fn focus_active_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_panel) = self.active_panel(cx) {
active_panel.focus_handle(cx).focus(window, cx);
}
}
fn on_action_toggle_zoom(
&mut self,
_: &ToggleZoom,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.zoomable(cx).is_none() {
return;
}
if !self.zoomed {
cx.emit(PanelEvent::ZoomIn)
} else {
cx.emit(PanelEvent::ZoomOut)
}
self.zoomed = !self.zoomed;
cx.spawn_in(window, {
let zoomed = self.zoomed;
async move |view, cx| {
_ = cx.update(|window, cx| {
_ = view.update(cx, |view, cx| {
view.set_zoomed(zoomed, window, cx);
});
});
}
})
.detach();
}
fn on_action_close_panel(
&mut self,
_: &ClosePanel,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.closable(cx) {
return;
}
if let Some(panel) = self.active_panel(cx) {
self.remove_panel(panel, window, cx);
}
// Remove self from the parent DockArea when the last panel is closed.
if self.panels.is_empty() {
let tab_panel = Arc::new(cx.entity());
window.defer(cx, {
let dock_area = self.dock_area.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.remove_panel_from_all_docks(tab_panel, window, cx);
});
}
});
}
}
// Bind actions to the tab panel, only when the tab panel is not collapsed.
fn bind_actions(&self, cx: &mut Context<Self>) -> Div {
v_flex().when(!self.collapsed, |this| {
this.on_action(cx.listener(Self::on_action_toggle_zoom))
.on_action(cx.listener(Self::on_action_close_panel))
})
}
}
/// Split direction for a cursor position inside `bounds`; `None` means the
/// centre zone, i.e. merge into the tab group instead of splitting.
fn split_placement_at(bounds: Bounds<Pixels>, position: Point<Pixels>) -> Option<Placement> {
if position.x < bounds.left() + bounds.size.width * 0.35 {
Some(Placement::Left)
} else if position.x > bounds.left() + bounds.size.width * 0.65 {
Some(Placement::Right)
} else if position.y < bounds.top() + bounds.size.height * 0.35 {
Some(Placement::Top)
} else if position.y > bounds.top() + bounds.size.height * 0.65 {
Some(Placement::Bottom)
} else {
None
}
}
impl Focusable for TabPanel {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
if let Some(active_panel) = self.active_panel(cx) {
active_panel.focus_handle(cx)
} else {
self.focus_handle.clone()
}
}
}
impl EventEmitter<DismissEvent> for TabPanel {}
impl EventEmitter<PanelEvent> for TabPanel {}
impl Render for TabPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
let focus_handle = self.focus_handle(cx);
let active_panel = self.active_panel(cx);
let state = TabState {
closable: self.closable(cx),
draggable: self.draggable(cx),
droppable: self.droppable(cx),
zoomable: self.zoomable(cx),
active_panel,
};
self.bind_actions(cx)
.id("tab-panel")
.track_focus(&focus_handle)
.tab_group()
.size_full()
.overflow_hidden()
.bg(cx.theme().tokens.background)
.child(self.render_title_bar(&state, window, cx))
.child(self.render_active_panel(&state, window, cx))
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use gpui::{Axis, TestAppContext, VisualTestContext, WindowHandle, WindowOptions, size};
use gpui_component::tab::{Tab, TabBar};
use gpui_component::{Root, Theme, v_flex};
use super::*;
use crate::DockItem;
#[test]
fn drop_placeholder_bounds_cover_each_target_placement() {
let bounds = gpui::Bounds {
origin: point(px(120.), px(80.)),
size: gpui::size(px(400.), px(300.)),
};
assert_eq!(
DropPlaceholderBounds::for_placement(bounds, Some(Placement::Left)),
DropPlaceholderBounds {
origin: point(px(0.), px(0.)),
size: gpui::size(px(200.), px(300.)),
}
);
assert_eq!(
DropPlaceholderBounds::for_placement(bounds, Some(Placement::Right)),
DropPlaceholderBounds {
origin: point(px(200.), px(0.)),
size: gpui::size(px(200.), px(300.)),
}
);
assert_eq!(
DropPlaceholderBounds::for_placement(bounds, Some(Placement::Top)),
DropPlaceholderBounds {
origin: point(px(0.), px(0.)),
size: gpui::size(px(400.), px(150.)),
}
);
assert_eq!(
DropPlaceholderBounds::for_placement(bounds, Some(Placement::Bottom)),
DropPlaceholderBounds {
origin: point(px(0.), px(150.)),
size: gpui::size(px(400.), px(150.)),
}
);
assert_eq!(
DropPlaceholderBounds::for_placement(bounds, None),
DropPlaceholderBounds {
origin: point(px(0.), px(0.)),
size: gpui::size(px(400.), px(300.)),
}
);
}
#[test]
fn split_placement_follows_the_cursor_zone() {
let bounds = gpui::Bounds {
origin: point(px(120.), px(80.)),
size: gpui::size(px(400.), px(300.)),
};
// 35% / 65% of 400x300 from origin (120, 80).
let at = |x: f32, y: f32| split_placement_at(bounds, point(px(x), px(y)));
assert_eq!(at(130., 230.), Some(Placement::Left));
assert_eq!(at(510., 230.), Some(Placement::Right));
assert_eq!(at(320., 90.), Some(Placement::Top));
assert_eq!(at(320., 370.), Some(Placement::Bottom));
assert_eq!(at(320., 230.), None, "centre merges into the tab group");
}
#[test]
fn split_placement_prefers_horizontal_in_the_corners() {
let bounds = gpui::Bounds {
origin: Point::default(),
size: gpui::size(px(400.), px(300.)),
};
// Top-left corner satisfies both Left and Top; x is tested first.
assert_eq!(
split_placement_at(bounds, point(px(10.), px(10.))),
Some(Placement::Left)
);
assert_eq!(
split_placement_at(bounds, point(px(390.), px(290.))),
Some(Placement::Right)
);
}
#[test]
fn split_placement_boundaries_fall_into_the_centre() {
let bounds = gpui::Bounds {
origin: Point::default(),
size: gpui::size(px(400.), px(300.)),
};
// Comparisons are strict, so the threshold itself is the centre zone.
assert_eq!(split_placement_at(bounds, point(px(140.), px(150.))), None);
assert_eq!(split_placement_at(bounds, point(px(260.), px(150.))), None);
assert_eq!(split_placement_at(bounds, point(px(200.), px(105.))), None);
assert_eq!(split_placement_at(bounds, point(px(200.), px(195.))), None);
}
/// Shared, cross-panel ordered log of every `set_active` delivery.
type Log = Arc<Mutex<Vec<(&'static str, bool)>>>;
struct TestPanel {
name: &'static str,
focus_handle: FocusHandle,
log: Log,
visible: bool,
}
impl Panel for TestPanel {
fn panel_name(&self) -> &'static str {
"TestPanel"
}
fn visible(&self, _: &App) -> bool {
self.visible
}
fn set_active(&mut self, active: bool, _: &mut Window, _: &mut Context<Self>) {
self.log.lock().unwrap().push((self.name, active));
}
}
impl EventEmitter<PanelEvent> for TestPanel {}
impl Focusable for TestPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for TestPanel {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
struct DockFixture {
dock_area: Entity<DockArea>,
window: WindowHandle<Root>,
log: Log,
}
fn setup(cx: &mut TestAppContext) -> DockFixture {
let log = Log::default();
let mut dock_area = None;
let window = cx.update(|cx| {
cx.open_window(Default::default(), |window, cx| {
cx.set_global(Theme::default());
let area = cx.new(|cx| DockArea::new("test-dock", None, window, cx));
dock_area = Some(area.clone());
cx.new(|cx| Root::new(area, window, cx))
})
.unwrap()
});
DockFixture {
dock_area: dock_area.unwrap(),
window,
log,
}
}
fn test_panel(name: &'static str, log: &Log, cx: &mut App) -> Entity<TestPanel> {
let log = log.clone();
cx.new(|cx| TestPanel {
name,
focus_handle: cx.focus_handle(),
log,
visible: true,
})
}
/// Build a tab group holding `names`, returning the kept-alive DockItem,
/// its TabPanel, and the panel entities.
fn build_tabs(
fixture: &DockFixture,
names: &[&'static str],
active_ix: Option<usize>,
cx: &mut VisualTestContext,
) -> (DockItem, Entity<TabPanel>, Vec<Entity<TestPanel>>) {
let weak_dock_area = fixture.dock_area.downgrade();
let log = fixture.log.clone();
let names = names.to_vec();
let (item, panels) = cx.update(move |window, cx| {
let panels: Vec<_> = names
.iter()
.map(|name| test_panel(name, &log, cx))
.collect();
let items = panels
.iter()
.map(|panel| Arc::new(panel.clone()) as Arc<dyn PanelView>)
.collect();
let mut item = DockItem::tabs(items, &weak_dock_area, window, cx);
if let Some(ix) = active_ix {
item = item.active_index(ix, cx);
}
(item, panels)
});
let DockItem::Tabs { view, .. } = &item else {
unreachable!("DockItem::tabs must return DockItem::Tabs");
};
let tab_panel = view.clone();
(item, tab_panel, panels)
}
fn drain(log: &Log) -> Vec<(&'static str, bool)> {
std::mem::take(&mut *log.lock().unwrap())
}
/// An empty StackPanel used to dump as `PanelInfo::Panel`, the `PanelState`
/// default, so restoring looked it up in `PanelRegistry` and failed.
#[gpui::test]
fn empty_center_round_trips_as_a_stack(cx: &mut TestAppContext) {
let fixture = setup(cx);
let cx = VisualTestContext::from_window(fixture.window.into(), cx);
let center = cx.read(|cx| fixture.dock_area.read(cx).dump(cx).center);
assert_eq!(center.panel_name, "StackPanel");
assert!(
matches!(center.info, PanelInfo::Stack { .. }),
"got {:?}",
center.info
);
}
#[gpui::test]
fn fresh_center_is_empty(cx: &mut TestAppContext) {
let fixture = setup(cx);
let cx = VisualTestContext::from_window(fixture.window.into(), cx);
assert!(
cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)),
"DockArea::new starts with an empty StackPanel centre"
);
}
#[gpui::test]
fn center_holding_a_tab_group_is_not_empty(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (item, _, _) = build_tabs(&fixture, &["A", "B"], None, &mut cx);
cx.update(|window, cx| {
fixture
.dock_area
.update(cx, |area, cx| area.set_center(item, window, cx))
});
cx.run_until_parked();
assert!(!cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)));
}
/// The `DockItem` tree still lists the tab group here, so anything reading
/// `DockItem::Split { items }` would report non-empty.
#[gpui::test]
fn center_is_empty_again_once_every_panel_is_removed(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (item, tab_panel, panels) = build_tabs(&fixture, &["A", "B"], None, &mut cx);
cx.update(|window, cx| {
fixture
.dock_area
.update(cx, |area, cx| area.set_center(item, window, cx))
});
cx.run_until_parked();
for panel in panels {
cx.update(|window, cx| {
tab_panel.update(cx, |tab_panel, cx| {
tab_panel.remove_panel(Arc::new(panel.clone()), window, cx)
})
});
}
cx.run_until_parked();
assert!(cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)));
}
#[gpui::test]
fn center_is_not_empty_after_adding_to_a_tab_group(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (item, tab_panel, _) = build_tabs(&fixture, &[], None, &mut cx);
cx.update(|window, cx| {
fixture
.dock_area
.update(cx, |area, cx| area.set_center(item, window, cx))
});
cx.run_until_parked();
assert!(cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)));
let log = fixture.log.clone();
cx.update(|window, cx| {
let panel = test_panel("A", &log, cx);
tab_panel.update(cx, |tab_panel, cx| {
tab_panel.add_panel(Arc::new(panel), window, cx)
});
});
cx.run_until_parked();
assert!(!cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)));
}
/// Rendering skips invisible panels, so a centre holding only hidden ones
/// draws nothing and counts as empty.
#[gpui::test]
fn center_holding_only_hidden_panels_is_empty(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (item, tab_panel, panels) = build_tabs(&fixture, &["A", "B"], None, &mut cx);
cx.update(|window, cx| {
fixture
.dock_area
.update(cx, |area, cx| area.set_center(item, window, cx))
});
cx.run_until_parked();
assert!(!cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)));
cx.update(|_, cx| {
for panel in &panels {
panel.update(cx, |panel, _| panel.visible = false);
}
});
cx.run_until_parked();
assert_eq!(
cx.read(|cx| tab_panel.read(cx).panels.len()),
2,
"hiding a panel does not remove it from the tab group"
);
assert!(cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)));
}
#[gpui::test]
fn single_panel_group_receives_initial_active(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let _keep = build_tabs(&fixture, &["A"], None, &mut cx);
cx.run_until_parked();
assert_eq!(drain(&fixture.log), [("A", true)]);
}
#[gpui::test]
fn multi_tab_construction_notifies_only_displayed_panel(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let _keep = build_tabs(&fixture, &["A", "B", "C"], None, &mut cx);
cx.run_until_parked();
// No false→true flip on A, no duplicate true, B/C silent.
assert_eq!(drain(&fixture.log), [("A", true)]);
}
#[gpui::test]
fn active_index_restore_notifies_that_panel_only(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let _keep = build_tabs(&fixture, &["A", "B", "C"], Some(2), &mut cx);
cx.run_until_parked();
assert_eq!(drain(&fixture.log), [("C", true)]);
}
#[gpui::test]
fn switching_tabs_sends_false_then_true(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (_keep, tab_panel, _) = build_tabs(&fixture, &["A", "B"], None, &mut cx);
cx.run_until_parked();
drain(&fixture.log);
cx.update(|window, cx| {
tab_panel.update(cx, |tab_panel, cx| tab_panel.set_active_ix(1, window, cx));
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), [("A", false), ("B", true)]);
}
#[gpui::test]
fn reselecting_active_tab_stays_silent(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (_keep, tab_panel, _) = build_tabs(&fixture, &["A", "B"], None, &mut cx);
cx.run_until_parked();
drain(&fixture.log);
cx.update(|window, cx| {
tab_panel.update(cx, |tab_panel, cx| tab_panel.set_active_ix(0, window, cx));
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), []);
}
#[gpui::test]
fn inserting_at_active_ix_swaps_notifications(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (_keep, tab_panel, _) = build_tabs(&fixture, &["A", "B"], None, &mut cx);
cx.run_until_parked();
drain(&fixture.log);
let log = fixture.log.clone();
cx.update(|window, cx| {
let c = test_panel("C", &log, cx);
tab_panel.update(cx, |tab_panel, cx| {
tab_panel.insert_panel_at(Arc::new(c), 0, window, cx)
});
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), [("A", false), ("C", true)]);
}
#[gpui::test]
fn removing_before_active_keeps_displayed_panel(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (_keep, tab_panel, panels) = build_tabs(&fixture, &["A", "B", "C"], None, &mut cx);
cx.update(|window, cx| {
tab_panel.update(cx, |tab_panel, cx| tab_panel.set_active_ix(1, window, cx));
});
cx.run_until_parked();
drain(&fixture.log);
cx.update(|window, cx| {
tab_panel.update(cx, |tab_panel, cx| {
tab_panel.remove_panel(Arc::new(panels[0].clone()), window, cx)
});
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), []);
cx.update(|_, cx| {
tab_panel.read_with(cx, |tab_panel, _| {
assert_eq!(tab_panel.active_ix, 0);
assert_eq!(
tab_panel.panels[0].view().entity_id(),
panels[1].entity_id()
);
});
});
}
#[gpui::test]
fn collapse_and_expand_notify_active_panel(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (_keep, tab_panel, _) = build_tabs(&fixture, &["A", "B"], None, &mut cx);
cx.run_until_parked();
drain(&fixture.log);
cx.update(|window, cx| {
tab_panel.update(cx, |tab_panel, cx| {
tab_panel.set_collapsed(true, window, cx)
});
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), [("A", false)]);
cx.update(|window, cx| {
tab_panel.update(cx, |tab_panel, cx| {
tab_panel.set_collapsed(false, window, cx)
});
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), [("A", true)]);
}
#[gpui::test]
fn background_add_is_silent_but_first_panel_is_not(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (_keep, tab_panel, _) = build_tabs(&fixture, &["A", "B"], None, &mut cx);
cx.run_until_parked();
drain(&fixture.log);
let log = fixture.log.clone();
cx.update(|window, cx| {
let c = test_panel("C", &log, cx);
tab_panel.update(cx, |tab_panel, cx| {
tab_panel.add_panel_with_active(Arc::new(c), false, window, cx)
});
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), []);
// The first panel of an empty group is displayed regardless of the
// `active` flag, so it must be told.
let (_keep2, empty_tab_panel, _) = build_tabs(&fixture, &[], None, &mut cx);
cx.run_until_parked();
drain(&fixture.log);
let log = fixture.log.clone();
cx.update(|window, cx| {
let d = test_panel("D", &log, cx);
empty_tab_panel.update(cx, |tab_panel, cx| {
tab_panel.add_panel_with_active(Arc::new(d), false, window, cx)
});
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), [("D", true)]);
}
#[gpui::test]
fn drag_active_panel_to_other_group_stays_silent_for_it(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (_keep_src, source, source_panels) = build_tabs(&fixture, &["A", "B"], None, &mut cx);
let (_keep_dst, target, _) = build_tabs(&fixture, &["C"], None, &mut cx);
cx.run_until_parked();
drain(&fixture.log);
// A was already told `true`; becoming the target's active tab must
// not repeat it.
cx.update(|window, cx| {
let drag = DragPanel::new(Arc::new(source_panels[0].clone()), source.clone());
target.update(cx, |tab_panel, cx| {
tab_panel.on_drop(&drag, None, true, window, cx)
});
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), [("B", true), ("C", false)]);
}
#[gpui::test]
fn drag_active_panel_to_background_slot_deactivates_it(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (_keep_src, source, source_panels) = build_tabs(&fixture, &["A"], None, &mut cx);
let (_keep_dst, target, _) = build_tabs(&fixture, &["C", "D"], None, &mut cx);
cx.run_until_parked();
drain(&fixture.log);
// A was told `true` and becomes a background tab, so it gets one `false`.
cx.update(|window, cx| {
let drag = DragPanel::new(Arc::new(source_panels[0].clone()), source.clone());
target.update(cx, |tab_panel, cx| {
tab_panel.on_drop(&drag, None, false, window, cx)
});
});
cx.run_until_parked();
assert_eq!(drain(&fixture.log), [("A", false)]);
}
/// Adding a panel to the right of the center splits it horizontally;
/// a later bottom add wraps the split in a vertical one.
#[gpui::test]
fn add_panel_splits_the_center(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (item_a, _, _) = build_tabs(&fixture, &["A"], None, &mut cx);
let (_item_b, _, panels_b) = build_tabs(&fixture, &["B"], None, &mut cx);
let (_item_c, _, panels_c) = build_tabs(&fixture, &["C"], None, &mut cx);
cx.update(|window, cx| {
fixture.dock_area.update(cx, |dock_area, cx| {
dock_area.set_center(item_a, window, cx);
dock_area.add_panel(
Arc::new(panels_b[0].clone()),
DockPlacement::Right,
window,
cx,
);
});
});
let (axis, len) = cx.read(|cx| match fixture.dock_area.read(cx).center() {
DockItem::Split { axis, items, .. } => (*axis, items.len()),
other => panic!("center must be a horizontal split, got {other:?}"),
});
assert_eq!(axis, Axis::Horizontal);
assert_eq!(len, 2, "the right panel must be appended to the split");
cx.update(|window, cx| {
fixture.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel(
Arc::new(panels_c[0].clone()),
DockPlacement::Bottom,
window,
cx,
);
});
});
cx.read(|cx| match fixture.dock_area.read(cx).center() {
DockItem::Split {
axis, items, sizes, ..
} => {
assert_eq!(*axis, Axis::Vertical);
assert_eq!(items.len(), 2);
assert_eq!(sizes.len(), 2);
}
other => panic!("center must be wrapped in a vertical split, got {other:?}"),
});
}
#[derive(Default)]
struct ProbeFlags {
empty_down: AtomicBool,
empty_click: AtomicBool,
control_down: AtomicBool,
control_click: AtomicBool,
}
struct ProbeView {
flags: Entity<ProbeFlags>,
}
impl Render for ProbeView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let flags = self.flags.clone();
let empty = title_bar_drag_handlers(
div().id("empty-space").h_full().flex_grow_1().min_w_16(),
window,
cx,
)
.debug_selector(|| "empty-space".into())
.on_mouse_down(MouseButton::Left, {
let flags = flags.clone();
move |_, _, cx| flags.update(cx, |f, _| f.empty_down.store(true, Ordering::SeqCst))
})
.on_click({
let flags = flags.clone();
move |_, _, cx| flags.update(cx, |f, _| f.empty_click.store(true, Ordering::SeqCst))
});
let control =
title_bar_drag_handlers(div().id("control-space").h_8().flex_grow_1(), window, cx)
.debug_selector(|| "control-space".into())
.on_mouse_down(MouseButton::Left, {
let flags = flags.clone();
move |_, _, cx| {
flags.update(cx, |f, _| f.control_down.store(true, Ordering::SeqCst))
}
})
.on_click({
let flags = flags.clone();
move |_, _, cx| {
flags.update(cx, |f, _| f.control_click.store(true, Ordering::SeqCst))
}
});
v_flex()
.size_full()
.child(
TabBar::new("probe-bar")
.child(Tab::new().label("Alpha"))
.child(Tab::new().label("Beta"))
.suffix(div())
.last_empty_space(empty),
)
.child(control)
}
}
/// Diagnostic: verify that the tab bar's `last_empty_space` element
/// receives mouse events when wrapped by `title_bar_drag_handlers`.
#[gpui::test]
fn tab_bar_empty_space_receives_events(cx: &mut TestAppContext) {
let (flags, handle) = cx.update(|cx| {
cx.set_global(Theme::default());
let flags = cx.new(|_| ProbeFlags::default());
let handle = cx.open_window(
WindowOptions {
window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds {
origin: gpui::Point::default(),
size: size(px(800.), px(100.)),
})),
..Default::default()
},
|window, cx| {
let flags = flags.clone();
let content = cx.new(|_| ProbeView { flags });
cx.new(|cx| Root::new(content, window, cx))
},
);
(flags, handle.unwrap())
});
let mut cx = VisualTestContext::from_window(handle.into(), cx);
cx.run_until_parked();
cx.update(|window, cx| {
_ = window.draw(cx);
});
let empty_bounds = cx
.debug_bounds("empty-space")
.expect("empty-space must be laid out");
let control_bounds = cx
.debug_bounds("control-space")
.expect("control-space must be laid out");
let empty_center = empty_bounds.center();
let control_center = control_bounds.center();
// Control: a plain div with the same handlers, outside the TabBar.
cx.simulate_click(control_center, Default::default());
// Target: the TabBar's last_empty_space.
cx.simulate_click(empty_center, Default::default());
let (empty_down, empty_click, control_down, control_click) = cx.read(|cx| {
let flags = flags.read(cx);
(
flags.empty_down.load(Ordering::SeqCst),
flags.empty_click.load(Ordering::SeqCst),
flags.control_down.load(Ordering::SeqCst),
flags.control_click.load(Ordering::SeqCst),
)
});
assert!(
control_down,
"control mouse_down must fire at {control_center:?}"
);
assert!(
control_click,
"control click must fire at {control_center:?}"
);
assert!(
empty_down,
"empty-space mouse_down must fire at {empty_center:?}"
);
assert!(
empty_click,
"empty-space click must fire at {empty_center:?}"
);
}
/// The title-bar drag overlay must cover the dead zone between the last
/// tab and the suffix (TabBar's scrollable content is content-width, so
/// that area has no element of its own).
#[gpui::test]
fn title_bar_drag_overlay_covers_the_dead_zone(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (item, _tab_panel, _) = build_tabs(&fixture, &["Alpha", "Beta"], None, &mut cx);
cx.update(|window, cx| {
fixture
.dock_area
.update(cx, |dock_area, cx| dock_area.set_center(item, window, cx));
});
cx.run_until_parked();
// Frame 1 measures the tab bar; the overlay renders from frame 2 on.
cx.update(|window, cx| {
_ = window.draw(cx);
});
cx.run_until_parked();
cx.update(|window, cx| {
_ = window.draw(cx);
});
cx.run_until_parked();
let overlay = cx
.debug_bounds("title-bar-drag")
.expect("title-bar drag overlay must be laid out");
assert!(
overlay.size.height > px(20.),
"overlay must cover the tab bar height, got {overlay:?}"
);
assert!(
overlay.size.width > px(100.),
"overlay must cover the dead zone, got {overlay:?}"
);
// A click in the middle of the dead zone must reach the overlay
// without panicking (its handlers start a window move on drag).
cx.simulate_click(overlay.center(), Default::default());
}
}