mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 12:37:08 -06:00
#766: deliver ScriptUI mouse events to script listeners
Problem: ScriptUI controls never sent mouse events to scripts.
addEventListener("mousedown" | "mouseup" | "mousemove" | "mouseover" |
"mouseout" | "click") listeners never ran, so custom-drawn controls
(onDraw + mouse, as DuScriptUI builds them) could not react to the pointer.
docs/plugins.md listed this as missing.
Fix: scriptui.js builds a ScriptUI MouseEvent (clientX/clientY relative to
the target, button, detail = click count, screenX/Y, modifier keys,
getModifierState, stopPropagation, preventDefault) and delivers it in the
capture, target and bubble phases. addEventListener takes the capture flag.
The parent walk is bounded, so a script-made parent cycle cannot hang it.
Each published control lists the mouse events a listener wants
(Widget::mouse, inherited from containers). The new scriptui.mouse
command dispatches one event with clamped, finite values and rejects
unknown types and disabled controls. The egui ScriptUI view hit-tests the
pointer over script windows and docked panels, skipping covered layers and
windows blocked by a modal dialog. It sends mouseover/mouseout,
mousemove, mousedown/mouseup with click counts, and a click carrying the
pointer details in place of the plain click, so a checkbox still toggles
once. Handlers run under the existing script limits; a throwing listener
reports an error and the window keeps working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9a7d3e7699
commit
1c06a793a5
@@ -90,3 +90,5 @@ mod tests_panels;
|
||||
mod tests_ui;
|
||||
#[cfg(test)]
|
||||
mod tests_ui_more;
|
||||
#[cfg(test)]
|
||||
mod tests_ui_mouse;
|
||||
|
||||
@@ -594,13 +594,21 @@ function __uiProto() {
|
||||
if (e.indexOf("on") !== 0) e = "on" + e.charAt(0).toUpperCase() + e.slice(1);
|
||||
this.__fire(e);
|
||||
},
|
||||
addEventListener: function (name, fn) {
|
||||
// `capture`: the listener runs in the capture phase of mouse events (on the way down from
|
||||
// the window to the target) instead of the bubble phase.
|
||||
addEventListener: function (name, fn, capture) {
|
||||
var k = String(name).toLowerCase();
|
||||
if (capture) {
|
||||
if (!this.__capture) this.__capture = Object.create(null);
|
||||
(this.__capture[k] = this.__capture[k] || []).push(fn);
|
||||
return;
|
||||
}
|
||||
(this.__listeners[k] = this.__listeners[k] || []).push(fn);
|
||||
},
|
||||
removeEventListener: function (name, fn) {
|
||||
removeEventListener: function (name, fn, capture) {
|
||||
var k = String(name).toLowerCase();
|
||||
if (this.__listeners[k]) this.__listeners[k] = this.__listeners[k].filter(function (f) { return f !== fn; });
|
||||
var m = capture ? this.__capture : this.__listeners;
|
||||
if (m && m[k]) m[k] = m[k].filter(function (f) { return f !== fn; });
|
||||
},
|
||||
dispatchEvent: function (e) { this.__fire("on" + String(e.type || e).charAt(0).toUpperCase() + String(e.type || e).slice(1)); },
|
||||
__fire: function (name) {
|
||||
@@ -715,7 +723,91 @@ function __uiSize(d) {
|
||||
return [w > 0 ? w : 0, h > 0 ? h : 0];
|
||||
}
|
||||
|
||||
function __uiJson(c) {
|
||||
// ScriptUI mouse events: delivered to `addEventListener` listeners of the control under the
|
||||
// pointer and its containers (capture, target, bubble), as the JavaScript Tools Guide documents.
|
||||
var __uiMouseKinds = ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "click"];
|
||||
|
||||
function __uiMouseListeners(c, type, capture) {
|
||||
var m = capture ? c.__capture : c.__listeners;
|
||||
if (!m || !Object.prototype.hasOwnProperty.call(m, type)) return [];
|
||||
var l = m[type];
|
||||
return l instanceof Array ? l.slice() : [];
|
||||
}
|
||||
|
||||
// The mouse events that reach a listener at `c`: its own, and its containers' (`inherited`).
|
||||
function __uiMouseWanted(c, inherited) {
|
||||
var out = inherited.slice();
|
||||
for (var i = 0; i < __uiMouseKinds.length; i++) {
|
||||
var t = __uiMouseKinds[i];
|
||||
if (out.indexOf(t) < 0 && (__uiMouseListeners(c, t, false).length || __uiMouseListeners(c, t, true).length)) out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 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}.
|
||||
function __uiMouseEvent(type, target, v) {
|
||||
var num = function (x, d) {
|
||||
x = Number(x);
|
||||
return isFinite(x) ? x : d;
|
||||
};
|
||||
var keys = { alt: !!v.altKey, control: !!v.ctrlKey, ctrl: !!v.ctrlKey, meta: !!v.metaKey, shift: !!v.shiftKey };
|
||||
return {
|
||||
type: type,
|
||||
target: target,
|
||||
currentTarget: target,
|
||||
eventPhase: "none",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
captures: true,
|
||||
view: target.window,
|
||||
relatedTarget: undefined,
|
||||
timeStamp: new Date(),
|
||||
detail: num(v.detail, type === "mousemove" || type === "mouseover" || type === "mouseout" ? 0 : 1),
|
||||
button: num(v.button, 0),
|
||||
clientX: num(v.x, 0),
|
||||
clientY: num(v.y, 0),
|
||||
screenX: num(v.screenX, 0),
|
||||
screenY: num(v.screenY, 0),
|
||||
altKey: keys.alt,
|
||||
ctrlKey: keys.ctrl,
|
||||
metaKey: keys.meta,
|
||||
shiftKey: keys.shift,
|
||||
defaultPrevented: false,
|
||||
__stopped: false,
|
||||
stopPropagation: function () { this.__stopped = true; },
|
||||
preventDefault: function () { this.defaultPrevented = true; },
|
||||
getModifierState: function (k) { return !!keys[String(k).toLowerCase()]; },
|
||||
toString: function () { return "[object MouseEvent]"; }
|
||||
};
|
||||
}
|
||||
|
||||
// Deliver a mouse event to `target`: capture listeners from the window down, the target's own
|
||||
// listeners, then bubble listeners back up. stopPropagation() ends it after the current control.
|
||||
function __uiMouseFire(target, type, v) {
|
||||
var path = [];
|
||||
// A tree built by add() is shallow; the bound keeps a script's own `parent` cycle harmless.
|
||||
for (var p = target; p && path.length < 256; p = p.parent) path.unshift(p);
|
||||
var e = __uiMouseEvent(type, target, v);
|
||||
var run = function (c, phase, capture) {
|
||||
var l = __uiMouseListeners(c, type, capture);
|
||||
e.currentTarget = c;
|
||||
e.eventPhase = phase;
|
||||
for (var i = 0; i < l.length; i++) if (typeof l[i] === "function") l[i].call(c, e);
|
||||
};
|
||||
var n = path.length - 1;
|
||||
for (var i = 0; i < n; i++) {
|
||||
run(path[i], "capture", true);
|
||||
if (e.__stopped) return e;
|
||||
}
|
||||
run(target, "target", true);
|
||||
run(target, "target", false);
|
||||
for (var j = n - 1; j >= 0 && !e.__stopped; j--) run(path[j], "bubble", false);
|
||||
return e;
|
||||
}
|
||||
|
||||
function __uiJson(c, inherited) {
|
||||
var mouse = __uiMouseWanted(c, inherited || []);
|
||||
var ps = __uiSize(c.preferredSize);
|
||||
if (c.type === "edittext" && c.characters > 0) ps = [c.characters * 7 + 12, ps ? ps[1] : 0];
|
||||
var o = {
|
||||
@@ -730,6 +822,7 @@ function __uiJson(c) {
|
||||
children: []
|
||||
};
|
||||
if (ps) o.preferredSize = ps;
|
||||
if (mouse.length) o.mouse = mouse;
|
||||
if (c.__ub && !c.__root) o.fixedBounds = c.__ub;
|
||||
if (c.__root && c.__ub) o.fixedBounds = [0, 0, c.__ub[2] - c.__ub[0], c.__ub[3] - c.__ub[1]];
|
||||
if (__uiContainers[c.type]) {
|
||||
@@ -739,7 +832,7 @@ function __uiJson(c) {
|
||||
if (m) o.margins = m;
|
||||
if (typeof c.spacing === "number") o.spacing = c.spacing;
|
||||
var kids = c.children;
|
||||
for (var i = 0; i < kids.length; i++) o.children.push(__uiJson(kids[i]));
|
||||
for (var i = 0; i < kids.length; i++) o.children.push(__uiJson(kids[i], mouse));
|
||||
}
|
||||
if (c.alignment !== null && c.alignment !== undefined) o.alignment = __uiAlign(c.alignment);
|
||||
if (c.type === "checkbox" || c.type === "radiobutton") o.checked = !!c.value;
|
||||
@@ -824,16 +917,26 @@ function __uiDispatch(winId, id, kind, valueJson) {
|
||||
for (var i = 0; i < sib.length; i++) if (sib[i] !== c && sib[i].type === "radiobutton") sib[i].value = false;
|
||||
c.value = true;
|
||||
};
|
||||
// A mouse click carries the pointer's details ({x, y, button, detail, …keys}).
|
||||
var mouse = v !== null && typeof v === "object" && !(v instanceof Array) ? v : null;
|
||||
if (kind !== "click" && __uiMouseKinds.indexOf(kind) >= 0) {
|
||||
__uiMouseFire(c, kind, mouse || {});
|
||||
return;
|
||||
}
|
||||
if (kind === "click") {
|
||||
if (c.type === "checkbox") c.value = !c.value;
|
||||
else if (c.type === "radiobutton") radio();
|
||||
else if (c.type === "tab" && c.parent) {
|
||||
c.parent.selection = c;
|
||||
c.parent.__fire("onChange");
|
||||
if (mouse) __uiMouseFire(c, "click", mouse);
|
||||
return;
|
||||
}
|
||||
var handled = c.__handlers().indexOf("onClick") >= 0;
|
||||
c.__fire("onClick");
|
||||
if (mouse) {
|
||||
if (typeof c.onClick === "function") c.onClick.call(c);
|
||||
__uiMouseFire(c, "click", mouse);
|
||||
} else c.__fire("onClick");
|
||||
// Dialog buttons without handlers: OK closes with 1, Cancel with 2.
|
||||
if (!handled && c.type === "button" && w.__kind === "dialog") {
|
||||
var n = String(c.name || c.properties.name || c.text).toLowerCase();
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! ScriptUI mouse events (#766): `addEventListener("mousedown" | "mouseup" | "mousemove" |
|
||||
//! "mouseover" | "mouseout" | "click", handler)` listeners receive a MouseEvent through
|
||||
//! `scriptui.mouse`, with capture / target / bubble phases as the JavaScript Tools Guide
|
||||
//! documents.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default()));
|
||||
s
|
||||
}
|
||||
|
||||
const CANVAS: &str = r#"
|
||||
var w = new Window("palette", "Canvas");
|
||||
var g = w.add("group", undefined, undefined, { name: "row" });
|
||||
var pad = g.add("group", undefined, undefined, { name: "pad" });
|
||||
pad.preferredSize = [120, 80];
|
||||
var box = w.add("checkbox", undefined, "Snap", { name: "snap" });
|
||||
var plain = w.add("statictext", undefined, "no listeners", { name: "plain" });
|
||||
function rec(tag) {
|
||||
return function (e) {
|
||||
writeLn(tag + ":" + e.type + "@" + e.eventPhase + " " + e.clientX + "," + e.clientY + " b" + e.button + " d" + e.detail +
|
||||
(e.shiftKey ? " shift" : "") + (e.altKey ? " alt" : "") + (e.getModifierState("Control") ? " ctrl" : "") +
|
||||
(e.currentTarget === this ? "" : " WRONG-THIS") + " " + e.target.properties.name);
|
||||
};
|
||||
}
|
||||
pad.addEventListener("mousedown", rec("pad"));
|
||||
pad.addEventListener("mousemove", rec("pad"));
|
||||
pad.addEventListener("mouseover", rec("pad"));
|
||||
pad.addEventListener("mouseout", rec("pad"));
|
||||
pad.addEventListener("mouseup", rec("pad"));
|
||||
g.addEventListener("mousedown", rec("row"));
|
||||
w.addEventListener("mousedown", rec("win"), true);
|
||||
box.onClick = function () { writeLn("onClick " + box.value); };
|
||||
box.addEventListener("click", rec("box"));
|
||||
w.show();
|
||||
"#;
|
||||
|
||||
fn lines(r: &Value) -> Vec<String> {
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
r["output"].as_str().unwrap_or_default().lines().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_events_reach_listeners_with_pointer_details() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, CANVAS, "canvas.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
// The published tree says which controls want which mouse events (containers' listeners
|
||||
// reach their children: events bubble).
|
||||
let pad = w.root.lookup(&json!("pad")).unwrap();
|
||||
assert_eq!(pad.mouse, ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout"]);
|
||||
assert_eq!(w.root.lookup(&json!("snap")).unwrap().mouse, ["mousedown", "click"], "the window's capture listener + its own");
|
||||
assert_eq!(w.root.lookup(&json!("plain")).unwrap().mouse, ["mousedown"]);
|
||||
// A press: capture on the window, the target, then bubbling up through the group.
|
||||
let r = s
|
||||
.execute(
|
||||
"scriptui.mouse",
|
||||
json!({"widget": "pad", "type": "mousedown", "x": 12.5, "y": 30, "button": 2, "detail": 2, "shiftKey": true, "ctrlKey": true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
lines(&r),
|
||||
[
|
||||
"win:mousedown@capture 12.5,30 b2 d2 shift ctrl pad",
|
||||
"pad:mousedown@target 12.5,30 b2 d2 shift ctrl pad",
|
||||
"row:mousedown@bubble 12.5,30 b2 d2 shift ctrl pad",
|
||||
]
|
||||
);
|
||||
// Hover and moves carry no click count; x / y default to the control's centre.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "pad", "type": "mouseover"})).unwrap();
|
||||
assert_eq!(lines(&r), ["pad:mouseover@target 60,40 b0 d0 pad"]);
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "pad", "type": "MouseMove", "x": 3, "y": 4, "altKey": true})).unwrap();
|
||||
assert_eq!(lines(&r), ["pad:mousemove@target 3,4 b0 d0 alt pad"]);
|
||||
// An event nobody listens for on a control runs nothing (but still answers).
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "plain", "type": "mouseup"})).unwrap();
|
||||
assert!(lines(&r).is_empty());
|
||||
// A mouse click does what a click does (the checkbox toggles once, onClick runs) and its
|
||||
// listeners get the pointer.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "snap", "type": "click", "x": 5, "y": 6})).unwrap();
|
||||
assert_eq!(lines(&r), ["onClick true", "box:click@target 5,6 b0 d1 snap"]);
|
||||
assert!(s.script_ui.windows[0].root.lookup(&json!("snap")).unwrap().checked);
|
||||
// Agents' plain click still fires the click listeners.
|
||||
let r = s.execute("scriptui.click", json!({"widget": "snap"})).unwrap();
|
||||
assert_eq!(r["output"].as_str().unwrap_or_default().lines().next(), Some("onClick false"));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_propagation_capture_and_removal() {
|
||||
let mut s = session();
|
||||
let code = r#"
|
||||
var w = new Window("palette", "Stops");
|
||||
var g = w.add("group", undefined, undefined, { name: "outer" });
|
||||
var b = g.add("button", undefined, "Go", { name: "go" });
|
||||
var stopHere = function (e) { writeLn("capture " + e.currentTarget.properties.name); e.stopPropagation(); };
|
||||
g.addEventListener("mouseup", stopHere, true);
|
||||
b.addEventListener("mouseup", function () { writeLn("never"); });
|
||||
var bubble = function (e) { writeLn("bubble " + e.eventPhase); };
|
||||
g.addEventListener("mousedown", bubble);
|
||||
b.addEventListener("mousedown", function (e) { writeLn("target"); e.stopPropagation(); e.preventDefault(); writeLn("prevented " + e.defaultPrevented); });
|
||||
b.addEventListener("mousemove", function () { writeLn("move"); });
|
||||
b.removeEventListener("mousemove", b.__listeners.mousemove[0]);
|
||||
w.show();
|
||||
"#;
|
||||
let o = run_code(&mut s, code, "stops.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
// A capture listener that stops: the target never hears it.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "go", "type": "mouseup"})).unwrap();
|
||||
assert_eq!(lines(&r), ["capture outer"]);
|
||||
// Stopped at the target: no bubbling to the group.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "go", "type": "mousedown"})).unwrap();
|
||||
assert_eq!(lines(&r), ["target", "prevented true"]);
|
||||
// The group itself as the target.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "outer", "type": "mousedown"})).unwrap();
|
||||
assert_eq!(lines(&r), ["bubble target"]);
|
||||
// Removed listeners don't run, and no longer ask for the event.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "go", "type": "mousemove"})).unwrap();
|
||||
assert!(lines(&r).is_empty());
|
||||
assert!(!s.script_ui.windows[0].root.lookup(&json!("go")).unwrap().mouse.iter().any(|m| m == "mousemove"));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// Hostile input: unknown events, disabled controls, absurd coordinates, a throwing listener,
|
||||
/// a script that makes a control its own parent.
|
||||
#[test]
|
||||
fn mouse_events_survive_bad_input_and_throwing_listeners() {
|
||||
let mut s = session();
|
||||
let code = r#"
|
||||
var w = new Window("palette", "Bad");
|
||||
var b = w.add("button", undefined, "Boom", { name: "boom" });
|
||||
var off = w.add("button", undefined, "Off", { name: "off" });
|
||||
off.enabled = false;
|
||||
off.addEventListener("mousedown", function () { writeLn("disabled ran"); });
|
||||
var n = 0;
|
||||
b.addEventListener("mousedown", function (e) { n++; writeLn("at " + e.clientX + "," + e.clientY + " b" + e.button + " d" + e.detail); if (n === 2) throw new Error("bad press"); });
|
||||
var loop = w.add("group", undefined, undefined, { name: "loop" });
|
||||
loop.addEventListener("mousedown", function () { writeLn("loop ok"); });
|
||||
w.show();
|
||||
loop.parent = loop;
|
||||
"#;
|
||||
let o = run_code(&mut s, code, "bad.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert!(s.execute("scriptui.mouse", json!({"widget": "boom", "type": "dblclick"})).is_err());
|
||||
assert!(s.execute("scriptui.mouse", json!({"widget": "off", "type": "mousedown"})).is_err(), "disabled controls get no events");
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "boom", "type": "mousedown", "x": 1e300, "y": -1e300, "button": 99, "detail": -4})).unwrap();
|
||||
assert_eq!(lines(&r), ["at 1000000,-1000000 b2 d0"]);
|
||||
// A throwing listener reports its error; the window keeps working.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "boom", "type": "mousedown"})).unwrap();
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(r["error"]["message"].as_str().unwrap_or_default().contains("bad press"), "{r}");
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "boom", "type": "mousedown"})).unwrap();
|
||||
let at = s.script_ui.windows[0].root.lookup(&json!("boom")).unwrap().bounds;
|
||||
assert_eq!(lines(&r), [format!("at {},{} b0 d1", at[2] / 2.0, at[3] / 2.0)], "x / y default to the centre");
|
||||
// A parent cycle made by the script ends instead of hanging.
|
||||
let r = s.execute("scriptui.mouse", json!({"widget": "loop", "type": "mousedown"})).unwrap();
|
||||
assert!(lines(&r).iter().any(|l| l == "loop ok"), "{r}");
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user