#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:
vangel76
2026-10-11 12:39:23 +02:00
co-authored by Claude Opus 5.5
parent 9a7d3e7699
commit 1c06a793a5
10 changed files with 536 additions and 15 deletions
+2
View File
@@ -90,3 +90,5 @@ mod tests_panels;
mod tests_ui;
#[cfg(test)]
mod tests_ui_more;
#[cfg(test)]
mod tests_ui_mouse;
+109 -6
View File
@@ -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();
+167
View File
@@ -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();
}