feat(gpui): add dock, effect stack, node graph, and timeline widgets
Implements four workspace widget modules with accompanying learn examples: - dock: dockable panel layout system (tabs, splits, drag-to-dock) with serde-based persistence via PanelRegistry / DockLayoutState - effect_stack: linear effect-stack inspector widget - node_graph: node-graph editor (nodes, ports, wires, pan/zoom canvas) - timeline: video-editing timeline (tracks, clips, ruler, playhead) Timeline snapping prefers the earlier frame when two snap points are equally close, with SnapKind priority breaking same-frame ties.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
//! Floating (undocked) panels.
|
||||
//!
|
||||
//! # Status
|
||||
//!
|
||||
//! Floating panels require spawning one OS window per floated panel, sharing
|
||||
//! entities across windows, and dragging between windows. GPUI's multi-window
|
||||
//! support (multiple `cx.open_window` roots sharing an [`App`](crate::App))
|
||||
//! is sufficient in principle, but drag-and-drop *across* windows and
|
||||
//! focus/activation semantics are unverified. Until that is proven,
|
||||
//! [`DockArea::float_panel`](crate::dock::DockArea::float_panel) always
|
||||
//! returns `false` and this module's view type is never constructed by the
|
||||
//! dock machinery itself; it remains available so a caller can host a panel
|
||||
//! in its own window.
|
||||
//!
|
||||
//! # Intended API
|
||||
//!
|
||||
//! - [`DockArea::float_panel`](crate::dock::DockArea::float_panel) removes a
|
||||
//! panel from the layout tree and opens it in its own borderless-chrome
|
||||
//! window hosting a [`FloatingPanelWindow`].
|
||||
//! - [`FloatingPanelWindow`] renders the panel plus a title bar that acts as
|
||||
//! a drag surface; dropping the window back over a dock area re-docks the
|
||||
//! panel at the hovered [`DropTarget`](crate::dock::DropTarget).
|
||||
//! - Floating windows are recorded in
|
||||
//! [`DockLayoutState::floating`](crate::dock::DockLayoutState::floating) so
|
||||
//! sessions restore them in place.
|
||||
//!
|
||||
//! Everything here is subject to change when the feature is implemented for
|
||||
//! real.
|
||||
|
||||
use crate::dock::PanelHandle;
|
||||
use crate::{
|
||||
div, px, Context, IntoElement, ParentElement, Pixels, Point, Render, Styled, Window,
|
||||
WindowBounds,
|
||||
};
|
||||
use crate::colors::DefaultColors;
|
||||
|
||||
/// A window hosting a single undocked panel.
|
||||
///
|
||||
/// See the [module documentation](crate::dock) — floating support is not yet
|
||||
/// wired into [`DockArea`](crate::dock::DockArea), so this view is only
|
||||
/// constructed by callers that host a panel in their own window.
|
||||
///
|
||||
/// The window renders a minimal title bar (panel title, re-dock drag surface,
|
||||
/// close button) above the panel's view, and reports its bounds back to the
|
||||
/// owning [`DockArea`](crate::dock::DockArea) so
|
||||
/// [`DockLayoutState`](crate::dock::DockLayoutState) can restore the window
|
||||
/// geometry.
|
||||
pub struct FloatingPanelWindow {
|
||||
/// The panel hosted by this window.
|
||||
panel: PanelHandle,
|
||||
/// Last known window position, mirrored into layout snapshots.
|
||||
#[allow(dead_code)] // read once floating-window geometry is persisted
|
||||
origin: Point<Pixels>,
|
||||
}
|
||||
|
||||
impl FloatingPanelWindow {
|
||||
/// Creates the content view for a new floating window hosting `panel`.
|
||||
///
|
||||
/// The caller is responsible for opening the window with
|
||||
/// `cx.open_window` and remembering its handle so it can be closed when
|
||||
/// the panel re-docks. `initial_bounds` comes from the saved layout, or
|
||||
/// from a sensible default near the main window.
|
||||
pub fn new(
|
||||
panel: PanelHandle,
|
||||
initial_bounds: Option<WindowBounds>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let _ = cx;
|
||||
let origin = initial_bounds
|
||||
.map(|bounds| bounds.get_bounds().origin)
|
||||
.unwrap_or_else(|| Point::new(px(0.0), px(0.0)));
|
||||
Self { panel, origin }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for FloatingPanelWindow {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let _ = window;
|
||||
let title = self.panel.title().clone();
|
||||
let view = self.panel.view().clone();
|
||||
div().flex().flex_col().h_full().bg(cx.default_colors().clone().background)
|
||||
.child(
|
||||
div().flex().flex_row().items_center().w_full().px_2().py_1()
|
||||
.child(div().flex_1().text_xs().truncate().child(title)),
|
||||
)
|
||||
.child(div().flex_1().min_h_0().overflow_hidden().child(view))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
//! The dock layout tree, drop targeting, and serializable layout snapshots.
|
||||
//!
|
||||
//! [`DockLayout`] owns the *shape* of a docked workspace as a tree of
|
||||
//! [`DockNode`]s. The tree stores only [`PanelId`]s; the actual panel views
|
||||
//! live in the [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea). All structural edits go
|
||||
//! through the layout operations on `DockLayout` ([`insert_panel`],
|
||||
//! [`remove_panel`], [`move_panel`], [`resize_split`]), which maintain the
|
||||
//! invariants documented on [`DockNode`]; [`cleanup`](DockLayout::cleanup)
|
||||
//! re-normalizes after edits.
|
||||
//!
|
||||
//! Persistence is split in two: [`DockLayoutState`](crate::dock::DockLayoutState) is a serde snapshot of the
|
||||
//! tree with panels referenced by *string keys*, and [`PanelRegistry`] is the
|
||||
//! application-implemented bridge between keys and live panels.
|
||||
|
||||
use crate::Axis;
|
||||
use crate::dock::{PanelHandle, PanelId};
|
||||
use crate::{App, Window};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
/// One node of the dock layout tree.
|
||||
///
|
||||
/// # Invariants
|
||||
///
|
||||
/// These are maintained by every [`DockLayout`] mutator (and re-established
|
||||
/// by [`DockLayout::cleanup`]); code constructing nodes by hand must uphold
|
||||
/// them:
|
||||
///
|
||||
/// - `Split.children` has at least two entries and contains no direct
|
||||
/// `Split` child with the same `direction` (such nests are flattened).
|
||||
/// - `Split.ratio` is finite and clamped to `(0.0, 1.0)` exclusive; see
|
||||
/// [`DockLayout::resize_split`] for clamping against minimum panel sizes.
|
||||
/// - `Tabs.panels` is non-empty and `Tabs.active < panels.len()`.
|
||||
/// - Every [`PanelId`] occurs at most once in the whole tree.
|
||||
///
|
||||
/// A tree consisting of a single `Panel` leaf is valid. An "empty" layout is
|
||||
/// represented by [`DockLayout::root`] being `None`, never by empty nodes.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DockNode {
|
||||
/// A row or column of child nodes sized proportionally.
|
||||
Split {
|
||||
/// The axis along which children are laid out: [`Axis::Horizontal`]
|
||||
/// places children side by side, [`Axis::Vertical`] stacks them.
|
||||
direction: Axis,
|
||||
/// Fraction of the available extent (along `direction`) assigned to
|
||||
/// the first child, relative to the remaining children. For two
|
||||
/// children this is simply the first child's share. Adjusted by
|
||||
/// dragging a split handle; see
|
||||
/// [`DockLayout::resize_split`].
|
||||
ratio: f32,
|
||||
/// The children, in layout order. Never empty, never a single child,
|
||||
/// and never contains a nested `Split` with the same `direction`.
|
||||
children: Vec<DockNode>,
|
||||
},
|
||||
/// A tab group showing one of several panels at a time.
|
||||
Tabs {
|
||||
/// Panels in tab order. Non-empty.
|
||||
panels: Vec<PanelId>,
|
||||
/// Index into `panels` of the visible tab. Always `< panels.len()`.
|
||||
active: usize,
|
||||
},
|
||||
/// A leaf holding exactly one panel.
|
||||
Panel(PanelId),
|
||||
}
|
||||
|
||||
/// Where, relative to a drop target, a dragged panel should be docked.
|
||||
///
|
||||
/// Every tab group / leaf panel offers all five zones while a drag is in
|
||||
/// flight; the root additionally offers its four outer edges (see
|
||||
/// [`DropTarget`]). Hit-testing from a cursor position is done by
|
||||
/// [`DockArea::drop_zone_at`](crate::dock::DockArea::drop_zone_at).
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum DropZone {
|
||||
/// Dock to the left of the target: split the target's node horizontally,
|
||||
/// inserting the dragged panel as the new left child.
|
||||
Left,
|
||||
/// Dock to the right of the target (horizontal split, new right child).
|
||||
Right,
|
||||
/// Dock above the target (vertical split, new top child).
|
||||
Top,
|
||||
/// Dock below the target (vertical split, new bottom child).
|
||||
Bottom,
|
||||
/// Merge the dragged panel into the target as a new tab. The dragged
|
||||
/// panel becomes the active tab of the resulting group.
|
||||
Center,
|
||||
}
|
||||
|
||||
impl DropZone {
|
||||
/// All five zones, in declaration order. Useful for painting affordances.
|
||||
pub const ALL: [DropZone; 5] = [
|
||||
DropZone::Left,
|
||||
DropZone::Right,
|
||||
DropZone::Top,
|
||||
DropZone::Bottom,
|
||||
DropZone::Center,
|
||||
];
|
||||
|
||||
/// Returns `true` if this zone splits the target rather than merging into
|
||||
/// it as a tab — i.e. anything except [`DropZone::Center`].
|
||||
pub const fn is_split(self) -> bool {
|
||||
!matches!(self, DropZone::Center)
|
||||
}
|
||||
|
||||
/// Returns `true` if this zone merges the dragged panel into the target
|
||||
/// as a tab ([`DropZone::Center`]).
|
||||
pub const fn is_merge(self) -> bool {
|
||||
matches!(self, DropZone::Center)
|
||||
}
|
||||
|
||||
/// Returns the split axis this zone implies, or `None` for
|
||||
/// [`DropZone::Center`]. `Left`/`Right` split along
|
||||
/// [`Axis::Horizontal`], `Top`/`Bottom` along [`Axis::Vertical`].
|
||||
pub const fn split_axis(self) -> Option<Axis> {
|
||||
match self {
|
||||
DropZone::Left | DropZone::Right => Some(Axis::Horizontal),
|
||||
DropZone::Top | DropZone::Bottom => Some(Axis::Vertical),
|
||||
DropZone::Center => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A concrete place a dragged panel can be dropped.
|
||||
///
|
||||
/// Combines the panel (tab group) being hovered with the zone within it.
|
||||
/// When `panel` is `None`, the target is an outer edge of the whole layout
|
||||
/// root — this is how a drop on the window's very edge splits the entire
|
||||
/// tree. `zone` must be an edge zone (never [`DropZone::Center`]) when
|
||||
/// `panel` is `None`.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DropTarget {
|
||||
/// The panel whose tab group / leaf is targeted, or `None` to target an
|
||||
/// outer edge of the root.
|
||||
pub panel: Option<PanelId>,
|
||||
/// The zone within the target.
|
||||
pub zone: DropZone,
|
||||
}
|
||||
|
||||
/// Path addressing a node within a [`DockLayout`]: child indices from the root.
|
||||
///
|
||||
/// `NodePath(vec![])` addresses the root; each successive index descends into
|
||||
/// that node's `children` (for splits) or is invalid (for `Tabs`/`Panel`,
|
||||
/// which have no node children). Paths are invalidated by any structural edit
|
||||
/// and must be re-derived with [`DockLayout::find_panel`] afterwards.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct NodePath(pub Vec<usize>);
|
||||
|
||||
/// Returns a stable `usize` key for `path`, used to derive element ids for
|
||||
/// per-node chrome (split containers, split handles).
|
||||
///
|
||||
/// Two different paths always produce different keys; equal paths produce
|
||||
/// equal keys.
|
||||
pub(crate) fn path_key(path: &NodePath) -> usize {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
path.0.hash(&mut hasher);
|
||||
hasher.finish() as usize
|
||||
}
|
||||
|
||||
/// The dock layout tree: the shape of one [`DockArea`](crate::dock::DockArea)'s workspace.
|
||||
///
|
||||
/// The tree is pure data (no views, no GPUI handles) and is cheap to clone.
|
||||
/// All edits go through the methods below so the [`DockNode`] invariants are
|
||||
/// preserved; after any sequence of edits the mutators run
|
||||
/// [`cleanup`](DockLayout::cleanup) themselves.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DockLayout {
|
||||
root: Option<DockNode>,
|
||||
}
|
||||
|
||||
impl DockLayout {
|
||||
/// Creates an empty layout (no root node).
|
||||
pub fn new() -> Self {
|
||||
Self { root: None }
|
||||
}
|
||||
|
||||
/// Returns the root node, or `None` if the layout is empty.
|
||||
pub fn root(&self) -> Option<&DockNode> {
|
||||
self.root.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the root node mutably, or `None` if the layout is empty.
|
||||
pub(crate) fn root_mut(&mut self) -> Option<&mut DockNode> {
|
||||
self.root.as_mut()
|
||||
}
|
||||
|
||||
/// Returns `true` if `panel` occurs anywhere in the tree.
|
||||
pub fn contains(&self, panel: PanelId) -> bool {
|
||||
let Some(root) = self.root.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let mut stack = vec![root];
|
||||
while let Some(node) = stack.pop() {
|
||||
match node {
|
||||
DockNode::Panel(id) => {
|
||||
if *id == panel {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
DockNode::Tabs { panels, .. } => {
|
||||
if panels.contains(&panel) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
DockNode::Split { children, .. } => stack.extend(children),
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns the path to the node containing `panel`, or `None` if the
|
||||
/// panel is not in the tree.
|
||||
///
|
||||
/// For a `Panel` leaf the path addresses the leaf itself; for a tab in a
|
||||
/// `Tabs` group it addresses the `Tabs` node (inspect the node to get the
|
||||
/// tab index).
|
||||
pub fn find_panel(&self, panel: PanelId) -> Option<NodePath> {
|
||||
let root = self.root.as_ref()?;
|
||||
Self::find_panel_in(root, panel, &mut Vec::new())
|
||||
}
|
||||
|
||||
/// Depth-first search recording the path taken; the path of the node
|
||||
/// containing `panel` when found.
|
||||
fn find_panel_in(node: &DockNode, panel: PanelId, path: &mut Vec<usize>) -> Option<NodePath> {
|
||||
match node {
|
||||
DockNode::Panel(id) if *id == panel => Some(NodePath(path.clone())),
|
||||
DockNode::Panel(_) => None,
|
||||
DockNode::Tabs { panels, .. } => {
|
||||
if panels.contains(&panel) {
|
||||
Some(NodePath(path.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
DockNode::Split { children, .. } => {
|
||||
for (index, child) in children.iter().enumerate() {
|
||||
path.push(index);
|
||||
if let Some(found) = Self::find_panel_in(child, panel, path) {
|
||||
return Some(found);
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts `panel` at `target`.
|
||||
///
|
||||
/// - `target: None` — the layout must be empty; `panel` becomes the root.
|
||||
/// If the layout is *not* empty this is a no-op returning `false`
|
||||
/// (choose a concrete [`DropTarget`] instead).
|
||||
/// - Edge zones — wraps/splits the target node along
|
||||
/// [`DropZone::split_axis`]. If the target already sits inside a split
|
||||
/// with the same axis, the panel is inserted as a sibling instead of
|
||||
/// nesting.
|
||||
/// - [`DropZone::Center`] — appends `panel` to the target's tab group
|
||||
/// (converting a `Panel` leaf into `Tabs`) and makes it active.
|
||||
///
|
||||
/// Returns `true` if the tree changed. Fails (returns `false`) if
|
||||
/// `panel` is already present — use [`move_panel`](DockLayout::move_panel)
|
||||
/// to relocate — or if the target panel no longer exists.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics in debug builds if `target.panel` is `None` and
|
||||
/// `target.zone` is [`DropZone::Center`].
|
||||
pub fn insert_panel(&mut self, panel: PanelId, target: Option<DropTarget>) -> bool {
|
||||
if self.contains(panel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(target) = target else {
|
||||
if self.root.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.root = Some(DockNode::Panel(panel));
|
||||
return true;
|
||||
};
|
||||
|
||||
debug_assert!(
|
||||
target.panel.is_some() || target.zone != DropZone::Center,
|
||||
"a root-edge target must use an edge zone, never Center"
|
||||
);
|
||||
|
||||
let Some(target_panel) = target.panel else {
|
||||
// An outer edge of the whole tree: split the root itself.
|
||||
let changed = self.insert_at_root_edge(panel, target.zone);
|
||||
if changed {
|
||||
self.cleanup();
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
|
||||
if !self.contains(target_panel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if target.zone.is_merge() {
|
||||
self.insert_into_tabs(panel, target_panel);
|
||||
} else {
|
||||
self.insert_at_edge(panel, target_panel, target.zone);
|
||||
}
|
||||
self.cleanup();
|
||||
true
|
||||
}
|
||||
|
||||
/// Inserts `panel` at an outer edge of the root, splitting the whole tree
|
||||
/// unless the root is already a split along the same axis (in which case
|
||||
/// the panel becomes a sibling).
|
||||
fn insert_at_root_edge(&mut self, panel: PanelId, zone: DropZone) -> bool {
|
||||
let Some(mut root) = self.root.take() else {
|
||||
return false;
|
||||
};
|
||||
let axis = zone
|
||||
.split_axis()
|
||||
.expect("root-edge zones always imply a split axis");
|
||||
let before = matches!(zone, DropZone::Left | DropZone::Top);
|
||||
|
||||
if let DockNode::Split {
|
||||
direction,
|
||||
children,
|
||||
..
|
||||
} = &mut root
|
||||
{
|
||||
if *direction == axis {
|
||||
if before {
|
||||
children.insert(0, DockNode::Panel(panel));
|
||||
} else {
|
||||
children.push(DockNode::Panel(panel));
|
||||
}
|
||||
self.root = Some(root);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
self.root = Some(DockNode::Split {
|
||||
direction: axis,
|
||||
ratio: 0.5,
|
||||
children: if before {
|
||||
vec![DockNode::Panel(panel), root]
|
||||
} else {
|
||||
vec![root, DockNode::Panel(panel)]
|
||||
},
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Inserts `panel` relative to the node containing `target_panel`,
|
||||
/// splitting it unless it already sits in a same-axis split (then the
|
||||
/// panel is inserted as a sibling instead).
|
||||
fn insert_at_edge(&mut self, panel: PanelId, target_panel: PanelId, zone: DropZone) {
|
||||
let axis = zone.split_axis().expect("edge zones imply a split axis");
|
||||
let path = self
|
||||
.find_panel(target_panel)
|
||||
.expect("caller checked the target exists");
|
||||
let before = matches!(zone, DropZone::Left | DropZone::Top);
|
||||
|
||||
// If the target sits inside a split along the same axis, insert the
|
||||
// panel as a sibling rather than nesting a split inside a split.
|
||||
let mut parent = path.0.clone();
|
||||
if parent.pop().is_some() {
|
||||
let parent_path = NodePath(parent);
|
||||
if let Some(DockNode::Split {
|
||||
direction,
|
||||
children,
|
||||
..
|
||||
}) = self.node_at_mut(&parent_path)
|
||||
{
|
||||
if *direction == axis {
|
||||
let index = *path.0.last().expect("non-root path has a last index");
|
||||
children.insert(
|
||||
if before { index } else { index + 1 },
|
||||
DockNode::Panel(panel),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise wrap the target node in a fresh split.
|
||||
let old_node = self
|
||||
.node_at(&path)
|
||||
.expect("path was just derived")
|
||||
.clone();
|
||||
let new_node = DockNode::Panel(panel);
|
||||
let replacement = DockNode::Split {
|
||||
direction: axis,
|
||||
ratio: 0.5,
|
||||
children: if before {
|
||||
vec![new_node, old_node]
|
||||
} else {
|
||||
vec![old_node, new_node]
|
||||
},
|
||||
};
|
||||
*self
|
||||
.node_at_mut(&path)
|
||||
.expect("path was just derived") = replacement;
|
||||
}
|
||||
|
||||
/// Appends `panel` to the tab group containing `target_panel` (converting
|
||||
/// a `Panel` leaf into a `Tabs` node) and makes it active.
|
||||
fn insert_into_tabs(&mut self, panel: PanelId, target_panel: PanelId) {
|
||||
let path = self
|
||||
.find_panel(target_panel)
|
||||
.expect("caller checked the target exists");
|
||||
let node = self.node_at_mut(&path).expect("path was just derived");
|
||||
match node {
|
||||
DockNode::Panel(_) => {
|
||||
*node = DockNode::Tabs {
|
||||
panels: vec![target_panel, panel],
|
||||
active: 1,
|
||||
};
|
||||
}
|
||||
DockNode::Tabs { panels, active } => {
|
||||
panels.push(panel);
|
||||
*active = panels.len() - 1;
|
||||
}
|
||||
DockNode::Split { .. } => unreachable!("find_panel never addresses a Split"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes `panel` from the tree, running [`cleanup`](DockLayout::cleanup)
|
||||
/// to collapse nodes left empty or single-childed.
|
||||
///
|
||||
/// Returns `true` if the panel was present. Removing the last panel sets
|
||||
/// the root to `None`.
|
||||
pub fn remove_panel(&mut self, panel: PanelId) -> bool {
|
||||
let Some(root) = self.root.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if !Self::remove_from_node(root, panel) {
|
||||
return false;
|
||||
}
|
||||
self.cleanup();
|
||||
true
|
||||
}
|
||||
|
||||
/// Recursively removes `panel` from `node`, returning whether it was found.
|
||||
fn remove_from_node(node: &mut DockNode, panel: PanelId) -> bool {
|
||||
match node {
|
||||
DockNode::Panel(id) => *id == panel,
|
||||
DockNode::Tabs { panels, active } => {
|
||||
let Some(index) = panels.iter().position(|&other| other == panel) else {
|
||||
return false;
|
||||
};
|
||||
panels.remove(index);
|
||||
if !panels.is_empty() {
|
||||
*active = (*active).min(panels.len() - 1);
|
||||
}
|
||||
true
|
||||
}
|
||||
DockNode::Split { children, .. } => children
|
||||
.iter_mut()
|
||||
.any(|child| Self::remove_from_node(child, panel)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically moves `panel` to `target`.
|
||||
///
|
||||
/// Equivalent to [`remove_panel`](DockLayout::remove_panel) followed by
|
||||
/// [`insert_panel`](DockLayout::insert_panel), but a no-op (returning
|
||||
/// `false`) if `panel` is not present or the drop would land the panel
|
||||
/// back in its own position (e.g. `Center` onto its own group). Moving
|
||||
/// the only panel of a group away collapses the group.
|
||||
pub fn move_panel(&mut self, panel: PanelId, target: DropTarget) -> bool {
|
||||
if !self.contains(panel) {
|
||||
return false;
|
||||
}
|
||||
// Dropping onto the panel's own node — its group for `Center`, its own
|
||||
// node for edge zones — would target a node that no longer exists
|
||||
// after the removal below, so treat every self-drop as a no-op.
|
||||
if target.panel == Some(panel) {
|
||||
return false;
|
||||
}
|
||||
let removed = self.remove_panel(panel);
|
||||
debug_assert!(removed, "panel presence was checked above");
|
||||
let inserted = self.insert_panel(panel, Some(target));
|
||||
debug_assert!(inserted, "drop target must remain valid after the removal");
|
||||
inserted
|
||||
}
|
||||
|
||||
/// Sets the `ratio` of the `Split` node at `path`.
|
||||
///
|
||||
/// `ratio` is clamped so that no child shrinks below its minimum extent
|
||||
/// (see the min-size constants in
|
||||
/// the `split_handle` module); out-of-range values are
|
||||
/// clamped rather than rejected.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `path` does not address a [`DockNode::Split`].
|
||||
pub fn resize_split(&mut self, path: &NodePath, ratio: f32) {
|
||||
let Some(node) = self.node_at_mut(path) else {
|
||||
panic!("resize_split: path {path:?} does not address a node");
|
||||
};
|
||||
let DockNode::Split { ratio: current, .. } = node else {
|
||||
panic!("resize_split: path {path:?} does not address a Split node");
|
||||
};
|
||||
// Pixel-level minimum extents (see split_handle::MIN_CHILD_EXTENT)
|
||||
// depend on the rendered size and cannot be enforced on a ratio;
|
||||
// clamp to a conservative fraction so both children keep a share.
|
||||
*current = ratio.clamp(0.05, 0.95);
|
||||
}
|
||||
|
||||
/// Re-establishes the [`DockNode`] invariants after structural edits.
|
||||
///
|
||||
/// Concretely: removes empty `Tabs` nodes, replaces single-child `Split`s
|
||||
/// with their child, flattens same-direction `Split` nests, and clamps
|
||||
/// `active` tab indices into range. All public mutators call this
|
||||
/// internally; call it manually only after mutating nodes obtained via
|
||||
/// interior references (which the API avoids exposing for this reason).
|
||||
pub fn cleanup(&mut self) {
|
||||
self.root = self.root.take().and_then(Self::cleanup_node);
|
||||
}
|
||||
|
||||
/// Normalizes a single node, returning `None` when it collapses away.
|
||||
fn cleanup_node(node: DockNode) -> Option<DockNode> {
|
||||
match node {
|
||||
DockNode::Panel(_) => Some(node),
|
||||
DockNode::Tabs {
|
||||
mut panels,
|
||||
mut active,
|
||||
} => {
|
||||
if panels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
active = active.min(panels.len() - 1);
|
||||
Some(DockNode::Tabs { panels, active })
|
||||
}
|
||||
DockNode::Split {
|
||||
direction,
|
||||
ratio,
|
||||
children,
|
||||
} => {
|
||||
let children: Vec<DockNode> = children
|
||||
.into_iter()
|
||||
.filter_map(Self::cleanup_node)
|
||||
.collect();
|
||||
// Flatten direct same-direction split nests.
|
||||
let mut flat = Vec::with_capacity(children.len());
|
||||
for child in children {
|
||||
match child {
|
||||
DockNode::Split {
|
||||
direction: nested_direction,
|
||||
children: nested_children,
|
||||
..
|
||||
} if nested_direction == direction => flat.extend(nested_children),
|
||||
other => flat.push(other),
|
||||
}
|
||||
}
|
||||
let ratio = if ratio.is_finite() {
|
||||
ratio.clamp(0.05, 0.95)
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
match flat.len() {
|
||||
0 => None,
|
||||
1 => Some(flat.pop().expect("len == 1")),
|
||||
_ => Some(DockNode::Split {
|
||||
direction,
|
||||
ratio,
|
||||
children: flat,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visits every [`PanelId`] in the tree, in depth-first order.
|
||||
pub fn panels(&self) -> Vec<PanelId> {
|
||||
let mut panels = Vec::new();
|
||||
if let Some(root) = &self.root {
|
||||
Self::collect_panels(root, &mut panels);
|
||||
}
|
||||
panels
|
||||
}
|
||||
|
||||
fn collect_panels(node: &DockNode, out: &mut Vec<PanelId>) {
|
||||
match node {
|
||||
DockNode::Panel(id) => out.push(*id),
|
||||
DockNode::Tabs { panels, .. } => out.extend_from_slice(panels),
|
||||
DockNode::Split { children, .. } => {
|
||||
for child in children {
|
||||
Self::collect_panels(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `ratio` of the `Split` node at `path`, or `None` if `path`
|
||||
/// does not address a split.
|
||||
pub fn split_ratio(&self, path: &NodePath) -> Option<f32> {
|
||||
match self.node_at(path) {
|
||||
Some(DockNode::Split { ratio, .. }) => Some(*ratio),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes `panel` the active tab of the `Tabs` node at `path`, if present.
|
||||
pub fn set_tabs_active(&mut self, path: &NodePath, panel: PanelId) -> bool {
|
||||
let Some(node) = self.node_at_mut(path) else {
|
||||
return false;
|
||||
};
|
||||
let DockNode::Tabs { panels, active } = node else {
|
||||
return false;
|
||||
};
|
||||
match panels.iter().position(|&other| other == panel) {
|
||||
Some(index) => {
|
||||
*active = index;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the node at `path`, or `None` if the path is invalid.
|
||||
fn node_at(&self, path: &NodePath) -> Option<&DockNode> {
|
||||
let mut node = self.root.as_ref()?;
|
||||
for &index in &path.0 {
|
||||
let DockNode::Split { children, .. } = node else {
|
||||
return None;
|
||||
};
|
||||
node = children.get(index)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the node at `path`, or `None`.
|
||||
pub(crate) fn node_at_mut(&mut self, path: &NodePath) -> Option<&mut DockNode> {
|
||||
let mut node = self.root.as_mut()?;
|
||||
for &index in &path.0 {
|
||||
let DockNode::Split { children, .. } = node else {
|
||||
return None;
|
||||
};
|
||||
node = children.get_mut(index)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
}
|
||||
|
||||
/// Application-provided bridge between panel string keys and live panels.
|
||||
///
|
||||
/// Panels are live views and cannot be serialized, so persistence stores only
|
||||
/// a stable string key per panel. On save, [`panel_key`](PanelRegistry::panel_key)
|
||||
/// maps each [`PanelId`] to its key; on restore,
|
||||
/// [`build_panel`](PanelRegistry::build_panel) reconstructs a fresh view from
|
||||
/// a key. The application owns the mapping — e.g. `"media-bin"`,
|
||||
/// `"program-monitor"`, or per-project keys like `"inspector:clip-42"`.
|
||||
///
|
||||
/// Keys must round-trip: a key produced by `panel_key` must be accepted by
|
||||
/// `build_panel`. Keys that fail to rebuild are dropped from the restored
|
||||
/// layout (with their positions collapsed by [`DockLayout::cleanup`]), so a
|
||||
/// panel type removed in a newer app version degrades gracefully instead of
|
||||
/// failing the whole restore.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// struct OakPanelRegistry;
|
||||
///
|
||||
/// impl PanelRegistry for OakPanelRegistry {
|
||||
/// fn panel_key(&self, id: PanelId) -> Option<String> {
|
||||
/// match id.raw() {
|
||||
/// 1 => Some("media-bin".into()),
|
||||
/// 2 => Some("program-monitor".into()),
|
||||
/// _ => None,
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App)
|
||||
/// -> Option<PanelHandle>
|
||||
/// {
|
||||
/// match key {
|
||||
/// "media-bin" => Some(PanelHandle::new(cx.new(|_| MediaBin::new()), cx)),
|
||||
/// "program-monitor" => Some(PanelHandle::new(cx.new(|_| Monitor::new()), cx)),
|
||||
/// _ => None,
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait PanelRegistry: 'static {
|
||||
/// Returns the stable string key for a live panel, or `None` if the panel
|
||||
/// is transient and should be omitted from saved layouts.
|
||||
fn panel_key(&self, id: PanelId) -> Option<String>;
|
||||
|
||||
/// Rebuilds the panel identified by `key`, or returns `None` if the key
|
||||
/// is unknown (the panel is then skipped during restore).
|
||||
///
|
||||
/// Called on the main thread during
|
||||
/// [`DockArea::restore_state`](crate::dock::DockArea::restore_state)(crate::dock::DockArea::restore_state); the registry may create entities with
|
||||
/// `cx.new` and perform per-panel setup, but should not open windows or
|
||||
/// otherwise mutate the dock area.
|
||||
fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App) -> Option<PanelHandle>;
|
||||
}
|
||||
|
||||
/// A serde-serializable snapshot of a [`DockLayout`], with panels referenced
|
||||
/// by registry string keys.
|
||||
///
|
||||
/// This is the type to persist (via `serde_json`, a settings file, ...).
|
||||
/// Capture it from a live layout with [`DockLayoutState::capture`] and turn it
|
||||
/// back into a tree with [`DockLayoutState::to_layout`]; then hand it to
|
||||
/// [`DockArea::restore_state`](crate::dock::DockArea::restore_state)(crate::dock::DockArea::restore_state), which rebuilds the views through the
|
||||
/// [`PanelRegistry`].
|
||||
///
|
||||
/// The serialized form is versioned via the `version` field; the current
|
||||
/// version is [`DockLayoutState::VERSION`]. Unknown/newer versions should be
|
||||
/// rejected by the caller before restoring.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DockLayoutState {
|
||||
/// Snapshot format version; written by [`capture`](DockLayoutState::capture),
|
||||
/// checked by the caller on load.
|
||||
pub version: u32,
|
||||
root: Option<SerializedNode>,
|
||||
/// Registry keys for panels that existed when the snapshot was taken but
|
||||
/// whose `panel_key` returned `Some` while they were not reachable in the
|
||||
/// tree (reserved for floating panels; see
|
||||
/// [`FloatingPanelWindow`](crate::dock::FloatingPanelWindow)). Empty until floating support
|
||||
/// lands.
|
||||
#[serde(default)]
|
||||
pub floating: BTreeMap<String, SerializedFloating>,
|
||||
}
|
||||
|
||||
/// Reserved per-floating-panel data inside [`DockLayoutState`](crate::dock::DockLayoutState).
|
||||
///
|
||||
/// Placeholder for the deferred floating-window feature (see
|
||||
/// [`FloatingPanelWindow`](crate::dock::FloatingPanelWindow)): remembers that a panel was undocked
|
||||
/// and where its window was. Not yet produced by
|
||||
/// [`DockLayoutState::capture`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SerializedFloating {
|
||||
/// Logical x position of the floating window, in pixels.
|
||||
pub x: f32,
|
||||
/// Logical y position of the floating window, in pixels.
|
||||
pub y: f32,
|
||||
/// Width of the floating window, in pixels.
|
||||
pub width: f32,
|
||||
/// Height of the floating window, in pixels.
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// A serialized [`DockNode`] with panel keys instead of ids.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
enum SerializedNode {
|
||||
/// Serialized [`DockNode::Split`].
|
||||
Split {
|
||||
/// See [`DockNode::Split::direction`].
|
||||
direction: Axis,
|
||||
/// See [`DockNode::Split::ratio`].
|
||||
ratio: f32,
|
||||
/// See [`DockNode::Split::children`].
|
||||
children: Vec<SerializedNode>,
|
||||
},
|
||||
/// Serialized [`DockNode::Tabs`]; `active` is an index into `panels`.
|
||||
Tabs {
|
||||
/// Registry keys of the tabbed panels, in tab order.
|
||||
panels: Vec<String>,
|
||||
/// Active tab index.
|
||||
active: usize,
|
||||
},
|
||||
/// Serialized [`DockNode::Panel`], holding the panel's registry key.
|
||||
Panel(String),
|
||||
}
|
||||
|
||||
impl DockLayoutState {
|
||||
/// The snapshot format version written by
|
||||
/// [`capture`](DockLayoutState::capture).
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
/// Snapshots `layout`, translating panel ids to string keys via
|
||||
/// `registry`.
|
||||
///
|
||||
/// Panels whose [`PanelRegistry::panel_key`] returns `None` are omitted
|
||||
/// from the snapshot; their nodes are collapsed as if removed.
|
||||
pub fn capture(layout: &DockLayout, registry: &dyn PanelRegistry) -> Self {
|
||||
let root = layout
|
||||
.root
|
||||
.as_ref()
|
||||
.and_then(|node| Self::serialize_node(node, registry));
|
||||
Self {
|
||||
version: Self::VERSION,
|
||||
root,
|
||||
floating: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes `node`, dropping panels without a key; `None` when the node
|
||||
/// collapses away entirely.
|
||||
fn serialize_node(node: &DockNode, registry: &dyn PanelRegistry) -> Option<SerializedNode> {
|
||||
match node {
|
||||
DockNode::Panel(id) => registry.panel_key(*id).map(SerializedNode::Panel),
|
||||
DockNode::Tabs { panels, active } => {
|
||||
let active_panel = panels.get(*active).copied();
|
||||
let mut serialized = Vec::new();
|
||||
let mut serialized_active = 0;
|
||||
for id in panels.iter() {
|
||||
if let Some(key) = registry.panel_key(*id) {
|
||||
if Some(*id) == active_panel {
|
||||
serialized_active = serialized.len();
|
||||
}
|
||||
serialized.push(key);
|
||||
}
|
||||
}
|
||||
if serialized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serialized_active = serialized_active.min(serialized.len() - 1);
|
||||
Some(SerializedNode::Tabs {
|
||||
panels: serialized,
|
||||
active: serialized_active,
|
||||
})
|
||||
}
|
||||
DockNode::Split {
|
||||
direction,
|
||||
ratio,
|
||||
children,
|
||||
} => {
|
||||
let children: Vec<SerializedNode> = children
|
||||
.iter()
|
||||
.filter_map(|child| Self::serialize_node(child, registry))
|
||||
.collect();
|
||||
match children.len() {
|
||||
0 => None,
|
||||
1 => children.into_iter().next(),
|
||||
_ => Some(SerializedNode::Split {
|
||||
direction: *direction,
|
||||
ratio: *ratio,
|
||||
children,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the pure tree shape, leaving view reconstruction to the
|
||||
/// caller (see [`DockArea::restore_state`](crate::dock::DockArea::restore_state)(crate::dock::DockArea::restore_state), which resolves keys through
|
||||
/// the registry).
|
||||
///
|
||||
/// The returned layout is normalized ([`DockLayout::cleanup`] has run).
|
||||
pub fn to_layout(&self) -> DockLayout {
|
||||
let root = self.root.as_ref().and_then(Self::deserialize_node);
|
||||
let mut layout = DockLayout { root };
|
||||
layout.cleanup();
|
||||
layout
|
||||
}
|
||||
|
||||
/// Deserializes a single node, addressing panels by deterministic interim
|
||||
/// ids (see [`interim_id`]); `None` when the node collapses away.
|
||||
fn deserialize_node(node: &SerializedNode) -> Option<DockNode> {
|
||||
match node {
|
||||
SerializedNode::Panel(key) => Some(DockNode::Panel(interim_id(key))),
|
||||
SerializedNode::Tabs { panels, active } => {
|
||||
if panels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let panels: Vec<PanelId> = panels.iter().map(|key| interim_id(key)).collect();
|
||||
Some(DockNode::Tabs {
|
||||
active: (*active).min(panels.len() - 1),
|
||||
panels,
|
||||
})
|
||||
}
|
||||
SerializedNode::Split {
|
||||
direction,
|
||||
ratio,
|
||||
children,
|
||||
} => {
|
||||
let children: Vec<DockNode> = children
|
||||
.iter()
|
||||
.filter_map(Self::deserialize_node)
|
||||
.collect();
|
||||
match children.len() {
|
||||
0 => None,
|
||||
1 => children.into_iter().next(),
|
||||
_ => Some(DockNode::Split {
|
||||
direction: *direction,
|
||||
ratio: *ratio,
|
||||
children,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the registry keys referenced by this snapshot, depth-first.
|
||||
///
|
||||
/// Used by [`DockArea::restore_state`](crate::dock::DockArea::restore_state)(crate::dock::DockArea::restore_state) to rebuild panels
|
||||
/// (and learn their real ids) before re-keying the tree.
|
||||
pub(crate) fn keys(&self) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
if let Some(root) = &self.root {
|
||||
Self::collect_keys(root, &mut keys);
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn collect_keys(node: &SerializedNode, out: &mut Vec<String>) {
|
||||
match node {
|
||||
SerializedNode::Panel(key) => out.push(key.clone()),
|
||||
SerializedNode::Tabs { panels, .. } => out.extend(panels.iter().cloned()),
|
||||
SerializedNode::Split { children, .. } => {
|
||||
for child in children {
|
||||
Self::collect_keys(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a registry key to a deterministic interim [`PanelId`].
|
||||
///
|
||||
/// [`DockLayoutState::to_layout`] cannot know the real ids of rebuilt panels,
|
||||
/// so it addresses nodes with ids derived from the key. `restore_state` later
|
||||
/// rebuilds the real panels and re-maps the tree using the same hash, so the
|
||||
/// two passes agree.
|
||||
pub(crate) fn interim_id(key: &str) -> PanelId {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
key.hash(&mut hasher);
|
||||
PanelId::new(hasher.finish())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Dockable panel layout system: tabs, splits, and drag-to-dock.
|
||||
//!
|
||||
//! This module provides the building blocks for IDE/NLE-style user interfaces in
|
||||
//! which the user can rearrange the workspace by dragging panels between
|
||||
//! tab groups and split containers, and where the resulting layout can be
|
||||
//! persisted and restored across sessions.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The layout of a [`DockArea`](crate::dock::DockArea) is an immutable-by-convention tree of
|
||||
//! [`DockNode`](crate::dock::DockNode)s:
|
||||
//!
|
||||
//! - `Split { direction, ratio, children }` — a row or column of child nodes,
|
||||
//! sized proportionally. `ratio` is the fraction of the cross axis given to
|
||||
//! the first child; with more than two children it is the fraction given to
|
||||
//! the first child relative to the rest. See [`DockLayout::resize_split`](crate::dock::DockLayout::resize_split).
|
||||
//! - `Tabs { panels, active }` — a tab group showing one panel at a time,
|
||||
//! with a tab strip (the internal `tab_bar` component) for switching,
|
||||
//! closing, and reordering.
|
||||
//! - `Panel(panel_id)` — a leaf holding exactly one panel.
|
||||
//!
|
||||
//! Panels themselves are ordinary GPUI views that implement [`DockPanel`](crate::dock::DockPanel)
|
||||
//! (on top of [`Render`](crate::Render)). They are held by the [`DockArea`](crate::dock::DockArea)
|
||||
//! as type-erased [`PanelHandle`](crate::dock::PanelHandle)s keyed by [`PanelId`](crate::dock::PanelId); the tree stores only
|
||||
//! ids, never views.
|
||||
//!
|
||||
//! # Drag to dock
|
||||
//!
|
||||
//! Dragging a panel by its tab (or a dedicated drag surface) starts a dock
|
||||
//! drag. While dragging, every potential target offers five [`DropZone`](crate::dock::DropZone)s —
|
||||
//! `Left`, `Right`, `Top`, `Bottom`, and `Center` — computed by
|
||||
//! [`DockArea::drop_zone_at`](crate::dock::DockArea::drop_zone_at). Dropping on an edge zone splits the target
|
||||
//! node in that direction; dropping on `Center` merges the dragged panel into
|
||||
//! the target as a new tab. In addition, the outer edges of the root offer
|
||||
//! drop zones that split the entire layout. A translucent drop indicator is
|
||||
//! rendered above the content using [`deferred`](crate::deferred) so
|
||||
//! it is not clipped by panel bounds.
|
||||
//!
|
||||
//! # Persistence
|
||||
//!
|
||||
//! Because panels are live views, only the *shape* of the tree plus stable
|
||||
//! string keys for panels can be serialized. [`DockLayoutState`](crate::dock::DockLayoutState) is a
|
||||
//! serde-serializable snapshot; the application supplies a [`PanelRegistry`](crate::dock::PanelRegistry)
|
||||
//! that maps [`PanelId`](crate::dock::PanelId)s to string keys on save and rebuilds views from
|
||||
//! those keys on restore. See [`DockLayout`](crate::dock::DockLayout) for details.
|
||||
//!
|
||||
//! # Wiring into your app
|
||||
//!
|
||||
//! The intended consumer is the Oak video editor: a media/project bin, source
|
||||
//! and program monitors, an inspector, and a timeline, all dockable. The
|
||||
//! typical setup is:
|
||||
//!
|
||||
//! 1. Implement [`DockPanel`](crate::dock::DockPanel) for each panel view (media bin, viewer,
|
||||
//! inspector, timeline, ...).
|
||||
//! 2. Implement [`PanelRegistry`](crate::dock::PanelRegistry) for an application type that knows how to
|
||||
//! construct each panel from its string key.
|
||||
//! 3. Create a [`DockArea`](crate::dock::DockArea), register the registry with
|
||||
//! [`DockArea::with_registry`](crate::dock::DockArea::with_registry), and seed panels with [`DockArea::add_panel`](crate::dock::DockArea::add_panel).
|
||||
//! 4. On startup, call [`DockArea::restore_state`](crate::dock::DockArea::restore_state) with the previously saved
|
||||
//! [`DockLayoutState`](crate::dock::DockLayoutState) (e.g. from `serde_json`); on quit, persist
|
||||
//! [`DockArea::save_state`](crate::dock::DockArea::save_state).
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let dock = cx.new(|cx| {
|
||||
//! DockArea::new(cx)
|
||||
//! .with_registry(Arc::new(MyPanelRegistry))
|
||||
//! });
|
||||
//! dock.update(cx, |dock, cx| {
|
||||
//! dock.add_panel(PanelHandle::new(media_bin, cx), None, cx);
|
||||
//! dock.add_panel(PanelHandle::new(viewer, cx), None, cx);
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! See `examples/learn/dock_layout.rs` for a fuller sketch.
|
||||
|
||||
mod dock_area;
|
||||
mod floating;
|
||||
mod layout;
|
||||
mod panel;
|
||||
mod split_handle;
|
||||
mod tab_bar;
|
||||
|
||||
pub use dock_area::{DockArea, DockEvent};
|
||||
pub use floating::FloatingPanelWindow;
|
||||
pub(crate) use layout::path_key;
|
||||
pub use layout::{
|
||||
DockLayout, DockLayoutState, DockNode, DropTarget, DropZone, NodePath, PanelRegistry,
|
||||
};
|
||||
pub use panel::{DockPanel, PanelEvent, PanelHandle, PanelId};
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Panel identity, the [`DockPanel`](crate::dock::DockPanel) trait, and type-erased panel handles.
|
||||
//!
|
||||
//! A *panel* is any view the user can dock. It is an ordinary GPUI view
|
||||
//! ([`Render`]) that additionally implements [`DockPanel`](crate::dock::DockPanel) so the dock system
|
||||
//! can identify it, label its tab, and negotiate closing. The [`DockArea`](crate::dock::DockArea)
|
||||
//! stores panels as [`PanelHandle`]s — a type-erased wrapper around
|
||||
//! [`AnyView`] plus the metadata the dock chrome (tab strip, drop overlay)
|
||||
//! needs without downcasting.
|
||||
|
||||
use crate::{
|
||||
AnyElement, AnyView, App, Context, Entity, EventEmitter, Render, SharedString, Subscription,
|
||||
Window,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Stable, copyable identifier for a docked panel.
|
||||
///
|
||||
/// A `PanelId` uniquely identifies one panel instance within a [`DockArea`](crate::dock::DockArea)
|
||||
/// for its whole lifetime: the layout tree ([`DockNode`](crate::dock::DockNode))
|
||||
/// refers to panels exclusively by id, and events such as
|
||||
/// [`DockEvent::PanelFocused`](crate::dock::DockEvent::PanelFocused) carry it.
|
||||
///
|
||||
/// Ids are assigned by the panel implementation (or the application) via
|
||||
/// [`DockPanel::panel_id`]. They must be unique within a dock area; adding a
|
||||
/// second panel with an existing id is an error (see
|
||||
/// [`DockArea::add_panel`](crate::dock::DockArea::add_panel)(crate::dock::DockArea::add_panel)). Ids are *not*
|
||||
/// required to be stable across sessions — persistence goes through string
|
||||
/// keys, see [`PanelRegistry`](crate::dock::PanelRegistry).
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct PanelId(u64);
|
||||
|
||||
impl PanelId {
|
||||
/// Creates a panel id from a raw numeric value.
|
||||
///
|
||||
/// The value only needs to be unique within the owning
|
||||
/// [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea); a simple per-application counter
|
||||
/// (or a hash of a stable name) is sufficient.
|
||||
pub const fn new(raw: u64) -> Self {
|
||||
Self(raw)
|
||||
}
|
||||
|
||||
/// Returns the raw numeric value backing this id.
|
||||
pub const fn raw(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PanelId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "panel-{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Events a dock panel can emit to its containing [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea).
|
||||
///
|
||||
/// Panels emit these through their [`EventEmitter<PanelEvent>`] implementation
|
||||
/// (required by [`DockPanel`](crate::dock::DockPanel)). The dock area subscribes to every panel it
|
||||
/// holds and reacts — e.g. by updating the tab label or starting the close
|
||||
/// flow — without the panel needing a direct reference to the dock.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PanelEvent {
|
||||
/// The panel asked to be closed (e.g. its own close affordance was
|
||||
/// invoked).
|
||||
///
|
||||
/// The dock area does not remove the panel unconditionally: it first
|
||||
/// consults [`DockPanel::should_close`], then calls [`DockPanel::on_close`]
|
||||
/// and removes the panel only if closing was confirmed.
|
||||
CloseRequested,
|
||||
/// The panel's content gained keyboard focus.
|
||||
///
|
||||
/// The dock area uses this to keep its own `focused_panel` bookkeeping in
|
||||
/// sync and to emit
|
||||
/// [`DockEvent::PanelFocused`](crate::dock::DockEvent::PanelFocused).
|
||||
Focused,
|
||||
/// The panel's title changed; the tab strip should re-render the label.
|
||||
///
|
||||
/// The new title is read back through [`DockPanel::title`] rather than
|
||||
/// carried in the event, so panels never have to clone it.
|
||||
TitleChanged,
|
||||
}
|
||||
|
||||
/// A view that can live inside a [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea).
|
||||
///
|
||||
/// Implement this on the same view type that implements [`Render`]. The dock
|
||||
/// area renders the panel's normal [`Render::render`] output as the tab
|
||||
/// group's content; this trait only supplies dock-specific metadata and
|
||||
/// lifecycle hooks.
|
||||
///
|
||||
/// # Required items
|
||||
///
|
||||
/// - [`panel_id`](DockPanel::panel_id) — stable identity.
|
||||
/// - [`title`](DockPanel::title) — tab label.
|
||||
/// - [`tab_content`](DockPanel::tab_content) — rich tab content (icon + label,
|
||||
/// status dot, ...).
|
||||
///
|
||||
/// # Provided items
|
||||
///
|
||||
/// - [`closable`](DockPanel::closable) — whether a close button is shown
|
||||
/// (default `true`).
|
||||
/// - [`should_close`](DockPanel::should_close) — veto hook, e.g. an unsaved
|
||||
/// changes confirmation (default `true`).
|
||||
/// - [`on_close`](DockPanel::on_close) — cleanup hook run after a confirmed
|
||||
/// close.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// struct MediaBin { /* ... */ }
|
||||
///
|
||||
/// impl Render for MediaBin { /* ... */ }
|
||||
/// impl EventEmitter<PanelEvent> for MediaBin {}
|
||||
///
|
||||
/// impl DockPanel for MediaBin {
|
||||
/// fn panel_id(&self) -> PanelId { PanelId::new(1) }
|
||||
/// fn title(&self, _cx: &App) -> SharedString { "Media Bin".into() }
|
||||
/// fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
/// div().child("Media Bin").into_any_element()
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait DockPanel: Render + EventEmitter<PanelEvent> + 'static {
|
||||
/// Returns the stable id of this panel.
|
||||
///
|
||||
/// Must return the same value for the whole lifetime of the view and must
|
||||
/// be unique among all panels added to one dock area.
|
||||
fn panel_id(&self) -> PanelId;
|
||||
|
||||
/// Returns the plain-text title shown in the tab strip and, where
|
||||
/// relevant, in window titles for floated panels.
|
||||
///
|
||||
/// Called on every render of the containing tab bar, so it should be
|
||||
/// cheap. Emit [`PanelEvent::TitleChanged`] after changing whatever state
|
||||
/// feeds this.
|
||||
fn title(&self, cx: &App) -> SharedString;
|
||||
|
||||
/// Returns the element rendered inside this panel's tab.
|
||||
///
|
||||
/// The default tab bar renders [`title`](DockPanel::title) when this is
|
||||
/// not customized, but panels may return richer content (icon, dirty
|
||||
/// indicator, close-on-middle-click affordances). The returned element
|
||||
/// must not handle close or drag interactions itself — the tab strip
|
||||
/// overlays those.
|
||||
fn tab_content(&self, cx: &App) -> AnyElement;
|
||||
|
||||
/// Whether this panel shows a close button and can be closed by the user.
|
||||
///
|
||||
/// Non-closable panels can still be removed programmatically via
|
||||
/// [`DockArea::remove_panel`](crate::dock::DockArea::remove_panel).
|
||||
/// Defaults to `true`.
|
||||
fn closable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Called when the user has asked to close this panel, before removal.
|
||||
///
|
||||
/// Return `true` to allow the close, `false` to veto it (for example
|
||||
/// after showing an "unsaved changes" dialog). This may be called on the
|
||||
/// same event-loop turn as the close request, so asynchronous
|
||||
/// confirmations should veto now and re-trigger closing later through
|
||||
/// [`DockArea::remove_panel`](crate::dock::DockArea::remove_panel).
|
||||
/// Defaults to `true`.
|
||||
fn should_close(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Called after a close was confirmed and before the panel is removed
|
||||
/// from the layout.
|
||||
///
|
||||
/// Use this to release resources tied to the dock (subscriptions,
|
||||
/// scratch entities). The default implementation does nothing.
|
||||
fn on_close(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
|
||||
}
|
||||
|
||||
/// A type-erased panel plus the metadata the dock chrome needs.
|
||||
///
|
||||
/// Wraps the panel's view as an [`AnyView`] so a [`DockArea`](crate::dock::DockArea)(crate::dock::DockArea)
|
||||
/// can hold heterogeneous panel types in one collection. The metadata
|
||||
/// ([`PanelId`], title, closability) is a cached snapshot taken at
|
||||
/// construction and refreshed when the panel emits [`PanelEvent::TitleChanged`].
|
||||
///
|
||||
/// Construct with [`PanelHandle::new`]; pass to
|
||||
/// [`DockArea::add_panel`](crate::dock::DockArea::add_panel)(crate::dock::DockArea::add_panel).
|
||||
pub struct PanelHandle {
|
||||
id: PanelId,
|
||||
view: AnyView,
|
||||
title: SharedString,
|
||||
closable: bool,
|
||||
/// Subscription to the panel's [`PanelEvent`]s while it is held by a
|
||||
/// dock area, installed by [`DockArea`](crate::dock::DockArea) when the
|
||||
/// panel is added.
|
||||
subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl PanelHandle {
|
||||
/// Wraps a panel view, snapshotting its current metadata.
|
||||
///
|
||||
/// `panel` must implement [`DockPanel`](crate::dock::DockPanel). The handle keeps the view alive
|
||||
/// for as long as it is stored in the dock area.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Does not panic, but adding two handles with the same
|
||||
/// [`DockPanel::panel_id`] to one dock area is rejected there.
|
||||
pub fn new<P: DockPanel>(panel: Entity<P>, cx: &App) -> Self {
|
||||
let id = panel.read(cx).panel_id();
|
||||
let title = panel.read(cx).title(cx);
|
||||
let closable = panel.read(cx).closable();
|
||||
Self {
|
||||
id,
|
||||
view: panel.into(),
|
||||
title,
|
||||
closable,
|
||||
subscription: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the id the panel reported at snapshot time.
|
||||
pub fn panel_id(&self) -> PanelId {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Returns the cached tab title.
|
||||
///
|
||||
/// May be stale between a title change and the dock area processing
|
||||
/// [`PanelEvent::TitleChanged`]; treat as display-only.
|
||||
pub fn title(&self) -> &SharedString {
|
||||
&self.title
|
||||
}
|
||||
|
||||
/// Returns the cached value of [`DockPanel::closable`].
|
||||
pub fn closable(&self) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
/// Returns the type-erased panel view.
|
||||
pub fn view(&self) -> &AnyView {
|
||||
&self.view
|
||||
}
|
||||
|
||||
/// Returns the subscription to this panel's [`PanelEvent`]s, if the dock
|
||||
/// area has installed one.
|
||||
#[allow(dead_code)] // reserved for the dock's panel-event bookkeeping
|
||||
pub(crate) fn subscription(&self) -> &Option<Subscription> {
|
||||
&self.subscription
|
||||
}
|
||||
|
||||
/// Installs (or replaces) the subscription to this panel's [`PanelEvent`]s.
|
||||
///
|
||||
/// Used by [`DockArea`](crate::dock::DockArea) when the panel is added or
|
||||
/// restored; the previous subscription, if any, is dropped.
|
||||
pub(crate) fn set_subscription(&mut self, subscription: Option<Subscription>) {
|
||||
self.subscription = subscription;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! The draggable divider between the children of a
|
||||
//! [`DockNode::Split`](crate::dock::DockNode::Split).
|
||||
//!
|
||||
//! Internal component — not part of the public API. One `SplitHandle` is
|
||||
//! rendered between each pair of split children; dragging it adjusts the
|
||||
//! split's `ratio` through
|
||||
//! [`DockLayout::resize_split`](crate::dock::DockLayout::resize_split).
|
||||
|
||||
use crate::{
|
||||
div, px, App, AppContext, Axis, ClickEvent, Context, ElementId, EventEmitter,
|
||||
InteractiveElement, IntoElement, Pixels, Point, Render, StatefulInteractiveElement, Styled,
|
||||
Window,
|
||||
};
|
||||
use crate::colors::DefaultColors;
|
||||
|
||||
use super::{NodePath, path_key};
|
||||
|
||||
/// Events emitted by a [`SplitHandle`] toward its owning [`DockArea`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum SplitHandleEvent {
|
||||
/// The user dragged the handle; the split at `path` should be resized to
|
||||
/// `ratio`.
|
||||
ResizeRequested {
|
||||
/// Path of the split node to resize.
|
||||
path: NodePath,
|
||||
/// Desired new ratio, already clamped to the allowed range.
|
||||
ratio: f32,
|
||||
},
|
||||
/// The user double-clicked the handle; the split at `path` should be
|
||||
/// reset to [`SplitHandle::RESET_RATIO`].
|
||||
ResetRequested {
|
||||
/// Path of the split node to reset.
|
||||
path: NodePath,
|
||||
},
|
||||
}
|
||||
|
||||
/// The payload of a handle drag; carried by the drag-and-drop system so the
|
||||
/// split container can identify which handle is being dragged.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SplitHandleDrag {
|
||||
/// Path of the split being resized.
|
||||
pub(crate) path: NodePath,
|
||||
}
|
||||
|
||||
/// A resize handle between two children of a split node.
|
||||
///
|
||||
/// # Behavior
|
||||
///
|
||||
/// - Renders as a thin divider along the split's cross axis with the matching
|
||||
/// resize cursor (`col-resize` for [`Axis::Horizontal`] splits,
|
||||
/// `row-resize` for [`Axis::Vertical`]).
|
||||
/// - Dragging converts the pointer delta into a ratio delta (pixels of the
|
||||
/// parent extent → fraction) and emits
|
||||
/// [`SplitHandleEvent::ResizeRequested`]; the owning
|
||||
/// [`DockArea`](crate::dock::DockArea) applies it via
|
||||
/// [`DockLayout::resize_split`], clamping so neither side shrinks below
|
||||
/// [`SplitHandle::MIN_CHILD_EXTENT`].
|
||||
/// - Double-clicking emits [`SplitHandleEvent::ResetRequested`] to reset the
|
||||
/// split to an even 50/50.
|
||||
///
|
||||
/// The handle carries the [`NodePath`] of its split so it can address the
|
||||
/// correct node after unrelated edits elsewhere in the tree; paths are
|
||||
/// re-derived on every render, never stored across frames.
|
||||
pub(crate) struct SplitHandle {
|
||||
/// Axis along which the parent split lays out its children; the handle
|
||||
/// itself extends along the perpendicular axis.
|
||||
direction: Axis,
|
||||
/// Path of the split node this handle resizes, valid for the current
|
||||
/// frame only.
|
||||
path: NodePath,
|
||||
/// Pointer position where the current drag started, if dragging.
|
||||
drag_origin: Option<Pixels>,
|
||||
}
|
||||
|
||||
impl SplitHandle {
|
||||
/// Thickness of the handle's interactive area, in logical pixels. The
|
||||
/// visual divider may be thinner; the wider hitbox makes the handle
|
||||
/// grabbable.
|
||||
pub(crate) const HITBOX: Pixels = Pixels(6.0);
|
||||
|
||||
/// Minimum extent, in logical pixels, that a split child may be resized
|
||||
/// to by dragging. Expressed as a fraction of the parent extent when
|
||||
/// computing the drag clamp.
|
||||
pub(crate) const MIN_CHILD_EXTENT: Pixels = Pixels(120.0);
|
||||
|
||||
/// The ratio a double-click resets to (even split).
|
||||
pub(crate) const RESET_RATIO: f32 = 0.5;
|
||||
|
||||
/// Creates a handle for the split at `path`.
|
||||
pub(crate) fn new(direction: Axis, path: NodePath) -> Self {
|
||||
Self {
|
||||
direction,
|
||||
path,
|
||||
drag_origin: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Begins a drag, remembering the pointer origin.
|
||||
pub(crate) fn begin_drag(&mut self, origin: Pixels) {
|
||||
self.drag_origin = Some(origin);
|
||||
}
|
||||
|
||||
/// Applies an in-progress drag: converts the pointer delta to a ratio
|
||||
/// delta relative to the parent extent and emits a
|
||||
/// [`SplitHandleEvent::ResizeRequested`].
|
||||
///
|
||||
/// `start_ratio` is the split's ratio at drag start, re-read from the
|
||||
/// layout by the owning dock area on every move so external edits during
|
||||
/// the drag are respected.
|
||||
pub(crate) fn drag_to(
|
||||
&mut self,
|
||||
position: Pixels,
|
||||
parent_extent: Pixels,
|
||||
start_ratio: f32,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(origin) = self.drag_origin else {
|
||||
return;
|
||||
};
|
||||
if parent_extent.0 <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Keep both children above MIN_CHILD_EXTENT, but never clamp harder
|
||||
// than a quarter of the parent so tiny parents stay resizable.
|
||||
let min_ratio = (Self::MIN_CHILD_EXTENT.0 / parent_extent.0).min(0.25);
|
||||
let max_ratio = 1.0 - min_ratio;
|
||||
let ratio = (start_ratio + (position.0 - origin.0) / parent_extent.0)
|
||||
.clamp(min_ratio, max_ratio);
|
||||
cx.emit(SplitHandleEvent::ResizeRequested {
|
||||
path: self.path.clone(),
|
||||
ratio,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Ends the current drag, if any.
|
||||
pub(crate) fn end_drag(&mut self) {
|
||||
self.drag_origin = None;
|
||||
}
|
||||
|
||||
/// Emits a [`SplitHandleEvent::ResetRequested`] for a double-click.
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(SplitHandleEvent::ResetRequested {
|
||||
path: self.path.clone(),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<SplitHandleEvent> for SplitHandle {}
|
||||
|
||||
impl Render for SplitHandle {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let direction = self.direction;
|
||||
let handle = cx.entity();
|
||||
|
||||
// Begins the drag on the handle (recording the pointer origin) and
|
||||
// returns the ghost view shown under the pointer.
|
||||
let ghost_ctor = move |_drag: &SplitHandleDrag,
|
||||
origin: Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
cx: &mut App| {
|
||||
handle.update(cx, |handle, _cx| {
|
||||
handle.begin_drag(if direction == Axis::Horizontal {
|
||||
origin.x
|
||||
} else {
|
||||
origin.y
|
||||
});
|
||||
});
|
||||
cx.new(|_cx| SplitDragGhost { direction })
|
||||
};
|
||||
|
||||
let mut root = div()
|
||||
.id(ElementId::named_usize("dock-split-handle", path_key(&self.path)))
|
||||
.flex_none()
|
||||
.bg(colors.separator)
|
||||
.on_click(cx.listener(move |this, event: &ClickEvent, _window, cx| {
|
||||
if event.click_count() >= 2 {
|
||||
this.reset(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
// A horizontal split stacks children side by side, so its divider is
|
||||
// a vertical bar and vice versa.
|
||||
match direction {
|
||||
Axis::Horizontal => {
|
||||
root = root
|
||||
.w(px(Self::HITBOX.0))
|
||||
.h_full()
|
||||
.cursor_col_resize();
|
||||
}
|
||||
Axis::Vertical => {
|
||||
root = root
|
||||
.w_full()
|
||||
.h(px(Self::HITBOX.0))
|
||||
.cursor_row_resize();
|
||||
}
|
||||
}
|
||||
|
||||
root.on_drag(
|
||||
SplitHandleDrag {
|
||||
path: self.path.clone(),
|
||||
},
|
||||
ghost_ctor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating view shown under the pointer while a handle is being dragged.
|
||||
struct SplitDragGhost {
|
||||
direction: Axis,
|
||||
}
|
||||
|
||||
impl Render for SplitDragGhost {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let mut ghost = div().bg(colors.border).rounded_sm();
|
||||
match self.direction {
|
||||
Axis::Horizontal => ghost = ghost.w(px(2.0)).h(px(64.0)),
|
||||
Axis::Vertical => ghost = ghost.w(px(64.0)).h(px(2.0)),
|
||||
}
|
||||
ghost
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! The tab strip rendered above each `Tabs` group in a
|
||||
//! [`DockArea`](crate::dock::DockArea).
|
||||
//!
|
||||
//! Internal component — not part of the public API. One `TabBar` exists per
|
||||
//! [`DockNode::Tabs`](crate::dock::DockNode::Tabs) node; it renders one tab
|
||||
//! per panel (via [`DockPanel::tab_content`](crate::dock::DockPanel::tab_content)),
|
||||
//! tracks the active tab, hosts close buttons, and is the drag source for
|
||||
//! both tab reordering and dock drags.
|
||||
|
||||
use crate::{
|
||||
div, px, App, AppContext, ClickEvent, Context, DragMoveEvent, ElementId, EventEmitter,
|
||||
InteractiveElement, IntoElement, ParentElement, Pixels, Point, Render, ScrollDelta,
|
||||
ScrollWheelEvent, SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use crate::colors::DefaultColors;
|
||||
|
||||
use super::PanelId;
|
||||
|
||||
/// Events emitted by a [`TabBar`] toward its owning [`DockArea`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum TabBarEvent {
|
||||
/// The tab order or the active tab changed in place.
|
||||
Reordered {
|
||||
/// The new tab order (mirrors the owning `Tabs` node's `panels`).
|
||||
tabs: Vec<PanelId>,
|
||||
/// Index of the active tab in `tabs`.
|
||||
active: usize,
|
||||
},
|
||||
/// The user clicked the close button of a tab.
|
||||
CloseRequested(PanelId),
|
||||
/// A tab was dragged out of the strip; the dock area takes over the drag.
|
||||
DockDragStarted {
|
||||
/// The panel being dragged.
|
||||
panel: PanelId,
|
||||
/// Pointer position in window coordinates.
|
||||
position: Point<Pixels>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single tab's computed geometry within the strip, cached during render
|
||||
/// for hit-testing (close button, reorder, drag start).
|
||||
pub(crate) struct TabGeometry {
|
||||
/// The panel this tab shows.
|
||||
#[allow(dead_code)] // retained for future hit-testing of individual tabs
|
||||
pub panel: PanelId,
|
||||
/// Left edge of the tab relative to the strip.
|
||||
pub x: Pixels,
|
||||
/// Width of the tab.
|
||||
pub width: Pixels,
|
||||
}
|
||||
|
||||
/// The tab strip for one `Tabs` node.
|
||||
///
|
||||
/// # Responsibilities
|
||||
///
|
||||
/// - Render tabs in tree order, highlighting the active one and dimming
|
||||
/// inactive ones.
|
||||
/// - Show a close button on tabs whose panel reports
|
||||
/// [`closable`](crate::dock::DockPanel::closable); clicking it emits
|
||||
/// [`TabBarEvent::CloseRequested`], which the dock area routes through its
|
||||
/// close flow (never removes the panel directly).
|
||||
/// - Reorder tabs by dragging within the strip: dropping a tab between two
|
||||
/// others emits [`TabBarEvent::Reordered`] and the dock area rewrites
|
||||
/// [`DockNode::Tabs::panels`](crate::dock::DockNode::Tabs::panels) in place.
|
||||
/// - Escalate to a dock drag: once a dragged tab leaves the strip's bounds,
|
||||
/// emit [`TabBarEvent::DockDragStarted`] so the
|
||||
/// [`DockArea`](crate::dock::DockArea)'s drag-to-dock plumbing takes over.
|
||||
/// - Overflow: when tabs exceed the available width, the strip scrolls
|
||||
/// horizontally (wheel and drag) instead of shrinking tabs below a minimum
|
||||
/// width; the active tab is scrolled into view when activated.
|
||||
///
|
||||
/// # Invariants
|
||||
///
|
||||
/// `tabs` always mirrors the owning node's `panels` order exactly and
|
||||
/// `active < tabs.len()`; the dock area re-creates or syncs the strip
|
||||
/// whenever the tree changes rather than the strip mutating the tree itself.
|
||||
pub(crate) struct TabBar {
|
||||
/// Panels in tab order, mirroring the owning `Tabs` node.
|
||||
tabs: Vec<PanelId>,
|
||||
/// Index of the active tab.
|
||||
active: usize,
|
||||
/// Horizontal scroll offset for overflowed strips.
|
||||
scroll_offset: Pixels,
|
||||
/// Per-tab geometry from the last frame, for hit-testing.
|
||||
geometry: Vec<TabGeometry>,
|
||||
/// Tab titles from the last sync, rendered as the tab labels.
|
||||
titles: Vec<SharedString>,
|
||||
/// Per-tab closability from the last sync.
|
||||
closable: Vec<bool>,
|
||||
}
|
||||
|
||||
impl TabBar {
|
||||
/// Minimum width a tab is allowed to shrink to before the strip starts
|
||||
/// scrolling instead.
|
||||
pub(crate) const MIN_TAB_WIDTH: Pixels = Pixels(80.0);
|
||||
|
||||
/// Creates a strip for the given tabs; `active` is clamped into range.
|
||||
pub(crate) fn new(tabs: Vec<PanelId>, active: usize) -> Self {
|
||||
Self {
|
||||
active: active.min(tabs.len().saturating_sub(1)),
|
||||
tabs,
|
||||
scroll_offset: Pixels(0.0),
|
||||
geometry: Vec::new(),
|
||||
titles: Vec::new(),
|
||||
closable: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Syncs the strip with the owning node after a tree edit, preserving
|
||||
/// scroll position where possible.
|
||||
///
|
||||
/// Called from the dock area during render, so it must not notify.
|
||||
pub(crate) fn sync(
|
||||
&mut self,
|
||||
tabs: &[PanelId],
|
||||
active: usize,
|
||||
titles: &[SharedString],
|
||||
closable: &[bool],
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
self.tabs = tabs.to_vec();
|
||||
self.titles = titles.to_vec();
|
||||
self.closable = closable.to_vec();
|
||||
self.active = active.min(self.tabs.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
/// Returns the index of the tab containing `point` (strip-relative
|
||||
/// coordinates), using the cached geometry.
|
||||
pub(crate) fn tab_index_at(&self, point: Point<Pixels>) -> Option<usize> {
|
||||
let count = self.geometry.partition_point(|tab| tab.x.0 <= point.x.0);
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
// `count - 1` is the last tab whose left edge is left of the point;
|
||||
// tabs are contiguous, so that tab contains the point.
|
||||
Some(count - 1)
|
||||
}
|
||||
|
||||
/// Reorders the tab at `from` to position `to`, keeping the active tab
|
||||
/// pointing at the same panel, and emits [`TabBarEvent::Reordered`] so
|
||||
/// the dock area can rewrite the owning node.
|
||||
///
|
||||
/// No-op if either index is out of range or `from == to`.
|
||||
pub(crate) fn move_tab(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
|
||||
if from >= self.tabs.len() || to >= self.tabs.len() || from == to {
|
||||
return;
|
||||
}
|
||||
let active_panel = self.tabs.get(self.active).copied();
|
||||
let panel = self.tabs.remove(from);
|
||||
let insert_at = if to > from { to - 1 } else { to };
|
||||
self.tabs.insert(insert_at, panel);
|
||||
self.active = active_panel
|
||||
.and_then(|p| self.tabs.iter().position(|tab| *tab == p))
|
||||
.unwrap_or(0);
|
||||
cx.emit(TabBarEvent::Reordered {
|
||||
tabs: self.tabs.clone(),
|
||||
active: self.active,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Makes the tab at `index` active, scrolling it into view first.
|
||||
fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if index >= self.tabs.len() {
|
||||
return;
|
||||
}
|
||||
self.active = index;
|
||||
self.scroll_tab_into_view(index, window, cx);
|
||||
cx.emit(TabBarEvent::Reordered {
|
||||
tabs: self.tabs.clone(),
|
||||
active: self.active,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Scrolls the strip so the tab at `index` is fully visible.
|
||||
///
|
||||
/// The strip does not know the exact width of the visible viewport, so
|
||||
/// this approximates it with the window width — good enough to bring an
|
||||
/// overflowing tab back into view.
|
||||
fn scroll_tab_into_view(&mut self, index: usize, window: &mut Window, _cx: &mut Context<Self>) {
|
||||
let Some(geometry) = self.geometry.get(index) else {
|
||||
return;
|
||||
};
|
||||
let viewport = window.viewport_size().width.0;
|
||||
let left = geometry.x.0;
|
||||
let right = geometry.x.0 + geometry.width.0;
|
||||
let scrolled = self.scroll_offset.0;
|
||||
if left < scrolled {
|
||||
self.scroll_offset = Pixels(left.max(0.0));
|
||||
} else if right > scrolled + viewport {
|
||||
self.scroll_offset = Pixels((right - viewport).max(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<TabBarEvent> for TabBar {}
|
||||
|
||||
impl Render for TabBar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
|
||||
// Recompute per-tab geometry from the current order and scroll offset.
|
||||
self.geometry = self
|
||||
.tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, &panel)| TabGeometry {
|
||||
panel,
|
||||
x: Pixels(index as f32 * Self::MIN_TAB_WIDTH.0 - self.scroll_offset.0),
|
||||
width: Self::MIN_TAB_WIDTH,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut root = div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
.h(px(32.0))
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| {
|
||||
let delta = match event.delta {
|
||||
ScrollDelta::Pixels(delta) => delta.x.0,
|
||||
ScrollDelta::Lines(delta) => delta.x * 20.0,
|
||||
};
|
||||
this.scroll_offset = Pixels((this.scroll_offset.0 + delta).max(0.0));
|
||||
cx.notify();
|
||||
}))
|
||||
.on_drag_move::<PanelId>(cx.listener(|this, event: &DragMoveEvent<PanelId>, _window, cx| {
|
||||
let dragged = *event.drag(cx);
|
||||
// Ignore drags of panels that don't belong to this strip
|
||||
// (e.g. a dock-level drag from another group passing over).
|
||||
if !this.tabs.contains(&dragged) {
|
||||
return;
|
||||
}
|
||||
if event.bounds.contains(&event.event.position) {
|
||||
if let Some(to) = this.tab_index_at(event.event.position) {
|
||||
if let Some(from) = this.tabs.iter().position(|tab| *tab == dragged) {
|
||||
this.move_tab(from, to, cx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The tab left the strip: hand the drag to the dock area.
|
||||
cx.emit(TabBarEvent::DockDragStarted {
|
||||
panel: dragged,
|
||||
position: event.event.position,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
for (index, &panel) in self.tabs.iter().enumerate() {
|
||||
let active = index == self.active;
|
||||
let title = self
|
||||
.titles
|
||||
.get(index)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| SharedString::from("Tab"));
|
||||
let closable = self.closable.get(index).copied().unwrap_or(false);
|
||||
|
||||
// Ghost shown under the pointer while this tab is being dragged.
|
||||
let ghost_title = title.clone();
|
||||
let ghost_ctor = move |_panel: &PanelId,
|
||||
_origin: Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
cx: &mut App| {
|
||||
cx.new(|_cx| TabDragGhost { title: ghost_title.clone() })
|
||||
};
|
||||
|
||||
let mut tab = div()
|
||||
.id(ElementId::named_usize("dock-tab", panel.raw() as usize))
|
||||
.w(px(Self::MIN_TAB_WIDTH.0))
|
||||
.flex_none()
|
||||
.h_full()
|
||||
.cursor_pointer()
|
||||
.bg(if active { colors.selected } else { colors.background })
|
||||
.text_color(if active { colors.text } else { colors.disabled })
|
||||
.child(title)
|
||||
.on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| {
|
||||
this.activate(index, window, cx);
|
||||
}))
|
||||
.on_drag(panel, ghost_ctor);
|
||||
|
||||
if closable {
|
||||
tab = tab.child(
|
||||
div()
|
||||
.id(ElementId::named_usize("dock-tab-close", panel.raw() as usize))
|
||||
.cursor_pointer()
|
||||
.text_xs()
|
||||
.text_color(colors.disabled)
|
||||
.child("✕")
|
||||
.on_click(cx.listener(
|
||||
move |_this, _event: &ClickEvent, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.emit(TabBarEvent::CloseRequested(panel));
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
root = root.child(tab);
|
||||
}
|
||||
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating view shown under the pointer while a tab is being dragged.
|
||||
struct TabDragGhost {
|
||||
title: SharedString,
|
||||
}
|
||||
|
||||
impl Render for TabDragGhost {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.bg(colors.background)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.shadow_md()
|
||||
.child(self.title.clone())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user