use std::cell::{Cell, RefCell}; use std::ops::Deref as _; use std::rc::Rc; use std::sync::Arc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity, GlobalElementId, ImageSource, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _, WeakEntity, Window, actions, div, px, }; pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement}; use gpui_base::dock::{ DockAreaRenderer, DockContext, DragPanel, DropIndicator, InsertTarget, NodeId, PaneNode, PaneRef, PanelId, TabGroupContext, TabGroupRenderer, }; use gpui_base::{HandleEdge, Placement, ResizeHandleContext}; use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _, button_radius, panel_radius}; use crate::button::{Button, ButtonVariants as _}; use crate::resizable::{resize_handle, resize_handle_appearance}; use crate::tab::Tab; use crate::tab::tab_bar::TabBar; use crate::title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers, window_controls}; use crate::{IconName, Selectable, Sizable, StyledExt, background, h_flex, v_flex}; mod panel; pub use panel::*; actions!(dock, [ToggleZoom, ClosePanel]); pub fn dock_area( id: impl Into, window: &mut Window, cx: &mut App, ) -> Entity { let shared = Rc::new(SkinShared { area: RefCell::new(None), resizing: Cell::new(None), }); let area = cx.new(|cx| { DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin { shared: shared.clone(), })) }); *shared.area.borrow_mut() = Some(area.downgrade()); area } pub fn add_panel( area: &mut DockArea, panel: PanelHandle, placement: DockPlacement, window: &mut Window, cx: &mut Context, ) { let key = panel.panel().panel_id(cx); if let Some((id, node, ix)) = find_panel(area, &key, cx) { area.move_panel( id, InsertTarget::Tabs { node, ix: Some(ix), activate: true, }, window, cx, ); return; } area.add_panel_view(Arc::new(panel), placement, None, window, cx); } /// Add a panel to a dock reached through a weak handle. pub fn add_panel_to( dock: &WeakEntity, panel: PanelHandle, placement: DockPlacement, window: &mut Window, cx: &mut App, ) { if let Err(error) = dock.update(cx, |area, cx| { add_panel(area, panel, placement, window, cx); }) { log::warn!("Failed to add panel to dock: {error}"); } } /// The panel in any region of `area` whose logical id is `key`. fn find_panel(area: &DockArea, key: &SharedString, cx: &App) -> Option<(PanelId, NodeId, usize)> { let placements = [ DockPlacement::Center, DockPlacement::Left, DockPlacement::Right, DockPlacement::Bottom, ]; for placement in placements { let Some(tree) = area.layout(placement) else { continue; }; for id in tree.panels() { let matches = area .panel(id) .and_then(PanelHandle::of) .is_some_and(|handle| handle.panel().panel_id(cx) == *key); if !matches { continue; } let Some(node) = tree.find_panel_node(id) else { continue; }; let Some(PaneRef::Tabs { panels, .. }) = tree.find_node(node).map(PaneNode::kind) else { continue; }; let Some(ix) = panels.iter().position(|candidate| *candidate == id) else { continue; }; return Some((id, node, ix)); } } None } fn left_top_group(node: &PaneNode) -> Option { match node.kind() { PaneRef::Tabs { .. } => Some(node.id()), PaneRef::Split { children, .. } => children.first().and_then(left_top_group), } } fn right_top_group(node: &PaneNode) -> Option { match node.kind() { PaneRef::Tabs { .. } => Some(node.id()), PaneRef::Split { axis, children, .. } => { let child = if axis == gpui::Axis::Vertical { children.first() } else { children.last() }; child.and_then(right_top_group) } } } #[derive(Default)] struct SkinShared { area: RefCell>>, resizing: Cell>, } impl SkinShared { fn area(&self) -> Option> { self.area.borrow().as_ref().and_then(|area| area.upgrade()) } } struct DockSkin { shared: Rc, } impl DockSkin { fn resize_handle(&self, dock: &DockContext) -> impl IntoElement { let placement = dock.placement(); let shared = self.shared.clone(); let id = match placement { DockPlacement::Left => "dock-resize-handle-left", DockPlacement::Right => "dock-resize-handle-right", DockPlacement::Bottom => "dock-resize-handle-bottom", DockPlacement::Center => "dock-resize-handle-center", }; let edge = match placement { DockPlacement::Left => Some(HandleEdge::Trailing), DockPlacement::Right | DockPlacement::Bottom => Some(HandleEdge::Leading), DockPlacement::Center => None, }; resize_handle(id, placement.axis()) .when_some(edge, |this, edge| this.inside(edge)) .with_appearance(resize_handle_appearance()) .on_drag(DockResizeHandle, move |info, _, _, cx| { cx.stop_propagation(); shared.resizing.set(Some(placement)); cx.new(|_| info.deref().clone()) }) } } impl DockAreaRenderer for DockSkin { fn render_split_handle( &self, handle: &ResizeHandleContext, window: &mut Window, cx: &mut App, ) -> Option { resize_handle_appearance()(handle, window, cx) } fn render_dock( &self, dock: &DockContext, content: AnyElement, _: &mut Window, _: &mut App, ) -> AnyElement { div() .flex() .size_full() .relative() .child(content) .child(self.resize_handle(dock)) .child(DockResizeTracker { dock: dock.clone(), shared: self.shared.clone(), }) .into_any_element() } fn tab_group_renderer(&self) -> Rc { Rc::new(TabGroupSkin::new(self.shared.clone())) } } /// The payload a dock's resize handle drags; the handle is the affordance. #[derive(Clone)] struct DockResizeHandle; impl Render for DockResizeHandle { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Empty } } /// Turns the window's mouse stream into dock resizing. struct DockResizeTracker { dock: DockContext, shared: Rc, } impl IntoElement for DockResizeTracker { type Element = Self; fn into_element(self) -> Self::Element { self } } impl gpui::Element for DockResizeTracker { type PrepaintState = (); type RequestLayoutState = (); fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } fn request_layout( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { (window.request_layout(Style::default(), None, cx), ()) } fn prepaint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, _: Bounds, _: &mut Self::RequestLayoutState, _: &mut Window, _: &mut App, ) { } fn paint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, _: Bounds, _: &mut Self::RequestLayoutState, _: &mut Self::PrepaintState, window: &mut Window, _: &mut App, ) { let placement = self.dock.placement(); window.on_mouse_event({ let dock = self.dock.clone(); let shared = self.shared.clone(); move |event: &MouseMoveEvent, phase, window, cx| { if !phase.bubble() || shared.resizing.get() != Some(placement) { return; } let open = shared .area() .is_some_and(|area| area.read(cx).is_dock_open(placement)); if !open { dock.toggle(window, cx); } dock.resize_to(event.position, window, cx); } }); window.on_mouse_event({ let shared = self.shared.clone(); move |_: &MouseUpEvent, phase, _, _| { if !phase.bubble() || shared.resizing.get() != Some(placement) { return; } shared.resizing.set(None); } }); } } struct DragPreview { panel: Arc, } impl Render for DragPreview { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() .id("drag-panel") .cursor_grab() .p_2() .min_w_24() .justify_center() .overflow_hidden() .whitespace_nowrap() .rounded(px(button_radius())) .text_style(TextStyle::Callout) .text_color(cx.theme().text) .text_ellipsis() .when(cx.theme().shadow, |this| this.shadow_xs()) .bg(cx.theme().bg) .child(panel_title(&self.panel, cx)) } } struct TabGroupSkin { shared: Rc, scroll_handle: ScrollHandle, last_active_ix: Cell>, } impl TabGroupSkin { fn new(shared: Rc) -> Self { Self { shared, scroll_handle: ScrollHandle::new(), last_active_ix: Cell::new(None), } } fn is_title_bar_group(&self, group: &TabGroupContext, cx: &App) -> bool { let Some(area) = self.shared.area() else { return false; }; area.read(cx) .layout(DockPlacement::Center) .and_then(|tree| left_top_group(tree.root())) == Some(group.node()) } /// Whether this group is the left dock's root with a single panel. fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool { let Some(area) = self.shared.area() else { return false; }; let area = area.read(cx); area.layout(DockPlacement::Left) .map(|tree| tree.root().id()) == Some(group.node()) && group.panels().len() == 1 } fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool { let Some(area) = self.shared.area() else { return false; }; let area = area.read(cx); let left_open = area.is_dock_open(DockPlacement::Left) && !area.is_empty(DockPlacement::Left, cx); let tree = if left_open { area.layout(DockPlacement::Left) } else { area.layout(DockPlacement::Center) }; tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node()) } fn render_tabs( &self, group: &TabGroupContext, visible: &[usize], window: &mut Window, cx: &mut App, ) -> AnyElement { let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx); let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx); let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let has_leading = left_button.is_some() || bottom_button.is_some(); let collapsed = group.is_collapsed(); let droppable = group.is_droppable(); let tabs_count = group.panels().len(); let is_title_bar = self.is_title_bar_group(group, cx); let wallpaper = group .active_panel() .and_then(|panel| panel_background(panel, cx)) .is_some(); let displayed = group.active_panel().map(|panel| panel.panel_id(cx)); let displayed_ix = displayed.and_then(|displayed| { group .panels() .iter() .position(|panel| panel.panel_id(cx) == displayed) }); let needs_traffic_light_padding = cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx); let empty_space = div() .id("tab-bar-empty-space") .h_full() .flex_grow_1() .min_w_16() .when(droppable, |this| { this.drag_over::(|this, _, _, cx| this.bg(cx.theme().drop_target)) .on_drop({ let group = TabGroupContext::clone(group); move |drag: &DragPanel, window, cx| { let ix = (drag.source() == group.node()).then(|| tabs_count - 1); group.drop_panel(drag.clone(), ix, false, window, cx); } }) }); let empty_space = if is_title_bar { title_bar_drag_handlers(empty_space, window, cx).into_any_element() } else { empty_space.into_any_element() }; let bar = TabBar::new("tab-bar") .track_scroll(&self.scroll_handle) .h(TABBAR_HEIGHT) .when(!wallpaper, |this| this.bg(cx.theme().bg)) .when(needs_traffic_light_padding, |this| { this.pl(px(TRAFFIC_LIGHT_PADDING)) }) .when(is_title_bar || has_leading, |this| { this.prefix( h_flex() .size(TABBAR_HEIGHT) .flex_shrink_0() .justify_center() .top_0() .right(-px(1.)) .pl_0p5() .pr_1() .children(left_button) .children(bottom_button), ) }) .children(visible.iter().map(|ix| { let ix = *ix; let panel = group.panels()[ix].clone(); let drag = tab_drag(group, ix, cx); Tab::new() .ix(ix) .tab_bar_prefix(has_leading) .child(panel_title(&panel, cx)) .selected(!collapsed && displayed_ix == Some(ix)) .disabled(collapsed) .suffix( Button::new("close") .icon(IconName::Close) .tooltip("Close panel") .ghost() .xsmall() .on_click({ let group = TabGroupContext::clone(group); let panel = panel.clone(); move |_, window, cx| { group.close(panel.panel_id(cx), window, cx); } }), ) .on_click({ let group = TabGroupContext::clone(group); move |_, window, cx| group.select_tab(ix, window, cx) }) .when(!collapsed, |this| { this.on_mouse_down(MouseButton::Middle, { let group = TabGroupContext::clone(group); let panel = panel.clone(); move |_, window, cx| { group.close(panel.panel_id(cx), window, cx); } }) .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); cx.new(|_| DragPreview { panel: panel.clone(), }) } }) }) .when(droppable, |this| { this.drag_over::(|this, _, _, cx| { this.rounded_l_none() .border_l_2() .border_r_0() .border_color(cx.theme().drop_line) }) .on_drop({ let group = TabGroupContext::clone(group); move |drag: &DragPanel, window, cx| { group.drop_panel(drag.clone(), Some(ix), true, window, cx); } }) }) }) })) .last_empty_space(empty_space) .when(!collapsed, |this| { this.suffix( h_flex() .flex_shrink_0() .items_center() .top_0() .right_0() .h_full() .px_0p5() .gap_1() .children(right_button), ) }); if is_title_bar { h_flex() .h(TABBAR_HEIGHT) .w_full() .when(!wallpaper, |this| this.bg(cx.theme().bg)) .child(bar.flex_1()) .child(window_controls()) .into_any_element() } else { bar.into_any_element() } } fn dock_toggle_button( &self, placement: DockPlacement, group: &TabGroupContext, cx: &mut App, ) -> Option