diff --git a/_release-content/release-notes/dock_tree.md b/_release-content/release-notes/dock_tree.md new file mode 100644 index 0000000000000..e3cfc70f65531 --- /dev/null +++ b/_release-content/release-notes/dock_tree.md @@ -0,0 +1,19 @@ +--- +title: Dock tree +authors: ["@jbuehler23"] +pull_requests: [26034] +--- + +`bevy_ui_widgets` can now describe dockable layouts as plain data. A `DockTree` component holds tab groups and the splits between them, with operations to add, move, split and close tabs. Empty groups are removed and nested splits are merged as the tree changes. + +Split sizes are flex weights, the same as `Pane::size` in the split pane widget. Each tab names its content with a `DockPanelKey` that the app maps to its own UI, and with the `serialize` feature a layout can be saved and restored. + +```rust +let mut tree = DockTree::new(); +let center = tree.root(); +tree.add_tab(center, "viewport")?; +tree.split(center, DockEdge::Left, "outliner")?; +tree.split(center, DockEdge::Bottom, "assets")?; +``` + +The dock tree holds the data only. Building UI from the tree and dragging tabs between groups come in later releases. diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index e55ccbf5cb2d6..e53570e82099a 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -135,6 +135,7 @@ serialize = [ "bevy_time/serialize", "bevy_transform/serialize", "bevy_ui?/serialize", + "bevy_ui_widgets?/serialize", "bevy_utils/serialize", "bevy_window?/serialize", "bevy_winit?/serialize", diff --git a/crates/bevy_ui_widgets/Cargo.toml b/crates/bevy_ui_widgets/Cargo.toml index 0d6b248c363e2..4273caca2548d 100644 --- a/crates/bevy_ui_widgets/Cargo.toml +++ b/crates/bevy_ui_widgets/Cargo.toml @@ -31,15 +31,21 @@ bevy_window = { path = "../bevy_window", version = "0.20.0-dev" } # other accesskit = "0.25" parley = { version = "0.11.0", default-features = false } +serde = { version = "1", features = ["derive"], optional = true } smol_str = "0.2" +thiserror = { version = "2", default-features = false } # The shortcut layout (Cmd vs Ctrl) depends on the HOST os, which on wasm is # only knowable at runtime -- see `mac_host` in text_input. [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { version = "0.3", features = ["Window", "Navigator"] } +[dev-dependencies] +ron = "0.12" + [features] default = [] +serialize = ["dep:serde"] [lints] workspace = true diff --git a/crates/bevy_ui_widgets/src/dock/mod.rs b/crates/bevy_ui_widgets/src/dock/mod.rs new file mode 100644 index 0000000000000..c5998e543f468 --- /dev/null +++ b/crates/bevy_ui_widgets/src/dock/mod.rs @@ -0,0 +1,70 @@ +//! Dock layouts for Bevy UI, described as plain data. +//! +//! A [`DockTree`] describes how a region of the screen is divided into tab groups. It holds +//! no entities and draws nothing; UI is built from it and kept in sync by other code. Layout +//! changes such as moving, splitting or closing tabs are made on the tree. +//! +//! The tree has two kinds of node: +//! +//! - a leaf, [`DockLeaf`], is a tab group: an ordered list of [`DockTab`]s and the one that +//! is active +//! - a split, [`DockSplit`], lays out two or more children along a [`ControlOrientation`], +//! each with a flex weight that matches [`Pane::size`] +//! +//! Each tab names its content with a [`DockPanelKey`]. The app decides what content a key +//! stands for, so the same tree can be saved and loaded across runs. +//! +//! [`DockNodeId`]s of leaves and [`DockTabId`]s are stable across edits and never reused. +//! Split nodes are created and removed as the tree is simplified: empty leaves are removed, +//! splits with a single child are replaced by that child, and a split nested in a split of +//! the same orientation is merged into its parent. +//! +//! [`DockTree`] is a component, so an app can have several docks, one per root entity. With +//! the `serialize` feature it can be saved and loaded with serde. +//! +//! The tree is the source of truth for the layout, and the dock's UI entities are derived from +//! it. Widgets such as tabs and splitters only propose changes, which are applied to the tree, +//! and the UI is then brought back in line with it. Keeping the layout as plain data makes +//! operations like splitting at an edge or collapsing empty groups simple to write and test, +//! makes layouts easy to save and restore, and lets the UI entities be rebuilt without losing +//! the layout. +//! +//! ``` +//! use bevy_ui_widgets::{DockEdge, DockTree}; +//! +//! let mut tree = DockTree::new(); +//! let center = tree.root(); +//! tree.add_tab(center, "viewport").unwrap(); +//! tree.split(center, DockEdge::Left, "outliner").unwrap(); +//! let (right, _) = tree.split(center, DockEdge::Right, "inspector").unwrap(); +//! tree.add_tab(right, "settings").unwrap(); +//! tree.split(center, DockEdge::Bottom, "assets").unwrap(); +//! +//! assert_eq!(tree.leaves().count(), 4); +//! ``` +//! +//! The above code produces this layout: +//! +//! ┌───┬───┬─────┐ +//! │ O │ V │ I/S │ +//! ├───┴───┴─────┤ +//! │ A │ +//! └─────────────┘ +//! +//! The above code produces this layout, where `O` is the outliner, `V` the viewport, `I/S` the +//! inspector and settings tabs, and `A` the assets: +//! +//! ```text +//! +---+---+-----+ +//! | O | V | I/S | +//! | +---+ | +//! | | A | | +//! +---+---+-----+ +//! ``` +//! +//! [`ControlOrientation`]: crate::ControlOrientation +//! [`Pane::size`]: crate::Pane::size + +mod tree; + +pub use tree::*; diff --git a/crates/bevy_ui_widgets/src/dock/tree.rs b/crates/bevy_ui_widgets/src/dock/tree.rs new file mode 100644 index 0000000000000..45c98be228612 --- /dev/null +++ b/crates/bevy_ui_widgets/src/dock/tree.rs @@ -0,0 +1,962 @@ +use alloc::{collections::BTreeMap, string::String, vec, vec::Vec}; + +use bevy_ecs::{component::Component, reflect::ReflectComponent}; +use bevy_reflect::{prelude::ReflectDefault, Reflect}; +use thiserror::Error; + +use crate::ControlOrientation; + +#[cfg(feature = "serialize")] +use bevy_reflect::{ReflectDeserialize, ReflectSerialize}; +#[cfg(feature = "serialize")] +use serde::{Deserialize, Serialize}; + +/// Identifies a node in a [`DockTree`]. Ids are never reused within a tree. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Reflect)] +#[reflect(Clone, Debug, PartialEq, Hash)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct DockNodeId(u64); + +/// Identifies a tab in a [`DockTree`]. A tab keeps its id when it moves between leaves, and +/// ids are never reused within a tree. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Reflect)] +#[reflect(Clone, Debug, PartialEq, Hash)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct DockTabId(u64); + +/// Names the content shown by a tab. The app maps each key to the content it builds for it. +/// +/// Several tabs may share a key, for example two views of the same kind of panel. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Reflect)] +#[reflect(Clone, Debug, PartialEq, Hash)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct DockPanelKey(pub String); + +impl DockPanelKey { + /// Returns the key as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for DockPanelKey { + fn from(value: &str) -> Self { + Self(value.into()) + } +} + +impl From for DockPanelKey { + fn from(value: String) -> Self { + Self(value) + } +} + +/// An edge of a node, used to place a new leaf beside it. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Reflect)] +#[reflect(Clone, Debug, PartialEq, Hash)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub enum DockEdge { + /// Above the node. + Top, + /// Below the node. + Bottom, + /// Left of the node. + Left, + /// Right of the node. + Right, +} + +impl DockEdge { + /// The orientation of a split that places a node at this edge. + pub fn orientation(self) -> ControlOrientation { + match self { + DockEdge::Top | DockEdge::Bottom => ControlOrientation::Vertical, + DockEdge::Left | DockEdge::Right => ControlOrientation::Horizontal, + } + } + + /// Returns true for [`DockEdge::Top`] and [`DockEdge::Left`], which place the new node + /// before the target, so it ends up above or to the left of it. + pub fn is_start(self) -> bool { + matches!(self, DockEdge::Top | DockEdge::Left) + } +} + +/// A tab in a [`DockLeaf`]. +#[derive(Clone, Debug, PartialEq, Reflect)] +#[reflect(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct DockTab { + /// The id of this tab. + pub id: DockTabId, + /// The content this tab shows. + pub panel: DockPanelKey, +} + +/// A tab group: an ordered list of tabs. A non-empty group has exactly one active tab, and an +/// empty group has none. +#[derive(Clone, Debug, Default, PartialEq, Reflect)] +#[reflect(Clone, Debug, Default, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct DockLeaf { + tabs: Vec, + active: Option, + persistent: bool, +} + +impl DockLeaf { + /// The tabs in display order. + pub fn tabs(&self) -> &[DockTab] { + &self.tabs + } + + /// The id of the active tab, or `None` if the leaf is empty. + pub fn active(&self) -> Option { + self.active + } + + /// The active tab, or `None` if the leaf is empty. + pub fn active_tab(&self) -> Option<&DockTab> { + self.active + .and_then(|id| self.tabs.iter().find(|t| t.id == id)) + } + + /// The position of a tab in this leaf. + pub fn tab_index(&self, tab: DockTabId) -> Option { + self.tabs.iter().position(|t| t.id == tab) + } + + /// Whether this leaf is kept when its last tab is removed. + pub fn is_persistent(&self) -> bool { + self.persistent + } +} + +/// A child of a [`DockSplit`]. +#[derive(Copy, Clone, Debug, PartialEq, Reflect)] +#[reflect(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct DockSplitChild { + /// The child node. + pub node: DockNodeId, + /// The flex weight of the child along the split axis, as in [`Pane::size`]. + /// + /// [`Pane::size`]: crate::Pane::size + pub size: f32, +} + +/// Two or more nodes laid out side by side along an orientation. +#[derive(Clone, Debug, PartialEq, Reflect)] +#[reflect(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct DockSplit { + orientation: ControlOrientation, + children: Vec, +} + +impl DockSplit { + /// The axis the children are laid out along, as in [`SplitPane::orientation`]. + /// + /// [`SplitPane::orientation`]: crate::SplitPane::orientation + pub fn orientation(&self) -> ControlOrientation { + self.orientation + } + + /// The children in layout order: left to right, or top to bottom. + pub fn children(&self) -> &[DockSplitChild] { + &self.children + } +} + +/// A node in a [`DockTree`]. +#[derive(Clone, Debug, PartialEq, Reflect)] +#[reflect(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub enum DockNode { + /// A tab group. + Leaf(DockLeaf), + /// A split between child nodes. + Split(DockSplit), +} + +impl DockNode { + /// Returns the leaf if this node is one. + pub fn as_leaf(&self) -> Option<&DockLeaf> { + match self { + DockNode::Leaf(leaf) => Some(leaf), + DockNode::Split(_) => None, + } + } + + /// Returns the split if this node is one. + pub fn as_split(&self) -> Option<&DockSplit> { + match self { + DockNode::Split(split) => Some(split), + DockNode::Leaf(_) => None, + } + } +} + +/// An error from an operation on a [`DockTree`]. +#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum DockError { + /// The node does not exist. + #[error("dock node {0:?} does not exist")] + NodeNotFound(DockNodeId), + /// The node is a split where a leaf was expected. + #[error("dock node {0:?} is not a leaf")] + NotALeaf(DockNodeId), + /// The node is a leaf where a split was expected. + #[error("dock node {0:?} is not a split")] + NotASplit(DockNodeId), + /// The tab does not exist. + #[error("dock tab {0:?} does not exist")] + TabNotFound(DockTabId), + /// The tab exists but is not in the given leaf. + #[error("dock tab {tab:?} is not in leaf {leaf:?}")] + TabNotInLeaf { + /// The leaf that was searched. + leaf: DockNodeId, + /// The tab that was not found. + tab: DockTabId, + }, + /// Split sizes must be finite, positive, and one per child. + #[error("split sizes must be finite, positive, and one per child")] + InvalidSizes, +} + +/// A dock layout: a tree of tab groups and the splits between them. +/// +/// The tree is the source of truth for the layout, and lives on the dock's root entity. The +/// dock's UI is derived from it, and widgets propose changes that are applied to the tree. +/// +/// The tree always has a root node. A new tree's root is an empty leaf. +#[derive(Component, Clone, Debug, PartialEq, Reflect)] +#[reflect(Component, Clone, Debug, Default, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(Serialize, Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct DockTree { + nodes: BTreeMap, + root: DockNodeId, + next_node: u64, + next_tab: u64, +} + +impl Default for DockTree { + fn default() -> Self { + let root = DockNodeId(0); + Self { + nodes: BTreeMap::from([(root, DockNode::Leaf(DockLeaf::default()))]), + root, + next_node: 1, + next_tab: 0, + } + } +} + +impl DockTree { + /// Creates a tree whose root is an empty leaf. + pub fn new() -> Self { + Self::default() + } + + /// The root node. + pub fn root(&self) -> DockNodeId { + self.root + } + + /// Returns a node by id. + pub fn node(&self, id: DockNodeId) -> Option<&DockNode> { + self.nodes.get(&id) + } + + /// Returns a leaf by id, or `None` if it does not exist or is a split. + pub fn leaf(&self, id: DockNodeId) -> Option<&DockLeaf> { + self.node(id).and_then(DockNode::as_leaf) + } + + /// Every node reachable from the root with its depth, in depth-first order. + pub fn iter_dfs(&self) -> Vec<(DockNodeId, usize)> { + let mut out = Vec::new(); + let mut stack = vec![(self.root, 0)]; + while let Some((id, depth)) = stack.pop() { + out.push((id, depth)); + if let Some(DockNode::Split(split)) = self.nodes.get(&id) { + stack.extend(split.children.iter().rev().map(|c| (c.node, depth + 1))); + } + } + out + } + + /// Every leaf reachable from the root, in depth-first order. + pub fn leaves(&self) -> impl Iterator { + self.iter_dfs() + .into_iter() + .filter_map(|(id, _)| self.leaf(id).map(|leaf| (id, leaf))) + } + + /// Every tab with the leaf that holds it, in depth-first then display order. + pub fn tabs(&self) -> impl Iterator { + self.leaves() + .flat_map(|(id, leaf)| leaf.tabs.iter().map(move |tab| (id, tab))) + } + + /// The leaf that holds a tab. + pub fn find_leaf_for_tab(&self, tab: DockTabId) -> Option { + self.nodes.iter().find_map(|(id, node)| match node { + DockNode::Leaf(leaf) if leaf.tab_index(tab).is_some() => Some(*id), + _ => None, + }) + } + + /// The first tab showing a panel, with the leaf that holds it. + pub fn find_panel(&self, panel: &str) -> Option<(DockNodeId, DockTabId)> { + self.tabs() + .find(|(_, tab)| tab.panel.as_str() == panel) + .map(|(leaf, tab)| (leaf, tab.id)) + } + + /// The split that contains a node, or `None` for the root or an unknown node. + pub fn parent_of(&self, child: DockNodeId) -> Option { + self.nodes.iter().find_map(|(id, node)| match node { + DockNode::Split(split) if split.children.iter().any(|c| c.node == child) => Some(*id), + _ => None, + }) + } + + /// Appends a tab showing `panel` to a leaf and makes it active. + pub fn add_tab( + &mut self, + leaf: DockNodeId, + panel: impl Into, + ) -> Result { + self.leaf_mut(leaf)?; + let tab = self.fresh_tab(panel.into()); + let id = tab.id; + let target = self.leaf_mut(leaf)?; + target.tabs.push(tab); + target.active = Some(id); + Ok(id) + } + + /// Places a new leaf holding a tab for `panel` at an edge of `target`, which may be a leaf + /// or a split. The new leaf takes half of the space `target` had. + /// + /// Returns the new leaf and tab. + pub fn split( + &mut self, + target: DockNodeId, + edge: DockEdge, + panel: impl Into, + ) -> Result<(DockNodeId, DockTabId), DockError> { + self.node_exists(target)?; + let tab = self.fresh_tab(panel.into()); + let id = tab.id; + let leaf = self.split_with(target, edge, tab); + Ok((leaf, id)) + } + + /// Moves a tab into a leaf at `index`, or to the end if `index` is `None`, and makes it + /// active. The index is clamped and counts positions after the tab is taken out, so this + /// also reorders tabs within a leaf. + pub fn move_tab( + &mut self, + tab: DockTabId, + to: DockNodeId, + index: Option, + ) -> Result<(), DockError> { + self.leaf_mut(to)?; + let (_, entry) = self.take_tab(tab)?; + let target = self.leaf_mut(to)?; + let index = index.unwrap_or(target.tabs.len()).min(target.tabs.len()); + target.tabs.insert(index, entry); + target.active = Some(tab); + self.simplify(); + Ok(()) + } + + /// Moves a tab into a new leaf at an edge of `target`, as in [`DockTree::split`]. + /// + /// Returns the new leaf. + pub fn move_tab_to_edge( + &mut self, + tab: DockTabId, + target: DockNodeId, + edge: DockEdge, + ) -> Result { + self.node_exists(target)?; + let (_, entry) = self.take_tab(tab)?; + Ok(self.split_with(target, edge, entry)) + } + + /// Removes a tab and returns it. A leaf left empty is removed unless it is the root or + /// persistent. + pub fn remove_tab(&mut self, tab: DockTabId) -> Result { + let (_, entry) = self.take_tab(tab)?; + self.simplify(); + Ok(entry) + } + + /// Makes a tab in a leaf active. + pub fn set_active(&mut self, leaf: DockNodeId, tab: DockTabId) -> Result<(), DockError> { + let target = self.leaf_mut(leaf)?; + if target.tab_index(tab).is_none() { + return Err(DockError::TabNotInLeaf { leaf, tab }); + } + target.active = Some(tab); + Ok(()) + } + + /// Sets whether a leaf is kept when its last tab is removed. + pub fn set_persistent(&mut self, leaf: DockNodeId, persistent: bool) -> Result<(), DockError> { + self.leaf_mut(leaf)?.persistent = persistent; + Ok(()) + } + + /// Sets the sizes of a split's children, one per child in layout order. + /// + /// This takes the same values a split pane reports in its `ValueChange>`. + pub fn set_sizes(&mut self, split: DockNodeId, sizes: &[f32]) -> Result<(), DockError> { + let DockNode::Split(target) = self + .nodes + .get_mut(&split) + .ok_or(DockError::NodeNotFound(split))? + else { + return Err(DockError::NotASplit(split)); + }; + if sizes.len() != target.children.len() || sizes.iter().any(|s| !s.is_finite() || *s <= 0.0) + { + return Err(DockError::InvalidSizes); + } + for (child, size) in target.children.iter_mut().zip(sizes) { + child.size = *size; + } + Ok(()) + } + + /// Normalizes the tree. Empty leaves are removed unless they are the root or persistent, + /// splits left with one child are replaced by that child, and a split nested directly in + /// a split of the same orientation is merged into it, keeping the space each node had. + /// + /// Operations that remove or add nodes call this themselves. + pub fn simplify(&mut self) { + let root = self.root; + self.root = match self.simplify_node(root, root) { + Some(id) => id, + None => { + let id = self.fresh_node(); + self.nodes.insert(id, DockNode::Leaf(DockLeaf::default())); + id + } + }; + } + + fn simplify_node(&mut self, id: DockNodeId, root: DockNodeId) -> Option { + let (orientation, old_children) = match self.nodes.get(&id)? { + DockNode::Leaf(leaf) => { + if leaf.tabs.is_empty() && !leaf.persistent && id != root { + self.nodes.remove(&id); + return None; + } + return Some(id); + } + DockNode::Split(split) => (split.orientation, split.children.clone()), + }; + let mut children = Vec::with_capacity(old_children.len()); + for child in old_children { + let Some(node) = self.simplify_node(child.node, root) else { + continue; + }; + match self.nodes.get(&node) { + Some(DockNode::Split(inner)) if inner.orientation == orientation => { + let total: f32 = inner.children.iter().map(|c| c.size).sum(); + children.extend(inner.children.iter().map(|c| DockSplitChild { + node: c.node, + size: child.size * c.size / total, + })); + self.nodes.remove(&node); + } + _ => children.push(DockSplitChild { + node, + size: child.size, + }), + } + } + match children.len() { + 0 => { + self.nodes.remove(&id); + None + } + 1 => { + self.nodes.remove(&id); + Some(children[0].node) + } + _ => { + if let Some(DockNode::Split(split)) = self.nodes.get_mut(&id) { + split.children = children; + } + Some(id) + } + } + } + + fn split_with(&mut self, target: DockNodeId, edge: DockEdge, tab: DockTab) -> DockNodeId { + let parent = self.parent_of(target); + let leaf = self.fresh_node(); + self.nodes.insert( + leaf, + DockNode::Leaf(DockLeaf { + active: Some(tab.id), + tabs: vec![tab], + persistent: false, + }), + ); + let pair = if edge.is_start() { + [leaf, target] + } else { + [target, leaf] + }; + let split = self.fresh_node(); + self.nodes.insert( + split, + DockNode::Split(DockSplit { + orientation: edge.orientation(), + children: pair.map(|node| DockSplitChild { node, size: 1.0 }).into(), + }), + ); + match parent.and_then(|p| self.nodes.get_mut(&p)) { + Some(DockNode::Split(parent)) => { + for child in &mut parent.children { + if child.node == target { + child.node = split; + } + } + } + _ => self.root = split, + } + self.simplify(); + leaf + } + + fn take_tab(&mut self, tab: DockTabId) -> Result<(DockNodeId, DockTab), DockError> { + let leaf = self + .find_leaf_for_tab(tab) + .ok_or(DockError::TabNotFound(tab))?; + let source = self.leaf_mut(leaf)?; + let index = source.tab_index(tab).ok_or(DockError::TabNotFound(tab))?; + let entry = source.tabs.remove(index); + if source.active == Some(tab) { + let next = index.min(source.tabs.len().saturating_sub(1)); + source.active = source.tabs.get(next).map(|t| t.id); + } + Ok((leaf, entry)) + } + + fn leaf_mut(&mut self, id: DockNodeId) -> Result<&mut DockLeaf, DockError> { + match self.nodes.get_mut(&id) { + Some(DockNode::Leaf(leaf)) => Ok(leaf), + Some(DockNode::Split(_)) => Err(DockError::NotALeaf(id)), + None => Err(DockError::NodeNotFound(id)), + } + } + + fn node_exists(&self, id: DockNodeId) -> Result<(), DockError> { + if self.nodes.contains_key(&id) { + Ok(()) + } else { + Err(DockError::NodeNotFound(id)) + } + } + + fn fresh_node(&mut self) -> DockNodeId { + let id = DockNodeId(self.next_node); + self.next_node += 1; + id + } + + fn fresh_tab(&mut self, panel: DockPanelKey) -> DockTab { + let id = DockTabId(self.next_tab); + self.next_tab += 1; + DockTab { id, panel } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{string::ToString, vec::Vec}; + + fn tree_with(panels: &[&str]) -> (DockTree, DockNodeId) { + let mut tree = DockTree::new(); + let root = tree.root(); + for panel in panels { + tree.add_tab(root, *panel).unwrap(); + } + (tree, root) + } + + fn panels(tree: &DockTree, leaf: DockNodeId) -> Vec { + tree.leaf(leaf) + .unwrap() + .tabs() + .iter() + .map(|t| t.panel.as_str().to_string()) + .collect() + } + + fn active_panel(tree: &DockTree, leaf: DockNodeId) -> Option<&str> { + tree.leaf(leaf)?.active_tab().map(|t| t.panel.as_str()) + } + + fn tab(tree: &DockTree, panel: &str) -> DockTabId { + tree.find_panel(panel).unwrap().1 + } + + fn root_split(tree: &DockTree) -> &DockSplit { + tree.node(tree.root()).unwrap().as_split().unwrap() + } + + fn child_nodes(split: &DockSplit) -> Vec { + split.children().iter().map(|c| c.node).collect() + } + + #[test] + fn new_tree_has_empty_root_leaf() { + let tree = DockTree::new(); + assert_eq!(tree.leaves().count(), 1); + assert!(tree.leaf(tree.root()).unwrap().tabs().is_empty()); + } + + #[test] + fn added_tabs_get_unique_ids_and_become_active() { + let (tree, root) = tree_with(&["a", "b"]); + let leaf = tree.leaf(root).unwrap(); + assert_ne!(leaf.tabs()[0].id, leaf.tabs()[1].id); + assert_eq!(leaf.active(), Some(leaf.tabs()[1].id)); + } + + #[test] + fn split_right_wraps_target() { + let (mut tree, root) = tree_with(&["a"]); + let (new_leaf, _) = tree.split(root, DockEdge::Right, "b").unwrap(); + let split = root_split(&tree); + assert_eq!(split.orientation(), ControlOrientation::Horizontal); + assert_eq!(child_nodes(split), vec![root, new_leaf]); + assert!(split.children().iter().all(|c| c.size == 1.0)); + assert_eq!(panels(&tree, root), vec!["a"]); + assert_eq!(panels(&tree, new_leaf), vec!["b"]); + } + + #[test] + fn split_at_each_edge() { + for (edge, orientation, new_first) in [ + (DockEdge::Top, ControlOrientation::Vertical, true), + (DockEdge::Bottom, ControlOrientation::Vertical, false), + (DockEdge::Left, ControlOrientation::Horizontal, true), + (DockEdge::Right, ControlOrientation::Horizontal, false), + ] { + let (mut tree, root) = tree_with(&["a"]); + let (new_leaf, _) = tree.split(root, edge, "b").unwrap(); + let split = root_split(&tree); + assert_eq!(split.orientation(), orientation); + let expected = if new_first { + vec![new_leaf, root] + } else { + vec![root, new_leaf] + }; + assert_eq!(child_nodes(split), expected); + } + } + + #[test] + fn split_top_puts_new_first() { + let (mut tree, root) = tree_with(&["a"]); + let (new_leaf, _) = tree.split(root, DockEdge::Top, "b").unwrap(); + assert_eq!(child_nodes(root_split(&tree)), vec![new_leaf, root]); + } + + #[test] + fn split_bottom_puts_new_last() { + let (mut tree, root) = tree_with(&["a"]); + let (new_leaf, _) = tree.split(root, DockEdge::Bottom, "b").unwrap(); + assert_eq!(child_nodes(root_split(&tree)), vec![root, new_leaf]); + } + + #[test] + fn split_of_nested_leaf_preserves_other_sibling() { + let (mut tree, root) = tree_with(&["a"]); + let (right, _) = tree.split(root, DockEdge::Right, "b").unwrap(); + let (bottom, _) = tree.split(right, DockEdge::Bottom, "c").unwrap(); + + assert_eq!(panels(&tree, root), vec!["a"]); + assert_eq!(panels(&tree, right), vec!["b"]); + assert_eq!(panels(&tree, bottom), vec!["c"]); + let inner = tree.parent_of(right).unwrap(); + assert_eq!(tree.parent_of(inner), Some(tree.root())); + } + + #[test] + fn split_in_same_orientation_joins_parent() { + let (mut tree, root) = tree_with(&["a"]); + let (right, _) = tree.split(root, DockEdge::Right, "b").unwrap(); + let (middle, _) = tree.split(root, DockEdge::Right, "c").unwrap(); + + let split = root_split(&tree); + assert_eq!(child_nodes(split), vec![root, middle, right]); + let sizes: Vec = split.children().iter().map(|c| c.size).collect(); + assert_eq!(sizes, vec![0.5, 0.5, 1.0]); + } + + #[test] + fn move_tab_relocates_and_activates() { + let (mut tree, root) = tree_with(&["a", "b"]); + let (right, _) = tree.split(root, DockEdge::Right, "c").unwrap(); + tree.move_tab(tab(&tree, "a"), right, None).unwrap(); + + assert_eq!(panels(&tree, root), vec!["b"]); + assert_eq!(panels(&tree, right), vec!["c", "a"]); + assert_eq!(active_panel(&tree, right), Some("a")); + } + + #[test] + fn move_tab_within_same_leaf() { + let (mut tree, root) = tree_with(&["a", "b", "c"]); + let a = tab(&tree, "a"); + tree.move_tab(a, root, Some(2)).unwrap(); + assert_eq!(panels(&tree, root), vec!["b", "c", "a"]); + tree.move_tab(a, root, Some(0)).unwrap(); + assert_eq!(panels(&tree, root), vec!["a", "b", "c"]); + assert_eq!(tab(&tree, "a"), a); + } + + #[test] + fn move_last_tab_simplifies_tree() { + let (mut tree, root) = tree_with(&["a"]); + let (right, _) = tree.split(root, DockEdge::Right, "b").unwrap(); + tree.move_tab(tab(&tree, "a"), right, None).unwrap(); + + assert_eq!(tree.root(), right); + assert_eq!(tree.leaves().count(), 1); + assert_eq!(panels(&tree, right), vec!["b", "a"]); + } + + #[test] + fn move_tab_to_edge_keeps_tab_id() { + let (mut tree, root) = tree_with(&["a", "b"]); + let b = tab(&tree, "b"); + let leaf = tree.move_tab_to_edge(b, root, DockEdge::Left).unwrap(); + + assert_eq!(child_nodes(root_split(&tree)), vec![leaf, root]); + assert_eq!(tree.find_panel("b"), Some((leaf, b))); + assert_eq!(panels(&tree, root), vec!["a"]); + } + + #[test] + fn remove_last_tab_keeps_root_empty_leaf() { + let (mut tree, root) = tree_with(&["a"]); + let removed = tree.remove_tab(tab(&tree, "a")).unwrap(); + + assert_eq!(removed.panel.as_str(), "a"); + assert_eq!(tree.root(), root); + assert!(tree.leaf(root).unwrap().tabs().is_empty()); + } + + #[test] + fn remove_last_tab_collapses_leaf_and_parent() { + let (mut tree, root) = tree_with(&["a"]); + let (right, _) = tree.split(root, DockEdge::Right, "b").unwrap(); + tree.split(right, DockEdge::Bottom, "c").unwrap(); + tree.set_sizes(tree.root(), &[1.0, 3.0]).unwrap(); + + tree.remove_tab(tab(&tree, "c")).unwrap(); + + let split = root_split(&tree); + assert_eq!(child_nodes(split), vec![root, right]); + assert_eq!(split.children()[1].size, 3.0); + assert_eq!(tree.iter_dfs().len(), 3); + } + + #[test] + fn removing_active_tab_activates_neighbor() { + let (mut tree, root) = tree_with(&["a", "b", "c"]); + tree.set_active(root, tab(&tree, "b")).unwrap(); + tree.remove_tab(tab(&tree, "b")).unwrap(); + assert_eq!(active_panel(&tree, root), Some("c")); + tree.remove_tab(tab(&tree, "c")).unwrap(); + assert_eq!(active_panel(&tree, root), Some("a")); + } + + #[test] + fn set_sizes_rejects_invalid_values() { + let (mut tree, root) = tree_with(&["a"]); + tree.split(root, DockEdge::Right, "b").unwrap(); + let split = tree.root(); + + tree.set_sizes(split, &[2.0, 1.0]).unwrap(); + assert_eq!(root_split(&tree).children()[0].size, 2.0); + for sizes in [&[1.0][..], &[0.0, 1.0], &[-1.0, 1.0], &[f32::NAN, 1.0]] { + assert_eq!(tree.set_sizes(split, sizes), Err(DockError::InvalidSizes)); + } + assert_eq!( + tree.set_sizes(root, &[1.0]), + Err(DockError::NotASplit(root)) + ); + } + + #[test] + fn set_active_requires_tab_in_leaf() { + let (mut tree, root) = tree_with(&["a", "b"]); + let a = tab(&tree, "a"); + tree.set_active(root, a).unwrap(); + assert_eq!(active_panel(&tree, root), Some("a")); + + let stranger = DockTabId(9999); + assert_eq!( + tree.set_active(root, stranger), + Err(DockError::TabNotInLeaf { + leaf: root, + tab: stranger + }) + ); + assert_eq!(active_panel(&tree, root), Some("a")); + } + + #[test] + fn duplicate_panel_supported() { + let (mut tree, root) = tree_with(&[]); + let first = tree.add_tab(root, "outliner").unwrap(); + let second = tree.add_tab(root, "outliner").unwrap(); + assert_ne!(first, second); + assert_eq!(panels(&tree, root), vec!["outliner", "outliner"]); + + tree.remove_tab(second).unwrap(); + let leaf = tree.leaf(root).unwrap(); + assert_eq!(leaf.tabs().len(), 1); + assert_eq!(leaf.tabs()[0].id, first); + assert_eq!(leaf.active(), Some(first)); + } + + #[test] + fn persistent_leaf_kept_when_emptied() { + let (mut tree, root) = tree_with(&["a"]); + let (other, _) = tree.split(root, DockEdge::Right, "b").unwrap(); + tree.set_persistent(root, true).unwrap(); + tree.move_tab(tab(&tree, "a"), other, None).unwrap(); + + let leaf = tree.leaf(root).unwrap(); + assert!(leaf.tabs().is_empty()); + assert!(leaf.is_persistent()); + assert_eq!(child_nodes(root_split(&tree)), vec![root, other]); + } + + #[test] + fn nested_split_chain_simplifies_when_drained() { + let (mut tree, root) = tree_with(&["a"]); + let (right, _) = tree.split(root, DockEdge::Right, "b").unwrap(); + tree.split(right, DockEdge::Bottom, "c").unwrap(); + + tree.move_tab(tab(&tree, "b"), root, None).unwrap(); + tree.move_tab(tab(&tree, "c"), root, None).unwrap(); + + assert_eq!(tree.root(), root); + assert_eq!(tree.iter_dfs(), vec![(root, 0)]); + assert_eq!(panels(&tree, root), vec!["a", "b", "c"]); + } + + #[test] + fn invalid_ids_return_errors() { + let (mut tree, root) = tree_with(&["a"]); + tree.split(root, DockEdge::Right, "b").unwrap(); + let split = tree.root(); + let missing_node = DockNodeId(9999); + let missing_tab = DockTabId(9999); + let before = tree.clone(); + + assert_eq!( + tree.add_tab(missing_node, "x"), + Err(DockError::NodeNotFound(missing_node)) + ); + assert_eq!(tree.add_tab(split, "x"), Err(DockError::NotALeaf(split))); + assert_eq!( + tree.split(missing_node, DockEdge::Top, "x"), + Err(DockError::NodeNotFound(missing_node)) + ); + assert_eq!( + tree.move_tab(missing_tab, root, None), + Err(DockError::TabNotFound(missing_tab)) + ); + assert_eq!( + tree.move_tab(tab(&tree, "a"), split, None), + Err(DockError::NotALeaf(split)) + ); + assert_eq!( + tree.move_tab_to_edge(tab(&tree, "a"), missing_node, DockEdge::Top), + Err(DockError::NodeNotFound(missing_node)) + ); + assert_eq!( + tree.remove_tab(missing_tab), + Err(DockError::TabNotFound(missing_tab)) + ); + assert_eq!( + tree.set_persistent(missing_node, true), + Err(DockError::NodeNotFound(missing_node)) + ); + assert_eq!(tree, before); + assert_eq!(tree.parent_of(missing_node), None); + assert_eq!(tree.find_leaf_for_tab(missing_tab), None); + } + + #[cfg(feature = "serialize")] + #[test] + fn serde_round_trip() { + let (mut tree, root) = tree_with(&["a"]); + tree.split(root, DockEdge::Right, "b").unwrap(); + tree.set_sizes(tree.root(), &[2.0, 1.0]).unwrap(); + + let text = ron::to_string(&tree).unwrap(); + let mut restored: DockTree = ron::from_str(&text).unwrap(); + assert_eq!(restored, tree); + + let c = restored.add_tab(root, "c").unwrap(); + assert!(tree.tabs().all(|(_, t)| t.id != c)); + } +} diff --git a/crates/bevy_ui_widgets/src/lib.rs b/crates/bevy_ui_widgets/src/lib.rs index ed702d532e516..7698171d25d2d 100644 --- a/crates/bevy_ui_widgets/src/lib.rs +++ b/crates/bevy_ui_widgets/src/lib.rs @@ -47,9 +47,12 @@ //! //! For more guidance on this, see the documentation for [`EntityEvent`]. +extern crate alloc; + mod button; mod checkbox; mod dialog; +mod dock; mod drag_proxy; mod list; mod menu; @@ -70,6 +73,7 @@ use bevy_input_focus::pointer_focus::PointerFocusPlugin; pub use button::*; pub use checkbox::*; pub use dialog::*; +pub use dock::*; pub use drag_proxy::*; pub use list::*; pub use menu::*; diff --git a/crates/bevy_ui_widgets/src/scrollbar.rs b/crates/bevy_ui_widgets/src/scrollbar.rs index e69bf5ff48b0c..8853a23b764cc 100644 --- a/crates/bevy_ui_widgets/src/scrollbar.rs +++ b/crates/bevy_ui_widgets/src/scrollbar.rs @@ -19,6 +19,8 @@ use bevy_picking::events::{ }; use bevy_picking::hover::PointerCaptureMap; use bevy_reflect::{prelude::ReflectDefault, Reflect}; +#[cfg(feature = "serialize")] +use bevy_reflect::{ReflectDeserialize, ReflectSerialize}; use bevy_ui::{ prelude::BorderRect, ui_layout_system, BackgroundColor, BorderColor, BorderRadius, ComputedNode, ComputedUiRenderTargetInfo, ComputedUiTargetCamera, FocusPolicy, ScrollPosition, @@ -29,6 +31,11 @@ use bevy_ui::{ // TODO: Move this to a more central place. #[derive(Debug, Default, Clone, Copy, PartialEq, Reflect)] #[reflect(PartialEq, Clone, Default)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] pub enum ControlOrientation { /// Horizontal orientation (stretching from left to right) Horizontal,