diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index baa43ed7..5bb9c8b6 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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 { + 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, + 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 { + 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.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, +} + +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"); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs new file mode 100644 index 00000000..44900918 --- /dev/null +++ b/crates/ui-egui/src/dock_ui.rs @@ -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) -> 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 = 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) -> Option { + 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::(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::(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] +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 669c5258..561bfa9b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, /// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`). pub recovery: Option, + /// 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 { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0bdec4cd..f139d251 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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(); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 21381012..1cbccca4 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index edd79041..77abe611 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, + /// Undocked (floating) panel groups. + #[serde(default)] + pub floating: Vec, + /// Floating panels of the saved workspaces. + #[serde(default)] + pub saved_floating: std::collections::BTreeMap>, + /// The panel maximized to fill the dock area (`~`), if any. + #[serde(default)] + pub maximized: Option, 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(),