mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-09 20:12:57 +00:00
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:
1 parent
0d350d8e11
commit
b98ef89921
9 files changed
+266
-145
No files matched your search
@@ -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()],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(_))),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Reference in new issue
Block a user