//! Docking: a tree of splits and tab groups. Panels are rounded frames separated by //! thin gutters; each group has a tab strip (active tab bright, with a panel menu "≡"); the focused //! panel gets a blue outline. Gutters drag to resize; workspaces are serialized trees. use egui::{Align2, Rect, Sense, Stroke, pos2, vec2}; use serde::{Deserialize, Serialize}; use craft_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize as SharedSplitSize, split_rect}; use crate::icons::{self, Icon}; use crate::theme::Tokens; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum PanelKind { Project, EffectControls, Composition, Layer, Timeline, Info, Audio, Preview, EffectsPresets, Properties, Character, Paragraph, Align, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, RenderQueue, Flowchart, History, Markers, MaskInterpolation, ScriptConsole, EssentialGraphics, /// A dockable ScriptUI panel (a script from the ScriptUI Panels folder), by script window id. ScriptPanel(u32), /// Another Composition viewer (View ▸ New Viewer), by viewer id (`panels::viewers`). Viewer(u32), LumetriScopes, Footage, MediaBrowser, Metadata, Progress, ContentAwareFill, /// Window ▸ Create Nulls From Paths (our own panel). CreateNullsFromPaths, /// Window ▸ VR Comp Editor (our own panel). VrCompEditor, } impl PanelKind { pub const ALL: [PanelKind; 34] = [ PanelKind::Project, PanelKind::EffectControls, PanelKind::Composition, PanelKind::Layer, PanelKind::Timeline, PanelKind::Info, PanelKind::Audio, PanelKind::Preview, PanelKind::EffectsPresets, PanelKind::Properties, PanelKind::Character, PanelKind::Paragraph, PanelKind::Align, PanelKind::Tracker, PanelKind::Wiggler, PanelKind::Smoother, PanelKind::MotionSketch, PanelKind::Paint, PanelKind::Brushes, PanelKind::RenderQueue, PanelKind::Flowchart, PanelKind::History, PanelKind::Markers, PanelKind::MaskInterpolation, PanelKind::ScriptConsole, PanelKind::EssentialGraphics, PanelKind::LumetriScopes, PanelKind::Footage, PanelKind::MediaBrowser, PanelKind::Metadata, PanelKind::Progress, PanelKind::ContentAwareFill, PanelKind::CreateNullsFromPaths, PanelKind::VrCompEditor, ]; pub fn title(self) -> &'static str { match self { PanelKind::Project => "Project", PanelKind::EffectControls => "Effect Controls", PanelKind::Composition => "Composition", PanelKind::Layer => "Layer", PanelKind::Timeline => "Timeline", PanelKind::Info => "Info", PanelKind::Audio => "Audio", PanelKind::Preview => "Preview", PanelKind::EffectsPresets => "Effects & Presets", PanelKind::Properties => "Properties", PanelKind::Character => "Character", PanelKind::Paragraph => "Paragraph", PanelKind::Align => "Align", PanelKind::Tracker => "Tracker", PanelKind::Wiggler => "Wiggler", PanelKind::Smoother => "Smoother", PanelKind::MotionSketch => "Motion Sketch", PanelKind::Paint => "Paint", PanelKind::Brushes => "Brushes", PanelKind::RenderQueue => "Render Queue", PanelKind::Flowchart => "Flowchart", PanelKind::History => "History", PanelKind::Markers => "Markers", PanelKind::MaskInterpolation => "Mask Interpolation", PanelKind::ScriptConsole => "Script Console", PanelKind::EssentialGraphics => "Essential Graphics", PanelKind::ScriptPanel(_) => "ScriptUI Panel", PanelKind::Viewer(_) => "Composition", PanelKind::LumetriScopes => "Lumetri Scopes", PanelKind::Footage => "Footage", PanelKind::MediaBrowser => "Media Browser", PanelKind::Metadata => "Metadata", PanelKind::Progress => "Progress", PanelKind::ContentAwareFill => "Content-Aware Fill", PanelKind::CreateNullsFromPaths => "Create Nulls From Paths", PanelKind::VrCompEditor => "VR Comp Editor", } } pub fn id(self) -> String { format!("{self:?}") } pub fn from_name(s: &str) -> Option { let n = s.to_ascii_lowercase().replace([' ', '_', '-', '&'], ""); Self::ALL.iter().copied().find(|p| format!("{p:?}").to_ascii_lowercase() == n || p.title().to_ascii_lowercase().replace([' ', '&', '-'], "") == n) } /// Window-menu shortcut. pub fn window_shortcut(self) -> Option<&'static str> { match self { PanelKind::Project => Some("Cmd+0"), PanelKind::Info => Some("Cmd+2"), PanelKind::Preview => Some("Cmd+3"), PanelKind::Audio => Some("Cmd+4"), PanelKind::EffectsPresets => Some("Cmd+5"), PanelKind::Character => Some("Cmd+6"), PanelKind::Paragraph => Some("Cmd+7"), PanelKind::Paint => Some("Cmd+8"), PanelKind::Brushes => Some("Cmd+9"), PanelKind::RenderQueue => Some("Cmd+Alt+0"), PanelKind::EffectControls => Some("F3"), _ => None, } } /// Chrome-less panels (none in the AE layout; kept for parity with the docking code). pub fn compact(self) -> bool { false } } #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub enum SplitSize { Ratio(f32), /// First child has a fixed size in points. FixedA(f32), /// Second child has a fixed size in points. FixedB(f32), } impl SplitSize { fn shared(self) -> SharedSplitSize { match self { Self::Ratio(value) => SharedSplitSize::Ratio(value), Self::FixedA(value) => SharedSplitSize::FixedFirst(value), Self::FixedB(value) => SharedSplitSize::FixedSecond(value), } } } /// One frame in a [`DockNode::Stack`]: a header with one tab per panel (a panel dropped onto a /// stacked panel joins its frame as a tab, as in After Effects). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(from = "StackEntryRepr")] pub struct StackEntry { pub panels: Vec, /// The shown tab. pub active: usize, pub open: bool, /// Content height when open (set by dragging the gaps); `None` = share the remaining height. pub height: Option, } /// A saved [`StackEntry`]: workspaces saved before stacked frames held tabs name one `panel`. #[derive(Deserialize)] struct StackEntryRepr { #[serde(default)] panel: Option, #[serde(default)] panels: Vec, #[serde(default)] active: usize, open: bool, #[serde(default)] height: Option, } impl From for StackEntry { fn from(r: StackEntryRepr) -> StackEntry { let mut panels = r.panels; if let Some(p) = r.panel && !panels.contains(&p) { panels.insert(0, p); } let active = r.active.min(panels.len().saturating_sub(1)); StackEntry { panels, active, open: r.open, height: r.height } } } impl StackEntry { /// A frame showing just `panel`. pub fn new(panel: PanelKind, open: bool, height: Option) -> StackEntry { StackEntry { panels: vec![panel], active: 0, open, height } } pub fn contains(&self, p: PanelKind) -> bool { self.panels.contains(&p) } /// The frame's shown panel. pub fn shown(&self) -> Option { self.panels.get(self.active).or(self.panels.first()).copied() } /// Show `p` (one of its tabs). fn show(&mut self, p: PanelKind) { if let Some(i) = self.panels.iter().position(|x| *x == p) { self.active = i; } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum DockNode { /// `vertical`: children stacked top/bottom; else side by side. Split { vertical: bool, size: SplitSize, a: Box, b: Box, }, Tabs { panels: Vec, active: usize, }, /// After Effects' stacked panels (e.g. the Default workspace's right column): each panel has /// a header row; clicking it expands or collapses the panel in place, and dragging the gap /// between two open panels resizes them. Stack { entries: Vec, }, } fn tabs(p: &[PanelKind], active: usize) -> DockNode { DockNode::Tabs { panels: p.to_vec(), active } } fn stack(e: &[(PanelKind, bool, Option)]) -> DockNode { DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry::new(panel, open, height)).collect() } } fn hsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode { DockNode::Split { vertical: false, size, a: Box::new(a), b: Box::new(b) } } fn vsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode { DockNode::Split { vertical: true, size, a: Box::new(a), b: Box::new(b) } } /// The built-in workspaces: After Effects 2026's (Window ▸ Workspace), less Libraries (an Adobe /// service). Learn shows the Home screen (community links instead of Adobe's tutorials); /// Essential Graphics uses the Properties panel; Undocked Panels floats the panels over the /// Composition panel. pub const WORKSPACES: &[&str] = &[ "Default", "Standard", "Small Screen", "Animation", "Effects", "Motion Tracking", "Paint", "Text", "Minimal", "All Panels", "Review", "Learn", "Essential Graphics", "Color", "Undocked Panels", ]; /// The floating panels a workspace starts with (Undocked Panels), in points for a window of /// about 1680×1020 (they are kept inside the window when drawn). pub fn workspace_floating(name: &str) -> Vec { use PanelKind::*; let f = |panels: &[PanelKind], rect: [f32; 4]| Floating { panels: panels.to_vec(), active: 0, rect }; match name { "Undocked Panels" => vec![ f(&[Project, EffectControls], [24.0, 110.0, 320.0, 420.0]), f(&[Preview, Info, Audio], [1330.0, 110.0, 320.0, 300.0]), f(&[EffectsPresets, Properties, Character, Paragraph], [1330.0, 430.0, 320.0, 360.0]), f(&[Timeline, RenderQueue], [24.0, 640.0, 1280.0, 340.0]), ], _ => vec![], } } /// The default layout of a named workspace. pub fn workspace(name: &str) -> DockNode { use PanelKind::*; use SplitSize::*; let right = |p: &[PanelKind], q: &[PanelKind]| vsplit(Ratio(0.45), tabs(p, 0), tabs(q, 0)); match name { "Animation" => vsplit( Ratio(0.55), hsplit( FixedA(300.0), tabs(&[Project, EffectControls], 1), hsplit(FixedB(300.0), tabs(&[Composition, Layer], 0), right(&[Info, Preview, Audio], &[EffectsPresets, Smoother, Wiggler, MotionSketch])), ), tabs(&[Timeline, RenderQueue], 0), ), "Effects" => vsplit( Ratio(0.58), hsplit( FixedA(340.0), tabs(&[EffectControls, Project], 0), hsplit(FixedB(300.0), tabs(&[Composition, Layer], 0), right(&[Info, Preview], &[EffectsPresets])), ), tabs(&[Timeline, RenderQueue], 0), ), "Motion Tracking" => vsplit( Ratio(0.6), hsplit( FixedA(300.0), tabs(&[Project, EffectControls], 0), hsplit(FixedB(300.0), tabs(&[Layer, Composition], 0), right(&[Info, Preview], &[Tracker])), ), tabs(&[Timeline], 0), ), "Paint" => vsplit( Ratio(0.6), hsplit( FixedA(300.0), tabs(&[Project, EffectControls], 0), hsplit(FixedB(300.0), tabs(&[Layer, Composition], 0), right(&[Paint, Info], &[Brushes, Preview])), ), tabs(&[Timeline], 0), ), "Text" => vsplit( Ratio(0.58), hsplit( FixedA(300.0), tabs(&[Project, EffectControls], 0), hsplit(FixedB(300.0), tabs(&[Composition], 0), right(&[Character, Paragraph], &[Align, Preview, EffectsPresets])), ), tabs(&[Timeline], 0), ), "Minimal" => vsplit(Ratio(0.6), tabs(&[Composition], 0), tabs(&[Timeline], 0)), // Review: a large viewer with the markers and the project beside it. "Review" => vsplit( Ratio(0.66), hsplit(FixedA(280.0), tabs(&[Project, Markers], 0), hsplit(FixedB(260.0), tabs(&[Composition, Layer], 0), right(&[Preview, Info], &[Audio]))), tabs(&[Timeline, RenderQueue], 0), ), // Essential Graphics: the Properties panel (After Effects' successor to the Essential // Graphics panel) full height on the right, type panels beside the viewer. "Essential Graphics" => hsplit( FixedB(320.0), vsplit( Ratio(0.58), hsplit( FixedA(280.0), tabs(&[Project, EffectControls], 0), hsplit(FixedB(260.0), tabs(&[Composition, Layer], 0), right(&[Character, Paragraph], &[Align, Preview])), ), tabs(&[Timeline], 0), ), stack(&[(Properties, true, None), (EffectsPresets, false, None)]), ), // Color: wide Effect Controls for grading effects, Info for colour readouts. "Color" => vsplit( Ratio(0.6), hsplit( FixedA(360.0), tabs(&[EffectControls, Project], 0), hsplit(FixedB(340.0), tabs(&[Composition, Layer], 0), right(&[LumetriScopes, Info, Preview], &[EffectsPresets])), ), tabs(&[Timeline], 0), ), // Undocked Panels: the Composition panel docked, the others floating // ([`workspace_floating`]). "Undocked Panels" => tabs(&[Composition, Layer, Flowchart], 0), "Small Screen" => vsplit( Ratio(0.55), hsplit( FixedA(240.0), tabs(&[Project, EffectControls], 0), hsplit(FixedB(240.0), tabs(&[Composition], 0), tabs(&[Info, Preview, EffectsPresets], 1)), ), tabs(&[Timeline], 0), ), "All Panels" => vsplit( Ratio(0.55), hsplit( FixedA(300.0), tabs(&[Project, EffectControls, Flowchart, History, MediaBrowser, Metadata], 0), hsplit( FixedB(300.0), tabs(&[Composition, Layer, Footage], 0), right( &[Info, Preview, Audio, Align, Character, Paragraph, LumetriScopes], &[ EffectsPresets, Properties, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, Markers, ContentAwareFill, CreateNullsFromPaths, VrCompEditor, ], ), ), ), tabs(&[Timeline, RenderQueue, Progress], 0), ), "Standard" => vsplit( Ratio(0.56), hsplit( FixedA(300.0), tabs(&[Project, EffectControls], 0), hsplit(FixedB(290.0), tabs(&[Composition, Layer], 0), right(&[Info, Audio], &[Preview, EffectsPresets])), ), tabs(&[Timeline, RenderQueue], 0), ), // Default (as in After Effects): Project/Effect Controls | Composition over the Timeline, // and a full-height right column with Preview above the Properties panel. _ => hsplit( FixedB(300.0), vsplit(Ratio(0.56), hsplit(FixedA(300.0), tabs(&[Project, EffectControls], 0), tabs(&[Composition, Layer], 0)), tabs(&[Timeline, RenderQueue], 0)), stack(&[(Preview, true, Some(46.0)), (Properties, true, None), (Align, false, None), (Audio, false, None), (EffectsPresets, false, None)]), ), } } impl DockNode { /// Every panel in the tree. pub fn panels(&self, out: &mut Vec) { match self { DockNode::Split { a, b, .. } => { a.panels(out); b.panels(out); } DockNode::Tabs { panels, .. } => out.extend(panels.iter().copied()), DockNode::Stack { entries } => out.extend(entries.iter().flat_map(|e| e.panels.iter().copied())), } } pub fn contains(&self, p: PanelKind) -> bool { let mut v = Vec::new(); self.panels(&mut v); v.contains(&p) } /// Make `p` the active tab of its group. Returns false if not present. pub fn activate(&mut self, p: PanelKind) -> bool { if !crate::dock_shared::valid_tree(self) { return false; } match self { DockNode::Split { a, b, .. } => a.activate(p) || b.activate(p), DockNode::Tabs { panels, active } => { if let Some(i) = panels.iter().position(|x| *x == p) { *active = i; true } else { false } } DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) { Some(e) => { e.show(p); e.open = true; true } None => false, }, } } /// Expand or collapse a stacked panel's frame (showing `p`). Returns false if `p` is not in /// a stack. pub fn toggle_stacked(&mut self, p: PanelKind) -> bool { if !crate::dock_shared::valid_tree(self) { return false; } match self { DockNode::Split { a, b, .. } => a.toggle_stacked(p) || b.toggle_stacked(p), DockNode::Tabs { .. } => false, DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) { Some(e) => { e.show(p); e.open = !e.open; true } None => false, }, } } pub fn is_visible(&self, p: PanelKind) -> bool { match self { DockNode::Split { a, b, .. } => a.is_visible(p) || b.is_visible(p), DockNode::Tabs { panels, active } => panels.get(*active) == Some(&p), DockNode::Stack { entries } => entries.iter().any(|e| e.open && e.shown() == Some(p)), } } /// Close a panel (remove its tab; empty groups collapse their split). pub fn close(&mut self, p: PanelKind) { if !crate::dock_shared::valid_for_close(self) { return; } if let DockNode::Split { a, b, .. } = self { a.close(p); b.close(p); let empty = |n: &DockNode| match n { DockNode::Tabs { panels, .. } => panels.is_empty(), DockNode::Stack { entries } => entries.is_empty(), DockNode::Split { .. } => false, }; if empty(a) { *self = (**b).clone(); } else if empty(b) { *self = (**a).clone(); } } else if let DockNode::Tabs { panels, active } = self { let shown = panels.get(*active).copied(); panels.retain(|x| *x != p); *active = shown.and_then(|shown| panels.iter().position(|x| *x == shown)).unwrap_or((*active).min(panels.len().saturating_sub(1))); } else if let DockNode::Stack { entries } = self { for e in entries.iter_mut() { let shown = e.shown(); e.panels.retain(|x| *x != p); e.active = shown.and_then(|shown| e.panels.iter().position(|x| *x == shown)).unwrap_or(e.active.min(e.panels.len().saturating_sub(1))); } entries.retain(|e| !e.panels.is_empty()); } } /// Add a panel as a tab next to `near` (or into the first group). pub fn open_near(&mut self, p: PanelKind, near: PanelKind) { if !crate::dock_shared::valid_tree(self) { return; } if self.contains(p) { self.activate(p); return; } fn add(n: &mut DockNode, p: PanelKind, near: PanelKind) -> bool { match n { DockNode::Split { a, b, .. } => add(a, p, near) || add(b, p, near), DockNode::Tabs { panels, active } => { if panels.contains(&near) { panels.push(p); *active = panels.len() - 1; true } else { false } } DockNode::Stack { entries } => match entries.iter().position(|e| e.contains(near)) { Some(i) => { entries.insert(i + 1, StackEntry::new(p, true, None)); true } None => false, }, } } fn add_first(n: &mut DockNode, p: PanelKind) -> bool { match n { DockNode::Split { a, b, .. } => add_first(a, p) || add_first(b, p), DockNode::Tabs { panels, active } if !panels.is_empty() => { panels.push(p); *active = panels.len() - 1; true } DockNode::Stack { entries } if !entries.is_empty() => { entries.push(StackEntry::new(p, true, None)); true } _ => false, } } if self.panel_count() == 0 { // Every panel was closed: there is no anchor, so the panel becomes the whole dock. *self = tabs(&[p], 0); } else if !add(self, p, near) && !add(self, p, PanelKind::EffectsPresets) && !add(self, p, PanelKind::Project) { // None of the anchors is docked (e.g. Minimal with Timeline closed): use the first group. add_first(self, p); } } } /// Where a dragged panel tab lands on a group: as a tab (center) or split off to one side. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Zone { Center, Left, Right, Top, Bottom, } impl Zone { pub(crate) fn shared(self) -> craft_ui::docking::Zone { match self { Self::Center => craft_ui::docking::Zone::Center, Self::Left => craft_ui::docking::Zone::Left, Self::Right => craft_ui::docking::Zone::Right, Self::Top => craft_ui::docking::Zone::Top, Self::Bottom => craft_ui::docking::Zone::Bottom, } } pub fn from_name(s: &str) -> Option { Some(match s.to_ascii_lowercase().as_str() { "center" | "tab" => Zone::Center, "left" => Zone::Left, "right" => Zone::Right, "top" => Zone::Top, "bottom" => Zone::Bottom, _ => return None, }) } } /// The drop zone for a pointer at `pos` over a group `rect` (with a `tab_h` tab strip): the tab /// strip and the middle of the group are Center; within a quarter of the size from an edge, /// the nearest edge. pub fn drop_zone(rect: Rect, tab_h: f32, pos: egui::Pos2) -> Zone { match craft_ui::docking::drop_zone(rect, tab_h, pos) { Some(craft_ui::docking::Zone::Left) => Zone::Left, Some(craft_ui::docking::Zone::Right) => Zone::Right, Some(craft_ui::docking::Zone::Top) => Zone::Top, Some(craft_ui::docking::Zone::Bottom) => Zone::Bottom, _ => Zone::Center, } } /// The shared preview region, or an empty rectangle for invalid geometry. pub fn zone_rect(rect: Rect, zone: Zone) -> Rect { craft_ui::docking::zone_rect(rect, zone.shared()).unwrap_or(Rect::NOTHING) } /// A panel group floating over the dock (undocked). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Floating { pub panels: Vec, pub active: usize, /// [x, y, w, h] in points. pub rect: [f32; 4], } /// A floating group moved out of the main window into its own native window (which can sit on /// another monitor): the group holding `panel` shows there. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct PanelWindow { pub panel: PanelKind, /// The window's [x, y] outer position and [w, h] inner size, in desktop points. pub rect: [f32; 4], } /// The smallest and largest native panel window, in points. pub const PANEL_WINDOW_MIN: [f32; 2] = [160.0, 100.0]; pub const PANEL_WINDOW_MAX: f32 = 16_384.0; /// The farthest a native panel window may sit from the desktop origin, in points. pub const PANEL_WINDOW_MAX_POS: f32 = 100_000.0; impl PanelWindow { /// The rect made safe to hand to the windowing system: finite, with a size within /// [`PANEL_WINDOW_MIN`]..=[`PANEL_WINDOW_MAX`] and a position within /// ±[`PANEL_WINDOW_MAX_POS`] (hostile workspace files and bad monitor reports included). pub fn sanitized(rect: [f32; 4]) -> [f32; 4] { let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; let [x, y, w, h] = rect; [ finite(x, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS), finite(y, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS), finite(w, 420.0).clamp(PANEL_WINDOW_MIN[0], PANEL_WINDOW_MAX), finite(h, 320.0).clamp(PANEL_WINDOW_MIN[1], PANEL_WINDOW_MAX), ] } } impl DockNode { /// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it /// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s /// frame: Center adds a tab to that frame and Top / Bottom add a frame above or below it /// (#302); Left / Right split the column. `p` is removed from its old place first. Returns /// false (tree unchanged) if `anchor` isn't docked or is `p` itself alone. pub fn dock_panel(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { if !crate::dock_shared::valid_tree(self) || p == anchor || !self.contains(anchor) { return false; } let before = self.clone(); self.close(p); fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { let here = match n { DockNode::Split { a, b, .. } => return rec(a, p, anchor, zone) || rec(b, p, anchor, zone), DockNode::Tabs { panels, .. } => panels.contains(&anchor), DockNode::Stack { entries } => entries.iter().any(|e| e.contains(anchor)), }; if !here { return false; } match (zone, &mut *n) { (Zone::Center, DockNode::Tabs { panels, active }) => { let i = panels.iter().position(|x| *x == anchor).map_or(panels.len(), |i| i + 1); panels.insert(i, p); *active = i; } (Zone::Center, DockNode::Stack { entries }) => { let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false }; let i = e.panels.iter().position(|x| *x == anchor).map_or(e.panels.len(), |i| i + 1); e.panels.insert(i, p); e.active = i; e.open = true; } (Zone::Top | Zone::Bottom, DockNode::Stack { entries }) => { let Some(i) = entries.iter().position(|e| e.contains(anchor)) else { return false }; let at = if zone == Zone::Top { i } else { i + 1 }; entries.insert(at, StackEntry::new(p, true, None)); } (z, node) => { let new = tabs(&[p], 0); let old = std::mem::replace(node, tabs(&[], 0)); let vertical = matches!(z, Zone::Top | Zone::Bottom); let (a, b) = if matches!(z, Zone::Left | Zone::Top) { (new, old) } else { (old, new) }; *node = DockNode::Split { vertical, size: SplitSize::Ratio(0.5), a: Box::new(a), b: Box::new(b) }; } } true } if rec(self, p, anchor, zone) { true } else { *self = before; false } } /// Move panel `p` into the group that holds `anchor` as a tab before `before` (one of that /// group's panels) or last, and show it. False (tree unchanged) if `anchor` isn't docked. pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option) -> bool { if !crate::dock_shared::valid_tree(self) { return false; } if p == anchor || !self.contains(anchor) { return false; } let saved = self.clone(); self.close(p); fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, before: Option) -> bool { match n { DockNode::Split { a, b, .. } => rec(a, p, anchor, before) || rec(b, p, anchor, before), DockNode::Tabs { panels, active } if panels.contains(&anchor) => { let i = before.and_then(|b| panels.iter().position(|x| *x == b)).unwrap_or(panels.len()); panels.insert(i, p); *active = i; true } DockNode::Stack { entries } => { // A tab of the anchor's frame. let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false }; let i = before.and_then(|b| e.panels.iter().position(|x| *x == b)).unwrap_or(e.panels.len()); e.panels.insert(i, p); e.active = i; e.open = true; true } _ => false, } } if rec(self, p, anchor, before) { true } else { *self = saved; false } } /// Path of the group containing `p` (as in [`Group::path`]; stacks add `sN`). pub fn path_of(&self, p: PanelKind) -> Option { fn rec(n: &DockNode, p: PanelKind, path: &str) -> Option { match n { DockNode::Split { a, b, .. } => rec(a, p, &format!("{path}a")).or_else(|| rec(b, p, &format!("{path}b"))), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| path.to_string()), DockNode::Stack { entries } => entries.iter().position(|e| e.contains(p)).map(|i| format!("{path}s{i}")), } } rec(self, p, "") } /// Number of panels in the tree. pub fn panel_count(&self) -> usize { let mut v = vec![]; self.panels(&mut v); v.len() } } /// A full layout: the docked tree plus floating panels (what a workspace saves). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Layout { pub root: DockNode, #[serde(default)] pub floating: Vec, } impl Layout { /// Apply a shared transaction without replacing the persisted app schema. pub fn apply(&mut self, action: craft_ui::docking::Action) -> Result<(), String> { crate::dock_shared::encode(&self.root, &self.floating)?.validate().map_err(|error| error.to_string())?; let moving_to_float = match &action { craft_ui::docking::Action::Float { panel, .. } => Some(*panel), craft_ui::docking::Action::Move { panel, anchor, .. } if self.floating.iter().any(|group| group.panels.contains(anchor)) => Some(*panel), _ => None, }; if moving_to_float.is_some_and(|panel| self.root.contains(panel) && self.root.panel_count() <= 1) { return Err("the last docked panel must remain in the workspace".into()); } crate::dock_shared::apply(self, action) } /// Undock an existing panel; the final docked panel stays in the workspace. pub fn float(&mut self, p: PanelKind, rect: [f32; 4]) -> bool { self.apply(craft_ui::docking::Action::Float { panel: p, rect }).is_ok() } /// Close an existing floating panel and collapse its empty group. pub fn unfloat(&mut self, p: PanelKind) -> bool { crate::dock_shared::unfloat(self, p) } /// Move or reorder a docked/floating tab through the shared transaction. pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option) -> bool { if !crate::dock_shared::valid_tree(&self.root) { return false; } self.apply(craft_ui::docking::Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Tab { before } }).is_ok() } /// Dock a panel beside an anchor; floating destinations accept tabs only. pub fn dock(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { if !crate::dock_shared::valid_tree(&self.root) { return false; } self.apply(craft_ui::docking::Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Split(zone.shared()) }).is_ok() } pub fn contains(&self, p: PanelKind) -> bool { self.root.contains(p) || self.floating.iter().any(|group| group.panels.contains(&p)) } } /// One laid-out tab group. pub struct Group { pub path: String, pub rect: Rect, pub content: Rect, pub panels: Vec, pub active: usize, /// In a [`DockNode::Stack`]: whether the panel is expanded. pub stacked: Option, } /// Actions produced by interacting with the dock chrome. #[derive(Debug, Clone, PartialEq)] pub enum DockAction { Activate(PanelKind), /// Expand/collapse a stacked panel. ToggleStacked(PanelKind), Focus(PanelKind), Close(PanelKind), PanelMenu(PanelKind, egui::Pos2), /// A tab started being dragged (re-dock or undock). BeginDrag(PanelKind), /// The tab's lock was toggled (Composition / Timeline viewer lock). ToggleLock(PanelKind), /// A document tab of a panel was clicked (the Timeline's tab of comp `id`). ActivateDoc(PanelKind, u64), /// A document tab's close button (or middle click): close that document (comp `id`). CloseDoc(PanelKind, u64), } /// One document a panel shows as a tab of its own (the Timeline: one tab per open comp, as in /// After Effects). #[derive(Debug, Clone, PartialEq)] pub struct DocTab { pub id: u64, pub title: String, pub deco: Option, /// The document the panel shows now. pub active: bool, } /// What a group's tabs show: labels, the comp swatch / close / lock decorations and the panels /// that show one tab per document. pub struct TabInfo<'a> { pub title: &'a dyn Fn(PanelKind) -> String, pub decos: &'a [(PanelKind, TabDeco)], pub docs: &'a [(PanelKind, Vec)], } /// What drawing a group's chrome produced: actions and where each panel's tabs are (a panel with /// document tabs spans all of them; tabs hidden behind the overflow chevron are left out). #[derive(Default)] pub struct Chrome { pub actions: Vec, pub tabs: Vec<(PanelKind, Rect)>, } /// After Effects-style extras on a tab that shows a composition (Composition, Timeline): a close /// button (×), the item's label colour as a small square and the viewer lock. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TabDeco { pub swatch: egui::Color32, pub locked: bool, /// Close button and lock (Composition, Timeline); Effect Controls shows just the swatch. pub viewer: bool, } /// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging. pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, out: &mut Vec, reg: &mut crate::automation::Registry) { let mut actions = Vec::new(); layout_with_actions(ui, node, rect, t, path, out, reg, &mut actions); for action in actions { crate::dock_shared::apply_tree(node, action); } } /// Lay out chrome while returning mutations for the application's UI command route. pub fn layout_with_actions( ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, out: &mut Vec, reg: &mut crate::automation::Registry, actions: &mut Vec>, ) { layout_inner(ui, node, rect, t, path, &[], out, reg, actions); } fn layout_inner( ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, tree_path: &[bool], out: &mut Vec, reg: &mut crate::automation::Registry, actions: &mut Vec>, ) { match node { DockNode::Tabs { panels, active } => { let tab_h = if panels.len() == 1 && panels[0].compact() { 8.0 } else { t.tab_h }; let content = Rect::from_min_max(pos2(rect.min.x, rect.min.y + tab_h), rect.max); out.push(Group { path: path.to_string(), rect, content, panels: panels.clone(), active: *active, stacked: None }); } DockNode::Stack { entries } => { // A header for every entry; the open panels' bodies share the rest. let head = t.tab_h; let g = t.gap; let n = entries.len() as f32; let avail = rect.height() - n * head - (n - 1.0).max(0.0) * g; let bodies = stack_bodies(entries, avail); let mut y = rect.min.y; let mut drag = None; for (i, e) in entries.iter().enumerate() { let body = bodies.get(i).copied().unwrap_or(0.0); let r = Rect::from_min_max(pos2(rect.min.x, y), pos2(rect.max.x, (y + head + body).min(rect.max.y))); let content = Rect::from_min_max(pos2(r.min.x, r.min.y + head), r.max); let group = format!("{path}s{i}"); // The gap below a panel resizes the open panels on either side of it. let above = entries.get(..=i).and_then(|s| s.iter().rposition(|x| x.open)); let below = entries.get(i + 1..).and_then(|s| s.iter().position(|x| x.open)).map(|k| i + 1 + k); if let (Some(above), Some(below)) = (above, below) { let gap = Rect::from_min_max(pos2(rect.min.x, r.max.y), pos2(rect.max.x, r.max.y + g)); if let Some(d) = gutter(ui, gap, true, &group, t, reg) { drag = Some((above, below, d)); } } out.push(Group { path: group, rect: r, content, panels: e.panels.clone(), active: e.active, stacked: Some(e.open) }); y = r.max.y + g; } if let Some((above, below, d)) = drag { let mut proposed = entries.clone(); resize_stacked(&mut proposed, &bodies, above, below, d); for (before, after) in entries.iter().zip(proposed) { if before.height != after.height && let Some(panel) = after.shown() { actions.push(craft_ui::docking::Action::ResizeStack { panel, height: after.height }); } } } } DockNode::Split { vertical, size, a, b } => { let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return }; let response = resize_handle(split.divider, *vertical, path, t).value(split.first_size(), 0.0..=split.available()).show(ui); reg.add(&format!("dock.gutter.{path}"), response.response.rect, "gutter"); if response.delta != 0.0 && let Some(resized) = split.resized(size.shared(), response.delta, 40.0) { actions.push(craft_ui::docking::Action::ResizeSplit { path: tree_path.to_vec(), size: resized }); } let mut first_path = tree_path.to_vec(); first_path.push(false); let mut second_path = tree_path.to_vec(); second_path.push(true); layout_inner(ui, a, split.first, t, &format!("{path}a"), &first_path, out, reg, actions); layout_inner(ui, b, split.second, t, &format!("{path}b"), &second_path, out, reg, actions); } } } /// A gap between dock areas that drags to resize them (`vertical`: up and down), with a slightly /// larger hit area than the gap. Returns the drag this frame. fn gutter(ui: &mut egui::Ui, gap: Rect, vertical: bool, path: &str, t: &Tokens, reg: &mut crate::automation::Registry) -> Option { let response = resize_handle(gap, vertical, path, t).show(ui); reg.add(&format!("dock.gutter.{path}"), response.response.rect, "gutter"); (response.delta != 0.0).then_some(response.delta) } fn resize_handle(gap: Rect, vertical: bool, path: &str, t: &Tokens) -> ResizeHandle<'static> { let axis = if vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; let style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus }; let label = if vertical { crate::i18n::tr("Height:") } else { crate::i18n::tr("Width:") }; ResizeHandle::new(egui::Id::new(("dock-gutter", path.to_string())), axis, gap, label, style) } /// The least body height of an open stacked panel (when the stack has room for it). const STACK_MIN: f32 = 40.0; /// An open stacked panel without a height of its own: it shares the room the others leave. fn flexible(e: &StackEntry) -> bool { e.open && !e.height.is_some_and(f32::is_finite) } /// The body heights of a stack's panels in `avail` points (headers and gaps taken out). Open /// panels with a height keep it and the others share the rest; when no panel takes the rest, or /// there is too little room, the heights grow or shrink in proportion (down to [`STACK_MIN`]) so /// the open panels fill the stack. fn stack_bodies(entries: &[StackEntry], avail: f32) -> Vec { let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 }; let open = entries.iter().filter(|e| e.open).count(); let min = STACK_MIN.min(avail / open.max(1) as f32); let fixed: Vec = entries.iter().filter(|e| e.open && !flexible(e)).filter_map(|e| e.height).map(|h| h.max(min)).collect(); let flex = open.saturating_sub(fixed.len()); let k = fixed.len() as f32; // What the panels with a height may take, and how much of it lies above their minimum. let room = avail - flex as f32 * min; let sum: f32 = fixed.iter().sum(); let (excess, target) = (sum - k * min, (room - k * min).max(0.0)); let fit = flex == 0 || sum > room; let size = |h: f32| match (fit, excess > 0.0) { (false, _) => h, (true, true) => min + (h - min) / excess * target, (true, false) => min + target / k.max(1.0), }; let used: f32 = fixed.iter().map(|&h| size(h)).sum(); let share = (avail - used).max(0.0) / flex.max(1) as f32; entries .iter() .map(|e| match (e.open, flexible(e)) { (false, _) => 0.0, (true, true) => share, (true, false) => size(e.height.unwrap_or(min).max(min)), }) .collect() } /// `v` held to `lo..=hi`, never panicking: when the room is too small for both minimums /// (`hi < lo`), `lo` wins; a non-finite `v` gives `lo`. #[cfg(test)] fn clamp_ordered(v: f32, lo: f32, hi: f32) -> f32 { let lo = if lo.is_finite() { lo } else { 0.0 }; let hi = if hi.is_finite() { hi.max(lo) } else { lo }; if v.is_finite() { v.max(lo).min(hi) } else { lo } } /// The size of a split's first side in `avail` points (both sides keep 20 pt when there is room). /// `f32::clamp` panics when its bounds cross, which a split shorter than 40 pt did (#306). #[cfg(test)] fn split_first(size: SplitSize, avail: f32) -> f32 { let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 }; let first = match size { SplitSize::Ratio(r) => avail * r, SplitSize::FixedA(px) => px.min(avail - 20.0), SplitSize::FixedB(px) => avail - px.min(avail - 20.0), }; let lo = 20.0_f32.min(avail); clamp_ordered(first, lo, avail - 20.0).min(avail) } /// Drag the gap between the open stacked panels `above` and `below` (laid out at `bodies`) by /// `d` points: one grows as much as the other shrinks, neither below [`STACK_MIN`]. The new sizes /// become the panels' heights, except that the last panel without one keeps sharing the rest /// (so the stack still fills its column when the window is resized). fn resize_stacked(entries: &mut [StackEntry], bodies: &[f32], above: usize, below: usize, d: f32) { let (Some(&a), Some(&b)) = (bodies.get(above), bodies.get(below)) else { return }; let pair = a + b; let lo = STACK_MIN.min(pair / 2.0); let na = (a + d).max(lo).min(pair - lo); for (i, h) in [(above, na), (below, pair - na)] { let last_flexible = entries.iter().filter(|e| flexible(e)).count() == 1; if let Some(e) = entries.get_mut(i) && !(last_flexible && flexible(e)) { e.height = Some(h); } } } /// Draw a group's frame + tab strip: one tab per panel, or per document for panels that show /// several (the Timeline's comps). Tab labels may name the comp or layer they show (After /// Effects style). pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut crate::automation::Registry, info: &TabInfo) -> Chrome { struct Entry { panel: PanelKind, doc: Option, label: String, deco: Option, /// The group's shown tab. active: bool, /// The document its panel shows (even behind another tab). shown_doc: bool, } let mut out = Chrome::default(); let painter = ui.painter().clone(); painter.rect_filled(g.rect, t.radius, t.panel_bg); let active_panel = g.panels.get(g.active).copied(); let compact = g.panels.len() == 1 && g.panels[0].compact(); if compact { // grip dots let c = pos2(g.rect.center().x, g.rect.min.y + 4.0); for dx in [-4.0, 0.0, 4.0] { painter.circle_filled(c + vec2(dx, 0.0), 1.0, t.text_faint); } } else { let mut entries: Vec = vec![]; for (pi, p) in g.panels.iter().enumerate() { let shown = pi == g.active; match info.docs.iter().find(|(k, d)| k == p && !d.is_empty()) { Some((_, docs)) => entries.extend(docs.iter().map(|d| Entry { panel: *p, doc: Some(d.id), label: d.title.clone(), deco: d.deco, active: shown && d.active, shown_doc: d.active, })), None => entries.push(Entry { panel: *p, doc: None, label: (info.title)(*p), deco: info.decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d), active: shown, shown_doc: false, }), } } let ai = entries.iter().position(|e| e.active).unwrap_or(0); let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h)); if t.gradients { // Settings ▸ Appearance ▸ Use Gradients. crate::theme::gradient_rect(&painter, strip.shrink2(vec2(t.radius, 0.0)), t.grad_top(t.panel_bg), t.panel_bg); } let activate = |e: &Entry| match e.doc { Some(id) => DockAction::ActivateDoc(e.panel, id), None => DockAction::Activate(e.panel), }; let close = |e: &Entry| match e.doc { Some(id) => DockAction::CloseDoc(e.panel, id), None => DockAction::Close(e.panel), }; let mut x = strip.min.x + 12.0; let text_y = strip.min.y + 16.0; for (k, e) in entries.iter().enumerate() { let p = &e.panel; // A collapsed stacked panel's header is plain text (no underline or panel menu). let is_active = k == ai && g.stacked != Some(false); let galley = painter.layout_no_wrap(e.label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); let menu_w = if is_active { 20.0 } else { 0.0 }; // The lock belongs to the panel: on its shown document's tab only. let lock = e.deco.is_some_and(|d| d.viewer) && (e.doc.is_none() || is_active); // × (active tab only), swatch and lock before the label. let deco_w = match e.deco { Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + if lock { 16.0 } else { 0.0 }, Some(_) => 14.0, None => 0.0, }; let w = galley.size().x + 16.0 + menu_w + deco_w; if x + w > strip.max.x - 20.0 && k > ai { let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0)); let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, t.tab_text); icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, t.tab_text); crate::widgets::focus_outline(ui, &resp, t); reg.add_response(&format!("panel.overflow.{}", g.path), &resp, "More tabs"); if resp.clicked() && let Some(next) = entries.get((ai + 1) % entries.len()) { out.actions.push(activate(next)); } break; } let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h)); match out.tabs.iter_mut().find(|(q, _)| q == p) { Some((_, r)) => *r = r.union(tab), None => out.tabs.push((*p, tab)), } let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), k)), Sense::click_and_drag()); if resp.drag_started() { out.actions.push(DockAction::BeginDrag(*p)); } // A document tab also answers to the panel's id while it is the shown one. let auto_id = match e.doc { Some(id) => format!("panel.tab.{}.{id}", p.id()), None => format!("panel.tab.{}", p.id()), }; reg.add_response(&auto_id, &resp, &e.label); if e.shown_doc { reg.add_response(&format!("panel.tab.{}", p.id()), &resp, &e.label); } let mut label_x = tab.min.x + 8.0; if let Some(d) = e.deco { let mut dx = tab.min.x + 6.0; if is_active && d.viewer { let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0)); let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), k)), Sense::click()); let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; let kk = 3.5; painter.line_segment([cr.center() + vec2(-kk, -kk), cr.center() + vec2(kk, kk)], Stroke::new(1.2, cc)); painter.line_segment([cr.center() + vec2(-kk, kk), cr.center() + vec2(kk, -kk)], Stroke::new(1.2, cc)); reg.add_response(&format!("panel.tab.{}.close", p.id()), &cresp, "Close"); crate::widgets::focus_outline(ui, &cresp, t); if cresp.clicked() { out.actions.push(close(e)); } dx += 16.0; } let sw = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(9.0, 9.0)); painter.rect_filled(sw, 1.0, d.swatch); dx += 14.0; label_x = dx; if lock { let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0)); let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), k)), Sense::click()); let lc = if d.locked { t.tab_text_active } else if lresp.hovered() { t.tab_text } else { t.text_faint }; icons::paint(&painter, lr, Icon::Lock, lc); reg.add_response(&format!("panel.tab.{}.lock", p.id()), &lresp, if d.locked { "Unlock" } else { "Lock" }); crate::widgets::focus_outline(ui, &lresp, t); if lresp.clicked() { out.actions.push(DockAction::ToggleLock(*p)); } label_x = dx + 16.0; } } let label_w = galley.size().x; let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text }; painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col); if is_active { let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0)); let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click()); reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu"); let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text }; for dy in [-3.5, 0.0, 3.5] { painter.line_segment([pos2(mr.min.x, mr.center().y + dy), pos2(mr.max.x, mr.center().y + dy)], Stroke::new(1.5, mc)); } let uy = strip.min.y + 23.0; painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active)); crate::widgets::focus_outline(ui, &mresp, t); if mresp.clicked() { out.actions.push(DockAction::PanelMenu(*p, mr.left_bottom())); } } crate::widgets::focus_outline(ui, &resp, t); if resp.clicked() { // A stacked frame's shown tab expands / collapses it; another tab shows that // panel (expanding the frame). if g.stacked.is_some() && (k == ai || entries.len() == 1) { out.actions.push(DockAction::ToggleStacked(*p)); } else { out.actions.push(activate(e)); } out.actions.push(DockAction::Focus(*p)); } if resp.middle_clicked() { out.actions.push(close(e)); } x += w + 8.0; } // A stacked panel's whole header bar expands/collapses it, not just its name (as in After // Effects): the frame with its shown tab. if g.stacked.is_some() && let Some(e) = entries.get(ai).or(entries.first()) { let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max); if rest.width() > 0.0 { let resp = ui.interact(rest, egui::Id::new(("tab-rest", g.path.clone())), Sense::click()); reg.add(&format!("panel.header.{}", e.panel.id()), rest, &e.label); if resp.clicked() { out.actions.push(DockAction::ToggleStacked(e.panel)); out.actions.push(DockAction::Focus(e.panel)); } } } } // clicking anywhere in the panel focuses it if let Some(p) = active_panel && ui.rect_contains_pointer(g.rect) && ui.input(|i| i.pointer.any_pressed()) { out.actions.push(DockAction::Focus(p)); } out } /// Placeholder body for panels that are not implemented yet. pub fn placeholder(ui: &mut egui::Ui, rect: Rect, t: &Tokens, text: &str) { ui.painter().text(rect.center(), Align2::CENTER_CENTER, text, Tokens::ui(12.0), t.text_faint); } #[cfg(test)] mod tests { use super::*; /// The original split clamp panicked whenever 0 < available < 40. Run the actual dock /// layout without the app's last-resort panic guard so that regression cannot be hidden. #[test] fn tiny_split_layouts_stay_inside_their_parent() { let ctx = egui::Context::default(); let tokens = Tokens::for_kind(crate::theme::ThemeKind::Dark); for extent in [20.0, 0.0, 1.0, 5.0, 15.0, 35.0, 39.9, 40.0, 80.0] { for vertical in [false, true] { for size in [SplitSize::Ratio(0.5), SplitSize::FixedA(300.0), SplitSize::FixedB(300.0)] { let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::Vec2::splat(extent)); let mut node = DockNode::Split { vertical, size, a: Box::new(tabs(&[PanelKind::Project], 0)), b: Box::new(tabs(&[PanelKind::Composition], 0)) }; let mut groups = vec![]; let mut registry = crate::automation::Registry::default(); let mut output = ctx.run_ui(egui::RawInput::default(), |ui| layout(ui, &mut node, rect, &tokens, "", &mut groups, &mut registry)); output.textures_delta.clear(); assert_eq!(groups.len(), 2); for group in groups { assert!(rect.contains_rect(group.rect), "{extent}, {vertical}: {:?}", group.rect); assert!(group.rect.width() >= 0.0 && group.rect.height() >= 0.0); } } } } } #[test] fn open_near_in_an_empty_dock_makes_the_panel_the_dock() { for p in [PanelKind::Composition, PanelKind::Timeline, PanelKind::Project] { let mut d = tabs(&[], 0); d.open_near(p, PanelKind::EffectsPresets); assert!(d.is_visible(p)); assert_eq!(d.panel_count(), 1); } } #[test] fn open_near_without_any_anchor_uses_the_first_group() { let mut d = tabs(&[PanelKind::Composition], 0); d.open_near(PanelKind::Timeline, PanelKind::EffectsPresets); assert!(d.is_visible(PanelKind::Timeline)); assert_eq!(d.panel_count(), 2); } #[test] fn stacked_panels_toggle_open_close() { use PanelKind::*; let mut d = stack(&[(Preview, true, Some(46.0)), (Properties, true, None), (Align, false, None)]); assert!(d.is_visible(Properties) && !d.is_visible(Align)); assert!(d.toggle_stacked(Align)); assert!(d.is_visible(Align)); assert!(d.toggle_stacked(Align) && !d.is_visible(Align)); // Showing a collapsed panel expands it; opening a new panel near one inserts after it. assert!(d.activate(Align) && d.is_visible(Align)); d.open_near(Info, Properties); let mut v = vec![]; d.panels(&mut v); assert_eq!(v, [Preview, Properties, Info, Align]); d.close(Preview); assert!(!d.contains(Preview)); assert!(!d.toggle_stacked(Timeline)); } fn entry(panel: PanelKind, open: bool, height: Option) -> StackEntry { StackEntry::new(panel, open, height) } fn close_to(a: &[f32], b: &[f32]) -> bool { a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3) } #[test] fn stacked_panels_fill_their_column() { use PanelKind::*; // A panel with a height keeps it; the open ones without share the rest. let s = [entry(Preview, true, Some(100.0)), entry(Properties, true, None), entry(Align, false, None), entry(Audio, true, None)]; assert!(close_to(&stack_bodies(&s, 500.0), &[100.0, 200.0, 0.0, 200.0])); // Too little room: the heights shrink so the others keep their minimum. let s = [entry(Preview, true, Some(500.0)), entry(Properties, true, None)]; assert!(close_to(&stack_bodies(&s, 300.0), &[260.0, STACK_MIN])); // Nothing to take the rest: the heights grow in proportion (above the minimum) to fill. let s = [entry(Preview, true, Some(100.0)), entry(Properties, true, Some(300.0)), entry(Align, false, Some(80.0))]; assert!(close_to(&stack_bodies(&s, 600.0), &[137.5, 462.5, 0.0])); let s = [entry(Preview, true, Some(46.0)), entry(Properties, false, None)]; assert!(close_to(&stack_bodies(&s, 600.0), &[600.0, 0.0])); // Hostile sizes never make a body negative or non-finite. for avail in [-50.0, 0.0, 10.0, f32::NAN, f32::INFINITY] { for h in [None, Some(-5.0), Some(f32::NAN), Some(f32::INFINITY), Some(1e30), Some(f32::MAX)] { let s = [entry(Preview, true, h), entry(Properties, true, Some(60.0)), entry(Align, false, h)]; let b = stack_bodies(&s, avail); assert!(b.iter().all(|x| x.is_finite() && *x >= 0.0), "{avail} {h:?}: {b:?}"); } } assert!(stack_bodies(&[], 100.0).is_empty()); } #[test] fn dragging_a_stack_gap_trades_height_between_its_neighbours() { use PanelKind::*; let mut s = vec![entry(Preview, true, Some(46.0)), entry(Properties, true, None), entry(Align, false, None)]; let bodies = stack_bodies(&s, 800.0); resize_stacked(&mut s, &bodies, 0, 1, 100.0); // Preview keeps its new height; Properties, the only panel without one, takes the rest. assert_eq!(s[0].height, Some(146.0)); assert_eq!(s[1].height, None); assert!(close_to(&stack_bodies(&s, 800.0), &[146.0, 654.0, 0.0])); // Neither side goes below the minimum. for (d, want) in [(-1000.0, STACK_MIN), (1000.0, 800.0 - STACK_MIN)] { let bodies = stack_bodies(&s, 800.0); resize_stacked(&mut s, &bodies, 0, 1, d); assert!(close_to(&stack_bodies(&s, 800.0)[..1], &[want]), "{d}"); } // Two sharing panels: the upper one gets a height, the lower one keeps sharing. let mut s = vec![entry(Preview, true, None), entry(Align, false, None), entry(Properties, true, None)]; let bodies = stack_bodies(&s, 400.0); resize_stacked(&mut s, &bodies, 0, 2, 50.0); assert_eq!((s[0].height, s[2].height), (Some(250.0), None)); assert!(close_to(&stack_bodies(&s, 400.0), &[250.0, 0.0, 150.0])); // Out-of-range indices change nothing. let before = s.clone(); resize_stacked(&mut s, &bodies, 0, 9, 50.0); assert_eq!(s, before); } #[test] fn default_workspace_has_ae_right_column_stack() { let d = workspace("Default"); assert!(d.is_visible(PanelKind::Properties) && d.is_visible(PanelKind::Preview)); assert!(d.contains(PanelKind::EffectsPresets) && !d.is_visible(PanelKind::EffectsPresets)); } #[test] fn workspaces_contain_core_panels() { for w in WORKSPACES { let d = workspace(w); let floating: Vec = workspace_floating(w).into_iter().flat_map(|f| f.panels).collect(); assert!(d.contains(PanelKind::Timeline) || floating.contains(&PanelKind::Timeline), "{w}"); assert!(d.contains(PanelKind::Composition) || d.contains(PanelKind::Layer), "{w}"); // A panel is in one place only. let mut all = vec![]; d.panels(&mut all); all.extend(floating); let n = all.len(); all.sort_by_key(|p| p.id()); all.dedup(); assert_eq!(all.len(), n, "{w} has a panel twice"); } } #[test] fn after_effects_workspaces_exist() { // Window ▸ Workspace in After Effects 2026, less Libraries (an Adobe service). for w in [ "Default", "Review", "Learn", "Small Screen", "Standard", "All Panels", "Animation", "Essential Graphics", "Color", "Effects", "Minimal", "Motion Tracking", "Paint", "Text", "Undocked Panels", ] { assert!(WORKSPACES.contains(&w), "{w}"); } assert!(!WORKSPACES.contains(&"Libraries")); // Layouts differ from Default (except Learn, which is Default plus the Home screen). let def = workspace("Default"); for w in WORKSPACES.iter().filter(|w| !matches!(**w, "Default" | "Learn")) { assert_ne!(workspace(w), def, "{w} falls back to Default"); } assert!(workspace("Essential Graphics").contains(PanelKind::Properties)); assert!(workspace("Color").contains(PanelKind::EffectControls)); assert!(!workspace_floating("Undocked Panels").is_empty()); // Every built-in workspace is in Window ▸ Workspace. let menu: Vec = effectcraft_engine::menus::entries() .into_iter() .filter(|(_, e)| e.command == "window.workspace") .filter_map(|(_, e)| e.params.get("name").and_then(|v| v.as_str()).map(str::to_string)) .collect(); for w in WORKSPACES { assert!(menu.iter().any(|m| m == w), "{w} missing from Window > Workspace"); } } #[test] fn short_splits_lay_out_without_panicking() { // #306: a split 0 < avail < 40 pt tall made `f32::clamp` panic (its bounds crossed). for avail in [0.0, 5.0, 19.0, 20.0, 30.0, 39.0, 40.0, 41.0, -10.0, f32::NAN, f32::INFINITY] { for size in [SplitSize::Ratio(0.5), SplitSize::Ratio(f32::NAN), SplitSize::FixedA(300.0), SplitSize::FixedB(300.0), SplitSize::FixedA(-5.0)] { let f = split_first(size, avail); assert!(f.is_finite() && f >= 0.0, "{size:?} {avail}: {f}"); if avail.is_finite() && avail > 0.0 { assert!(f <= avail, "{size:?} {avail}: {f}"); } } } assert_eq!(split_first(SplitSize::FixedA(300.0), 30.0), 20.0); assert_eq!(split_first(SplitSize::Ratio(0.5), 200.0), 100.0); assert_eq!(clamp_ordered(5.0, 40.0, -4.0), 40.0); // Every workspace, laid out in rects too short and too narrow for its minimums. let ctx = egui::Context::default(); let t = Tokens::for_kind(crate::theme::ThemeKind::Dark); for w in WORKSPACES { for (wd, ht) in [(30.0, 30.0), (800.0, 36.0), (800.0, 45.0), (36.0, 600.0), (1.0, 1.0), (0.0, 0.0)] { let mut node = workspace(w); let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { let mut out = vec![]; let mut reg = crate::automation::Registry::default(); layout(ui, &mut node, Rect::from_min_size(pos2(0.0, 0.0), vec2(wd, ht)), &t, "", &mut out, &mut reg); assert!(!out.is_empty(), "{w}"); }); frame.textures_delta.clear(); } } } #[test] fn drop_zone_math() { let r = Rect::from_min_size(pos2(100.0, 100.0), vec2(400.0, 300.0)); let tab = 28.0; assert_eq!(drop_zone(r, tab, pos2(300.0, 110.0)), Zone::Center, "tab strip"); assert_eq!(drop_zone(r, tab, r.center()), Zone::Center); assert_eq!(drop_zone(r, tab, pos2(120.0, 250.0)), Zone::Left); assert_eq!(drop_zone(r, tab, pos2(490.0, 250.0)), Zone::Right); assert_eq!(drop_zone(r, tab, pos2(300.0, 140.0)), Zone::Top); assert_eq!(drop_zone(r, tab, pos2(300.0, 390.0)), Zone::Bottom); // Corners go to the nearer edge. assert_eq!(drop_zone(r, tab, pos2(105.0, 395.0)), Zone::Left); assert_eq!(zone_rect(r, Zone::Right), Rect::from_min_max(pos2(300.0, 100.0), r.max)); assert_eq!(zone_rect(r, Zone::Top).height(), 150.0); assert_eq!(zone_rect(r, Zone::Center), r); } #[test] fn dock_panel_splits_and_inserts_tabs() { use PanelKind::*; let mut d = workspace("Standard"); let n = d.panel_count(); // Tab next to an anchor. assert!(d.dock_panel(Info, Timeline, Zone::Center)); assert_eq!(d.panel_count(), n); let p = d.path_of(Timeline).unwrap(); assert_eq!(d.path_of(Info).unwrap(), p); assert!(d.is_visible(Info)); // Split to the right of the Composition group: a new single-tab group beside it. assert!(d.dock_panel(Info, Composition, Zone::Right)); let comp_path = d.path_of(Composition).unwrap(); let info_path = d.path_of(Info).unwrap(); assert_eq!(comp_path.len(), info_path.len()); assert!(comp_path.ends_with('a') && info_path.ends_with('b')); assert!(d.is_visible(Info)); // Top: the new group comes first in a vertical split. assert!(d.dock_panel(Audio, Timeline, Zone::Top)); assert!(d.path_of(Audio).unwrap().ends_with('a')); // Dropping a panel on itself or an undocked anchor changes nothing. let before = d.clone(); assert!(!d.dock_panel(Audio, Audio, Zone::Left)); assert!(!d.dock_panel(Audio, Tracker, Zone::Left)); assert_eq!(d, before); // Into a stack: a tab of the anchor's frame (#302). let mut s = workspace("Default"); let props = s.path_of(Properties).unwrap(); assert!(s.dock_panel(Tracker, Properties, Zone::Center)); assert_eq!(s.path_of(Tracker), Some(props.clone())); assert!(s.is_visible(Tracker) && !s.is_visible(Properties)); assert!(s.activate(Properties) && s.is_visible(Properties)); // Top / Bottom: a frame of its own above / below the anchor's, not a split column. assert!(s.dock_panel(Info, Properties, Zone::Bottom)); let below = s.path_of(Info).unwrap(); assert_ne!(below, props); assert_eq!(s.path_of(Properties), Some(props.clone())); assert!(s.dock_panel(Info, Properties, Zone::Top)); assert_eq!(s.path_of(Info), Some(props.clone()), "above Properties: its frame's index"); // Closing a tab keeps the frame; closing the frame's last tab removes it. s.close(Tracker); assert!(s.contains(Properties) && !s.contains(Tracker)); // A tab dragged onto a stacked frame's tab strip joins that frame. let mut t = workspace("Default"); assert!(t.insert_tab(Info, Preview, None)); assert_eq!(t.path_of(Info), t.path_of(Preview)); // Moving the only panel of a group collapses its split. let mut m = hsplit(SplitSize::Ratio(0.5), tabs(&[Project], 0), tabs(&[Composition], 0)); assert!(m.dock_panel(Project, Composition, Zone::Center)); assert_eq!(m, tabs(&[Composition, Project], 1)); } /// #302: workspaces saved when each stacked frame held one `panel` still load. #[test] fn old_stack_entries_load() { let e: StackEntry = serde_json::from_str(r#"{"panel":"Preview","open":true,"height":46.0}"#).unwrap(); assert_eq!(e, StackEntry::new(PanelKind::Preview, true, Some(46.0))); let s = serde_json::to_string(&workspace("Default")).unwrap(); assert_eq!(serde_json::from_str::(&s).unwrap(), workspace("Default")); } #[test] fn floating_layout_round_trips() { use PanelKind::*; let mut l = Layout { root: workspace("Standard"), floating: vec![] }; assert!(l.float(Info, [200.0, 150.0, 320.0, 240.0])); assert!(!l.root.contains(Info) && l.contains(Info)); // Tab another panel into the floating group, then dock it back. assert!(l.dock(Audio, Info, Zone::Center)); assert_eq!(l.floating[0].panels, [Info, Audio]); assert!(!l.dock(Audio, Info, Zone::Left), "floating groups only take tabs"); assert!(l.dock(Info, Timeline, Zone::Bottom)); assert_eq!(l.floating[0].panels, [Audio]); assert!(l.root.contains(Info)); let s = serde_json::to_string(&l).unwrap(); let back: Layout = serde_json::from_str(&s).unwrap(); assert_eq!(back, l); // A workspace saved before floating panels existed still loads. let old: Layout = serde_json::from_str(&format!("{{\"root\": {}}}", serde_json::to_string(&l.root).unwrap())).unwrap(); assert!(old.floating.is_empty()); // The last docked panel can't float. let mut one = Layout { root: tabs(&[Timeline], 0), floating: vec![] }; assert!(!one.float(Timeline, [0.0; 4])); assert!(l.unfloat(Audio) && l.floating.is_empty()); } #[test] fn close_and_open() { let mut d = workspace("Default"); d.close(PanelKind::Paragraph); assert!(!d.contains(PanelKind::Paragraph)); d.open_near(PanelKind::Tracker, PanelKind::EffectsPresets); assert!(d.is_visible(PanelKind::Tracker)); let s = serde_json::to_string(&d).unwrap(); let back: DockNode = serde_json::from_str(&s).unwrap(); assert_eq!(back, d); assert_eq!(PanelKind::from_name("effects & presets"), Some(PanelKind::EffectsPresets)); } /// Ported from #630 (Ni-zav): the reporter's reproduction on the Minimal workspace. #[test] fn window_panels_reopen_after_last_panel_is_closed() { use PanelKind::*; for reopen in [Composition, Timeline, Project] { let mut d = workspace("Minimal"); d.close(Composition); d.close(Timeline); assert_eq!(d.panel_count(), 0); d.open_near(reopen, EffectsPresets); assert_eq!(d.panel_count(), 1); assert!(d.is_visible(reopen), "{reopen:?} must be visible"); } let mut d = tabs(&[Audio], 0); d.open_near(History, Project); assert_eq!(d, tabs(&[Audio, History], 1)); let mut d = workspace("Minimal"); d.open_near(EffectsPresets, Timeline); assert_eq!(d.path_of(EffectsPresets), d.path_of(Timeline)); } #[test] fn closing_earlier_inactive_tab_keeps_the_shown_panel() { use PanelKind::*; let mut d = tabs(&[Properties, Align, EffectsPresets], 1); d.close(Properties); assert!(d.is_visible(Align), "closing an inactive tab keeps the shown panel"); assert!(!d.is_visible(EffectsPresets)); assert_eq!(d.panel_count(), 2); } #[test] fn closing_active_tab_keeps_existing_neighbor_choice() { use PanelKind::*; for (active, closed, shown) in [(0, Properties, Align), (1, Align, EffectsPresets), (2, EffectsPresets, Align)] { let mut d = tabs(&[Properties, Align, EffectsPresets], active); d.close(closed); assert!(d.is_visible(shown), "active {active}: {closed:?} -> {shown:?}"); } } #[test] fn close_normalizes_invalid_active_and_collapses_empty_splits() { use PanelKind::*; let mut d = tabs(&[Properties, Align, EffectsPresets], usize::MAX); d.close(History); assert!(d.is_visible(EffectsPresets)); d.close(Properties); assert!(d.is_visible(EffectsPresets)); d.close(EffectsPresets); assert!(d.is_visible(Align)); d.close(Align); assert_eq!(d, tabs(&[], 0)); let mut d = DockNode::Split { vertical: true, size: SplitSize::Ratio(0.5), a: Box::new(tabs(&[Properties], 0)), b: Box::new(tabs(&[Align], 0)) }; d.close(Properties); assert_eq!(d, tabs(&[Align], 0)); d.close(Align); assert_eq!(d, tabs(&[], 0)); } #[test] fn removing_earlier_inactive_floating_tab_keeps_the_shown_panel() { use PanelKind::*; let mut layout = Layout { root: tabs(&[Composition], 0), floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active: 1, rect: [0.0, 0.0, 300.0, 200.0] }], }; assert!(layout.unfloat(Properties)); let f = &layout.floating[0]; assert_eq!(f.panels.get(f.active), Some(&Align)); assert!(layout.root.is_visible(Composition)); } #[test] fn floating_removal_keeps_active_neighbor_and_handles_invalid_index() { use PanelKind::*; for (active, closed, shown) in [(0, Properties, Align), (1, Align, EffectsPresets), (2, EffectsPresets, Align), (usize::MAX, Properties, EffectsPresets)] { let mut layout = Layout { root: tabs(&[Composition], 0), floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active, rect: [0.0, 0.0, 300.0, 200.0] }], }; assert!(layout.unfloat(closed)); let f = &layout.floating[0]; assert_eq!(f.panels.get(f.active), Some(&shown)); for p in [Properties, Align, EffectsPresets] { layout.unfloat(p); } assert!(layout.floating.is_empty()); assert!(!layout.unfloat(History)); } } }