diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 3db0d090..d7f6e9a3 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -795,7 +795,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.close(); } if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() { - actions.push(("project.move".into(), json!({"items": [id.0], "folder": null}))); + actions.push(("project.move".into(), json!({"items": targets(app, *id, selected), "folder": null}))); ui.close(); } if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ea6579d1..cbd0b58b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1890,7 +1890,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, layer, &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), @@ -2104,7 +2104,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, layer, &mut actions); + layer_context_menu(&body, &comp, layer, &selected, &mut actions); let drag_key = format!("bar-{}", layer.id.0); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { if resp.dragged() { @@ -3469,8 +3469,16 @@ fn prop_menu( item(ui, "essentialGraphics", "Add Property to Essential Graphics", false, "essential.addProperty", json!({"comp": comp.0, "layer": l, "prop": uid})); } -fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) { - crate::widgets::context_menu(&resp, |ui| layer_menu(ui, &[layer.id.0], actions)); +/// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part +/// of the selection keeps the selection and the menu acts on all of it; right-clicking an +/// unselected layer selects only that layer. +fn layer_context_menu(resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], actions: &mut Vec<(String, serde_json::Value)>) { + let in_selection = selected.contains(&layer.id); + if resp.secondary_clicked() && !in_selection && !layer.switches.locked { + actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); + } + let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; + crate::widgets::context_menu(&resp, |ui| layer_menu(ui, &targets, actions)); } /// Shared layer actions for Timeline rows and Composition context menus. diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 7b1ca768..00337c92 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -430,3 +430,82 @@ fn timeline_selected_effect_row_highlights() { assert!(h.state().session.state.selected_props.contains(&(id, fx)), "the click selects the effect"); assert!(highlighted(&h), "the selected effect's row is highlighted"); } + +/// Right-clicking a layer in the Timeline keeps the selection when the layer is part of it, +/// so the context menu's commands act on every selected layer; right-clicking an unselected +/// layer selects only it (After Effects). Reported on Discord: Pre-compose and Delete from the +/// menu acted on the right-clicked layer only (#878). +#[test] +fn timeline_context_menu_acts_on_the_whole_selection() { + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + let row = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) + }; + let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center(); + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + // Outline row: Delete removes both selected layers. + h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); + h.run_steps(3); + let p = row(&h, ids[1]); + right_click(&mut h, p); + assert_eq!(h.state().session.state.selected_layers, vec![ids[0], ids[1]]); + h.get_by_label("Delete").click(); + h.run_steps(3); + let left: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + assert_eq!(left, vec![ids[2], ids[3]]); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + // Layer bar: same, with Duplicate. + h.state_mut().session.execute("layer.select", json!({"layers":[ids[2].0, ids[3].0]})).unwrap(); + h.run_steps(3); + let p = bar(&h, ids[3]); + right_click(&mut h, p); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 6); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + // An unselected row: right-clicking selects only it. + h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); + h.run_steps(3); + let p = row(&h, ids[3]); + right_click(&mut h, p); + assert_eq!(h.state().session.state.selected_layers, vec![ids[3]]); +} + +/// The Project panel's Move to Project Root acts on every selected item when the right-clicked +/// item is part of the selection, as Delete and Label do. +#[test] +fn project_context_move_to_root_acts_on_the_whole_selection() { + let mut h = harness(); + let solids: Vec = { + let p = &h.state().session.project; + p.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).map(|i| i.id.0).collect() + }; + assert!(solids.len() >= 2); + let folder = h.state_mut().session.execute("project.newFolder", json!({"name":"Bin"})).unwrap()["item"].as_u64().unwrap(); + h.state_mut().session.execute("project.move", json!({"items": solids, "folder": folder})).unwrap(); + h.state_mut().ui.project_open_folders.insert(folder); + h.state_mut().session.execute("project.select", json!({"items": [solids[0], solids[1]]})).unwrap(); + h.run_steps(3); + let p = rect(&h, &format!("project.item.{}.name", solids[1])).center(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.get_by_label("Move to Project Root").click(); + h.run_steps(3); + let project = &h.state().session.project; + for id in &solids[..2] { + assert_eq!(project.item(effectcraft_engine::project::ItemId(*id)).unwrap().parent, None); + } +}