diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 7cd457640..8364f67ec 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -176,11 +176,9 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result { // Read the geometry before editing, with the same expression-aware context the renderer // uses: an expression-driven corner radius, size or position must convert to what is on // screen, not to the property's underlying value. - let expr = s.expr.clone(); let prepared = { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let ctx = - EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) }; let mut prepared = Vec::new(); for (lid, uid) in &shapes { let Some(l) = comp.layer(*lid) else { continue }; diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 251531a07..7ad0d34c3 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -577,7 +577,7 @@ pub fn specs() -> Vec { "Mouse Event on Script Window Control", [], None, - "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", + "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), buttons? (held: 1 left, 2 right, 4 middle), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", always, scriptui::mouse ), diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 082f0c861..1ad4b9a40 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -759,6 +759,7 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { "screenX": num("screenX", -FAR, FAR, 0.0), "screenY": num("screenY", -FAR, FAR, 0.0), "button": num("button", 0.0, 2.0, 0.0).round(), + "buttons": num("buttons", 0.0, 7.0, 0.0).round(), "detail": num("detail", 0.0, 1000.0, clicks).round(), "altKey": flag("altKey"), "ctrlKey": flag("ctrlKey"), diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 4760d058b..178bd8be1 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -745,7 +745,8 @@ function __uiMouseWanted(c, inherited) { } // A ScriptUI MouseEvent. `v`: {x, y (relative to the target control), button (0 left, 1 middle, -// 2 right), detail (click count), screenX, screenY, altKey, ctrlKey, metaKey, shiftKey}. +// 2 right), buttons (held: 1 left, 2 right, 4 middle), detail (click count), screenX, screenY, +// altKey, ctrlKey, metaKey, shiftKey}. function __uiMouseEvent(type, target, v) { var num = function (x, d) { x = Number(x); @@ -765,6 +766,7 @@ function __uiMouseEvent(type, target, v) { timeStamp: new Date(), detail: num(v.detail, type === "mousemove" || type === "mouseover" || type === "mouseout" ? 0 : 1), button: num(v.button, 0), + buttons: num(v.buttons, 0), clientX: num(v.x, 0), clientY: num(v.y, 0), screenX: num(v.screenX, 0), diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index d12309b02..14460ed28 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -395,7 +395,11 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & return; } let mut st: MouseTrack = ui.data(|d| d.get_temp(id)).unwrap_or_default(); - let (latest, events, mods, time) = ui.input(|i| (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time)); + let (latest, events, mods, time, held) = ui.input(|i| { + let held = |b: egui::PointerButton, bit: u8| if i.pointer.button_down(b) { bit } else { 0 }; + let held = held(egui::PointerButton::Primary, 1) | held(egui::PointerButton::Secondary, 2) | held(egui::PointerButton::Middle, 4); + (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time, held) + }); let ctx = ui.ctx(); let layer = ui.layer_id(); let target = |p: egui::Pos2| -> Option<&Widget> { @@ -414,7 +418,7 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & w.id, json!({ "type": ty, "x": p.x - at.x, "y": p.y - at.y, "screenX": p.x, "screenY": p.y, - "button": button, "detail": detail, + "button": button, "buttons": held, "detail": detail, "altKey": mods.alt, "ctrlKey": mods.ctrl, "shiftKey": mods.shift, "metaKey": mods.mac_cmd, }), )); @@ -431,10 +435,15 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & } st.hover = now_id; } - if let Some((w, p)) = now - && st.at != Some(p) - { - send(w, "mousemove", p, 0, 0, acts); + // While a button is held, the control it was pressed on keeps getting the moves, inside or + // outside the window, as in After Effects (#2584). + let captured = st.down.and_then(|(c, _)| root.find(c)); + if let Some(p) = latest.filter(|p| st.at != Some(*p)) { + match (captured, now) { + (Some(c), _) => send(c, "mousemove", p, 0, 0, acts), + (None, Some((w, _))) => send(w, "mousemove", p, 0, 0, acts), + (None, None) => {} + } } st.at = latest; // Presses and releases. @@ -446,7 +455,9 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & egui::PointerButton::Secondary => 2, _ => continue, }; - let Some(w) = target(*pos) else { + // A release goes to the control the button was pressed on, wherever the pointer is. + let pressed_on = st.down.filter(|(_, db)| !*pressed && *db == b).and_then(|(c, _)| root.find(c)); + let Some(w) = pressed_on.or_else(|| target(*pos)) else { if !pressed { st.down = None; } @@ -460,7 +471,8 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & send(w, "mousedown", *pos, b, st.clicks, acts); } else { send(w, "mouseup", *pos, b, st.clicks, acts); - if st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { + let over = target(*pos).is_some_and(|t| t.id == w.id); + if over && st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { // The click with the pointer's details, after mousedown / mouseup, in place of // the plain click the control reported. acts.retain(|a| !matches!(a, Act::Click(aw, ac) if *aw == win && *ac == w.id)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 641a549a6..9f308e4be 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3591,7 +3591,15 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: } app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name); if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 { - actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); + // Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as + // property rows and Effect Controls headers do (#2552: several effects and styles for + // one animation preset). + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if add && app.session.state.selected_props.contains(&(layer.id, *uid)) { + actions.push(("prop.deselect".into(), json!({"layer": layer.id.0, "prop": uid}))); + } else { + actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false, "add": add}))); + } } if gr.double_clicked() { if fx.is_some() { diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index 938d1a827..09986f6d1 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -300,6 +300,47 @@ fn script_windows_send_mouse_events_to_listeners() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } +/// #2584: a drag that starts on a control keeps reaching it outside the script window (moves and +/// the release), and `MouseEvent.buttons` tells a drag from a hover. +#[test] +fn a_drag_keeps_its_control_past_the_window_edge() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Drag"); + var pad = w.add("group", undefined, undefined, { name: "pad" }); + pad.preferredSize = [120, 80]; + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [400, 20]; + var seen = []; + var rec = function (e) { seen.push(e.type + (e.clientX < 0 ? " out" : " in") + " B" + e.buttons); log.text = seen.join("|"); }; + pad.addEventListener("mousedown", rec); + pad.addEventListener("mousemove", rec); + pad.addEventListener("mouseup", rec); + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "drag.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(3); + let win = h.state().session.script_ui.windows[0].id; + let pad = rect(&h, &format!("scriptui.{win}.pad")); + let (from, to) = (pad.min + vec2(10.0, 20.0), pad.min + vec2(-300.0, 20.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.run_steps(1); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + let log = h.state().session.script_ui.windows[0].root.lookup(&json!("log")).cloned().unwrap().text; + assert!(log.starts_with("mousemove in B0|mousedown in B1|"), "a hover move has no button held: {log}"); + assert!(log.contains("mousedown in B1|mousemove out B1"), "moves outside the window still arrive, with the button held: {log}"); + assert!(log.ends_with("|mouseup out B0"), "the release outside reaches the control: {log}"); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); +} + fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { h.input_mut().events.push(Event::PointerMoved(c)); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index cd56db825..ae1e5a17f 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -453,3 +453,42 @@ fn effects_drag_to_a_new_place_in_the_timeline() { h.state_mut().session.execute("edit.undo", json!({})).unwrap(); assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop"); } + +/// #2552: Shift-clicking effect rows in the Timeline adds them to the selection (and a second +/// Shift-click takes one out), so one animation preset can hold several effects. +#[test] +fn shift_click_selects_several_effects_for_a_preset() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Gaussian Blur", "Invert"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone(); + let uids: Vec = effects.groups().map(|g| g.uid).collect(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(effects.uid); + open_layer(&mut h, l); + let shift_click = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| { + let p = center(h, &format!("timeline.group.{uid}.name")); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + }; + let selected = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::>(); + click(&mut h, &format!("timeline.group.{}.name", uids[0])); + assert_eq!(selected(&h), [uids[0]]); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0], uids[1]], "Shift adds the second effect"); + let path = std::env::temp_dir().join(format!("ec-2552-{}.ffx", std::process::id())); + let r = h.state_mut().session.execute("anim.savePreset", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["effects"], 2, "{r}"); + let _ = std::fs::remove_file(&path); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0]], "a second Shift-click takes it out"); +}