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.
This commit is contained in:
aiongg committed 2026-10-19 04:35:58 +00:00
1 parent c4b007706d
commit 0ae7cddbb5
6 files changed
+1140 -84

No files matched your search

+573
View File
@@ -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<P> {
pub panels: Vec<P>,
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<f32>,
}
impl<P> Group<P> {
pub fn new(panels: Vec<P>, 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<P> {
pub groups: Vec<Group<P>>,
/// 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<f32>,
}
impl<P> Column<P> {
pub fn new(groups: Vec<Group<P>>, 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<P> {
/// Relative to the panel `anchor`, which must not be part of what moves.
At { anchor: P, placement: Placement<P> },
/// 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<f32>,
/// The column, when the group was docked.
pub column: Option<ColumnLocation>,
}
#[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<f32>,
}
/// 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<bool>),
Dock {
path: Vec<bool>,
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<P> {
pub groups: Vec<Group<P>>,
pub width: Option<f32>,
pub iconic: bool,
pub iconic_width: Option<f32>,
pub rect: Option<[f32; 4]>,
pub native: Option<[f32; 4]>,
}
impl<P> Moving<P> {
pub fn group(group: Group<P>) -> Self {
Self { groups: vec![group], width: None, iconic: false, iconic_width: None, rect: None, native: None }
}
fn into_column(self, width: f32) -> Column<P> {
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<P>, path: &[bool]) -> Option<&'a Node<P>> {
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<P>, path: &[bool]) -> Option<&'a mut Node<P>> {
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<P>(panels: &mut Vec<P>, active: &mut usize, index: usize, new: Vec<P>, 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<P: Clone + Eq + Hash> Layout<P> {
pub(super) fn place(&self, panel: &P) -> Option<Place> {
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<P>, Vec<bool>)> = 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<Vec<bool>> {
let mut visit: Vec<(&Node<P>, Vec<bool>)> = 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<Column<P>>> {
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<P>> {
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<P>> {
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<P>> {
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<P>> {
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<Node<P>> {
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<Vec<P>, 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<Moving<P>, 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<Moving<P>, 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<Moving<P>, 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<Moving<P>, 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<P>, to: Target<P>) -> 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<P>) {
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<usize> {
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<usize>, moving: Moving<P>) -> 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<P>(groups: Vec<Group<P>>) -> (Vec<P>, 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<P>(column: &Column<P>) -> bool {
valid_width(column.width) && column.iconic_width.is_none_or(valid_width)
}
+2
View File
@@ -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};
+514 -58
View File
@@ -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<P> {
Split {
axis: SplitAxis,
@@ -35,6 +37,13 @@ pub enum Node<P> {
Stack {
entries: Vec<StackEntry<P>>,
},
/// 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<Column<P>>,
},
}
#[derive(Clone, Debug, PartialEq)]
@@ -45,13 +54,93 @@ pub struct StackEntry<P> {
pub height: Option<f32>,
}
/// 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<P> {
pub panels: Vec<P>,
pub active: usize,
pub groups: Vec<Group<P>>,
/// `[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<P> Floating<P> {
pub fn single(panels: Vec<P>, active: usize, rect: [f32; 4]) -> Self {
Self { groups: vec![Group::new(panels, active)], rect, native: None }
}
pub fn panels(&self) -> impl Iterator<Item = &P> {
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<P>],
rect: [f32; 4],
#[serde(skip_serializing_if = "Option::is_none")]
native: Option<[f32; 4]>,
}
impl<P: Serialize> Serialize for Floating<P> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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<P> {
groups: Option<Vec<Group<P>>>,
panels: Option<Vec<P>>,
#[serde(default)]
active: usize,
rect: [f32; 4],
#[serde(default)]
native: Option<[f32; 4]>,
}
impl<'de, P: Deserialize<'de>> Deserialize<'de> for Floating<P> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let repr = Repr::<P>::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<P> Default for Layout<P> {
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Placement<P> {
/// Insert before this existing destination member, or append. Also reorders a group.
Tab { before: Option<P> },
/// 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<P> {
/// rather than accidentally inserting beside only its first leaf when restoring.
#[cfg_attr(feature = "serde", serde(default))]
pub sibling: Vec<P>,
/// 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<GroupLocation>,
}
/// 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<P> {
Move {
panel: P,
@@ -141,6 +242,62 @@ pub enum Action<P> {
panel: P,
height: Option<f32>,
},
/// 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<P>,
},
/// Move the dock column containing `panel`. `Float` makes a window of its groups.
MoveColumn {
panel: P,
to: Target<P>,
},
/// Dock or merge the floating window containing `panel`, every group included.
MoveWindow {
panel: P,
to: Target<P>,
},
/// 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<f32>,
},
ResizeIconic {
panel: P,
width: Option<f32>,
},
/// 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<P: Clone + Eq + Hash> Layout<P> {
}
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<P: Clone + Eq + Hash> Layout<P> {
}
}
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<P: Clone + Eq + Hash> Layout<P> {
}
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<P: Clone + Eq + Hash> Layout<P> {
/// Capture a panel's nearest surviving group/split neighbor before hiding it.
pub fn location(&self, panel: &P) -> Result<Location<P>, 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<P>>| 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<P>>| 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<P: Clone + Eq + Hash> Layout<P> {
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<P: Clone + Eq + Hash> Layout<P> {
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<P: Clone + Eq + Hash> Layout<P> {
Ok(())
}
/// Apply every action in order, or none: the first rejection leaves the layout unchanged.
pub fn apply_all(&mut self, actions: impl IntoIterator<Item = Action<P>>, 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<P>) -> Result<Vec<P>, 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<P>) -> Result<(), DockError> {
match action {
Action::Move { panel, anchor, placement } => {
@@ -432,19 +785,20 @@ impl<P: Clone + Eq + Hash> Layout<P> {
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<P: Clone + Eq + Hash> Layout<P> {
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<P: Clone + Eq + Hash> Layout<P> {
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<P>, 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<P: Eq>(node: &Node<P>, 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<StackEntry<P>> {
pub(super) fn take(&mut self, panel: &P) -> Option<StackEntry<P>> {
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<P>, anchor: &P, placement: Placement<P>) -> 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<P: Clone + Eq + Hash> Layout<P> {
}
}
fn unit_of<P>(action: &Action<P>) -> 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<P>, 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<P: Clone + Eq>(node: Node<P>, panel: &P, removed: &mut Option<Sta
(node, None) | (None, node) => 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<P: Eq>(node: &mut Node<P>, 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<P>, 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<P: Clone + Eq>(node: &mut Node<P>, entry: &StackEntry<P>, 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<P: Clone + Eq>(node: &mut Node<P>, entry: &StackEntry<P>, 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<P: Clone + Eq>(node: &mut Node<P>, entry: &StackEntry<P>, 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<P: Clone + Eq>(panels: &[P], panel: &P) -> Option<Location<P>>
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<P>(node: &Node<P>) -> Option<&P> {
@@ -697,6 +1150,7 @@ fn first_panel<P>(node: &Node<P>) -> 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<P: Clone + Eq>(node: &Node<P>, panel: &P) -> Option<Location<P>
}
Some(location)
}
Node::Dock { .. } => None,
}
}
@@ -773,6 +1228,7 @@ fn node_members<'a, P>(node: &'a Node<P>, 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())),
}
}
+33 -8
View File
@@ -208,7 +208,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
|| 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
}
/// Groups stacked top to bottom in equal shares of `rect`.
fn stacked_groups(&mut self, ui: &mut Ui, groups: &[super::Group<P>], 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<P>) -> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let layers = ctx.memory(|memory| memory.layer_ids().collect::<Vec<_>>());
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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
fn finish_group_drag(&mut self, ctx: &Context) {
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag<P>>(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::<GroupDrag<P>>(self.id.with("group-drag")));
return;
}
@@ -873,7 +898,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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()] })
+16 -16
View File
@@ -14,7 +14,7 @@ fn sample() -> Layout<u32> {
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::<u32>::new(Id::new("focus-area")).chrome_has_focus(&ctx, &Layout::default()));
ctx.memory_mut(|memory| memory.request_focus(Id::new("unrendered")));
+2 -2
View File
@@ -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}");