From aa31ffc2fb306d67d8d44f6c15020c6b33706f15 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 11:31:27 -0400 Subject: [PATCH] Project: opening a comp brings up its Composition panel (#90) Opening a comp (Project double-click, Open Composition, a Timeline tab) now reopens the Composition panel if it was closed and brings it (and the Timeline) to the front, as in After Effects. A closed Composition panel also reopens in the centre (with the Layer panel or other viewers, else above the Timeline) instead of beside Effects & Presets, which also fixes Window > Composition. App::raise_panel opens/fronts a panel without moving the focus; show_panel builds on it. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit adf2c6dac78ce426ea0bf6b4199b7238d8ad99dd) --- crates/ui-egui/src/lib.rs | 37 +++++++-- crates/ui-egui/src/panels/viewers.rs | 8 +- crates/ui-egui/tests/ui_panel_focus.rs | 110 +++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/ui_panel_focus.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c50859bf..764c6b69 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -381,17 +381,41 @@ impl EffectcraftApp { v } + /// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and + /// give it the focus: Window ▸ . 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) { + if !self.ui.floating.iter().any(|f| f.panels.contains(&p)) && self.ui.maximized.is_some_and(|m| m != p) { self.ui.maximized = None; } + self.raise_panel(p); + self.ui.focused = p; + } + + /// Bring panel `p` to the front of its group, opening it in its usual place if it is closed, + /// without moving the focus (a comp opening brings up its viewer, an effect applied brings up + /// Effect Controls). + pub fn raise_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); + return; + } if !self.ui.dock.contains(p) { + // The Composition panel lives in the centre, with the Layer panel and other viewers; + // with none of those left, above the Timeline. + let centre = |d: &dock::DockNode| { + let mut v = Vec::new(); + d.panels(&mut v); + v.into_iter().find(|q| matches!(q, PanelKind::Layer | PanelKind::Viewer(_) | PanelKind::Flowchart | PanelKind::Footage)) + }; let near = match p { + PanelKind::Composition => match centre(&self.ui.dock) { + Some(q) => q, + None if self.ui.dock.dock_panel(p, PanelKind::Timeline, dock::Zone::Top) => { + self.ui.dock.activate(p); + return; + } + None => PanelKind::EffectsPresets, + }, PanelKind::Layer | PanelKind::Flowchart | PanelKind::Viewer(_) => PanelKind::Composition, PanelKind::RenderQueue => PanelKind::Timeline, PanelKind::EffectControls | PanelKind::History => PanelKind::Project, @@ -400,7 +424,6 @@ impl EffectcraftApp { self.ui.dock.open_near(p, near); } self.ui.dock.activate(p); - self.ui.focused = p; } pub fn render_source(&self) -> RenderSource { diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 36fdf4e3..0527f137 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -192,10 +192,12 @@ pub fn on_open_comp(app: &mut EffectcraftApp, comp: ItemId) { }; } app.ui.viewers.insert(app.ui.active_viewer, Some(comp.0)); - // The comp's viewer and its Timeline tab come to the front (unless locked). + // The comp's viewer and its Timeline tab come to the front (unless locked), reopened if they + // were closed (as in After Effects). for p in [active_panel(app), PanelKind::Timeline] { - if !app.ui.locked_tabs.contains(&p.id()) { - app.ui.dock.activate(p); + let closed = !app.ui.dock.contains(p) && !app.ui.floating.iter().any(|f| f.panels.contains(&p)); + if closed || !app.ui.locked_tabs.contains(&p.id()) { + app.raise_panel(p); } } } diff --git a/crates/ui-egui/tests/ui_panel_focus.rs b/crates/ui-egui/tests/ui_panel_focus.rs new file mode 100644 index 00000000..d82f11cf --- /dev/null +++ b/crates/ui-egui/tests/ui_panel_focus.rs @@ -0,0 +1,110 @@ +//! Panels that come up on their own (egui_kittest), as in After Effects: opening a comp brings up +//! its Composition panel even when it was closed (#90). + +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; + +/// Comp "Main" (open) holding a solid, and comp "Other" (not open). +fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut s = Session::default(); + let other = s.execute("comp.new", json!({"name": "Other", "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.newSolid", json!({"name": "Red", "color": [1.0, 0.0, 0.0, 1.0]})).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, other, 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).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(); + 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); +} + +/// 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 invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: serde_json::Value) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(2); +} + +/// Issue #90: with the Composition panel closed (the centre shows the Layer panel), opening a +/// comp (double-click in the Project panel, or Open Composition) brings the Composition panel +/// back, in front, where it normally lives (with the Layer panel), and so does Window ▸ +/// Composition. +#[test] +fn opening_a_comp_brings_up_a_closed_composition_panel() { + let (mut h, other, main) = harness(); + assert_eq!(group_of(&h.state().ui.dock, PanelKind::Composition), Some(vec![PanelKind::Composition, PanelKind::Layer])); + invoke(&mut h, "window.closePanel", json!({"panel": "Composition"})); + assert!(!h.state().ui.dock.contains(PanelKind::Composition)); + assert!(h.state().ui.dock.is_visible(PanelKind::Layer), "the centre shows the Layer panel"); + + // Double-click "Other" in the Project panel. + let name = rect(&h, &format!("project.item.{other}.name")); + click_n(&mut h, name.center(), 2); + assert_eq!(h.state().session.active_comp_id(), Some(ItemId(other))); + let dock = &h.state().ui.dock; + assert!(dock.is_visible(PanelKind::Composition), "the Composition panel is back and in front"); + assert_eq!(group_of(dock, PanelKind::Composition), Some(vec![PanelKind::Layer, PanelKind::Composition]), "back in the centre"); + assert!(h.state().auto.elements.iter().any(|e| e.id == "panel.Composition"), "the Composition panel is drawn"); + + // Open Composition (the Project panel's context menu) with the panel closed again. + invoke(&mut h, "window.closePanel", json!({"panel": "Composition"})); + invoke(&mut h, "comp.open", json!({"comp": main})); + assert_eq!(h.state().session.active_comp_id(), Some(ItemId(main))); + assert!(h.state().ui.dock.is_visible(PanelKind::Composition)); + assert_eq!(group_of(&h.state().ui.dock, PanelKind::Composition), Some(vec![PanelKind::Layer, PanelKind::Composition])); + + // Window ▸ Composition reopens it in the centre too (not beside Effects & Presets). + invoke(&mut h, "window.closePanel", json!({"panel": "Composition"})); + invoke(&mut h, "window.panel", json!({"panel": "Composition"})); + assert_eq!(group_of(&h.state().ui.dock, PanelKind::Composition), Some(vec![PanelKind::Layer, PanelKind::Composition])); + assert!(h.state().ui.dock.is_visible(PanelKind::Composition)); +} + +/// Issue #90 in a workspace without a Layer panel: closing Composition leaves only the Timeline, +/// and opening a comp brings the viewer back above it. +#[test] +fn opening_a_comp_in_the_minimal_workspace_docks_the_viewer_above_the_timeline() { + let (mut h, other, _) = harness(); + invoke(&mut h, "window.workspace", json!({"name": "Minimal"})); + invoke(&mut h, "window.closePanel", json!({"panel": "Composition"})); + assert!(!h.state().ui.dock.contains(PanelKind::Composition)); + invoke(&mut h, "comp.open", json!({"comp": other})); + match &h.state().ui.dock { + DockNode::Split { vertical: true, a, b, .. } => { + assert_eq!(group_of(a, PanelKind::Composition), Some(vec![PanelKind::Composition]), "the viewer is on top"); + assert!(b.contains(PanelKind::Timeline)); + } + other => panic!("expected the viewer above the Timeline, got {other:?}"), + } +}