From c7c90cfa74dd4d43de194e882bff7163e6b960ad Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 11:58:19 +0700 Subject: [PATCH] M3.15: Timeline tabs per open comp; Project double-click opens; tab drag insertion mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Timeline shows one tab per open comp (dock DocTab). Clicking a tab shows that comp; × or middle-click closes that comp's Timeline (comp.close), not the panel. Opening a comp brings its viewer and Timeline tab to the front. - Project panel: a double-click on an item's name opens it (a comp gets its own tab) instead of renaming; Enter and the context menu rename. - Tab drags: over a tab strip an insertion mark shows where the tab lands, and it lands there (Layout::insert_tab), which also reorders a group's own tabs; side zones are unchanged. - Tests: ui_tabs (double-click, tab switch / close, drag reorder and cross-group insert; an ignored mid-drag snapshot); the Timeline close test follows the new behaviour. - Docs: parity, control protocol. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 228 +++++++++++++++++++++----- crates/ui-egui/src/dock_ui.rs | 163 ++++++++++++++---- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/panels/project.rs | 9 +- crates/ui-egui/tests/ui_fx_editors.rs | 5 +- crates/ui-egui/tests/ui_tabs.rs | 146 +++++++++++++++++ docs/control-protocol.md | 4 +- docs/parity.md | 6 + 8 files changed, 485 insertions(+), 83 deletions(-) create mode 100644 crates/ui-egui/tests/ui_tabs.rs diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 590843d7..562da350 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -607,6 +607,39 @@ impl DockNode { } } + /// Move panel `p` into the group that holds `anchor` as a tab before `before` (one of that + /// group's panels) or last, and show it. False (tree unchanged) if `anchor` isn't docked. + pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option) -> bool { + if p == anchor || !self.contains(anchor) { + return false; + } + let saved = self.clone(); + self.close(p); + fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, before: Option) -> bool { + match n { + DockNode::Split { a, b, .. } => rec(a, p, anchor, before) || rec(b, p, anchor, before), + DockNode::Tabs { panels, active } if panels.contains(&anchor) => { + let i = before.and_then(|b| panels.iter().position(|x| *x == b)).unwrap_or(panels.len()); + panels.insert(i, p); + *active = i; + true + } + DockNode::Stack { entries } if entries.iter().any(|e| e.panel == anchor) => { + let i = before.and_then(|b| entries.iter().position(|e| e.panel == b)).unwrap_or(entries.len()); + entries.insert(i, StackEntry { panel: p, open: true, height: None }); + true + } + _ => false, + } + } + if rec(self, p, anchor, before) { + true + } else { + *self = saved; + false + } + } + /// Path of the group containing `p` (as in [`Group::path`]; stacks add `sN`). pub fn path_of(&self, p: PanelKind) -> Option { fn rec(n: &DockNode, p: PanelKind, path: &str) -> Option { @@ -664,6 +697,36 @@ impl Layout { found } + /// Move `p` (docked or floating) into the group of `anchor` (docked or floating) as a tab + /// before `before` or last: where a tab dragged onto a tab strip lands. + pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option) -> bool { + if p == anchor || before == Some(p) { + return false; + } + if self.floating.iter().any(|f| f.panels.contains(&anchor)) { + if self.root.contains(p) { + if self.root.panel_count() <= 1 { + return false; + } + self.root.close(p); + } else { + self.unfloat(p); + } + // (Removing `p` may have dropped an emptied group: look the anchor up again.) + let Some(f) = self.floating.iter_mut().find(|f| f.panels.contains(&anchor)) else { return false }; + let i = before.and_then(|b| f.panels.iter().position(|x| *x == b)).unwrap_or(f.panels.len()); + f.panels.insert(i, p); + f.active = i; + return true; + } + if !self.root.contains(anchor) { + return false; + } + // A floating panel isn't in the tree, so insert_tab's removal is a no-op for it. + self.unfloat(p); + self.root.insert_tab(p, anchor, before) + } + /// 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 { @@ -724,6 +787,37 @@ pub enum DockAction { BeginDrag(PanelKind), /// The tab's lock was toggled (Composition / Timeline viewer lock). ToggleLock(PanelKind), + /// A document tab of a panel was clicked (the Timeline's tab of comp `id`). + ActivateDoc(PanelKind, u64), + /// A document tab's close button (or middle click): close that document (comp `id`). + CloseDoc(PanelKind, u64), +} + +/// One document a panel shows as a tab of its own (the Timeline: one tab per open comp, as in +/// After Effects). +#[derive(Debug, Clone, PartialEq)] +pub struct DocTab { + pub id: u64, + pub title: String, + pub deco: Option, + /// The document the panel shows now. + pub active: bool, +} + +/// What a group's tabs show: labels, the comp swatch / close / lock decorations and the panels +/// that show one tab per document. +pub struct TabInfo<'a> { + pub title: &'a dyn Fn(PanelKind) -> String, + pub decos: &'a [(PanelKind, TabDeco)], + pub docs: &'a [(PanelKind, Vec)], +} + +/// What drawing a group's chrome produced: actions and where each panel's tabs are (a panel with +/// document tabs spans all of them; tabs hidden behind the overflow chevron are left out). +#[derive(Default)] +pub struct Chrome { + pub actions: Vec, + pub tabs: Vec<(PanelKind, Rect)>, } /// After Effects-style extras on a tab that shows a composition (Composition, Timeline): a close @@ -810,18 +904,21 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa } } -/// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus). -/// `title` gives a tab's label, which may name the comp or layer it shows (After Effects style). -pub fn draw_group_chrome( - ui: &mut egui::Ui, - g: &Group, - focused: PanelKind, - t: &Tokens, - reg: &mut crate::automation::Registry, - title: &dyn Fn(PanelKind) -> String, - decos: &[(PanelKind, TabDeco)], -) -> Vec { - let mut actions = Vec::new(); +/// Draw a group's frame + tab strip: one tab per panel, or per document for panels that show +/// several (the Timeline's comps). Tab labels may name the comp or layer they show (After +/// Effects style). +pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &Tokens, reg: &mut crate::automation::Registry, info: &TabInfo) -> Chrome { + struct Entry { + panel: PanelKind, + doc: Option, + label: String, + deco: Option, + /// The group's shown tab. + active: bool, + /// The document its panel shows (even behind another tab). + shown_doc: bool, + } + let mut out = Chrome::default(); let painter = ui.painter().clone(); painter.rect_filled(g.rect, t.radius, t.panel_bg); let active_panel = g.panels.get(g.active).copied(); @@ -833,57 +930,102 @@ pub fn draw_group_chrome( painter.circle_filled(c + vec2(dx, 0.0), 1.0, t.text_faint); } } else { + let mut entries: Vec = vec![]; + for (pi, p) in g.panels.iter().enumerate() { + let shown = pi == g.active; + match info.docs.iter().find(|(k, d)| k == p && !d.is_empty()) { + Some((_, docs)) => entries.extend(docs.iter().map(|d| Entry { + panel: *p, + doc: Some(d.id), + label: d.title.clone(), + deco: d.deco, + active: shown && d.active, + shown_doc: d.active, + })), + None => entries.push(Entry { + panel: *p, + doc: None, + label: (info.title)(*p), + deco: info.decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d), + active: shown, + shown_doc: false, + }), + } + } + let ai = entries.iter().position(|e| e.active).unwrap_or(0); let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h)); if t.gradients { // Settings ▸ Appearance ▸ Use Gradients. crate::theme::gradient_rect(&painter, strip.shrink2(vec2(t.radius, 0.0)), t.grad_top(t.panel_bg), t.panel_bg); } + let activate = |e: &Entry| match e.doc { + Some(id) => DockAction::ActivateDoc(e.panel, id), + None => DockAction::Activate(e.panel), + }; + let close = |e: &Entry| match e.doc { + Some(id) => DockAction::CloseDoc(e.panel, id), + None => DockAction::Close(e.panel), + }; let mut x = strip.min.x + 12.0; let text_y = strip.min.y + 16.0; - for (i, p) in g.panels.iter().enumerate() { + for (k, e) in entries.iter().enumerate() { + let p = &e.panel; // A collapsed stacked panel's header is plain text (no underline or panel menu). - let is_active = i == g.active && g.stacked != Some(false); - let label = title(*p); - let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); + let is_active = k == ai && g.stacked != Some(false); + let galley = painter.layout_no_wrap(e.label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); let menu_w = if is_active { 20.0 } else { 0.0 }; - let deco = decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d); + // The lock belongs to the panel: on its shown document's tab only. + let lock = e.deco.is_some_and(|d| d.viewer) && (e.doc.is_none() || is_active); // × (active tab only), swatch and lock before the label. - let deco_w = match deco { - Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + 16.0, + let deco_w = match e.deco { + Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + if lock { 16.0 } else { 0.0 }, Some(_) => 14.0, None => 0.0, }; let w = galley.size().x + 16.0 + menu_w + deco_w; - if x + w > strip.max.x - 20.0 && i > g.active { + if x + w > strip.max.x - 20.0 && k > ai { let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0)); let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, t.tab_text); icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, t.tab_text); - if resp.clicked() { - let next = g.panels[(g.active + 1) % g.panels.len()]; - actions.push(DockAction::Activate(next)); + if resp.clicked() + && let Some(next) = entries.get((ai + 1) % entries.len()) + { + out.actions.push(activate(next)); } break; } let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h)); - 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)); + match out.tabs.iter_mut().find(|(q, _)| q == p) { + Some((_, r)) => *r = r.union(tab), + None => out.tabs.push((*p, tab)), + } + let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), k)), Sense::click_and_drag()); + if resp.drag_started() { + out.actions.push(DockAction::BeginDrag(*p)); + } + // A document tab also answers to the panel's id while it is the shown one. + let auto_id = match e.doc { + Some(id) => format!("panel.tab.{}.{id}", p.id()), + None => format!("panel.tab.{}", p.id()), + }; + reg.add(&auto_id, tab, &e.label); + if e.shown_doc { + reg.add(&format!("panel.tab.{}", p.id()), tab, &e.label); } - reg.add(&format!("panel.tab.{}", p.id()), tab, &label); let mut label_x = tab.min.x + 8.0; - if let Some(d) = deco { + if let Some(d) = e.deco { let mut dx = tab.min.x + 6.0; if is_active && d.viewer { let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0)); - let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), i)), Sense::click()); + let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), k)), Sense::click()); let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; - let k = 3.5; - painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc)); - painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc)); + let kk = 3.5; + painter.line_segment([cr.center() + vec2(-kk, -kk), cr.center() + vec2(kk, kk)], Stroke::new(1.2, cc)); + painter.line_segment([cr.center() + vec2(-kk, kk), cr.center() + vec2(kk, -kk)], Stroke::new(1.2, cc)); reg.add(&format!("panel.tab.{}.close", p.id()), cr, "Close"); if cresp.clicked() { - actions.push(DockAction::Close(*p)); + out.actions.push(close(e)); } dx += 16.0; } @@ -891,9 +1033,9 @@ pub fn draw_group_chrome( painter.rect_filled(sw, 1.0, d.swatch); dx += 14.0; label_x = dx; - if d.viewer { + if lock { let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0)); - let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), i)), Sense::click()); + let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), k)), Sense::click()); let lc = if d.locked { t.tab_text_active } else if lresp.hovered() { @@ -904,7 +1046,7 @@ pub fn draw_group_chrome( icons::paint(&painter, lr, Icon::Lock, lc); reg.add(&format!("panel.tab.{}.lock", p.id()), lr, if d.locked { "Unlock" } else { "Lock" }); if lresp.clicked() { - actions.push(DockAction::ToggleLock(*p)); + out.actions.push(DockAction::ToggleLock(*p)); } label_x = dx + 16.0; } @@ -923,19 +1065,19 @@ pub fn draw_group_chrome( let uy = strip.min.y + 23.0; painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active)); if mresp.clicked() { - actions.push(DockAction::PanelMenu(*p, mr.left_bottom())); + out.actions.push(DockAction::PanelMenu(*p, mr.left_bottom())); } } if resp.clicked() { if g.stacked.is_some() { - actions.push(DockAction::ToggleStacked(*p)); + out.actions.push(DockAction::ToggleStacked(*p)); } else { - actions.push(DockAction::Activate(*p)); + out.actions.push(activate(e)); } - actions.push(DockAction::Focus(*p)); + out.actions.push(DockAction::Focus(*p)); } if resp.middle_clicked() { - actions.push(DockAction::Close(*p)); + out.actions.push(close(e)); } x += w + 8.0; } @@ -949,9 +1091,9 @@ pub fn draw_group_chrome( && ui.rect_contains_pointer(g.rect) && ui.input(|i| i.pointer.any_pressed()) { - actions.push(DockAction::Focus(p)); + out.actions.push(DockAction::Focus(p)); } - actions + out } /// Placeholder body for panels that are not implemented yet. diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 1f42df2e..565cb995 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -15,7 +15,8 @@ fn drag_id() -> egui::Id { 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. + /// "Effect Controls Title", "Properties: Title" (the Timeline's tabs are its comps, see + /// [`Self::tab_docs`]). fn tab_titles(&self) -> Vec<(PanelKind, String)> { let mut out = Vec::new(); // ScriptUI panels are named after their script. @@ -27,7 +28,6 @@ impl EffectcraftApp { 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))); @@ -35,13 +35,35 @@ impl EffectcraftApp { out } + /// A comp's label colour as a tab swatch (faint without a label). + fn comp_swatch(&self, cid: effectcraft_engine::project::ItemId) -> Color32 { + match self.session.project.item(cid).map(|i| i.label) { + Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l), + _ => self.tokens.text_faint, + } + } + + /// The Timeline's tabs: one per open comp, as in After Effects (opening a precomp adds a tab + /// next to its parent's instead of replacing it). + fn tab_docs(&self) -> Vec<(PanelKind, Vec)> { + let st = &self.session.state; + let locked = self.ui.locked_tabs.contains(&PanelKind::Timeline.id()); + let docs = st + .open_comps + .iter() + .filter_map(|id| { + let it = self.session.project.item(*id).filter(|i| i.as_comp().is_some())?; + let deco = dock::TabDeco { swatch: self.comp_swatch(*id), locked, viewer: true }; + Some(dock::DocTab { id: id.0, title: it.name.clone(), deco: Some(deco), active: st.active_comp == Some(*id) }) + }) + .collect(); + vec![(PanelKind::Timeline, docs)] + } + /// The Composition and Timeline tabs' close button, label-colour swatch and viewer lock. fn tab_decos(&self) -> Vec<(PanelKind, dock::TabDeco)> { let Some(cid) = self.session.active_comp_id() else { return vec![] }; - let swatch = match self.session.project.item(cid).map(|i| i.label) { - Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l), - _ => self.tokens.text_faint, - }; + let swatch = self.comp_swatch(cid); let mut v: Vec<(PanelKind, dock::TabDeco)> = [PanelKind::Composition, PanelKind::Timeline] .into_iter() .map(|p| (p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true })) @@ -149,9 +171,15 @@ impl EffectcraftApp { let mut actions = Vec::new(); let titles = self.tab_titles(); let decos = self.tab_decos(); + let docs = self.tab_docs(); let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); + let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs }; + // Where each group's tabs are (tab drags show where a tab will land between them). + let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![]; for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title, &decos)); + let c = dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &info); + actions.extend(c.actions); + tab_rects.push((g.path.clone(), c.tabs)); } self.label_tab_marks(ui); if maximized.is_none() { @@ -168,7 +196,8 @@ impl EffectcraftApp { // 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) { + if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) { + tab_rects.push((g.path.clone(), tabs)); float_groups.push(g); } } @@ -197,15 +226,35 @@ impl EffectcraftApp { self.ui.locked_tabs.insert(p.id().to_string()); } } + // The Timeline's comp tabs: show that comp / close its Timeline. + DockAction::ActivateDoc(p, id) => { + 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); + } + if self.session.state.active_comp.map(|c| c.0) != Some(id) { + let _ = crate::menus::invoke(self, &ctx, "comp.open", serde_json::json!({"comp": id})); + } + } + DockAction::CloseDoc(_, id) => { + let _ = crate::menus::invoke(self, &ctx, "comp.close", serde_json::json!({"comp": id})); + } } } - self.tab_drag(&ctx, &groups, &float_groups); + self.tab_drag(&ctx, &groups, &float_groups, &tab_rects); 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 { + fn floating_window( + &mut self, + ctx: &egui::Context, + i: usize, + info: &dock::TabInfo, + actions: &mut Vec, + ) -> Option<(Group, Vec<(PanelKind, Rect)>)> { let t = self.tokens; let f = self.ui.floating.get(i)?.clone(); let screen = ctx.content_rect(); @@ -236,8 +285,8 @@ impl EffectcraftApp { active: f.active.min(f.panels.len().saturating_sub(1)), stacked: None, }; - let decos = self.tab_decos(); - actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title, &decos)); + let c = dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, info); + actions.extend(c.actions); 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); @@ -261,14 +310,16 @@ impl EffectcraftApp { { fl.rect = [nr.min.x, nr.min.y, nr.width(), nr.height()]; } - group = Some(g); + group = Some((g, c.tabs)); }); 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]) { + /// A tab being dragged. Over a tab strip (or the middle of a group) it shows where the tab + /// will land: an insertion mark between two tabs (a drop there reorders a group's own tabs + /// too); near a group's edge, the half it will split off. On release it lands there (or + /// floats with Cmd/Ctrl or outside every group). + fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group], tab_rects: &[(String, Vec<(PanelKind, Rect)>)]) { 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 }; @@ -276,23 +327,55 @@ impl EffectcraftApp { 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 { + let plan: Option = match target { Some((g, is_float)) if !float_drop => { - let anchor = g.panels.get(g.active).copied().unwrap_or(p); + let others: Vec = g.panels.iter().copied().filter(|x| *x != p).collect(); 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); + match (others.first().copied(), zone) { + // The dragged tab alone in its group: nowhere to go here. + (None, _) => None, + (Some(first), Zone::Center) => { + let rects: Vec<(PanelKind, Rect)> = tab_rects + .iter() + .find(|(path, _)| *path == g.path) + .map(|(_, r)| r.iter().copied().filter(|(k, _)| *k != p).collect()) + .unwrap_or_default(); + let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h)); + let before = if strip.contains(pos) { + // Before the first tab whose middle is right of the pointer. + rects.iter().find(|(_, r)| r.center().x > pos.x).map(|(k, _)| *k) + } else if g.panels.contains(&p) { + // The middle of its own group: it stays where it is. + Some(p) + } else { + // The middle of another group: right after its shown tab. + let shown = g.panels.get(g.active).and_then(|a| others.iter().position(|x| x == a)).unwrap_or(others.len()); + others.get(shown + 1).copied() + }; + if before == Some(p) { + None + } else { + let x = match before.and_then(|b| rects.iter().find(|(k, _)| *k == b)) { + Some((_, r)) => r.min.x - 4.0, + None => rects.last().map_or(strip.min.x + 8.0, |(_, r)| r.max.x + 4.0), + }; + painter.rect_filled(strip.shrink2(vec2(2.0, 1.0)), t.radius, t.focus.gamma_multiply(0.12)); + if !strip.contains(pos) { + painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside); + } + insertion_mark(&painter, x, strip, t.focus); + Some(DropPlan::Tab { anchor: first, before }) + } + } + (Some(first), zone) => { + let shown = g.panels.get(g.active).copied().filter(|a| *a != p).unwrap_or(first); + 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. + painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside); + Some(DropPlan::Side { anchor: shown, zone }) } - Some((anchor, zone)) } } _ => None, @@ -307,7 +390,8 @@ impl EffectcraftApp { 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)), + Some(DropPlan::Tab { anchor, before }) => self.edit_layout(|l| l.insert_tab(p, anchor, before)), + Some(DropPlan::Side { 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)) @@ -322,6 +406,23 @@ impl EffectcraftApp { } } +/// Where a dragged tab lands. +enum DropPlan { + /// A tab in the group of `anchor`, before `before` (last when `None`). + Tab { anchor: PanelKind, before: Option }, + /// A new group split off one side of `anchor`'s group. + Side { anchor: PanelKind, zone: Zone }, +} + +/// The insertion mark of a tab drag: a bar between two tabs at `x`, with caps. +fn insertion_mark(painter: &egui::Painter, x: f32, strip: Rect, col: Color32) { + let (y0, y1) = (strip.min.y + 3.0, strip.max.y - 2.0); + painter.line_segment([pos2(x, y0), pos2(x, y1)], Stroke::new(2.5, col)); + for (y, d) in [(y0, 1.0), (y1, -1.0)] { + painter.add(egui::Shape::convex_polygon(vec![pos2(x - 4.5, y), pos2(x + 4.5, y), pos2(x, y + 5.0 * d)], col, Stroke::NONE)); + } +} + /// 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(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 05508451..beaa5846 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1121,8 +1121,11 @@ impl EffectcraftApp { for ev in self.session.drain_events() { match ev { effectcraft_engine::Event::OpenComp(_) => { - if !self.ui.locked_tabs.contains(&PanelKind::Composition.id()) { - self.ui.dock.activate(PanelKind::Composition); + // The comp's viewer and its Timeline tab come to the front. + for p in [PanelKind::Composition, PanelKind::Timeline] { + if !self.ui.locked_tabs.contains(&p.id()) { + self.ui.dock.activate(p); + } } self.ui.timeline.pps = None; } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index d78a1055..2f58b87b 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -535,7 +535,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("project.item.{}", id.0), r, &it.name); let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag()); app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name); - let resp = resp.union(nresp.clone()); + let resp = resp.union(nresp); if resp.clicked() { let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); if add { @@ -546,10 +546,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.session.state.project_selection = vec![*id]; } } - if nresp.double_clicked() { - // Double-click the name: rename in place. - ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone()))); - } else if resp.double_clicked() { + // Double-click opens the item, as in After Effects (a comp gets its own Timeline tab and + // the viewer); Enter or the context menu renames. + if resp.double_clicked() { match &it.kind { ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))), ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => { diff --git a/crates/ui-egui/tests/ui_fx_editors.rs b/crates/ui-egui/tests/ui_fx_editors.rs index 331df879..5334cf07 100644 --- a/crates/ui-egui/tests/ui_fx_editors.rs +++ b/crates/ui-egui/tests/ui_fx_editors.rs @@ -264,6 +264,9 @@ fn composition_tabs_and_scrolling_preview() { assert!(h.state().ui.locked_tabs.contains("Composition")); click_id(&mut h, "panel.tab.Composition.lock"); assert!(h.state().ui.locked_tabs.is_empty()); + // The Timeline's × closes its comp's tab (the panel stays, as in After Effects). + let shown = h.state().session.state.active_comp.unwrap(); click_id(&mut h, "panel.tab.Timeline.close"); - assert!(!h.state().ui.dock.contains(PanelKind::Timeline)); + assert!(!h.state().session.state.open_comps.contains(&shown)); + assert!(h.state().ui.dock.contains(PanelKind::Timeline)); } diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs new file mode 100644 index 00000000..11ddcb2b --- /dev/null +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -0,0 +1,146 @@ +//! Comp tabs and tab drags (egui_kittest): double-clicking a comp in the Project panel opens it +//! as its own Timeline tab (not a rename, and not in place of the comp shown), the tabs switch and +//! close comps, and a dragged tab lands where its insertion mark shows. + +use effectcraft_engine::Session; +use effectcraft_engine::project::ItemId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::{DockNode, PanelKind}; +use egui::{Event, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +/// "Pre" and "Main" (Main holds Pre); only Main is open. +fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut s = Session::default(); + let pre = s.execute("comp.new", json!({"name": "Pre", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.addItem", json!({"item": pre})).unwrap(); + s.state.open_comps = vec![ItemId(main)]; + s.state.active_comp = Some(ItemId(main)); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, pre, main) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { + let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).cloned().unwrap_or_else(|| { + let have: Vec<&String> = h.state().auto.elements.iter().map(|e| &e.id).filter(|i| i.starts_with("panel.") || i.starts_with("project.")).collect(); + panic!("no element {id}; have {have:?}") + }); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) +} + +fn click_n(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, n: u32) { + h.event(Event::PointerMoved(p)); + h.step(); + // All in one frame, straight into the input (the harness' event queue spreads presses over + // frames, longer apart than a double-click). + for _ in 0..n { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + h.step(); + h.run_steps(2); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=6 { + let f = k as f32 / 6.0; + h.event(Event::PointerMoved(from + (to - from) * f)); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.step(); + h.run_steps(2); +} + +/// The tabs of the docked group holding `p`, in order. +fn group_of(n: &DockNode, p: PanelKind) -> Option> { + match n { + DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)), + DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()), + DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()), + } +} + +fn open_comps(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.state.open_comps.iter().map(|c| c.0).collect() +} + +#[test] +fn project_double_click_opens_a_comp_as_its_own_timeline_tab() { + let (mut h, pre, main) = harness(); + // Double-click the precomp's name in the Project panel: it opens (no rename) next to Main. + let name = rect(&h, &format!("project.item.{pre}.name")); + click_n(&mut h, name.center(), 2); + assert_eq!(open_comps(&h), vec![main, pre]); + assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre))); + assert_eq!(h.state().session.project.item(ItemId(pre)).map(|i| i.name.clone()), Some("Pre".into())); + // One Timeline tab per comp, the newest last; the shown one answers to the panel's id. + let (tm, tp) = (rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}"))); + assert!(tp.min.x > tm.max.x, "{tm:?} {tp:?}"); + assert_eq!(rect(&h, "panel.tab.Timeline"), tp); + // Clicking Main's tab shows Main again; both stay open. + click_n(&mut h, tm.center(), 1); + assert_eq!(h.state().session.state.active_comp, Some(ItemId(main))); + assert_eq!(open_comps(&h), vec![main, pre]); + // × closes the shown comp's Timeline only: the panel stays with Pre. + let close = rect(&h, "panel.tab.Timeline.close"); + click_n(&mut h, close.center(), 1); + assert_eq!(open_comps(&h), vec![pre]); + assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre))); + assert!(h.state().ui.dock.contains(PanelKind::Timeline)); + // Double-clicking an open comp again adds no second tab. + let name = rect(&h, &format!("project.item.{pre}.name")); + click_n(&mut h, name.center(), 2); + assert_eq!(open_comps(&h), vec![pre]); +} + +#[test] +fn dragged_tabs_land_where_the_insertion_mark_is() { + let (mut h, _, _) = harness(); + let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone(); + assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::Timeline, PanelKind::RenderQueue])); + // Reorder within the group: Render Queue dropped on the left half of the Timeline tab goes + // before it. + let tl = rect(&h, "panel.tab.Timeline"); + let rq = rect(&h, "panel.tab.RenderQueue"); + drag(&mut h, rq.center(), pos2(tl.min.x + tl.width() * 0.2, tl.center().y)); + assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline])); + // Another group's tab dropped between them lands between them. + let (rq, tl) = (rect(&h, "panel.tab.RenderQueue"), rect(&h, "panel.tab.Timeline")); + let fx = rect(&h, "panel.tab.EffectControls"); + drag(&mut h, fx.center(), pos2((rq.max.x + tl.min.x) / 2.0 + 2.0, tl.center().y)); + assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::EffectControls, PanelKind::Timeline])); + // Past the last tab: last. + let (ec, tl) = (rect(&h, "panel.tab.EffectControls"), rect(&h, "panel.tab.Timeline")); + drag(&mut h, ec.center(), pos2(tl.max.x + 30.0, tl.center().y)); + assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline, PanelKind::EffectControls])); +} + +/// `cargo test -p effectcraft-ui-egui --test ui_tabs -- --ignored`: the insertion mark mid-drag +/// (`target/test-out/tab-drag.png`). +#[test] +#[ignore] +fn tab_drag_snapshot() { + let (mut h, _, _) = harness(); + let (tl, rq) = (rect(&h, "panel.tab.Timeline"), rect(&h, "panel.tab.RenderQueue")); + let fx = rect(&h, "panel.tab.EffectControls"); + let to = pos2((tl.max.x + rq.min.x) / 2.0, tl.center().y); + h.event(Event::PointerMoved(fx.center())); + h.step(); + h.event(Event::PointerButton { pos: fx.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=6 { + h.event(Event::PointerMoved(fx.center() + (to - fx.center()) * (k as f32 / 6.0))); + h.step(); + } + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out"); + std::fs::create_dir_all(dir).unwrap(); + h.render().expect("render").save(format!("{dir}/tab-drag.png")).unwrap(); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f789de9f..c6744898 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -85,7 +85,9 @@ Points are logical window coordinates. A target is `{id}` (the element's centre; | `ui.screenshot` | `{path?, panel?, id?}` | `{path, width, height}`: a PNG of the window, or cropped to one panel or element. | Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`, -`header.workspace.Animation`, `project.item.`, `viewer.comp`, `viewer.handle..`, +`panel.tab.Timeline.` (one Timeline tab per open comp; `panel.tab.Timeline` is the shown +one), `header.workspace.Animation`, `project.item.` (a double-click opens the item, Enter +renames it), `viewer.comp`, `viewer.handle..`, `timeline.layer..bar`, `timeline.layer..twirl`, `timeline.layer..row` (click selects, Enter or a double-click renames, drag reorders), `timeline.prop..stopwatch`, `timeline.key..`, `timeline.cti`, `effectControls.prop..value` and diff --git a/docs/parity.md b/docs/parity.md index 636c8359..be5baa25 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -422,6 +422,12 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph Editor keys are small squares whatever their interpolation, as in After Effects. +Since (M3.15): the Timeline shows one tab per open comp, as in After Effects. Double-clicking a +comp in the Project panel opens it as a new tab next to the comps already open; it no longer +renames (Enter or the context menu still do). A tab's × closes that comp's Timeline, not the +panel. A tab dragged over a tab strip shows an insertion mark between the tabs it will land +between, and lands there; this reorders a group's own tabs too. + Since (M5.16): Key Light (After Effects' Keylight 1.2) is checked end to end on a green-screen plate with an uneven screen, spill and soft edges (`effect.apply "Keylight (1.2)"`, pick, Clip Black / White, render). The colour eyedroppers of Keying effects sample the effect's input