effectcraft/crates/engine/src/scriptui.rs

840 lines
34 KiB
Rust

//! ScriptUI: the windows, dialogs and dockable panels scripts build with After Effects'
//! ScriptUI object model (`new Window("dialog", "Title")`, `win.add("button", undefined, "OK")`,
//! `orientation`, `alignChildren`, `onClick`/`onChange`, `show()`, `close()`,
//! `layout.layout()`…).
//!
//! The scripting engine (`effectcraft-script`) owns the live JavaScript objects; after every
//! script step it publishes a serde description of each open window here ([`ScriptWindow`],
//! a [`Widget`] tree with computed [`Widget::bounds`]). Frontends draw that description (the egui
//! UI renders dialogs, palettes and dockable panels from it) and report what the user does as
//! [`ScriptUiEvent`]s through the `scriptui.*` commands, which hand them to the script's
//! handlers ([`ScriptUi::dispatch`]). Agents use the same commands: list the open script
//! windows, read their widget trees, click buttons and set values.
//!
//! [`layout`] is ScriptUI's automatic layout (orientation, alignChildren / alignment, spacing,
//! margins, preferredSize), so every frontend places widgets the same way.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{EngineError, Result, Session};
/// What kind of window a script made.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WindowKind {
/// Modal: the script waits in `show()` until it closes.
#[default]
Dialog,
/// Floating, non-modal.
Palette,
/// A non-modal document window.
Window,
/// A dockable panel (a script from the ScriptUI Panels folder, opened from the Window menu).
Panel,
}
/// ScriptUI control types (the `type` passed to `add()`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WidgetKind {
/// The window itself (the tree's root).
#[default]
Window,
Panel,
Group,
Button,
IconButton,
StaticText,
EditText,
Checkbox,
RadioButton,
Slider,
Scrollbar,
Progressbar,
DropDownList,
ListBox,
TabbedPanel,
Tab,
Image,
/// A type we don't draw (it still lays out as an empty box).
#[serde(other)]
Unknown,
}
impl WidgetKind {
pub fn is_container(self) -> bool {
matches!(self, WidgetKind::Window | WidgetKind::Panel | WidgetKind::Group | WidgetKind::TabbedPanel | WidgetKind::Tab)
}
}
/// One control (or container) of a script window.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Widget {
/// Unique within its window (the window itself is 0).
pub id: u32,
#[serde(rename = "type")]
pub kind: WidgetKind,
/// `properties.name` (agents can address controls by it).
pub name: String,
pub text: String,
/// Slider / scrollbar / progressbar value.
pub value: f64,
pub min: f64,
pub max: f64,
/// Checkbox / radio button state.
pub checked: bool,
/// Drop-down list / list box items.
pub items: Vec<String>,
/// Selected item indices.
pub selection: Vec<usize>,
/// `column` (default for windows and panels), `row` (groups) or `stack`.
pub orientation: String,
/// `[horizontal, vertical]`: left / center / right / fill, top / center / bottom / fill.
pub align_children: Vec<String>,
/// This control's own alignment in its parent (overrides the parent's alignChildren).
pub alignment: Vec<String>,
pub spacing: Option<f64>,
/// `[left, top, right, bottom]`.
pub margins: Option<[f64; 4]>,
pub preferred_size: Option<[f64; 2]>,
/// Bounds set by the script (`[left, top, right, bottom]` relative to the parent).
pub fixed_bounds: Option<[f64; 4]>,
/// Laid-out bounds `[x, y, width, height]` relative to the window's content.
pub bounds: [f64; 4],
pub enabled: bool,
pub visible: bool,
pub help_tip: String,
pub multiline: bool,
pub read_only: bool,
/// Event handlers the script attached (`onClick`, `onChange`, `onChanging`…).
pub handlers: 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.
/// Frontends paint it instead of the control's default look, unless it contains
/// [`DrawOp::Os`] (`drawOSControl()`), which draws the default look first.
pub draw: Vec<DrawOp>,
/// The image of an image control or icon button.
pub image: Option<ImageRef>,
pub children: Vec<Widget>,
}
/// One segment of a ScriptUIGraphics path (control coordinates, pixels).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "k")]
pub enum PathSeg {
/// `moveTo(x, y)`: starts a new subpath.
M { x: f64, y: f64 },
/// `lineTo(x, y)`.
L { x: f64, y: f64 },
/// `rectPath(x, y, w, h)`: a closed rectangle subpath.
R { x: f64, y: f64, w: f64, h: f64 },
/// `ellipsePath(x, y, w, h)`: a closed ellipse inscribed in the rectangle.
E { x: f64, y: f64, w: f64, h: f64 },
/// `closePath()`.
Z,
}
/// One paint call of an `onDraw` handler. Colours are straight RGBA in 0..1; `None` is a theme
/// colour (a brush or pen made from a theme colour name).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "camelCase")]
pub enum DrawOp {
/// `fillPath(brush, path)`.
Fill { color: Option<[f32; 4]>, path: Vec<PathSeg> },
/// `strokePath(pen, path)`.
Stroke { color: Option<[f32; 4]>, width: f64, path: Vec<PathSeg> },
/// `drawString(text, pen, x, y, font)`: `(x, y)` is the text's top-left.
Text {
text: String,
color: Option<[f32; 4]>,
x: f64,
y: f64,
size: f64,
#[serde(default)]
style: String,
},
/// `drawOSControl()`: the control's default look.
Os,
/// `drawImage(image, x, y, w?, h?)`: `w` / `h` 0 = the image's own size.
Image {
#[serde(default)]
image: Option<ImageRef>,
x: f64,
y: f64,
w: f64,
h: f64,
},
}
/// A ScriptUIImage (`ScriptUI.newImage`, an image control's or icon button's image, a
/// `drawImage` argument): an image file, or embedded PNG / JPEG bytes.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct ImageRef {
/// File path (or a resource name we can't resolve: drawn as a placeholder).
pub src: Option<String>,
/// Embedded bytes as a binary string (one character per byte, as in resource strings).
pub data: Option<String>,
pub name: String,
}
impl ImageRef {
/// The encoded image bytes (the embedded data, else the file through `services`).
pub fn bytes(&self, services: &dyn crate::Services) -> Option<Vec<u8>> {
if let Some(d) = &self.data {
return d.chars().map(|c| u8::try_from(c as u32).ok()).collect();
}
services.read_file(self.src.as_deref()?).ok()
}
}
impl PathSeg {
/// The subpaths of `path` as polylines (ellipses flattened to `n` segments), each with
/// whether it is closed.
pub fn polylines(path: &[PathSeg], n: usize) -> Vec<(Vec<[f64; 2]>, bool)> {
fn flush(cur: &mut Vec<[f64; 2]>, out: &mut Vec<(Vec<[f64; 2]>, bool)>, closed: bool) {
if cur.len() > 1 {
out.push((std::mem::take(cur), closed));
} else {
cur.clear();
}
}
let mut out = vec![];
let mut cur: Vec<[f64; 2]> = vec![];
for seg in path {
match *seg {
PathSeg::M { x, y } => {
flush(&mut cur, &mut out, false);
cur.push([x, y]);
}
PathSeg::L { x, y } => cur.push([x, y]),
PathSeg::Z => {
let start = cur.first().copied();
flush(&mut cur, &mut out, true);
// Drawing continues from the subpath's start.
cur.extend(start);
}
PathSeg::R { x, y, w, h } => {
flush(&mut cur, &mut out, false);
out.push((vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]], true));
}
PathSeg::E { x, y, w, h } => {
flush(&mut cur, &mut out, false);
let (cx, cy, rx, ry) = (x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
let n = n.max(8);
let pts = (0..n)
.map(|i| {
let a = i as f64 / n as f64 * std::f64::consts::TAU;
[cx + rx * a.cos(), cy + ry * a.sin()]
})
.collect();
out.push((pts, true));
}
}
}
flush(&mut cur, &mut out, false);
out
}
/// The area `fillPath` paints, as triangles: every subpath closed, overlapping subpaths
/// combined by the non-zero winding rule (`even_odd`: the even-odd rule), so concave,
/// self-intersecting and holed paths fill correctly (frontends' polygon fills are often
/// convex-only). Exact trapezoid decomposition: the plane is cut into horizontal bands at
/// every vertex and edge crossing; inside a band no edges cross, so each inside span is a
/// trapezoid (two triangles).
pub fn fill_triangles(path: &[PathSeg], n: usize, even_odd: bool) -> Vec<[[f64; 2]; 3]> {
// Non-horizontal edges, top to bottom, with their winding direction.
let mut edges: Vec<([f64; 2], [f64; 2], i32)> = vec![];
for (pts, _) in PathSeg::polylines(path, n) {
for i in 0..pts.len() {
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) || a[1] == b[1] {
continue;
}
edges.push(if a[1] < b[1] { (a, b, 1) } else { (b, a, -1) });
}
}
if edges.len() < 2 {
return vec![];
}
let x_at = |e: &([f64; 2], [f64; 2], i32), y: f64| e.0[0] + (e.1[0] - e.0[0]) * (y - e.0[1]) / (e.1[1] - e.0[1]);
let mut ys: Vec<f64> = edges.iter().flat_map(|e| [e.0[1], e.1[1]]).collect();
// Where two edges cross, a band boundary.
for i in 0..edges.len() {
for j in i + 1..edges.len() {
let (a, b) = (&edges[i], &edges[j]);
let (lo, hi) = (a.0[1].max(b.0[1]), a.1[1].min(b.1[1]));
if hi <= lo {
continue;
}
let (d0, d1) = (x_at(a, lo) - x_at(b, lo), x_at(a, hi) - x_at(b, hi));
if d0 * d1 < 0.0 {
ys.push(lo + (hi - lo) * d0 / (d0 - d1));
}
}
}
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
let mut out = vec![];
let mut row: Vec<(f64, f64, f64, i32)> = vec![];
for w in ys.windows(2) {
let (ya, yb) = (w[0], w[1]);
let ym = (ya + yb) * 0.5;
row.clear();
row.extend(edges.iter().filter(|e| e.0[1] < ym && ym < e.1[1]).map(|e| (x_at(e, ym), x_at(e, ya), x_at(e, yb), e.2)));
row.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut wind = 0;
for k in 0..row.len().saturating_sub(1) {
wind += row[k].3;
let inside = if even_odd { wind % 2 != 0 } else { wind != 0 };
if inside {
let (l, r) = (row[k], row[k + 1]);
out.push([[l.1, ya], [r.1, ya], [r.2, yb]]);
out.push([[l.1, ya], [r.2, yb], [l.2, yb]]);
}
}
}
out
}
}
impl Widget {
pub fn find(&self, id: u32) -> Option<&Widget> {
if self.id == id {
return Some(self);
}
self.children.iter().find_map(|c| c.find(id))
}
pub fn find_mut(&mut self, id: u32) -> Option<&mut Widget> {
if self.id == id {
return Some(self);
}
self.children.iter_mut().find_map(|c| c.find_mut(id))
}
/// Every control, depth first.
pub fn walk<'a>(&'a self, out: &mut Vec<&'a Widget>) {
out.push(self);
for c in &self.children {
c.walk(out);
}
}
/// A control by id (number or `"#12"`), `properties.name` or text.
pub fn lookup(&self, key: &Value) -> Option<&Widget> {
let mut all = vec![];
self.walk(&mut all);
match key {
Value::Number(n) => n.as_u64().and_then(|n| self.find(n as u32)),
Value::String(s) => {
if let Some(n) = s.strip_prefix('#').and_then(|n| n.parse::<u32>().ok()) {
return self.find(n);
}
all.iter().find(|w| !w.name.is_empty() && w.name == *s).or_else(|| all.iter().find(|w| w.id != 0 && w.text == *s)).copied()
}
_ => None,
}
}
}
/// An open script window.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ScriptWindow {
/// Unique in the process.
pub id: u32,
/// The script context that owns it (its handlers run there).
pub host: u32,
pub kind: WindowKind,
pub title: String,
/// The script that made it.
pub script: String,
/// Shown and not closed.
pub visible: bool,
/// The script is waiting in this dialog's `show()`.
pub modal: bool,
/// The window and its controls (`root.bounds` is the content size).
pub root: Widget,
}
/// A user (or agent) action on a script window control.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScriptUiEvent {
pub window: u32,
pub widget: u32,
/// `click`, `change`, `changing` or `close`.
pub kind: String,
/// The new value: text, number, bool, or selected index(es); for `close`, the result.
pub value: Value,
}
/// 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>;
/// The open script windows.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ScriptUi {
pub windows: Vec<ScriptWindow>,
/// Bumped whenever a window changes.
pub revision: u64,
/// The scripting engine's event hand-off (set with [`Session::script`]).
#[serde(skip)]
pub dispatch: Option<ScriptUiDispatch>,
}
impl ScriptUi {
pub fn window(&self, id: u32) -> Option<&ScriptWindow> {
self.windows.iter().find(|w| w.id == id)
}
/// Replace the windows a script host published (laying them out).
pub fn publish(&mut self, host: u32, mut windows: Vec<ScriptWindow>) {
for w in &mut windows {
w.host = host;
layout(w);
}
windows.retain(|w| w.visible);
let mut out: Vec<ScriptWindow> = vec![];
// Keep the order windows first appeared in.
for old in self.windows.drain(..) {
if old.host != host {
out.push(old);
} else if let Some(i) = windows.iter().position(|w| w.id == old.id) {
out.push(windows.remove(i));
}
}
out.extend(windows);
self.windows = out;
self.revision += 1;
}
}
// ---------------------------------------------------------------- layout
/// Average character width and line height of the default dialog font (points).
const CHAR_W: f64 = 7.0;
const LINE_H: f64 = 16.0;
fn text_w(s: &str) -> f64 {
s.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * CHAR_W
}
fn lines(s: &str) -> f64 {
s.lines().count().max(1) as f64
}
fn orientation(w: &Widget) -> &str {
match w.orientation.as_str() {
"" => match w.kind {
WidgetKind::Group => "row",
WidgetKind::TabbedPanel => "stack",
_ => "column",
},
o => o,
}
}
fn margins(w: &Widget) -> [f64; 4] {
w.margins.unwrap_or(match w.kind {
WidgetKind::Window => [15.0; 4],
WidgetKind::Panel => [10.0, 15.0, 10.0, 10.0],
WidgetKind::TabbedPanel => [0.0, 24.0, 0.0, 0.0],
WidgetKind::Tab => [10.0; 4],
_ => [0.0; 4],
})
}
fn spacing(w: &Widget) -> f64 {
w.spacing.unwrap_or(10.0)
}
fn align_children(w: &Widget) -> (String, String) {
let d = match w.kind {
WidgetKind::Group => ("center", "center"),
_ => ("center", "top"),
};
let a = |i: usize, d: &str| w.align_children.get(i).filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| d.to_string());
// A single value applies to the axis it names.
if w.align_children.len() == 1 {
let v = w.align_children[0].clone();
return match v.as_str() {
"top" | "bottom" => (d.0.into(), v),
"fill" if orientation(w) == "row" => (d.0.into(), v),
_ => (v, d.1.into()),
};
}
(a(0, d.0), a(1, d.1))
}
/// Preferred size of a control (its own content; containers include their children).
pub fn preferred(w: &Widget) -> [f64; 2] {
let ps = w.preferred_size.unwrap_or([0.0; 2]);
let content = if w.kind.is_container() {
let kids: Vec<[f64; 2]> = w.children.iter().filter(|c| c.visible).map(preferred).collect();
let m = margins(w);
let sp = spacing(w);
let n = kids.len() as f64;
let (cw, ch) = match orientation(w) {
"row" => (kids.iter().map(|k| k[0]).sum::<f64>() + sp * (n - 1.0).max(0.0), kids.iter().map(|k| k[1]).fold(0.0, f64::max)),
"stack" => (kids.iter().map(|k| k[0]).fold(0.0, f64::max), kids.iter().map(|k| k[1]).fold(0.0, f64::max)),
_ => (kids.iter().map(|k| k[0]).fold(0.0, f64::max), kids.iter().map(|k| k[1]).sum::<f64>() + sp * (n - 1.0).max(0.0)),
};
let title_w = if matches!(w.kind, WidgetKind::Panel) { text_w(&w.text) + 20.0 } else { 0.0 };
[(cw + m[0] + m[2]).max(title_w), ch + m[1] + m[3]]
} else {
match w.kind {
WidgetKind::Button | WidgetKind::IconButton => [(text_w(&w.text) + 24.0).max(80.0), 25.0],
WidgetKind::StaticText => [text_w(&w.text) + 4.0, LINE_H * lines(&w.text)],
WidgetKind::EditText => {
if w.multiline {
[(text_w(&w.text) + 12.0).max(160.0), (LINE_H * lines(&w.text) + 8.0).max(60.0)]
} else {
[(text_w(&w.text) + 12.0).max(40.0), 22.0]
}
}
WidgetKind::Checkbox | WidgetKind::RadioButton => [text_w(&w.text) + 24.0, 18.0],
WidgetKind::Slider | WidgetKind::Scrollbar => [100.0, 22.0],
WidgetKind::Progressbar => [100.0, 10.0],
WidgetKind::DropDownList => [w.items.iter().map(|i| text_w(i)).fold(0.0, f64::max) + 36.0, 22.0],
WidgetKind::ListBox => [(w.items.iter().map(|i| text_w(i)).fold(0.0, f64::max) + 24.0).max(80.0), (w.items.len().clamp(3, 10) as f64 * 18.0 + 4.0)],
_ => [20.0, 20.0],
}
};
[if ps[0] > 0.0 { ps[0] } else { content[0] }, if ps[1] > 0.0 { ps[1] } else { content[1] }]
}
fn place(w: &mut Widget, x: f64, y: f64, width: f64, height: f64) {
w.bounds = [x, y, width, height];
if !w.kind.is_container() {
return;
}
let m = margins(w);
let sp = spacing(w);
let (ah, av) = align_children(w);
let inner = [m[0], m[1], (width - m[0] - m[2]).max(0.0), (height - m[1] - m[3]).max(0.0)];
let orient = orientation(w).to_string();
let mut cursor = 0.0;
for c in w.children.iter_mut() {
if !c.visible {
c.bounds = [0.0; 4];
continue;
}
if let Some(b) = c.fixed_bounds {
let (cx, cy, cw, ch) = (x + b[0], y + b[1], b[2] - b[0], b[3] - b[1]);
place(c, cx, cy, cw, ch);
continue;
}
let pref = preferred(c);
let h_align = c.alignment.first().filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| ah.clone());
let v_align = c.alignment.get(1).filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| av.clone());
let along = |a: &str, free: f64| match a {
"center" => free / 2.0,
"right" | "bottom" => free,
_ => 0.0,
};
let (cx, cy, cw, ch) = match orient.as_str() {
"row" => {
let ch = if v_align == "fill" { inner[3] } else { pref[1] };
let cy = inner[1] + along(&v_align, inner[3] - ch);
let r = (inner[0] + cursor, cy, pref[0], ch);
cursor += pref[0] + sp;
r
}
"stack" => {
let cw = if h_align == "fill" { inner[2] } else { pref[0] };
let ch = if v_align == "fill" { inner[3] } else { pref[1] };
(inner[0] + along(&h_align, inner[2] - cw), inner[1] + along(&v_align, inner[3] - ch), cw, ch)
}
_ => {
let cw = if h_align == "fill" { inner[2] } else { pref[0] };
let cx = inner[0] + along(&h_align, inner[2] - cw);
let r = (cx, inner[1] + cursor, cw, pref[1]);
cursor += pref[1] + sp;
r
}
};
place(c, x + cx, y + cy, cw, ch);
}
// Rows centre their content run as a whole when it is narrower than the row.
if orient == "row" && (ah == "center" || ah == "right") {
let used = (cursor - sp).max(0.0);
let shift = along_shift(&ah, inner[2] - used);
if shift > 0.0 {
for c in w.children.iter_mut().filter(|c| c.visible && c.fixed_bounds.is_none()) {
shift_all(c, shift, 0.0);
}
}
}
// Columns aligned to the bottom / centre move the whole run.
if orient == "column" && (av == "center" || av == "bottom") {
let used = (cursor - sp).max(0.0);
let shift = along_shift(&av, inner[3] - used);
if shift > 0.0 {
for c in w.children.iter_mut().filter(|c| c.visible && c.fixed_bounds.is_none()) {
shift_all(c, 0.0, shift);
}
}
}
}
fn along_shift(a: &str, free: f64) -> f64 {
match a {
"center" => (free / 2.0).max(0.0),
"right" | "bottom" => free.max(0.0),
_ => 0.0,
}
}
fn shift_all(w: &mut Widget, dx: f64, dy: f64) {
w.bounds[0] += dx;
w.bounds[1] += dy;
for c in &mut w.children {
shift_all(c, dx, dy);
}
}
/// ScriptUI automatic layout: size the window to its preferred size (or the size the script
/// gave it) and place every control (`bounds` relative to the window's top-left).
pub fn layout(w: &mut ScriptWindow) {
let pref = preferred(&w.root);
let (width, height) = match w.root.fixed_bounds {
Some(b) if b[2] > b[0] && b[3] > b[1] => (b[2] - b[0], b[3] - b[1]),
_ => (pref[0], pref[1]),
};
place(&mut w.root, 0.0, 0.0, width, height);
}
// ---------------------------------------------------------------- commands
fn window_p(s: &Session, p: &Value, cmd: &str) -> Result<u32> {
let key = p.get("window");
let w = match key {
Some(Value::Number(n)) => n.as_u64().map(|n| n as u32).filter(|n| s.script_ui.window(*n).is_some()),
Some(Value::String(t)) => s.script_ui.windows.iter().find(|w| w.title == *t).map(|w| w.id),
None if s.script_ui.windows.len() == 1 => s.script_ui.windows.first().map(|w| w.id),
_ => None,
};
w.ok_or_else(|| crate::commands::bad(cmd, "no such script window (give `window`: id or title from scriptui.list)"))
}
fn widget_p(s: &Session, win: u32, p: &Value, cmd: &str) -> Result<u32> {
let w = s.script_ui.window(win).ok_or_else(|| crate::commands::bad(cmd, "window closed"))?;
let key = p.get("widget").ok_or_else(|| crate::commands::bad(cmd, "missing `widget` (id, `#id`, properties.name or text)"))?;
w.root.lookup(key).map(|w| w.id).ok_or_else(|| crate::commands::bad(cmd, format!("no control {key} in window {win}")))
}
fn dispatch(s: &mut Session, ev: ScriptUiEvent) -> Result<Value> {
let f = s.script_ui.dispatch.ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))?;
// The event may close a ScriptUI panel (its tab, or the script itself): forget it then.
let open = crate::commands::scripts::open_panel_names(s);
let r = f(s, &ev).map_err(EngineError::Other);
crate::commands::scripts::forget_closed_panels(s, &open);
r
}
/// `scriptui.list`: the open script windows.
pub(crate) fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(
s.script_ui
.windows
.iter()
.map(|w| json!({"window": w.id, "title": w.title, "kind": w.kind, "script": w.script, "modal": w.modal, "size": [w.root.bounds[2], w.root.bounds[3]]}))
.collect::<Vec<_>>()
))
}
/// `scriptui.get`: one window's control tree.
pub(crate) fn get(s: &mut Session, p: &Value) -> Result<Value> {
let id = window_p(s, p, "scriptui.get")?;
Ok(serde_json::to_value(s.script_ui.window(id)).unwrap_or(Value::Null))
}
/// `scriptui.click`: press a button / toggle a checkbox / pick a radio button.
pub(crate) fn click(s: &mut Session, p: &Value) -> Result<Value> {
let c = "scriptui.click";
let window = window_p(s, p, c)?;
let widget = widget_p(s, window, p, c)?;
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")));
}
dispatch(s, ScriptUiEvent { window, widget, kind: "click".into(), value: Value::Null })
}
/// `scriptui.set`: set a control's value (edit text, slider, checkbox, list selection) as the
/// user would, firing onChanging / onChange.
pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
let c = "scriptui.set";
let window = window_p(s, p, c)?;
let widget = widget_p(s, window, p, c)?;
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")));
}
let mut value = p.get("value").cloned().ok_or_else(|| crate::commands::bad(c, "missing `value`"))?;
// List selections by item text.
if matches!(w.kind, WidgetKind::DropDownList | WidgetKind::ListBox)
&& let Value::String(t) = &value
{
let i = w.items.iter().position(|it| it == t).ok_or_else(|| crate::commands::bad(c, format!("no item `{t}` (items: {:?})", w.items)))?;
value = json!(i);
}
let kind = if p.get("changing").and_then(Value::as_bool) == Some(true) { "changing" } else { "change" };
dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), 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")?;
let result = p.get("result").cloned().unwrap_or(json!(2));
dispatch(s, ScriptUiEvent { window, widget: 0, kind: "close".into(), value: result })
}
#[cfg(test)]
mod tests {
use super::*;
fn w(kind: WidgetKind, text: &str) -> Widget {
Widget { kind, text: text.into(), enabled: true, visible: true, ..Default::default() }
}
#[test]
fn column_and_row_layout() {
let mut row = w(WidgetKind::Group, "");
row.id = 1;
let mut ok = w(WidgetKind::Button, "OK");
ok.id = 2;
let mut cancel = w(WidgetKind::Button, "Cancel");
cancel.id = 3;
row.children = vec![ok, cancel];
let mut label = w(WidgetKind::StaticText, "Name:");
label.id = 4;
let mut root = w(WidgetKind::Window, "Dialog");
root.children = vec![label, row];
let mut win = ScriptWindow { id: 1, root, visible: true, ..Default::default() };
layout(&mut win);
let r = &win.root;
// Window: 15 px margins, children stacked with 10 px spacing.
let row = r.find(1).unwrap();
assert_eq!(row.bounds[2], 170.0, "two 80 px buttons + 10 spacing");
assert_eq!(r.bounds[2], 200.0);
assert_eq!(r.find(4).unwrap().bounds[1], 15.0);
assert_eq!(row.bounds[1], 15.0 + 16.0 + 10.0);
// Centred (the default alignChildren of windows).
assert_eq!(row.bounds[0], 15.0);
let label = r.find(4).unwrap();
assert!((label.bounds[0] + label.bounds[2] / 2.0 - 100.0).abs() < 1e-9);
assert_eq!(r.find(3).unwrap().bounds[0], 15.0 + 80.0 + 10.0);
// alignChildren left / fill.
win.root.align_children = vec!["fill".into(), "top".into()];
layout(&mut win);
assert_eq!(win.root.find(4).unwrap().bounds[2], 170.0);
win.root.align_children = vec!["left".into()];
layout(&mut win);
assert_eq!(win.root.find(4).unwrap().bounds[0], 15.0);
// preferredSize wins.
win.root.find_mut(2).unwrap().preferred_size = Some([120.0, 30.0]);
layout(&mut win);
assert_eq!(win.root.find(2).unwrap().bounds[2..], [120.0, 30.0]);
// Lookup by name / text / id.
win.root.find_mut(2).unwrap().name = "ok".into();
assert_eq!(win.root.lookup(&json!("ok")).unwrap().id, 2);
assert_eq!(win.root.lookup(&json!("Cancel")).unwrap().id, 3);
assert_eq!(win.root.lookup(&json!("#4")).unwrap().id, 4);
assert_eq!(win.root.lookup(&json!(1)).unwrap().id, 1);
}
#[test]
fn draw_lists_flatten_to_polylines_and_round_trip() {
let path = vec![
PathSeg::M { x: 0.0, y: 0.0 },
PathSeg::L { x: 10.0, y: 0.0 },
PathSeg::L { x: 10.0, y: 10.0 },
PathSeg::Z,
PathSeg::R { x: 1.0, y: 2.0, w: 3.0, h: 4.0 },
PathSeg::E { x: 0.0, y: 0.0, w: 20.0, h: 10.0 },
];
let lines = PathSeg::polylines(&path, 16);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], (vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], true));
assert_eq!(lines[1].0[2], [4.0, 6.0]);
assert_eq!(lines[2].0.len(), 16);
assert!((lines[2].0[0][0] - 20.0).abs() < 1e-9 && (lines[2].0[4][1] - 10.0).abs() < 1e-9);
let op = DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path };
let j = serde_json::to_value(&op).unwrap();
assert_eq!(j["op"], "fill");
assert_eq!(j["path"][0], json!({"k": "M", "x": 0.0, "y": 0.0}));
assert_eq!(serde_json::from_value::<DrawOp>(j).unwrap(), op);
let t: DrawOp = serde_json::from_value(json!({"op": "text", "text": "Hi", "color": null, "x": 1, "y": 2, "size": 14})).unwrap();
assert!(matches!(t, DrawOp::Text { size, .. } if size == 14.0));
}
#[test]
fn publish_replaces_a_hosts_windows() {
let mut ui = ScriptUi::default();
let win = |id: u32, visible: bool| ScriptWindow { id, visible, root: w(WidgetKind::Window, ""), ..Default::default() };
ui.publish(7, vec![win(1, true), win(2, true)]);
ui.publish(8, vec![win(3, true)]);
assert_eq!(ui.windows.iter().map(|w| (w.id, w.host)).collect::<Vec<_>>(), [(1, 7), (2, 7), (3, 8)]);
ui.publish(7, vec![win(2, true), win(1, false)]);
assert_eq!(ui.windows.iter().map(|w| w.id).collect::<Vec<_>>(), [2, 3]);
let j = serde_json::to_value(&ui).unwrap();
assert_eq!(j["windows"][0]["root"]["type"], "window");
}
fn area(tris: &[[[f64; 2]; 3]]) -> f64 {
tris.iter().map(|t| ((t[1][0] - t[0][0]) * (t[2][1] - t[0][1]) - (t[2][0] - t[0][0]) * (t[1][1] - t[0][1])).abs() * 0.5).sum()
}
fn poly(pts: &[[f64; 2]]) -> Vec<PathSeg> {
let mut v = vec![PathSeg::M { x: pts[0][0], y: pts[0][1] }];
v.extend(pts[1..].iter().map(|p| PathSeg::L { x: p[0], y: p[1] }));
v.push(PathSeg::Z);
v
}
fn covers(tris: &[[[f64; 2]; 3]], p: [f64; 2]) -> bool {
let s = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
tris.iter().any(|t| {
let (d0, d1, d2) = (s(t[0], t[1]), s(t[1], t[2]), s(t[2], t[0]));
(d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0)
})
}
#[test]
fn concave_self_intersecting_and_holed_fills() {
// An L (concave): area 3, the notch stays empty.
let l = poly(&[[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0], [1.0, 2.0], [0.0, 2.0]]);
let t = PathSeg::fill_triangles(&l, 48, false);
assert!((area(&t) - 3.0).abs() < 1e-9, "{}", area(&t));
assert!(covers(&t, [0.5, 1.5]) && !covers(&t, [1.5, 1.5]));
// A five-pointed star drawn in one stroke: non-zero fills the centre, even-odd doesn't.
let star: Vec<[f64; 2]> = (0..5)
.map(|i| {
let a = (i * 2 % 5) as f64 * std::f64::consts::TAU / 5.0 - std::f64::consts::FRAC_PI_2;
[50.0 + 40.0 * a.cos(), 50.0 + 40.0 * a.sin()]
})
.collect();
let nz = PathSeg::fill_triangles(&poly(&star), 48, false);
let eo = PathSeg::fill_triangles(&poly(&star), 48, true);
assert!(covers(&nz, [50.0, 50.0]) && !covers(&eo, [50.0, 50.0]));
assert!(covers(&eo, [50.0, 15.0]), "a point of the star");
assert!(area(&nz) > area(&eo));
// A rectangle with a hole (an inner square the other way round).
let mut holed = poly(&[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]);
holed.extend(poly(&[[3.0, 3.0], [3.0, 7.0], [7.0, 7.0], [7.0, 3.0]]));
let h = PathSeg::fill_triangles(&holed, 48, false);
assert!((area(&h) - 84.0).abs() < 1e-9 && !covers(&h, [5.0, 5.0]));
// rectPath + ellipsePath in one path; an unclosed subpath fills as if closed.
let shapes = vec![PathSeg::R { x: 0.0, y: 0.0, w: 4.0, h: 2.0 }, PathSeg::E { x: 10.0, y: 0.0, w: 20.0, h: 20.0 }];
let a = area(&PathSeg::fill_triangles(&shapes, 256, false));
assert!((a - 8.0 - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}");
let open = vec![PathSeg::M { x: 0.0, y: 0.0 }, PathSeg::L { x: 4.0, y: 0.0 }, PathSeg::L { x: 0.0, y: 4.0 }];
assert!((area(&PathSeg::fill_triangles(&open, 48, false)) - 8.0).abs() < 1e-9);
}
}