UI.7: add shared panel docking transactions and workspace renderer

This commit is contained in:
skylar taylor-barrick committed 2026-10-09 22:29:35 -06:00
1 parent 67ce2e9a82
commit d9077e2c09
14 files changed
+2449 -1

No files matched your search

Generated
+34
View File
@@ -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"
+4
View File
@@ -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.
+3 -1
View File
@@ -23,12 +23,14 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
## Libraries
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls and theme helpers for egui frontends;
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking and theme helpers for egui frontends;
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
and compatibility boundaries across the Craft suite.
- [Theme infrastructure](docs/theme-infrastructure.md): eight-app comparison and the small shared
widget-state styling boundary.
- [Panel docking](docs/ui-docking.md): validated panel moves, floating groups, workspace adapters
and an optional egui docking surface.
## License and credits
+6
View File
@@ -12,8 +12,14 @@ workspace = true
[dependencies]
egui = { workspace = true }
serde = { version = "1", features = ["derive"], optional = true }
[features]
serde = ["dep:serde"]
[dev-dependencies]
egui = { workspace = true, features = ["default_fonts"] }
serde_json = "1"
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
egui_kittest = { version = "0.36", features = ["wgpu"] }
image = { version = "0.25", default-features = false, features = ["png"] }
+21
View File
@@ -3,6 +3,7 @@
`craft-ui` provides small egui components with application-supplied styles. `icons` prepares
embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and
interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.
`docking` supplies transactional panel moves and an optional docking surface.
Adapters cover all twelve Rust Craft apps; the [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
records their scope and acceptance limits. Assets, complete themes, localization and command handling
remain in each application. No icons, fonts or application engines are bundled.
@@ -153,6 +154,26 @@ or use an application-specific adapter. The library does not persist geometry, d
or create panes. Apps keep their existing dock trees, stacked panels, layout files, pane contents,
cameras, document focus and native windows. Shared controls do not constitute a dock-tree migration.
## Panel docking
`docking::Layout<P>` 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
+17
View File
@@ -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;
+790
View File
@@ -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<P> {
Split {
axis: SplitAxis,
size: SplitSize,
first: Box<Node<P>>,
second: Box<Node<P>>,
},
Tabs {
panels: Vec<P>,
active: usize,
},
/// An accordion keeps its open state and preferred heights when other panels move.
Stack {
entries: Vec<StackEntry<P>>,
},
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StackEntry<P> {
pub panel: P,
pub open: bool,
pub height: Option<f32>,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Floating<P> {
pub panels: Vec<P>,
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<P> {
pub root: Option<Node<P>>,
pub floating: Vec<Floating<P>>,
}
impl<P> Default for Layout<P> {
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<P> {
/// Insert before this existing destination member, or append. Also reorders a group.
Tab { before: Option<P> },
/// Center inserts after the anchor; sides create a new half-size split.
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<P> {
pub floating: Option<[f32; 4]>,
pub anchor: Option<P>,
pub placement: Placement<P>,
pub size: Option<SplitSize>,
/// Accordion state to restore if the destination is still an accordion.
pub stack: Option<(bool, Option<f32>)>,
/// 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<P>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Action<P> {
Move {
panel: P,
anchor: P,
placement: Placement<P>,
},
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<P>,
},
/// Reveal an absent panel beside a protected canvas or at another explicit destination.
OpenAt {
panel: P,
anchor: P,
placement: Placement<P>,
},
/// 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<bool>,
size: SplitSize,
},
SetStackOpen {
panel: P,
open: bool,
},
ResizeStack {
panel: P,
height: Option<f32>,
},
}
/// 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<f32>) -> 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<P: Clone + Eq + Hash> Layout<P> {
/// 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<P>> = 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<Location<P>, DockError> {
self.validate()?;
if let Some(group) = self.floating.iter().find(|g| g.panels.contains(panel)) {
let mut location = group_location(&group.panels, panel).ok_or(DockError::MissingPanel)?;
location.floating = Some(group.rect);
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<P>) -> 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<P>) -> 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<P>, 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<P>) -> 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<P: Eq>(node: &Node<P>, panel: &P, anchor: &P) -> bool {
match node {
Node::Tabs { panels, .. } => panels.contains(panel) && panels.contains(anchor),
Node::Stack { entries } => entries.iter().any(|e| &e.panel == panel) && entries.iter().any(|e| &e.panel == anchor),
Node::Split { first, second, .. } => in_node(first, panel, anchor) || in_node(second, panel, anchor),
}
}
self.root.as_ref().is_some_and(|n| in_node(n, panel, anchor))
|| self.floating.iter().any(|g| g.panels.contains(panel) && g.panels.contains(anchor))
}
fn take(&mut self, panel: &P) -> Option<StackEntry<P>> {
let mut removed = None;
self.root = self.root.take().and_then(|n| remove_node(n, panel, &mut removed));
if removed.is_some() {
return removed;
}
for group in &mut self.floating {
if let Some(p) = remove_tab(&mut group.panels, &mut group.active, panel) {
removed = Some(StackEntry { panel: p, open: true, height: None });
break;
}
}
self.floating.retain(|g| !g.panels.is_empty());
removed
}
fn insert(&mut self, entry: StackEntry<P>, anchor: &P, placement: Placement<P>) -> Result<(), DockError> {
if let Some(group) = self.floating.iter_mut().find(|g| g.panels.contains(anchor)) {
let index = match placement {
Placement::Tab { before } => before.as_ref().and_then(|p| group.panels.iter().position(|q| q == p)).unwrap_or(group.panels.len()),
Placement::Split(Zone::Center) => group.panels.iter().position(|p| p == anchor).map_or(group.panels.len(), |i| i + 1),
Placement::Split(_) => return Err(DockError::InvalidPlacement),
};
group.panels.insert(index, entry.panel);
group.active = index;
return Ok(());
}
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<P: Clone + Eq>(panels: &mut Vec<P>, active: &mut usize, panel: &P) -> Option<P> {
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<P: Clone + Eq>(node: Node<P>, panel: &P, removed: &mut Option<StackEntry<P>>) -> Option<Node<P>> {
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<P: Eq>(node: &mut Node<P>, 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<P>, panel: &P) -> Option<&'a mut StackEntry<P>> {
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<P: Clone + Eq>(node: &mut Node<P>, entry: &StackEntry<P>, anchor: &P, placement: &Placement<P>) -> 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<P: Clone + Eq>(panels: &[P], panel: &P) -> Option<Location<P>> {
let index = panels.iter().position(|p| p == panel)?;
let anchor = panels.iter().find(|p| *p != panel).cloned();
let before = panels.get(index + 1).cloned();
Some(Location { floating: None, anchor, placement: Placement::Tab { before }, size: None, stack: None, sibling: Vec::new() })
}
fn first_panel<P>(node: &Node<P>) -> 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<P: Clone + Eq>(node: &Node<P>, panel: &P) -> Option<Location<P>> {
match node {
Node::Tabs { panels, .. } => group_location(panels, panel),
Node::Stack { entries } => {
let entry = entries.iter().find(|e| &e.panel == panel)?;
let panels: Vec<P> = 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<P: Eq>(node: &mut Node<P>, panel: &P, size: SplitSize) -> bool {
fn alone<P: Eq>(node: &Node<P>, 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<P: Clone + Eq>(node: &mut Node<P>, 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<P>, 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<P: Clone + Eq + Hash>(node: &mut Node<P>, 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
}
}
+615
View File
@@ -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<P> {
pub actions: Vec<Action<P>>,
pub error: Option<DockError>,
pub focused: Option<P>,
}
impl<P> Default for DockOutput<P> {
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<P> {
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<P: Clone + Send + Sync + 'static>(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<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) {
ctx.data_mut(|d| d.remove::<Drag<P>>(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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(
self,
ui: &mut Ui,
layout: &Layout<P>,
style: &DockStyle,
label: impl Fn(&P) -> String,
permissions: impl Fn(&P) -> Permissions,
body: impl FnMut(&mut Ui, &P),
) -> DockOutput<P> {
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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(
self,
ui: &mut Ui,
layout: &Layout<P>,
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<P> {
let rect = ui.available_rect_before_wrap();
if let Err(error) = layout.validate() {
cancel_drag::<P>(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::<P>(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<P>,
targets: Vec<DropTarget<P>>,
output: DockOutput<P>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
fn node(&mut self, ui: &mut Ui, node: &Node<P>, rect: Rect, path: &mut Vec<bool>) {
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<P>) -> 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<f32> =
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::<Vec<_>>());
let mut groups: Vec<_> = self.layout.floating.iter().collect();
groups.sort_by_key(|group| {
let id = group.panels.first().map(|panel| self.id.with(("floating", panel)));
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::<Drag<P>>(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::<P>(ctx, self.id);
return;
}
let Some(pointer) = pointer.filter(|p| p.is_finite()) else {
if released {
cancel_drag::<P>(ctx, self.id);
}
return;
};
let proposal = resolve_drop(&drag.panel, pointer, &self.targets);
if released {
cancel_drag::<P>(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) }
}
+99
View File
@@ -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<P> {
pub anchor: P,
pub rect: Rect,
pub tab_strip: Option<Rect>,
pub tabs: Vec<(P, Rect)>,
pub allow_splits: bool,
pub accepts_tabs: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DropProposal<P> {
pub anchor: P,
pub placement: Placement<P>,
pub preview: Rect,
pub insertion: Option<Rect>,
}
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<Zone> {
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<Rect> {
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<P: Clone + Eq>(panel: &P, pointer: Pos2, targets: &[DropTarget<P>]) -> Option<DropProposal<P>> {
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 })
}
+499
View File
@@ -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<u32> {
Node::Tabs { panels: panels.to_vec(), active }
}
fn sample() -> Layout<u32> {
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<u32> {
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<u32> {
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<u32> = 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<u32> = 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::<Location<u32>>(&encoded).unwrap(), location);
}
fn frame(ctx: &Context, layout: &Layout<u32>, events: Vec<Event>, focused: bool) -> (DockOutput<u32>, Vec<Id>) {
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<u32>, events: Vec<Event>) -> Vec<Action<u32>> {
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();
}
+2
View File
@@ -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),
+1
View File
@@ -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;
+249
View File
@@ -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<u32>,
output: FullOutput,
width: f32,
frame: usize,
values: BTreeMap<u32, String>,
body_ids: BTreeMap<u32, Id>,
edit_ids: BTreeMap<u32, Id>,
applied: Vec<Action<u32>>,
}
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<Event>) {
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<PathBuf>, 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();
}
}
}
}
}
+109
View File
@@ -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<P>` contains an optional root `Node<P>` 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.