From 0ae7cddbb5fbf97aba98961873a96facb9a296cb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:35:13 +0000 Subject: [PATCH] docking: docks, columns, groups and floating stacks in the layout model Node::Dock holds columns of groups on a logical window edge (Side::Start or Side::End). Floating windows hold a stack of groups and an optional native rect; the v1 {panels, active, rect} shape still parses, and a window with one plain group writes it. Placement gains Group and Column, Target names where a group, column or window goes, and the new actions move, close, minimize, collapse and resize groups, columns and windows atomically. Location records group and column state so a panel closed from a dock or floating stack restores to the same group, seam, column or dock. Layout gains apply_all, closed_by and panel_state; Permissions gains locked. Node, Action, Placement and DockError are non_exhaustive. The renderer draws each floating window's groups stacked and a dock's columns side by side until the dock and floating-stack profiles land. --- crates/ui/src/docking/dock_model.rs | 573 +++++++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 2 + crates/ui/src/docking/model.rs | 572 +++++++++++++++++++++++--- crates/ui/src/docking/surface.rs | 41 +- crates/ui/src/docking/tests.rs | 32 +- crates/ui/tests/docking_workflows.rs | 4 +- 6 files changed, 1140 insertions(+), 84 deletions(-) create mode 100644 crates/ui/src/docking/dock_model.rs diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs new file mode 100644 index 00000000..d4200fe3 --- /dev/null +++ b/crates/ui/src/docking/dock_model.rs @@ -0,0 +1,573 @@ +//! Docks, columns, groups and floating stacks: their types and the structural edits behind +//! group, column and window moves. +//! +//! A group, column or floating window is named by a panel it contains. Edits run on a proposed +//! copy of the layout, so a helper that fails part-way leaves nothing behind. + +use std::hash::Hash; + +use super::model::{DockError, Floating, Layout, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width}; +use crate::layout::{SplitAxis, SplitSize}; + +/// Width of a new column when nothing better is known. +pub(super) const DEFAULT_COLUMN_WIDTH: f32 = 240.0; +/// Rect of a new floating window when the caller gives none and the source has no size. +const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0]; + +/// The window edge a [`Node::Dock`] sits on, in reading order so right-to-left interfaces +/// mirror. `Start` is the edge of a horizontal split's first child (left in a left-to-right +/// layout); the renderer maps it to a physical edge from the layout direction. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Side { + Start, + End, +} + +/// Tabs that move, minimize and size together, in a dock column or a floating stack. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(bound(deserialize = "P: serde::Deserialize<'de>")))] +pub struct Group

{ + pub panels: Vec

, + pub active: usize, + /// Only the tab strip shows. `height` is kept for when the group expands again. + #[cfg_attr(feature = "serde", serde(default))] + pub minimized: bool, + /// Preferred height in points; `None` shares the free height of the column or stack. + #[cfg_attr(feature = "serde", serde(default))] + pub height: Option, +} + +impl

Group

{ + pub fn new(panels: Vec

, active: usize) -> Self { + Self { panels, active, minimized: false, height: None } + } + + pub fn active_panel(&self) -> Option<&P> { + self.panels.get(self.active) + } +} + +/// One column of a dock: groups from top to bottom. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(bound(deserialize = "P: serde::Deserialize<'de>")))] +pub struct Column

{ + pub groups: Vec>, + /// Expanded width in points. + pub width: f32, + /// Collapsed to one icon per panel. + #[cfg_attr(feature = "serde", serde(default))] + pub iconic: bool, + /// Width while iconic; labels show beside the icons when it is wide enough. + #[cfg_attr(feature = "serde", serde(default))] + pub iconic_width: Option, +} + +impl

Column

{ + pub fn new(groups: Vec>, width: f32) -> Self { + Self { groups, width, iconic: false, iconic_width: None } + } +} + +/// Where a group, column or floating window goes. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Target

{ + /// Relative to the panel `anchor`, which must not be part of what moves. + At { anchor: P, placement: Placement

}, + /// A new dock on an edge that has none (or an empty one). The root becomes the other side + /// of a horizontal split. + NewDock { side: Side }, + /// A new floating window. `None` keeps the source's size at a default position. + Float { rect: Option<[f32; 4]> }, +} + +/// What the Window menu shows for a panel. The model does not track the z-order of floating +/// windows, so `Front` means "showing": the active tab of an expanded group in an expanded column. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelState { + Hidden, + Open, + Front, +} + +/// The group a closed panel was in, when that group was in a dock or a floating stack. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GroupLocation { + pub minimized: bool, + pub height: Option, + /// The column, when the group was docked. + pub column: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ColumnLocation { + pub side: Side, + pub width: f32, + pub iconic: bool, + pub iconic_width: Option, +} + +/// Where a panel is. Paths lead from the root: false is a split's first child, true its second. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Place { + /// A `Tabs` or `Stack` node. + Node(Vec), + Dock { + path: Vec, + column: usize, + group: usize, + }, + Floating { + window: usize, + group: usize, + }, +} + +/// Which container a whole-container action names. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Unit { + Group, + Column, + Window, +} + +/// Groups in transit, with the size and state of the container they came from. +pub(super) struct Moving

{ + pub groups: Vec>, + pub width: Option, + pub iconic: bool, + pub iconic_width: Option, + pub rect: Option<[f32; 4]>, + pub native: Option<[f32; 4]>, +} + +impl

Moving

{ + pub fn group(group: Group

) -> Self { + Self { groups: vec![group], width: None, iconic: false, iconic_width: None, rect: None, native: None } + } + + fn into_column(self, width: f32) -> Column

{ + Column { groups: self.groups, width: self.width.unwrap_or(width), iconic: self.iconic, iconic_width: self.iconic_width } + } +} + +pub(super) fn node_at<'a, P>(mut node: &'a Node

, path: &[bool]) -> Option<&'a Node

> { + for second_child in path { + let Node::Split { first, second, .. } = node else { return None }; + node = if *second_child { second } else { first }; + } + Some(node) +} + +pub(super) fn node_at_mut<'a, P>(mut node: &'a mut Node

, path: &[bool]) -> Option<&'a mut Node

> { + for second_child in path { + let Node::Split { first, second, .. } = node else { return None }; + node = if *second_child { second } else { first }; + } + Some(node) +} + +/// Insert `new` at `index` and select the member at `index + selected`. +fn splice

(panels: &mut Vec

, active: &mut usize, index: usize, new: Vec

, selected: usize) { + let index = index.min(panels.len()); + let count = new.len(); + panels.splice(index..index, new); + *active = index + selected.min(count.saturating_sub(1)); +} + +impl Layout

{ + pub(super) fn place(&self, panel: &P) -> Option { + for (w, window) in self.floating.iter().enumerate() { + if let Some(g) = window.groups.iter().position(|group| group.panels.contains(panel)) { + return Some(Place::Floating { window: w, group: g }); + } + } + let mut visit: Vec<(&Node

, Vec)> = self.root.iter().map(|node| (node, Vec::new())).collect(); + let mut visited = 0usize; + while let Some((node, path)) = visit.pop() { + visited = visited.saturating_add(1); + if visited > MAX_NODES { + return None; + } + match node { + Node::Split { first, second, .. } => { + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + visit.push((second, second_path)); + visit.push((first, first_path)); + } + Node::Tabs { panels, .. } if panels.contains(panel) => return Some(Place::Node(path)), + Node::Stack { entries } if entries.iter().any(|e| &e.panel == panel) => return Some(Place::Node(path)), + Node::Dock { columns, .. } => { + for (c, column) in columns.iter().enumerate() { + if let Some(g) = column.groups.iter().position(|group| group.panels.contains(panel)) { + return Some(Place::Dock { path, column: c, group: g }); + } + } + } + Node::Tabs { .. } | Node::Stack { .. } => {} + } + } + None + } + + pub(super) fn dock_path(&self, side: Side) -> Option> { + let mut visit: Vec<(&Node

, Vec)> = self.root.iter().map(|node| (node, Vec::new())).collect(); + let mut visited = 0usize; + while let Some((node, path)) = visit.pop() { + visited = visited.saturating_add(1); + if visited > MAX_NODES { + return None; + } + match node { + Node::Split { first, second, .. } => { + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + visit.push((second, second_path)); + visit.push((first, first_path)); + } + Node::Dock { side: s, .. } if *s == side => return Some(path), + _ => {} + } + } + None + } + + pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec>> { + match self.root.as_mut().and_then(|root| node_at_mut(root, path)) { + Some(Node::Dock { columns, .. }) => Some(columns), + _ => None, + } + } + + pub(super) fn column(&self, place: &Place) -> Option<&Column

> { + let Place::Dock { path, column, .. } = place else { return None }; + match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Dock { columns, .. }) => columns.get(*column), + _ => None, + } + } + + pub(super) fn column_mut(&mut self, place: &Place) -> Option<&mut Column

> { + let Place::Dock { path, column, .. } = place else { return None }; + self.columns_mut(path)?.get_mut(*column) + } + + /// The dock or floating group at `place`; `None` for a `Tabs` or `Stack` node. + pub(super) fn group(&self, place: &Place) -> Option<&Group

> { + match place { + Place::Node(_) => None, + Place::Dock { group, .. } => self.column(place)?.groups.get(*group), + Place::Floating { window, group } => self.floating.get(*window)?.groups.get(*group), + } + } + + pub(super) fn group_mut(&mut self, place: &Place) -> Option<&mut Group

> { + match place { + Place::Node(_) => None, + Place::Dock { group, .. } => self.column_mut(place)?.groups.get_mut(*group), + Place::Floating { window, group } => self.floating.get_mut(*window)?.groups.get_mut(*group), + } + } + + /// Remove the node at `path`; its parent split collapses into the sibling. + pub(super) fn take_node(&mut self, path: &[bool]) -> Option> { + let Some((second_child, parent_path)) = path.split_last() else { return self.root.take() }; + let parent = self.root.as_mut().and_then(|root| node_at_mut(root, parent_path))?; + if !matches!(parent, Node::Split { .. }) { + return None; + } + match std::mem::replace(parent, Node::Tabs { panels: Vec::new(), active: 0 }) { + Node::Split { first, second, .. } => { + let (taken, kept) = if *second_child { (second, first) } else { (first, second) }; + *parent = *kept; + Some(*taken) + } + other => { + *parent = other; + None + } + } + } + + /// The members of the group, column or floating window containing `panel`, in order. + /// An accordion entry is a group of one. + pub(super) fn members(&self, panel: &P, unit: Unit) -> Result, DockError> { + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + match (unit, &place) { + (Unit::Group, Place::Node(path)) => match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Tabs { panels, .. }) => Ok(panels.clone()), + Some(Node::Stack { .. }) => Ok(vec![panel.clone()]), + _ => Err(DockError::MissingPanel), + }, + (Unit::Group, _) => self.group(&place).map(|g| g.panels.clone()).ok_or(DockError::MissingPanel), + (Unit::Column, Place::Dock { .. }) => { + let column = self.column(&place).ok_or(DockError::MissingPanel)?; + Ok(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect()) + } + (Unit::Window, Place::Floating { window, .. }) => { + let window = self.floating.get(*window).ok_or(DockError::MissingPanel)?; + Ok(window.panels().cloned().collect()) + } + (Unit::Column | Unit::Window, _) => Err(DockError::InvalidPlacement), + } + } + + pub(super) fn take_unit(&mut self, panel: &P, unit: Unit) -> Result, DockError> { + match unit { + Unit::Group => self.take_group(panel), + Unit::Column => self.take_column(panel), + Unit::Window => self.take_window(panel), + } + } + + fn take_group(&mut self, panel: &P) -> Result, DockError> { + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + match place { + Place::Node(path) => { + if let Some(Node::Stack { entries }) = self.root.as_mut().and_then(|root| node_at_mut(root, &path)) + && entries.len() > 1 + && let Some(index) = entries.iter().position(|e| &e.panel == panel) + { + let entry = entries.remove(index); + let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height }; + return Ok(Moving::group(group)); + } + match self.take_node(&path) { + Some(Node::Tabs { panels, active }) => Ok(Moving::group(Group::new(panels, active))), + Some(Node::Stack { mut entries }) if entries.len() == 1 => { + let entry = entries.remove(0); + Ok(Moving::group(Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height })) + } + _ => Err(DockError::MissingPanel), + } + } + Place::Dock { path, column, group } => { + let columns = self.columns_mut(&path).ok_or(DockError::MissingPanel)?; + let source = columns.get_mut(column).ok_or(DockError::MissingPanel)?; + if group >= source.groups.len() { + return Err(DockError::MissingPanel); + } + let taken = source.groups.remove(group); + let width = source.width; + if source.groups.is_empty() { + columns.remove(column); + } + if columns.is_empty() { + self.take_node(&path); + } + Ok(Moving { width: Some(width), ..Moving::group(taken) }) + } + Place::Floating { window, group } => { + let source = self.floating.get_mut(window).ok_or(DockError::MissingPanel)?; + if group >= source.groups.len() { + return Err(DockError::MissingPanel); + } + let taken = source.groups.remove(group); + let rect = source.rect; + let native = source.native; + let whole = source.groups.is_empty(); + if whole { + self.floating.remove(window); + } + let width = rect.get(2).copied(); + Ok(Moving { width, rect: whole.then_some(rect), native: native.filter(|_| whole), ..Moving::group(taken) }) + } + } + } + + fn take_column(&mut self, panel: &P) -> Result, DockError> { + let Some(Place::Dock { path, column, .. }) = self.place(panel) else { return Err(DockError::InvalidPlacement) }; + let columns = self.columns_mut(&path).ok_or(DockError::MissingPanel)?; + if column >= columns.len() { + return Err(DockError::MissingPanel); + } + let taken = columns.remove(column); + if columns.is_empty() { + self.take_node(&path); + } + Ok(Moving { + groups: taken.groups, + width: Some(taken.width), + iconic: taken.iconic, + iconic_width: taken.iconic_width, + rect: None, + native: None, + }) + } + + fn take_window(&mut self, panel: &P) -> Result, DockError> { + let Some(Place::Floating { window, .. }) = self.place(panel) else { return Err(DockError::InvalidPlacement) }; + if window >= self.floating.len() { + return Err(DockError::MissingPanel); + } + let taken = self.floating.remove(window); + Ok(Moving { + groups: taken.groups, + width: taken.rect.get(2).copied(), + iconic: false, + iconic_width: None, + rect: Some(taken.rect), + native: taken.native, + }) + } + + /// Insert groups taken by [`Self::take_unit`] (or a new single-panel group). + pub(super) fn insert_moving(&mut self, moving: Moving

, to: Target

) -> Result<(), DockError> { + if moving.groups.is_empty() || moving.groups.iter().any(|g| g.panels.is_empty()) { + return Err(DockError::MissingPanel); + } + match to { + Target::Float { rect } => { + let native = if rect.is_none() { moving.native } else { None }; + let rect = rect.or(moving.rect).unwrap_or_else(|| { + let [x, y, width, height] = DEFAULT_FLOAT; + [x, y, moving.width.unwrap_or(width), height] + }); + if !valid_rect(rect) { + return Err(DockError::InvalidGeometry); + } + self.floating.push(Floating { groups: moving.groups, rect, native }); + Ok(()) + } + Target::NewDock { side } => { + if let Some(path) = self.dock_path(side) { + let columns = self.columns_mut(&path).ok_or(DockError::MissingTarget)?; + if !columns.is_empty() { + return Err(DockError::InvalidPlacement); + } + columns.push(moving.into_column(DEFAULT_COLUMN_WIDTH)); + return Ok(()); + } + let column = moving.into_column(DEFAULT_COLUMN_WIDTH); + self.add_dock(side, column); + Ok(()) + } + Target::At { anchor, placement } => { + let place = self.place(&anchor).ok_or(DockError::MissingTarget)?; + match placement { + Placement::Tab { before } => { + let index = match &before { + Some(before) => { + if self.place(before).as_ref() != Some(&place) { + return Err(DockError::MissingTarget); + } + self.index_in(&place, before) + } + None => None, + }; + self.merge(&place, index, moving) + } + Placement::Split(Zone::Center) => { + let index = self.index_in(&place, &anchor).map(|i| i + 1); + self.merge(&place, index, moving) + } + Placement::Split(zone) => { + let Place::Node(path) = place else { return Err(DockError::InvalidPlacement) }; + let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?; + let (panels, active) = flatten(moving.groups); + let new = Node::Tabs { panels, active }; + let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); + let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let (first, second) = if matches!(zone, Zone::Top | Zone::Left) { (new, old) } else { (old, new) }; + *node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; + Ok(()) + } + Placement::Group { below } => { + let (groups, index) = match &place { + Place::Dock { group, .. } => (&mut self.column_mut(&place).ok_or(DockError::MissingTarget)?.groups, *group), + Place::Floating { window, group } => { + (&mut self.floating.get_mut(*window).ok_or(DockError::MissingTarget)?.groups, *group) + } + Place::Node(_) => return Err(DockError::InvalidPlacement), + }; + let index = (index + usize::from(below)).min(groups.len()); + groups.splice(index..index, moving.groups); + Ok(()) + } + Placement::Column { after } => { + let Place::Dock { path, column, .. } = &place else { return Err(DockError::InvalidPlacement) }; + let columns = self.columns_mut(path).ok_or(DockError::MissingTarget)?; + let width = columns.get(*column).map_or(DEFAULT_COLUMN_WIDTH, |c| c.width); + let index = (column + usize::from(after)).min(columns.len()); + columns.insert(index, moving.into_column(width)); + Ok(()) + } + } + } + } + } + + /// Wrap the root in a horizontal split with a new dock on `side`. The split's fixed size is + /// the column width. + pub(super) fn add_dock(&mut self, side: Side, column: Column

) { + let width = column.width; + let dock = Node::Dock { side, columns: vec![column] }; + self.root = Some(match self.root.take() { + None => dock, + Some(root) => { + let (first, second, size) = match side { + Side::Start => (dock, root, SplitSize::FixedFirst(width)), + Side::End => (root, dock, SplitSize::FixedSecond(width)), + }; + Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) } + } + }); + } + + fn index_in(&self, place: &Place, panel: &P) -> Option { + match place { + Place::Node(path) => match self.root.as_ref().and_then(|root| node_at(root, path))? { + Node::Tabs { panels, .. } => panels.iter().position(|p| p == panel), + Node::Stack { entries } => entries.iter().position(|e| &e.panel == panel), + _ => None, + }, + _ => self.group(place)?.panels.iter().position(|p| p == panel), + } + } + + /// Join every moving panel to the group at `place`, before `index` or at the end. The first + /// moving group's active panel becomes active. + fn merge(&mut self, place: &Place, index: Option, moving: Moving

) -> Result<(), DockError> { + let (panels, selected) = flatten(moving.groups); + match place { + Place::Node(path) => match self.root.as_mut().and_then(|root| node_at_mut(root, path)) { + Some(Node::Tabs { panels: target, active }) => { + splice(target, active, index.unwrap_or(target.len()), panels, selected); + Ok(()) + } + Some(Node::Stack { entries }) => { + let index = index.unwrap_or(entries.len()).min(entries.len()); + entries.splice(index..index, panels.into_iter().map(|panel| super::StackEntry { panel, open: true, height: None })); + Ok(()) + } + _ => Err(DockError::MissingTarget), + }, + _ => { + let group = self.group_mut(place).ok_or(DockError::MissingTarget)?; + let end = group.panels.len(); + splice(&mut group.panels, &mut group.active, index.unwrap_or(end), panels, selected); + Ok(()) + } + } + } +} + +/// All panels in order, and the index of the first group's active panel among them. +fn flatten

(groups: Vec>) -> (Vec

, usize) { + let selected = groups.first().map_or(0, |g| g.active.min(g.panels.len().saturating_sub(1))); + (groups.into_iter().flat_map(|g| g.panels).collect(), selected) +} + +pub(super) fn valid_column

(column: &Column

) -> bool { + valid_width(column.width) && column.iconic_width.is_none_or(valid_width) +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index a7bf3f98..d29be7a4 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -5,10 +5,12 @@ //! Validate deserialized layouts before rendering them. Every transaction checks its complete //! input and commits only a valid result; a rejected move cannot lose the source panel. +mod dock_model; mod model; mod surface; mod targets; +pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag}; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect}; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index a7056677..92eeedbb 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -1,11 +1,12 @@ use std::{collections::HashSet, fmt, hash::Hash}; +use super::dock_model::{Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, valid_column}; use crate::layout::{SplitAxis, SplitSize}; /// Limits are checked before cloning or recursively modifying an application layout. pub const MAX_DEPTH: usize = 64; pub const MAX_PANELS: usize = 4096; -const MAX_NODES: usize = MAX_PANELS * 2; +pub(super) const MAX_NODES: usize = MAX_PANELS * 2; const MAX_EXTENT: f32 = 1_000_000.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -20,6 +21,7 @@ pub enum Zone { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Node

{ Split { axis: SplitAxis, @@ -35,6 +37,13 @@ pub enum Node

{ Stack { entries: Vec>, }, + /// One window edge's dock, columns from the window edge inwards. A dock is the root or, + /// following only same-side children of horizontal splits, at its window edge. At most one + /// per side. An empty dock (no columns) is valid and accepts [`Target::NewDock`]. + Dock { + side: Side, + columns: Vec>, + }, } #[derive(Clone, Debug, PartialEq)] @@ -45,13 +54,93 @@ pub struct StackEntry

{ pub height: Option, } +/// A floating window: a stack of groups, top to bottom. +/// +/// Serde reads the v1 shape `{panels, active, rect}` as one group. A window with one plain group +/// (not minimized, no height, no native rect) writes that shape, so older builds can read files +/// that use no newer feature. #[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Floating

{ - pub panels: Vec

, - pub active: usize, + pub groups: Vec>, /// `[x, y, width, height]` in logical points, in the application's egui viewport. pub rect: [f32; 4], + /// Desktop rect while the window is in its own OS window. + pub native: Option<[f32; 4]>, +} + +impl

Floating

{ + pub fn single(panels: Vec

, active: usize, rect: [f32; 4]) -> Self { + Self { groups: vec![Group::new(panels, active)], rect, native: None } + } + + pub fn panels(&self) -> impl Iterator { + self.groups.iter().flat_map(|group| group.panels.iter()) + } + + pub fn contains(&self, panel: &P) -> bool + where + P: PartialEq, + { + self.groups.iter().any(|group| group.panels.contains(panel)) + } +} + +#[cfg(feature = "serde")] +mod floating_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + use super::{Floating, Group}; + + #[derive(Serialize)] + struct V1<'a, P> { + panels: &'a [P], + active: usize, + rect: [f32; 4], + } + + #[derive(Serialize)] + struct V2<'a, P> { + groups: &'a [Group

], + rect: [f32; 4], + #[serde(skip_serializing_if = "Option::is_none")] + native: Option<[f32; 4]>, + } + + impl Serialize for Floating

{ + fn serialize(&self, serializer: S) -> Result { + match self.groups.as_slice() { + [only] if !only.minimized && only.height.is_none() && self.native.is_none() => { + V1 { panels: &only.panels, active: only.active, rect: self.rect }.serialize(serializer) + } + groups => V2 { groups, rect: self.rect, native: self.native }.serialize(serializer), + } + } + } + + #[derive(Deserialize)] + #[serde(bound(deserialize = "P: Deserialize<'de>"))] + struct Repr

{ + groups: Option>>, + panels: Option>, + #[serde(default)] + active: usize, + rect: [f32; 4], + #[serde(default)] + native: Option<[f32; 4]>, + } + + impl<'de, P: Deserialize<'de>> Deserialize<'de> for Floating

{ + fn deserialize>(deserializer: D) -> Result { + let repr = Repr::

::deserialize(deserializer)?; + let groups = match (repr.groups, repr.panels) { + (Some(groups), None) => groups, + (None, Some(panels)) => vec![Group::new(panels, repr.active)], + (None, None) => return Err(serde::de::Error::missing_field("groups")), + (Some(_), Some(_)) => return Err(serde::de::Error::duplicate_field("panels")), + }; + Ok(Floating { groups, rect: repr.rect, native: repr.native }) + } + } } #[derive(Clone, Debug, PartialEq)] @@ -69,11 +158,17 @@ impl

Default for Layout

{ #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Placement

{ /// Insert before this existing destination member, or append. Also reorders a group. Tab { before: Option

}, - /// Center inserts after the anchor; sides create a new half-size split. + /// Center inserts after the anchor; sides create a new half-size split. Sides apply to + /// `Tabs` and `Stack` nodes only. Split(Zone), + /// A new group above or below the anchor's group, in a dock column or a floating stack. + Group { below: bool }, + /// A new column before (towards the window edge) or after the anchor's column. + Column { after: bool }, } /// A closed panel's former placement. Applications may retain this beside visibility flags. @@ -92,10 +187,16 @@ pub struct Location

{ /// rather than accidentally inserting beside only its first leaf when restoring. #[cfg_attr(feature = "serde", serde(default))] pub sibling: Vec

, + /// Group and column state when the panel was in a dock or a floating stack. Applies when + /// restoring creates a new group, column or dock for the panel. + #[cfg_attr(feature = "serde", serde(default))] + pub group: Option, } +/// Groups, columns and floating windows are named by a panel they contain. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Action

{ Move { panel: P, @@ -141,6 +242,62 @@ pub enum Action

{ panel: P, height: Option, }, + /// Move the group containing `panel` with its order, active tab and minimized state. + /// `Target::At` with `Tab` or `Split(Center)` merges every member. An accordion entry is a + /// group of one. A target inside the group is a no-op. + MoveGroup { + panel: P, + to: Target

, + }, + /// Move the dock column containing `panel`. `Float` makes a window of its groups. + MoveColumn { + panel: P, + to: Target

, + }, + /// Dock or merge the floating window containing `panel`, every group included. + MoveWindow { + panel: P, + to: Target

, + }, + /// Close Tab Group. + CloseGroup { + panel: P, + }, + /// × on a floating window: closes all its panels. + CloseWindow { + panel: P, + }, + /// Close Other Panels in Group. + CloseOthers { + panel: P, + }, + /// Minimize a dock or floating group to its tab strip. On an accordion entry, closes it. + SetMinimized { + panel: P, + minimized: bool, + }, + SetIconic { + panel: P, + iconic: bool, + }, + ResizeColumn { + panel: P, + width: f32, + }, + /// Preferred height of a dock or floating group, or of an accordion entry. + ResizeGroup { + panel: P, + height: Option, + }, + ResizeIconic { + panel: P, + width: Option, + }, + /// Put the floating window in its own OS window at `rect`, or back in the app window. + SetNative { + panel: P, + rect: Option<[f32; 4]>, + }, } /// Rendering and command adapters can protect a document canvas while leaving panels movable. @@ -160,9 +317,17 @@ impl Default for Permissions { impl Permissions { pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false }; + + /// Lock Workspace: nothing moves, floats or closes; tab drops keep their policy. + /// [`super::Workspace::apply`] applies this when the workspace is locked; an application + /// with a global lock preference applies it in its own permissions callback. + pub fn locked(self) -> Self { + Self { movable: false, floatable: false, closable: false, accepts_tabs: self.accepts_tabs } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum DockError { LimitExceeded, DuplicatePanel, @@ -172,6 +337,8 @@ pub enum DockError { InvalidGeometry, InvalidPlacement, NotAllowed, + /// Saved by a newer version of the docking format. + UnsupportedVersion, } impl fmt::Display for DockError { @@ -185,6 +352,7 @@ impl fmt::Display for DockError { Self::InvalidGeometry => "docking geometry is not finite and bounded", Self::InvalidPlacement => "docking destination does not support this placement", Self::NotAllowed => "this panel does not allow the requested docking operation", + Self::UnsupportedVersion => "docking data was saved by a newer version", }) } } @@ -195,6 +363,10 @@ pub(super) fn valid_rect(rect: [f32; 4]) -> bool { rect.iter().all(|v| v.is_finite() && v.abs() <= MAX_EXTENT) && rect.get(2).is_some_and(|v| *v > 0.0) && rect.get(3).is_some_and(|v| *v > 0.0) } +pub(super) fn valid_width(width: f32) -> bool { + width.is_finite() && width > 0.0 && width <= MAX_EXTENT +} + fn valid_size(size: SplitSize) -> bool { match size { SplitSize::Ratio(r) => r.is_finite() && (0.0..=1.0).contains(&r), @@ -219,19 +391,47 @@ impl Layout

{ } let mut seen = HashSet::new(); let mut count = 0usize; - let mut visit = self.root.as_ref().map(|n| vec![(n, 1usize)]).unwrap_or_default(); - while let Some((node, depth)) = visit.pop() { + let mut docks = (false, false); + // Each node carries whether it touches the window's start and end edges. + let mut visit = self.root.as_ref().map(|n| vec![(n, 1usize, true, true)]).unwrap_or_default(); + while let Some((node, depth, start, end)) = visit.pop() { count = count.saturating_add(1); if depth > MAX_DEPTH || count > MAX_NODES { return Err(DockError::LimitExceeded); } match node { - Node::Split { size, first, second, .. } => { + Node::Split { axis, size, first, second } => { if !valid_size(*size) { return Err(DockError::InvalidGeometry); } - visit.push((second, depth + 1)); - visit.push((first, depth + 1)); + let horizontal = *axis == SplitAxis::Horizontal; + visit.push((second, depth + 1, false, end && horizontal)); + visit.push((first, depth + 1, start && horizontal, false)); + } + Node::Dock { side, columns } => { + let (at_edge, taken) = match side { + Side::Start => (start, &mut docks.0), + Side::End => (end, &mut docks.1), + }; + if !at_edge || *taken { + return Err(DockError::InvalidPlacement); + } + *taken = true; + if columns.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + for column in columns { + if !valid_column(column) || column.groups.is_empty() { + return Err(DockError::InvalidGeometry); + } + count = count.saturating_add(column.groups.len().saturating_add(1)); + if count > MAX_NODES { + return Err(DockError::LimitExceeded); + } + for group in &column.groups { + check_group(group, &mut seen)?; + } + } } Node::Tabs { panels, active } => { check_tabs(panels, *active, &mut seen)?; @@ -250,10 +450,16 @@ impl Layout

{ } } for floating in &self.floating { - if !valid_rect(floating.rect) || floating.panels.is_empty() { + if !valid_rect(floating.rect) || floating.groups.is_empty() || floating.native.is_some_and(|r| !valid_rect(r)) { return Err(DockError::InvalidGeometry); } - check_tabs(&floating.panels, floating.active, &mut seen)?; + count = count.saturating_add(floating.groups.len()); + if count > MAX_NODES { + return Err(DockError::LimitExceeded); + } + for group in &floating.groups { + check_group(group, &mut seen)?; + } } Ok(()) } @@ -275,10 +481,14 @@ impl Layout

{ } Node::Tabs { panels: group, .. } => panels.extend(group.iter().take(MAX_PANELS - panels.len())), Node::Stack { entries } => panels.extend(entries.iter().map(|e| &e.panel).take(MAX_PANELS - panels.len())), + Node::Dock { columns, .. } => { + let members = columns.iter().flat_map(|c| c.groups.iter()).flat_map(|g| g.panels.iter()); + panels.extend(members.take(MAX_PANELS - panels.len())); + } } } - for group in self.floating.iter().take(MAX_PANELS) { - panels.extend(group.panels.iter().take(MAX_PANELS - panels.len())); + for window in self.floating.iter().take(MAX_PANELS) { + panels.extend(window.panels().take(MAX_PANELS - panels.len())); } panels } @@ -296,12 +506,69 @@ impl Layout

{ /// Capture a panel's nearest surviving group/split neighbor before hiding it. pub fn location(&self, panel: &P) -> Result, DockError> { self.validate()?; - if let Some(group) = self.floating.iter().find(|g| g.panels.contains(panel)) { - let mut location = group_location(&group.panels, panel).ok_or(DockError::MissingPanel)?; - location.floating = Some(group.rect); + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + let (groups, group, floating, column) = match &place { + Place::Node(_) => return self.root.as_ref().and_then(|node| node_location(node, panel)).ok_or(DockError::MissingPanel), + Place::Floating { window, group } => { + let window = self.floating.get(*window).ok_or(DockError::MissingPanel)?; + (&window.groups, *group, Some(window.rect), None) + } + Place::Dock { path, group, .. } => { + let side = match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Dock { side, .. }) => *side, + _ => return Err(DockError::MissingPanel), + }; + let column = self.column(&place).ok_or(DockError::MissingPanel)?; + let state = ColumnLocation { side, width: column.width, iconic: column.iconic, iconic_width: column.iconic_width }; + (&column.groups, *group, None, Some(state)) + } + }; + let own = groups.get(group).ok_or(DockError::MissingPanel)?; + let mut location = group_location(&own.panels, panel).ok_or(DockError::MissingPanel)?; + location.floating = floating; + location.group = Some(GroupLocation { minimized: own.minimized, height: own.height, column }); + if location.anchor.is_some() { return Ok(location); } - self.root.as_ref().and_then(|node| node_location(node, panel)).ok_or(DockError::MissingPanel) + // Alone in its group: anchor to the neighbouring group, then the neighbouring column. + let first = |g: Option<&Group

>| g.and_then(|g| g.panels.first()).cloned(); + if let Some(anchor) = first(groups.get(group + 1)) { + location.anchor = Some(anchor); + location.placement = Placement::Group { below: false }; + } else if let Some(anchor) = first(group.checked_sub(1).and_then(|i| groups.get(i))) { + location.anchor = Some(anchor); + location.placement = Placement::Group { below: true }; + } else if groups.len() == 1 + && let Place::Dock { path, column, .. } = &place + && let Some(Node::Dock { columns, .. }) = self.root.as_ref().and_then(|root| node_at(root, path)) + { + let first_of = |c: Option<&Column

>| first(c.and_then(|c| c.groups.first())); + if let Some(anchor) = first_of(columns.get(column + 1)) { + location.anchor = Some(anchor); + location.placement = Placement::Column { after: false }; + } else if let Some(anchor) = first_of(column.checked_sub(1).and_then(|i| columns.get(i))) { + location.anchor = Some(anchor); + location.placement = Placement::Column { after: true }; + } + } + Ok(location) + } + + /// Hidden, open behind another tab or in a collapsed container, or showing. + pub fn panel_state(&self, panel: &P) -> PanelState { + let showing = match self.place(panel) { + None => return PanelState::Hidden, + Some(Place::Node(path)) => match self.root.as_ref().and_then(|root| node_at(root, &path)) { + Some(Node::Tabs { panels, active }) => panels.get(*active) == Some(panel), + Some(Node::Stack { entries }) => entries.iter().any(|e| &e.panel == panel && e.open), + _ => false, + }, + Some(place) => { + let iconic = self.column(&place).is_some_and(|c| c.iconic); + self.group(&place).is_some_and(|g| !g.minimized && g.active_panel() == Some(panel)) && !iconic + } + }; + if showing { PanelState::Front } else { PanelState::Open } } /// Restore an absent panel at a captured location. Missing anchors are reported without @@ -314,18 +581,57 @@ impl Layout

{ if location.size.is_some_and(|s| !valid_size(s)) || location.stack.is_some_and(|(_, h)| !valid_height(h)) { return Err(DockError::InvalidGeometry); } + let state = location.group.as_ref(); + if state.is_some_and(|s| { + !valid_height(s.height) || s.column.is_some_and(|c| !valid_width(c.width) || c.iconic_width.is_some_and(|w| !valid_width(w))) + }) { + return Err(DockError::InvalidGeometry); + } + // A restore that creates a group, column or dock gives it the saved state. + let moving = || { + let group = + Group { panels: vec![panel.clone()], active: 0, minimized: state.is_some_and(|s| s.minimized), height: state.and_then(|s| s.height) }; + let column = state.and_then(|s| s.column); + Moving { + width: column.map(|c| c.width), + iconic: column.is_some_and(|c| c.iconic), + iconic_width: column.and_then(|c| c.iconic_width), + ..Moving::group(group) + } + }; let mut proposed = self.clone(); if let Some(rect) = location.floating { if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } if let Some(anchor) = &location.anchor { - if !proposed.floating.iter().any(|g| g.panels.contains(anchor)) { + if !proposed.floating.iter().any(|g| g.contains(anchor)) { return Err(DockError::MissingTarget); } - proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; + if let Placement::Group { .. } = location.placement { + proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?; + } else { + proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; + } } else { - proposed.floating.push(Floating { panels: vec![panel.clone()], active: 0, rect }); + proposed.insert_moving(moving(), Target::Float { rect: Some(rect) })?; + } + } else if let Some(anchor) = &location.anchor + && matches!(location.placement, Placement::Group { .. } | Placement::Column { .. }) + { + if !proposed.contains(anchor) { + return Err(DockError::MissingTarget); + } + proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?; + } else if let Some(column) = state.and_then(|s| s.column).filter(|_| location.anchor.is_none()) { + // Alone in its dock: back at that edge, as the outermost column if a dock exists. + match proposed.dock_path(column.side) { + Some(path) => { + let columns = proposed.columns_mut(&path).ok_or(DockError::MissingTarget)?; + let Moving { groups, .. } = moving(); + columns.insert(0, Column { groups, width: column.width, iconic: column.iconic, iconic_width: column.iconic_width }); + } + None => proposed.insert_moving(moving(), Target::NewDock { side: column.side })?, } } else if let Some(anchor) = &location.anchor { if !proposed.contains(anchor) { @@ -397,6 +703,31 @@ impl Layout

{ Action::Close { panel } if !permissions(panel).closable => return Err(DockError::NotAllowed), Action::MoveFloating { panel, .. } if !permissions(panel).movable => return Err(DockError::NotAllowed), Action::Open { anchor: Some(anchor), .. } if !permissions(anchor).accepts_tabs => return Err(DockError::NotAllowed), + Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } | Action::MoveWindow { panel, to } => { + let members = self.members(panel, unit_of(&action))?; + if members.iter().any(|p| !permissions(p).movable) { + return Err(DockError::NotAllowed); + } + match to { + Target::Float { .. } if members.iter().any(|p| !permissions(p).floatable) => return Err(DockError::NotAllowed), + Target::At { anchor, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) } + if !members.contains(anchor) && !permissions(anchor).accepts_tabs => + { + return Err(DockError::NotAllowed); + } + _ => {} + } + } + Action::CloseGroup { panel } | Action::CloseWindow { panel } | Action::CloseOthers { panel } => { + let others = matches!(action, Action::CloseOthers { .. }); + let members = self.members(panel, unit_of(&action))?; + if members.iter().filter(|p| !others || *p != panel).any(|p| !permissions(p).closable) { + return Err(DockError::NotAllowed); + } + } + Action::SetNative { panel, rect: Some(_) } if self.members(panel, Unit::Window)?.iter().any(|p| !permissions(p).floatable) => { + return Err(DockError::NotAllowed); + } _ => {} } let mut proposed = self.clone(); @@ -406,6 +737,28 @@ impl Layout

{ Ok(()) } + /// Apply every action in order, or none: the first rejection leaves the layout unchanged. + pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + let mut proposed = self.clone(); + for action in actions { + proposed.apply_with_permissions(action, &permissions)?; + } + *self = proposed; + Ok(()) + } + + /// The panels a close action (`Close`, `CloseGroup`, `CloseWindow`, `CloseOthers`) would + /// remove, in group order, so the application can release their state once the action is + /// applied. Empty for other actions. + pub fn closed_by(&self, action: &Action

) -> Result, DockError> { + Ok(match action { + Action::Close { panel } => vec![panel.clone()], + Action::CloseGroup { panel } | Action::CloseWindow { panel } => self.members(panel, unit_of(action))?, + Action::CloseOthers { panel } => self.members(panel, Unit::Group)?.into_iter().filter(|p| p != panel).collect(), + _ => Vec::new(), + }) + } + fn apply_inner(&mut self, action: Action

) -> Result<(), DockError> { match action { Action::Move { panel, anchor, placement } => { @@ -432,19 +785,20 @@ impl Layout

{ return Err(DockError::InvalidGeometry); } let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; - self.floating.push(Floating { panels: vec![entry.panel], active: 0, rect }); + self.floating.push(Floating::single(vec![entry.panel], 0, rect)); Ok(()) } Action::Close { panel } => self.take(&panel).map(|_| ()).ok_or(DockError::MissingPanel), Action::Activate { panel } => { - if self.root.as_mut().is_some_and(|node| activate(node, &panel)) { + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + if let Some(group) = self.group_mut(&place) { + // Selecting a panel shows it: a minimized group expands. + group.active = group.panels.iter().position(|p| p == &panel).ok_or(DockError::MissingPanel)?; + group.minimized = false; return Ok(()); } - for group in &mut self.floating { - if let Some(i) = group.panels.iter().position(|p| p == &panel) { - group.active = i; - return Ok(()); - } + if self.root.as_mut().is_some_and(|node| activate(node, &panel)) { + return Ok(()); } Err(DockError::MissingPanel) } @@ -477,8 +831,8 @@ impl Layout

{ if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } - let group = self.floating.iter_mut().find(|g| g.panels.contains(&panel)).ok_or(DockError::MissingPanel)?; - group.rect = rect; + let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::MissingPanel)?; + window.rect = rect; Ok(()) } Action::ResizeSplit { path, size } => { @@ -510,47 +864,115 @@ impl Layout

{ entry.height = height; Ok(()) } + Action::MoveGroup { ref panel, ref to } | Action::MoveColumn { ref panel, ref to } | Action::MoveWindow { ref panel, ref to } => { + let unit = unit_of(&action); + if let Target::At { anchor, .. } = to + && self.members(panel, unit)?.contains(anchor) + { + // Dropped on itself. + return Ok(()); + } + let moving = self.take_unit(panel, unit)?; + self.insert_moving(moving, to.clone()) + } + Action::CloseGroup { ref panel } | Action::CloseWindow { ref panel } => self.take_unit(panel, unit_of(&action)).map(|_| ()), + Action::CloseOthers { panel } => { + for other in self.members(&panel, Unit::Group)? { + if other != panel { + self.take(&other).ok_or(DockError::MissingPanel)?; + } + } + Ok(()) + } + Action::SetMinimized { panel, minimized } => { + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + if let Some(group) = self.group_mut(&place) { + group.minimized = minimized; + return Ok(()); + } + let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; + entry.open = !minimized; + Ok(()) + } + Action::ResizeGroup { panel, height } => { + if !valid_height(height) { + return Err(DockError::InvalidGeometry); + } + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + if let Some(group) = self.group_mut(&place) { + group.height = height; + return Ok(()); + } + let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; + entry.height = height; + Ok(()) + } + Action::SetIconic { panel, iconic } => { + self.column_of(&panel)?.iconic = iconic; + Ok(()) + } + Action::ResizeColumn { panel, width } => { + if !valid_width(width) { + return Err(DockError::InvalidGeometry); + } + self.column_of(&panel)?.width = width; + Ok(()) + } + Action::ResizeIconic { panel, width } => { + if width.is_some_and(|w| !valid_width(w)) { + return Err(DockError::InvalidGeometry); + } + self.column_of(&panel)?.iconic_width = width; + Ok(()) + } + Action::SetNative { panel, rect } => { + if rect.is_some_and(|r| !valid_rect(r)) { + return Err(DockError::InvalidGeometry); + } + let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::InvalidPlacement)?; + window.native = rect; + Ok(()) + } } } + fn column_of(&mut self, panel: &P) -> Result<&mut Column

, DockError> { + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + self.column_mut(&place).ok_or(DockError::InvalidPlacement) + } + fn same_group(&self, panel: &P, anchor: &P) -> bool { - fn in_node(node: &Node

, panel: &P, anchor: &P) -> bool { - match node { - Node::Tabs { panels, .. } => panels.contains(panel) && panels.contains(anchor), - Node::Stack { entries } => entries.iter().any(|e| &e.panel == panel) && entries.iter().any(|e| &e.panel == anchor), - Node::Split { first, second, .. } => in_node(first, panel, anchor) || in_node(second, panel, anchor), - } - } - self.root.as_ref().is_some_and(|n| in_node(n, panel, anchor)) - || self.floating.iter().any(|g| g.panels.contains(panel) && g.panels.contains(anchor)) + let place = self.place(panel); + place.is_some() && place == self.place(anchor) } - fn take(&mut self, panel: &P) -> Option> { + pub(super) fn take(&mut self, panel: &P) -> Option> { let mut removed = None; self.root = self.root.take().and_then(|n| remove_node(n, panel, &mut removed)); if removed.is_some() { return removed; } - for group in &mut self.floating { - if let Some(p) = remove_tab(&mut group.panels, &mut group.active, panel) { - removed = Some(StackEntry { panel: p, open: true, height: None }); + for window in &mut self.floating { + for group in &mut window.groups { + if let Some(p) = remove_tab(&mut group.panels, &mut group.active, panel) { + removed = Some(StackEntry { panel: p, open: true, height: None }); + break; + } + } + if removed.is_some() { + window.groups.retain(|g| !g.panels.is_empty()); break; } } - self.floating.retain(|g| !g.panels.is_empty()); + self.floating.retain(|g| !g.groups.is_empty()); removed } fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

) -> Result<(), DockError> { - if let Some(group) = self.floating.iter_mut().find(|g| g.panels.contains(anchor)) { - let index = match placement { - Placement::Tab { before } => before.as_ref().and_then(|p| group.panels.iter().position(|q| q == p)).unwrap_or(group.panels.len()), - Placement::Split(Zone::Center) => group.panels.iter().position(|p| p == anchor).map_or(group.panels.len(), |i| i + 1), - Placement::Split(_) => return Err(DockError::InvalidPlacement), - }; - group.panels.insert(index, entry.panel); - group.active = index; - return Ok(()); + let place = self.place(anchor).ok_or(DockError::MissingTarget)?; + if matches!(place, Place::Dock { .. } | Place::Floating { .. }) || matches!(placement, Placement::Group { .. } | Placement::Column { .. }) { + let group = Group::new(vec![entry.panel], 0); + return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement }); } if let Some(root) = self.root.as_mut() && insert_node(root, &entry, anchor, &placement) @@ -561,6 +983,14 @@ impl Layout

{ } } +fn unit_of

(action: &Action

) -> Unit { + match action { + Action::MoveColumn { .. } => Unit::Column, + Action::MoveWindow { .. } | Action::CloseWindow { .. } => Unit::Window, + _ => Unit::Group, + } +} + fn check_panel<'a, P: Eq + Hash>(panel: &'a P, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { if seen.len() >= MAX_PANELS { return Err(DockError::LimitExceeded); @@ -571,6 +1001,13 @@ fn check_panel<'a, P: Eq + Hash>(panel: &'a P, seen: &mut HashSet<&'a P>) -> Res Ok(()) } +fn check_group<'a, P: Eq + Hash>(group: &'a Group

, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { + if group.panels.is_empty() || !valid_height(group.height) { + return Err(DockError::InvalidGeometry); + } + check_tabs(&group.panels, group.active, seen) +} + fn check_tabs<'a, P: Eq + Hash>(panels: &'a [P], active: usize, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { if panels.len() > MAX_PANELS { return Err(DockError::LimitExceeded); @@ -614,6 +1051,20 @@ fn remove_node(node: Node

, panel: &P, removed: &mut Option node, } } + Node::Dock { side, mut columns } => { + // A dock that was already empty stays; one emptied by this removal collapses. + let had_columns = !columns.is_empty(); + for column in &mut columns { + for group in &mut column.groups { + if let Some(panel) = remove_tab(&mut group.panels, &mut group.active, panel) { + *removed = Some(StackEntry { panel, open: true, height: None }); + } + } + column.groups.retain(|g| !g.panels.is_empty()); + } + columns.retain(|c| !c.groups.is_empty()); + (!had_columns || !columns.is_empty()).then_some(Node::Dock { side, columns }) + } } } @@ -636,6 +1087,7 @@ fn activate(node: &mut Node

, panel: &P) -> bool { } } Node::Split { first, second, .. } => activate(first, panel) || activate(second, panel), + Node::Dock { .. } => false, } } @@ -643,7 +1095,7 @@ fn stack_entry_mut<'a, P: Eq>(node: &'a mut Node

, panel: &P) -> Option<&'a mu match node { Node::Stack { entries } => entries.iter_mut().find(|e| &e.panel == panel), Node::Split { first, second, .. } => stack_entry_mut(first, panel).or_else(|| stack_entry_mut(second, panel)), - Node::Tabs { .. } => None, + Node::Tabs { .. } | Node::Dock { .. } => None, } } @@ -652,6 +1104,7 @@ fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: Node::Split { first, second, .. } => return insert_node(first, entry, anchor, placement) || insert_node(second, entry, anchor, placement), Node::Tabs { panels, .. } => panels.contains(anchor), Node::Stack { entries } => entries.iter().any(|e| &e.panel == anchor), + Node::Dock { .. } => false, }; if !here { return false; @@ -668,7 +1121,7 @@ fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: Node::Tabs { panels, active } => { let index = match placement { Placement::Tab { before } => before.as_ref().and_then(|p| panels.iter().position(|q| q == p)).unwrap_or(panels.len()), - Placement::Split(_) => panels.iter().position(|p| p == anchor).map_or(panels.len(), |i| i + 1), + _ => panels.iter().position(|p| p == anchor).map_or(panels.len(), |i| i + 1), }; panels.insert(index, entry.panel.clone()); *active = index; @@ -676,11 +1129,11 @@ fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: Node::Stack { entries } => { let index = match placement { Placement::Tab { before } => before.as_ref().and_then(|p| entries.iter().position(|e| &e.panel == p)).unwrap_or(entries.len()), - Placement::Split(_) => entries.iter().position(|e| &e.panel == anchor).map_or(entries.len(), |i| i + 1), + _ => entries.iter().position(|e| &e.panel == anchor).map_or(entries.len(), |i| i + 1), }; entries.insert(index, entry.clone()); } - Node::Split { .. } => return false, + Node::Split { .. } | Node::Dock { .. } => return false, } true } @@ -689,7 +1142,7 @@ fn group_location(panels: &[P], panel: &P) -> Option> let index = panels.iter().position(|p| p == panel)?; let anchor = panels.iter().find(|p| *p != panel).cloned(); let before = panels.get(index + 1).cloned(); - Some(Location { floating: None, anchor, placement: Placement::Tab { before }, size: None, stack: None, sibling: Vec::new() }) + Some(Location { floating: None, anchor, placement: Placement::Tab { before }, size: None, stack: None, sibling: Vec::new(), group: None }) } fn first_panel

(node: &Node

) -> Option<&P> { @@ -697,6 +1150,7 @@ fn first_panel

(node: &Node

) -> Option<&P> { Node::Tabs { panels, .. } => panels.first(), Node::Stack { entries } => entries.first().map(|e| &e.panel), Node::Split { first, second, .. } => first_panel(first).or_else(|| first_panel(second)), + Node::Dock { columns, .. } => columns.iter().flat_map(|c| c.groups.iter()).find_map(|g| g.panels.first()), } } @@ -733,6 +1187,7 @@ fn node_location(node: &Node

, panel: &P) -> Option } Some(location) } + Node::Dock { .. } => None, } } @@ -773,6 +1228,7 @@ fn node_members<'a, P>(node: &'a Node

, members: &mut Vec<&'a P>) { node_members(first, members); node_members(second, members); } + Node::Dock { columns, .. } => members.extend(columns.iter().flat_map(|c| c.groups.iter()).flat_map(|g| g.panels.iter())), } } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 53b81372..545bc432 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -208,7 +208,9 @@ impl DockArea

{ || matches(self.id.with(("move-group", panels.first()))) }; for floating in &layout.floating { - if group(&floating.panels) || floating.panels.first().is_some_and(|first| matches(self.id.with(("floating", first)).with("resize"))) { + if floating.groups.iter().any(|g| group(&g.panels)) + || floating.panels().next().is_some_and(|first| matches(self.id.with(("floating", first)).with("resize"))) + { return true; } } @@ -216,6 +218,7 @@ impl DockArea

{ while let Some((node, path)) = nodes.pop() { match node { Node::Tabs { panels, .. } if group(panels) => return true, + Node::Dock { columns, .. } if columns.iter().flat_map(|c| c.groups.iter()).any(|g| group(&g.panels)) => return true, Node::Stack { entries } => { if entries.iter().any(|entry| { matches(self.id.with(("tab", &entry.panel))) @@ -401,6 +404,17 @@ impl Renderer<'_, P> { } } Node::Tabs { panels, active } => self.group(ui, panels, *active, rect, false), + Node::Dock { columns, .. } => { + // Interim rendering: columns side by side, groups stacked evenly. + let total: f32 = columns.iter().map(|c| c.width).sum(); + let mut left = rect.left(); + for column in columns { + let width = if total > 0.0 { rect.width() * column.width / total } else { 0.0 }; + let column_rect = Rect::from_min_max(pos2(left, rect.top()), pos2(left + width, rect.bottom())); + left += width; + self.stacked_groups(ui, &column.groups, column_rect, false); + } + } Node::Stack { entries } => { let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height()); let remaining = (rect.height() - total_header).max(0.0); @@ -487,8 +501,19 @@ impl Renderer<'_, P> { } } + /// Groups stacked top to bottom in equal shares of `rect`. + fn stacked_groups(&mut self, ui: &mut Ui, groups: &[super::Group

], rect: Rect, floating: bool) { + let share = rect.height() / groups.len().max(1) as f32; + for (index, group) in groups.iter().enumerate() { + let top = rect.top() + share * index as f32; + let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), top + share)); + self.group(ui, &group.panels, group.active, group_rect, floating); + } + } + fn node_limits(&self, node: &Node

) -> PanelLimits { match node { + Node::Dock { .. } => PanelLimits { min: Vec2::splat(self.style.min_pane), ..PanelLimits::default() }, Node::Tabs { panels, .. } => { let mut limits = PanelLimits::default(); for panel in panels { @@ -750,17 +775,17 @@ impl Renderer<'_, P> { let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); let mut groups: Vec<_> = self.layout.floating.iter().collect(); groups.sort_by_key(|group| { - let id = group.panels.first().map(|panel| self.id.with(("floating", panel))); + let id = group.panels().next().map(|panel| self.id.with(("floating", panel))); layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX) }); for group in groups { - let Some(first) = group.panels.first() else { continue }; + let Some(first) = group.panels().next() else { continue }; let [x, y, width, height] = group.rect; - let limits = self.node_limits(&Node::Tabs { panels: group.panels.clone(), active: group.active }); + let limits = self.node_limits(&Node::Tabs { panels: group.panels().cloned().collect(), active: 0 }); let size = vec2(width, height).max(limits.min).min(limits.max); let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen); let id = self.id.with(("floating", first)); - if self.activation.as_ref().is_some_and(|panel| group.panels.contains(panel)) { + if self.activation.as_ref().is_some_and(|panel| group.contains(panel)) { ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); self.output.focused = self.activation.clone(); @@ -769,7 +794,7 @@ impl Renderer<'_, P> { ui.set_min_size(rect.size()); let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); ui.allocate_rect(rect, Sense::hover()); - self.group(ui, &group.panels, group.active, rect, true); + self.stacked_groups(ui, &group.groups, rect, true); let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max); let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); self.register_interaction(ui.ctx(), response.id); @@ -813,7 +838,7 @@ impl Renderer<'_, P> { fn finish_group_drag(&mut self, ctx: &Context) { let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("group-drag"))) else { return }; - if !self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel) && g.panels.iter().all(|p| (self.permissions)(p).movable)) { + if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| (self.permissions)(p).movable)) { ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); return; } @@ -873,7 +898,7 @@ impl Renderer<'_, P> { let action = if let Some(proposal) = proposal.filter(|_| !gone) { Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) } else if (self.permissions)(&drag.panel).floatable - && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel))) + && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))) { let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 941220db..68401324 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -14,7 +14,7 @@ fn sample() -> Layout { first: Box::new(tabs(&[1], 0)), second: Box::new(tabs(&[2, 3, 4], 1)), }), - floating: vec![Floating { panels: vec![5, 6], active: 1, rect: [80.0, 70.0, 300.0, 220.0] }], + floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])], } } @@ -29,8 +29,8 @@ fn floating_brush_panel_joins_main_tabs_without_losing_other_panels() { let Node::Split { size, second, .. } = layout.root.as_ref().unwrap() else { panic!() }; assert_eq!(*size, SplitSize::FixedSecond(240.0)); assert_eq!(**second, tabs(&[2, 3, 5, 4], 2)); - assert_eq!(layout.floating[0].panels, vec![6]); - assert_eq!(layout.floating[0].active, 0); + assert_eq!(layout.floating[0].groups[0].panels, vec![6]); + assert_eq!(layout.floating[0].groups[0].active, 0); layout.validate().unwrap(); assert_eq!(layout.panels().len(), 6); } @@ -43,8 +43,8 @@ fn detach_redock_merge_and_collapse_empty_source_containers() { layout.apply(move_to(1, 2, Zone::Left)).unwrap(); assert_eq!(layout.floating.len(), 1); layout.apply(move_to(5, 6, Zone::Center)).unwrap(); - assert_eq!(layout.floating[0].panels, vec![6, 5]); - assert_eq!(layout.floating[0].active, 1); + assert_eq!(layout.floating[0].groups[0].panels, vec![6, 5]); + assert_eq!(layout.floating[0].groups[0].active, 1); layout.apply(move_to(6, 3, Zone::Center)).unwrap(); layout.apply(move_to(5, 3, Zone::Center)).unwrap(); assert!(layout.floating.is_empty()); @@ -154,7 +154,7 @@ fn close_and_restore_preserve_fixed_side_and_floating_geometry() { first: Box::new(tabs(&[1], 0)), second: Box::new(tabs(&[2], 0)), }), - floating: vec![Floating { panels: vec![3], active: 0, rect: [41.0, 52.0, 233.0, 244.0] }], + floating: vec![Floating::single(vec![3], 0, [41.0, 52.0, 233.0, 244.0])], }; let saved = layout.clone(); let first = layout.location(&1).unwrap(); @@ -197,12 +197,12 @@ fn closing_and_reopening_an_entire_accordion_restores_its_kind_and_heights() { #[test] fn invalid_saved_layouts_reject_duplicates_selection_depth_and_nonfinite_geometry() { let mut duplicate = sample(); - duplicate.floating[0].panels.push(1); + duplicate.floating[0].groups[0].panels.push(1); assert_eq!(duplicate.validate(), Err(DockError::DuplicatePanel)); let mut invalid = sample(); - invalid.floating[0].active = usize::MAX; + invalid.floating[0].groups[0].active = usize::MAX; assert_eq!(invalid.validate(), Err(DockError::InvalidSelection)); - invalid.floating[0].active = 0; + invalid.floating[0].groups[0].active = 0; invalid.floating[0].rect[3] = f32::INFINITY; assert_eq!(invalid.validate(), Err(DockError::InvalidGeometry)); let mut deep = tabs(&[0], 0); @@ -404,7 +404,7 @@ fn actual_pointer_drag_docks_a_floating_panel_into_the_main_tab_strip() { step(&ctx, &mut layout, vec![Event::PointerMoved(target)]); let actions = step(&ctx, &mut layout, vec![pointer_button(target, false)]); assert!(actions.iter().any(|a| matches!(a, Action::Move { panel: 5, .. })), "{actions:?}"); - assert!(!layout.floating.iter().any(|g| g.panels.contains(&5))); + assert!(!layout.floating.iter().any(|g| g.contains(&5))); assert_eq!(layout.panels().len(), 6); let Node::Split { second, .. } = layout.root.as_ref().unwrap() else { panic!() }; let Node::Tabs { panels, active } = second.as_ref() else { panic!() }; @@ -429,7 +429,7 @@ fn tab_dragged_out_of_the_window_floats_where_it_left() { step(&ctx, &mut layout, vec![Event::PointerMoved(exit)]); let actions = step(&ctx, &mut layout, vec![Event::PointerGone]); assert!(actions.iter().any(|a| matches!(a, Action::Float { panel: 3, .. })), "{actions:?}"); - assert!(layout.floating.iter().any(|g| g.panels == [3]), "{layout:?}"); + assert!(layout.floating.iter().any(|g| g.panels().eq([3].iter())), "{layout:?}"); let back = pos2(400.0, 300.0); assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(back)]).is_empty()); assert!(step(&ctx, &mut layout, vec![pointer_button(back, false)]).is_empty(), "the drag ended when the pointer left"); @@ -634,7 +634,7 @@ fn restoring_a_panel_beside_a_nested_subtree_preserves_the_complete_workspace() #[test] fn floating_only_surface_does_not_allocate_the_callers_document_region() { let ctx = Context::default(); - let layout = Layout { root: None, floating: vec![Floating { panels: vec![1u32], active: 0, rect: [30.0, 40.0, 200.0, 200.0] }] }; + let layout = Layout { root: None, floating: vec![Floating::single(vec![1u32], 0, [30.0, 40.0, 200.0, 200.0])] }; ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| { let before = ui.available_rect_before_wrap(); let out = DockArea::new(Id::new("float-only")).show( @@ -666,8 +666,8 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]); assert!(actions.iter().any(|a| matches!(a, Action::MoveFloating { .. }))); - assert_eq!(layout.floating[0].panels, original.panels); - assert_eq!(layout.floating[0].active, original.active); + assert_eq!(layout.floating[0].groups[0].panels, original.groups[0].panels); + assert_eq!(layout.floating[0].groups[0].active, original.groups[0].active); assert_ne!(layout.floating[0].rect, original.rect); step(&ctx, &mut layout, vec![pointer_button(start + vec2(50.0, 40.0), false)]); for _ in 0..2 { @@ -680,7 +680,7 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { let detached = step(&ctx, &mut layout, vec![pointer_button(tab + vec2(100.0, 100.0), false)]); assert!(detached.iter().any(|a| matches!(a, Action::Float { panel: 5, .. }))); assert_eq!(layout.floating.len(), 2); - assert_eq!(layout.floating[0].panels, vec![6]); + assert_eq!(layout.floating[0].groups[0].panels, vec![6]); } #[test] @@ -1026,7 +1026,7 @@ fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &layout)); paint(tab); let mut invalid = layout.clone(); - invalid.floating.push(Floating { panels: vec![3], active: 0, rect: [10.0, 10.0, 100.0, 100.0] }); + invalid.floating.push(Floating::single(vec![3], 0, [10.0, 10.0, 100.0, 100.0])); assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &invalid)); assert!(!DockArea::::new(Id::new("focus-area")).chrome_has_focus(&ctx, &Layout::default())); ctx.memory_mut(|memory| memory.request_focus(Id::new("unrendered"))); diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 996c29e2..29a6c9d9 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -55,7 +55,7 @@ impl Harness { first: Box::new(Node::Tabs { panels: vec![CANVAS], active: 0 }), second: Box::new(Node::Tabs { panels: vec![LAYERS, PROPERTIES], active: 0 }), }), - floating: vec![Floating { panels: vec![BRUSHES], active: 0, rect: [38.0, 76.0, 210.0, 250.0] }], + floating: vec![Floating::single(vec![BRUSHES], 0, [38.0, 76.0, 210.0, 250.0])], }; let mut harness = Self { ctx, @@ -235,7 +235,7 @@ fn floating_brushes_merge_detach_and_redock_through_real_pointer_input() { assert_eq!(h.body_ids[&BRUSHES], brush_id); h.capture(&directory, "02-merged-main-tabs"); h.drag(h.tab(BRUSHES).center(), pos2((width - 240.0) * 0.5, 270.0)); - assert!(h.layout.floating.iter().any(|g| g.panels.contains(&BRUSHES)), "{name}: {:?}", h.applied); + assert!(h.layout.floating.iter().any(|g| g.contains(&BRUSHES)), "{name}: {:?}", h.applied); h.capture(&directory, "03-detached-again"); h.drag(h.tab(BRUSHES).center(), h.tab(LAYERS).left_center() + vec2(4.0, 0.0)); assert!(h.layout.floating.is_empty(), "{name}");