//! 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, /// Selected item indices. pub selection: Vec, /// `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, /// This control's own alignment in its parent (overrides the parent's alignChildren). pub alignment: Vec, pub spacing: Option, /// `[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, /// 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, /// The image of an image control or icon button. pub image: Option, pub children: Vec, } /// 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 }, /// `strokePath(pen, path)`. Stroke { color: Option<[f32; 4]>, width: f64, path: Vec }, /// `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, 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, /// Embedded bytes as a binary string (one character per byte, as in resource strings). pub data: Option, 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> { 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 = 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::().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; /// The open script windows. #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(default)] pub struct ScriptUi { pub windows: Vec, /// Bumped whenever a window changes. pub revision: u64, /// The scripting engine's event hand-off (set with [`Session::script`]). #[serde(skip)] pub dispatch: Option, } 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) { for w in &mut windows { w.host = host; layout(w); } windows.retain(|w| w.visible); let mut out: Vec = 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::() + 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::() + 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 { 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 { 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 { let f = s.script_ui.dispatch.ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))?; f(s, &ev).map_err(EngineError::Other) } /// `scriptui.list`: the open script windows. pub(crate) fn list(s: &mut Session, _: &Value) -> Result { 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::>() )) } /// `scriptui.get`: one window's control tree. pub(crate) fn get(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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 { 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::(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::>(), [(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::>(), [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 { 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); } }