Dock

Dock builds application workspaces from draggable tab groups, nested splits, and collapsible left, right, and bottom docks. It is the layout foundation used by Longbridge in production, not an isolated UI demo.

Install

gpui-base owns the data model, layout calculation, and drag-and-drop behavior. gpui-component supplies the polished controls and visual language. Use gpui_kit::component::dock when you want a Dock ready to fit into a real application.

For the renderer-independent architecture and custom-renderer API, see Dock — gpui-base.

#Create a dock area

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
/// The workspace: the panels read into a [`Dock`], in the box the page gives it.
/// Every change comes back as a message, and `update` writes it into this demo's
/// own state.
fn workspace(app: &App) -> Element<'_, Message> {
    // The panels, which one is active in each region, which edge docks are
    // collapsed, and each region's size. Every change comes back as a message
    // and this demo writes it into its own state.
    let dock = Dock::new(app.panels.iter().map(panel))
        .active(Region::Left, app.active[Region::Left.index()])
        .active(Region::Center, app.active[Region::Center.index()])
        .active(Region::Right, app.active[Region::Right.index()])
        .active(Region::Bottom, app.active[Region::Bottom.index()])
        .collapsed(Region::Left, app.collapsed[Region::Left.index()])
        .collapsed(Region::Right, app.collapsed[Region::Right.index()])
        .collapsed(Region::Bottom, app.collapsed[Region::Bottom.index()])
        .size(Region::Left, app.sizes[Region::Left.index()])
        .size(Region::Right, app.sizes[Region::Right.index()])
        .size(Region::Bottom, app.sizes[Region::Bottom.index()])
        .on_select(Message::Select)
        .on_close(Message::Close)
        .on_move(Message::Move)
        .on_resize(Message::Resize)
        .on_toggle(Message::Toggle);

    // The dock fills what it is given; the page fixes the height so the
    // workspace reads as one screen.
    container(dock)
        .width(Length::Fill)
        .height(Length::Fixed(460.0))
        .into()
}

Create the area through DockSkin. Keep the returned skin if you want to change its appearance later.

use gpui_kit::component::dock::{DockArea, DockSkin};

struct Workspace {
    dock_area: Entity<DockArea>,
    dock_skin: Rc<DockSkin>,
}

impl Workspace {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let (dock_area, dock_skin) =
            DockSkin::dock_area("main-dock", Some(1), window, cx);

        Self { dock_area, dock_skin }
    }
}
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { DockRegion, DockPanelDef, DockDragState, DockState, DockArea } from "dock.slint";

The optional version belongs to your saved layout schema. Increase it when your application can no longer restore an older layout.

#Define a panel

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
/// One panel: its identity, its tab's title, the dock it starts in, whether its
/// tab closes, and its body. The order the panels are given in is the order
/// their tabs are drawn.
fn panel<'a>(def: &PanelDef) -> Panel<'a, Message> {
    Panel::new(def.id, def.title, body_text(def.body))
        .region(def.region)
        .closable(def.closable)
}

A styled Dock panel implements BasePanel for identity and persistence, and Panel for its title, tab, and toolbar presentation.

use gpui_kit::component::dock::{BasePanel, Panel, PanelEvent};

struct FilesPanel {
    focus_handle: FocusHandle,
}

impl EventEmitter<PanelEvent> for FilesPanel {}

impl Focusable for FilesPanel {
    fn focus_handle(&self, _: &App) -> FocusHandle {
        self.focus_handle.clone()
    }
}

impl BasePanel for FilesPanel {
    fn panel_name(&self) -> &'static str {
        "FilesPanel"
    }
}

impl Panel for FilesPanel {
    fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        "Files"
    }
}

impl Render for FilesPanel {
    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        div().size_full().p_3().child("Project files")
    }
}
SlintNo Slint version of this example yet.

Wrap styled panels with panel_handle. This preserves the gpui-component panel chrome when the base Dock stores the panel behind its renderer-independent handle.

#Describe the initial layout

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
/// The workspace's first layout: seven panels the regions group into a left
/// dock of two, a centre of two, a right dock of one and a bottom dock of two.
fn default_layout() -> Vec<PanelDef> {
    vec![
        PanelDef {
            id: 1,
            title: "Explorer",
            body: "src/ · Cargo.toml · README.md",
            region: Region::Left,
            closable: true,
        },
        PanelDef {
            id: 2,
            title: "Search",
            body: "12 results in 4 files",
            region: Region::Left,
            closable: true,
        },
        PanelDef {
            id: 3,
            title: "main.rs",
            body: "fn main() { app::run(); }",
            region: Region::Center,
            closable: false,
        },
        PanelDef {
            id: 4,
            title: "lib.rs",
            body: "pub mod dock; pub mod theme;",
            region: Region::Center,
            closable: true,
        },
        PanelDef {
            id: 5,
            title: "Terminal",
            body: "$ cargo run --example dock",
            region: Region::Bottom,
            closable: true,
        },
        PanelDef {
            id: 6,
            title: "Problems",
            body: "No problems detected",
            region: Region::Bottom,
            closable: true,
        },
        PanelDef {
            id: 7,
            title: "Outline",
            body: "Dock · Panel · Region",
            region: Region::Right,
            closable: true,
        },
    ]
}

DockLayout is a value: compose it before a window exists, serialize it, compare it, or generate it from application state. Tabs and splits can be nested freely.

use gpui_kit::component::dock::{DockLayout, panel_handle};

let files = cx.new(|cx| FilesPanel {
    focus_handle: cx.focus_handle(),
});
let editor = cx.new(|cx| EditorPanel::new(cx));

let layout = DockLayout::h_split()
    .child(
        DockLayout::tabs().panel_view(panel_handle(files), cx),
        Some(px(240.)),
    )
    .child(
        DockLayout::tabs().panel_view(panel_handle(editor), cx),
        None,
    );

self.dock_area.update(cx, |area, cx| {
    area.set_center(layout, window, cx);
});
SlintNo Slint version of this example yet.

Use h_split() and v_split() for rows and columns and tabs() for a tab group. A None split size fills the remaining space.

The Dock area also supports left, right, and bottom regions through DockPlacement. Panels can be added, removed, activated, zoomed, and moved at runtime; user operations emit DockEvent, including LayoutChanged for persistence.

#Restore and persist layouts

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
impl App {
    /// Put the workspace back to the layout it started with.
    ///
    /// iced 0.14 has no Dock state to serialize or deserialize: the layout is
    /// this application's own values, so restoring is building them again. An
    /// application that wants to persist a workspace stores its own panel list
    /// and the three arrays beside it, with whatever it already uses for state.
    fn reset_layout(&mut self) {
        self.panels = default_layout();
        self.active = DEFAULT_ACTIVE;
        self.collapsed = [false; 4];
        self.sizes = DEFAULT_SIZES;
    }
}

Dump the entire workspace as DockAreaState, store it with Serde, then load it on the next launch.

use gpui_kit::component::dock::DockAreaState;

// Save.
let state = self.dock_area.read(cx).dump(cx);
let json = serde_json::to_string_pretty(&state)?;

// Restore.
let state: DockAreaState = serde_json::from_str(&json)?;
self.dock_area.update(cx, |area, cx| {
    area.load(state, window, cx)
})?;
SlintNo Slint version of this example yet.

Register every restorable panel name during application initialization. The registered factory recreates the styled panel behind a panel_handle.

register_panel(cx, "FilesPanel", |state, window, cx| {
    let panel = cx.new(|cx| FilesPanel::from_state(state, window, cx));
    panel_handle(panel)
});
SlintNo Slint version of this example yet.

Dock state retains compatibility with layouts saved by earlier releases. Keep a sensible fallback layout for removed application panels or deliberate schema changes.

#Move a panel to another group

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
impl App {
    /// Move `panel` into `region`, immediately before `before`, or to the end of
    /// the region when `before` is `None`. The panel keeps its body — only its
    /// region and position change.
    ///
    /// The tab drag reports a reorder inside a panel's own strip, so `region` is
    /// that strip's region there. The message carries the region anyway, which is
    /// what lets an application raise the same move into a *different* region
    /// from a menu or shortcut; the pointer does not cross strips.
    fn move_panel(&mut self, panel: u64, region: Region, before: Option<u64>) {
        let Some(index) = self.panels.iter().position(|def| def.id == panel) else {
            return;
        };
        let mut moved = self.panels.remove(index);
        moved.region = region;
        // `before` is resolved after the removal, so a panel moved onto its own
        // former neighbour lands where the pointer asked rather than one off.
        let at = before
            .and_then(|id| self.panels.iter().position(|def| def.id == id))
            .unwrap_or(self.panels.len());
        self.panels.insert(at, moved);
        self.active[region.index()] = Some(panel);
    }
}

The panel list is the layout’s source of truth. Each panel names its title, its body, the dock it starts in, and whether its tab can be closed. The Slint sample below is the workspace the gallery at the top of this page runs.

// Panels start in the left, centre, right, or bottom dock.
let workspace = DockLayout::h_split()
    .child(DockLayout::tabs().panel_view(panel_handle(explorer), cx), Some(px(240.)))
    .child(DockLayout::tabs().panel_view(panel_handle(editor), cx), None);
Text {
    text: "Drag a tab onto another group's tab strip to join that group. Drop it on the group's left or right edge to dock it beside the group, which splits the centre into two panes. The toolbar opens and closes each dock, zooms the centre, and locks the layout; the X on a tab closes that panel.";
    color: Theme.muted-foreground;
    font-size: Theme.font-size-sm;
    wrap: word-wrap;
}

Dragging a tab rearranges the workspace. A drop on a group’s tab strip joins that group; a drop on its left or right edge docks the panel beside it, which splits the centre into two panes. Close a panel with the tab’s close button, collapse a dock with the chevron in its tab bar, and zoom the centre with the tab bar’s zoom button.

// The same operations the pointer performs are available on the area.
area.update(cx, |area, cx| {
    area.add_panel(panel_handle(outline), DockPlacement::Right, None, window, cx);
    area.toggle_dock(DockPlacement::Bottom, window, cx);
});
SlintNo Slint version of this example yet.

#Style the workspace

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
/// The appearance the Dock does not take.
///
/// There is no `DockSkin`: a tab strip, a pane, a rail and a divider all read
/// their fills and edges from `theme.rs`, so the workspace is already wearing
/// the gallery's tokens and a theme switch repaints it. The one input the
/// component takes is each edge dock's size, and [`Region::clamp_size`] — the
/// region's own floor and ceiling — is what bounds a drag.
const DEFAULT_SIZES: [f32; 4] = [
    Region::Left.default_size(),
    Region::Center.default_size(),
    Region::Right.default_size(),
    Region::Bottom.default_size(),
];

DockSkin keeps rendering decisions outside the layout engine. You can configure the common panel presentation without changing Dock behavior:

self.dock_skin.set_panel_style(PanelStyle::default(), cx);
self.dock_skin.set_toggle_button_visible(true, cx);
SlintNo Slint version of this example yet.

For complete control, implement the renderer traits in gpui-base. The same layout data and operations can then drive an entirely different Dock style.

#Runnable example

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
/// Run the demo: the same application the library's gallery serves as
/// `?story=dock`, in a native window.
pub fn run(theme: Theme) -> iced::Result {
    iced::application(boot, update, view)
        .title("uni-kit — dock")
        .theme(theme)
        .run()
}

The repository includes a complete workspace with edge docks, runtime panel operations, layout persistence, and keyboard actions:

cargo run -p example-dock
SlintNo Slint version of this example yet.

See examples/dock/src/main.rs for the full implementation.