docking: rewrite the docking guide for the current API

docs/ui-docking.md covers the Dock and Frame profiles, the model and
actions, Workspace, the renderer and DockContent, gestures, DockCommand,
DockStyle::localized, hit rects and the DockDriver test driver, and links
the task-pane guide.

Also: ui.dock open, close and activate map in one place, the command tests
build as a cfg(test) module, the hit-kind table gets a type alias, the
testing example uses public API, and rustfmt.
This commit is contained in:
aiongg committed 2026-10-19 12:07:14 +00:00
1 parent 0d350d8e11
commit b98ef89921
9 files changed
+266 -145

No files matched your search

+35 -11
View File
@@ -162,11 +162,23 @@ pub enum UiDock<P> {
#[non_exhaustive]
pub enum DockOperation<P> {
/// Show a panel at its last location and select it.
Open { panel: P },
Close { panel: P },
Activate { panel: P },
Float { panel: P, rect: [f32; 4] },
MoveFloating { panel: P, rect: [f32; 4] },
Open {
panel: P,
},
Close {
panel: P,
},
Activate {
panel: P,
},
Float {
panel: P,
rect: [f32; 4],
},
MoveFloating {
panel: P,
rect: [f32; 4],
},
Move {
panel: P,
target: P,
@@ -175,14 +187,20 @@ pub enum DockOperation<P> {
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
before: Option<Option<P>>,
},
SetStackOpen { panel: P, open: bool },
SetStackOpen {
panel: P,
open: bool,
},
/// `height` is required; `null` clears it.
ResizeStack {
panel: P,
#[serde(deserialize_with = "required")]
height: Option<f32>,
},
ResizeSplit { path: Vec<bool>, size: SplitSize },
ResizeSplit {
path: Vec<bool>,
size: SplitSize,
},
}
/// A drop zone by name, as control protocols write it. PascalCase [`Zone`] names also parse.
@@ -365,11 +383,11 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
let anchor = docked.or_else(framed).cloned().ok_or(DockError::MissingTarget)?;
one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Tab { before: None } })?)
}
Self::Activate { panel } | Self::UiDock(UiDock::Operation(DockOperation::Activate { panel } | DockOperation::Open { panel })) => {
Self::Activate { panel } => {
shown(panel)?;
one(Action::Activate { panel: panel.clone() })
}
Self::Close { panel } | Self::UiDock(UiDock::Operation(DockOperation::Close { panel })) => one(Action::Close { panel: panel.clone() }),
Self::Close { panel } => one(Action::Close { panel: panel.clone() }),
Self::CloseGroup { panel } => one(Action::CloseGroup { panel: panel.clone() }),
Self::CloseOthers { panel } => one(Action::CloseOthers { panel: panel.clone() }),
Self::Minimize { panel, minimized } => {
@@ -399,6 +417,11 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
Ok(actions)
}
Self::UiDock(UiDock::Operation(operation)) => match operation {
DockOperation::Open { panel } | DockOperation::Activate { panel } => {
shown(panel)?;
one(Action::Activate { panel: panel.clone() })
}
DockOperation::Close { panel } => one(Action::Close { panel: panel.clone() }),
DockOperation::Float { panel, rect } => one(Action::Float { panel: panel.clone(), rect: *rect }),
DockOperation::MoveFloating { panel, rect } => one(Action::MoveFloating { panel: panel.clone(), rect: *rect }),
DockOperation::Move { panel, target, zone, before } => {
@@ -408,7 +431,6 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
DockOperation::SetStackOpen { panel, open } => one(Action::SetStackOpen { panel: panel.clone(), open: *open }),
DockOperation::ResizeStack { panel, height } => one(Action::ResizeStack { panel: panel.clone(), height: *height }),
DockOperation::ResizeSplit { path, size } => one(Action::ResizeSplit { path: path.clone(), size: *size }),
DockOperation::Open { .. } | DockOperation::Close { .. } | DockOperation::Activate { .. } => unreachable!("matched above"),
},
Self::Workspace { .. } | Self::NewWorkspace { .. } | Self::DeleteWorkspace { .. } | Self::ResetWorkspace {} | Self::Lock { .. } => {
Err(DockError::NotAllowed)
@@ -469,7 +491,9 @@ fn members<P: Clone + Eq + Hash>(layout: &Layout<P>, panel: &P, unit: Unit) -> V
}
_ => Vec::new(),
},
Some(Place::Floating { .. }) => layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.panels().cloned().collect()).unwrap_or_default(),
Some(Place::Floating { .. }) => {
layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.panels().cloned().collect()).unwrap_or_default()
}
_ => vec![panel.clone()],
},
}
+21 -5
View File
@@ -1,4 +1,5 @@
//! `DockCommand`: every documented shape parses and maps to atomic layout actions.
#![cfg(feature = "serde")]
use std::collections::BTreeSet;
@@ -158,7 +159,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
actions(json!({"command": "window.panel.minimize", "panel": "layers", "minimized": false})),
vec![Action::SetMinimized { panel: s("layers"), minimized: false }]
);
assert_eq!(actions(id(json!({"command": "window.panel.iconic", "panel": "history"}))), vec![Action::SetIconic { panel: s("history"), iconic: true }]);
assert_eq!(
actions(id(json!({"command": "window.panel.iconic", "panel": "history"}))),
vec![Action::SetIconic { panel: s("history"), iconic: true }]
);
// window: a floating window goes native at its rect; a docked group floats first.
assert_eq!(
@@ -170,7 +174,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
matches!(docked.as_slice(), [Action::MoveGroup { to: Target::Float { rect: None }, .. }, Action::SetNative { rect: Some(_), .. }]),
"{docked:?}"
);
assert_eq!(actions(json!({"command": "window.panel.window", "panel": "color", "native": false})), vec![Action::SetNative { panel: s("color"), rect: None }]);
assert_eq!(
actions(json!({"command": "window.panel.window", "panel": "color", "native": false})),
vec![Action::SetNative { panel: s("color"), rect: None }]
);
// Named workspaces and Reset belong to the application.
for params in [
@@ -210,7 +217,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
);
let to_layers = |placement| vec![Action::Move { panel: s("color"), anchor: s("layers"), placement }];
assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers"}))), to_layers(Placement::Split(Zone::Center)));
assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "left"}))), to_layers(Placement::Split(Zone::Left)));
assert_eq!(
actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "left"}))),
to_layers(Placement::Split(Zone::Left))
);
assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": null}))), to_layers(tab(None)));
assert_eq!(
actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": "channels"}))),
@@ -252,11 +262,17 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
}
rejected(json!({"command": "window.panel.move", "panel": "layers", "anchor": "nowhere"}), DockError::MissingTarget);
rejected(json!({"command": "window.panel.move", "panel": "nowhere", "anchor": "layers"}), DockError::MissingPanel);
rejected(json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "left", "before": null}), DockError::InvalidPlacement);
rejected(
json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "left", "before": null}),
DockError::InvalidPlacement,
);
rejected(json!({"command": "window.panel.float", "panel": "layers", "width": 1e30}), DockError::InvalidGeometry);
rejected(json!({"command": "window.panel.move", "panel": "canvas", "anchor": "layers"}), DockError::NotAllowed);
rejected(op(json!({"operation": "moveFloating", "panel": "color", "rect": [0, 0, -5, 10]})), DockError::InvalidGeometry);
rejected(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "top", "before": "channels"})), DockError::InvalidPlacement);
rejected(
op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "top", "before": "channels"})),
DockError::InvalidPlacement,
);
rejected(op(json!({"operation": "resizeSplit", "path": [true, true, true], "size": {"Ratio": 0.5}})), DockError::MissingTarget);
// A docked group cannot float into a native window when locked.
let mut locked = workspace();
+5 -4
View File
@@ -21,6 +21,9 @@ impl DockContent<u32> for Content {
type Driver = DockDriver<u32, Content>;
/// A name and a test for one kind of [`Hit`].
type Check<'a> = (&'a str, &'a dyn Fn(&Hit<u32>) -> bool);
/// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0.
fn docked(groups: &[&[u32]], iconic: bool) -> Layout<u32> {
let mut column = Column::new(groups.iter().map(|g| Group::new(g.to_vec(), 0)).collect(), 240.0);
@@ -267,9 +270,7 @@ fn hit_rects_cover_every_gesture_source() {
let mut icons = Column::new(vec![Group::new(vec![9], 0)], 240.0);
icons.iconic = true;
let dock = Node::Dock { side: Side::Start, columns: vec![icons, expanded] };
let stack = Node::Stack {
entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }],
};
let stack = Node::Stack { entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }] };
let frames = Node::Split {
axis: SplitAxis::Vertical,
size: SplitSize::Ratio(0.5),
@@ -285,7 +286,7 @@ fn hit_rects_cover_every_gesture_source() {
let d = DockDriver::new(layout.clone(), Content);
let hits = d.hits();
let kinds = |f: &dyn Fn(&Hit<u32>) -> bool| hits.iter().filter(|(h, _)| f(h)).count();
let expected: [(&str, &dyn Fn(&Hit<u32>) -> bool); 15] = [
let expected: [Check<'_>; 15] = [
("tab", &|h| matches!(h, Hit::Tab(_))),
("drag bar", &|h| matches!(h, Hit::DragBar(_))),
("border", &|h| matches!(h, Hit::Border(_))),
+3 -3
View File
@@ -21,10 +21,10 @@ mod workspace;
#[cfg(feature = "serde")]
pub use command::{DockCommand, DockOperation, UiDock, ZoneName};
pub use hits::{Divider, Hit, ResizeEdge};
pub use render::{close_flyout, flyout, open_flyout};
pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target};
pub use hits::{Divider, Hit, ResizeEdge};
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
pub use render::{close_flyout, flyout, open_flyout};
pub use surface::{
DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, HidePanels, PanelLimits, Profile, begin_drag, cancel_drag,
is_dragging,
@@ -34,7 +34,7 @@ pub use workspace::Workspace;
use surface::{Drag, GroupDrag, GroupGesture};
#[cfg(all(test, feature = "serde"))]
#[cfg(test)]
mod command_tests;
#[cfg(test)]
mod dock_tests;
+1 -5
View File
@@ -41,11 +41,7 @@ fn side_at(edge: Edge, right_to_left: bool) -> Side {
}
pub(in crate::docking) fn column_width<P>(column: &Column<P>, style: &DockStyle) -> f32 {
if column.iconic {
column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width)
} else {
column.width
}
if column.iconic { column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) } else { column.width }
}
/// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved
+4 -3
View File
@@ -112,8 +112,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if let Some(panel) = self.activation.as_ref().filter(|p| column.groups.iter().any(|g| g.panels.contains(p))) {
open_flyout(ui.ctx(), self.id, panel.clone());
}
let total: f32 = column.groups.iter().map(|g| self.icon_group_height(g)).sum::<f32>()
+ column.groups.len().saturating_sub(1) as f32 * SEPARATOR;
let total: f32 =
column.groups.iter().map(|g| self.icon_group_height(g)).sum::<f32>() + column.groups.len().saturating_sub(1) as f32 * SEPARATOR;
if total > body.height() + 0.5 {
let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { return };
let mut child =
@@ -239,7 +239,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
if labels {
let text = self.content.label(ui, panel, &label, &self.style.font, color);
let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), (cell.right() - icon.right() - 12.0).max(0.0), self.style.font.clone());
let galley =
text.into_galley(ui, Some(egui::TextWrapMode::Truncate), (cell.right() - icon.right() - 12.0).max(0.0), self.style.font.clone());
painter.galley(pos2(icon.right() + 6.0, cell.center().y - galley.size().y * 0.5), galley, color);
} else if response.hovered() && !response.dragged() {
response.clone().on_hover_text(&label);
+3 -2
View File
@@ -3,8 +3,9 @@
//! ```ignore
//! let mut driver = DockDriver::new(app_layout(), Panels::default());
//! let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap();
//! driver.drag(bar.center(), egui::pos2(4.0, 300.0));
//! assert!(driver.layout.dock_path(Side::Start).is_some());
//! driver.drag(bar.center(), egui::pos2(2.0, 300.0));
//! driver.settle();
//! assert!(driver.hit(&Hit::ColumnBar(PanelId::Layers)).is_some());
//! ```
use std::{fmt::Debug, hash::Hash};
+2 -1
View File
@@ -414,7 +414,8 @@ mod v1_fixtures {
assert!(!iconic(&d.layout), "{at}: expanded");
// Float the column by command, then drag its window back to the start edge.
let float: DockCommand<String> = serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap();
let float: DockCommand<String> =
serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap();
let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone())).unwrap();
d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}"));
d.settle();
+192 -111
View File
@@ -1,151 +1,232 @@
# Panel docking
`craft_ui::docking` lets an application move the same panel between docked tabs, side splits and
floating groups. The shared layer owns placement and interaction; the application owns each
panel's contents, commands, document state and persistence policy.
`craft_ui::docking` lets the user arrange an app's panels: tab groups, docks with columns,
splits, floating windows and iconic columns. craft-ui owns the layout model, the renderer and the
gestures. The app owns panel contents, commands, document state and where layouts are saved.
## Model and transactions
Office apps that draw their own document with a few task panes beside it use
[`craft_ui::panes`](ui-panes.md) instead.
`Layout<P>` contains an optional root `Node<P>` and floating tab groups. A node can be:
## Profiles
- `Tabs`: ordered panel identities with one active index.
- `Split`: horizontal or vertical children, with a ratio or fixed first/second size.
- `Stack`: accordion entries with individual open state and optional content height.
`DockStyle::profile` picks one of two arrangements. Both use the same model and actions.
Choose a stable, nonlocalized panel ID type implementing `Clone + Eq + Hash`. Rendering also
requires `Debug + Send + Sync + 'static`. A panel identity occurs at most once in the layout.
Keep transient document/sequence tabs separate when they represent documents inside one panel.
| Profile | Arrangement | Apps |
|---|---|---|
| `Profile::Dock` (default) | Docks on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | Creative apps with a canvas and panel docks |
| `Profile::Frame` | A split tree of tab frames. Frame menus offer Maximize Frame, Undock and Close Other Panels. Ctrl/Cmd + tab drag undocks. Drops on all four outer edges split at the root. | Apps whose whole window is frames |
Sides are logical: `Side::Start` is the left edge, or the right edge when
`DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its
locale.
## Model
`Layout<P>` holds an optional root `Node<P>` and a list of `Floating` windows.
| Node | Holds |
|---|---|
| `Tabs` | A tab group: panels and the active index. |
| `Split` | Two children, horizontal or vertical, with a ratio or a fixed first or second size. |
| `Stack` | Accordion entries, each open or closed, with an optional height. |
| `Dock` | A side and its columns. A `Column` has `Group`s, a width, and `iconic`. |
A `Floating` window has one or more stacked groups, a rect, and an optional native-window rect.
`Floating::single` makes a window with one group; `panels()` and `contains()` read it.
`P` is the app's panel id: stable, not localized, `Clone + Eq + Hash`. The renderer also needs
`Debug + Send + Sync + 'static`. A panel occurs at most once in a layout.
Change a layout only through `Action`s:
```rust
use craft_ui::docking::{Action, Floating, Layout, Node, Placement};
let mut layout = Layout {
root: Some(Node::Tabs { panels: vec!["layers", "properties"], active: 0 }),
floating: vec![Floating {
panels: vec!["brushes"], active: 0, rect: [40.0, 80.0, 240.0, 300.0],
}],
floating: vec![Floating::single(vec!["brushes"], 0, [40.0, 80.0, 240.0, 300.0])],
};
layout.apply(Action::Move {
panel: "brushes", anchor: "layers",
panel: "brushes",
anchor: "layers",
placement: Placement::Tab { before: Some("properties") },
})?;
# Ok::<(), craft_ui::docking::DockError>(())
```
Every transaction validates the original layout before cloning or recursive mutation. It checks
the resulting layout before replacing the original. Missing or self targets, duplicate IDs,
invalid active indices, excessive depth/count and invalid geometry return `DockError`; they do
not remove the source panel. Tab removals retain the selected panel by identity when possible.
Empty source groups collapse. Moving within an accordion preserves its entry state and height;
unaffected split sizing and accordion entries remain unchanged.
- `Move`, `Float` and `Close` act on one panel. `MoveGroup`, `MoveColumn` and `MoveWindow` move a
group, a dock column or a floating window, named by any panel in it.
- A `Target` is `At { anchor, placement }`, `Float { rect }` or `NewDock { side }`.
- A `Placement` is a tab position, a split zone, a new group above or below, a new column, or a
new frame on an outer edge.
- Other actions select, minimize, collapse to icons, resize splits, columns and groups, and move
a floating window to its own OS window (`SetNative`, feature `native-windows`).
`OpenAt` adds a currently absent registered panel at an explicit tab or side destination. `Open`
is the simpler group-reveal operation. Registry checks remain app-owned: the shared model cannot
know whether an ID names an actual panel or whether its content is supported by the application.
Every action checks the whole result before it commits. A bad target, a duplicate id, a stale
split path or invalid geometry returns a `DockError` and leaves the layout unchanged. The source
panel is never lost.
Use `apply_with_permissions` to prevent moving/closing a document canvas or merging a panel into
it. `Permissions::PROTECTED` disallows these operations while allowing side splits around it.
An app that requires a permanently docked panel also checks that policy before accepting a
transaction; an empty shared root is valid when all panels float.
`Permissions` limit what may happen to a panel. `Permissions::PROTECTED` keeps a canvas docked
and open and takes no tabs, but allows splits beside it. `apply_with_permissions` applies them.
## Rendering and interaction
## Workspace
Existing renderers can convert their layout into the shared model for transactions and retain
their own panel chrome. `DropTarget` describes visible body/tab rectangles and whether a group
accepts tabs or splits. Supply targets back-to-front, including floating-window layer order.
`resolve_drop` returns a tab insertion or side/center placement and its preview rectangle.
The frontmost containing group blocks targets underneath even when it rejects a drop.
`Workspace<P>` is what an app saves: the layout, the hidden panels with where each one goes back,
an optional saved arrangement for Reset, and `locked`.
The optional `DockArea` renderer handles tab fitting and overflow, drag/drop previews,
Escape/focus-loss cancellation, split/accordion dividers and floating-group resize grips. It renders a
snapshot and returns `DockOutput.actions`; apply them through the application's command path.
With `serde` enabled, `Action` also serializes so pointer, keyboard and control-channel requests
can share that command path, including splitter changes, tab reorder and floating bounds.
Panel body callbacks receive stable global egui IDs, so moving a panel does not reset its widget
memory. The application should still keep durable panel/document data outside transient egui
widget memory.
| Method | Does |
|---|---|
| `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused. |
| `set_visible(panel, visible, fallback)` | Hide a panel, or show it at its recorded place, else beside `fallback`. |
| `reconcile(registered, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. |
| `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. |
| `validate` | Check a loaded workspace before use. |
`DockStyle` supplies colors, font, spacing and localized action labels. `show_with_limits` also
accepts per-panel minimum/maximum sizes. Visible geometry clamps to the available area; a narrow
viewport relaxes minima proportionally. Persisted preferred sizes are unchanged by this drawing
clamp. A divider gesture produces an explicit resize action.
With the `serde` feature, `Workspace`, `Layout` and `Action` serialize. Files without a version
are v1 and load unchanged. craft-ui does not choose where files go; the app does.
Use `begin_drag` with the same area ID to carry an existing tab/header gesture into `DockArea`
when an app switches from its default arrangement to a customizable workspace.
## Rendering
Floating groups are egui areas in one viewport. Native child windows, cross-viewport drag
transport, OS window ownership and multi-monitor coordinate conversion remain the host's
responsibility. The model does not instantiate or reparent native windows.
`DockArea::new(id).show_content(ui, &layout, &style, &mut content)` draws a layout snapshot and
returns a `DockOutput`. It does not change the layout. The app applies `output.actions` through
its own command path, so pointer, keyboard and control commands share one path.
## Persistence and visibility
`DockContent<P>` is the app's adapter. It draws each panel body and gives its label, icon,
permissions, size limits, preferred floating size, panel menu and group header. `bare` hides the
tab strip of a lone panel. Panel bodies keep a stable egui id when they move, so widget memory
survives.
Enable `craft-ui`'s optional `serde` feature to serialize the shared model, or retain an existing
schema through an adapter. The serialization feature does not choose a filesystem location or
write preferences. Applications validate panel IDs, payload size and schema version before
committing loaded state. Bound recursive deserialization/conversion in the app as well as using
`Layout::validate`; the validator cannot retroactively protect an unbounded parser.
`DockOutput` also has:
To hide a panel without forgetting its placement, capture `Layout::location`, then close it.
Keep the resulting `Location` with the app's visibility state. `restore` uses stable neighbor
IDs, restores captured fixed sizing/floating bounds and recreates a previously closed accordion
group. If a neighbor was removed, it returns an unchanged layout and `MissingTarget`; choose an
explicit fallback such as a default side or remembered floating rectangle. Restoring multiple
panels closed from the same group in reverse close order preserves neighbor relationships.
- `focused`: the focused panel.
- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and
`DockEvent::ToggleAutoCollapse` (Dock).
- `error`: a rejected gesture.
## Acceptance
Builder options on `DockArea`:
The shared tests cover lossless moves, tab reorder, stale targets, atomic errors, protected
canvases, accordion state, fixed sizing, hidden-panel restoration, bounded malformed layouts,
and a deterministic sequence of 800 panel moves. Pointer tests exercise dragging a floating
panel into the main tab strip and canceling a drag.
| Option | Use |
|---|---|
| `maximized(panel)` | Maximize Frame: that frame fills the area. |
| `hide_panels(HidePanels)` | Hide panels for Tab and Shift+Tab. Protected panels stay. |
| `auto_collapse_iconic(on)` | Close a flyout on Escape or a click outside (default on). |
| `raise_panel(panel)` | Raise and focus a panel's floating window this frame. |
| `blocked_rects(rects)` | App overlays that docking gestures must ignore. |
`tests/docking_workflows.rs` renders a document and populated Layers/Properties/Brushes panels,
then uses actual pointer events to merge, detach and redock Brushes. It checks retained values
and widget identity at two widths, two display scales and both egui themes. To retain captures:
`DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome rather than on a
panel body. `begin_drag` hands a drag that started in the app's own chrome to the area.
`docking::chrome` has the floating frame and title bar for apps that draw their own windows.
## Gestures
- A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags
the whole group. A column bar drags a column. A floating window's title bar drags the window.
- Drop previews show the destination: an outline for tabs, a line for a new group, column or dock,
a filled half for a split. Escape or losing window focus cancels a drag.
- Double-click a tab or drag bar to minimize a group to its strip.
- A column's »/« button collapses it to icons, one icon per panel with a separator between groups.
Widen an iconic column to show panel names. Click an icon to open its group as a flyout on the
canvas side. Drag an icon to move its panel, or the grip above an icon group to move the group.
## Commands
With the `serde` feature, `DockCommand<P>` is the shared control vocabulary. The app puts the
command id under `"command"` in the params and deserializes them. craft-ui has no `serde_json`
dependency.
```rust,ignore
let mut params = params.clone();
params["command"] = json!(id);
let command: DockCommand<PanelId> = serde_json::from_value(params)?;
if command.is_app_owned() {
return app.workspace_command(command);
}
let closed = command.apply(&mut app.workspace, |panel| app.permissions(panel))?;
```
| Id | Params (`?` is optional) |
|---|---|
| `window.panel.layout` | `{action}`: a serialized `Action` |
| `window.panel.move` | `{panel, anchor, zone?, before?, group?, placement?}` |
| `window.panel.float` | `{panel, x?, y?, width?, height?, group?, column?}` |
| `window.panel.dock` | `{panel, anchor?, zone?, before?, group?, column?, side?}` |
| `window.panel.activate`, `.close`, `.closeGroup`, `.closeOthers` | `{panel}` |
| `window.panel.minimize` | `{panel, minimized?}` |
| `window.panel.iconic` | `{panel, iconic?}` |
| `window.panel.window` | `{panel, native?}` |
| `window.workspace`, `.new`, `.delete` | `{name}` |
| `window.workspace.reset`, `ui.dock.reset` | `{}` |
| `window.workspace.lock` | `{locked?}` |
| `ui.dock` | `{action}`, or `{operation, …}` |
- `zone` is `tab`, `center`, `left`, `right`, `top`, `bottom`, `above` or `below`. `before` names
the tab to insert before; `null` appends.
- `group` moves the panel's whole group; `column` moves its dock column, or its whole window when
it floats. `side` is `Start` or `End`.
- Without a value, `minimized`, `iconic` and `locked` toggle.
- `ui.dock` operations are those of LightCraft, PDFCraft, CADCraft and SoundCraft: `open`,
`close`, `activate`, `float {rect}`, `moveFloating {rect}`, `move {target, zone?, before?}`,
`setStackOpen {open}`, `resizeStack {height}` and `resizeSplit {path, size}`. `rect` is
`[x, y, width, height]`.
`to_actions` returns the actions a command makes without applying them. `apply` applies them all
or none and returns the panels it closed, so the app can release their state. Showing a hidden
panel and Lock change the workspace directly. Named workspaces and Reset belong to the app:
`is_app_owned` is true for them and `apply` refuses them. `DockCommand::IDS` lists every id for
the app's command registry. App-specific aliases stay in the app.
## Labels
Every visible and accessibility label is a `*_label` field of `DockStyle`, with an English
default. `DockStyle::localized` sets them all from one keyed lookup:
```rust,ignore
let style = DockStyle::from_ui(ui).localized(|key| app.strings.get(key).cloned());
```
Keys are `dock.close`, `dock.float`, `dock.collapseToIcons` and so on; the rustdoc of `localized`
lists them all. `DockContent::label` and `action_label` can shape panel and action text further;
the logical label stays the accessibility name.
## Testing
`DockArea::hit_rects(ctx, id)` lists every gesture source drawn last frame as a `Hit` with its
rect: tabs, drag bars, borders, column bars, »/« buttons, title bars, close buttons, icons, icon
grips, resize edges and dividers. It is always compiled, so an app can return it from a
`ui.docking` control method next to the layout and the focused panel.
The `testing` feature adds `docking::testing::DockDriver`. It runs a `DockArea` in a headless
egui context with real pointer and key events, and applies the actions each frame emits.
```rust,ignore
let mut driver = DockDriver::new(app_layout(), Panels::default());
let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap();
driver.drag(bar.center(), egui::pos2(2.0, 300.0));
driver.settle();
assert!(driver.hit(&Hit::ColumnBar(PanelId::Layers)).is_some());
```
It also has `press`, `release`, `click`, `hold`, `key`, `focus`, `hits`, and checks for painted
previews (`filled`, `outlined`).
craft-ui's own tests in `crates/ui/tests/docking_workflows.rs`:
- Load every app's v1 layout fixture, dock groups into a new dock, a bottom band and a seam,
collapse and expand the column, float it by command and drag it back, then check the layout
reads back as written.
- Render Layers, Properties and Brushes panels with real content, and merge, detach and redock
Brushes by pointer at two widths, two scales and both themes. Set `CRAFT_UI_DOCK_VISUAL_DIR`
to keep the captures:
```sh
CRAFT_UI_DOCK_VISUAL_DIR=/absolute/output/path cargo test -p craft-ui --test docking_workflows --all-features
```
These are offscreen egui checks. An adopting application still needs command, content, workspace
reload, theme and input tests using its own renderer and persistence adapter.
An adopting app still tests its own commands, contents, saved workspaces and themes.
## Application chrome and activation
## What stays in the app
`DockArea::show_customized` accepts one mutable `DockContent<P>` adapter, with body,
panel-menu, group-header and label methods. Apps queue commands in these callbacks and
apply them after rendering the readonly snapshot. `group_header_width` reserves a bounded
trailing portion of the existing tab strip; its child UI is clipped to that region.
Collapsed accordion entries still have panel menus; collapsing tab groups is host policy.
`label` returns `WidgetText`, including an application-shaped galley, used for tab width,
painting and menu labels. The separate logical label callback supplies tab accessibility
text. `action_label` independently shapes the localized stock Float/Move/Close menu
text without receiving a panel identity. Its default returns unchanged logical `WidgetText`;
custom visual text never replaces the action's logical accessibility label. Panel targets
and overflow entries continue to use `label`. Original `show` and `show_with_limits` call
sites remain supported.
Apply `Action::Activate` in the host, then use `DockArea::raise_panel(panel)` on the next
frame to raise the owning in-viewport floating layer and focus the panel tab. This request
does not mutate the serialized layout or persist z-order. Floating area identity still
derives from the first panel; native-window identity remains host-owned.
Floating groups have a distinct 12-point grip in the existing tab strip. Moving it emits
`MoveFloating` for the whole group; dragging an individual tab detaches just that panel.
`DockOutput::group_drag` and `group_drop` expose the group identity and release position
for app-owned atomic group docking policies. Escape/lost focus restores the group's
starting rectangle. No generic group docking or app command registry is inferred.
Hosts supply overlay rectangles via `blocked_rects` before showing the surface. These
block drop previews, release actions and float fallback at the pointer, including overlays
appearing mid-drag. Hosts must provide current overlay geometry or cancel docking when
modal state prevents interaction; later-drawn overlays cannot be discovered retroactively.
`DockArea::chrome_has_focus(ctx, layout)` classifies focus on rendered dock tabs,
collapse buttons, overflow, split/stack dividers, floating resize corners and group
grips. It excludes panel content and custom header/menu widgets and rejects invalid
layouts. Hosts can permit global commands from chrome while reserving Enter, Space,
Tab, Escape and arrow keys for activation, traversal, cancellation and resizing.
Content widgets retain their usual keyboard ownership. The renderer owns the IDs;
hosts do not need to reconstruct them.
- The panel registry, panel contents and document state.
- Named workspaces, Reset, and when and where layouts are saved.
- Menus and key bindings outside the docking chrome.
- Native window ownership on platforms without egui viewports.