Files
coop/crates/ui/src/dock/mod.rs
T

806 lines
26 KiB
Rust

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<SharedString>,
window: &mut Window,
cx: &mut App,
) -> Entity<DockArea> {
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<DockArea>,
) {
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<DockArea>,
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<NodeId> {
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<NodeId> {
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<Option<WeakEntity<DockArea>>>,
resizing: Cell<Option<DockPlacement>>,
}
impl SkinShared {
fn area(&self) -> Option<Entity<DockArea>> {
self.area.borrow().as_ref().and_then(|area| area.upgrade())
}
}
struct DockSkin {
shared: Rc<SkinShared>,
}
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<AnyElement> {
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<dyn TabGroupRenderer> {
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<Self>) -> impl IntoElement {
Empty
}
}
/// Turns the window's mouse stream into dock resizing.
struct DockResizeTracker {
dock: DockContext,
shared: Rc<SkinShared>,
}
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<gpui::ElementId> {
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<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Window,
_: &mut App,
) {
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &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<dyn gpui_base::dock::PanelView>,
}
impl Render for DragPreview {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<SkinShared>,
scroll_handle: ScrollHandle,
last_active_ix: Cell<Option<usize>>,
}
impl TabGroupSkin {
fn new(shared: Rc<SkinShared>) -> 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::<DragPanel>(|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::<DragPanel>(|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<Button> {
if group.is_zoomed() {
return None;
}
let area = self.shared.area()?;
let is_open = {
let area = area.read(cx);
if !area.is_dock_collapsible(placement) {
return None;
}
let designated = match placement {
DockPlacement::Left => area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Right => area
.layout(DockPlacement::Center)
.and_then(|tree| right_top_group(tree.root())),
DockPlacement::Bottom => area
.layout(DockPlacement::Bottom)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Center => None,
};
if designated != Some(group.node()) {
return None;
}
area.is_dock_open(placement)
};
let icon = match (placement, is_open) {
(DockPlacement::Left, true) => IconName::PanelLeft,
(DockPlacement::Left, false) => IconName::PanelLeftOpen,
(DockPlacement::Right, true) => IconName::PanelRight,
(DockPlacement::Right, false) => IconName::PanelRightOpen,
(DockPlacement::Bottom, true) => IconName::PanelBottom,
(DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
(DockPlacement::Center, _) => return None,
};
Some(
Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
.icon(icon)
.small()
.ghost()
.tab_stop(false)
.map(|this| {
if is_open {
this.tooltip("Collapse")
} else {
this.tooltip("Expand")
}
})
.on_click(move |_, window, cx| {
area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
}),
)
}
}
impl TabGroupRenderer for TabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let wallpaper = group
.active_panel()
.and_then(|panel| panel_background(panel, cx));
div()
.id("tab-panel")
.relative()
.when_some(wallpaper, |this, source| this.child(background(source, cx)))
.when(!group.is_collapsed(), |this| {
this.on_action({
let group = TabGroupContext::clone(group);
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
})
.on_action({
let group = TabGroupContext::clone(group);
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
}
})
})
}
fn render_tab_bar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// The plain left group draws bare so its content can own the window's top-left corner.
if self.is_plain_left_group(group, cx) {
return Empty.into_any_element();
}
let visible: Vec<usize> = group
.panels()
.iter()
.enumerate()
.filter(|(_, panel)| panel.visible(cx))
.map(|(ix, _)| ix)
.collect();
let active_ix = group.active_ix();
if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix)
&& let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix)
{
self.scroll_handle.scroll_to_item(visible_ix);
}
if visible.is_empty() {
return Empty.into_any_element();
}
self.render_tabs(group, visible.as_slice(), window, cx)
}
fn render_active_panel(
&self,
panel: AnyView,
group: &TabGroupContext,
_: &mut Window,
cx: &mut App,
) -> AnyElement {
if group.is_collapsed() {
return Empty.into_any_element();
}
let wallpaper = group
.active_panel()
.and_then(|active| panel_background(active, cx))
.is_some();
v_flex()
.id("tab-content")
.group("")
.overflow_hidden()
.flex_1()
.child(
div()
.size_full()
.when(!wallpaper, |this| this.bg(cx.theme().bg))
.overflow_hidden()
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
)
.into_any_element()
}
fn render_drop_indicator(
&self,
indicator: DropIndicator,
_: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let size = indicator.bounds().size;
let fraction = 0.35;
let (left, top, width, height) = match indicator.placement() {
Some(Placement::Left) => (px(0.), px(0.), size.width * fraction, size.height),
Some(Placement::Right) => (
size.width * (1. - fraction),
px(0.),
size.width * fraction,
size.height,
),
Some(Placement::Top) => (px(0.), px(0.), size.width, size.height * fraction),
Some(Placement::Bottom) => (
px(0.),
size.height * (1. - fraction),
size.width,
size.height * fraction,
),
None => (px(0.), px(0.), size.width, size.height),
};
Some(
div()
.absolute()
.left(left)
.top(top)
.w(width)
.h(height)
.rounded(px(panel_radius()))
.border_1()
.border_color(cx.theme().drop_line)
.bg(cx.theme().drop_target)
.into_any_element(),
)
}
}
fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
group
.is_draggable()
.then(|| group.drag_panel(ix, cx))
.flatten()
}
fn panel_title(panel: &Arc<dyn gpui_base::dock::PanelView>, cx: &App) -> AnyElement {
match PanelHandle::of(panel) {
Some(handle) => handle.panel().title(cx),
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
}
}
fn panel_background(panel: &Arc<dyn gpui_base::dock::PanelView>, cx: &App) -> Option<ImageSource> {
PanelHandle::of(panel).and_then(|handle| handle.panel().background(cx))
}