//! The dock: the center pane tree — tabbed panes the app fills from //! registered panels, split two ways, collapsing as tabs close. use std::collections::HashMap; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, AnyView, App, AppContext as _, Axis, Context, ElementId, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, canvas, div, px, relative, size, }; use gpui_native_core::common::{h_flex, v_flex}; use gpui_native_core::context_menu::ContextMenu; use gpui_native_core::menu::MenuItem; use gpui_native_data::tabs::{Tab, Tabs, TabsVariant}; use gpui_native_icons::IconRole; use gpui_native_theme::{ActiveTheme as _, Size}; use crate::resize_handle::ResizeHandle; /// The narrowest either side of a split can get, as a fraction of the split. const MIN_SPLIT_RATIO: f32 = 0.1; /// The fraction a split opens at, and what a double-click on the seam restores. const DEFAULT_SPLIT_RATIO: f32 = 0.5; /// Builds the view a panel opens as, once per open. type PanelBuilder = Box AnyView>; /// A registered panel: its identity, chrome, and how its view is built. pub struct PanelDescriptor { id: SharedString, title: SharedString, icon: Option, build: PanelBuilder, } impl PanelDescriptor { /// Creates a panel descriptor that opens through `build`. pub fn new(id: I, title: T, build: F) -> Self where I: Into, T: Into, F: Fn(&mut Window, &mut App) -> AnyView + 'static, { Self { id: id.into(), title: title.into(), icon: None, build: Box::new(build), } } /// Shows an icon leading the tab label. pub fn icon(mut self, role: IconRole) -> Self { self.icon = Some(role); self } } /// Which side of its pane a split places the new pane on. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SplitDirection { Left, Right, Top, Bottom, } impl SplitDirection { fn axis(self) -> Axis { match self { Self::Left | Self::Right => Axis::Horizontal, Self::Top | Self::Bottom => Axis::Vertical, } } /// Whether the new pane takes the split's leading slot. fn leads(self) -> bool { matches!(self, Self::Left | Self::Top) } } /// The payload a dock tab drags: the panel to move. #[derive(Clone)] pub struct PanelDrag { pub panel: SharedString, } /// The card that follows the cursor while a tab is dragged. struct DragPreview { title: SharedString, } impl Render for DragPreview { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); div() .px_2() .py_1() .rounded(theme.radius.sm) .border_1() .border_color(theme.colors.border) .bg(theme.colors.surface) .text_size(theme.sizes.medium.text_size) .text_color(theme.colors.foreground) .opacity(0.85) .child(self.title.clone()) } } /// One open panel view. struct PanelTab { panel: SharedString, title: SharedString, icon: Option, view: AnyView, } /// A leaf: the tabs of one region and which is active. struct Pane { key: u64, tabs: Vec, active: Option, } /// An internal node: two children along an axis. struct Split { key: u64, axis: Axis, ratio: f32, a: Box, b: Box, } enum Node { Pane(Pane), Split(Split), } /// The dock's pane tree, owned by the application. pub struct DockState { root: Node, panels: HashMap, /// The pane new panels open into, kept on the last activated pane. active_pane: u64, next_key: u64, } impl DockState { /// Creates a dock with one empty pane. pub fn new(cx: &mut App) -> Entity { cx.new(|_| Self { root: Node::Pane(Pane { key: 0, tabs: Vec::new(), active: None, }), panels: HashMap::new(), active_pane: 0, next_key: 1, }) } /// Registers a panel descriptor, replacing any earlier one with its id. pub fn register_panel(&mut self, panel: PanelDescriptor, cx: &mut Context) { if self.panels.insert(panel.id.clone(), panel).is_none() { cx.notify(); } } /// Opens a panel into the active pane and activates its tab. pub fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context) { if self.is_open(id) { self.activate_panel(id, cx); return; } let Some(panel) = self.panels.get(id) else { return; }; let tab = PanelTab { panel: SharedString::from(id), title: panel.title.clone(), icon: panel.icon, view: (panel.build)(window, cx), }; let pane_key = self.target_pane_key(); if let Some(pane) = self.pane_mut(pane_key) { pane.tabs.push(tab); pane.active = Some(SharedString::from(id)); } self.active_pane = pane_key; cx.notify(); } /// Activates a panel's tab and makes its pane the active pane. pub fn activate_panel(&mut self, id: &str, cx: &mut Context) { let Some((pane_key, _)) = self.locate(id) else { return; }; let changed = { let Some(pane) = self.pane_mut(pane_key) else { return; }; let changed = pane.active.as_deref() != Some(id); if changed { pane.active = Some(SharedString::from(id)); } changed }; self.active_pane = pane_key; if changed { cx.notify(); } } /// Closes a panel's tab. pub fn close_panel(&mut self, id: &str, cx: &mut Context) { let Some((pane_key, index)) = self.locate(id) else { return; }; let emptied = { let Some(pane) = self.pane_mut(pane_key) else { return; }; let was_active = pane.active.as_deref() == Some(id); pane.tabs.remove(index); if was_active { pane.active = pane .tabs .get(index) .or_else(|| { index .checked_sub(1) .and_then(|previous| pane.tabs.get(previous)) }) .map(|tab| tab.panel.clone()); } pane.tabs.is_empty() }; if emptied { self.detach_pane(pane_key); if self.active_pane == pane_key { self.active_pane = self.first_pane_key().unwrap_or(0); } } cx.notify(); } /// Closes every other tab in the panel's pane, keeping this one active. pub fn close_others(&mut self, id: &str, cx: &mut Context) { let Some((pane_key, _)) = self.locate(id) else { return; }; let changed = { let Some(pane) = self.pane_mut(pane_key) else { return; }; let before = pane.tabs.len(); pane.tabs.retain(|tab| tab.panel.as_ref() == id); pane.active = Some(SharedString::from(id)); pane.tabs.len() != before }; if changed { cx.notify(); } } /// Closes every tab in the panel's pane; a non-root pane collapses. pub fn close_pane(&mut self, id: &str, cx: &mut Context) { let Some((pane_key, _)) = self.locate(id) else { return; }; let was_empty = self .pane_mut(pane_key) .is_none_or(|pane| pane.tabs.is_empty()); if was_empty { return; } if let Some(pane) = self.pane_mut(pane_key) { pane.tabs.clear(); pane.active = None; } self.detach_pane(pane_key); if self.active_pane == pane_key { self.active_pane = self.first_pane_key().unwrap_or(0); } cx.notify(); } /// Moves a panel's tab into a new pane on `direction`'s side of its pane. pub fn split_panel(&mut self, id: &str, direction: SplitDirection, cx: &mut Context) { self.split_panel_at(id, id, direction, cx); } /// Moves a panel's tab into a new pane on `direction`. pub fn split_panel_at( &mut self, id: &str, anchor: &str, direction: SplitDirection, cx: &mut Context, ) { let (Some((source_key, index)), Some((target_key, _))) = (self.locate(id), self.locate(anchor)) else { return; }; let tab = { let Some(pane) = self.pane_mut(source_key) else { return; }; let tab = pane.tabs.remove(index); if pane.active.as_deref() == Some(id) { pane.active = pane .tabs .get(index) .or_else(|| { index .checked_sub(1) .and_then(|previous| pane.tabs.get(previous)) }) .map(|remaining| remaining.panel.clone()); } tab }; let emptied = self .pane_mut(source_key) .is_none_or(|pane| pane.tabs.is_empty()); let new_key = self.next_key; let split_key = self.next_key + 1; self.next_key += 2; let new_pane = Node::Pane(Pane { key: new_key, tabs: vec![tab], active: Some(SharedString::from(id)), }); Self::wrap_in(&mut self.root, target_key, new_pane, direction, split_key); if emptied { self.detach_pane(source_key); } self.active_pane = new_key; cx.notify(); } /// Moves a panel's tab into the pane holding `anchor`, appended last. pub fn move_panel(&mut self, id: &str, anchor: &str, cx: &mut Context) { let (Some((source_key, index)), Some((target_key, _))) = (self.locate(id), self.locate(anchor)) else { return; }; if source_key == target_key { return; } let tab = { let Some(pane) = self.pane_mut(source_key) else { return; }; let tab = pane.tabs.remove(index); if pane.active.as_deref() == Some(id) { pane.active = pane .tabs .get(index) .or_else(|| { index .checked_sub(1) .and_then(|previous| pane.tabs.get(previous)) }) .map(|remaining| remaining.panel.clone()); } tab }; if let Some(pane) = self.pane_mut(target_key) { pane.tabs.push(tab); pane.active = Some(SharedString::from(id)); } let emptied = self .pane_mut(source_key) .is_none_or(|pane| pane.tabs.is_empty()); if emptied { self.detach_pane(source_key); if self.active_pane == source_key { self.active_pane = self.first_pane_key().unwrap_or(0); } } self.active_pane = target_key; cx.notify(); } /// The active tab of the pane new panels open into. pub fn active_panel(&self) -> Option { let pane = pane_in(&self.root, self.target_pane_key())?; pane.active.clone() } /// Whether a panel's tab is open anywhere in the tree. pub fn is_open(&self, id: &str) -> bool { self.locate(id).is_some() } fn locate(&self, id: &str) -> Option<(u64, usize)> { Self::locate_in(&self.root, id) } fn locate_in(node: &Node, id: &str) -> Option<(u64, usize)> { match node { Node::Pane(pane) => pane .tabs .iter() .position(|tab| tab.panel.as_ref() == id) .map(|index| (pane.key, index)), Node::Split(split) => { Self::locate_in(&split.a, id).or_else(|| Self::locate_in(&split.b, id)) } } } fn target_pane_key(&self) -> u64 { if pane_in(&self.root, self.active_pane).is_some() { self.active_pane } else { self.first_pane_key().unwrap_or(0) } } fn first_pane_key(&self) -> Option { Self::first_pane_in(&self.root) } fn first_pane_in(node: &Node) -> Option { match node { Node::Pane(pane) => Some(pane.key), Node::Split(split) => { Self::first_pane_in(&split.a).or_else(|| Self::first_pane_in(&split.b)) } } } fn pane_mut(&mut self, key: u64) -> Option<&mut Pane> { Self::pane_mut_in(&mut self.root, key) } fn pane_mut_in(node: &mut Node, key: u64) -> Option<&mut Pane> { match node { Node::Pane(pane) => (pane.key == key).then_some(pane), Node::Split(split) => Self::pane_mut_in(&mut split.a, key) .or_else(|| Self::pane_mut_in(&mut split.b, key)), } } fn split_mut(node: &mut Node, key: u64) -> Option<&mut Split> { let Node::Split(split) = node else { return None; }; if split.key == key { return Some(split); } Self::split_mut(&mut split.a, key).or_else(|| Self::split_mut(&mut split.b, key)) } /// Nudges a split's ratio by `fraction`, clamped at both ends. fn resize_split(&mut self, key: u64, fraction: f32, cx: &mut Context) { let Some(split) = Self::split_mut(&mut self.root, key) else { return; }; let ratio = (split.ratio + fraction).clamp(MIN_SPLIT_RATIO, 1.0 - MIN_SPLIT_RATIO); if (split.ratio - ratio).abs() > f32::EPSILON { split.ratio = ratio; cx.notify(); } } /// Restores a split to its default ratio. fn reset_split(&mut self, key: u64, cx: &mut Context) { let Some(split) = Self::split_mut(&mut self.root, key) else { return; }; if (split.ratio - DEFAULT_SPLIT_RATIO).abs() > f32::EPSILON { split.ratio = DEFAULT_SPLIT_RATIO; cx.notify(); } } /// Replaces a pane with a split holding it and `new_pane`. fn wrap_in( node: &mut Node, key: u64, new_pane: Node, direction: SplitDirection, split_key: u64, ) { match node { Node::Pane(pane) if pane.key == key => { let old_pane = take_node(node); let split = if direction.leads() { Split { key: split_key, axis: direction.axis(), ratio: DEFAULT_SPLIT_RATIO, a: Box::new(new_pane), b: Box::new(old_pane), } } else { Split { key: split_key, axis: direction.axis(), ratio: DEFAULT_SPLIT_RATIO, a: Box::new(old_pane), b: Box::new(new_pane), } }; *node = Node::Split(split); } Node::Pane(_) => {} Node::Split(split) => { if pane_in(&split.a, key).is_some() { Self::wrap_in(&mut split.a, key, new_pane, direction, split_key); } else if pane_in(&split.b, key).is_some() { Self::wrap_in(&mut split.b, key, new_pane, direction, split_key); } } } } /// Removes a pane and replaces its parent split with the sibling subtree. fn detach_pane(&mut self, key: u64) { Self::detach_in(&mut self.root, key); } fn detach_in(node: &mut Node, key: u64) { let Node::Split(split) = node else { return; }; let a_direct = direct_pane_key(&split.a) == Some(key); let b_direct = direct_pane_key(&split.b) == Some(key); if a_direct != b_direct { let survivor = if a_direct { take_node(&mut split.b) } else { take_node(&mut split.a) }; *node = survivor; } else { Self::detach_in(&mut split.a, key); Self::detach_in(&mut split.b, key); } } /// The tree the dock renders from, one frame at a time. fn snapshot(&self) -> TreeView { fn snap(node: &Node) -> TreeView { match node { Node::Pane(pane) => TreeView::Pane(PaneView { key: pane.key, tabs: pane .tabs .iter() .map(|tab| TabView { panel: tab.panel.clone(), title: tab.title.clone(), icon: tab.icon, view: tab.view.clone(), }) .collect(), active: pane.active.clone(), }), Node::Split(split) => TreeView::Split(SplitView { key: split.key, axis: split.axis, ratio: split.ratio, a: Box::new(snap(&split.a)), b: Box::new(snap(&split.b)), }), } } snap(&self.root) } } /// Finds a pane by key anywhere in the tree. fn pane_in(node: &Node, key: u64) -> Option<&Pane> { match node { Node::Pane(pane) => (pane.key == key).then_some(pane), Node::Split(split) => pane_in(&split.a, key).or_else(|| pane_in(&split.b, key)), } } /// The pane key of a node that is a pane, for seam-level tree edits. fn direct_pane_key(node: &Node) -> Option { match node { Node::Pane(pane) => Some(pane.key), Node::Split(_) => None, } } /// Takes a node out, leaving a placeholder that is never read again. fn take_node(node: &mut Node) -> Node { std::mem::replace( node, Node::Pane(Pane { key: u64::MAX, tabs: Vec::new(), active: None, }), ) } /// One open panel view, per frame. #[derive(Clone)] struct TabView { panel: SharedString, title: SharedString, icon: Option, view: AnyView, } #[derive(Clone)] struct PaneView { key: u64, tabs: Vec, active: Option, } struct SplitView { key: u64, axis: Axis, ratio: f32, a: Box, b: Box, } enum TreeView { Pane(PaneView), Split(SplitView), } /// A dock: renders the pane tree of a [`DockState`]. #[derive(IntoElement)] pub struct Dock { id: ElementId, state: Entity, } impl Dock { /// Creates a dock bound to `state`. pub fn new(id: I, state: Entity) -> Self where I: Into, { Self { id: id.into(), state, } } } impl RenderOnce for Dock { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let tree = self.state.read(cx).snapshot(); let root = render_node(&self.id, &self.state, &tree, window, cx); div() .id(self.id.clone()) .debug_selector({ let id = self.id.clone(); move || format!("dock-{id}") }) .size_full() .flex() .flex_col() .overflow_hidden() .child(root) } } fn render_node( dock_id: &ElementId, state: &Entity, node: &TreeView, window: &mut Window, cx: &mut App, ) -> AnyElement { match node { TreeView::Pane(pane) => render_pane(dock_id, state, pane, cx), TreeView::Split(split) => render_split(dock_id, state, split, window, cx), } } fn render_split( dock_id: &ElementId, state: &Entity, split: &SplitView, window: &mut Window, cx: &mut App, ) -> AnyElement { let span_state = window.with_element_namespace(dock_id.clone(), |window| { window .use_keyed_state(("split-span", split.key), cx, |_, _| size(px(0.), px(0.))) .clone() }); let seam = ResizeHandle::new(SharedString::from(format!("{dock_id}-seam-{}", split.key))) .axis(split.axis) .on_resize({ let span_state = span_state.clone(); let state = state.clone(); let axis = split.axis; let key = split.key; move |delta, _, cx| { let span = match axis { Axis::Horizontal => span_state.read(cx).width, Axis::Vertical => span_state.read(cx).height, }; if span > px(0.) { let fraction = delta / span; state.update(cx, |dock, cx| dock.resize_split(key, fraction, cx)); } } }) .on_reset({ let state = state.clone(); let key = split.key; move |_, cx| state.update(cx, |dock, cx| dock.reset_split(key, cx)) }); div() .id(SharedString::from(format!("{dock_id}-split-{}", split.key))) .size_full() .min_w_0() .min_h_0() .overflow_hidden() .flex() .when(split.axis == Axis::Horizontal, |split_el| { split_el.flex_row() }) .when(split.axis == Axis::Vertical, |split_el| split_el.flex_col()) .child( canvas(|bounds, _, _| bounds.size, { let span_state = span_state.clone(); move |_, span, _, cx| span_state.update(cx, |slot, _| *slot = span) }) .absolute() .inset_0(), ) .child( div() .flex_grow(split.ratio) .flex_basis(relative(0.)) .min_w_0() .min_h_0() .overflow_hidden() .child(render_node(dock_id, state, &split.a, window, cx)), ) .child(seam) .child( div() .flex_grow(1.0 - split.ratio) .flex_basis(relative(0.)) .min_w_0() .min_h_0() .overflow_hidden() .child(render_node(dock_id, state, &split.b, window, cx)), ) .into_any_element() } fn render_pane( dock_id: &ElementId, state: &Entity, pane: &PaneView, cx: &App, ) -> AnyElement { if pane.tabs.is_empty() { return div().size_full().into_any_element(); } let theme = cx.theme(); let metrics = theme.sizes.resolve(Size::default()); let pane_id = SharedString::from(format!("{dock_id}-pane-{}", pane.key)); let active_view = pane .tabs .iter() .find(|tab| Some(&tab.panel) == pane.active.as_ref()) .map(|tab| tab.view.clone()); // The cache keeps an un-notified panel from re-rendering on dock frames. let content = div() .id(SharedString::from(format!( "{dock_id}-content-{}", pane.key ))) .debug_selector({ let id = SharedString::from(format!("{dock_id}-content-{}", pane.key)); move || id.to_string() }) .flex_1() .min_h_0() .overflow_hidden() .role(Role::TabPanel) .when_some(active_view, |content, view| { content.child(view.cached(StyleRefinement::default().absolute().size_full())) }) .when(cx.has_active_drag(), |content| { content.child(render_drop_zones(state, pane)) }); let menu = ContextMenu::new(SharedString::from(format!( "{dock_id}-pane-{}-menu", pane.key ))) .target({ let dock_id = dock_id.clone(); let state = state.clone(); let pane = pane.clone(); let strip_height = metrics.strip_height; let padding_x = metrics.dense_padding_x; move |_, _| { let selected = pane .active .as_ref() .and_then(|active| pane.tabs.iter().position(|tab| &tab.panel == active)) .unwrap_or(0); let tabs = Tabs::new(SharedString::from(format!( "{dock_id}-pane-{}-tabs", pane.key ))) .variant(TabsVariant::Pill) .selected(selected) .tabs({ let tabs = pane.tabs.clone(); move |_, _| { tabs.iter() .map(|tab| { let mut entry = Tab::new(tab.title.clone()); if let Some(role) = tab.icon { entry = entry.icon(role); } entry.closeable(true).wrap({ let panel = tab.panel.clone(); let title = tab.title.clone(); move |tab| { tab.on_drag( PanelDrag { panel: panel.clone(), }, { let title = title.clone(); move |_, _, _, cx| { cx.new(|_| DragPreview { title: title.clone(), }) } }, ) .into_any_element() } }) }) .collect() } }) .on_change({ let state = state.clone(); let panels: Vec = pane.tabs.iter().map(|tab| tab.panel.clone()).collect(); move |index, _, cx| { if let Some(panel) = panels.get(index) { state.update(cx, |dock, cx| dock.activate_panel(panel, cx)); } } }) .on_close({ let state = state.clone(); let panels: Vec = pane.tabs.iter().map(|tab| tab.panel.clone()).collect(); move |index, _, cx| { if let Some(panel) = panels.get(index) { state.update(cx, |dock, cx| dock.close_panel(panel, cx)); } } }); // Chrome-less like the future titlebar: the pills carry the look. h_flex() .flex_none() .h(strip_height) .w_full() .items_center() .pr(padding_x) .child(tabs) .child(div().flex_1()) .into_any_element() } }) .items({ let state = state.clone(); let active = pane.active.clone(); let panels: Vec = pane.tabs.iter().map(|tab| tab.panel.clone()).collect(); move |_, _| { let mut items = Vec::new(); if let Some(active) = &active { items.push(MenuItem::new("Close").icon(IconRole::Close).handler({ let state = state.clone(); let active = active.clone(); move |_, cx| state.update(cx, |dock, cx| dock.close_panel(&active, cx)) })); if panels.len() > 1 { items.push(MenuItem::new("Close Others").handler({ let state = state.clone(); let active = active.clone(); move |_, cx| state.update(cx, |dock, cx| dock.close_others(&active, cx)) })); } } if let Some(first) = panels.first() { items.push(MenuItem::new("Close Pane").handler({ let state = state.clone(); let first = first.clone(); move |_, cx| state.update(cx, |dock, cx| dock.close_pane(&first, cx)) })); } items } }); v_flex() .id(pane_id.clone()) .debug_selector({ let id = pane_id.clone(); move || id.to_string() }) .size_full() .overflow_hidden() .child(menu) .child(content) .into_any_element() } /// The five drop targets a live tab drag sees over a pane's content: /// /// - Four edge bands that split /// - The center that moves the tab in. fn render_drop_zones(state: &Entity, pane: &PaneView) -> AnyElement { let Some(anchor) = pane .active .clone() .or_else(|| pane.tabs.first().map(|tab| tab.panel.clone())) else { return div().into_any_element(); }; div() .absolute() .inset_0() .child( drop_zone(state, &anchor, Some(SplitDirection::Left)) .absolute() .top_0() .bottom_0() .left_0() .w(relative(0.25)), ) .child( drop_zone(state, &anchor, Some(SplitDirection::Right)) .absolute() .top_0() .bottom_0() .right_0() .w(relative(0.25)), ) .child( drop_zone(state, &anchor, Some(SplitDirection::Top)) .absolute() .top_0() .left(relative(0.25)) .right(relative(0.25)) .h(relative(0.25)), ) .child( drop_zone(state, &anchor, Some(SplitDirection::Bottom)) .absolute() .bottom_0() .left(relative(0.25)) .right(relative(0.25)) .h(relative(0.25)), ) .child( drop_zone(state, &anchor, None) .absolute() .inset(relative(0.25)), ) .into_any_element() } /// Renders a drop zone for panel dragging. fn drop_zone( state: &Entity, anchor: &str, direction: Option, ) -> gpui::Div { let state = state.clone(); let anchor = SharedString::from(anchor); div() .drag_over::(|zone, _, _, cx| zone.bg(cx.theme().colors.selection)) .on_drop::(move |drag, _, cx| { state.update(cx, |dock, cx| match direction { Some(direction) => { dock.split_panel_at(&drag.panel, &anchor, direction, cx); } None => dock.move_panel(&drag.panel, &anchor, cx), }); }) }