1243 lines
38 KiB
Rust
1243 lines
38 KiB
Rust
//! 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 std::rc::Rc;
|
|
|
|
use gpui::prelude::FluentBuilder as _;
|
|
use gpui::{
|
|
AnyElement, AnyView, App, AppContext as _, Axis, Context, ElementId, Entity,
|
|
InteractiveElement as _, IntoElement, ParentElement as _, Pixels, 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;
|
|
use crate::window_controls::{TRAFFIC_LIGHT_PAD, WindowControls, titlebar_strip};
|
|
|
|
/// 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<dyn Fn(&mut Window, &mut App) -> AnyView>;
|
|
/// Runs when a panel with a right sidebar activates, so the app can open it.
|
|
type OpenRightSidebarHandler = Rc<dyn Fn(&mut App)>;
|
|
|
|
/// A registered panel: its identity, chrome, and how its view is built.
|
|
pub struct PanelDescriptor {
|
|
id: SharedString,
|
|
title: SharedString,
|
|
icon: Option<IconRole>,
|
|
build: PanelBuilder,
|
|
right_sidebar: Option<PanelBuilder>,
|
|
}
|
|
|
|
impl PanelDescriptor {
|
|
/// Creates a panel descriptor that opens through `build`.
|
|
pub fn new<I, T, F>(id: I, title: T, build: F) -> Self
|
|
where
|
|
I: Into<SharedString>,
|
|
T: Into<SharedString>,
|
|
F: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
|
{
|
|
Self {
|
|
id: id.into(),
|
|
title: title.into(),
|
|
icon: None,
|
|
build: Box::new(build),
|
|
right_sidebar: None,
|
|
}
|
|
}
|
|
|
|
/// Shows an icon leading the tab label.
|
|
pub fn icon(mut self, role: IconRole) -> Self {
|
|
self.icon = Some(role);
|
|
self
|
|
}
|
|
|
|
/// Sets the view the right sidebar shows while this panel is active.
|
|
///
|
|
/// Activating the panel opens the right sidebar.
|
|
pub fn right_sidebar<F>(mut self, build: F) -> Self
|
|
where
|
|
F: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
|
{
|
|
self.right_sidebar = Some(Box::new(build));
|
|
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<Self>) -> 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<IconRole>,
|
|
view: AnyView,
|
|
right_view: Option<AnyView>,
|
|
}
|
|
|
|
/// A leaf: the tabs of one region and which is active.
|
|
struct Pane {
|
|
key: u64,
|
|
tabs: Vec<PanelTab>,
|
|
active: Option<SharedString>,
|
|
}
|
|
|
|
/// An internal node: two children along an axis.
|
|
struct Split {
|
|
key: u64,
|
|
axis: Axis,
|
|
ratio: f32,
|
|
a: Box<Node>,
|
|
b: Box<Node>,
|
|
}
|
|
|
|
enum Node {
|
|
Pane(Pane),
|
|
Split(Split),
|
|
}
|
|
|
|
/// The dock's pane tree, owned by the application.
|
|
pub struct DockState {
|
|
root: Node,
|
|
panels: HashMap<SharedString, PanelDescriptor>,
|
|
/// The pane new panels open into, kept on the last activated pane.
|
|
active_pane: u64,
|
|
next_key: u64,
|
|
/// The right sidebar view on display, retained from its panel.
|
|
right_view: Option<AnyView>,
|
|
/// Runs when a panel with a right sidebar activates, so the app can open it.
|
|
on_open_right_sidebar: Option<OpenRightSidebarHandler>,
|
|
}
|
|
|
|
impl DockState {
|
|
/// Creates a dock with one empty pane.
|
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
|
cx.new(|_| Self {
|
|
root: Node::Pane(Pane {
|
|
key: 0,
|
|
tabs: Vec::new(),
|
|
active: None,
|
|
}),
|
|
panels: HashMap::new(),
|
|
active_pane: 0,
|
|
next_key: 1,
|
|
right_view: None,
|
|
on_open_right_sidebar: None,
|
|
})
|
|
}
|
|
|
|
/// Runs when a panel with a right sidebar activates.
|
|
pub fn on_open_right_sidebar(&mut self, handler: impl Fn(&mut App) + 'static) {
|
|
self.on_open_right_sidebar = Some(Rc::new(handler));
|
|
}
|
|
|
|
/// Registers a panel descriptor, replacing any earlier one with its id.
|
|
pub fn register_panel(&mut self, panel: PanelDescriptor) {
|
|
self.panels.insert(panel.id.clone(), panel);
|
|
}
|
|
|
|
/// 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<Self>) {
|
|
if self.is_open(id) {
|
|
self.activate_panel(id, cx);
|
|
return;
|
|
}
|
|
|
|
let Some(panel) = self.panels.get(id) else {
|
|
return;
|
|
};
|
|
|
|
let right_view = panel
|
|
.right_sidebar
|
|
.as_ref()
|
|
.map(|build| (build)(window, cx));
|
|
|
|
let tab = PanelTab {
|
|
panel: SharedString::from(id),
|
|
title: panel.title.clone(),
|
|
icon: panel.icon,
|
|
view: (panel.build)(window, cx),
|
|
right_view,
|
|
};
|
|
|
|
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;
|
|
self.show_right_view_of(id, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
/// Points the right sidebar at the panel's own view and asks the app to open it.
|
|
fn show_right_view_of(&mut self, id: &str, cx: &mut Context<Self>) {
|
|
let right_view = tab_in(&self.root, id).and_then(|tab| tab.right_view.clone());
|
|
let Some(right_view) = right_view else {
|
|
return;
|
|
};
|
|
self.right_view = Some(right_view);
|
|
if let Some(handler) = self.on_open_right_sidebar.clone() {
|
|
(handler)(cx);
|
|
}
|
|
}
|
|
|
|
/// Activates a panel's tab and makes its pane the active pane.
|
|
pub fn activate_panel(&mut self, id: &str, cx: &mut Context<Self>) {
|
|
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 {
|
|
self.show_right_view_of(id, cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
/// Closes a panel's tab.
|
|
pub fn close_panel(&mut self, id: &str, cx: &mut Context<Self>) {
|
|
let Some((pane_key, index)) = self.locate(id) else {
|
|
return;
|
|
};
|
|
|
|
let (closed_right_view, emptied) = {
|
|
let Some(pane) = self.pane_mut(pane_key) else {
|
|
return;
|
|
};
|
|
|
|
let was_active = pane.active.as_deref() == Some(id);
|
|
let closed = 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());
|
|
}
|
|
|
|
(closed.right_view, pane.tabs.is_empty())
|
|
};
|
|
|
|
// The sidebar showed the closed panel's view.
|
|
if self.right_view.as_ref() == closed_right_view.as_ref() {
|
|
self.right_view = first_right_view(&self.root);
|
|
}
|
|
|
|
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<Self>) {
|
|
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 {
|
|
// The kept tab may have no right view, show the first remaining one.
|
|
self.right_view = self
|
|
.locate(id)
|
|
.and_then(|(pane_key, _)| self.pane(pane_key))
|
|
.and_then(|pane| {
|
|
pane.active.as_deref().and_then(|active| {
|
|
pane.tabs.iter().find(|tab| tab.panel.as_ref() == active)
|
|
})
|
|
})
|
|
.and_then(|tab| tab.right_view.clone())
|
|
.or_else(|| first_right_view(&self.root));
|
|
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<Self>) {
|
|
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);
|
|
}
|
|
|
|
// The closed pane's views are gone; show the first remaining one.
|
|
self.right_view = first_right_view(&self.root);
|
|
|
|
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>) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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<SharedString> {
|
|
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<u64> {
|
|
Self::first_pane_in(&self.root)
|
|
}
|
|
|
|
fn first_pane_in(node: &Node) -> Option<u64> {
|
|
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(&self, key: u64) -> Option<&Pane> {
|
|
Self::pane_in(&self.root, key)
|
|
}
|
|
|
|
fn pane_in(node: &Node, key: u64) -> Option<&Pane> {
|
|
match node {
|
|
Node::Pane(pane) => (pane.key == key).then_some(pane),
|
|
Node::Split(split) => {
|
|
Self::pane_in(&split.a, key).or_else(|| Self::pane_in(&split.b, key))
|
|
}
|
|
}
|
|
}
|
|
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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.
|
|
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)),
|
|
}
|
|
}
|
|
|
|
/// Finds an open tab by panel id, anywhere in the tree.
|
|
fn tab_in<'a>(node: &'a Node, id: &str) -> Option<&'a PanelTab> {
|
|
match node {
|
|
Node::Pane(pane) => pane.tabs.iter().find(|tab| tab.panel.as_ref() == id),
|
|
Node::Split(split) => tab_in(&split.a, id).or_else(|| tab_in(&split.b, id)),
|
|
}
|
|
}
|
|
|
|
/// The first open tab's right sidebar view, in display order.
|
|
fn first_right_view(node: &Node) -> Option<AnyView> {
|
|
match node {
|
|
Node::Pane(pane) => pane.tabs.iter().find_map(|tab| tab.right_view.clone()),
|
|
Node::Split(split) => first_right_view(&split.a).or_else(|| first_right_view(&split.b)),
|
|
}
|
|
}
|
|
|
|
/// The pane key of a node that is a pane, for seam-level tree edits.
|
|
fn direct_pane_key(node: &Node) -> Option<u64> {
|
|
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<IconRole>,
|
|
view: AnyView,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct PaneView {
|
|
key: u64,
|
|
tabs: Vec<TabView>,
|
|
active: Option<SharedString>,
|
|
}
|
|
|
|
struct SplitView {
|
|
key: u64,
|
|
axis: Axis,
|
|
ratio: f32,
|
|
a: Box<TreeView>,
|
|
b: Box<TreeView>,
|
|
}
|
|
|
|
enum TreeView {
|
|
Pane(PaneView),
|
|
Split(SplitView),
|
|
}
|
|
|
|
/// A dock: renders the pane tree of a [`DockState`].
|
|
#[derive(IntoElement)]
|
|
pub struct Dock {
|
|
id: ElementId,
|
|
state: Entity<DockState>,
|
|
/// The pill bar is the window's titlebar: trailing window controls.
|
|
window_controls: bool,
|
|
/// The pill bar leads the window: pad for the macOS traffic lights.
|
|
traffic_light_pad: bool,
|
|
}
|
|
|
|
impl Dock {
|
|
/// Creates a dock bound to `state`.
|
|
pub fn new<I>(id: I, state: Entity<DockState>) -> Self
|
|
where
|
|
I: Into<ElementId>,
|
|
{
|
|
Self {
|
|
id: id.into(),
|
|
state,
|
|
window_controls: false,
|
|
traffic_light_pad: false,
|
|
}
|
|
}
|
|
|
|
/// Renders the window controls in the pill bar's trailing end.
|
|
pub fn window_controls(mut self, window_controls: bool) -> Self {
|
|
self.window_controls = window_controls;
|
|
self
|
|
}
|
|
|
|
/// Pads the pill bar's leading edge for the macOS traffic lights.
|
|
pub fn traffic_light_pad(mut self, traffic_light_pad: bool) -> Self {
|
|
self.traffic_light_pad = traffic_light_pad;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for Dock {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let tree = self.state.read(cx).snapshot();
|
|
let titlebar = Titlebar {
|
|
window_controls: self.window_controls,
|
|
traffic_light_pad: self.traffic_light_pad,
|
|
height: cx.theme().titlebar_height,
|
|
};
|
|
let root = render_node(&self.id, &self.state, &tree, &titlebar, 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)
|
|
}
|
|
}
|
|
|
|
/// Which titlebar duties the pane's pill bar carries.
|
|
#[derive(Clone, Copy, Default)]
|
|
struct Titlebar {
|
|
window_controls: bool,
|
|
traffic_light_pad: bool,
|
|
height: Pixels,
|
|
}
|
|
|
|
/// The right sidebar content of the dock's active panel.
|
|
#[derive(IntoElement)]
|
|
pub struct DockSidebar {
|
|
id: ElementId,
|
|
state: Entity<DockState>,
|
|
}
|
|
|
|
impl DockSidebar {
|
|
/// Creates the sidebar content bound to `state`.
|
|
pub fn new<I>(id: I, state: Entity<DockState>) -> Self
|
|
where
|
|
I: Into<ElementId>,
|
|
{
|
|
Self {
|
|
id: id.into(),
|
|
state,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for DockSidebar {
|
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
// The cache keeps an un-notified sidebar from re-rendering on frames.
|
|
let right_view = self.state.read(cx).right_view.clone();
|
|
|
|
div()
|
|
.id(self.id.clone())
|
|
.debug_selector({
|
|
let id = self.id.clone();
|
|
move || format!("{id}-view")
|
|
})
|
|
.size_full()
|
|
.overflow_hidden()
|
|
.when_some(right_view, |sidebar, view| {
|
|
sidebar.child(view.cached(StyleRefinement::default().absolute().size_full()))
|
|
})
|
|
}
|
|
}
|
|
|
|
fn render_node(
|
|
dock_id: &ElementId,
|
|
state: &Entity<DockState>,
|
|
node: &TreeView,
|
|
titlebar: &Titlebar,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
match node {
|
|
TreeView::Pane(pane) => render_pane(dock_id, state, pane, titlebar, cx),
|
|
TreeView::Split(split) => render_split(dock_id, state, split, titlebar, window, cx),
|
|
}
|
|
}
|
|
|
|
fn render_split(
|
|
dock_id: &ElementId,
|
|
state: &Entity<DockState>,
|
|
split: &SplitView,
|
|
titlebar: &Titlebar,
|
|
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, titlebar, 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, titlebar, window, cx)),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_pane(
|
|
dock_id: &ElementId,
|
|
state: &Entity<DockState>,
|
|
pane: &PaneView,
|
|
titlebar: &Titlebar,
|
|
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_id = SharedString::from(format!("{dock_id}-content-{}", pane.key));
|
|
let content = div()
|
|
.id(content_id.clone())
|
|
.debug_selector(move || content_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.children(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 titlebar = *titlebar;
|
|
let padding_x = metrics.dense_padding_x;
|
|
|
|
move |window, cx| {
|
|
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.panel.clone(), 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<SharedString> =
|
|
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<SharedString> =
|
|
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));
|
|
}
|
|
}
|
|
});
|
|
|
|
let bar_id = SharedString::from(format!("{dock_id}-pane-{}-bar", pane.key));
|
|
|
|
h_flex()
|
|
.id(bar_id.clone())
|
|
.debug_selector(move || bar_id.to_string())
|
|
.flex_none()
|
|
.w_full()
|
|
.h(titlebar.height)
|
|
.items_center()
|
|
.pl(if titlebar.traffic_light_pad {
|
|
TRAFFIC_LIGHT_PAD
|
|
} else {
|
|
padding_x
|
|
})
|
|
.pr(padding_x)
|
|
.child(tabs)
|
|
.child(titlebar_strip(
|
|
div()
|
|
.id(SharedString::from(format!(
|
|
"{dock_id}-pane-{}-strip",
|
|
pane.key
|
|
)))
|
|
.flex_1()
|
|
.self_stretch(),
|
|
window,
|
|
cx,
|
|
))
|
|
.when(titlebar.window_controls, |bar| {
|
|
bar.child(WindowControls::new(SharedString::from(format!(
|
|
"{dock_id}-pane-{}-controls",
|
|
pane.key
|
|
))))
|
|
})
|
|
.into_any_element()
|
|
}
|
|
})
|
|
.items({
|
|
let state = state.clone();
|
|
let active = pane.active.clone();
|
|
let panels: Vec<SharedString> = 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<DockState>, pane: &PaneView) -> Vec<AnyElement> {
|
|
let Some(anchor) = pane
|
|
.active
|
|
.clone()
|
|
.or_else(|| pane.tabs.first().map(|tab| tab.panel.clone()))
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
|
|
vec![
|
|
drop_zone(state, &anchor, Some(SplitDirection::Left))
|
|
.absolute()
|
|
.top_0()
|
|
.bottom_0()
|
|
.left_0()
|
|
.w(relative(0.25))
|
|
.into_any_element(),
|
|
drop_zone(state, &anchor, Some(SplitDirection::Right))
|
|
.absolute()
|
|
.top_0()
|
|
.bottom_0()
|
|
.right_0()
|
|
.w(relative(0.25))
|
|
.into_any_element(),
|
|
drop_zone(state, &anchor, Some(SplitDirection::Top))
|
|
.absolute()
|
|
.top_0()
|
|
.left(relative(0.25))
|
|
.right(relative(0.25))
|
|
.h(relative(0.25))
|
|
.into_any_element(),
|
|
drop_zone(state, &anchor, Some(SplitDirection::Bottom))
|
|
.absolute()
|
|
.bottom_0()
|
|
.left(relative(0.25))
|
|
.right(relative(0.25))
|
|
.h(relative(0.25))
|
|
.into_any_element(),
|
|
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<DockState>,
|
|
anchor: &str,
|
|
direction: Option<SplitDirection>,
|
|
) -> gpui::Div {
|
|
let state = state.clone();
|
|
let anchor = SharedString::from(anchor);
|
|
div()
|
|
.drag_over::<PanelDrag>(|zone, _, _, cx| zone.bg(cx.theme().colors.selection))
|
|
.on_drop::<PanelDrag>(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),
|
|
});
|
|
})
|
|
}
|