#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
+47 -2
View File
@@ -110,6 +110,10 @@ pub struct Widget {
pub read_only: bool,
/// Event handlers the script attached (`onClick`, `onChange`, `onChanging`…).
pub handlers: Vec<String>,
/// The mouse events ([`MOUSE_EVENTS`]) a script listener receives when they happen on this
/// control: its own `addEventListener` listeners and its containers' (events bubble).
/// Frontends send only these.
pub mouse: Vec<String>,
/// The active tab of a tabbed panel (child index).
pub active_tab: usize,
/// What the control's `onDraw` handler painted (ScriptUIGraphics), in control coordinates.
@@ -364,12 +368,17 @@ pub struct ScriptWindow {
pub struct ScriptUiEvent {
pub window: u32,
pub widget: u32,
/// `click`, `change`, `changing` or `close`.
/// `click`, `change`, `changing`, `close`, or a mouse event ([`MOUSE_EVENTS`]).
pub kind: String,
/// The new value: text, number, bool, or selected index(es); for `close`, the result.
/// The new value: text, number, bool, or selected index(es); for `close`, the result; for
/// mouse events (and a mouse `click`), the pointer: `{x, y, button, detail, screenX, screenY,
/// altKey, ctrlKey, metaKey, shiftKey}` with `x`/`y` relative to the control.
pub value: Value,
}
/// The ScriptUI mouse events scripts can listen for (`addEventListener`).
pub const MOUSE_EVENTS: [&str; 6] = ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "click"];
/// Hands an event to the script that owns the window; returns what the handler reported
/// (`{ok, output, error}`) and updates [`Session::script_ui`].
pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Result<Value, String>;
@@ -687,6 +696,42 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value })
}
/// `scriptui.mouse`: a mouse event on a control, as the pointer would make it: the script's
/// `addEventListener` listeners on the control and its containers receive a MouseEvent
/// (`clientX`/`clientY` relative to the control, `button`, `detail` = click count, modifier
/// keys). A `click` also does what clicking the control does (toggle a checkbox, press a button).
pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
let c = "scriptui.mouse";
let window = window_p(s, p, c)?;
let widget = widget_p(s, window, p, c)?;
let ty = p.get("type").and_then(Value::as_str).unwrap_or("click").to_ascii_lowercase();
let Some(ty) = MOUSE_EVENTS.iter().find(|t| **t == ty) else {
return Err(crate::commands::bad(c, format!("unknown mouse event `{ty}` (one of {})", MOUSE_EVENTS.join(", "))));
};
let w = s.script_ui.window(window).and_then(|w| w.root.find(widget)).cloned().unwrap_or_default();
if !w.enabled {
return Err(crate::commands::bad(c, format!("control {widget} is disabled")));
}
// Hostile input stays finite and bounded.
const FAR: f64 = 1.0e6;
let num = |k: &str, lo: f64, hi: f64, d: f64| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map_or(d, |v| v.clamp(lo, hi));
let flag = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let clicks = if matches!(*ty, "mousemove" | "mouseover" | "mouseout") { 0.0 } else { 1.0 };
let value = json!({
"x": num("x", -FAR, FAR, w.bounds[2] / 2.0),
"y": num("y", -FAR, FAR, w.bounds[3] / 2.0),
"screenX": num("screenX", -FAR, FAR, 0.0),
"screenY": num("screenY", -FAR, FAR, 0.0),
"button": num("button", 0.0, 2.0, 0.0).round(),
"detail": num("detail", 0.0, 1000.0, clicks).round(),
"altKey": flag("altKey"),
"ctrlKey": flag("ctrlKey"),
"metaKey": flag("metaKey"),
"shiftKey": flag("shiftKey"),
});
dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value })
}
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
let window = window_p(s, p, "scriptui.close")?;