diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index acbe8ac97..2b5b58509 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -529,6 +529,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } return r; } + // An effect added to a layer (Effect menu, Last Effect, dropped on a layer) brings + // up Effect Controls on that layer, as in After Effects. + if matches!(id, "effect.apply" | "effect.applyLast") { + let r = run_engine(app, ctx, id, params.clone()); + if r.is_ok() { + crate::panels::effect_controls::reveal_applied(app, ¶ms); + } + return r; + } if id == "layer.rename" && params.get("name").is_none() { crate::panels::timeline::begin_rename(app, ctx); return Ok(Value::Null); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 780fdf380..0432d6c34 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -39,6 +39,27 @@ fn selected_layer(app: &EffectcraftApp) -> Option { comp.layer(*id).cloned() } +/// An effect was just applied (`effect.apply` / `effect.applyLast` with `params`): Effect +/// Controls comes up (opened if closed, brought to the front, the focus left where it is) on the +/// layer the effect went to, with the new effect selected, as in After Effects. +pub fn reveal_applied(app: &mut EffectcraftApp, params: &serde_json::Value) { + // `effect.apply` selects the new effect on (the last of) the layers it went to. + let target = app.session.state.selected_props.last().map(|(l, _)| *l); + if let Some(l) = target + && !app.session.state.selected_layers.contains(&l) + && params.get("comp").is_none() + && app.session.active_comp().is_some_and(|c| c.layer(l).is_some()) + { + // Show that layer (dropped on an unselected one), keeping the new effect selected. + let props = app.session.state.selected_props.clone(); + match app.session.execute("layer.select", json!({"layers": [l.0]})) { + Ok(_) => app.session.state.selected_props = props, + Err(e) => app.ui.status = e.to_string(), + } + } + app.raise_panel(crate::dock::PanelKind::EffectControls); +} + /// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate /// drags are separate undo steps while one drag is one step. pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index c8315c97b..0f7b49b1e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1669,7 +1669,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); } if gr.double_clicked() { - ui_actions.push(UiAct::ToggleGroup(*uid)); + if fx.is_some() { + // An effect's name: open it in Effect Controls (as in After Effects). + actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); + actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); + actions.push(("window.panel".into(), json!({"panel": "EffectControls"}))); + } else { + ui_actions.push(UiAct::ToggleGroup(*uid)); + } } } RowKind::Waveform { item } => { diff --git a/crates/ui-egui/tests/ui_panel_focus.rs b/crates/ui-egui/tests/ui_panel_focus.rs index d82f11cf8..f87c3dddc 100644 --- a/crates/ui-egui/tests/ui_panel_focus.rs +++ b/crates/ui-egui/tests/ui_panel_focus.rs @@ -1,5 +1,6 @@ //! 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). +//! its Composition panel even when it was closed (#90); adding an effect, or double-clicking one +//! in the Timeline, brings up Effect Controls (#87). use effectcraft_engine::Session; use effectcraft_engine::project::ItemId; @@ -108,3 +109,81 @@ fn opening_a_comp_in_the_minimal_workspace_docks_the_viewer_above_the_timeline() other => panic!("expected the viewer above the Timeline, got {other:?}"), } } + +/// The id of layer `name` in the active comp. +fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 { + h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0 +} + +fn try_invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: serde_json::Value) -> serde_json::Value { + let ctx = h.ctx.clone(); + let r = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(2); + r +} + +fn ec_up(h: &Harness<'_, EffectcraftApp>) -> bool { + h.state().ui.dock.is_visible(PanelKind::EffectControls) && h.state().auto.elements.iter().any(|e| e.id == "panel.EffectControls") +} + +/// Issue #87: adding an effect to a layer from the Effect menu (and Last Effect), or dropping one +/// on a layer in the Timeline (`effect.apply` with its `layers`), brings up Effect Controls, even +/// when it was closed, on the layer the effect went to; the focus stays where it was. +#[test] +fn applying_an_effect_brings_up_effect_controls_on_that_layer() { + let (mut h, _, _) = harness(); + invoke(&mut h, "layer.newSolid", json!({"name": "Blue", "color": [0.0, 0.0, 1.0, 1.0]})); + let (red, blue) = (layer_id(&h, "Red"), layer_id(&h, "Blue")); + // Effect Controls shares its group with the Project panel, which is in front. + invoke(&mut h, "window.panel", json!({"panel": "Project"})); + assert!(!h.state().ui.dock.is_visible(PanelKind::EffectControls)); + + // Effect ▸ Blur & Sharpen ▸ Gaussian Blur on the selected layer. + invoke(&mut h, "layer.select", json!({"layers": [red]})); + h.state_mut().ui.focused = PanelKind::Timeline; + try_invoke(&mut h, "effect.apply", json!({"effect": "ec.blur.gaussian"})); + assert!(ec_up(&h), "Effect Controls came to the front"); + assert_eq!(h.state().ui.focused, PanelKind::Timeline, "the focus stays"); + + // Closed: Effect ▸ Last Effect reopens it. + invoke(&mut h, "window.closePanel", json!({"panel": "EffectControls"})); + assert!(!h.state().ui.dock.contains(PanelKind::EffectControls)); + try_invoke(&mut h, "effect.applyLast", json!({})); + assert!(ec_up(&h), "Effect Controls reopened"); + + // Dropped on the other (unselected) layer in the Timeline: Effect Controls shows that layer, + // with the new effect selected. + invoke(&mut h, "window.closePanel", json!({"panel": "EffectControls"})); + let r = try_invoke(&mut h, "effect.apply", json!({"effect": "ec.blur.gaussian", "layers": [blue]})); + let uid = r["effects"][0].as_u64().unwrap(); + assert!(ec_up(&h)); + let st = &h.state().session.state; + assert_eq!(st.selected_layers.iter().map(|l| l.0).collect::>(), vec![blue], "Effect Controls shows the layer it went to"); + assert_eq!(st.selected_props.iter().map(|(l, u)| (l.0, *u)).collect::>(), vec![(blue, uid)], "the new effect is selected"); +} + +/// Issue #87: double-clicking an effect's name under its layer in the Timeline opens it in +/// Effect Controls (instead of twirling it). +#[test] +fn double_clicking_an_effect_in_the_timeline_opens_effect_controls() { + let (mut h, _, _) = harness(); + invoke(&mut h, "layer.newSolid", json!({"name": "Blue", "color": [0.0, 0.0, 1.0, 1.0]})); + let red = layer_id(&h, "Red"); + let r = try_invoke(&mut h, "effect.apply", json!({"effect": "ec.blur.gaussian", "layers": [red]})); + let uid = r["effects"][0].as_u64().unwrap(); + // Reveal the layer's effects (E), then close Effect Controls and select the other layer. + invoke(&mut h, "layer.select", json!({"layers": [red]})); + invoke(&mut h, "timeline.revealAdd.effects", json!({})); + invoke(&mut h, "window.closePanel", json!({"panel": "EffectControls"})); + let blue = layer_id(&h, "Blue"); + invoke(&mut h, "layer.select", json!({"layers": [blue]})); + assert!(!h.state().ui.dock.contains(PanelKind::EffectControls)); + + let name = rect(&h, &format!("timeline.group.{uid}.name")); + click_n(&mut h, name.center(), 2); + assert!(ec_up(&h), "Effect Controls opened"); + let st = &h.state().session.state; + assert_eq!(st.selected_layers.iter().map(|l| l.0).collect::>(), vec![red], "on the effect's layer"); + assert_eq!(st.selected_props.iter().map(|(l, u)| (l.0, *u)).collect::>(), vec![(red, uid)], "with the effect selected"); + assert!(!h.state().ui.timeline.open_groups.contains(&uid), "the effect did not twirl open"); +}