UI-1: drag-to-dock with drop zones, floating panels, maximize, workspaces save floating

- dock::drop_zone / zone_rect (AE-style center/left/right/top/bottom zones with highlight),
  DockNode::dock_panel (tab next to / split off an anchor), Layout {root, floating} with
  float / unfloat / dock; serde (old workspaces still load).
- Tab drag re-docks; Cmd/Ctrl-release or a drop outside every group floats it; floating
  groups are movable, resizable in-window windows that take tabs; panel menu Undock /
  Maximize (toggle, ` maximizes the panel under the pointer) / Close; window.dockPanel /
  floatPanel / maximizePanel / closePanel for agents; Save Workspace stores floating panels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 11:03:06 -04:00
parent 84f517e6c1
commit 53ce3cc23c
6 changed files with 640 additions and 71 deletions

View File

@ -381,6 +381,204 @@ impl DockNode {
}
}
/// 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 fn from_name(s: &str) -> Option<Zone> {
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 {
if pos.y < rect.min.y + tab_h {
return Zone::Center;
}
let fx = ((pos.x - rect.min.x) / rect.width().max(1.0)).clamp(0.0, 1.0);
let fy = ((pos.y - rect.min.y) / rect.height().max(1.0)).clamp(0.0, 1.0);
let d = [(fx, Zone::Left), (1.0 - fx, Zone::Right), (fy, Zone::Top), (1.0 - fy, Zone::Bottom)];
let (m, z) = d.into_iter().min_by(|a, b| a.0.total_cmp(&b.0)).unwrap_or((1.0, Zone::Center));
if m > 0.25 { Zone::Center } else { z }
}
/// The part of a group `rect` a drop into `zone` would occupy (the AE-style highlight).
pub fn zone_rect(rect: Rect, zone: Zone) -> Rect {
let c = rect.center();
match zone {
Zone::Center => rect,
Zone::Left => Rect::from_min_max(rect.min, pos2(c.x, rect.max.y)),
Zone::Right => Rect::from_min_max(pos2(c.x, rect.min.y), rect.max),
Zone::Top => Rect::from_min_max(rect.min, pos2(rect.max.x, c.y)),
Zone::Bottom => Rect::from_min_max(pos2(rect.min.x, c.y), rect.max),
}
}
/// A panel group floating over the dock (undocked).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Floating {
pub panels: Vec<PanelKind>,
pub active: usize,
/// [x, y, w, h] in points.
pub rect: [f32; 4],
}
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. `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 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.panel == 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 i = entries.iter().position(|e| e.panel == anchor).map_or(entries.len(), |i| i + 1);
entries.insert(i, StackEntry { panel: p, open: true, height: 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
}
}
/// Path of the group containing `p` (as in [`Group::path`]; stacks add `sN`).
pub fn path_of(&self, p: PanelKind) -> Option<String> {
fn rec(n: &DockNode, p: PanelKind, path: &str) -> Option<String> {
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.panel == 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<Floating>,
}
impl Layout {
/// Undock `p` into a floating group at `rect` (no-op if it is the last docked panel).
pub fn float(&mut self, p: PanelKind, rect: [f32; 4]) -> bool {
if self.root.contains(p) {
if self.root.panel_count() <= 1 {
return false;
}
self.root.close(p);
} else if !self.unfloat(p) {
return false;
}
self.floating.push(Floating { panels: vec![p], active: 0, rect });
true
}
/// Remove `p` from the floating groups (dropping emptied groups). True if it was floating.
pub fn unfloat(&mut self, p: PanelKind) -> bool {
let mut found = false;
for f in &mut self.floating {
if let Some(i) = f.panels.iter().position(|x| *x == p) {
f.panels.remove(i);
f.active = f.active.min(f.panels.len().saturating_sub(1));
found = true;
}
}
self.floating.retain(|f| !f.panels.is_empty());
found
}
/// Dock `p` (docked or floating) next to `anchor`; `anchor` may be floating too (then Center
/// adds a tab to that floating group).
pub fn dock(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool {
if p == anchor {
return false;
}
if self.floating.iter().any(|f| f.panels.contains(&anchor)) {
if zone != Zone::Center {
return false;
}
if self.root.contains(p) {
if self.root.panel_count() <= 1 {
return false;
}
self.root.close(p);
} else {
self.unfloat(p);
}
let Some(f) = self.floating.iter_mut().find(|f| f.panels.contains(&anchor)) else { return false };
f.panels.push(p);
f.active = f.panels.len() - 1;
return true;
}
if !self.root.contains(anchor) {
return false;
}
// A floating panel isn't in the tree, so dock_panel's removal is a no-op for it.
self.unfloat(p);
self.root.dock_panel(p, anchor, zone)
}
pub fn contains(&self, p: PanelKind) -> bool {
self.root.contains(p) || self.floating.iter().any(|f| f.panels.contains(&p))
}
}
/// One laid-out tab group.
pub struct Group {
pub path: String,
@ -401,6 +599,8 @@ pub enum DockAction {
Focus(PanelKind),
Close(PanelKind),
PanelMenu(PanelKind, egui::Pos2),
/// A tab started being dragged (re-dock or undock).
BeginDrag(PanelKind),
}
/// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging.
@ -521,7 +721,10 @@ pub fn draw_group_chrome(
break;
}
let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h));
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click());
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag());
if resp.drag_started() {
actions.push(DockAction::BeginDrag(*p));
}
reg.add(&format!("panel.tab.{}", p.id()), tab, &label);
let label_x = tab.min.x + 8.0;
let label_w = galley.size().x;
@ -613,6 +816,84 @@ mod tests {
}
}
#[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.
let mut s = workspace("Default");
assert!(s.dock_panel(Tracker, Properties, Zone::Center));
assert!(s.path_of(Tracker).unwrap().contains('s'));
// 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));
}
#[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");

View File

@ -0,0 +1,267 @@
//! The dock area: lays out the docked tree (or the maximized panel), draws each group's chrome
//! and body, floating (undocked) panel groups over it, and tab drag-and-drop: drag a tab onto a
//! group to tab it there (center) or split it off to a side (After Effects' drop zones, with a
//! highlight of where it will land); hold Cmd/Ctrl when releasing, or drop outside every group,
//! to float it. `` ` `` maximizes the panel under the pointer.
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind, Zone};
use crate::{EffectcraftApp, panels};
fn drag_id() -> egui::Id {
egui::Id::new("dock-tab-drag")
}
impl EffectcraftApp {
/// Tab labels that name what the panel shows, as After Effects does: "Composition Intro",
/// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp.
fn tab_titles(&self) -> Vec<(PanelKind, String)> {
let mut out = Vec::new();
let Some(comp) = self.session.active_comp() else { return out };
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
out.push((PanelKind::Composition, format!("Composition {cname}")));
out.push((PanelKind::Timeline, cname));
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
}
out
}
/// Edit the full layout (docked tree + floating groups) with a [`Layout`] operation.
pub fn edit_layout(&mut self, f: impl FnOnce(&mut Layout) -> bool) -> bool {
let mut l = Layout { root: self.ui.dock.clone(), floating: self.ui.floating.clone() };
if !f(&mut l) {
return false;
}
self.ui.dock = l.root;
self.ui.floating = l.floating;
if self.ui.maximized.is_some_and(|m| !self.ui.dock.contains(m)) {
self.ui.maximized = None;
}
true
}
/// Close a panel wherever it is (docked or floating).
pub fn close_panel(&mut self, p: PanelKind) {
self.ui.dock.close(p);
self.edit_layout(|l| l.unfloat(p));
if self.ui.maximized == Some(p) {
self.ui.maximized = None;
}
}
/// Toggle the maximized panel (`p`, or none).
pub fn toggle_maximize(&mut self, p: PanelKind) {
self.ui.maximized = if self.ui.maximized == Some(p) || !self.ui.dock.contains(p) { None } else { Some(p) };
}
/// The docked panel whose group is under `pos` (from the last frame), else the focused one.
pub fn panel_at(&self, pos: Option<egui::Pos2>) -> PanelKind {
pos.and_then(|p| self.dock_rects.iter().rev().find(|(_, r)| r.contains(p)).map(|(k, _)| *k)).unwrap_or(self.ui.focused)
}
fn panel_body(&mut self, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {
self.auto.add(&format!("panel.{}", p.id()), rect, p.title());
let mut content = rect;
if p == PanelKind::Composition && !self.ui.start_screen && panels::precomp::has_flow(self) {
// Composition Navigator: the flow of nested comps above the viewer.
let nav = Rect::from_min_size(content.min, vec2(content.width(), panels::precomp::NAV_H));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(nav).id_salt("comp-navigator"));
child.set_clip_rect(nav.intersect(ui.clip_rect()));
panels::precomp::navigator(self, &mut child, nav);
content.min.y = nav.max.y;
}
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())));
child.set_clip_rect(content.intersect(ui.clip_rect()));
panels::show(self, &mut child, p, content);
if p == PanelKind::Composition && !self.ui.start_screen {
panels::anim_tools::sketch_overlay(self, &mut child);
}
}
pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) {
let t = self.tokens;
let ctx = ui.ctx().clone();
// The maximized panel fills the area; the tree is kept as it is.
let maximized = self.ui.maximized.filter(|m| self.ui.dock.contains(*m));
let mut dock = match maximized {
Some(m) => DockNode::Tabs { panels: vec![m], active: 0 },
None => std::mem::replace(&mut self.ui.dock, DockNode::Tabs { panels: vec![], active: 0 }),
};
let mut groups = Vec::new();
dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto);
let mut actions = Vec::new();
let titles = self.tab_titles();
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
for g in &groups {
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title));
}
if maximized.is_none() {
self.ui.dock = dock;
}
self.dock_rects = groups.iter().filter_map(|g| Some((*g.panels.get(g.active)?, g.rect))).collect();
for g in &groups {
let Some(p) = g.panels.get(g.active).copied() else { continue };
if g.content.height() < 2.0 {
continue; // a collapsed stacked panel: header only
}
self.panel_body(ui, p, g.content);
}
// Floating groups over the dock.
let mut float_groups: Vec<Group> = vec![];
for i in 0..self.ui.floating.len() {
if let Some(g) = self.floating_window(&ctx, i, &title, &mut actions) {
float_groups.push(g);
}
}
for a in actions {
match a {
DockAction::Activate(p) => {
if !self.ui.dock.activate(p)
&& let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p))
{
f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0);
}
}
DockAction::ToggleStacked(p) => {
self.ui.dock.toggle_stacked(p);
}
DockAction::Focus(p) => self.ui.focused = p,
DockAction::Close(p) => self.close_panel(p),
DockAction::PanelMenu(p, pos) => {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("panel-menu"), (p, pos)));
}
DockAction::BeginDrag(p) => ctx.data_mut(|d| {
d.insert_temp(drag_id(), p);
}),
}
}
self.tab_drag(&ctx, &groups, &float_groups);
panels::panel_menu_popup(self, ui);
}
/// One floating group: a window with a tab strip (drag the empty strip to move it, the
/// corner to resize it) and the active panel's body.
fn floating_window(&mut self, ctx: &egui::Context, i: usize, title: &dyn Fn(PanelKind) -> String, actions: &mut Vec<DockAction>) -> Option<Group> {
let t = self.tokens;
let f = self.ui.floating.get(i)?.clone();
let screen = ctx.content_rect();
let mut rect = Rect::from_min_size(pos2(f.rect[0], f.rect[1]), vec2(f.rect[2].max(160.0), f.rect[3].max(100.0)));
// Keep the strip reachable.
rect = rect.translate(vec2(
(screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0),
(screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0),
));
let id = egui::Id::new(("floating-panel", f.panels.first().map(|p| p.id()).unwrap_or_default()));
let mut group = None;
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).interactable(true).show(ctx, |ui| {
let (r, _) = ui.allocate_exact_size(rect.size(), Sense::hover());
ui.painter().rect_filled(r.expand(1.0), t.radius, Color32::from_black_alpha(90));
let strip = Rect::from_min_size(r.min, vec2(r.width(), t.tab_h));
// Move by the strip (tabs, registered later, take precedence over it).
let mv = ui.interact(strip, id.with("move"), Sense::drag());
self.auto.add(&format!("panel.float.{i}.move"), strip, "Move floating panel");
let mut nr = r;
if mv.dragged() {
nr = nr.translate(mv.drag_delta());
}
let g = Group {
path: format!("f{i}"),
rect: r,
content: Rect::from_min_max(pos2(r.min.x, r.min.y + t.tab_h), r.max),
panels: f.panels.clone(),
active: f.active.min(f.panels.len().saturating_sub(1)),
stacked: None,
};
actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title));
ui.painter().rect_stroke(r, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
if let Some(p) = g.panels.get(g.active).copied() {
self.panel_body(ui, p, g.content);
}
// Resize from the corner.
let grip = Rect::from_min_max(r.max - vec2(14.0, 14.0), r.max);
let rs = ui.interact(grip, id.with("resize"), Sense::drag());
self.auto.add(&format!("panel.float.{i}.resize"), grip, "Resize floating panel");
for k in 0..3 {
let o = 4.0 + k as f32 * 4.0;
ui.painter().line_segment([pos2(r.max.x - o, r.max.y - 2.0), pos2(r.max.x - 2.0, r.max.y - o)], Stroke::new(1.0, t.text_faint));
}
if rs.hovered() || rs.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe);
}
if rs.dragged() {
nr = Rect::from_min_size(nr.min, (nr.size() + rs.drag_delta()).max(vec2(160.0, 100.0)));
}
if nr != r
&& let Some(fl) = self.ui.floating.get_mut(i)
{
fl.rect = [nr.min.x, nr.min.y, nr.width(), nr.height()];
}
group = Some(g);
});
group
}
/// A tab being dragged: highlight the drop zone under the pointer; on release, dock (or
/// float with Cmd/Ctrl or outside every group).
fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group]) {
let Some(p) = ctx.data(|d| d.get_temp::<PanelKind>(drag_id())) else { return };
let t = self.tokens;
let Some(pos) = ctx.pointer_latest_pos() else { return };
// Floating windows are on top; then the docked groups.
let target = floats.iter().map(|g| (g, true)).chain(groups.iter().map(|g| (g, false))).find(|(g, _)| g.rect.contains(pos));
let float_drop = ctx.input(|i| i.modifiers.command);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
let plan: Option<(PanelKind, Zone)> = match target {
Some((g, is_float)) if !float_drop => {
let anchor = g.panels.get(g.active).copied().unwrap_or(p);
let zone = if is_float { Zone::Center } else { dock::drop_zone(g.rect, t.tab_h, pos) };
let self_only = anchor == p && g.panels.len() == 1;
if self_only {
None
} else {
let anchor = if anchor == p { g.panels.iter().copied().find(|x| *x != p).unwrap_or(p) } else { anchor };
let hr = dock::zone_rect(g.rect, zone).shrink(2.0);
painter.rect_filled(hr, t.radius, t.focus.gamma_multiply(0.25));
painter.rect_stroke(hr, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
// Outline the whole group for the side zones, like AE's drop-zone frame.
if zone != Zone::Center {
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
}
Some((anchor, zone))
}
}
_ => None,
};
// Ghost of the dragged tab.
let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE);
let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0));
painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85));
painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE);
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
ctx.request_repaint();
if ctx.input(|i| i.pointer.any_released()) {
ctx.data_mut(|d| d.remove::<PanelKind>(drag_id()));
let ok = match plan {
Some((anchor, zone)) => self.edit_layout(|l| l.dock(p, anchor, zone)),
None if float_drop || target.is_none() => {
let r = [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0];
self.edit_layout(|l| l.float(p, r))
}
None => false,
};
if ok {
self.ui.focused = p;
self.show_panel(p);
}
}
}
}
/// A new floating group's default rect near the centre of `screen`.
pub fn default_float_rect(screen: Rect) -> [f32; 4] {
let c = screen.center();
[c.x - 210.0, c.y - 160.0, 420.0, 320.0]
}

View File

@ -9,6 +9,7 @@ pub mod audio;
pub mod automation;
pub mod control;
pub mod dock;
pub mod dock_ui;
pub mod frames;
pub mod header;
pub mod icons;
@ -144,6 +145,9 @@ pub struct EffectcraftApp {
applied_prefs: Option<u64>,
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
/// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one
/// under the pointer.
pub(crate) dock_rects: Vec<(PanelKind, egui::Rect)>,
}
impl EffectcraftApp {
@ -182,6 +186,7 @@ impl EffectcraftApp {
last_reveal: None,
applied_prefs: None,
recovery: None,
dock_rects: vec![],
}
.with_ui_commands()
}
@ -247,6 +252,8 @@ impl EffectcraftApp {
pub fn set_workspace(&mut self, name: &str) {
self.ui.workspace = name.to_string();
self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name));
self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_default();
self.ui.maximized = None;
}
/// Built-in workspaces followed by the saved ones (Window ▸ Workspace ▸ Save as New Workspace).
@ -257,6 +264,14 @@ impl EffectcraftApp {
}
pub fn show_panel(&mut self, p: PanelKind) {
if let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p)) {
f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0);
self.ui.focused = p;
return;
}
if self.ui.maximized.is_some_and(|m| m != p) {
self.ui.maximized = None;
}
if !self.ui.dock.contains(p) {
let near = match p {
PanelKind::Layer | PanelKind::Flowchart => PanelKind::Composition,
@ -685,73 +700,6 @@ impl EffectcraftApp {
}
}
}
/// Tab labels that name what the panel shows, as After Effects does: "Composition Intro",
/// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp.
fn tab_titles(&self) -> Vec<(PanelKind, String)> {
let mut out = Vec::new();
let Some(comp) = self.session.active_comp() else { return out };
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
out.push((PanelKind::Composition, format!("Composition {cname}")));
out.push((PanelKind::Timeline, cname));
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
}
out
}
fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) {
let t = self.tokens;
let mut dock = std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 });
let mut groups = Vec::new();
dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto);
let mut actions = Vec::new();
let titles = self.tab_titles();
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
for g in &groups {
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title));
}
self.ui.dock = dock;
for g in &groups {
let Some(p) = g.panels.get(g.active).copied() else { continue };
if g.content.height() < 2.0 {
continue; // a collapsed stacked panel: header only
}
self.auto.add(&format!("panel.{}", p.id()), g.content, p.title());
let mut content = g.content;
if p == PanelKind::Composition && !self.ui.start_screen && panels::precomp::has_flow(self) {
// Composition Navigator: the flow of nested comps above the viewer.
let nav = egui::Rect::from_min_size(content.min, egui::vec2(content.width(), panels::precomp::NAV_H));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(nav).id_salt("comp-navigator"));
child.set_clip_rect(nav.intersect(ui.clip_rect()));
panels::precomp::navigator(self, &mut child, nav);
content.min.y = nav.max.y;
}
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())));
child.set_clip_rect(content.intersect(ui.clip_rect()));
panels::show(self, &mut child, p, content);
if p == PanelKind::Composition && !self.ui.start_screen {
panels::anim_tools::sketch_overlay(self, &mut child);
}
}
for a in actions {
match a {
dock::DockAction::Activate(p) => {
self.ui.dock.activate(p);
}
dock::DockAction::ToggleStacked(p) => {
self.ui.dock.toggle_stacked(p);
}
dock::DockAction::Focus(p) => self.ui.focused = p,
dock::DockAction::Close(p) => self.ui.dock.close(p),
dock::DockAction::PanelMenu(p, pos) => {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("panel-menu"), (p, pos)));
}
}
}
panels::panel_menu_popup(self, ui);
}
}
impl EffectcraftApp {

View File

@ -79,6 +79,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("app.solidSettings", "New Solid...", [], None),
uic!("app.home", "Home", [], None),
uic!("markers.dialog", "Marker Settings...", [], None),
uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")),
uic!("window.dockPanel", "Dock Panel", [], None),
uic!("window.floatPanel", "Undock Panel", [], None),
uic!("window.closePanel", "Close Panel", [], None),
];
pub fn panel_command_id(p: PanelKind) -> String {
@ -164,6 +168,51 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
reveal(app, k, now);
return Ok(Value::Null);
}
// Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}.
if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) {
let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`")));
match op {
"maximizePanel" => {
let p = match panel_of("panel") {
Some(p) => p?,
None => app.panel_at(ctx.pointer_hover_pos()),
};
app.toggle_maximize(p);
return Ok(json!({"maximized": app.ui.maximized}));
}
"closePanel" => {
let p = panel_of("panel").ok_or("closePanel: need `panel`")??;
app.close_panel(p);
return Ok(Value::Null);
}
"floatPanel" => {
let p = panel_of("panel").ok_or("floatPanel: need `panel`")??;
let r = params.get("rect").and_then(Value::as_array).map(|a| {
let g = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
[g(0), g(1), g(2), g(3)]
});
let r = r.unwrap_or_else(|| crate::dock_ui::default_float_rect(ctx.content_rect()));
if !app.edit_layout(|l| l.float(p, r)) {
return Err(format!("can't undock {}", p.title()));
}
return Ok(Value::Null);
}
_ => {
let p = panel_of("panel").ok_or("dockPanel: need `panel`")??;
let anchor = panel_of("anchor").ok_or("dockPanel: need `anchor` (a docked panel)")??;
let zone = params
.get("zone")
.and_then(Value::as_str)
.map_or(Some(crate::dock::Zone::Center), crate::dock::Zone::from_name)
.ok_or("zone: center|left|right|top|bottom")?;
if !app.edit_layout(|l| l.dock(p, anchor, zone)) {
return Err(format!("can't dock {} at {} ({zone:?})", p.title(), anchor.title()));
}
app.show_panel(p);
return Ok(Value::Null);
}
}
}
if id == "markers.dialog" {
// The Composition/Layer Marker dialog (double-click a marker): {layer?, index}.
let index = params.get("index").and_then(Value::as_u64).ok_or("markers.dialog: need `index`")? as usize;
@ -480,6 +529,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
"window.saveWorkspace" => {
let name = app.ui.workspace.clone();
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone());
json!({"workspace": name})
}
"window.saveWorkspaceAs" => {
@ -494,6 +544,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
return Ok(Value::Null);
};
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone());
app.ui.workspace = name.clone();
json!({"workspace": name})
}
@ -515,6 +566,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
};
let builtin = crate::dock::WORKSPACES.contains(&name.as_str());
if p.get("delete").and_then(Value::as_bool) == Some(true) {
app.ui.saved_floating.remove(&name);
if app.ui.saved_workspaces.remove(&name).is_none() && !builtin {
return Err(format!("no workspace `{name}`"));
}
@ -529,6 +581,9 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
}
let dock = app.ui.saved_workspaces.remove(&name).ok_or_else(|| format!("no workspace `{name}`"))?;
app.ui.saved_workspaces.insert(new.to_string(), dock);
if let Some(f) = app.ui.saved_floating.remove(&name) {
app.ui.saved_floating.insert(new.to_string(), f);
}
if app.ui.workspace == name {
app.ui.workspace = new.to_string();
}

View File

@ -98,11 +98,17 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
if ui.button("Close Panel").clicked() {
app.ui.dock.close(panel);
app.close_panel(panel);
close = true;
}
if ui.button("Maximize Panel").clicked() {
app.ui.dock = crate::dock::DockNode::Tabs { panels: vec![panel], active: 0 };
let floating = app.ui.floating.iter().any(|f| f.panels.contains(&panel));
if !floating && ui.button("Undock Panel").clicked() {
let r = crate::dock_ui::default_float_rect(ui.ctx().content_rect());
app.edit_layout(|l| l.float(panel, r));
close = true;
}
if !floating && ui.button(if app.ui.maximized == Some(panel) { "Restore Panel Size" } else { "Maximize Panel" }).clicked() {
app.toggle_maximize(panel);
close = true;
}
ui.separator();

View File

@ -434,6 +434,15 @@ pub struct UiState {
/// Saved workspace layouts (Save Changes to this Workspace / Save as New Workspace).
#[serde(default)]
pub saved_workspaces: std::collections::BTreeMap<String, DockNode>,
/// Undocked (floating) panel groups.
#[serde(default)]
pub floating: Vec<crate::dock::Floating>,
/// Floating panels of the saved workspaces.
#[serde(default)]
pub saved_floating: std::collections::BTreeMap<String, Vec<crate::dock::Floating>>,
/// The panel maximized to fill the dock area (`~`), if any.
#[serde(default)]
pub maximized: Option<PanelKind>,
pub focused: PanelKind,
pub viewer: ViewerState,
pub timeline: TimelineState,
@ -522,6 +531,9 @@ impl Default for UiState {
workspace: "Default".into(),
dock: crate::dock::workspace("Default"),
saved_workspaces: Default::default(),
floating: vec![],
saved_floating: Default::default(),
maximized: None,
focused: PanelKind::Composition,
viewer: ViewerState::default(),
timeline: TimelineState::default(),