mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-11 17:37:26 +00:00
UI.7: add shared panel docking transactions and workspace renderer
This commit is contained in:
1 parent
67ce2e9a82
commit
d9077e2c09
14 files changed
+2449
-1
No files matched your search
Generated
+34
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user