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, draggable: bool, droppable: bool, active_panel: Option>, } #[derive(Clone)] pub(crate) struct DragPanel { pub(crate) panel: Arc, pub(crate) tab_panel: Entity, drag_offset: Rc>>, 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, tab_panel: Entity) -> 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, size: gpui::Size, } impl DropPlaceholderBounds { fn for_placement(bounds: gpui::Bounds, placement: Option) -> 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, from: DropPlaceholderBounds, to: DropPlaceholderBounds, epoch: u64, } impl Render for DragPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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, /// The stock_panel can be None, if is None, that means the panels can't be split or move stack_panel: Option>, pub(crate) panels: Vec>, pub(crate) active_ix: usize, /// What each panel was last told via `set_active`, keyed by EntityId; absent means `false`. notified_active: HashMap, /// 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, zoomed: bool, collapsed: bool, /// When drag move, will get the placement of the panel to be split will_split_placement: Option, drop_placeholder_animation: Option, 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 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 of the tab bar's suffix (toolbar) area, which marks where the /// draggable region ends. title_bar_suffix_bounds: Option>, } impl Panel for TabPanel { fn panel_name(&self) -> &'static str { "TabPanel" } fn title(&mut self, window: &mut Window, cx: &mut Context) -> 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 { 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, ) -> 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, ) -> Option> { 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
, window: &mut Window, cx: &mut App, ) -> Stateful
{ 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>, dock_area: WeakEntity, _: &mut Window, cx: &mut Context, ) -> 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) { self.stack_panel = Some(view); } /// Return current active_panel View pub fn active_panel(&self, cx: &App) -> Option> { 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) { 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) { 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, 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, window: &mut Window, cx: &mut Context, ) { self.add_panel_with_active(panel, true, window, cx); } fn add_panel_with_active( &mut self, panel: Arc, active: bool, window: &mut Window, cx: &mut Context, ) { 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, placement: Placement, size: Option, window: &mut Window, cx: &mut Context, ) { 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, ix: usize, window: &mut Window, cx: &mut Context, ) { 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, window: &mut Window, cx: &mut Context, ) { 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, window: &mut Window, cx: &mut Context, ) -> Option { 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) { 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.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> + '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, ) -> 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, ) -> Option