mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 11:37:02 -06:00
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>
595 lines
27 KiB
Rust
595 lines
27 KiB
Rust
//! ScriptUI windows: the dialogs, palettes and dockable panels scripts build
|
|
//! (`Session::script_ui`). Each control is drawn at the bounds the engine's ScriptUI layout
|
|
//! computed; what the user does goes back through the `scriptui.click` / `scriptui.set` /
|
|
//! `scriptui.close` commands, which run the script's handlers. Every control registers an
|
|
//! automation id `scriptui.<window>.<control>` (and `scriptui.<window>.<name>` when the script
|
|
//! named it). Controls with an `onDraw` handler are painted from their draw list; edit texts and
|
|
//! sliders send live `changing` updates (onChanging) while typing / dragging. Mouse events
|
|
//! (`mousedown`, `mouseup`, `mousemove`, `mouseover`, `mouseout`, `click`) on a control go to the
|
|
//! script through `scriptui.mouse` when a listener wants them (`Widget::mouse`).
|
|
|
|
use crate::i18n::tr;
|
|
use effectcraft_engine::Services;
|
|
use effectcraft_engine::scriptui::{DrawOp, ImageRef, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout};
|
|
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::EffectcraftApp;
|
|
use crate::dock::PanelKind;
|
|
use crate::theme::Tokens;
|
|
|
|
enum Act {
|
|
Click(u32, u32),
|
|
Set(u32, u32, Value),
|
|
/// A live update (each keystroke / slider step): fires onChanging.
|
|
Changing(u32, u32, Value),
|
|
Close(u32),
|
|
/// A mouse event (`scriptui.mouse` parameters without window and widget).
|
|
Mouse(u32, u32, Value),
|
|
}
|
|
|
|
fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect {
|
|
Rect::from_min_size(origin + vec2(b[0] as f32, b[1] as f32), vec2(b[2] as f32, b[3] as f32))
|
|
}
|
|
|
|
fn register(app: &mut EffectcraftApp, win: u32, w: &Widget, rect: Rect) {
|
|
let label = if w.text.is_empty() { format!("{:?}", w.kind) } else { w.text.clone() };
|
|
app.auto.add(&format!("scriptui.{win}.{}", w.id), rect, &label);
|
|
if !w.name.is_empty() {
|
|
app.auto.add(&format!("scriptui.{win}.{}", w.name), rect, &label);
|
|
}
|
|
}
|
|
|
|
/// Draw `w`'s children (and, for containers, their children) at `origin` + bounds.
|
|
fn draw_children(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, win: u32, w: &Widget, acts: &mut Vec<Act>) {
|
|
let t = app.tokens;
|
|
if w.kind == WidgetKind::TabbedPanel {
|
|
// Tab headers, then the active tab only.
|
|
let r = rect_of(origin, w.bounds);
|
|
let mut x = r.min.x;
|
|
for (i, tab) in w.children.iter().enumerate() {
|
|
let tw = (tab.text.chars().count() as f32 * 7.0 + 20.0).max(48.0);
|
|
let hr = Rect::from_min_size(pos2(x, r.min.y), vec2(tw, 22.0));
|
|
let active = i == w.active_tab;
|
|
let resp = ui.interact(hr, egui::Id::new(("sui-tab", win, tab.id)), Sense::click());
|
|
ui.painter().rect_filled(
|
|
hr,
|
|
3.0,
|
|
if active {
|
|
t.row_selected
|
|
} else if resp.hovered() {
|
|
t.hover
|
|
} else {
|
|
t.field_bg
|
|
},
|
|
);
|
|
ui.painter().text(hr.center(), Align2::CENTER_CENTER, &tab.text, Tokens::ui(12.0), t.text);
|
|
register(app, win, tab, hr);
|
|
if resp.clicked() && !active && tab.enabled {
|
|
acts.push(Act::Click(win, tab.id));
|
|
}
|
|
x += tw + 2.0;
|
|
}
|
|
ui.painter().rect_stroke(Rect::from_min_max(pos2(r.min.x, r.min.y + 22.0), r.max), 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
|
if let Some(tab) = w.children.get(w.active_tab) {
|
|
draw_children(app, ui, origin, win, tab, acts);
|
|
}
|
|
return;
|
|
}
|
|
for c in w.children.iter().filter(|c| c.visible) {
|
|
draw_widget(app, ui, origin, win, c, acts);
|
|
}
|
|
}
|
|
|
|
fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, win: u32, w: &Widget, acts: &mut Vec<Act>) {
|
|
let t = app.tokens;
|
|
let services = app.session.services.clone();
|
|
let rect = rect_of(origin, w.bounds);
|
|
register(app, win, w, rect);
|
|
let id = egui::Id::new(("sui", win, w.id));
|
|
let tip = |r: egui::Response| if w.help_tip.is_empty() { r } else { r.on_hover_text(&w.help_tip) };
|
|
// onDraw: the script paints the control. Without drawOSControl() its paint replaces the
|
|
// default look (buttons and toggles still respond to clicks); with it, it goes on top.
|
|
let custom = w.handlers.iter().any(|h| h == "onDraw");
|
|
let os = w.draw.iter().any(|d| matches!(d, DrawOp::Os));
|
|
if custom && !os {
|
|
if w.kind.is_container() {
|
|
paint_draw(ui, rect, &w.draw, &t, &*services);
|
|
draw_children(app, ui, origin, win, w, acts);
|
|
return;
|
|
}
|
|
let clickable = matches!(w.kind, WidgetKind::Button | WidgetKind::IconButton | WidgetKind::Checkbox | WidgetKind::RadioButton);
|
|
let r = ui.interact(rect, id, if clickable && w.enabled { Sense::click() } else { Sense::hover() });
|
|
paint_draw(ui, rect, &w.draw, &t, &*services);
|
|
if tip(r).clicked() && clickable {
|
|
acts.push(Act::Click(win, w.id));
|
|
}
|
|
return;
|
|
}
|
|
if custom && w.kind.is_container() {
|
|
paint_draw(ui, rect, &w.draw, &t, &*services);
|
|
}
|
|
match w.kind {
|
|
WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts),
|
|
WidgetKind::Panel => {
|
|
let p = ui.painter();
|
|
p.rect_stroke(rect.shrink(1.0), 4.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
|
if !w.text.is_empty() {
|
|
let g = p.layout_no_wrap(w.text.clone(), Tokens::ui(11.5), t.text_dim);
|
|
let at = pos2(rect.min.x + 10.0, rect.min.y - 1.0);
|
|
p.rect_filled(Rect::from_min_size(at - vec2(3.0, 0.0), g.size() + vec2(6.0, 0.0)), 0.0, t.panel_bg);
|
|
p.galley(at, g, t.text_dim);
|
|
}
|
|
draw_children(app, ui, origin, win, w, acts);
|
|
}
|
|
WidgetKind::Button | WidgetKind::IconButton => {
|
|
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, egui::Button::new(&w.text))).inner;
|
|
if let Some(img) = &w.image {
|
|
paint_image(ui, rect.shrink(3.0), img, &*services);
|
|
}
|
|
if tip(r).clicked() {
|
|
acts.push(Act::Click(win, w.id));
|
|
}
|
|
}
|
|
WidgetKind::StaticText => {
|
|
let col = if w.enabled { t.text } else { t.text_faint };
|
|
ui.painter().with_clip_rect(rect.expand(2.0)).text(pos2(rect.min.x, rect.min.y + 1.0), Align2::LEFT_TOP, &w.text, Tokens::ui(12.0), col);
|
|
}
|
|
WidgetKind::EditText => {
|
|
// The text being typed lives here until the field loses focus (then onChange).
|
|
let mut buf: String = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| w.text.clone());
|
|
let focused = ui.memory(|m| m.has_focus(id));
|
|
if !focused {
|
|
buf = w.text.clone();
|
|
}
|
|
let te = if w.multiline { egui::TextEdit::multiline(&mut buf) } else { egui::TextEdit::singleline(&mut buf) };
|
|
let te = te.id(id).interactive(!w.read_only).font(Tokens::ui(12.0));
|
|
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, te)).inner;
|
|
let r = tip(r);
|
|
// Live updates already sent the text: the commit still runs onChange.
|
|
let dirty_id = id.with("dirty");
|
|
let dirty = ui.data(|d| d.get_temp::<bool>(dirty_id)).unwrap_or(false);
|
|
if r.lost_focus() && (buf != w.text || dirty) {
|
|
acts.push(Act::Set(win, w.id, json!(buf)));
|
|
ui.data_mut(|d| d.remove::<bool>(dirty_id));
|
|
} else if r.changed() && w.handlers.iter().any(|h| h == "onChanging") {
|
|
// Each keystroke reaches the script's onChanging (onChange runs on commit).
|
|
acts.push(Act::Changing(win, w.id, json!(buf)));
|
|
ui.data_mut(|d| d.insert_temp(dirty_id, true));
|
|
}
|
|
ui.data_mut(|d| d.insert_temp(id, buf));
|
|
}
|
|
WidgetKind::Checkbox => {
|
|
let mut v = w.checked;
|
|
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(snug(rect, &w.text), egui::Checkbox::new(&mut v, &w.text))).inner;
|
|
if tip(r).clicked() {
|
|
acts.push(Act::Click(win, w.id));
|
|
}
|
|
}
|
|
WidgetKind::RadioButton => {
|
|
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(snug(rect, &w.text), egui::RadioButton::new(w.checked, &w.text))).inner;
|
|
if tip(r).clicked() && !w.checked {
|
|
acts.push(Act::Click(win, w.id));
|
|
}
|
|
}
|
|
WidgetKind::Slider | WidgetKind::Scrollbar => {
|
|
// While dragging, the value lives here; the release sends it (onChanging / onChange).
|
|
let mut v: f64 = ui.data(|d| d.get_temp::<f64>(id)).unwrap_or(w.value);
|
|
let (lo, hi) = if w.max > w.min { (w.min, w.max) } else { (0.0, 100.0) };
|
|
let r = ui
|
|
.add_enabled_ui(w.enabled, |ui| {
|
|
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
|
|
ui.spacing_mut().slider_width = rect.width() - 4.0;
|
|
ui.add(egui::Slider::new(&mut v, lo..=hi).show_value(false))
|
|
})
|
|
.inner
|
|
})
|
|
.inner;
|
|
let r = tip(r);
|
|
if r.dragged() {
|
|
// Live: each step of the drag reaches the script (onChanging).
|
|
if r.changed() {
|
|
acts.push(Act::Changing(win, w.id, json!(v)));
|
|
}
|
|
ui.data_mut(|d| d.insert_temp(id, v));
|
|
} else {
|
|
ui.data_mut(|d| d.remove::<f64>(id));
|
|
if r.drag_stopped() || r.changed() {
|
|
acts.push(Act::Set(win, w.id, json!(v)));
|
|
}
|
|
}
|
|
}
|
|
WidgetKind::Progressbar => {
|
|
let span = if w.max > w.min { w.max - w.min } else { 100.0 };
|
|
let f = ((w.value - w.min) / span).clamp(0.0, 1.0) as f32;
|
|
ui.put(rect, egui::ProgressBar::new(f).desired_height(rect.height()));
|
|
}
|
|
WidgetKind::DropDownList => {
|
|
let cur = w.selection.first().and_then(|i| w.items.get(*i)).cloned().unwrap_or_default();
|
|
let mut pick: Option<usize> = None;
|
|
ui.add_enabled_ui(w.enabled, |ui| {
|
|
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
|
|
egui::ComboBox::from_id_salt(id).selected_text(cur).width(rect.width() - 8.0).show_ui(ui, |ui| {
|
|
for (i, it) in w.items.iter().enumerate() {
|
|
if it == "-" {
|
|
ui.separator();
|
|
} else if ui.selectable_label(w.selection.contains(&i), it).clicked() {
|
|
pick = Some(i);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
if let Some(i) = pick
|
|
&& !w.selection.contains(&i)
|
|
{
|
|
acts.push(Act::Set(win, w.id, json!(i)));
|
|
}
|
|
}
|
|
WidgetKind::ListBox => {
|
|
let mut pick: Option<usize> = None;
|
|
ui.painter().rect_filled(rect, 2.0, t.field_bg);
|
|
ui.add_enabled_ui(w.enabled, |ui| {
|
|
ui.scope_builder(UiBuilder::new().max_rect(rect.shrink(2.0)), |ui| {
|
|
egui::ScrollArea::vertical().id_salt(id).max_height(rect.height() - 4.0).show(ui, |ui| {
|
|
ui.set_width(rect.width() - 8.0);
|
|
for (i, it) in w.items.iter().enumerate() {
|
|
if ui.selectable_label(w.selection.contains(&i), it).clicked() {
|
|
pick = Some(i);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
if let Some(i) = pick {
|
|
acts.push(Act::Set(win, w.id, json!(i)));
|
|
}
|
|
}
|
|
WidgetKind::Image | WidgetKind::Unknown => {
|
|
if !w.image.as_ref().is_some_and(|img| paint_image(ui, rect, img, &*services)) {
|
|
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
|
}
|
|
}
|
|
}
|
|
if custom && !w.kind.is_container() {
|
|
paint_draw(ui, rect, &w.draw, &t, &*services);
|
|
}
|
|
}
|
|
|
|
/// Paint an onDraw draw list in `rect` (control coordinates → screen).
|
|
fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: &dyn Services) {
|
|
let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
|
|
let col = |c: &Option<[f32; 4]>, theme: Color32| match c {
|
|
Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(),
|
|
None => theme,
|
|
};
|
|
let at = |x: f64, y: f64| rect.min + vec2(x as f32, y as f32);
|
|
for op in ops {
|
|
match op {
|
|
DrawOp::Fill { color, path } => {
|
|
// Any path shape (concave, self-intersecting, with holes): non-zero winding
|
|
// triangles, not egui's convex-only polygon fill.
|
|
let c = col(color, t.field_bg);
|
|
let mut mesh = egui::Mesh::default();
|
|
for tri in PathSeg::fill_triangles(path, 48, false) {
|
|
let base = mesh.vertices.len() as u32;
|
|
for q in tri {
|
|
mesh.colored_vertex(at(q[0], q[1]), c);
|
|
}
|
|
mesh.add_triangle(base, base + 1, base + 2);
|
|
}
|
|
if !mesh.is_empty() {
|
|
p.add(egui::Shape::mesh(mesh));
|
|
}
|
|
}
|
|
DrawOp::Stroke { color, width, path } => {
|
|
let s = Stroke::new(*width as f32, col(color, t.text));
|
|
for (pts, closed) in PathSeg::polylines(path, 48) {
|
|
let pts: Vec<egui::Pos2> = pts.iter().map(|q| at(q[0], q[1])).collect();
|
|
p.add(if closed { egui::Shape::closed_line(pts, s) } else { egui::Shape::line(pts, s) });
|
|
}
|
|
}
|
|
DrawOp::Text { text, color, x, y, size, .. } => {
|
|
p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text));
|
|
}
|
|
DrawOp::Image { image, x, y, w, h } => {
|
|
let tex = image.as_ref().and_then(|i| image_texture(ui.ctx(), i, services));
|
|
let size = match &tex {
|
|
Some(tx) if *w <= 0.0 || *h <= 0.0 => tx.size_vec2(),
|
|
_ => vec2(*w as f32, *h as f32),
|
|
};
|
|
let r = Rect::from_min_size(at(*x, *y), size);
|
|
match tex {
|
|
Some(tx) => {
|
|
p.image(tx.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
|
}
|
|
None => {
|
|
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
|
}
|
|
}
|
|
}
|
|
DrawOp::Os => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A ScriptUIImage as a texture (decoded once and kept; `None` when it can't be read or
|
|
/// decoded).
|
|
fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) -> Option<egui::TextureHandle> {
|
|
let id = egui::Id::new(("scriptui-image", img));
|
|
if let Some(cached) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
|
|
return cached;
|
|
}
|
|
let tex = img.bytes(services).and_then(|b| image::load_from_memory(&b).ok()).map(|d| {
|
|
let rgba = d.to_rgba8();
|
|
let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
|
|
ctx.load_texture(format!("scriptui-image-{}", img.name), ci, egui::TextureOptions::LINEAR)
|
|
});
|
|
ctx.data_mut(|d| d.insert_temp(id, tex.clone()));
|
|
tex
|
|
}
|
|
|
|
/// An image control's or icon button's image, fitted (aspect kept) and centred in `r`.
|
|
fn paint_image(ui: &egui::Ui, r: Rect, img: &ImageRef, services: &dyn Services) -> bool {
|
|
let Some(tex) = image_texture(ui.ctx(), img, services) else { return false };
|
|
let s = tex.size_vec2();
|
|
let k = (r.width() / s.x.max(1.0)).min(r.height() / s.y.max(1.0)).min(1.0);
|
|
let dst = Rect::from_center_size(r.center(), s * k);
|
|
ui.painter().with_clip_rect(r.intersect(ui.clip_rect())).image(tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
|
true
|
|
}
|
|
|
|
/// Where the pointer was and what it did over one script window (for mouseover / mouseout and
|
|
/// click counts).
|
|
#[derive(Clone, Default)]
|
|
struct MouseTrack {
|
|
hover: Option<u32>,
|
|
at: Option<egui::Pos2>,
|
|
/// The last press: control, time, position (a quick second press there counts as a double
|
|
/// click).
|
|
last_down: Option<(u32, f64, egui::Pos2)>,
|
|
clicks: u32,
|
|
/// The control and button pressed (a release on the same control is a click).
|
|
down: Option<(u32, u8)>,
|
|
}
|
|
|
|
/// The deepest visible control of `w` at `p` (`w` itself when no child is there): later
|
|
/// siblings are on top; a tabbed panel shows its active tab only.
|
|
fn hit_test(w: &Widget, origin: egui::Pos2, p: egui::Pos2, depth: usize) -> Option<&Widget> {
|
|
if !w.visible || !rect_of(origin, w.bounds).contains(p) {
|
|
return None;
|
|
}
|
|
// Widget trees come from scripts: the depth is bounded.
|
|
if depth < 64 {
|
|
if w.kind == WidgetKind::TabbedPanel {
|
|
if let Some(hit) = w.children.get(w.active_tab).and_then(|t| hit_test(t, origin, p, depth + 1)) {
|
|
return Some(hit);
|
|
}
|
|
} else if let Some(hit) = w.children.iter().rev().find_map(|c| hit_test(c, origin, p, depth + 1)) {
|
|
return Some(hit);
|
|
}
|
|
}
|
|
Some(w)
|
|
}
|
|
|
|
fn wants_mouse(w: &Widget, depth: usize) -> bool {
|
|
!w.mouse.is_empty() || (depth < 64 && w.children.iter().any(|c| wants_mouse(c, depth + 1)))
|
|
}
|
|
|
|
/// Mouse events over a script window's controls (drawn at `origin`, visible in `area`) for the
|
|
/// script's listeners: mouseover / mouseout when the control under the pointer changes,
|
|
/// mousemove, mousedown / mouseup, and click (a press and release of the left button on the
|
|
/// same control; it replaces the plain click the control reported, so a checkbox toggles once).
|
|
/// Only events a listener wants are sent; none while `active` is false (a modal dialog is up).
|
|
fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: &Widget, active: bool, acts: &mut Vec<Act>) {
|
|
let id = egui::Id::new(("sui-mouse", win));
|
|
if !wants_mouse(root, 0) {
|
|
ui.data_mut(|d| d.remove::<MouseTrack>(id));
|
|
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 ctx = ui.ctx();
|
|
let layer = ui.layer_id();
|
|
let target = |p: egui::Pos2| -> Option<&Widget> {
|
|
if !active || !area.contains(p) || ctx.layer_id_at(p).is_some_and(|l| l != layer) {
|
|
return None;
|
|
}
|
|
hit_test(root, origin, p, 0)
|
|
};
|
|
let send = |w: &Widget, ty: &str, p: egui::Pos2, button: u8, detail: u32, acts: &mut Vec<Act>| {
|
|
if !w.enabled || !w.mouse.iter().any(|m| m == ty) {
|
|
return;
|
|
}
|
|
let at = rect_of(origin, w.bounds).min;
|
|
acts.push(Act::Mouse(
|
|
win,
|
|
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,
|
|
"altKey": mods.alt, "ctrlKey": mods.ctrl, "shiftKey": mods.shift, "metaKey": mods.mac_cmd,
|
|
}),
|
|
));
|
|
};
|
|
// The pointer moved: out of the old control, over the new one, along it.
|
|
let now = latest.and_then(|p| target(p).map(|w| (w, p)));
|
|
let now_id = now.map(|(w, _)| w.id);
|
|
if now_id != st.hover {
|
|
if let Some(old) = st.hover.and_then(|h| root.find(h)) {
|
|
send(old, "mouseout", latest.unwrap_or(origin), 0, 0, acts);
|
|
}
|
|
if let Some((w, p)) = now {
|
|
send(w, "mouseover", p, 0, 0, acts);
|
|
}
|
|
st.hover = now_id;
|
|
}
|
|
if let Some((w, p)) = now
|
|
&& st.at != Some(p)
|
|
{
|
|
send(w, "mousemove", p, 0, 0, acts);
|
|
}
|
|
st.at = latest;
|
|
// Presses and releases.
|
|
for e in &events {
|
|
let egui::Event::PointerButton { pos, button, pressed, .. } = e else { continue };
|
|
let b = match button {
|
|
egui::PointerButton::Primary => 0,
|
|
egui::PointerButton::Middle => 1,
|
|
egui::PointerButton::Secondary => 2,
|
|
_ => continue,
|
|
};
|
|
let Some(w) = target(*pos) else {
|
|
if !pressed {
|
|
st.down = None;
|
|
}
|
|
continue;
|
|
};
|
|
if *pressed {
|
|
let again = st.last_down.is_some_and(|(c, t, at)| c == w.id && time - t < 0.5 && at.distance(*pos) < 6.0);
|
|
st.clicks = if again { st.clicks.saturating_add(1) } else { 1 };
|
|
st.last_down = Some((w.id, time, *pos));
|
|
st.down = Some((w.id, b));
|
|
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") {
|
|
// 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));
|
|
send(w, "click", *pos, b, st.clicks, acts);
|
|
}
|
|
st.down = None;
|
|
}
|
|
}
|
|
ui.data_mut(|d| d.insert_temp(id, st));
|
|
}
|
|
|
|
/// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds.
|
|
fn snug(r: Rect, text: &str) -> Rect {
|
|
let w = (text.chars().count() as f32 * 7.0 + 26.0).min(r.width());
|
|
Rect::from_min_size(r.min, vec2(w, r.height()))
|
|
}
|
|
|
|
fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec<Act>) {
|
|
for a in acts {
|
|
let (cmd, p) = match a {
|
|
Act::Click(w, id) => ("scriptui.click", json!({"window": w, "widget": id})),
|
|
Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})),
|
|
Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})),
|
|
Act::Close(w) => ("scriptui.close", json!({"window": w})),
|
|
Act::Mouse(w, id, mut v) => {
|
|
if let Some(o) = v.as_object_mut() {
|
|
o.insert("window".into(), json!(w));
|
|
o.insert("widget".into(), json!(id));
|
|
}
|
|
("scriptui.mouse", v)
|
|
}
|
|
};
|
|
match crate::menus::invoke(app, ctx, cmd, p) {
|
|
Ok(r) => {
|
|
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
|
|
let msg = e["message"].as_str().unwrap_or("script error").to_string();
|
|
app.toast = Some((msg, ctx.input(|i| i.time)));
|
|
} else if let Some(o) = r["output"].as_str().filter(|o| !o.is_empty()) {
|
|
app.ui.status = o.lines().last().unwrap_or_default().to_string();
|
|
}
|
|
}
|
|
Err(e) => app.ui.status = e,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The floating script windows (dialogs, palettes, windows), drawn over the app.
|
|
pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|
let windows: Vec<ScriptWindow> = app.session.script_ui.windows.iter().filter(|w| w.kind != WindowKind::Panel).cloned().collect();
|
|
if windows.is_empty() {
|
|
return;
|
|
}
|
|
let t = app.tokens;
|
|
let mut acts = vec![];
|
|
let screen = ctx.content_rect();
|
|
let blocked = windows.iter().any(|w| w.modal);
|
|
if blocked {
|
|
// A modal dialog blocks the app behind it, as in After Effects.
|
|
let r = egui::Area::new(egui::Id::new("scriptui-modal-block")).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
|
|
ui.allocate_exact_size(screen.size(), Sense::click_and_drag());
|
|
ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(90));
|
|
});
|
|
let _ = r;
|
|
}
|
|
for w in &windows {
|
|
let size = vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32);
|
|
let mut open = true;
|
|
let order = if w.modal { egui::Order::Foreground } else { egui::Order::Middle };
|
|
let resp = egui::Window::new(if w.title.is_empty() { &w.script } else { &w.title })
|
|
.id(egui::Id::new(("scriptui-window", w.id)))
|
|
.order(order)
|
|
.collapsible(false)
|
|
.resizable(false)
|
|
.open(&mut open)
|
|
.default_pos(screen.center() - size / 2.0)
|
|
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel_bg))
|
|
.show(ctx, |ui| {
|
|
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
|
|
draw_children(app, ui, rect.min, w.id, &w.root, &mut acts);
|
|
mouse_events(ui, rect.min, rect, w.id, &w.root, w.modal || !blocked, &mut acts);
|
|
rect
|
|
});
|
|
if let Some(r) = resp {
|
|
app.auto.add(&format!("scriptui.{}", w.id), r.response.rect, &w.title);
|
|
}
|
|
if !open {
|
|
acts.push(Act::Close(w.id));
|
|
}
|
|
}
|
|
perform(app, ctx, acts);
|
|
}
|
|
|
|
/// A dockable ScriptUI panel's content (Window ▸ <panel>.jsx).
|
|
pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) {
|
|
let t = app.tokens;
|
|
let Some(mut w) = app.session.script_ui.window(id).cloned() else {
|
|
ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr("This ScriptUI panel has closed"), Tokens::ui(12.0), t.text_faint);
|
|
return;
|
|
};
|
|
// Docked panels fill their frame (ScriptUI lays them out at the panel's size).
|
|
let (pw, ph) = (rect.width() as f64, rect.height() as f64);
|
|
w.root.fixed_bounds = Some([0.0, 0.0, pw.max(w.root.bounds[2]), ph.max(w.root.bounds[3])]);
|
|
layout(&mut w);
|
|
// A modal script dialog blocks the panels too.
|
|
let modal = app.session.script_ui.windows.iter().any(|w| w.modal);
|
|
let mut acts = vec![];
|
|
let content = rect;
|
|
egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| {
|
|
let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover());
|
|
draw_children(app, ui, r.min, id, &w.root, &mut acts);
|
|
mouse_events(ui, r.min, r.intersect(ui.clip_rect()), id, &w.root, !modal, &mut acts);
|
|
});
|
|
app.auto.add(&format!("scriptui.{id}"), rect, &w.title);
|
|
let ctx = ui.ctx().clone();
|
|
perform(app, &ctx, acts);
|
|
}
|
|
|
|
/// Tab title of a script panel.
|
|
pub fn panel_title(app: &EffectcraftApp, id: u32) -> Option<String> {
|
|
app.session.script_ui.window(id).map(|w| if w.title.is_empty() { w.script.clone() } else { w.title.clone() })
|
|
}
|
|
|
|
/// Close the dock panels whose script window has gone (the script closed it, or a saved
|
|
/// workspace from an earlier run).
|
|
pub fn sync_panels(app: &mut EffectcraftApp) {
|
|
let mut all = vec![];
|
|
app.ui.dock.panels(&mut all);
|
|
for f in &app.ui.floating {
|
|
all.extend(f.panels.iter().copied());
|
|
}
|
|
for p in all {
|
|
if let PanelKind::ScriptPanel(id) = p
|
|
&& app.session.script_ui.window(id).is_none()
|
|
{
|
|
app.close_panel(p);
|
|
}
|
|
}
|
|
}
|