Dock
Dock 是 gpui-base 的无样式停靠布局基础。它把可持久化布局模型、面板生命周期、拖放与查询行为放在 Base,把标签、边框、图标、空状态和其他产品视觉交给应用提供的 renderer。
模型
布局是一棵由分栏、标签组、面板、边缘 dock 和可选 tiles 画布组成的树。稳定面板 ID 是移动、恢复、查询和事件关联的基础;不要用当前位置作为身份。
关键类型
DockArea 承载工作区和交互状态;布局节点描述结构;面板 trait/工厂负责由持久数据创建内容;renderer trait 把模型映射为应用外观。
开始使用
先创建持久 DockArea entity,注册面板工厂和 renderer,再安装初始布局。渲染期间只读取模型;添加、移动、关闭、缩放和锁定操作放在回调中执行并通知 GPUI。
描述布局
用嵌套节点声明水平/垂直分栏、标签组和面板。分栏尺寸属于相邻槽位的约束数据,不属于面板视觉样式。载入外部或旧版本布局后先规范化:移除空节点、折叠无意义嵌套并修正非法比例,同时保留稳定 ID。
面板与生命周期
面板提供身份、标题/元数据、渲染内容以及可选的激活、停用、关闭、保存和恢复 hook。hook 可能发生在拖放、布局替换、窗口关闭或恢复期间,必须保持幂等,不能依赖一次 render 创建的临时状态。资源与订阅跟随持久 entity 生命周期。
DockArea 操作
可以整体安装布局,也可以向指定标签组添加面板、在节点间移动面板、打开或关闭边缘 dock、缩放当前面板、锁定结构,以及按 ID 查询面板或节点。修改前验证目标仍存在,因为事件发生后布局可能已经变化。
直接编辑布局树
直接编辑适合批量迁移和恢复,不适合普通交互。编辑后必须规范化并通过 DockArea 安装,使索引、焦点、事件和渲染状态同步更新;不要绕过宿主只修改一份外部副本。
提供外观
DockAreaRenderer 负责工作区边框、边缘 dock 和空状态;TabGroupRenderer 负责标签、活动态、关闭入口和标签拖动反馈;TilesRenderer 负责画布节点与连接。renderer 接收只读上下文和明确回调,不应拥有领域状态,也不应在 render 中修改布局。
拖放与 Tiles 画布
拖动数据使用稳定面板身份。命中测试决定插入标签、分割方向或边缘 dock,提交前再次验证源与目标。Tiles 用于自由布局场景,位置与连接是模型数据;视觉、网格和选中反馈仍由 renderer 提供。
事件与持久化
订阅布局、活动面板、面板关闭和拖放事件来同步应用状态。持久化稳定 ID、节点种类、分栏比例、标签顺序、活动项、边缘 dock 与面板自有数据,不要序列化 GPUI entity、焦点句柄或 renderer。恢复时容忍未知面板类型,并对版本化数据做迁移和规范化。
架构取舍
Dock 的数据模型比单一分栏组件更重,但换来可查询、可移动、可持久化和可替换表现。Base 使用中性的 tree/panel/tab 命名,产品可以在 renderer 和面板工厂层映射成自己的术语。
可运行示例
cargo run -p gpui-base --example dockuse super::*;
use gpui::{
AnyElement, Axis, Div, Entity, EventEmitter, FocusHandle, Focusable, MouseButton,
MouseMoveEvent, MouseUpEvent, SharedString, Stateful, rgba,
};
use std::cell::RefCell;
const SURFACE: u32 = 0xffffff;
const CHROME: u32 = 0xf4f4f5;
const BORDER: u32 = 0xd4d4d8;
const MUTED: u32 = 0x71717a;
const ACCENT: u32 = 0x2563eb;
const DROP_TARGET: u32 = 0x2563eb33;
const TAB_BAR_HEIGHT: gpui::Pixels = px(26.);
const RESIZE_STRIP: gpui::Pixels = px(4.);
/// One dockable view. Its only obligation to base is a stable name; the title
/// and body are this example's own, and reach the skin through a downcast of
/// the handle base was given.
struct ShowcasePanel {
name: &'static str,
title: SharedString,
body: SharedString,
focus_handle: FocusHandle,
}
impl ShowcasePanel {
fn new(
name: &'static str,
title: impl Into<SharedString>,
body: impl Into<SharedString>,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx| Self {
name,
title: title.into(),
body: body.into(),
focus_handle: cx.focus_handle(),
})
}
}
impl Panel for ShowcasePanel {
fn panel_name(&self) -> &'static str {
self.name
}
}
impl EventEmitter<PanelEvent> for ShowcasePanel {}
impl Focusable for ShowcasePanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ShowcasePanel {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.flex_col()
.gap_1()
.p_3()
.text_xs()
.child(div().child(self.title.clone()))
.child(
div()
.text_color(super::example_rgb(MUTED))
.child(self.body.clone()),
)
}
}
/// The preview that follows the cursor while a tab is dragged.
///
/// Base's own `DragPanel` renders nothing, because a preview is appearance.
struct DragPreview {
title: SharedString,
}
impl Render for DragPreview {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.px_2()
.py_1()
.text_xs()
.bg(super::example_rgb(SURFACE))
.text_color(super::example_rgb(ACCENT))
.border_1()
.border_color(super::example_rgb(ACCENT))
.child(self.title.clone())
}
}
/// A panel's title, recovered across the renderer seam.
///
/// Base carries every panel as `Arc<dyn PanelView>`, which knows its
/// `panel_name` and nothing else — a title is presentation, and base has no
/// opinion about it. A skin gets one back by downcasting to the concrete
/// handle base was handed.
fn panel_title(panel: &Arc<dyn PanelView>, cx: &App) -> SharedString {
panel
.as_any()
.downcast_ref::<Entity<ShowcasePanel>>()
.map(|panel| panel.read(cx).title.clone())
.unwrap_or_else(|| panel.panel_name(cx).into())
}
/// Everything this example draws. Base draws none of it.
#[derive(Clone, Default)]
struct ShowcaseDockSkin {
/// The dock a resize drag is currently sizing, captured on mouse down.
///
/// A resize follows the pointer anywhere in the area, not only over the
/// strip, so the listener that tracks it sits on the area frame — which is
/// not handed a `DockContext`. The strip stashes its own here instead.
resizing: Rc<RefCell<Option<DockContext>>>,
}
impl ShowcaseDockSkin {
/// The strip on a dock's inner edge that resizes it: a wide hit area with
/// a hairline inside, so the edge reads as a line rather than a bar.
fn render_resize_strip(&self, dock: &DockContext) -> impl IntoElement {
let placement = dock.placement();
let dock = dock.clone();
let resizing = self.resizing.clone();
div()
.absolute()
.flex()
.items_center()
.justify_center()
.map(|this| match placement {
DockPlacement::Left => this
.top_0()
.right_0()
.h_full()
.w(RESIZE_STRIP)
.cursor_col_resize(),
DockPlacement::Bottom => this
.top_0()
.left_0()
.w_full()
.h(RESIZE_STRIP)
.cursor_row_resize(),
_ => this
.top_0()
.left_0()
.h_full()
.w(RESIZE_STRIP)
.cursor_col_resize(),
})
.child(
div()
.bg(super::example_rgb(BORDER))
.map(|line| match placement {
DockPlacement::Bottom => line.h(px(1.)).w_full(),
_ => line.w(px(1.)).h_full(),
}),
)
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
cx.stop_propagation();
*resizing.borrow_mut() = Some(dock.clone());
})
}
}
impl DockAreaRenderer for ShowcaseDockSkin {
fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
let dragging = self.resizing.clone();
let finished = self.resizing.clone();
div()
.id("showcase-dock")
.size_full()
.flex()
.flex_row()
.overflow_hidden()
.bg(super::example_rgb(CHROME))
.on_mouse_move(move |event: &MouseMoveEvent, window, cx| {
// Cloned out before the call, so the borrow is released before
// resizing reaches back into another frame reading this cell.
let dock = dragging.borrow().clone();
let Some(dock) = dock else {
return;
};
dock.resize_to(event.position, window, cx);
})
.on_mouse_up(MouseButton::Left, move |_: &MouseUpEvent, _, _| {
finished.borrow_mut().take();
})
}
fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
div()
.id("showcase-dock-center")
.flex()
.flex_1()
.flex_col()
.overflow_hidden()
}
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, _: &mut App) -> Stateful<Div> {
div()
.id(("showcase-dock-split", node.as_u64()))
.size_full()
.flex_1()
.min_h(px(0.))
.overflow_hidden()
}
/// Only the paint: base keeps the hit area, the cursor and the drag.
fn render_split_handle(
&self,
handle: &ResizeHandleContext,
_: &mut Window,
_: &mut App,
) -> Option<AnyElement> {
Some(
div()
.bg(super::example_rgb(if handle.is_active() {
ACCENT
} else {
BORDER
}))
.map(|line| match handle.axis() {
Axis::Horizontal => line.w(px(1.)).h_full(),
Axis::Vertical => line.h(px(1.)).w_full(),
})
.into_any_element(),
)
}
fn render_dock(
&self,
dock: &DockContext,
content: AnyElement,
_: &mut Window,
_: &mut App,
) -> AnyElement {
// A closed dock takes no space; the toolbar is what brings it back.
if !dock.is_open() {
return div().into_any_element();
}
div()
.flex()
.flex_none()
.relative()
.overflow_hidden()
.map(|this| match dock.placement() {
DockPlacement::Bottom => this.w_full().h(dock.size()).flex_col(),
_ => this.h_full().w(dock.size()).flex_row(),
})
.child(content)
.child(self.render_resize_strip(dock))
.into_any_element()
}
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
Rc::new(self.clone())
}
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
Rc::new(self.clone())
}
}
impl TabGroupRenderer for ShowcaseDockSkin {
fn frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
div()
.id("showcase-tab-group")
.size_full()
.flex()
.flex_col()
.min_h(px(0.))
.overflow_hidden()
.bg(super::example_rgb(SURFACE))
}
fn content_frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
// Relative, because the drop indicator is positioned against it.
div()
.id("showcase-tab-content")
.relative()
.flex_1()
.min_h(px(0.))
.overflow_hidden()
}
fn render_tab_bar(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> AnyElement {
div()
.flex()
.flex_row()
.items_center()
.h(TAB_BAR_HEIGHT)
.flex_none()
.overflow_hidden()
.bg(super::example_rgb(CHROME))
.border_b_1()
.border_color(super::example_rgb(BORDER))
.children(
group
.panels()
.iter()
.enumerate()
// A hidden panel keeps its place in the tree and its tab
// slot; it is the skin that leaves it undrawn.
.filter(|(_, panel)| panel.visible(cx))
.map(|(ix, panel)| {
let selected = ix == group.active_ix();
let title = panel_title(panel, cx);
div()
.id(("showcase-tab", ix))
.px_2()
.h_full()
.flex()
.items_center()
.text_xs()
.cursor_pointer()
.map(|this| match selected {
true => this
.bg(super::example_rgb(SURFACE))
.text_color(super::example_rgb(ACCENT)),
false => this.text_color(super::example_rgb(MUTED)),
})
.child(title.clone())
.on_click({
let group = group.clone();
move |_, window, cx| group.select_tab(ix, window, cx)
})
.when_some(group.drag_panel(ix, cx), |this, drag| {
this.on_drag(drag, move |_, _, _, cx| {
cx.new(|_| DragPreview {
title: title.clone(),
})
})
})
})
.collect::<Vec<_>>(),
)
.into_any_element()
}
/// Base resolves where a drop would land; painting it is all that is left.
fn render_drop_indicator(
&self,
indicator: DropIndicator,
_: &mut Window,
_: &mut App,
) -> Option<AnyElement> {
let to = indicator.to();
Some(
div()
.absolute()
.left(to.origin().x)
.top(to.origin().y)
.w(to.size().width)
.h(to.size().height)
.bg(rgba(DROP_TARGET))
.into_any_element(),
)
}
}
/// This example builds no tiles canvas, so none of this is reached. A
/// `DockAreaRenderer` must still name a tiles renderer, because base builds one
/// for any `Tiles` node a layout — or a persisted file — happens to hold.
impl TilesRenderer for ShowcaseDockSkin {
fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement {
div().into_any_element()
}
}
/// Build the area once, at showcase construction: a `DockArea` is an entity,
/// and rebuilding it every frame would discard the layout the viewer arranged.
pub(in super::super) fn build_dock(window: &mut Window, cx: &mut App) -> Entity<DockArea> {
let explorer = ShowcasePanel::new(
"Explorer",
"Explorer",
"Drag this tab into the other group to move it there.",
cx,
);
let search = ShowcasePanel::new(
"Search",
"Search",
"Two panels share this tab group. Click a tab to switch.",
cx,
);
let editor = ShowcasePanel::new(
"Editor",
"Editor",
"Drag a tab towards an edge of this group to split there.",
cx,
);
let terminal = ShowcasePanel::new(
"Terminal",
"Terminal",
"The bottom dock shares the column with the center region.",
cx,
);
let problems = ShowcasePanel::new("Problems", "Problems", "Nothing here.", cx);
let area = cx.new(|cx| {
DockArea::new("showcase-dock", Some(1), window, cx)
.with_renderer(Rc::new(ShowcaseDockSkin::default()))
});
area.update(cx, |area, cx| {
area.set_center(
DockLayout::h_split()
.child(
DockLayout::tabs().panel(explorer).panel(search),
Some(px(200.)),
)
.child(DockLayout::tabs().panel(editor), None),
window,
cx,
);
area.set_dock(
DockPlacement::Bottom,
DockLayout::tabs().panel(terminal).panel(problems),
window,
cx,
);
area.set_dock_size(DockPlacement::Bottom, px(140.), window, cx);
});
area
}
impl BaseShowcase {
/// A toggle for one dock, so a closed dock can be brought back.
fn dock_toggle(
&self,
placement: DockPlacement,
label: &'static str,
cx: &Context<Self>,
) -> impl IntoElement {
let open = self.dock.read(cx).is_dock_open(placement);
let area = self.dock.clone();
div()
.id(label)
.px_2()
.py_1()
.text_xs()
.cursor_pointer()
.border_1()
.border_color(super::example_rgb(BORDER))
.map(|this| match open {
true => this
.bg(super::example_rgb(SURFACE))
.text_color(super::example_rgb(ACCENT)),
false => this.text_color(super::example_rgb(MUTED)),
})
.child(label)
.on_click(move |_, window, cx| {
area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
})
}
pub(in super::super) fn dock(&self, cx: &Context<Self>) -> impl IntoElement {
// Fills whatever the showcase gives it — the surrounding container
// opts this example out of the centered, intrinsically-sized box the
// smaller parts use, so a percentage size resolves here.
div()
.size_full()
.flex()
.flex_col()
.overflow_hidden()
.border_1()
.border_color(super::example_rgb(BORDER))
.child(
div()
.flex()
.flex_none()
.items_center()
.gap_2()
.p_2()
.bg(super::example_rgb(CHROME))
.border_b_1()
.border_color(super::example_rgb(BORDER))
.child(self.dock_toggle(DockPlacement::Bottom, "Bottom", cx))
.child(div().text_xs().text_color(super::example_rgb(MUTED)).child(
"Drag a tab onto another group to merge it, or towards an edge to split",
)),
)
.child(div().flex_1().min_h(px(0.)).child(self.dock.clone()))
}
}集成检查清单
- 面板、标签组和需要持久化的节点使用稳定 ID。
- 领域状态保存在面板或应用 entity,renderer 只负责表现。
- 布局修改只发生在事件回调,并在安装/恢复后规范化。
- 处理未知面板、空布局、关闭否决、拖放目标失效和版本迁移。
- 验证键盘焦点、标签顺序、锁定、缩放、边缘 dock、减少动态效果和高对比度。