diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 7b7b7d20..ffd066ee 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -162,11 +162,23 @@ pub enum UiDock

{ #[non_exhaustive] pub enum DockOperation

{ /// 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

{ #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] before: Option>, }, - 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, }, - ResizeSplit { path: Vec, size: SplitSize }, + ResizeSplit { + path: Vec, + size: SplitSize, + }, } /// A drop zone by name, as control protocols write it. PascalCase [`Zone`] names also parse. @@ -365,11 +383,11 @@ impl DockCommand

{ 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 DockCommand

{ 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 DockCommand

{ 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(layout: &Layout

, 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()], }, } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 60ad359d..b106a39d 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -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(); diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index 67e722d2..cbcef4ef 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -21,6 +21,9 @@ impl DockContent for Content { type Driver = DockDriver; +/// A name and a test for one kind of [`Hit`]. +type Check<'a> = (&'a str, &'a dyn Fn(&Hit) -> bool); + /// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0. fn docked(groups: &[&[u32]], iconic: bool) -> Layout { 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) -> bool| hits.iter().filter(|(h, _)| f(h)).count(); - let expected: [(&str, &dyn Fn(&Hit) -> 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(_))), diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index b4c65efe..4c7582bf 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -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; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index fe5d4728..ae6646ee 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -41,11 +41,7 @@ fn side_at(edge: Edge, right_to_left: bool) -> Side { } pub(in crate::docking) fn column_width

(column: &Column

, 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 diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 738f0657..79a37f27 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -112,8 +112,8 @@ impl 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::() - + column.groups.len().saturating_sub(1) as f32 * SEPARATOR; + let total: f32 = + column.groups.iter().map(|g| self.icon_group_height(g)).sum::() + 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 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); diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 64702470..e34cad2f 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -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}; diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 1e8ae94c..ddf2ecee 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -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 = serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap(); + let float: DockCommand = + 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(); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 82324509..0e8b3ba6 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -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

` contains an optional root `Node

` 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

` holds an optional root `Node

` 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

` 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

` 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

` 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 = 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

` 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.