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:
parent
84f517e6c1
commit
53ce3cc23c
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue