Expand description
Dockable layout: splits, tab groups, and tiles canvases that a host can rearrange, persist, and restore — with no appearance of its own.
§The tree is the source of truth
Each region — the center, plus an optional left, right, and bottom dock —
is one PaneTree. A tree is pure data. Containers are addressed by
NodeId, panels by PanelId, and no GPUI entity handle is stored
anywhere in it, which is what lets the layout algebra be exercised without
an App and lets a whole layout be compared, normalized, and serialized as
an ordinary value. A container is a Split, a Tabs, or a Tiles; there
is no leaf variant, so a panel can only ever live inside a Tabs or a
Tiles.
Both ids are stable, and the rest of the design leans on it:
- A
NodeIdsurvives every edit and every normalization rule. A container still present after a drag carries the same id it had before, which is what keeps a reconcile from tearing down entities the drag never touched. - A
PanelIdis the panel entity’sEntityId, so it identifies the panel for as long as the entity lives, across any number of moves between groups and regions.
Every mutation goes through the tree — PaneTree::insert_panel,
remove_panel,
move_panel, split,
set_active, and the rest — and each normalizes
before returning, so the tree is self-consistent the instant an edit
returns. Every edit reports what it did as an EditResult.
§The area reconciles the tree into entities
DockArea owns the trees and a cache of container entities keyed by
NodeId (TabGroup for a Tabs node, TilesState for a Tiles
one), plus the panel handles keyed by PanelId. After any edit that
reports a change it walks the tree, creates entities for ids the cache does
not have, drops entries for ids that are gone — telling those panels
Panel::on_removed — pushes sizes and active indices into the survivors,
and emits DockEvent::LayoutChanged. Nothing else turns a tree edit into
live entities.
Because node ids are stable, a steady-state pass creates and drops nothing.
A layout is described the same entity-free way: DockLayout builds a
tree, and DockArea::set_center / DockArea::set_dock install it.
DockArea::dump and DockArea::load round-trip a whole area through
DockAreaState, rebuilding panels through the PanelRegistry.
§The renderer seam
Base supplies behavior; the host supplies appearance. Nothing in this module paints a color, a border, or a size. Three traits carry the appearance in:
DockAreaRenderer— the area frame, each split’s frame and the divider between its slots, one dock’s chrome, and the stand-in for a panel this build cannot construct.TabGroupRenderer— the tab bar, how the displayed panel is placed, and the drop indicator.TilesRenderer— a tiles canvas, its tile frames, and their drag bars.
A renderer never sees a drag event or a mouse position. Base attaches the
drag sources, drop hit-testing, focus, and keyboard handling to the very
elements the renderer returns, and hands it resolved state through
DockContext, TabGroupContext, and TileContext — each of which
also carries the callbacks (toggle, select_tab, close, resize_to)
that the renderer invokes rather than reimplementing.
An area built without a renderer still docks, drags, resizes and persists. It simply draws nothing but the panels themselves.
Panel splits at the same seam: this trait covers behavior, and a
presentation layer — gpui_component::dock::Panel — extends it with
titles, toolbars, and menus. A panel type implements both.
Every hook is optional in the same way: a renderer that declines one gets
base’s own minimum for it. DockAreaRenderer::render_split_handle is the
clearest case — return None and the divider falls back to a one-pixel
line colored from Theme::resizable, so a skin with no opinion about
dividers implements nothing, while one that has an opinion replaces the
paint without touching the hit area, the cursor, or the drag.
§Why the layout is data
The usual way to build a dock is to make each container a live view that holds its children, so the widget tree is the layout. That is what this module replaced, and the three costs it carries are the reason:
- Emptiness has to propagate. When the last panel leaves a tab group, the group must remove itself from its parent, which may empty the parent in turn. With containers as views this is mutual recursion between two types, reaching upward through parent handles — and those handles have to be installed after construction, which in GPUI means a deferred pass. There is a window in which the tree disagrees with itself.
- Structure and identity are the same thing. Rearranging the widget tree means creating and dropping views, so a drag can reset the state of containers it never touched.
- Nothing is testable without a window. Asserting that a split collapses
correctly requires an
App, an entity, and a frame.
Here the tree is a value and the entities are its projection. Collapse is
PaneTree::normalize: one post-order pass repeated to a fixpoint, no
parent pointers, no deferred work, and idempotent by construction. Identity
is a NodeId that survives every edit and every normalization rule, so
reconciliation is a diff rather than a rebuild. And the whole layout algebra
runs as plain #[test].
What this buys, stated as properties rather than adjectives: a layout can be
compared, cloned and serialized as an ordinary value; a steady-state
reconcile creates and drops nothing, so a drag leaves untouched panels
untouched; and normalize(normalize(t)) == normalize(t) holds for every
tree, which is what makes the persisted format canonical.
§Where this sits among docking libraries
Editors tend to build the layout engine into the application — Zed’s
PaneGroup and VS Code’s workbench are not reusable outside their hosts.
Standalone libraries split the engine from the view to varying degrees:
golden-layout owns its DOM outright, FlexLayout keeps a JSON model
beside a React renderer, and dockview goes furthest, running a
framework-agnostic engine behind thin adapters. All of them still paint
their own chrome and expose CSS as the way to change it.
This module takes the same separation one step further: the engine paints
nothing at all. A renderer returns elements and base attaches the drag
sources, drop hit-testing, focus and keyboard handling to the elements it
got back, so appearance is not a set of overrides on top of a default look —
there is no default look. crates/component/src/dock and
crates/base/examples/showcase/components/dock.rs are two unrelated
appearances over one behavior.
The naming follows the same neighborhood where it can. A tab group here is
what Zed calls a Pane and dockview calls a group; a Dock is Zed’s
dock; a Panel is the dockable content, as in dockview — note that
VS Code uses “panel” for the bottom region instead, and rc-dock uses it
for the tab container.
§A minimal area
use std::rc::Rc;
use gpui::{px, Context, Window};
use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
let area = cx.new(|cx| {
DockArea::new("workspace", Some(1), window, cx).with_renderer(Rc::new(MySkin))
});
area.update(cx, |area, cx| {
area.set_center(
DockLayout::h_split()
.child(DockLayout::tabs().panel(files.clone()), Some(px(240.)))
.child(DockLayout::tabs().panel(editor.clone()), None),
window,
cx,
);
area.set_dock(
DockPlacement::Bottom,
DockLayout::tabs().panel(terminal.clone()),
window,
cx,
);
});crates/base/examples/showcase/components/dock.rs is that program in full,
renderers included — run it with cargo run -p gpui-base dock.
crates/component/src/dock is the production skin over the same seam.
Re-exports§
pub use layout::DockLayout;pub use layout::EditResult;pub use layout::InsertTarget;pub use layout::NodeId;pub use layout::PaneNode;pub use layout::PaneRef;pub use layout::PaneTree;pub use layout::PanelId;pub use layout::RootKind;pub use layout::TilePanel;
Modules§
Structs§
- AnyDrag
- A host-owned value being dragged over the dock, opaque to the dock itself.
- Dock
- Runtime state for one dock: whether it is open, collapsible, its current size, and whether it is mid-resize.
- Dock
Area - The main area of the dock.
- Dock
Area State - Used to serialize and deserialize the DockArea.
- Dock
Context - What a skin needs to draw one dock, and the callbacks it invokes rather than reimplementing the open/close and clamping behavior.
- Dock
Sizing - Pure arithmetic for resizing one dock. The caller supplies the area
bounds and the opposite dock’s size; base does not reach across entities
to find them — the original
Dock::resizeread sibling dock sizes straight off the (application-owned)DockAreaentity, which base has no way to do. - Dock
State - Used to serialize and deserialize the Dock.
- Drag
Panel - A panel being dragged out of a tab group.
- Drop
Indicator - What the skin should draw while a drag hovers a group.
- Drop
Placeholder Bounds - The bounds a drop placeholder should occupy within a tab group, given where the drop would land.
- Panel
Build Context - Everything a panel builder needs to reconstruct a panel from persisted data.
- Panel
Registry - Global registry of panel builders, keyed by panel name, used to reconstruct
a panel view from persisted
PanelState/PanelInfodata. - Panel
State - Used to serialize and deserialize the DockerItem.
- TabGroup
- A tab group’s behavior, with no appearance of its own.
- TabGroup
Constraints - Everything the container knows about a group’s place in the dock.
- TabGroup
Context - What a skin needs to draw a tab group, and the callbacks it invokes rather than reimplementing behavior.
- Tile
Context - What a skin needs to draw one tile, and the callbacks it invokes rather than reimplementing the snapping and resize arithmetic.
- Tile
Meta - Tiles
State - A tiles canvas’s behavior, with no appearance of its own.
Enums§
- Dock
Event - What the dock area reports outward.
- Dock
Placement - Placement of a
Dockrelative to the center area. - Drop
Target - Where a host-owned drag landed.
- Panel
Event - Panel
Info - Resize
Side - What a skin actually needs off the tiles geometry: the two sizes it has to draw to, and which edge a resize is pulling. Which edge (or corner) of a tile a resize drag is manipulating.
- TabGroup
Event - Behavior a tab group cannot carry out on its own.
- Tiles
Event - What a tiles canvas cannot carry out on its own.
Constants§
- DRAG_
BAR_ HEIGHT - What a skin actually needs off the tiles geometry: the two sizes it has to
draw to, and which edge a resize is pulling.
Height of the tile’s drag bar. This is hit-target geometry the skin must
agree with when it paints the drag bar, not a visual constant, so it
lives here rather than in
crates/component. - HANDLE_
SIZE - What a skin actually needs off the tiles geometry: the two sizes it has to
draw to, and which edge a resize is pulling.
Size of the resize-handle hit target at a tile’s corner/edge. Same
reasoning as
DRAG_BAR_HEIGHT.
Traits§
- Dock
Area Renderer - Appearance for the dock area. Base draws none of it.
- Panel
- Behavior a dockable panel provides. Presentation lives in the layer above:
gpui_component::dock::Panelextends this with titles, toolbars, and menus. - Panel
Builder - Both halves of the persistence seam.
PaneTree::to_statereads panel properties throughPanelSource;PaneTree::from_stateturns persisted leaves back into panels throughPanelBuilder. Exporting only the first leftfrom_statepublic but uncallable, since no caller outside this crate could name the trait its parameter requires. Turns a persisted leaf into a live panel id. - Panel
Source - Both halves of the persistence seam.
PaneTree::to_statereads panel properties throughPanelSource;PaneTree::from_stateturns persisted leaves back into panels throughPanelBuilder. Exporting only the first leftfrom_statepublic but uncallable, since no caller outside this crate could name the trait its parameter requires. How the layout tree reads properties of panels it only knows by id. - Panel
View - Object-safe counterpart of
Panel, used to hold heterogeneous panel entities behind a single handle. - TabGroup
Renderer - Appearance for a tab group. Base draws none of it.
- Tiles
Renderer - Appearance for a tiles canvas. Base draws none of it.
Functions§
- register_
panel - Register the Panel init by panel_name to global registry.