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::{ Anchor, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity, GlobalElementId, 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, rems, }; pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement}; use gpui_base::dock::{ DockAreaRenderer, DockContext, DragPanel, DropIndicator, InsertTarget, NodeId, PaneNode, PaneRef, PanelId, TabGroupContext, TabGroupRenderer, TileContext, TilesRenderer, }; use gpui_base::{Placement, ResizeHandleContext, Side}; use theme::{ActiveTheme, TABBAR_HEIGHT}; use crate::button::{Button, ButtonVariants as _}; use crate::menu::DropdownMenu as _; use crate::resizable::{resize_handle, resize_handle_appearance}; use crate::tab::Tab; use crate::tab::tab_bar::TabBar; use crate::title_bar::{title_bar_drag_handlers, window_controls}; use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex}; mod panel; pub use panel::*; actions!(dock, [ToggleZoom, ClosePanel]); pub type TitleBarRenderer = fn(&mut Window, &mut App) -> AnyElement; #[derive(Default)] pub struct TitleBarChrome { trailing: Cell>, } impl TitleBarChrome { pub fn set_trailing(&self, renderer: TitleBarRenderer) { self.trailing.set(Some(renderer)); } fn trailing(&self, window: &mut Window, cx: &mut App) -> Option { self.trailing.get().map(|render| render(window, cx)) } } pub fn dock_area( id: impl Into, window: &mut Window, cx: &mut App, ) -> (Entity, Rc) { let chrome = Rc::new(TitleBarChrome::default()); let shared = Rc::new(SkinShared { area: RefCell::new(None), resizing: Cell::new(None), chrome: chrome.clone(), }); 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, chrome) } 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); } /// 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 } pub fn focus_tab_panel(area: &DockArea, window: &mut Window, cx: &mut App) { let Some(tree) = area.layout(DockPlacement::Center) else { return; }; let Some(node) = left_top_group(tree.root()) else { return; }; let Some(PaneRef::Tabs { panels, active_ix }) = tree.find_node(node).map(PaneNode::kind) else { return; }; let displayed = match panels.get(active_ix) { Some(panel) if area.panel(*panel).is_some_and(|panel| panel.visible(cx)) => Some(*panel), _ => panels .iter() .copied() .find(|id| area.panel(*id).is_some_and(|panel| panel.visible(cx))), }; let Some(panel) = displayed.and_then(|id| area.panel(id).cloned()) else { return; }; let focus_handle = panel.focus_handle(cx); window.focus(&focus_handle, cx); } 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), PaneRef::Tiles { .. } => None, } } 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) } PaneRef::Tiles { .. } => None, } } #[derive(Default)] struct SkinShared { area: RefCell>>, resizing: Cell>, chrome: Rc, } 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", }; resize_handle(id, placement.axis()) .placement(if placement.is_left() { Side::Left } else { Side::Right }) .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())) } fn tiles_renderer(&self) -> Rc { Rc::new(NoTiles) } } struct NoTiles; impl TilesRenderer for NoTiles { fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement { Empty.into_any_element() } } /// 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(cx.theme().radius) .text_sm() .text_color(cx.theme().text) .text_ellipsis() .when(cx.theme().shadow, |this| this.shadow_xs()) .bg(cx.theme().background) .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()) } fn render_toolbar( &self, group: &TabGroupContext, window: &mut Window, cx: &mut App, ) -> impl IntoElement { let zoomed = group.is_zoomed(); let closable = group.is_closable(); let zoomable = group.active_panel().is_some_and(|panel| panel.zoomable(cx)); let zoom_label = if zoomed { "Zoom Out" } else { "Zoom In" }; let buttons = group .active_panel() .and_then(PanelHandle::of) .map(|handle| handle.panel().toolbar_buttons(window, cx)) .unwrap_or_default(); let menu_panel = group .active_panel() .and_then(PanelHandle::of) .map(|handle| handle.panel().clone()); h_flex() .p_0p5() .gap_1p5() .occlude() .rounded_full() .children(buttons.into_iter().map(|button| button.small().ghost())) .when(zoomed, |this| { this.child( Button::new("zoom") .icon(IconName::Zoom) .small() .ghost() .tooltip("Zoom Out") .on_click({ let group = TabGroupContext::clone(group); move |_, window, cx| group.toggle_zoom(window, cx) }), ) }) .child( Button::new("menu") .icon(IconName::Ellipsis) .small() .ghost() .dropdown_menu({ move |menu, _, cx| { let menu = match menu_panel.clone() { Some(panel) => panel.popup_menu(menu, cx), None => menu, }; menu.when(zoomable, |this| { this.separator().menu(zoom_label, Box::new(ToggleZoom)) }) .when(closable, |this| { this.separator().menu("Close", Box::new(ClosePanel)) }) } }) .anchor(Anchor::TopRight), ) } fn render_title( &self, group: &TabGroupContext, ix: usize, window: &mut Window, cx: &mut App, ) -> AnyElement { let panel = group.panels()[ix].clone(); 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 drag = tab_drag(group, ix, cx); let is_title_bar = self.is_title_bar_group(group, cx); let trailing_chrome = is_title_bar .then(|| self.shared.chrome.trailing(window, cx)) .flatten(); let bar = h_flex() .id("tab-title-bar") .justify_between() .items_center() .line_height(rems(1.0)) .h(TABBAR_HEIGHT) .bg(cx.theme().panel_background) .when(left_button.is_some(), |this| this.pl_2()) .when(right_button.is_some(), |this| this.pr_2()) .when(has_leading, |this| { this.child( h_flex() .flex_shrink_0() .mr_1() .gap_1() .children(left_button) .children(bottom_button), ) }) .child( div() .id("tab") .flex_initial() .min_w_0() .px_2() .overflow_hidden() .whitespace_nowrap() .child( div() .w_full() .text_ellipsis() .text_sm() .child(panel_title(&panel, 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(), }) } }) }), ) .child({ let space = div().id("tab-title-space").flex_1().h_full(); if is_title_bar { title_bar_drag_handlers(space, window, cx).into_any_element() } else { space.into_any_element() } }) .child( h_flex() .flex_shrink_0() .ml_1() .gap_1() .child(self.render_toolbar(group, window, cx)) .children(right_button), ) .when_some(trailing_chrome, |this, chrome| this.child(chrome)); if is_title_bar { h_flex() .h(TABBAR_HEIGHT) .bg(cx.theme().panel_background) .child(bar.flex_1()) .child(window_controls()) .into_any_element() } else { bar.into_any_element() } } 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 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 is_title_bar = self.is_title_bar_group(group, cx); let trailing_chrome = is_title_bar .then(|| self.shared.chrome.trailing(window, cx)) .flatten(); 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().surface_background)) .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) .bg(cx.theme().panel_background) .when(is_title_bar || has_leading, |this| { this.prefix( h_flex() .items_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().border) }) .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() .items_center() .top_0() .right_0() .h_full() .px_0p5() .gap_1() .child(self.render_toolbar(group, window, cx)) .children(right_button) .children(trailing_chrome), ) }); if is_title_bar { h_flex() .h(TABBAR_HEIGHT) .w_full() .bg(cx.theme().panel_background) .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