diff --git a/Cargo.lock b/Cargo.lock index 6d23027..2fbd433 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,6 +379,8 @@ dependencies = [ "egui_extras", "egui_kittest", "image", + "serde", + "serde_json", ] [[package]] @@ -640,6 +642,7 @@ dependencies = [ "bytemuck", "ecolor", "emath", + "epaint_default_fonts", "font-types", "harfrust", "log", @@ -654,6 +657,12 @@ dependencies = [ "vello_cpu", ] +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + [[package]] name = "equivalent" version = "1.0.2" @@ -976,6 +985,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.22.4" @@ -2098,6 +2113,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.1.2" @@ -3346,6 +3374,12 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.5.3" diff --git a/NOTICE b/NOTICE index 7774abe..76fd456 100644 --- a/NOTICE +++ b/NOTICE @@ -37,4 +37,8 @@ crates/ui-egui/src/theme.rs, and: - DesignCraft, crates/ui-egui/src/theme.rs, revision 66c7ce7e96ec286a091878cc2541ee8369aa79d6. https://github.com/storytold/designcraft Copyright (c) 2026 ArtCraft Team and the DesignCraft contributors. +The transactional panel model and drop-zone semantics in crates/ui/src/docking draw on the +FilmCraft/EffectCraft split, tab and accordion layouts and VectorCraft floating groups listed +above. The generic transaction validation, optional renderer and workspace-location helpers +are implemented here without dependencies on application engines or native window helpers. All sources listed above are licensed MIT OR Apache-2.0. No icon artwork is copied here. diff --git a/README.md b/README.md index e0e78b7..b4d2d7e 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,14 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` owns panel placement independently of application content: a tree of split, +tab and accordion nodes plus floating tab groups. `apply(Action)` validates the complete layout, +applies a proposed change to a copy and commits only a valid result. Moves, tab insertion/reorder, +side splits, detach, activate, close, reveal and resizing use this one transaction path. Panel IDs +must be unique, stable values; labels and document state are supplied by the app. + +`Layout::location` captures a panel's placement before hiding it; `restore` returns it there if +the remembered destination still exists. Apps choose an explicit fallback when it does not. +Enable the optional `serde` feature to persist the shared layout, or convert to/from an existing +workspace schema. Validate loaded input before rendering or recursively converting it. + +`DockArea` is an optional renderer. It takes a layout snapshot, styles, labels, panel permissions +and body callbacks, then returns actions for the app's command handler. Its floating groups stay +inside the current egui viewport. Protected document canvases have no tab header. Per-panel size +limits constrain geometry without overwriting saved preferred widths when the window shrinks. +Applications with established chrome can use the shared transaction model and `resolve_drop` +without changing their renderer. See [the docking guide](../../docs/ui-docking.md) for details. + ## Toolbar overflow `layout::fit_toolbar(natural_widths, available, spacing, overflow_width)` keeps an ordered diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs new file mode 100644 index 0000000..b0defc5 --- /dev/null +++ b/crates/ui/src/docking/mod.rs @@ -0,0 +1,17 @@ +//! Shared panel docking: transactional layout changes and egui drag/drop presentation. +//! +//! Panel IDs identify application-owned contents, never document state or native windows. A +//! layout can be adapted to an existing workspace schema or serialized with the `serde` feature. +//! 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 model; +mod surface; +mod targets; + +pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; +pub use surface::{DockArea, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag}; +pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect}; + +#[cfg(test)] +mod tests; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs new file mode 100644 index 0000000..210857f --- /dev/null +++ b/crates/ui/src/docking/model.rs @@ -0,0 +1,790 @@ +use std::{collections::HashSet, fmt, hash::Hash}; + +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; +const MAX_EXTENT: f32 = 1_000_000.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Zone { + Center, + Left, + Right, + Top, + Bottom, +} + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Node

{ + Split { + axis: SplitAxis, + size: SplitSize, + first: Box>, + second: Box>, + }, + Tabs { + panels: Vec

, + active: usize, + }, + /// An accordion keeps its open state and preferred heights when other panels move. + Stack { + entries: Vec>, + }, +} + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct StackEntry

{ + pub panel: P, + pub open: bool, + pub height: Option, +} + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Floating

{ + pub panels: Vec

, + pub active: usize, + /// `[x, y, width, height]` in logical points, in the application's egui viewport. + pub rect: [f32; 4], +} + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Layout

{ + pub root: Option>, + pub floating: Vec>, +} + +impl

Default for Layout

{ + fn default() -> Self { + Self { root: None, floating: Vec::new() } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +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. + Split(Zone), +} + +/// A closed panel's former placement. Applications may retain this beside visibility flags. +/// Anchors are stable panel identities rather than mutable tree indices. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Location

{ + pub floating: Option<[f32; 4]>, + pub anchor: Option

, + pub placement: Placement

, + pub size: Option, + /// Accordion state to restore if the destination is still an accordion. + pub stack: Option<(bool, Option)>, + /// A split's complete sibling subtree. This retains placement around a nested workspace, + /// rather than accidentally inserting beside only its first leaf when restoring. + #[cfg_attr(feature = "serde", serde(default))] + pub sibling: Vec

, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Action

{ + Move { + panel: P, + anchor: P, + placement: Placement

, + }, + Float { + panel: P, + rect: [f32; 4], + }, + Close { + panel: P, + }, + Activate { + panel: P, + }, + /// Reveal a registered, currently absent panel. Applications validate their panel registry. + Open { + panel: P, + anchor: Option

, + }, + /// Reveal an absent panel beside a protected canvas or at another explicit destination. + OpenAt { + panel: P, + anchor: P, + placement: Placement

, + }, + /// Reposition/resize a floating group, identified by one of its panels. + MoveFloating { + panel: P, + rect: [f32; 4], + }, + /// Path from the root: false is the first child, true the second. + ResizeSplit { + path: Vec, + size: SplitSize, + }, + SetStackOpen { + panel: P, + open: bool, + }, + ResizeStack { + panel: P, + height: Option, + }, +} + +/// Rendering and command adapters can protect a document canvas while leaving panels movable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Permissions { + pub movable: bool, + pub floatable: bool, + pub closable: bool, + pub accepts_tabs: bool, +} + +impl Default for Permissions { + fn default() -> Self { + Self { movable: true, floatable: true, closable: true, accepts_tabs: true } + } +} + +impl Permissions { + pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false }; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DockError { + LimitExceeded, + DuplicatePanel, + MissingPanel, + MissingTarget, + InvalidSelection, + InvalidGeometry, + InvalidPlacement, + NotAllowed, +} + +impl fmt::Display for DockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::LimitExceeded => "docking layout exceeds its size or depth limit", + Self::DuplicatePanel => "panel occurs in more than one docking location", + Self::MissingPanel => "source panel is not present in the docking layout", + Self::MissingTarget => "docking destination is no longer present", + Self::InvalidSelection => "active docking tab is out of bounds", + 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", + }) + } +} + +impl std::error::Error for DockError {} + +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) +} + +fn valid_size(size: SplitSize) -> bool { + match size { + SplitSize::Ratio(r) => r.is_finite() && (0.0..=1.0).contains(&r), + SplitSize::FixedFirst(v) | SplitSize::FixedSecond(v) => v.is_finite() && (0.0..=MAX_EXTENT).contains(&v), + } +} + +fn valid_height(height: Option) -> bool { + height.is_none_or(|v| v.is_finite() && (0.0..=MAX_EXTENT).contains(&v)) +} + +fn valid_active(len: usize, active: usize) -> bool { + active < len || (len == 0 && active == 0) +} + +impl Layout

{ + /// Check depth, allocation bounds, geometry, selection and globally unique panel ownership. + /// Traversal is iterative, including for an invalid, excessively deep supplied tree. + pub fn validate(&self) -> Result<(), DockError> { + if self.floating.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + 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() { + count = count.saturating_add(1); + if depth > MAX_DEPTH || count > MAX_NODES { + return Err(DockError::LimitExceeded); + } + match node { + Node::Split { size, first, second, .. } => { + if !valid_size(*size) { + return Err(DockError::InvalidGeometry); + } + visit.push((second, depth + 1)); + visit.push((first, depth + 1)); + } + Node::Tabs { panels, active } => { + check_tabs(panels, *active, &mut seen)?; + } + Node::Stack { entries } => { + if entries.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + for entry in entries { + if !valid_height(entry.height) { + return Err(DockError::InvalidGeometry); + } + check_panel(&entry.panel, &mut seen)?; + } + } + } + } + for floating in &self.floating { + if !valid_rect(floating.rect) || floating.panels.is_empty() { + return Err(DockError::InvalidGeometry); + } + check_tabs(&floating.panels, floating.active, &mut seen)?; + } + Ok(()) + } + + /// Iterate identities without recursion. On excessive input returns no more than the limit. + pub fn panels(&self) -> Vec<&P> { + let mut panels = Vec::new(); + let mut visit: Vec<&Node

> = self.root.iter().collect(); + let mut visited = 0usize; + while let Some(node) = visit.pop() { + visited = visited.saturating_add(1); + if visited > MAX_NODES || panels.len() >= MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(second); + visit.push(first); + } + 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())), + } + } + for group in self.floating.iter().take(MAX_PANELS) { + panels.extend(group.panels.iter().take(MAX_PANELS - panels.len())); + } + panels + } + + pub fn contains(&self, panel: &P) -> bool { + self.panels().contains(&panel) + } + + /// Capture a panel's nearest surviving group/split neighbor before hiding it. + pub fn location(&self, panel: &P) -> Result, DockError> { + self.validate()?; + if let Some(group) = self.floating.iter().find(|g| g.panels.contains(panel)) { + let mut location = group_location(&group.panels, panel).ok_or(DockError::MissingPanel)?; + location.floating = Some(group.rect); + return Ok(location); + } + self.root.as_ref().and_then(|node| node_location(node, panel)).ok_or(DockError::MissingPanel) + } + + /// Restore an absent panel at a captured location. Missing anchors are reported without + /// changes, allowing the app to choose an explicit default. Unrelated tabs retain selection. + pub fn restore(&mut self, panel: P, location: &Location

) -> Result<(), DockError> { + self.validate()?; + if self.contains(&panel) { + return Err(DockError::DuplicatePanel); + } + if location.size.is_some_and(|s| !valid_size(s)) || location.stack.is_some_and(|(_, h)| !valid_height(h)) { + return Err(DockError::InvalidGeometry); + } + 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)) { + return Err(DockError::MissingTarget); + } + 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 }); + } + } else if let Some(anchor) = &location.anchor { + if !proposed.contains(anchor) { + return Err(DockError::MissingTarget); + } + if let Placement::Split(zone @ (Zone::Left | Zone::Right | Zone::Top | Zone::Bottom)) = location.placement + && !location.sibling.is_empty() + { + if location.sibling.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + let members: HashSet<&P> = location.sibling.iter().collect(); + if members.len() != location.sibling.len() || !members.contains(anchor) { + return Err(DockError::InvalidPlacement); + } + let inserted = proposed + .root + .as_mut() + .is_some_and(|root| restore_around_subtree(root, &panel, &members, zone, location.size.unwrap_or(SplitSize::Ratio(0.5)))); + if !inserted { + return Err(DockError::MissingTarget); + } + } else { + proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; + if let Some(size) = location.size + && let Some(root) = proposed.root.as_mut() + { + restore_parent_size(root, &panel, size); + } + } + } else if proposed.root.is_none() { + proposed.root = Some(Node::Tabs { panels: vec![panel.clone()], active: 0 }); + } else { + return Err(DockError::MissingTarget); + } + if let Some((open, height)) = location.stack + && let Some(root) = proposed.root.as_mut() + { + restore_stack_kind(root, &panel); + if let Some(entry) = stack_entry_mut(root, &panel) { + entry.open = open; + entry.height = height; + } + } + proposed.validate()?; + *self = proposed; + Ok(()) + } + + pub fn apply(&mut self, action: Action

) -> Result<(), DockError> { + self.apply_with_permissions(action, |_| Permissions::default()) + } + + /// Check application policy before making an atomic change. The callback must be stable + /// throughout one call. Registry validation remains with the application, particularly Open. + pub fn apply_with_permissions(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + self.validate()?; + match &action { + Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { + if !permissions(panel).movable { + return Err(DockError::NotAllowed); + } + let tab = matches!(placement, Placement::Tab { .. } | Placement::Split(Zone::Center)); + if tab && !permissions(anchor).accepts_tabs { + return Err(DockError::NotAllowed); + } + } + Action::Float { panel, .. } if !permissions(panel).floatable || !permissions(panel).movable => return Err(DockError::NotAllowed), + 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), + _ => {} + } + let mut proposed = self.clone(); + proposed.apply_inner(action)?; + proposed.validate()?; + *self = proposed; + Ok(()) + } + + fn apply_inner(&mut self, action: Action

) -> Result<(), DockError> { + match action { + Action::Move { panel, anchor, placement } => { + if panel == anchor { + return Err(DockError::InvalidPlacement); + } + if !self.contains(&anchor) { + return Err(DockError::MissingTarget); + } + if let Placement::Tab { before: Some(before) } = &placement { + if before == &panel { + // A tab dropped immediately before itself has not moved. + return if self.same_group(&panel, &anchor) { Ok(()) } else { Err(DockError::InvalidPlacement) }; + } + if !self.same_group(before, &anchor) { + return Err(DockError::MissingTarget); + } + } + let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; + self.insert(entry, &anchor, placement) + } + Action::Float { panel, rect } => { + if !valid_rect(rect) { + return Err(DockError::InvalidGeometry); + } + let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; + self.floating.push(Floating { panels: vec![entry.panel], active: 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)) { + return Ok(()); + } + for group in &mut self.floating { + if let Some(i) = group.panels.iter().position(|p| p == &panel) { + group.active = i; + return Ok(()); + } + } + Err(DockError::MissingPanel) + } + Action::Open { panel, anchor } => { + if self.contains(&panel) { + return self.apply_inner(Action::Activate { panel }); + } + let entry = StackEntry { panel, open: true, height: None }; + if let Some(anchor) = anchor { + self.insert(entry, &anchor, Placement::Tab { before: None }) + } else if self.root.is_none() { + self.root = Some(Node::Tabs { panels: vec![entry.panel], active: 0 }); + Ok(()) + } else { + Err(DockError::MissingTarget) + } + } + Action::OpenAt { panel, anchor, placement } => { + if self.contains(&panel) { + return self.apply_inner(Action::Move { panel, anchor, placement }); + } + if let Placement::Tab { before: Some(before) } = &placement + && !self.same_group(before, &anchor) + { + return Err(DockError::MissingTarget); + } + self.insert(StackEntry { panel, open: true, height: None }, &anchor, placement) + } + Action::MoveFloating { panel, rect } => { + 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; + Ok(()) + } + Action::ResizeSplit { path, size } => { + if path.len() >= MAX_DEPTH { + return Err(DockError::LimitExceeded); + } + if !valid_size(size) { + return Err(DockError::InvalidGeometry); + } + let mut node = self.root.as_mut().ok_or(DockError::MissingTarget)?; + for second_child in path { + let Node::Split { first, second, .. } = node else { return Err(DockError::MissingTarget) }; + node = if second_child { second } else { first }; + } + let Node::Split { size: old, .. } = node else { return Err(DockError::MissingTarget) }; + *old = size; + Ok(()) + } + Action::SetStackOpen { panel, open } => { + let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; + entry.open = open; + Ok(()) + } + Action::ResizeStack { panel, height } => { + if !valid_height(height) { + return Err(DockError::InvalidGeometry); + } + let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; + entry.height = height; + Ok(()) + } + } + } + + fn same_group(&self, panel: &P, anchor: &P) -> bool { + fn in_node(node: &Node

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

, anchor: &P, placement: Placement

) -> Result<(), DockError> { + if let Some(group) = self.floating.iter_mut().find(|g| g.panels.contains(anchor)) { + let index = match placement { + Placement::Tab { before } => before.as_ref().and_then(|p| group.panels.iter().position(|q| q == p)).unwrap_or(group.panels.len()), + Placement::Split(Zone::Center) => group.panels.iter().position(|p| p == anchor).map_or(group.panels.len(), |i| i + 1), + Placement::Split(_) => return Err(DockError::InvalidPlacement), + }; + group.panels.insert(index, entry.panel); + group.active = index; + return Ok(()); + } + if let Some(root) = self.root.as_mut() + && insert_node(root, &entry, anchor, &placement) + { + return Ok(()); + } + Err(DockError::MissingTarget) + } +} + +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); + } + if !seen.insert(panel) { + return Err(DockError::DuplicatePanel); + } + Ok(()) +} + +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); + } + if !valid_active(panels.len(), active) { + return Err(DockError::InvalidSelection); + } + for panel in panels { + check_panel(panel, seen)?; + } + Ok(()) +} + +fn remove_tab(panels: &mut Vec

, active: &mut usize, panel: &P) -> Option

{ + let index = panels.iter().position(|p| p == panel)?; + let selected = panels.get(*active).cloned(); + let removed = panels.remove(index); + *active = selected.as_ref().and_then(|p| panels.iter().position(|q| q == p)).unwrap_or(index.min(panels.len().saturating_sub(1))); + Some(removed) +} + +fn remove_node(node: Node

, panel: &P, removed: &mut Option>) -> Option> { + match node { + Node::Tabs { mut panels, mut active } => { + if let Some(panel) = remove_tab(&mut panels, &mut active, panel) { + *removed = Some(StackEntry { panel, open: true, height: None }); + } + (!panels.is_empty()).then_some(Node::Tabs { panels, active }) + } + Node::Stack { mut entries } => { + if let Some(i) = entries.iter().position(|e| &e.panel == panel) { + *removed = Some(entries.remove(i)); + } + (!entries.is_empty()).then_some(Node::Stack { entries }) + } + Node::Split { axis, size, first, second } => { + let first = remove_node(*first, panel, removed); + let second = remove_node(*second, panel, removed); + match (first, second) { + (Some(first), Some(second)) => Some(Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }), + (node, None) | (None, node) => node, + } + } + } +} + +fn activate(node: &mut Node

, panel: &P) -> bool { + match node { + Node::Tabs { panels, active } => { + if let Some(i) = panels.iter().position(|p| p == panel) { + *active = i; + true + } else { + false + } + } + Node::Stack { entries } => { + if let Some(entry) = entries.iter_mut().find(|e| &e.panel == panel) { + entry.open = true; + true + } else { + false + } + } + Node::Split { first, second, .. } => activate(first, panel) || activate(second, panel), + } +} + +fn stack_entry_mut<'a, P: Eq>(node: &'a mut Node

, panel: &P) -> Option<&'a mut StackEntry

> { + 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, + } +} + +fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: &P, placement: &Placement

) -> bool { + let here = match node { + 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), + }; + if !here { + return false; + } + if let Placement::Split(zone @ (Zone::Left | Zone::Right | Zone::Top | Zone::Bottom)) = placement { + let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); + let new = Node::Tabs { panels: vec![entry.panel.clone()], 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) }; + return true; + } + match node { + 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.insert(index, entry.panel.clone()); + *active = index; + } + 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.insert(index, entry.clone()); + } + Node::Split { .. } => return false, + } + true +} + +fn group_location(panels: &[P], panel: &P) -> Option> { + let index = panels.iter().position(|p| p == panel)?; + let anchor = panels.iter().find(|p| *p != panel).cloned(); + let before = panels.get(index + 1).cloned(); + Some(Location { floating: None, anchor, placement: Placement::Tab { before }, size: None, stack: None, sibling: Vec::new() }) +} + +fn first_panel

(node: &Node

) -> Option<&P> { + match node { + 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)), + } +} + +fn node_location(node: &Node

, panel: &P) -> Option> { + match node { + Node::Tabs { panels, .. } => group_location(panels, panel), + Node::Stack { entries } => { + let entry = entries.iter().find(|e| &e.panel == panel)?; + let panels: Vec

= entries.iter().map(|e| e.panel.clone()).collect(); + let mut location = group_location(&panels, panel)?; + location.stack = Some((entry.open, entry.height)); + Some(location) + } + Node::Split { axis, size, first, second } => { + let (mut location, sibling, is_first) = if let Some(location) = node_location(first, panel) { + (location, second, true) + } else { + (node_location(second, panel)?, first, false) + }; + if location.anchor.is_none() + && let Some(anchor) = first_panel(sibling) + { + location.anchor = Some(anchor.clone()); + location.placement = Placement::Split(match (*axis, is_first) { + (SplitAxis::Horizontal, true) => Zone::Left, + (SplitAxis::Horizontal, false) => Zone::Right, + (SplitAxis::Vertical, true) => Zone::Top, + (SplitAxis::Vertical, false) => Zone::Bottom, + }); + location.size = Some(*size); + let mut members = Vec::new(); + node_members(sibling, &mut members); + location.sibling = members.into_iter().cloned().collect(); + } + Some(location) + } + } +} + +fn restore_parent_size(node: &mut Node

, panel: &P, size: SplitSize) -> bool { + fn alone(node: &Node

, panel: &P) -> bool { + matches!(node, Node::Tabs { panels, .. } if panels.len() == 1 && panels.first() == Some(panel)) + } + if let Node::Split { size: current, first, second, .. } = node { + if alone(first, panel) || alone(second, panel) { + *current = size; + true + } else { + restore_parent_size(first, panel, size) || restore_parent_size(second, panel, size) + } + } else { + false + } +} + +fn restore_stack_kind(node: &mut Node

, panel: &P) { + match node { + Node::Tabs { panels, .. } if panels.len() == 1 && panels.first() == Some(panel) => { + *node = Node::Stack { entries: vec![StackEntry { panel: panel.clone(), open: true, height: None }] }; + } + Node::Split { first, second, .. } => { + restore_stack_kind(first, panel); + restore_stack_kind(second, panel); + } + _ => {} + } +} + +fn node_members<'a, P>(node: &'a Node

, members: &mut Vec<&'a P>) { + match node { + Node::Tabs { panels, .. } => members.extend(panels), + Node::Stack { entries } => members.extend(entries.iter().map(|e| &e.panel)), + Node::Split { first, second, .. } => { + node_members(first, members); + node_members(second, members); + } + } +} + +fn restore_around_subtree(node: &mut Node

, panel: &P, members: &HashSet<&P>, zone: Zone, size: SplitSize) -> bool { + let mut actual = Vec::new(); + node_members(node, &mut actual); + if actual.len() == members.len() && actual.iter().all(|p| members.contains(p)) { + let new = Node::Tabs { panels: vec![panel.clone()], active: 0 }; + let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); + let (first, second) = if matches!(zone, Zone::Left | Zone::Top) { (new, old) } else { (old, new) }; + *node = Node::Split { + axis: if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }, + size, + first: Box::new(first), + second: Box::new(second), + }; + true + } else if let Node::Split { first, second, .. } = node { + restore_around_subtree(first, panel, members, zone, size) || restore_around_subtree(second, panel, members, zone, size) + } else { + false + } +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs new file mode 100644 index 0000000..1e7284a --- /dev/null +++ b/crates/ui/src/docking/surface.rs @@ -0,0 +1,615 @@ +use std::{fmt::Debug, hash::Hash}; + +use egui::{Color32, Context, FontId, Id, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, Vec2, pos2, vec2}; + +use crate::{ + layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect}, + tabs, +}; + +use super::{Action, DockError, DropTarget, Layout, Node, Permissions, Placement, resolve_drop}; + +/// Application-supplied chrome. Body renderers retain their own UI style and contents. +#[derive(Clone, Debug)] +pub struct DockStyle { + pub tab_height: f32, + pub gap: f32, + pub min_pane: f32, + pub background: Color32, + pub tab_background: Color32, + pub active_background: Color32, + pub text: Color32, + pub inactive_text: Color32, + pub border: Stroke, + pub accent: Color32, + pub font: FontId, + /// Localize these action labels in the application adapter. + pub float_label: String, + pub close_label: String, + pub move_label: String, + pub panels_label: String, + pub resize_label: String, + pub resize_window_label: String, +} + +impl DockStyle { + pub fn from_ui(ui: &Ui) -> Self { + let visuals = ui.visuals(); + Self { + tab_height: 26.0, + gap: 4.0, + min_pane: 40.0, + background: visuals.panel_fill, + tab_background: visuals.extreme_bg_color, + active_background: visuals.widgets.active.bg_fill, + text: visuals.text_color(), + inactive_text: visuals.weak_text_color(), + border: visuals.widgets.noninteractive.bg_stroke, + accent: visuals.selection.stroke.color, + font: FontId::proportional(12.0), + float_label: "Float panel".into(), + close_label: "Close panel".into(), + move_label: "Move to group".into(), + panels_label: "Panels".into(), + resize_label: "Resize panels".into(), + resize_window_label: "Resize panel window".into(), + } + } + + fn valid(&self) -> bool { + [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size].iter().all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) + && self.tab_height > 0.0 + && self.font.size > 0.0 + } +} + +#[derive(Clone, Debug)] +pub struct DockOutput

{ + pub actions: Vec>, + pub error: Option, + pub focused: Option

, +} + +impl

Default for DockOutput

{ + fn default() -> Self { + Self { actions: Vec::new(), error: None, focused: None } + } +} + +/// Preferred body bounds. Tiny viewports relax minima proportionally rather than producing +/// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PanelLimits { + pub min: Vec2, + pub max: Vec2, +} + +impl Default for PanelLimits { + fn default() -> Self { + Self { min: Vec2::ZERO, max: Vec2::splat(1_000_000.0) } + } +} + +impl PanelLimits { + fn valid(self) -> bool { + self.min.is_finite() + && self.max.is_finite() + && self.min.x >= 0.0 + && self.min.y >= 0.0 + && self.max.x >= self.min.x + && self.max.y >= self.min.y + && self.max.x <= 1_000_000.0 + && self.max.y <= 1_000_000.0 + } +} + +#[derive(Clone)] +struct Drag

{ + panel: P, + origin: Pos2, + source: Rect, + grab: Vec2, +} + +/// Start a docking drag from an application's existing tab/header. Use the same area ID when +/// switching from a legacy layout to [`DockArea`]. The model is untouched until a valid release. +pub fn begin_drag(ctx: &Context, area_id: Id, panel: P, source_rect: Rect) -> bool { + let Some(origin) = ctx.pointer_interact_pos().filter(|p| p.is_finite()) else { return false }; + if !super::targets::usable(source_rect) { + return false; + } + let grab = (origin - source_rect.min).clamp(Vec2::ZERO, source_rect.size()); + ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab })); + true +} + +pub fn cancel_drag(ctx: &Context, area_id: Id) { + ctx.data_mut(|d| d.remove::>(area_id.with("drag"))); +} + +/// Optional renderer for applications without an existing docking surface. Apps with custom +/// chrome can instead use the model, target resolver and resize/tab primitives independently. +pub struct DockArea { + id: Id, +} + +impl DockArea { + pub fn new(id: Id) -> Self { + Self { id } + } + + /// Render a validated snapshot and return requested actions for the app's command handler. + /// No action is applied here. Panel contents get a global stable egui ID across moves. + /// Floating groups are egui areas inside this viewport; this does not create native windows. + pub fn show( + self, + ui: &mut Ui, + layout: &Layout

, + style: &DockStyle, + label: impl Fn(&P) -> String, + permissions: impl Fn(&P) -> Permissions, + body: impl FnMut(&mut Ui, &P), + ) -> DockOutput

{ + self.show_with_limits(ui, layout, style, label, permissions, |_| PanelLimits::default(), body) + } + + /// Like [`Self::show`], with per-panel size policies supplied by the application. + #[allow(clippy::too_many_arguments)] + pub fn show_with_limits( + self, + ui: &mut Ui, + layout: &Layout

, + style: &DockStyle, + label: impl Fn(&P) -> String, + permissions: impl Fn(&P) -> Permissions, + limits: impl Fn(&P) -> PanelLimits, + mut body: impl FnMut(&mut Ui, &P), + ) -> DockOutput

{ + let rect = ui.available_rect_before_wrap(); + if let Err(error) = layout.validate() { + cancel_drag::

(ui.ctx(), self.id); + return DockOutput { error: Some(error), ..Default::default() }; + } + if !style.valid() || !super::targets::usable(rect) || layout.panels().into_iter().any(|p| !limits(p).valid()) { + cancel_drag::

(ui.ctx(), self.id); + return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() }; + } + let mut renderer = Renderer { + id: self.id, + style, + label: &label, + permissions: &permissions, + limits: &limits, + body: &mut body, + layout, + targets: Vec::new(), + output: DockOutput::default(), + }; + if let Some(root) = &layout.root { + renderer.node(ui, root, rect, &mut Vec::new()); + ui.allocate_rect(rect, Sense::hover()); + } + renderer.floating(ui.ctx()); + renderer.finish_drag(ui.ctx()); + renderer.output + } +} + +struct Renderer<'a, P> { + id: Id, + style: &'a DockStyle, + label: &'a dyn Fn(&P) -> String, + permissions: &'a dyn Fn(&P) -> Permissions, + limits: &'a dyn Fn(&P) -> PanelLimits, + body: &'a mut dyn FnMut(&mut Ui, &P), + layout: &'a Layout

, + targets: Vec>, + output: DockOutput

, +} + +impl Renderer<'_, P> { + fn node(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &mut Vec) { + if !super::targets::usable(rect) { + return; + } + match node { + Node::Split { axis, size, first, second } => { + let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return }; + let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second)); + let first_size = wanted.first_size().clamp(lo, hi); + let Some(split) = split_rect(rect, *axis, SplitSize::FixedFirst(first_size), self.style.gap, 0.0) else { return }; + let response = ResizeHandle::new( + self.id.with(("split", &*path)), + *axis, + split.divider, + &self.style.resize_label, + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + ) + .value(split.first_size(), lo..=hi) + .show(ui); + if response.delta != 0.0 + && let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0) + { + self.output.actions.push(Action::ResizeSplit { path: path.clone(), size }); + } + path.push(false); + self.node(ui, first, split.first, path); + path.pop(); + path.push(true); + self.node(ui, second, split.second, path); + path.pop(); + } + Node::Tabs { panels, active } => self.group(ui, panels, *active, 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); + let total_wanted: f64 = entries.iter().filter(|e| e.open).map(|e| f64::from(e.height.unwrap_or(self.style.min_pane).max(1.0))).sum(); + let header = self.style.tab_height.min(rect.height() / entries.len().max(1) as f32); + let mut top = rect.top(); + for entry in entries { + let share = if entry.open && total_wanted > 0.0 { + (f64::from(remaining) * f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) / total_wanted) as f32 + } else { + 0.0 + }; + let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom()))); + let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom()))); + self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect); + let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); + let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); + response.widget_info(|| { + egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, (self.label)(&entry.panel)) + }); + ui.painter().with_clip_rect(collapse).text( + collapse.center(), + egui::Align2::CENTER_CENTER, + if entry.open { "−" } else { "+" }, + self.style.font.clone(), + self.style.text, + ); + if response.clicked() { + self.output.actions.push(Action::SetStackOpen { panel: entry.panel.clone(), open: !entry.open }); + } + let body_rect = Rect::from_min_max(pos2(rect.left(), title_rect.bottom()), panel_rect.max); + if entry.open { + self.contents(ui, &entry.panel, body_rect); + } + self.targets.push(DropTarget { + anchor: entry.panel.clone(), + rect: panel_rect, + tab_strip: Some(title_rect), + tabs: vec![(entry.panel.clone(), title_rect)], + allow_splits: true, + accepts_tabs: (self.permissions)(&entry.panel).accepts_tabs, + }); + top = panel_rect.bottom(); + } + } + } + } + + fn node_limits(&self, node: &Node

) -> PanelLimits { + match node { + Node::Tabs { panels, .. } => { + let mut limits = PanelLimits::default(); + for panel in panels { + let policy = (self.limits)(panel); + limits.min = limits.min.max(policy.min).max(Vec2::splat(self.style.min_pane)); + limits.max = limits.max.min(policy.max); + } + limits.max = limits.max.max(limits.min); + if panels.len() != 1 || panels.first().is_some_and(|p| (self.permissions)(p) != Permissions::PROTECTED) { + limits.min.y = (limits.min.y + self.style.tab_height).min(1_000_000.0); + limits.max.y = (limits.max.y + self.style.tab_height).min(1_000_000.0); + } + limits + } + Node::Stack { entries } => { + let mut limits = PanelLimits::default(); + for entry in entries { + let policy = (self.limits)(&entry.panel); + limits.min.x = limits.min.x.max(policy.min.x).max(self.style.min_pane); + limits.min.y += if entry.open { policy.min.y.max(self.style.min_pane) } else { 0.0 } + self.style.tab_height; + limits.max.x = limits.max.x.min(policy.max.x); + } + limits.min = limits.min.min(Vec2::splat(1_000_000.0)); + limits.max = limits.max.max(limits.min); + limits + } + Node::Split { axis, first, second, .. } => { + let a = self.node_limits(first); + let b = self.node_limits(second); + let limits = match axis { + SplitAxis::Horizontal => PanelLimits { + min: vec2(a.min.x + b.min.x + self.style.gap, a.min.y.max(b.min.y)), + max: vec2(a.max.x + b.max.x + self.style.gap, a.max.y.min(b.max.y)), + }, + SplitAxis::Vertical => PanelLimits { + min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + self.style.gap), + max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + self.style.gap), + }, + }; + PanelLimits { min: limits.min.min(Vec2::splat(1_000_000.0)), max: limits.max.max(limits.min).min(Vec2::splat(1_000_000.0)) } + } + } + } + + fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) { + if !super::targets::usable(rect) { + return; + } + ui.painter().rect_filled(rect, 0.0, self.style.background); + let mut child = + ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + (self.body)(&mut child, panel); + if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) { + self.output.focused = Some(panel.clone()); + } + } + + fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, rect: Rect, floating: bool) { + let Some(selected) = panels.get(active) else { return }; + let policy = (self.permissions)(selected); + let bare = panels.len() == 1 && policy == Permissions::PROTECTED; + if bare { + self.contents(ui, selected, rect); + self.targets.push(DropTarget { + anchor: selected.clone(), + rect, + tab_strip: None, + tabs: Vec::new(), + allow_splits: !floating, + accepts_tabs: false, + }); + return; + } + let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); + ui.painter().rect_filled(strip, 0.0, self.style.tab_background); + let widths: Vec = + panels.iter().map(|p| ui.painter().layout_no_wrap((self.label)(p), self.style.font.clone(), self.style.text).size().x + 24.0).collect(); + let fit = tabs::fit(&widths, active, strip.width(), 40.0, 22.0); + let mut x = strip.left(); + let mut targets = Vec::new(); + for (index, width) in &fit.shown { + let Some(panel) = panels.get(*index) else { continue }; + let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height())); + self.tab(ui, panel, tab, *index == active, rect); + targets.push((panel.clone(), tab)); + x += width; + } + if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { + let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height())); + let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label)); + ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text); + egui::Popup::menu(&response).show(|ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for index in &fit.overflow { + if let Some(panel) = panels.get(*index) + && ui.button((self.label)(panel)).clicked() + { + self.output.actions.push(Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + ui.close(); + } + } + }); + }); + } + // Include hidden tab identities for source-group detection without creating hit areas. + for panel in panels { + if !targets.iter().any(|(p, _)| p == panel) { + targets.push((panel.clone(), Rect::NOTHING)); + } + } + self.targets.push(DropTarget { + anchor: selected.clone(), + rect, + tab_strip: Some(strip), + tabs: targets, + allow_splits: !floating, + accepts_tabs: policy.accepts_tabs, + }); + self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); + ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); + } + + fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) { + let policy = (self.permissions)(panel); + let label = (self.label)(panel); + let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active) + .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) + .focus_stroke(Stroke::new(1.0, self.style.accent)) + .show_at(ui, rect, |ui, response| { + let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + &label, + self.style.font.clone(), + if active { self.style.text } else { self.style.inactive_text }, + ); + }); + if response.clicked() { + self.output.actions.push(Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + } + if policy.movable && response.drag_started() { + begin_drag(ui.ctx(), self.id, panel.clone(), group); + } + response.context_menu(|ui| { + if policy.movable && policy.floatable && ui.button(&self.style.float_label).clicked() { + let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); + self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }); + ui.close(); + } + if policy.movable { + ui.menu_button(&self.style.move_label, |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for target in self.layout.panels() { + if target != panel && (self.permissions)(target).accepts_tabs && ui.button((self.label)(target)).clicked() { + self.output.actions.push(Action::Move { + panel: panel.clone(), + anchor: target.clone(), + placement: Placement::Tab { before: None }, + }); + ui.close(); + } + } + }); + }); + } + if policy.closable && ui.button(&self.style.close_label).clicked() { + self.output.actions.push(Action::Close { panel: panel.clone() }); + ui.close(); + } + }); + } + + fn floating(&mut self, ctx: &Context) { + let screen = ctx.content_rect(); + if !super::targets::usable(screen) { + return; + } + let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); + let mut groups: Vec<_> = self.layout.floating.iter().collect(); + groups.sort_by_key(|group| { + let id = group.panels.first().map(|panel| self.id.with(("floating", panel))); + 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 [x, y, width, height] = group.rect; + let limits = self.node_limits(&Node::Tabs { panels: group.panels.clone(), active: group.active }); + 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)); + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { + 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); + 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); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label)); + if response.drag_started() { + response.request_focus(); + } + let mut delta = if response.dragged() { ui.input(|i| i.pointer.delta()) } else { Vec2::ZERO }; + if response.has_focus() { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter( + response.id, + egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }, + ) + }); + ui.input_mut(|input| { + if input.consume_key(egui::Modifiers::NONE, Key::ArrowLeft) { + delta.x -= 8.0; + } + if input.consume_key(egui::Modifiers::NONE, Key::ArrowRight) { + delta.x += 8.0; + } + if input.consume_key(egui::Modifiers::NONE, Key::ArrowUp) { + delta.y -= 8.0; + } + if input.consume_key(egui::Modifiers::NONE, Key::ArrowDown) { + delta.y += 8.0; + } + }); + } + if response.enabled() && delta != Vec2::ZERO { + let size = (rect.size() + delta).max(limits.min.max(vec2(40.0, self.style.tab_height + 20.0))).min(limits.max).min(screen.size()); + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [rect.left(), rect.top(), size.x, size.y] }); + } + if response.hovered() || response.dragged() || response.has_focus() { + ui.painter().line_segment([corner.left_bottom(), corner.right_top()], Stroke::new(2.0, self.style.accent)); + } + }); + } + } + + fn finish_drag(&mut self, ctx: &Context) { + let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("drag"))) else { return }; + let (pointer, down, released, focused) = + ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused)); + let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) { + cancel_drag::

(ctx, self.id); + return; + } + let Some(pointer) = pointer.filter(|p| p.is_finite()) else { + if released { + cancel_drag::

(ctx, self.id); + } + return; + }; + let proposal = resolve_drop(&drag.panel, pointer, &self.targets); + if released { + cancel_drag::

(ctx, self.id); + if pointer.distance(drag.origin) < 2.0 { + return; + } + let action = if let Some(proposal) = proposal { + Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) + } else if (self.permissions)(&drag.panel).floatable + && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.panels.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()] }) + } else { + None + }; + if let Some(action) = action { + // Validate the proposed gesture against the same complete snapshot. The app + // still applies it via its own command path after body rendering finishes. + let mut candidate = self.layout.clone(); + if candidate.apply_with_permissions(action.clone(), self.permissions).is_ok() { + self.output.actions.push(action); + self.output.focused = Some(drag.panel); + } + } + return; + } + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, self.id.with("drop-preview"))); + if let Some(proposal) = proposal { + painter.rect( + proposal.preview.shrink(1.0), + 0.0, + self.style.accent.gamma_multiply(0.15), + Stroke::new(2.0, self.style.accent), + StrokeKind::Inside, + ); + if let Some(marker) = proposal.insertion { + painter.rect_filled(marker, 0.0, self.style.accent); + } + } else if (self.permissions)(&drag.panel).floatable { + let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); + painter.rect_stroke(rect, 0.0, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + } + } +} + +fn clamp_float(rect: Rect, screen: Rect) -> Rect { + if !super::targets::usable(screen) { + return rect; + } + let size = rect.size().max(vec2(1.0, 1.0)).min(screen.size()); + let min = rect.min.max(screen.min).min(screen.max - size); + Rect::from_min_size(min, size) +} + +fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) { + let (a_min, a_max, b_min, b_max) = match axis { + SplitAxis::Horizontal => (first.min.x, first.max.x, second.min.x, second.max.x), + SplitAxis::Vertical => (first.min.y, first.max.y, second.min.y, second.max.y), + }; + let scale = if a_min + b_min > available { available / (a_min + b_min).max(1.0) } else { 1.0 }; + let lo = (a_min * scale).min(available); + let hi = (available - b_min * scale).max(lo).min(available); + let constrained_lo = lo.max(available - b_max); + let constrained_hi = hi.min(a_max); + if constrained_lo <= constrained_hi { (constrained_lo, constrained_hi) } else { (lo, hi) } +} diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs new file mode 100644 index 0000000..515b9c6 --- /dev/null +++ b/crates/ui/src/docking/targets.rs @@ -0,0 +1,99 @@ +use egui::{Pos2, Rect, pos2}; + +use super::{Placement, Zone}; + +/// One visible group, supplied in back-to-front order to [`resolve_drop`]. An empty tab strip +/// or a group that rejects tabs may still accept side splits, e.g. a protected document canvas. +#[derive(Clone, Debug)] +pub struct DropTarget

{ + pub anchor: P, + pub rect: Rect, + pub tab_strip: Option, + pub tabs: Vec<(P, Rect)>, + pub allow_splits: bool, + pub accepts_tabs: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DropProposal

{ + pub anchor: P, + pub placement: Placement

, + pub preview: Rect, + pub insertion: Option, +} + +pub(super) fn usable(rect: Rect) -> bool { + rect.is_finite() && rect.width() > 0.0 && rect.height() > 0.0 && rect.width() <= 1_000_000.0 && rect.height() <= 1_000_000.0 +} + +/// Tab header is the center; otherwise the nearest quarter-width/height edge wins. +/// Outside, non-finite, empty and inverted rectangles have no target. +pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { + if !usable(rect) || !pointer.is_finite() || !rect.contains(pointer) || !tab_height.is_finite() || tab_height < 0.0 { + return None; + } + if pointer.y < rect.top() + tab_height.min(rect.height()) { + return Some(Zone::Center); + } + let x = (pointer.x - rect.left()) / rect.width(); + let y = (pointer.y - rect.top()) / rect.height(); + let distances = [(x, Zone::Left), (1.0 - x, Zone::Right), (y, Zone::Top), (1.0 - y, Zone::Bottom)]; + let (distance, zone) = distances.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; + Some(if distance > 0.25 { Zone::Center } else { zone }) +} + +/// Preview of the region occupied after a split or group merge. +pub fn zone_rect(rect: Rect, zone: Zone) -> Option { + if !usable(rect) { + return None; + } + let center = rect.min + rect.size() * 0.5; + Some(match zone { + Zone::Center => rect, + Zone::Left => Rect::from_min_max(rect.min, pos2(center.x, rect.bottom())), + Zone::Right => Rect::from_min_max(pos2(center.x, rect.top()), rect.max), + Zone::Top => Rect::from_min_max(rect.min, pos2(rect.right(), center.y)), + Zone::Bottom => Rect::from_min_max(pos2(rect.left(), center.y), rect.max), + }) +} + +/// Resolve a pointer against visible groups. The frontmost containing group occludes groups +/// behind it even if it rejects the drop. A strip reorders by midpoint; the body of the source +/// group is a no-op at center. Floating groups normally set `allow_splits` to false. +pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarget

]) -> Option> { + if !pointer.is_finite() || targets.len() > super::MAX_PANELS { + return None; + } + let target = targets.iter().rev().find(|target| usable(target.rect) && target.rect.contains(pointer))?; + if target.tabs.len() > super::MAX_PANELS { + return None; + } + let anchor = if &target.anchor == panel { target.tabs.iter().find(|(p, _)| p != panel).map(|(p, _)| p)? } else { &target.anchor }; + let strip = target.tab_strip.filter(|r| usable(*r) && r.contains(pointer)); + if let Some(strip) = strip { + if !target.accepts_tabs { + return None; + } + let before = target.tabs.iter().filter(|(_, r)| usable(*r)).find(|(_, r)| pointer.x < r.center().x); + let before_panel = before.map(|(p, _)| p.clone()); + let x = before.map(|(_, r)| r.left()).unwrap_or_else(|| { + target.tabs.iter().filter(|(_, r)| usable(*r)).map(|(_, r)| r.right()).fold(strip.left(), f32::max).min(strip.right()) + }); + let insertion = Rect::from_min_max(pos2((x - 1.0).max(strip.left()), strip.top()), pos2((x + 1.0).min(strip.right()), strip.bottom())); + return Some(DropProposal { + anchor: anchor.clone(), + placement: Placement::Tab { before: before_panel }, + preview: target.rect, + insertion: Some(insertion), + }); + } + let zone = if target.allow_splits { drop_zone(target.rect, 0.0, pointer)? } else { Zone::Center }; + if zone == Zone::Center { + if !target.accepts_tabs || target.tabs.iter().any(|(p, _)| p == panel) { + return None; + } + } else if !target.allow_splits { + return None; + } + Some(DropProposal { anchor: anchor.clone(), placement: Placement::Split(zone), preview: zone_rect(target.rect, zone)?, insertion: None }) +} diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs new file mode 100644 index 0000000..763ab06 --- /dev/null +++ b/crates/ui/src/docking/tests.rs @@ -0,0 +1,499 @@ +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, pos2, vec2}; + +fn tabs(panels: &[u32], active: usize) -> Node { + Node::Tabs { panels: panels.to_vec(), active } +} + +fn sample() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(240.0), + 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] }], + } +} + +fn move_to(panel: u32, anchor: u32, zone: Zone) -> Action { + Action::Move { panel, anchor, placement: Placement::Split(zone) } +} + +#[test] +fn floating_brush_panel_joins_main_tabs_without_losing_other_panels() { + let mut layout = sample(); + layout.apply(Action::Move { panel: 5, anchor: 3, placement: Placement::Tab { before: Some(4) } }).unwrap(); + 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); + layout.validate().unwrap(); + assert_eq!(layout.panels().len(), 6); +} + +#[test] +fn detach_redock_merge_and_collapse_empty_source_containers() { + let mut layout = sample(); + layout.apply(Action::Float { panel: 1, rect: [30.0, 40.0, 250.0, 200.0] }).unwrap(); + assert_eq!(layout.root, Some(tabs(&[2, 3, 4], 1))); + 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); + layout.apply(move_to(6, 3, Zone::Center)).unwrap(); + layout.apply(move_to(5, 3, Zone::Center)).unwrap(); + assert!(layout.floating.is_empty()); + assert_eq!(layout.panels().len(), 6); +} + +#[test] +fn invalid_moves_are_atomic_including_after_a_source_would_have_been_removed() { + let layout = sample(); + for action in [ + move_to(5, 99, Zone::Center), + move_to(99, 2, Zone::Center), + move_to(5, 6, Zone::Left), + move_to(5, 5, Zone::Center), + Action::Move { panel: 5, anchor: 2, placement: Placement::Tab { before: Some(6) } }, + Action::Float { panel: 3, rect: [f32::NAN, 0.0, 100.0, 100.0] }, + Action::Float { panel: 3, rect: [0.0, 0.0, 0.0, 100.0] }, + Action::ResizeSplit { path: vec![false], size: SplitSize::Ratio(0.3) }, + ] { + let mut changed = layout.clone(); + assert!(changed.apply(action).is_err()); + assert_eq!(changed, layout); + } +} + +#[test] +fn removing_an_earlier_tab_preserves_selected_identity() { + let mut layout = Layout { root: Some(tabs(&[1, 2, 3, 4], 2)), floating: Vec::new() }; + layout.apply(Action::Close { panel: 1 }).unwrap(); + assert_eq!(layout.root, Some(tabs(&[2, 3, 4], 1))); + layout.apply(Action::Close { panel: 3 }).unwrap(); + assert_eq!(layout.root, Some(tabs(&[2, 4], 1))); +} + +#[test] +fn reordering_tabs_uses_destination_identity_after_removal() { + let mut layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: Vec::new() }; + layout.apply(Action::Move { panel: 1, anchor: 2, placement: Placement::Tab { before: None } }).unwrap(); + assert_eq!(layout.root, Some(tabs(&[2, 3, 1], 2))); + layout.apply(Action::Move { panel: 1, anchor: 2, placement: Placement::Tab { before: Some(2) } }).unwrap(); + assert_eq!(layout.root, Some(tabs(&[1, 2, 3], 0))); + let before = layout.clone(); + layout.apply(Action::Move { panel: 1, anchor: 2, placement: Placement::Tab { before: Some(1) } }).unwrap(); + assert_eq!(layout, before); +} + +#[test] +fn accordion_open_state_and_custom_height_survive_reorder() { + let mut layout = Layout { + root: Some(Node::Stack { + entries: vec![StackEntry { panel: 1, open: false, height: Some(123.0) }, StackEntry { panel: 2, open: true, height: None }], + }), + floating: Vec::new(), + }; + layout.apply(Action::Move { panel: 1, anchor: 2, placement: Placement::Tab { before: None } }).unwrap(); + let Node::Stack { entries } = layout.root.as_ref().unwrap() else { panic!() }; + assert_eq!(entries[1], StackEntry { panel: 1, open: false, height: Some(123.0) }); + let location = layout.location(&1).unwrap(); + layout.apply(Action::Close { panel: 1 }).unwrap(); + layout.restore(1, &location).unwrap(); + let Node::Stack { entries } = layout.root.as_ref().unwrap() else { panic!() }; + assert_eq!(entries[1], StackEntry { panel: 1, open: false, height: Some(123.0) }); +} + +#[test] +fn protected_canvas_remains_present_and_cannot_be_grouped_with_panels() { + let permissions = |panel: &u32| if *panel == 1 { Permissions::PROTECTED } else { Permissions::default() }; + let layout = sample(); + for action in [ + Action::Close { panel: 1 }, + Action::Float { panel: 1, rect: [10.0, 10.0, 200.0, 200.0] }, + move_to(1, 3, Zone::Center), + move_to(3, 1, Zone::Center), + Action::OpenAt { panel: 1, anchor: 3, placement: Placement::Tab { before: None } }, + ] { + let mut next = layout.clone(); + assert_eq!(next.apply_with_permissions(action, permissions), Err(DockError::NotAllowed)); + assert_eq!(next, layout); + } + let mut next = layout; + next.apply_with_permissions(Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) }, permissions).unwrap(); + assert!(next.contains(&1) && next.contains(&7)); +} + +#[test] +fn close_and_restore_preserve_fixed_side_and_floating_geometry() { + let mut layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(180.0), + 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] }], + }; + let saved = layout.clone(); + let first = layout.location(&1).unwrap(); + let float = layout.location(&3).unwrap(); + layout.apply(Action::Close { panel: 1 }).unwrap(); + layout.apply(Action::Close { panel: 3 }).unwrap(); + layout.restore(1, &first).unwrap(); + layout.restore(3, &float).unwrap(); + assert_eq!(layout, saved); + let invalid = Location { anchor: Some(99), ..first }; + layout.apply(Action::Close { panel: 1 }).unwrap(); + let before = layout.clone(); + assert_eq!(layout.restore(1, &invalid), Err(DockError::MissingTarget)); + assert_eq!(layout, before); +} + +#[test] +fn closing_and_reopening_an_entire_accordion_restores_its_kind_and_heights() { + let mut layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(220.0), + first: Box::new(tabs(&[1], 0)), + second: Box::new(Node::Stack { + entries: vec![StackEntry { panel: 2, open: false, height: Some(90.0) }, StackEntry { panel: 3, open: true, height: Some(180.0) }], + }), + }), + floating: Vec::new(), + }; + let before = layout.clone(); + let second = layout.location(&2).unwrap(); + layout.apply(Action::Close { panel: 2 }).unwrap(); + let third = layout.location(&3).unwrap(); + layout.apply(Action::Close { panel: 3 }).unwrap(); + layout.restore(3, &third).unwrap(); + layout.restore(2, &second).unwrap(); + assert_eq!(layout, before); +} + +#[test] +fn invalid_saved_layouts_reject_duplicates_selection_depth_and_nonfinite_geometry() { + let mut duplicate = sample(); + duplicate.floating[0].panels.push(1); + assert_eq!(duplicate.validate(), Err(DockError::DuplicatePanel)); + let mut invalid = sample(); + invalid.floating[0].active = usize::MAX; + assert_eq!(invalid.validate(), Err(DockError::InvalidSelection)); + invalid.floating[0].active = 0; + invalid.floating[0].rect[3] = f32::INFINITY; + assert_eq!(invalid.validate(), Err(DockError::InvalidGeometry)); + let mut deep = tabs(&[0], 0); + for panel in 1..=MAX_DEPTH as u32 { + deep = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(deep), second: Box::new(tabs(&[panel], 0)) }; + } + let mut deep = Layout { root: Some(deep), floating: Vec::new() }; + assert_eq!(deep.validate(), Err(DockError::LimitExceeded)); + assert_eq!(deep.apply(Action::Activate { panel: 0 }), Err(DockError::LimitExceeded)); + let oversized = Layout { root: Some(Node::Tabs { panels: (0..=MAX_PANELS as u32).collect(), active: 0 }), floating: Vec::new() }; + assert_eq!(oversized.validate(), Err(DockError::LimitExceeded)); +} + +fn target(anchor: u32, members: &[u32], rect: Rect) -> DropTarget { + let strip = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); + DropTarget { + anchor, + rect, + tab_strip: Some(strip), + tabs: members + .iter() + .enumerate() + .map(|(i, panel)| (*panel, Rect::from_min_size(rect.min + vec2(i as f32 * 60.0, 0.0), vec2(60.0, 24.0)))) + .collect(), + allow_splits: true, + accepts_tabs: true, + } +} + +#[test] +fn pointer_targets_distinguish_reorder_center_split_and_occlusion() { + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(300.0, 200.0)); + let group = target(2, &[2, 3], rect); + let placement = resolve_drop(&5, pos2(90.0, 30.0), std::slice::from_ref(&group)).unwrap(); + assert_eq!(placement.placement, Placement::Tab { before: Some(3) }); + assert!(placement.insertion.unwrap().is_finite()); + assert_eq!(resolve_drop(&5, rect.center(), std::slice::from_ref(&group)).unwrap().placement, Placement::Split(Zone::Center)); + assert_eq!(resolve_drop(&5, pos2(11.0, 90.0), std::slice::from_ref(&group)).unwrap().placement, Placement::Split(Zone::Left)); + assert!(resolve_drop(&3, rect.center(), std::slice::from_ref(&group)).is_none()); + assert!(resolve_drop(&3, pos2(11.0, 90.0), std::slice::from_ref(&group)).is_some()); + let mut overlay = target(5, &[5], rect); + overlay.accepts_tabs = false; + overlay.allow_splits = false; + assert!(resolve_drop(&3, rect.center(), &[group, overlay]).is_none()); + assert!(drop_zone(Rect::NOTHING, 24.0, pos2(0.0, 0.0)).is_none()); + assert!(drop_zone(rect, 24.0, pos2(f32::NAN, 0.0)).is_none()); +} + +#[test] +fn long_action_sequence_never_loses_or_duplicates_a_panel() { + let mut layout = sample(); + let mut seed = 72u64; + for step in 0..800 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let panel = (seed % 6 + 1) as u32; + let anchor = ((seed >> 16) % 6 + 1) as u32; + let action = if step % 7 == 0 { + Action::Float { panel, rect: [10.0, 20.0, 200.0, 220.0] } + } else { + move_to( + panel, + anchor, + match seed % 5 { + 0 => Zone::Left, + 1 => Zone::Right, + 2 => Zone::Top, + 3 => Zone::Bottom, + _ => Zone::Center, + }, + ) + }; + let before = layout.clone(); + if layout.apply(action).is_err() { + assert_eq!(layout, before); + } + layout.validate().unwrap(); + let mut panels: Vec = layout.panels().into_iter().copied().collect(); + panels.sort_unstable(); + assert_eq!(panels, vec![1, 2, 3, 4, 5, 6]); + } +} + +#[cfg(feature = "serde")] +#[test] +fn saved_layout_and_hidden_location_round_trip() { + let layout = sample(); + let value = serde_json::to_string(&layout).unwrap(); + let restored: Layout = serde_json::from_str(&value).unwrap(); + restored.validate().unwrap(); + assert_eq!(restored, layout); + let location = layout.location(&3).unwrap(); + let encoded = serde_json::to_string(&location).unwrap(); + assert_eq!(serde_json::from_str::>(&encoded).unwrap(), location); +} + +fn frame(ctx: &Context, layout: &Layout, events: Vec, focused: bool) -> (DockOutput, Vec) { + let mut output = DockOutput::default(); + let mut ids = Vec::new(); + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, focused, ..Default::default() }; + ctx.run_ui(input, |ui| { + let style = DockStyle::from_ui(ui); + output = DockArea::new(Id::new("test-area")).show( + ui, + layout, + &style, + |p| format!("Panel {p}"), + |_| Permissions::default(), + |ui, _| { + ids.push(ui.id()); + ui.label("Actual panel content"); + }, + ); + }) + .textures_delta + .clear(); + (output, ids) +} + +#[test] +fn renderer_keeps_content_identity_when_panel_moves_into_a_float() { + let ctx = Context::default(); + let mut layout = Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() }; + let (output, before) = frame(&ctx, &layout, Vec::new(), true); + assert!(output.error.is_none()); + layout.apply(Action::Float { panel: 1, rect: [100.0, 100.0, 320.0, 260.0] }).unwrap(); + let (output, after) = frame(&ctx, &layout, Vec::new(), true); + assert!(output.error.is_none()); + assert_eq!(before, after); +} + +#[test] +fn cancelling_or_losing_focus_during_a_drag_does_not_move_panels() { + let ctx = Context::default(); + let layout = sample(); + let source = Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 200.0)); + let press = Event::PointerButton { pos: pos2(30.0, 30.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }; + frame(&ctx, &layout, vec![Event::PointerMoved(pos2(30.0, 30.0)), press], true); + assert!(begin_drag(&ctx, Id::new("test-area"), 2u32, source)); + let (out, _) = frame( + &ctx, + &layout, + vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + true, + ); + assert!(out.actions.is_empty()); + assert!(begin_drag(&ctx, Id::new("test-area"), 2u32, source)); + let (out, _) = frame(&ctx, &layout, Vec::new(), false); + assert!(out.actions.is_empty()); + let (out, _) = frame( + &ctx, + &layout, + vec![Event::PointerButton { pos: pos2(700.0, 400.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }], + true, + ); + assert!(out.actions.is_empty()); +} + +fn step(ctx: &Context, layout: &mut Layout, events: Vec) -> Vec> { + let (out, _) = frame(ctx, layout, events, true); + assert!(out.error.is_none()); + for action in &out.actions { + layout.apply(action.clone()).unwrap(); + } + out.actions +} + +fn pointer_button(pos: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn actual_pointer_drag_docks_a_floating_panel_into_the_main_tab_strip() { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..3 { + step(&ctx, &mut layout, Vec::new()); + } + let source = ctx.read_response(Id::new("test-area").with(("tab", &5u32))).unwrap().rect.center(); + let target = ctx.read_response(Id::new("test-area").with(("tab", &3u32))).unwrap().rect.center(); + step(&ctx, &mut layout, vec![Event::PointerMoved(source), pointer_button(source, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(source + vec2(8.0, 0.0))]); + 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_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!() }; + assert_eq!(panels.get(*active), Some(&5)); +} + +#[test] +fn panel_size_limits_clamp_geometry_without_overwriting_preferred_widths() { + let layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(450.0), + first: Box::new(tabs(&[1], 0)), + second: Box::new(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(500.0), + first: Box::new(tabs(&[0], 0)), + second: Box::new(tabs(&[2], 0)), + }), + }), + floating: Vec::new(), + }; + let unchanged = layout.clone(); + let ctx = Context::default(); + let mut widths = Vec::new(); + for width in [1400.0, 900.0, 300.0, 1400.0] { + let mut rects = std::collections::BTreeMap::new(); + let mut out = DockOutput::default(); + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 600.0))), ..Default::default() }, |ui| { + out = DockArea::new(Id::new("bounds")).show_with_limits( + ui, + &layout, + &DockStyle::from_ui(ui), + |p| p.to_string(), + |p| if *p == 0 { Permissions::PROTECTED } else { Permissions::default() }, + |p| match *p { + 1 => PanelLimits { min: vec2(200.0, 40.0), max: vec2(480.0, 1_000_000.0) }, + 2 => PanelLimits { min: vec2(250.0, 40.0), max: vec2(520.0, 1_000_000.0) }, + _ => PanelLimits { min: vec2(360.0, 40.0), ..Default::default() }, + }, + |ui, p| { + rects.insert(*p, ui.max_rect()); + }, + ); + }) + .textures_delta + .clear(); + assert!(out.error.is_none()); + assert!(out.actions.is_empty()); + for rect in rects.values() { + assert!(rect.is_finite() && rect.is_positive()); + assert!(rect.left() >= 0.0 && rect.right() <= width); + } + if width >= 900.0 { + assert!((200.0..=480.0).contains(&rects[&1].width())); + assert!((250.0..=520.0).contains(&rects[&2].width())); + assert!(rects[&0].width() >= 360.0); + } + widths.push(rects[&1].width()); + } + assert_eq!(widths.first(), widths.last()); + assert_eq!(layout, unchanged); +} + +#[test] +fn focused_floating_resize_grip_accepts_both_arrow_axes_and_releases_tab() { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..3 { + step(&ctx, &mut layout, Vec::new()); + } + let id = Id::new("test-area").with(("floating", &5u32)).with("resize"); + ctx.memory_mut(|memory| memory.request_focus(id)); + let key = |key| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + step(&ctx, &mut layout, vec![key(egui::Key::ArrowRight), key(egui::Key::ArrowDown)]); + assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 228.0]); + step(&ctx, &mut layout, vec![key(egui::Key::Tab)]); + assert_ne!(ctx.memory(|memory| memory.focused()), Some(id)); +} + +#[test] +fn restoring_a_panel_beside_a_nested_subtree_preserves_the_complete_workspace() { + let mut layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::FixedSecond(330.0), + first: Box::new(tabs(&[1], 0)), + second: Box::new(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(220.0), + first: Box::new(tabs(&[2], 0)), + second: Box::new(tabs(&[3], 0)), + }), + }), + floating: Vec::new(), + }; + let expected = layout.clone(); + let location = layout.location(&1).unwrap(); + layout.apply(Action::Close { panel: 1 }).unwrap(); + layout.restore(1, &location).unwrap(); + assert_eq!(layout, expected); +} + +#[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] }] }; + 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( + ui, + &layout, + &DockStyle::from_ui(ui), + |p| p.to_string(), + |_| Permissions::default(), + |ui, _| { + ui.label("Panel"); + }, + ); + assert!(out.error.is_none()); + assert_eq!(ui.available_rect_before_wrap(), before); + }) + .textures_delta + .clear(); +} diff --git a/crates/ui/src/layout/geometry.rs b/crates/ui/src/layout/geometry.rs index 9d6de08..51fae4a 100644 --- a/crates/ui/src/layout/geometry.rs +++ b/crates/ui/src/layout/geometry.rs @@ -2,6 +2,7 @@ use egui::Rect; /// The direction in which the children of a split are placed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum SplitAxis { /// First child on the left, second child on the right. Horizontal, @@ -11,6 +12,7 @@ pub enum SplitAxis { /// A transient sizing request. Applications may adapt their own persisted sizing enum. #[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum SplitSize { /// Fraction of the space left after the divider is removed, assigned to the first child. Ratio(f32), diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b54cc84..ed6b8ff 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -6,6 +6,7 @@ //! accept focused style values and return egui responses; no application engine is required. pub mod buttons; +pub mod docking; pub mod icons; pub mod layout; pub mod tabs; diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs new file mode 100644 index 0000000..d65271a --- /dev/null +++ b/crates/ui/tests/docking_workflows.rs @@ -0,0 +1,249 @@ +//! Content-bearing offscreen acceptance of the shared docking surface. No OS windows are opened. +//! Set CRAFT_UI_DOCK_VISUAL_DIR to retain every stage of the pointer-driven workflow. + +use std::{collections::BTreeMap, path::PathBuf}; + +use craft_ui::{ + docking::{Action, DockArea, DockStyle, Floating, Layout, Node, Permissions}, + layout::{SplitAxis, SplitSize}, +}; +use egui::{Color32, Context, Event, FullOutput, Id, Modifiers, PointerButton, Pos2, RawInput, Rect, Stroke, pos2, vec2}; +use egui_kittest::{TestRenderer, wgpu::WgpuTestRenderer}; + +const CANVAS: u32 = 0; +const LAYERS: u32 = 1; +const PROPERTIES: u32 = 2; +const BRUSHES: u32 = 3; + +fn label(panel: &u32) -> String { + match *panel { + CANVAS => "Document", + LAYERS => "Layers", + PROPERTIES => "Properties", + BRUSHES => "Brushes", + _ => "Unknown", + } + .into() +} + +struct Harness { + ctx: Context, + renderer: WgpuTestRenderer, + layout: Layout, + output: FullOutput, + width: f32, + frame: usize, + values: BTreeMap, + body_ids: BTreeMap, + edit_ids: BTreeMap, + applied: Vec>, +} + +impl Harness { + fn new(dark: bool, width: f32, scale: f32) -> Self { + let ctx = Context::default(); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.set_pixels_per_point(scale); + ctx.all_styles_mut(|style| { + style.animation_time = 0.0; + style.visuals.text_cursor.blink = false; + }); + let layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(240.0), + 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] }], + }; + let mut harness = Self { + ctx, + renderer: WgpuTestRenderer::new(), + layout, + output: FullOutput::default(), + width, + frame: 0, + values: BTreeMap::from([ + (BRUSHES, "Soft round 24 px".into()), + (PROPERTIES, "72% opacity".into()), + (LAYERS, "Foreground selected".into()), + ]), + body_ids: BTreeMap::new(), + edit_ids: BTreeMap::new(), + applied: Vec::new(), + }; + harness.settle(); + harness + } + + fn step(&mut self, events: Vec) { + self.frame += 1; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 520.0))), + time: Some(self.frame as f64 / 60.0), + events, + focused: true, + max_texture_side: Some(4096), + ..Default::default() + }; + let mut actions = Vec::new(); + self.output = self.ctx.run_ui(input, |ui| { + let style = DockStyle::from_ui(ui); + let out = DockArea::new(Id::new("workflow")).show( + ui, + &self.layout, + &style, + label, + |panel| if *panel == CANVAS { Permissions::PROTECTED } else { Permissions::default() }, + |ui, panel| { + self.body_ids.insert(*panel, ui.id()); + if *panel == CANVAS { + let rect = ui.available_rect_before_wrap(); + ui.painter().rect_filled(rect, 0.0, Color32::from_rgb(44, 53, 66)); + let page = rect.shrink(24.0); + if page.is_positive() { + ui.painter().rect_filled(page, 1.0, Color32::from_rgb(243, 239, 225)); + ui.painter().text( + page.min + vec2(12.0, 28.0), + egui::Align2::LEFT_CENTER, + "SYNTHETIC LAYOUT", + egui::FontId::proportional(18.0), + Color32::from_rgb(26, 37, 57), + ); + for row in 0..5 { + let y = page.top() + 68.0 + row as f32 * 24.0; + ui.painter().line_segment( + [pos2(page.left() + 12.0, y), pos2(page.right() - 12.0, y)], + Stroke::new(4.0, Color32::from_rgb(122, 138, 151)), + ); + } + let art = Rect::from_min_max(page.min + vec2(12.0, 220.0), page.max - vec2(12.0, 20.0)); + if art.is_positive() { + ui.painter().rect_filled(art, 6.0, Color32::from_rgb(94, 143, 139)); + } + } + } else { + egui::ScrollArea::vertical().id_salt(("content-scroll", panel)).show(ui, |ui| { + ui.add_space(8.0); + ui.heading(label(panel)); + if let Some(value) = self.values.get_mut(panel) { + let response = ui.text_edit_singleline(value); + self.edit_ids.insert(*panel, response.id); + } + ui.separator(); + if *panel == BRUSHES { + for row in 1..=6 { + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.left_center() + vec2(14.0, 0.0), row as f32 + 2.0, ui.visuals().text_color()); + ui.painter().text( + rect.left_center() + vec2(32.0, 0.0), + egui::Align2::LEFT_CENTER, + format!("Brush preset {row}"), + egui::FontId::proportional(12.0), + ui.visuals().text_color(), + ); + } + } else if *panel == LAYERS { + for text in ["Foreground", "Title", "Shapes", "Background"] { + ui.label(text); + } + } else { + ui.label("Width: 320 px"); + ui.label("Height: 180 px"); + ui.label("Blend: Normal"); + ui.add(egui::ProgressBar::new(0.72).text("Opacity 72%")); + } + }); + } + }, + ); + assert!(out.error.is_none(), "{:?}", out.error); + actions = out.actions; + }); + self.renderer.handle_delta(&mut self.output.textures_delta); + for action in actions { + self.layout + .apply_with_permissions(action.clone(), |panel| if *panel == CANVAS { Permissions::PROTECTED } else { Permissions::default() }) + .unwrap(); + self.applied.push(action); + } + self.layout.validate().unwrap(); + } + + fn settle(&mut self) { + for _ in 0..4 { + self.step(Vec::new()); + } + } + + fn tab(&self, panel: u32) -> Rect { + self.ctx.read_response(Id::new("workflow").with(("tab", &panel))).unwrap().rect + } + + fn drag(&mut self, from: Pos2, to: Pos2) { + let pointer = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + self.step(vec![Event::PointerMoved(from), pointer(from, true)]); + self.step(vec![Event::PointerMoved(from + vec2(8.0, 0.0))]); + self.step(vec![Event::PointerMoved(to)]); + self.step(vec![pointer(to, false)]); + self.settle(); + } + + fn edit_brush_value(&mut self) { + let point = self.ctx.read_response(self.edit_ids[&BRUSHES]).unwrap().rect.center(); + let pointer = |pressed| Event::PointerButton { pos: point, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + self.step(vec![Event::PointerMoved(point), pointer(true)]); + self.step(vec![pointer(false)]); + self.step(vec![ + Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }, + Event::Text("Edited soft round 36 px".into()), + ]); + self.step(vec![Event::Key { key: egui::Key::A, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::COMMAND }]); + assert_eq!(self.values[&BRUSHES], "Edited soft round 36 px"); + } + + fn capture(&mut self, directory: &Option, name: &str) { + if let Some(directory) = directory { + std::fs::create_dir_all(directory).unwrap(); + let image = self.renderer.render(&self.ctx, &self.output).unwrap(); + image.save(directory.join(format!("{name}.png"))).unwrap(); + } + } +} + +#[test] +fn floating_brushes_merge_detach_and_redock_through_real_pointer_input() { + let output = std::env::var_os("CRAFT_UI_DOCK_VISUAL_DIR").map(PathBuf::from); + for dark in [true, false] { + for width in [640.0, 1000.0] { + for scale in [1.0, 2.0] { + let name = format!("{}-{}-{}x", if dark { "dark" } else { "light" }, width as u32, scale as u32); + let directory = output.as_ref().map(|p| p.join(&name)); + let mut h = Harness::new(dark, width, scale); + h.edit_brush_value(); + h.settle(); + h.capture(&directory, "01-floating-brushes"); + let original_values = h.values.clone(); + let brush_id = h.body_ids[&BRUSHES]; + h.drag(h.tab(BRUSHES).center(), h.tab(PROPERTIES).left_center() + vec2(4.0, 0.0)); + assert!(h.applied.iter().any(|a| matches!(a, Action::Move { panel: BRUSHES, .. })), "{name}: {:?}", h.applied); + assert!(h.layout.floating.is_empty(), "{name}"); + 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); + 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}"); + assert_eq!(h.body_ids[&BRUSHES], brush_id); + assert_eq!(h.values, original_values); + assert_eq!(h.layout.panels().len(), 4); + h.capture(&directory, "04-redocked"); + if let Some(directory) = &directory { + std::fs::write(directory.join("actions.txt"), format!("{:#?}\n", h.applied)).unwrap(); + } + } + } + } +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md new file mode 100644 index 0000000..09cb2de --- /dev/null +++ b/docs/ui-docking.md @@ -0,0 +1,109 @@ +# Panel docking + +`craft_ui::docking` lets an application move the same panel between docked tabs, side splits and +floating groups. The shared layer owns placement and interaction; the application owns each +panel's contents, commands, document state and persistence policy. + +## Model and transactions + +`Layout

` contains an optional root `Node

` and floating tab groups. A node can be: + +- `Tabs`: ordered panel identities with one active index. +- `Split`: horizontal or vertical children, with a ratio or fixed first/second size. +- `Stack`: accordion entries with individual open state and optional content height. + +Choose a stable, nonlocalized panel ID type implementing `Clone + Eq + Hash`. Rendering also +requires `Debug + Send + Sync + 'static`. A panel identity occurs at most once in the layout. +Keep transient document/sequence tabs separate when they represent documents inside one panel. + +```rust +use craft_ui::docking::{Action, Floating, Layout, Node, Placement}; + +let mut layout = Layout { + root: Some(Node::Tabs { panels: vec!["layers", "properties"], active: 0 }), + floating: vec![Floating { + panels: vec!["brushes"], active: 0, rect: [40.0, 80.0, 240.0, 300.0], + }], +}; +layout.apply(Action::Move { + panel: "brushes", anchor: "layers", + placement: Placement::Tab { before: Some("properties") }, +})?; +# Ok::<(), craft_ui::docking::DockError>(()) +``` + +Every transaction validates the original layout before cloning or recursive mutation. It checks +the resulting layout before replacing the original. Missing or self targets, duplicate IDs, +invalid active indices, excessive depth/count and invalid geometry return `DockError`; they do +not remove the source panel. Tab removals retain the selected panel by identity when possible. +Empty source groups collapse. Moving within an accordion preserves its entry state and height; +unaffected split sizing and accordion entries remain unchanged. + +`OpenAt` adds a currently absent registered panel at an explicit tab or side destination. `Open` +is the simpler group-reveal operation. Registry checks remain app-owned: the shared model cannot +know whether an ID names an actual panel or whether its content is supported by the application. + +Use `apply_with_permissions` to prevent moving/closing a document canvas or merging a panel into +it. `Permissions::PROTECTED` disallows these operations while allowing side splits around it. +An app that requires a permanently docked panel also checks that policy before accepting a +transaction; an empty shared root is valid when all panels float. + +## Rendering and interaction + +Existing renderers can convert their layout into the shared model for transactions and retain +their own panel chrome. `DropTarget` describes visible body/tab rectangles and whether a group +accepts tabs or splits. Supply targets back-to-front, including floating-window layer order. +`resolve_drop` returns a tab insertion or side/center placement and its preview rectangle. +The frontmost containing group blocks targets underneath even when it rejects a drop. + +The optional `DockArea` renderer handles tab fitting and overflow, drag/drop previews, +Escape/focus-loss cancellation, split dividers and floating-group resize grips. It renders a +snapshot and returns `DockOutput.actions`; apply them through the application's command path. +Panel body callbacks receive stable global egui IDs, so moving a panel does not reset its widget +memory. The application should still keep durable panel/document data outside transient egui +widget memory. + +`DockStyle` supplies colors, font, spacing and localized action labels. `show_with_limits` also +accepts per-panel minimum/maximum sizes. Visible geometry clamps to the available area; a narrow +viewport relaxes minima proportionally. Persisted preferred sizes are unchanged by this drawing +clamp. A divider gesture produces an explicit resize action. + +Use `begin_drag` with the same area ID to carry an existing tab/header gesture into `DockArea` +when an app switches from its default arrangement to a customizable workspace. + +Floating groups are egui areas in one viewport. Native child windows, cross-viewport drag +transport, OS window ownership and multi-monitor coordinate conversion remain the host's +responsibility. The model does not instantiate or reparent native windows. + +## Persistence and visibility + +Enable `craft-ui`'s optional `serde` feature to serialize the shared model, or retain an existing +schema through an adapter. The serialization feature does not choose a filesystem location or +write preferences. Applications validate panel IDs, payload size and schema version before +committing loaded state. Bound recursive deserialization/conversion in the app as well as using +`Layout::validate`; the validator cannot retroactively protect an unbounded parser. + +To hide a panel without forgetting its placement, capture `Layout::location`, then close it. +Keep the resulting `Location` with the app's visibility state. `restore` uses stable neighbor +IDs, restores captured fixed sizing/floating bounds and recreates a previously closed accordion +group. If a neighbor was removed, it returns an unchanged layout and `MissingTarget`; choose an +explicit fallback such as a default side or remembered floating rectangle. Restoring multiple +panels closed from the same group in reverse close order preserves neighbor relationships. + +## Acceptance + +The shared tests cover lossless moves, tab reorder, stale targets, atomic errors, protected +canvases, accordion state, fixed sizing, hidden-panel restoration, bounded malformed layouts, +and a deterministic sequence of 800 panel moves. Pointer tests exercise dragging a floating +panel into the main tab strip and canceling a drag. + +`tests/docking_workflows.rs` renders a document and populated Layers/Properties/Brushes panels, +then uses actual pointer events to merge, detach and redock Brushes. It checks retained values +and widget identity at two widths, two display scales and both egui themes. To retain captures: + +```sh +CRAFT_UI_DOCK_VISUAL_DIR=/absolute/output/path cargo test -p craft-ui --test docking_workflows --all-features +``` + +These are offscreen egui checks. An adopting application still needs command, content, workspace +reload, theme and input tests using its own renderer and persistence adapter.