37 KiB
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 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::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. |
new_dock_zones on |
Creative apps with a canvas and panel docks |
| A split tree of tab groups | Splits 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 Groups, 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).
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 Actions:
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,FloatandCloseact on one panel.MoveGroup,MoveColumnandMoveWindowmove a group, a dock column or a floating window, named by any panel in it.- A
TargetisAt { anchor, placement },Float { rect }orNewDock { side }. - A
Placementis a tab position, a split zone, a new group above or below (with an optional preferredheight), 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::Groupon 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, and the drop preview shows the half the new frame takes.
- Other actions select, minimize, collapse to icons, resize splits (
ResizeSplitin the docked layout,ResizeWindowSplitin the floating window holding a panel), columns and groups, and move a floating window to its own OS window (SetNative, andSetMonitorfor the monitor it is on, featurenative-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 beside fallback. |
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. 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_paneland 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 asDockEvent::ToggleMaximize(maximize_item) andDockEvent::ToggleAutoCollapseandDockEvent::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_flyoutorclose_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 drop preview
docking and craft_ui::panes both draw) for apps that draw their own windows.
Tab strips
Every shown tab carries its panel's full name. Tabs that do not fit 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: when it lies past the tabs that fit, it takes the place of the last of them. Only an active tab wider than the room beside the "…" button, or a lone tab wider than the strip, is cut with "…", and its tooltip names it in full. The drag bar always keeps its reserved width: tabs go to the overflow menu before it narrows, and only a strip narrower than the bar itself 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_sizeheight, else the docked height capped at 320, or at aFloatSpace::with_panel_sizesize for commands. - A group from a frame without a
floating_sizekeeps its frame size. A dock column of several groups floats at the tallestfloating_sizeheight, 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_docksa release over a target docks the whole window there, else it only moves. - Inside a floating window, tabs and drag bars drag and dock as they do in the docked layout,
into any window. Its groups split, join and resize like frames, with dividers and four-way
handles (
Divider::WindowSplitandDivider::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 frame, the tab strip and the middle join its tabs, and the nearest side splits it. The
side zones are trapezoids along the four edges of the body below the tab strip, like a picture
frame, all four the same depth:
DockStyle::zone_depth(40 pt), or a quarter of the body's smaller side when that is less, so every frame keeps a center. A frame too narrow or short for two halves ofDockStyle::min_panestill splits when the frames beyond it can give the room, as they do for a dragged divider; when they cannot, the drop joins the frame's tabs instead. The gap between two frames is no target. - The outer edge band (
DockStyle::edge_band) takes a drop as a new frame across the whole window side: top and bottom, and left and right withside_edge_zones. It takes a quarter of the window and is offered only when every frame keeps its minimum. Past the band, a frame's own side zones apply. - 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 throughPanelLimits::min. Frames have no maximum unless every panel in them asks for one throughPanelLimits::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 (heightisNone) takes the default height its panels ask for throughDockContent::dock_heights. Groups whose panels ask for afillheight 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 theircompactheights, so the filling group gets itsfillheight. 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, likePanelLimits. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock,
a filled half for a split. 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_shapegives 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. Escape or losing window focus cancels a drag. - 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. Withfloat_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. Withmiddle_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.
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, …} |
zoneistab,center,left,right,top,bottom,aboveorbelow.beforenames the tab to insert before;nullappends.left,right,topandbottomfollowPlacement::for_zone, so beside a docked panel they make a new column or group.applyandto_actionstake the layout'sright_to_leftfor that.groupmoves the panel's whole group;columnmoves its dock column, or its whole window when it floats.sideisStartorEnd: 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.dockwithleft,right,toporbottomand noanchororsidemakes a new frame on that outer edge (Placement::Edge). A hidden panel is shown first.window.panel.floatwithout 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 aFloatSpaceeach frame;float_space(ctx, id)reads it andapplyandto_actionstake 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,widthandheightreplace the matching values, and a group or column that is already a whole floating window keeps its rect.- Without a value,
minimized,iconicandlockedtoggle. ui.dockoperations are those of LightCraft, PDFCraft, CADCraft and SoundCraft:open,close,activate,float {rect},moveFloating {rect},move {target, zone?, before?},setStackOpen {open},resizeStack {height}andresizeSplit {path, size}.rectis[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)).
- Identity.
Floating::idnames the OS window, so a window keeps it when its panels change, including when its first panel moves out.Floating::newand loading draw fresh ids, andFloating::with_idsets one; a layout loads with unique ids; 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. - Level. The windows stay above other windows (
WindowLevel::AlwaysOnTop), so clicking the document does not bury them. While none of the app's windows has focus they hide (ViewportCommand::Visible(false)), and they come back when one has it again. The hide waits 250 ms, so focus moving between the app's own windows does not flicker. - 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 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. WithCRAFT_DOCK_DEBUG_CLOSE=1in 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::nativeis the desktop rect of the window's content (egui'sinner_rect), and the in-windowrectfollows it for builds that draw the window in-window. The content is also the window's origin for drags; onlyViewportCommand::OuterPositiontakes 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 changesnative(undo, a workspace reset, a reloaded layout whose ids match), the OS window moves there; a report that differs fromnativewhile a commanded move has not landed (up to 0.5 s) is ignored.Floating::monitoris the monitor holding the window's center, from the list the app passes toDockArea::monitors(each aMonitor { 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_sizekeeps the monitor size egui reports. - Restore. Only the windows of a loaded layout (a
nativethe 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 frommonitor_sizeand the main window's position. Hostile rects are made safe first. With a monitor list, a window whosenativethe 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::scaleis 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 inDockOutput::forwarded_modifiers(egui-winit sends Cmd/Ctrl+C, X and V asCopy,CutandPastewithout 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. - Drags. Tabs, groups and whole windows drag between the app's OS windows.
- 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. - 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. The window under that desktop point is a floating native window first, most recently focused first, then the main window. 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, dock line, 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. 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. Released over a target in another window, the whole window docks or
joins there as its groups (
Action::MoveWindow). The bar docks only withDockStyle::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. Escape puts it back. An in-window floating window's title bar still splits only groups of other floating windows. - Wayland. Where
outer_rectisNone, 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.
- 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 (
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, and
forwarded_events stays 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:
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.
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).
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_DIRto keep the captures:
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.