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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 12:35:11 +07:00
parent e008085a93
commit fab6dedd7c
3 changed files with 88 additions and 0 deletions

View File

@ -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 {

View File

@ -1063,6 +1063,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ctx.data_mut(|d| d.remove::<u64>(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));

View File

@ -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<String> {
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");
}