From fab6dedd7cb7f723756ee87421897612f276b6de Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:35:11 +0700 Subject: [PATCH] Effect Controls: right-click shows the Effect menu (#227) Right-clicking the Effect Controls panel did nothing outside an effect's header. As in After Effects, a right-click anywhere a control has no menu of its own (the empty area, property names, buttons such as Reset) now opens the Effect menu: Effect Controls, Last Effect, Remove All, Manage Effects and every effect category, applying the chosen effect to the selected layers. It is the menu bar's own Effect menu tree and drawing code; an effect header keeps its own menu. Test: right_click_in_effect_controls_shows_the_effect_menu. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 12 ++++ crates/ui-egui/src/panels/effect_controls.rs | 10 +++ crates/ui-egui/tests/ui_effect_controls.rs | 66 ++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4bdab23f..6cb70b82 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1549,6 +1549,18 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } +/// The entries of top-level menu `name` (the Effect menu is the Effect Controls panel's context +/// menu); returns the chosen command and its params. +pub(crate) fn menu_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, name: &str) -> Option<(String, Value)> { + let mut clicked = None; + if let Some(MenuNode::Submenu { children, .. }) = + effectcraft_engine::menus::menu_bar().iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == name)) + { + menu_nodes(app, ui, children, &mut clicked); + } + clicked.map(|(id, params)| (id, if params.is_null() { json!({}) } else { params })) +} + fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], clicked: &mut Option<(String, Value)>) { for n in nodes { match n { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index c579e8e9..27c034e8 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1063,6 +1063,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.remove::(drag_id)); } } + // Right-click anywhere a control has no menu of its own: the Effect menu, which applies to + // the selected layers (After Effects). Drawn after the rows, whose menus open first. + let menu_id = bg.id.with("effect-menu"); + let open = ui.input(|i| i.pointer.secondary_clicked()) + && ui.rect_contains_pointer(body) + && (!egui::Popup::is_any_open(&ctx) || egui::Popup::is_id_open(&ctx, menu_id)); + egui::Popup::context_menu(&bg).id(menu_id).open_memory(open.then_some(egui::SetOpenCommand::Bool(true))).show(|ui| { + ui.set_min_width(200.0); + actions.extend(crate::menus::menu_contents(app, ui, "Effect")); + }); ui.set_clip_rect(panel_clip); let content_h = y + scroll - body.min.y; scroll = scroll.min((content_h - body.height()).max(0.0)); diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 63fc4954..a31e9315 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -9,6 +9,7 @@ use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::PanelKind; use effectcraft_ui_egui::state::FxPick; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; struct Ids { @@ -269,3 +270,68 @@ fn angle_revolutions_scrub_and_take_typing() { type_into(&mut h, &format!("properties.prop.{rot}.revolutions"), "1"); assert_eq!(rotation(&h), 360.0); } + +fn right_click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + h.step(); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + h.run_steps(3); +} + +/// #227: right-clicking Effect Controls where no control has a menu of its own shows the +/// Effect menu (After Effects), which applies an effect to the selected layers; an effect's +/// header keeps its own menu. +#[test] +fn right_click_in_effect_controls_shows_the_effect_menu() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "A", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"name": "B", "color": "#804060"})).unwrap()["layer"].as_u64().unwrap(); + let fill = s.execute("effect.apply", json!({"layers": [b], "effect": "Fill"})).unwrap()["effects"][0].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(b), LayerId(a)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(3); + let category = " Blur & Sharpen ⏵"; + // An effect's header: its own menu. + let header = rect_of(&h, &format!("effectControls.effect.{fill}")).center(); + right_click(&mut h, header); + assert!(h.query_by_label("Duplicate").is_some(), "the effect's own menu"); + assert!(h.query_by_label(category).is_none()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + // A control without a menu (the header's Reset): the Effect menu. + let reset = rect_of(&h, &format!("effectControls.effect.{fill}.reset")).center(); + right_click(&mut h, reset); + assert!(h.query_by_label(category).is_some()); + assert_eq!(layer(&h.state().session, b).effects().map(|f| f.groups().count()), Some(1), "Reset didn't run"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.query_by_label(category).is_none()); + // The empty area below it: the Effect menu. + let panel = rect_of(&h, "panel.EffectControls"); + right_click(&mut h, egui::pos2(panel.center().x, panel.max.y - 30.0)); + let cat = h.query_by_label(category).expect("the Effect menu's categories").rect(); + assert!(h.query_by_label_contains("Remove All").is_some(), "the whole Effect menu"); + hover(&mut h, cat.center()); + let at = h.query_by_label(" Gaussian Blur").expect("Blur & Sharpen ▸ Gaussian Blur").rect().center(); + hover(&mut h, egui::pos2(at.x, cat.center().y)); + for k in 1..=10 { + hover(&mut h, egui::pos2(at.x, cat.center().y + (at.y - cat.center().y) * k as f32 / 10.0)); + } + click(&mut h, at); + let names = |h: &Harness<'_, EffectcraftApp>, l: u64| -> Vec { + layer(&h.state().session, l).effects().map(|f| f.groups().map(|g| g.name.clone()).collect()).unwrap_or_default() + }; + assert_eq!(names(&h, b), ["Fill", "Gaussian Blur"]); + assert_eq!(names(&h, a), ["Gaussian Blur"], "every selected layer gets it"); + assert!(h.query_by_label(category).is_none(), "the menu closed"); +}