Files
craft-libs/docs/ui-docking.md
T

805 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Panel docking
`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.
Office apps that draw their own document with a few task panes beside it use
[`craft_ui::panes`](ui-panes.md) instead.
## Arrangements
One model serves every app. What differs between apps is the default layout and a few
`DockStyle` settings. An app builds its style with `DockStyle::from_ui`, `from_visuals` or
`default` and then sets fields; new fields can be added, so the struct has no literal form
outside the crate.
| Arrangement | Layout | Style settings | Apps |
|---|---|---|---|
| Docks beside a canvas (the defaults) | `Node::Dock`s 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. | `new_dock_zones` on | Creative apps with a canvas and panel docks |
| A split tree of tab groups | `Split`s and `Tabs` only. | `side_edge_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, `wheel_selects_tabs`, `middle_click_closes_tabs` on, `new_dock_zones`, `float_on_empty_drop` and `title_bar_docks` off; menu labels such as "Undock Panel" set on `float_label`, `float_group_label` and `close_group_label` | 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. Right to left, tab strips also mirror: the first tab is rightmost, the group header and
drag bar are at the left, and drop insertion slots follow. Accordion headers and floating title
bars (`chrome::TitleBar`, through `ChromeStyle::right_to_left`) mirror too. Frame splits keep
their sides.
## 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, and a size: a ratio, a fixed first size or a fixed second size. A moved divider keeps the mode: a fixed side stores its new size in points, a ratio its new ratio. |
| `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 holds a tree like the docked layout's (`root`: splits, tab groups and
accordions, no docks), a rect, an optional native-window rect with the monitor it is on, and
`minimized`. Its `id` names the window across panel moves (see [Native windows](#native-windows)).
`Floating::single` makes a window with one group, `Floating::new` one with a tree, and
`Floating::stack` one with groups stacked top to bottom (a vertical split sized by the groups'
heights, as an app's own stacked layouts migrate); `panels()` and `contains()` read it. Each of
these calls makes a new window with a fresh `id`. Build a layout once and keep it, or give its
windows ids with `with_id`: a layout built anew each frame, as an unsaved default layout might
be, otherwise opens new OS windows each frame and loses their drags and places. Debug builds log
a warning when native windows are replaced by new ones with the same panels three frames running. Saved layouts keep loading: a window stored as one group (v1) or as a
stack of groups loads as that group or as a vertical split of the stacked groups, sized by their
heights.
`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::single(vec!["brushes"], 0, [40.0, 80.0, 240.0, 300.0]).with_id(1)],
};
layout.apply(Action::Move {
panel: "brushes",
anchor: "layers",
placement: Placement::Tab { before: Some("properties") },
})?;
# Ok::<(), craft_ui::docking::DockError>(())
```
- `Move`, `MovePanel`, `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.
- `Open` reveals an absent panel as the last tab of its `anchor`'s group. Without an anchor it
becomes the root of an empty layout, else joins the first dock group that accepts tabs and is
shown, not minimized or in an iconic column (the end-side dock's first), else gets a new frame a quarter wide at the right edge of the docked
area. `OpenAt` takes any placement.
- 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 (with an optional
preferred `height`), a new column, or a new frame on an outer edge.
- Containers take different placements: a dock column takes groups and columns, not splits.
`Placement::for_zone(layout, anchor, zone, right_to_left)` maps a left, right, top, bottom or
center zone onto what the anchor's container takes: a split in the frame tree, a new column on
that side or a new group above or below in a dock. A floating window takes splits as the
docked layout does; `Placement::Group` on a floating anchor splits its group above or below.
- A side split zone puts what moves in a new half-size split of the anchor's frame, and no
other frame changes size. The new split has its own divider. `Placement::SplitSized` sizes
that split instead (`size`) and resizes the enclosing split whose divider runs along the
anchor frame's side (`outer`), so the frames beyond that divider give the new frame its room.
- Other actions select, minimize, collapse to icons, resize splits (`ResizeSplit` in the docked
layout, `ResizeWindowSplit` in the floating window holding a panel), columns and groups, and
move a floating window to its own OS window (`SetNative`, and `SetMonitor` for the monitor it
is on, feature `native-windows`).
- A move between windows (docked to floating, floating to floating) is one action, checked as a
whole like any other.
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. `apply`, `apply_with_permissions` and `apply_all` return the panels the
actions closed, so the app can release their state; `closed_by` lists them before applying.
`Permissions` limit what may happen to a panel: `movable`, `floatable`, `closable`,
`accepts_tabs` and `accepts_splits`. `apply_with_permissions` enforces them for every action,
whoever issues it. `Permissions::PROTECTED` keeps a canvas docked and open and takes neither tabs
nor side splits; a group dropped on it floats there. An app that opens panels beside its canvas
uses `Permissions { accepts_splits: true, ..Permissions::PROTECTED }`.
## Workspace
`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`.
| 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; `SetNative`, which records the OS windows, still applies. |
| `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else as `Open` beside `fallback`, or where `Open` without an anchor puts it. A group or window closed one panel at a time comes back as one group as its panels are shown again. |
| `reconcile(registered, permissions, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. A registered panel that cannot close, such as the canvas, is always shown. |
| `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. |
| `validate` | Check a loaded workspace before use. |
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.
## Rendering
`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.
`DockContent<P>` is the app's adapter. It draws each panel body and gives its label, icon,
permissions, size limits, dock column heights, preferred floating size, panel menu and group header. The group header
sits at the end of the tab strip; `group_header_width` gets the strip width, so a header that
draws its own tabs can size them to the room left. Extra tabs (`group_extra_tabs_width` and
`group_extra_tabs`) sit right after the group's panel tabs, before the drag bar, and fit with
them: both shrink widest first to `min_tab_width`, the extra tabs as one tab, and in a row
narrower still the panel tabs overflow while the extra tabs keep that width. The host fits its
own tabs into the rect it gets. An icon is an image (`icon`), or drawn in code by `paint_icon`, which
gets a painter, the icon rect and the tint color and returns true when it painted. Panel bodies keep a stable egui id when they move, so widget memory
survives.
`DockOutput` also has:
- `focused`: the panel the user focused this frame: a clicked tab, a drop, or a press of any
pointer button in a panel body (protected panels included) or in the group header,
which focuses the group's shown panel. Commands, `raise_panel` and
workspace resets never set it; a host that tracks the last focused panel clears it when a reset
or command hides, closes or moves that panel.
- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (`maximize_item`) and
`DockEvent::ToggleAutoCollapse` and `DockEvent::FlyoutChanged { panel }` (docks): the
flyout opened, switched or closed (`None`). It comes once per change, whatever made it: an
icon, Escape, a press outside, a raised panel, `open_flyout` or `close_flyout`.
- `error`: a rejected gesture.
Builder options on `DockArea`:
| 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 a panel's floating window, or open its flyout, this frame. It moves no keyboard focus and does not set `DockOutput::focused`. |
| `locked(on)` | Draw `Workspace::locked`: every panel gets `Permissions::locked`, so nothing starts a move, float or close the workspace would refuse. |
| `blocked_rects(rects)` | App overlays that docking gestures must ignore. |
| `float_bounds(rect)` | Where floating windows, flyouts and drag ghosts may go (default: the whole viewport). Pass the viewport below a title bar drawn under a transparent OS title bar, so no floating window sits where a press moves the app window. |
`DockStyle::close_menu_items` and `DockStyle::close_button` hide the built-in Close menu items
and the floating window ×. They hide controls only: a panel that stays `closable` still closes
by the app's own Close items and commands.
`DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome (any control a
`Hit` names) rather than on a panel body. Focus follows only the user's input: a tab keeps keyboard focus until a command or a
workspace reset hides, closes or moves its panel, and then gives it up. A divider or a floating
window's resize grip holds focus only while the pointer drags it, so the app's shortcuts and
canvas arrow keys work right after; one reached with Tab or F6 keeps its focus, and arrow keys
resize it. Hovering focuses nothing. `begin_drag` hands a drag that started in the app's own chrome to the area.
`docking::chrome` has the floating frame, the title bar and `drop_preview` (the drag ghost
and region preview docking and `craft_ui::panes` both draw) for apps that draw their own windows.
## Tab strips
Tabs start at their natural width, wide enough for the panel's name. When the strip is too
narrow, the widest tabs shrink first, to a common width; tabs whose names fit that width keep
their natural width. A tab narrower than its name cuts the name with "…" and shows it in full as a
tooltip. Shrunk tabs keep their hit areas, drag and keyboard behaviour, and accessibility labels
with the full names.
No tab shrinks below `DockStyle::min_tab_width`: by default about three characters and the "…"
plus the tab's padding, 54 pt at the 12 pt font. Only when the tabs at that width do not all fit
do the rest go to the "…" button at the end of the strip, whose menu lists the hidden panels;
choosing one makes it active. The active tab is always shown, in its logical place, and the
others take the remaining room in their order. An active tab with less room than its minimum
width, or a lone tab wider than the strip, is cut the same way. The drag bar always keeps its
reserved width: tabs shrink, then overflow, before it narrows, and only a strip narrower than the
group header plus the bar shrinks it. A tab dropped on the "…" button goes to the end of the group.
## Floating size
A panel or group floated by a drag, a menu or a command without a rect takes its content height:
the title bar, the tab strip and the height the active panel's content used when it was last
drawn (the content ui's `min_rect`), at least 120 points. The most it takes is the height of
`DockContent::floating_size`, or 320 points for a panel leaving a dock or frame and a group
leaving a dock. A short panel therefore floats as a short window. Content that reaches the bottom
of its body (a panel that allocates its whole rect) has no height of its own: it floats at the
`floating_size` height, else at 320 points. A panel's width is its `floating_size` width, else
its docked width; a group keeps its docked width.
A flyout fits its content the same way, without the title bar.
- A panel the area has not drawn floats at the `floating_size` height, else the docked height
capped at 320, or at a `FloatSpace::with_panel_size` size for commands.
- A group from a frame without a `floating_size` keeps its frame size. A dock column of several
groups floats at the tallest `floating_size` height, else its column height capped at 320.
- A rect from a command and a window the user resized keep their size; floating windows are not
refitted while open.
- Docked layout does not change: a column's last group still fills the column.
## 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;
with `title_bar_docks` a release over a target docks the whole window there, else it only
moves. While the drag highlights a drop target, the window is drawn at 75% opacity so the
target shows through it; over empty space, and once the drag ends, it is opaque. A native
window moved by its only tab or tab row fades the same way.
- Inside a floating window, tabs and drag bars drag and dock as they do in the docked layout,
into any window. A drag bar or border of a window's only group takes the whole window along,
so, like the title bar, it looks through the window: the groups, docks and windows behind it
take the drop while the pointer is still inside it. In a window of several groups, the other
groups stay targets. Its groups split, join and resize like frames, with dividers and four-way
handles (`Divider::WindowSplit` and `Divider::WindowIntersection`, named by the window's first
panel). Double-clicking the title bar minimizes the window to its title bar, or to the tab strip
of its only group; so does double-clicking that group's strip. The group menu's float item
floats one group of a window that has several. Closing the window closes its panels; each
reopens where it was while the window or its neighbour in it is open, else in a window at the
window's last rect.
- A movable floating window resizes from every edge and corner. The top edge and corners are a
4 pt band above the title bar (`chrome::RESIZE_BAND`), so they take no moves and no clicks on
the title bar's buttons. A resize keeps the opposite edges in place and stays within the size
limits and the screen. A focused resize handle moves its edge 8 pt per arrow key.
- Over a group (a frame, a dock column's group or a floating group), the pointer's place
decides:
- The tab strip joins the group's tabs at the insertion slot under the pointer.
- In the body below the strip, the outer `DockStyle::side_zone_fraction` (one third) of its
width from the left or right edge, or of its height from the top or bottom edge, docks
beside the group on that side. Where two sides meet, at a corner, the side the pointer is
relatively nearer wins.
- The middle third joins the group's tabs.
- A group that takes no tabs, such as a protected canvas, takes nothing on its strip, and its
body has no middle: every point docks beside it on the relatively nearest side.
- In the docking profile (`new_dock_zones` without `side_edge_zones`), the body of a frame or
floating group that takes no tabs takes nothing, whatever its `accepts_splits`: a release
there is a release over no target, so a floating window stays floating and a docked panel
or group floats as `float_on_empty_drop` says. Only the bands along borders (below) dock
beside it, as in Adobe apps, whose document area is no drop zone. A dock column's group
keeps its sides.
- Beside a frame is a split of that frame. Beside a group in a dock column, the top and bottom
stack a new group above or below it, and the left and right make a new column on that side.
A group dragged to an edge of its own column, such as the window edge of the column it heads,
makes a new column there, as one of its tabs would; when it is the column's only group,
nothing changes.
- A frame too narrow or short for two halves of `DockStyle::min_pane` still splits when the
frames beyond it can give the room, as they do for a dragged divider. When they cannot, the
frame keeps its size and the new frame takes the width (or height) the moved panels have now,
at least its minimum, from the frames beyond the frame's divider on that side
(`Placement::SplitSized`). When those have no room either, that side joins the frame's tabs
instead, or takes nothing when the frame takes no tabs. The gap
between two frames is no target.
- Bands along borders come before the group zones. Each is centred on its border where the
pointer can reach both sides:
| Band | Drop | Field (default) |
|---|---|---|
| The seam between two stacked groups of a dock column | A new group between them | `seam_band` (28 pt) |
| Each edge of a dock column | A new column on that side | `column_edge_band` (28 pt) |
| The docked area's left or right edge without a dock, and an empty dock | A new dock | `new_dock_band` (28 pt) |
| The edge area's top and bottom in `drop_edges` (all four sides with `side_edge_zones`) | A new frame along that whole side, a quarter of the edge area | `edge_band` (28 pt, 8 pt with `side_edge_zones`; at most a quarter of the edge area) |
Where a band overlaps a group's outer third, both mean the same side. Without
`side_edge_zones`, a band reaches only 4 pt past its line into a tab strip that takes tabs;
the rest of the strip joins. The outer
edge band is offered only when every frame keeps its minimum.
- The edge area is the root, or the part between the docks. Without `side_edge_zones` it also
narrows to the canvas column: where neither side of a side-by-side split is a dock and only
one side holds a protected panel, that side. The top (bottom) band also narrows a split of
rows to its first (last) row when only that row holds the canvas, so a full-width row below
the canvas keeps the bottom band. Side panels in split frames then get no band across the
whole window, and the drop wraps only the canvas column. A drop on a narrowed band anchors
on the canvas, so `Placement::Edge` narrows the same way when applied; on a band that is not
narrowed it anchors on a panel that is not protected. The renderer tries the drop with its
own permissions and offers the band only when the drop wraps the panels the band shows.
`window.panel.dock` to an outer edge picks its anchor the same way, from the permissions
`apply` (or `to_actions`) gets and the area's `FloatSpace` (`with_side_edge_zones` before it
has drawn).
- `DockStyle::drop_edges` (a `Sides`, all four by default) lists which sides of the layout's
outer edge, and of a group that takes no tabs (a canvas) without `new_dock_zones`, take a
drop. There a canvas's whole body goes to the nearest listed side; with none listed it takes
nothing. In the docking profile the canvas body takes no drop and the outer left and right
edges are new docks, so only the outer top and bottom apply. Groups that take tabs keep all
four sides. A dock-column app whose palettes dock only at the left
and right, as in AutoCAD, sets `Sides::NONE` with `new_dock_zones` on. Not used with
`side_edge_zones`.
- With `side_edge_zones` (the split-tree arrangement), a frame's zones are a picture frame
instead: trapezoids along the four edges of the body below the tab strip, all four the same
depth, `docking::SIDE_EDGE_ZONE_DEPTH` (40 pt), or a quarter of the body's smaller side when
that is less, so every frame keeps a center. A dock column's groups take the same depth.
`drop_zone` and `resolve_drop` give these zones to hosts that draw their own frames.
- A host that resolves drops over its own groups calls `resolve_drop_by` with
`DockStyle::zone_rule()`, so its zones and previews match the dock area's in either profile.
`resolve_drop` always gives the split-tree zones.
- A dragged divider grows the frames that touch it on one side, up to their maximums. On the
other side the frame touching it shrinks to its minimum, then the next frame along the same
axis, and so on: the divider stops when every frame on that side is at its minimum. Frames
side by side across the axis shrink together, and the next frame gives way once one of them
is at its minimum. The line of frames ends at a split across the axis or at a dock; every
other frame keeps its size. Dividers between stacked panels and between the groups of a dock
column work the same way. Where a divider ends on another divider, a four-way handle
(`Divider::Intersection`) moves both.
- Every frame has the same minimum, `DockStyle::min_pane` (plus its tab strip), unless its panels
ask for more through `PanelLimits::min`. Frames have no maximum unless every panel in them asks
for one through `PanelLimits::max`: a panel without a maximum lifts the maximum of the panels it
joins.
- A dock column stacks its groups top to bottom. A minimized group shows only its tab strip, and a
group the user resized keeps that `Group::height`. A group the user never resized (`height` is
`None`) takes the default height its panels ask for through `DockContent::dock_heights`. Groups
whose panels ask for a `fill` height share the rest of the column, and groups whose panels ask
for no heights take their minimum. When no group asks for a fill, the groups without heights
share the rest, and when every open group has a height, the last one fills the column. In a short column the groups at their default give way first, down
to their `compact` heights, so the filling group gets its `fill` height. When the column is still
too short, every group gives way down to its minimum, and past that the column scrolls. Defaults
give way nearest the filling group first: one is at its compact height before the next gives
way. Towards the minimums, groups give way in proportion to their room. A group takes the
largest default, compact and fill height of its panels. All three are body heights, without
the tab strip, like `PanelLimits`.
- Drop previews have four shapes, the same for tab and group drags:
- A drop beside something shows the rect the dragged panel, group or column will take, at its
real position and size, as a 2 pt accent outline with a light fill. The renderer applies
the drop to a copy of the layout and lays it out with the code that draws it, so the rect
is the one the panel's group gets on release: the new frame for a split (including
`Placement::SplitSized`), the new group for a stack above or below a dock group or on a
seam, and the whole column, bar included, for a new column between columns or beside a
dock group's side.
- A drop on a band along the layout's outer edge shows a `DockStyle::drop_line_width` (4 pt)
accent line along that edge: the new-dock band, the window edge of a dock's outermost
column, and the outer edge band (`Placement::Edge`), whose line spans the edge area it
wraps. A drop between the icon groups of an iconic column shows the same line there.
- A drop that joins a group shows the same outline and fill around the whole group, and on
its strip the 2 pt insertion marker.
- A drop that floats shows the floating rect.
A drop that would leave every panel where it is, changing only sizes, shows nothing and
changes nothing: a lone group dropped beside the group it already borders, or a frame's tab
on the layout edge the frame already holds. Split sizes, column widths, group heights and
window rectangles are not compared, and a run of splits along one axis is the same however
it nests.
A group drag also shows its ghost at the pointer. With `side_edge_zones`, a drag over a group
instead outlines each of its zones that a drop would change something in (the side
trapezoids, the center rectangle and the tab strip; `zone_shape` gives the outlines) and
fills the zone under the pointer. Sides the group does not take, by its permissions or
because a split leaves no room, are not drawn; nor is the center of the group a tab comes
from. A drop there that the zones do not show, such as the outer edge band, fills the region
it takes, and dock drops show the line along the edge they dock to. Escape or losing window focus cancels a drag.
- A drag in progress (a tab, group, column, window bar, native window bar or divider, in any of
the app's windows) also ends when the layout passed to the area is not the one it drew last
frame, with or without its returned actions applied: the app switched workspaces, ran Reset
or changed the layout by command. Sizes and rectangles are not compared, so an app may clamp
a moved window or adjust a split during the drag. Nothing is restored from before the drag;
windows go where the new layout has them. `cancel_drag` ends the area's drags at any other
time. A native window moved by the compositor (`ViewportCommand::StartDrag`, as on Wayland)
is not the area's drag: it ends when the user releases the button. On macOS, where AppKit
moves a native window by its bar, a cancel ends the area's drag, so the release drops
nothing, but AppKit keeps moving the window until the release.
- With `float_on_empty_drop`, a release over no target floats what moves at the pointer, such
as a panel dropped on a protected canvas. Without it, that release cancels the drag and
nothing moves: the gap between two groups, a menu bar or a status bar is no target. A release
outside the app window floats in both cases.
- Ctrl (⌘ on macOS) held at the drop floats it wherever it lands. With `command_press_floats`,
the key held when a tab drag of a frame starts is enough. With `float_hint`, a hint under the
pointer names the modifier while a drag offers a docking target: `float_hint_label`, where
`{key}` becomes ⌘ or Ctrl.
- Double-click a tab or drag bar to minimize a group to its strip.
- Right-click a tab for its menu, or a drag bar for the group menu; Enter or Space on a focused
drag bar opens it too. A left click on the drag bar only focuses it.
- With `DockStyle::wheel_selects_tabs`, the mouse wheel over a tab strip selects the next or
previous tab. With `middle_click_closes_tabs`, a middle click on a closable panel's tab closes
it.
- 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 an icon group's top edge (the separator line above it) 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 space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect()));
let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |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. `left`, `right`, `top` and `bottom` follow
`Placement::for_zone`, so beside a docked panel they make a new column or group. `apply` and
`to_actions` take the layout's `right_to_left` for that.
- `group` moves the panel's whole group; `column` moves its dock column, or its whole window when
it floats. `side` is `Start` or `End`: what moves becomes a new last column of that dock, or
a new dock; when it already is that dock's last column, the command does nothing.
- `window.panel.dock` with `left`, `right`, `top` or `bottom` and no `anchor` or `side` makes a
new frame on that outer edge (`Placement::Edge`), wrapping what a drag to that edge would
(see the edge area above). A hidden panel is shown first.
- Showing a panel (`activate`, `dock` without a destination, `ui.dock` `open`) restores a hidden
panel's place; a panel never laid out opens as `Open` without an anchor does.
- `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see
Floating size). It goes
inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar.
The area records that geometry in a `FloatSpace` each frame; `float_space(ctx, id)` reads it
and `apply` and `to_actions` take it. Before the area has drawn, `FloatSpace::new(area)` floats
a panel at 260x320 and a group or column at its column width by 320.
`FloatSpace::with_panel_size(panel, size)` gives a panel the area has not drawn (a hidden
panel, a headless run) the host's own size; a size the area recorded wins. `x`, `y`, `width` and
`height` replace the matching values, and a group or column that is already a whole floating
window keeps its rect.
- 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.
## Native windows
With feature `native-windows`, on a backend with egui viewports (`!ctx.embed_viewports()`,
eframe on desktop), every floating window opens in its own OS window: a Ctrl/Cmd-drag, Undock
Panel and Undock Panel Group, every other drop that floats, and the floating windows of a
restored layout. A window still in-window opens over its place in the app window (the main
viewport's `inner_rect.min` plus its rect). A window with a panel whose
`DockContent::allow_native` returns false (it returns true unless the app overrides it), or
that is not floatable, stays in-window. Floating windows get no taskbar or Alt-Tab entry
(`with_taskbar(false)`).
On Wayland floating windows stay in-window: the compositor reports no window positions, so a
native window could not dock again by drag or reopen where it was. Their `native` rects are
kept, and the same layout opens native windows on X11, macOS and Windows. The dock area takes
Wayland to be the backend when `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (winit's rule) and
the main window has never reported its `outer_rect`; an app that forces X11 in code gets native
windows from the first frame. `DockArea::native_windows(bool)` overrides the choice either way.
- **Identity.** `Floating::id` names the OS window, so a window keeps it when its panels change,
including when its first panel moves out. `Floating::new` draws a fresh id and
`Floating::with_id` sets one. A saved layout stores every window's id and loads with unique
ids: a window saved without an id, or with one an earlier window took, gets a fresh one. A
window that merges into another takes the receiver's id, and a whole window that moves
(floated again, or its only group dragged) keeps its own.
- **Kind and level.** `DockContent::window_kind` sorts panels into palettes
(`WindowKind::Palette`, the default) and viewers (`WindowKind::Normal`), such as a video
monitor on a second display. A window holding any `Normal` panel is a normal window, whichever
of its tabs shows; the rest are palettes.
- Palettes stay above other windows (`WindowLevel::AlwaysOnTop`), so clicking the document
does not bury them. They stay shown while the app is in the background, so files dragged
from another app reach them and an OS sheet attached to one stays open. While none of the
app's windows has focus they take `WindowLevel::Normal`, so they do not cover other apps
(on Windows `WindowLevel::AlwaysOnBottom`: there `Normal` would put them in front of the
app the user switched to), and they go back to `AlwaysOnTop` when one has it again. The app counts as in the
background after 250 ms without focus, so focus moving between its own windows changes
nothing. The level is sent only when it changes.
- Normal windows have `WindowLevel::Normal` and stay shown while the app is in the
background. They stack with the main window by focus, below every palette; a drag over a
normal window that lies behind the main window targets the main window where the two
overlap, and the normal window outside the main window.
- Like palettes, normal windows have no taskbar entry; they come forward with the app
instead. When one of the app's windows gains focus after none had it for 250 ms,
the main window and each normal window get `WindowLevel::AlwaysOnTop` and then
`WindowLevel::Normal`, one window per frame, in their focus order from back to front.
They keep their order, nothing takes focus, and the window the user activated is raised
last, so it stays in front. The focus order counts a press in the main window as focusing
it, since the window system raises it then. The main window is left at the normal level,
so an app that keeps its main window always on top should not use normal windows. On
Windows the toggle puts the window in front of every window that is not topmost, without
activating it; on X11 the window manager raises a window as it takes
`_NET_WM_STATE_ABOVE`. egui 0.36 cannot make the main window a native window's owner,
which on Windows would bring the window forward with the main window, and
`ViewportCommand::Focus` would take focus. On
macOS nothing is raised: activation from the Dock or the application switcher brings all
of the app's windows forward, and a click brings the clicked window alone, as with the
app's other windows.
- **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style:
the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On
macOS the press on the bar hands the move to AppKit, and on Wayland, where a window cannot
be placed, the bar hands the move to the compositor. On
Windows and Linux the OS frame is off. On macOS the frame stays, with its title bar and
buttons hidden, for its resizing, rounded corners and standard window shadow. The dock area
paints no shadow of its own around a native window.
- **The ×.** Its place is set in `render/native.rs` (`MACOS_CLOSE`, `OTHER_CLOSE`): the
glyph's inset from the top and trailing edges, its size, and how far the hit area reaches
past the glyph on each side. On macOS the 9 pt glyph sits where macOS puts the close button
of its own windows: centered in the bar, its trailing side 8 pt in. AppKit's corner resize
area covers the glyph's trailing half and takes presses there, so the hit area reaches 10 pt
further toward the leading edge and down to the bar's bottom. Elsewhere the × is an 18 pt
square, its trailing side 7 pt in. With `CRAFT_DOCK_DEBUG_CLOSE=1` in the environment,
native windows outline the ×'s hit area in red and, on macOS, AppKit's resize areas in
orange.
- **Resize.** A movable window resizes from its edges and corners. On macOS AppKit does it,
with its own areas and cursors. Its corner area reaches about 15 pt into the window along
each edge (a 19 pt square, measured on macOS 26) and takes presses before the content sees
them. Over AppKit's areas the window sets no cursor of its own, which would flicker with
AppKit's. Elsewhere the window resizes from bands 6 pt deep along its edges, and each corner
is an L along both edges, 16 pt long and as deep as the edges. The bands leave out the bar's
buttons, so the corner's grab runs down beside the ×; elsewhere the top band lies over the
bar's drag area. The OS runs the resize from a band (`ViewportCommand::BeginResize`).
- **Close.** The bar's × and the OS close request close the window's panels, as the
floating window's × does. They reopen from the app's Window menu. A window with a panel that
may not close, or in a locked area, has no × and answers the OS close request with
`ViewportCommand::CancelClose`.
- **Place.** `Floating::native` is the desktop rect of the window's content (egui's
`inner_rect`), and the in-window `rect` follows it for builds that draw the window in-window.
The content is also the window's origin for drags; only `ViewportCommand::OuterPosition`
takes the outer position, which the renderer works out from the reported outer and inner
rects. Where the window system puts a new window's frame, not its content, at the place asked
for, the window is moved once so its content is there. When the app changes `native` (undo,
a workspace reset, a reloaded layout whose ids match), the OS window moves there; a report
that differs from `native` while a commanded move has not landed (up to 0.5 s) is ignored. `Floating::monitor` is the monitor
holding the window's center, from the list the app passes to `DockArea::monitors` (each a
`Monitor { name, rect, scale }` in desktop logical points). egui 0.36 reports no monitors, so
an app fills the list from its backend when it can; without one, `Floating::monitor_size`
keeps the monitor size egui reports.
- **Restore.** Only the windows of a loaded layout (a `native` the session has not shown yet)
are restored. A window made this session (a drag out, Undock, a Ctrl/Cmd drop) opens at the
rect it was given, only made safe, wherever that is on the desktop. A restored window opens on
its saved monitor when one with that name (or else that rect) is connected, moved in
proportion if the monitor's rect changed, and moved onto it when too little of its bar lies on
a monitor to grab (40 by 9 points). Otherwise it opens on the main window's monitor at the
same offset from that monitor's origin, wholly on screen. Without a monitor list, it is
clamped onto the main window's monitor, taken from `monitor_size` and the main window's
position. Hostile rects are made safe first. With a monitor list, a window whose `native` the
app changes, or whose bar drag ends with too little of the bar on a monitor, goes onto the
nearest monitor too.
- **Mixed scale factors.** Rects are stored in egui points as the window reports them, and
`Monitor::scale` is kept but not used. On macOS every window shares one point space, so this
is exact. On Windows egui-winit divides each window's physical position by that window's own
scale factor, so with monitors at different scales (say 100 % and 150 %) the points of windows
on different monitors are in different units. Across such monitors, a restored or
app-changed place can land off by the ratio of the scales, and a drop across windows on
different monitors can resolve against the wrong point. Same-scale setups are unaffected.
- **Shortcuts.** egui gives each OS window only its own input. Key, text, copy, cut and paste
events that reach a native window and that none of its widgets used come back in
`DockOutput::forwarded_events`, with the modifiers held in that window in
`DockOutput::forwarded_modifiers` (egui-winit sends Cmd/Ctrl+C, X and V as `Copy`, `Cut` and
`Paste` without the key). The app feeds them to the shortcut handling it runs on the main
window. A window where any widget has keyboard focus forwards nothing, and an Escape that
cancels a drag is not forwarded.
- **Input methods.** egui's IME descriptor (`PlatformOutput::ime`) goes out from the pass of
the window whose editor has the keyboard. A `craft_ui::ime::Composer` in a panel body of a
native window records its descriptor while the window draws, and the dock area publishes it
once per pass, after the window's last body. A body drawn after the editor's cannot clear it
(egui-winit turns the IME off in a pass without one, ending the composition). A descriptor
written directly in that pass, as a focused `TextEdit` writes one, is kept. An app whose own
editor publishes the descriptor does it in `DockContent::window_drawn`, called once per
window pass after the last body. Floating windows drawn in the main window (the web,
Wayland) are part of its pass, which publishes as it does without docking.
- **Modal dialogs.** egui's `Modal` blocks input only in the viewport it is shown in. While the
app shows one, it passes `DockArea::input_blocked(true)`. Each native window is then drawn
under `DockStyle::blocked_dim` (by default the panel fill at 60 %), and its bar, tabs, body
and resize edges take no input: its widgets are disabled, the pointer, key, text and scroll
events are dropped at the start of its frame, and a layer over its content takes the pointer,
so a popup left open takes no click either. It forwards only key releases, so a key pressed
before the block does not stay down in the app's shortcuts. It reports no `window_files`,
offers no drop targets, starts no move or resize, is not focused by `raise_panel`, and
answers the OS close request with `ViewportCommand::CancelClose`. On macOS it is not
resizable meanwhile.
- *Stacking.* A palette drops to `WindowLevel::Normal` while blocked, so a click on the main
window brings it in front of a palette that covers the modal. It stays shown and at that
level when the app goes to the background and returns. When the app is activated meanwhile, the
main window is raised last, in front of the normal windows and of a palette the user
clicked.
- *Edges.* Turning blocking on cancels a drag in progress, and a widget of a native window
loses keyboard focus. If a native window has the OS keyboard focus then (the modal was
opened from it, say by a double-click in a floating panel) and still has it 0.2 s later,
the main window gets `ViewportCommand::Focus`, so the modal gets the keys. The wait lets an
OS dialog opened with the modal (a file dialog) take focus first; then nothing is sent. A
zoom gesture still shows in egui's `InputState::zoom_delta`, which cannot be cleared; a
widget that zooms only while hovered ignores it. egui smooths wheel
scrolling, so a scroll area in a native window may still scroll for about 100 ms after
blocking ends, with the remainder of a wheel turn made just before it began.
- Floating windows drawn in the main window need nothing: the modal covers them.
- **Input hook.** eframe runs `App::raw_input_hook` for the main window only. An app that
rewrites input there (say, Ctrl+click to a secondary click on macOS, or blocking input
during automation) passes the same rewrite to `DockArea::native_input_hook` every frame as a
closure. eframe's hook takes `&mut self`, so state it shares with the closure goes in an
`Arc<Mutex<…>>`. The dock area runs it on each native window's `RawInput` (`viewport_id` names the window; see
`DockArea::window_viewport`). It runs as an egui `Plugin::input_hook`, before egui reads the
input, so widgets see rewritten pointer and key events as if the window system had sent them.
It runs once per pass, so a frame that egui runs twice calls it a second time with the
events already taken. The hook must be `Send + 'static`, as egui plugins are.
- **Dropped files.** Files dragged over or dropped on a native window come back in
`DockOutput::window_files`, one `WindowFiles` per window that had any this frame: the
window's id and viewport, the panel whose body is under the pointer (else the window's
shown panel, since the window system may send no pointer moves during a file drag), the
pointer's last position in the window, and the window's `hovered_files` and
`dropped_files`. Files on the main window stay in its own `RawInput`.
- **Drags.** Tabs, groups and whole windows drag between the app's OS windows, and the app's
own drags in panel bodies reach the window under the pointer.
- *Ownership.* The window where the button went down owns the drag: the window system sends it
the pointer's moves and the release while the button is held, inside the window or not. So
the pointer leaving a window (`PointerGone`) is not a release. A drag ends on release,
Escape (pressed in any of the app's windows, since keys go to the one with keyboard focus),
when none of the app's windows has focus, or when its window closes or its panel goes. A
window that AppKit moves (macOS, below) ignores Escape.
- *Hit-testing.* The owner adds its desktop origin to the pointer: a native window's reported
`inner_rect.min`, or while it moves by its bar, the place it was last put less the pointer's
offset from the pressed point. On macOS a window moving by its bar is hit-tested at
AppKit's cursor (`NSEvent::mouseLocation`). The window under that
desktop point is a floating native window in front of the main window first (palettes, then
normal windows, most recently focused first), then the main window, then a normal window
behind it. A window moving by its bar is never its own target. A point in no window is on the
desktop.
- *Targets and previews.* Each window draws its drop targets in its own coordinates, and the
window under the pointer draws the drop preview (zone, result rect, ghost) in its own frame.
The source window shows only the "Hold {key} to undock" hint. The release is resolved at the
end of the main window's frame, after every native window has drawn.
- *Drops.* Over a window, a tab or group docks or joins there with the usual targets: dock lines
and frame zones in the main window, frame zones and tab rows in floating windows. Over the
desktop, it floats into a new OS window placed so the grabbed tab stays under the pointer
(the window's top-left is the pointer minus the grab offset, less the bar's height). A
Ctrl/Cmd-drag floats there wherever it is released.
- *Moving a window.* Dragging the bar, the only tab of a window, or the drag area of its only
group moves the window, so the pressed point stays under the pointer.
- *macOS.* AppKit moves the window (`ViewportCommand::StartDrag`, which winit turns into
`performWindowDragWithEvent`), as it moves its own windows: from the press on the bar,
or from the start of the tab or group drag. The dock area sends no `OuterPosition`
meanwhile. egui keeps running frames, but the window's reported place lags the move by up
to a tenth of a second, so each frame takes the window's place as AppKit's cursor
(`NSEvent::mouseLocation`, flipped against the primary screen's height and divided by the
zoom factor) less the pressed point, and hit-tests targets, fades the window and routes
the drag at the cursor. The drag ends on the first frame where AppKit
(`NSEvent::pressedMouseButtons`) or egui reports the release; egui's can come a frame
later, and the drag is resolved once. Ctrl/Cmd is read from AppKit's modifier flags,
since AppKit sends egui no key events during the move; for the same reason Escape does
not cancel it, as with Finder's windows. A press on the bar released within the click
distance docks nothing.
- *Elsewhere.* The dock area moves the window with `OuterPosition`. Each move is worked
out from a pointer move of that frame and the window's reported place, which the
pointer's position is relative to; a move commanded earlier may land late. A frame with
no pointer move leaves the window. X11 sends no move whose position in the window repeats
the last one, so while the pointer keeps exact pace with the window the window can trail
it by one move until the next. Escape puts the window back.
- *Drops.* Released over a target in another window, the whole window docks or joins there
as its groups (`Action::MoveWindow`). The bar docks only with
`DockStyle::title_bar_docks`; the only tab or group docks either way, since it is the way
back in with that flag off. Targets: in the main window a dock line, a frame's tab row or
edge zone, or the layout's outer edge; in a floating window a frame zone or tab row. A
frame body's center takes a window moved by its only tab or group, as that tab or group
drag would, but not one moved by its bar. Over the desktop, or with Ctrl/Cmd held, it
stays where it was moved, and the layout takes its place once, at the release. An
in-window floating window's title bar still splits only groups of other floating windows.
- *Wayland.* With `DockArea::native_windows(true)` on Wayland, or wherever `outer_rect` is
`None`, desktop positions are unknown, so drops across windows cannot be resolved. A drag
works inside its own window, and a release outside it floats a new window that the
compositor places. Moving a window hands the move to the
compositor, which never docks it.
- *App drags in panel bodies.* A drag the app starts in a panel body can reach the window
under the pointer, so a drop site there works as it does inside one window.
- *Which drags.* An egui drag of one of the app's widgets, pressed with the primary button
in a panel body, that carries something: egui's `DragAndDrop` holds a payload
(`dnd_set_drag_payload`), or the app calls `docking::route_app_drag(ctx, area_id)` each
frame while the drag lasts, from the body or before the area shows. An app that keeps its
payload in the context's data makes that call. Other drags (sliders, scrollbars, text
selection, canvas pans) stay in their window and keep getting its pointer. A call counts
for its frame and the next; once routing starts, it lasts until the drag ends.
- *Events.* While the drag lasts, the owner's pointer moves over another of the app's
windows (found as for tab drags, front window first) go to that window in its own
coordinates: `PointerMoved` while the pointer is over it, `PointerGone` when it leaves,
and the release as a primary `PointerButton` with `pressed: false`. That window sees
`pointer_hover_pos` and `any_released` as if the window system had sent them. It sees no
press, so `any_down` stays false there and nothing in it is clicked. The owner sees the
pointer gone while it is over another window, so its panels under a palette see no hover
and no drop.
- *Input path.* Events go in through the area's egui input plugin, before any widget reads
the input; the app calls nothing more. In a native window this runs before the app's
`native_input_hook`. eframe runs the main window's `App::raw_input_hook` earlier, so that
hook sees the main window's input without the routed events.
- *Release timing.* A window drawn after the owner in the same pass gets the events in that
pass (from the main window, every native window does). The main window, and a native
window drawn before the owner, get them on the next pass, which is requested. When the
release goes to another window, the owner gets its own release, and the pointer events
that came after it, in the main window's next pass (a rerun of a discarded pass does not
count), so its drag stops (`drag_stopped`) then. egui clears a `DragAndDrop` payload when the owner sees the
release, so the target always reads its release while the payload is there. An app that
keeps its own payload clears it no earlier than the owner's release.
- *Limits.* Nothing is routed where desktop positions are unknown (Wayland,
`native_windows(false)`, embedded viewports) or while input is blocked. Escape in any of
the app's windows, a focus loss, `cancel_drag` and a replaced layout stop the routing:
the window under the pointer sees it go and gets no release it has not read yet. The drag
goes on in its own window until the button comes up, and the owner still gets its
release.
On the web and wherever egui embeds viewports, nothing changes: floating windows stay in the
page at their in-window rect, `native` and the monitor fields are kept but unused, the input
hook never runs, and `forwarded_events` and `window_files` stay empty. Without the feature, the fields exist and are never set by the
renderer.
## Labels
Every visible and accessibility label is a `*_label` field of `DockStyle`, with an English
default: title case for menu items, sentence case for names only screen readers announce. `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.
`DockArea::hit_id(id, &hit)` is the egui widget id of a control, for tests and automation that
focus it or read its response. `DockArea::floating_layer(id, &window)` is a floating window's
egui layer: compare its place in `memory.layer_ids()` with another layer to tell whether the
window covers it, and read its rect with `memory.area_rect(layer.id)`. A window the area does not
draw (its panels hidden, or a frame maximized) keeps its last rect there, so check
`memory.areas().is_visible(&layer)` first.
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`). With `native-windows`, `with_native_host(origin)` stands in for
OS windows: the main window's content sits at `origin` on the desktop, and each native window
runs its own frame with its own input and desktop rect (`host()`, `window_step`,
`window_click`). `NativeHost` can report a monitor size (`monitor_size`), give windows a frame
between their outer position and content (`frame`), land `OuterPosition` a frame late (`lag`),
and report no positions (`no_position`, as on Wayland). `window_pointer` moves the pointer to a
desktop point in a native window. With `moves_windows` the host moves a window for
`StartDrag` itself, as AppKit does: the window keeps the pointer at the pressed point, reports
its place as where the drag began until the release, and the host tells the cursor and the
button (`held`; a test sets it to `false` to report a release before egui does). A `HostWindow` takes files dragged
over it (`hovered_files`) or dropped on it (`dropped_files`), and `with_native_input_hook`
passes a hook to `DockArea::native_input_hook` every frame.
`crates/ui/examples/docking_native.rs` (`cargo run -p craft-ui --example docking_native
--features native-windows`) shows a dock with two native floating windows and prints every
action and window place, for checking moves on a real window system.
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
```
An adopting app still tests its own commands, contents, saved workspaces and themes.
## What stays in the app
- 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.