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 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 Column {
+ pub fn new(groups: Vec {
+ /// 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 {
+ pub groups: Vec 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 {
+ pub(super) fn place(&self, panel: &P) -> Option , Vec , Vec > {
+ 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 , 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 ) -> 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 , 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 {
pub height: Option {
- pub panels: Vec ,
- pub active: usize,
+ pub groups: Vec 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 ],
+ rect: [f32; 4],
+ #[serde(skip_serializing_if = "Option::is_none")]
+ native: Option<[f32; 4]>,
+ }
+
+ impl {
+ fn serialize {
+ groups: Option {
+ fn deserialize ::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 {
Move {
panel: P,
@@ -141,6 +242,62 @@ pub enum Action {
panel: P,
height: Option ,
+ },
+ /// 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 {
}
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 {
}
}
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 {
}
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 {
/// Capture a panel's nearest surviving group/split neighbor before hiding it.
pub fn location(&self, panel: &P) -> Result >| 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 {
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 {
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 {
Ok(())
}
+ /// Apply every action in order, or none: the first rejection leaves the layout unchanged.
+ pub fn apply_all(&mut self, actions: impl IntoIterator ) -> Result ) -> Result<(), DockError> {
match action {
Action::Move { panel, anchor, placement } => {
@@ -432,19 +785,20 @@ impl {
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 {
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 {
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 , 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 , 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 {
}
}
+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 , panel: &P, removed: &mut Option , 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 , 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 , 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 , 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 (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 , panel: &P) -> Option , 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 {
|| 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 {
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 ], 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