diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 628533af..49884bc0 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -188,7 +188,7 @@ pub fn specs() -> Vec { crate::query!( "scriptui.get", "Script Window Controls", - "{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, children…}}", + "{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, draw (onDraw paint list), children…}}", scriptui::get ), cmd!( @@ -205,7 +205,7 @@ pub fn specs() -> Vec { "Set Script Window Control", [], None, - "{window?, widget, value: text | number | bool | item index | item text} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", + "{window?, widget, value: text | number | bool | item index | item text, changing?: bool (a live update: each keystroke / slider step; fires onChanging only)} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", always, scriptui::set ), diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 2a76ba04..1b88bce7 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -112,9 +112,103 @@ pub struct Widget { 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, 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?)` (drawn as a placeholder frame). + Image { x: f64, y: f64, w: f64, h: f64 }, +} + +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 + } +} + impl Widget { pub fn find(&self, id: u32) -> Option<&Widget> { if self.id == id { @@ -493,7 +587,8 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result { 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); } - dispatch(s, ScriptUiEvent { window, widget, kind: "change".into(), value }) + 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). @@ -557,6 +652,31 @@ mod tests { 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(); diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index a7a85213..ad7a9fb7 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -37,7 +37,13 @@ //! `onChange` / `onChanging` / `onClose`, `show()` / `close()` and `layout.layout()`. Windows //! are published to [`Session::script_ui`](effectcraft_engine::Session::script_ui) for //! frontends and agents (`scriptui.*` commands); modal dialogs block `show()` until they close. -//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. +//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. Resource +//! strings (`new Window("dialog { ok: Button { text: 'OK' } }")`, `add("group { … }")`) build +//! whole trees; `onDraw` handlers paint with `ScriptUIGraphics` (`newPen`/`newBrush`/`rectPath`/ +//! `ellipsePath`/`moveTo`/`lineTo`/`fillPath`/`strokePath`/`drawString`/`measureString`) into a +//! serde draw list frontends paint; edit texts and sliders fire `onChanging` live. +//! * **Sockets** (`socket.rs`): `Socket` (`open`/`listen`/`poll`/`read`/`readln`/`write`/`close`) +//! over TCP, behind the same network preference; on the web `open` returns false. //! //! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which //! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console @@ -48,6 +54,7 @@ pub mod matchnames; mod model; mod runtime; +mod socket; mod ui; use effectcraft_engine::{ScriptRequest, Session}; @@ -75,3 +82,5 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { mod tests; #[cfg(test)] mod tests_ui; +#[cfg(test)] +mod tests_ui_more; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index fec9d9e3..f265ae39 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -166,7 +166,7 @@ File.prototype = { read: function (n) { if (this.__buf === null) this.open("r"); if (this.__buf === null) return ""; - var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substr(this.__pos, n); + var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substring(this.__pos, this.__pos + n); this.__pos += s.length; return s; }, @@ -255,7 +255,77 @@ Object.defineProperty(Folder, "temp", { get: function () { return null; } }); Object.defineProperty(Folder, "desktop", { get: function () { return null; } }); Object.defineProperty(Folder, "myDocuments", { get: function () { return null; } }); -function Socket() { __file("network", ""); } +// TCP sockets (behind Allow Scripts to Write Files and Access Network; see socket.rs). +function Socket() { + if (!(this instanceof Socket)) return new Socket(); + __sock("check", 0, "{}"); + this.__id = 0; + this.host = ""; + this.timeout = 10; + this.encoding = "ASCII"; + this.error = ""; +} +function __sockCall(s, op, extra) { + var p = extra || {}; + p.timeout = s.timeout; + p.encoding = s.encoding; + var r = JSON.parse(__sock(op, s.__id, JSON.stringify(p))); + s.error = r.error || ""; + return r; +} +Socket.prototype = { + constructor: Socket, + get connected() { return this.__id > 0 && __sockCall(this, "connected").value === true; }, + get eof() { return this.__id === 0 || __sockCall(this, "eof").value === true; }, + open: function (host, encoding) { + if (this.__id) this.close(); + if (encoding) this.encoding = String(encoding); + var r = __sockCall(this, "open", { host: String(host) }); + if (r.error) return false; + this.__id = r.id; + this.host = String(host); + return true; + }, + listen: function (port, encoding) { + if (this.__id) this.close(); + if (encoding) this.encoding = String(encoding); + var r = __sockCall(this, "listen", { port: __num(port, "port") }); + if (r.error) return false; + this.__id = r.id; + this.host = "127.0.0.1:" + r.port; + this.port = r.port; + return true; + }, + poll: function () { + if (!this.__id) return null; + var r = __sockCall(this, "poll"); + if (r.id === null || r.id === undefined) return null; + var s = Object.create(Socket.prototype); + s.__id = r.id; + s.host = r.host || ""; + s.timeout = this.timeout; + s.encoding = this.encoding; + s.error = ""; + return s; + }, + read: function (count) { + if (!this.__id) return ""; + return __sockCall(this, "read", { count: count === undefined ? -1 : __num(count, "count") }).data || ""; + }, + readln: function () { return this.__id ? __sockCall(this, "readln").data || "" : ""; }, + write: function () { + if (!this.__id) return false; + var s = Array.prototype.slice.call(arguments).map(__str).join(""); + return !__sockCall(this, "write", { data: s }).error; + }, + writeln: function () { return this.write(Array.prototype.slice.call(arguments).map(__str).join("") + "\n"); }, + close: function () { + if (this.__id) __sockCall(this, "close"); + this.__id = 0; + return true; + }, + toString: function () { return "[object Socket]"; } +}; var system = { callSystem: function () { throw __err("scripts can't run system commands"); } }; // ------------------------------------------------------------------ helpers diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 82e456af..291608c1 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -282,7 +282,7 @@ fn normalize(p: &str) -> std::path::PathBuf { .unwrap_or(out) } -const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; +pub(crate) const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; /// The file-access gate: reads under the project's folder are always allowed; other reads, all /// writes and listing other folders need the Allow Scripts to Write Files preference. @@ -375,13 +375,14 @@ pub(crate) fn new_context() -> Result { let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(200_000_000); limits.set_recursion_limit(2000); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 9] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 10] = [ (js_string!("__exec"), 2, native_exec), (js_string!("__query"), 2, native_query), (js_string!("__print"), 1, native_print), (js_string!("__undo"), 2, native_undo), (js_string!("__task"), 3, native_task), (js_string!("__file"), 3, native_file), + (js_string!("__sock"), 3, crate::socket::native_sock), (js_string!("__uiNewId"), 0, crate::ui::native_new_id), (js_string!("__uiModal"), 1, crate::ui::native_modal), (js_string!("__uiLayoutNative"), 1, crate::ui::native_layout), diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index e214be30..57e0f445 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -58,13 +58,205 @@ var __uiContainers = { window: 1, panel: 1, group: 1, tabbedpanel: 1, tab: 1 }; function Window(type, title, bounds, props) { if (!(this instanceof Window)) return new Window(type, title, bounds, props); - type = String(type === undefined ? "window" : type).toLowerCase(); - if (type.indexOf("{") >= 0) throw __err("ScriptUI resource strings are not supported: build the window with add()"); + type = String(type === undefined ? "window" : type); + var res = null; + if (type.indexOf("{") >= 0) { + // A resource string: `dialog { text: 'Title', ok: Button { text: 'OK' } }`. + res = __uiParseRes(type); + type = res.type; + var p0 = __uiResProps(res).props; + if (title === undefined && p0.text !== undefined) title = p0.text; + if (bounds === undefined && p0.bounds !== undefined) bounds = p0.bounds; + if (props === undefined && p0.properties !== undefined) props = p0.properties; + } + type = type.toLowerCase(); if (type !== "dialog" && type !== "palette" && type !== "window") throw __err("Bad window type " + type); __uiInit(this, "window", null, null, bounds, title, props); this.__kind = type; this.__root = true; __uiWins.push(this); + if (res) __uiApplyRes(this, res); +} + +// ---------------------------------------------------------------- resource strings +// +// The resource specification the JavaScript Tools Guide documents: `type { name: value, … }` +// where a value is a string ('…' or "…"), a number, true / false, an array `[…]`, an object +// literal `{…}` (e.g. `properties: {multiline: true}`) or a nested element `Type { … }` (a +// child control, also reachable as `parent.name`). Comments are allowed. + +function __uiParseRes(src) { + var s = String(src), i = 0; + function fail(m) { throw __err("ScriptUI resource string: " + m + " at offset " + i); } + function ws() { + for (;;) { + while (i < s.length && /\s/.test(s.charAt(i))) i++; + if (s.substring(i, i + 2) === "//") { + while (i < s.length && s.charAt(i) !== "\n") i++; + continue; + } + if (s.substring(i, i + 2) === "/*") { + var e = s.indexOf("*/", i + 2); + i = e < 0 ? s.length : e + 2; + continue; + } + return; + } + } + function ident() { + ws(); + var m = /^[A-Za-z_$][\w$]*/.exec(s.slice(i)); + if (!m) fail("expected a name"); + i += m[0].length; + return m[0]; + } + function str(q) { + i++; + var out = ""; + while (i < s.length && s.charAt(i) !== q) { + var c = s.charAt(i); + if (c === "\\") { + i++; + var e = s.charAt(i); + out += e === "n" ? "\n" : e === "t" ? "\t" : e === "r" ? "\r" : e; + } else out += c; + i++; + } + if (i >= s.length) fail("unterminated string"); + i++; + return out; + } + function value() { + ws(); + var c = s.charAt(i); + if (c === "'" || c === '"') return str(c); + if (c === "[") { + i++; + var a = []; + ws(); + if (s.charAt(i) === "]") { + i++; + return a; + } + for (;;) { + a.push(value()); + ws(); + if (s.charAt(i) === ",") { + i++; + continue; + } + if (s.charAt(i) === "]") { + i++; + return a; + } + fail("expected , or ]"); + } + } + if (c === "{") return { __object: body() }; + var m = /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?/.exec(s.slice(i)); + if (m) { + i += m[0].length; + return Number(m[0]); + } + var id = ident(); + if (id === "true") return true; + if (id === "false") return false; + if (id === "null" || id === "undefined") return null; + ws(); + if (s.charAt(i) === "{") return { __element: id, list: body() }; + // A bare word (an enum-like value). + return id; + } + function body() { + i++; + var list = []; + for (;;) { + ws(); + if (s.charAt(i) === "}") { + i++; + return list; + } + var k = s.charAt(i) === "'" || s.charAt(i) === '"' ? str(s.charAt(i)) : ident(); + ws(); + if (s.charAt(i) !== ":") fail("expected : after " + k); + i++; + list.push([k, value()]); + ws(); + if (s.charAt(i) === ",") { + i++; + continue; + } + if (s.charAt(i) === "}") { + i++; + return list; + } + fail("expected , or }"); + } + } + var t = ident(); + ws(); + if (s.charAt(i) !== "{") fail("expected { after " + t); + var list = body(); + ws(); + if (i < s.length) fail("unexpected text after the resource"); + return { type: t, list: list }; +} + +// The plain JavaScript value of a parsed resource value (object literals become objects). +function __uiResPlain(v) { + if (v instanceof Array) return v.map(__uiResPlain); + if (v && typeof v === "object" && v.__object) { + var o = {}; + for (var k = 0; k < v.__object.length; k++) o[v.__object[k][0]] = __uiResPlain(v.__object[k][1]); + return o; + } + return v; +} + +// A resource's own properties and its child elements (in source order). +function __uiResProps(res) { + var props = {}, kids = []; + for (var k = 0; k < res.list.length; k++) { + var e = res.list[k]; + if (e[1] && typeof e[1] === "object" && e[1].__element) kids.push([e[0], { type: e[1].__element, list: e[1].list }]); + else props[e[0]] = __uiResPlain(e[1]); + } + return { props: props, kids: kids }; +} + +var __uiResSkip = { text: 1, bounds: 1, properties: 1, minvalue: 1, maxvalue: 1, items: 1 }; +var __uiSizes = { preferredSize: 1, minimumSize: 1, maximumSize: 1, size: 1, location: 1 }; + +// Set a control's properties and create its children from a parsed resource. +function __uiApplyRes(c, res) { + var pk = __uiResProps(res), props = pk.props; + // Ranges before values, items before selections. + if (props.minvalue !== undefined) c.minvalue = __num(props.minvalue); + if (props.maxvalue !== undefined) c.maxvalue = __num(props.maxvalue); + if (props.items instanceof Array && c.items) for (var n = 0; n < props.items.length; n++) c.add("item", props.items[n]); + for (var k in props) { + if (__uiResSkip[k]) continue; + var v = props[k]; + if (__uiSizes[k] && v instanceof Array) v = __dim(v[0], v[1]); + c[k] = v; + } + for (var j = 0; j < pk.kids.length; j++) { + var child = __uiAddRes(c, pk.kids[j][1]); + child.__key = pk.kids[j][0]; + c[pk.kids[j][0]] = child; + } + return c; +} + +// add() with a resource string (or a parsed element): the new control. +function __uiAddRes(parent, spec) { + var res = typeof spec === "string" ? __uiParseRes(spec) : spec; + var p = __uiResProps(res).props; + var t = String(res.type).toLowerCase(); + var valued = t === "dropdownlist" || t === "listbox" || t === "slider" || t === "scrollbar" || t === "progressbar"; + var c = parent.add(t, p.bounds, valued ? undefined : p.text, p.properties); + if (valued && p.text !== undefined) c.text = String(p.text); + return __uiApplyRes(c, res); } Window.alert = function (s) { alert(s); }; Window.confirm = function (s) { return confirm(s); }; @@ -106,18 +298,95 @@ var ScriptUI = { events: { createEvent: function (t) { return { type: t }; } } }; +// A colour for the draw list: [r, g, b, a] in 0..1, or null for a theme colour (by name). +function __uiColor(c) { + if (c instanceof Array && c.length >= 3) return [__num(c[0]), __num(c[1]), __num(c[2]), c.length > 3 ? __num(c[3]) : 1]; + return null; +} +function __uiFontSize(f, g) { + var x = f && f.size !== undefined ? Number(f.size) : g && g.font && g.font.size !== undefined ? Number(g.font.size) : 12; + return x > 0 ? x : 12; +} + +// ScriptUIGraphics: what onDraw handlers paint with. Paths and paint calls are recorded into the +// control's draw list (`draw` in the window snapshot), which frontends paint. function __uiGraphics() { return { - newBrush: function (t, c) { return { type: t, color: c }; }, - newPen: function (t, c, w) { return { type: t, color: c, lineWidth: w }; }, - newPath: function () {}, moveTo: function () {}, lineTo: function () {}, rectPath: function () {}, - ellipsePath: function () {}, fillPath: function () {}, strokePath: function () {}, closePath: function () {}, - drawString: function () {}, drawOSControl: function () {}, drawImage: function () {}, drawFocusRing: function () {}, - measureString: function (s) { return __dim(String(s).length * 7, 16); }, - font: null, foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null + BrushType: ScriptUI.BrushType, + PenType: ScriptUI.PenType, + font: ScriptUI.newFont("dialog", "REGULAR", 12), + foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null, + currentPath: [], + currentPoint: [0, 0], + __rec: null, + __push: function (op) { if (this.__rec) this.__rec.push(op); }, + newBrush: function (t, c) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "" }; }, + newPen: function (t, c, w) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "", lineWidth: w === undefined ? 1 : __num(w) }; }, + newPath: function () { this.currentPath = []; return this.currentPath; }, + moveTo: function (x, y) { this.currentPath.push({ k: "M", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; }, + lineTo: function (x, y) { this.currentPath.push({ k: "L", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; }, + closePath: function () { this.currentPath.push({ k: "Z" }); }, + rectPath: function (x, y, w, h) { + if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; } + this.currentPath.push({ k: "R", x: __num(x), y: __num(y), w: __num(w), h: __num(h) }); + return this.currentPath; + }, + ellipsePath: function (x, y, w, h) { + if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; } + this.currentPath.push({ k: "E", x: __num(x), y: __num(y), w: __num(w), h: __num(h) }); + return this.currentPath; + }, + fillPath: function (brush, path) { + this.__push({ op: "fill", color: brush ? brush.color : null, path: (path || this.currentPath).slice() }); + }, + strokePath: function (pen, path) { + this.__push({ op: "stroke", color: pen ? pen.color : null, width: pen && pen.lineWidth !== undefined ? pen.lineWidth : 1, path: (path || this.currentPath).slice() }); + }, + drawString: function (text, pen, x, y, font) { + var f = font || this.font; + this.__push({ op: "text", text: String(text), color: pen ? pen.color : null, x: __num(x || 0), y: __num(y || 0), size: __uiFontSize(f, this), style: String(f && f.style !== undefined ? f.style : "") }); + }, + drawOSControl: function () { this.__push({ op: "os" }); }, + drawImage: function (img, x, y, w, h) { this.__push({ op: "image", x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); }, + drawFocusRing: function () {}, + measureString: function (s, font, boundaryWidth) { + var size = __uiFontSize(font, this); + var lines = String(s).split("\n"); + var cw = size * 0.58, w = 0; + for (var i = 0; i < lines.length; i++) w = Math.max(w, Math.ceil(lines[i].length * cw)); + var n = lines.length; + if (boundaryWidth > 0 && w > boundaryWidth) { + n = Math.ceil(w / boundaryWidth) + lines.length - 1; + w = boundaryWidth; + } + return __dim(w, Math.ceil(size * 1.25) * n); + } }; } +// Run the onDraw handlers of a window's controls (after laying it out, so `size` is known), +// recording what they paint. +function __uiRunDraws(win) { + var drawn = []; + (function walk(c) { + if (typeof c.onDraw === "function") drawn.push(c); + for (var i = 0; i < (c.children || []).length; i++) walk(c.children[i]); + })(win); + if (!drawn.length) return; + __uiLayout(win); + for (var k = 0; k < drawn.length; k++) { + var c = drawn[k]; + c.__draw = []; + c.graphics.__rec = c.__draw; + try { + c.onDraw.call(c, { type: "draw", target: c }); + } catch (e) { + try { writeLn("onDraw: " + (e && e.message ? e.message : e)); } catch (e2) {} + } + c.graphics.__rec = null; + } +} + function __uiInit(o, type, win, parent, bounds, text, props) { o.type = type; o.window = win || o; @@ -180,6 +449,10 @@ function __uiProto() { return { add: function (type, bounds, text, props) { var t = String(type).toLowerCase(); + if (t.indexOf("{") >= 0) { + if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls"); + return __uiAddRes(this, String(type)); + } if (t === "item" || t === "separator") return this.__addItem(t === "separator" ? "-" : bounds); if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls"); var ctor = __uiTypes[t]; @@ -302,7 +575,7 @@ function __uiProto() { }, __handlers: function () { var out = []; - var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize"]; + var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize", "onDraw"]; for (var i = 0; i < names.length; i++) { var n = names[i]; if (typeof this[n] === "function" || (this.__listeners[n.slice(2).toLowerCase()] || []).length) out.push(n); @@ -411,7 +684,7 @@ function __uiJson(c) { var o = { id: c.__id, type: c.type, - name: String(c.name || c.properties.name || ""), + name: String(c.name || c.properties.name || c.__key || ""), text: String(c.text === undefined || c.text === null ? "" : c.text), enabled: c.enabled !== false, visible: c.visible !== false, @@ -444,6 +717,7 @@ function __uiJson(c) { o.selection = c.__sel.slice(); } if (c.type === "tabbedpanel") o.activeTab = c.__sel.length ? c.__sel[0] : 0; + if (c.__draw && typeof c.onDraw === "function") o.draw = c.__draw; if (c.type === "edittext") { o.multiline = !!c.__multiline; o.readOnly = !!c.__readonly; @@ -456,6 +730,7 @@ function __uiSnapshot() { var out = []; for (var i = 0; i < __uiWins.length; i++) { var w = __uiWins[i]; + if (w.__shown && !w.__closed) __uiRunDraws(w); out.push({ id: w.__wid, kind: w.__kind, title: String(w.text || ""), visible: !!w.__shown && !w.__closed, modal: !!w.__modal, root: __uiJson(w) }); } // Closed windows are forgotten. @@ -529,14 +804,17 @@ function __uiDispatch(winId, id, kind, valueJson) { } return; } - // change / changing + // change / changing (live: each keystroke of an edit text, each step of a slider drag) + var before = c.type === "edittext" ? c.text : c.value; if (c.type === "edittext") c.text = v === null ? "" : String(v); else if (c.type === "statictext") c.text = v === null ? "" : String(v); else if (c.type === "slider" || c.type === "scrollbar" || c.type === "progressbar") c.value = Number(v); else if (c.type === "checkbox") c.value = !!v; else if (c.type === "radiobutton") { if (v) radio(); else c.value = false; } else if (c.type === "dropdownlist" || c.type === "listbox" || c.type === "tabbedpanel") c.selection = v; - if (c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") c.__fire("onChanging"); + var now = c.type === "edittext" ? c.text : c.value; + // onChanging already ran for live updates of the same value. + if ((c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") && (kind === "changing" || now !== before)) c.__fire("onChanging"); if (kind === "change") { if (c.type === "checkbox" || c.type === "radiobutton") c.__fire("onClick"); else c.__fire("onChange"); diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs new file mode 100644 index 00000000..8b1074d4 --- /dev/null +++ b/crates/script/src/socket.rs @@ -0,0 +1,241 @@ +//! `Socket`: TCP connections for scripts (the JavaScript Tools Guide's Socket object: `open`, +//! `listen`, `poll`, `read`, `readln`, `write`, `writeln`, `close`, `connected`, `eof`). +//! +//! Network access follows Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files +//! and Access Network: with it off, `new Socket()` throws. On the web there are no raw sockets: +//! `open` and `listen` return false (with `error` set). +//! +//! The JavaScript side holds only a handle; the streams live here, keyed by handle, so a script +//! window's handlers can keep using a socket opened earlier in the same script. + +use boa_engine::{Context, JsResult, JsValue}; +use serde_json::{Value as J, json}; + +use crate::runtime::{arg_string, js_str, throw, with_active}; + +/// `__sock(op, handle, paramsJson)` → result JSON (`{error}` on failure). +pub(crate) fn native_sock(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let op = arg_string(args, 0, ctx)?; + let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32; + let params = arg_string(args, 2, ctx)?; + let p: J = serde_json::from_str(¶ms).unwrap_or(J::Null); + let allowed = with_active(|a| Ok(a.session.prefs.scripting.allow_scripts_write_files)).unwrap_or(false); + if !allowed { + return Err(throw(format!("scripts can't access the network unless {} is on", crate::runtime::GATE))); + } + if op == "check" { + return Ok(js_str("{}")); + } + let r = imp::run(&op, id, &p); + Ok(js_str(&r.to_string())) +} + +fn err(e: impl std::fmt::Display) -> J { + json!({"error": e.to_string()}) +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use super::*; + pub(super) fn run(op: &str, _: u32, _: &J) -> J { + match op { + "connected" | "eof" => json!({"value": op == "eof"}), + "close" => json!({}), + _ => err("sockets are not available in the web version of EffectCraft"), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use std::collections::BTreeMap; + use std::io::{ErrorKind, Read, Write}; + use std::net::{TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + use super::*; + + enum Sock { + Stream { s: TcpStream, buf: Vec, eof: bool }, + Listener(TcpListener), + } + + struct Table { + next: u32, + socks: BTreeMap, + } + + fn table() -> &'static Mutex { + static T: Mutex
= Mutex::new(Table { next: 1, socks: BTreeMap::new() }); + &T + } + + fn add(s: Sock) -> u32 { + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + let id = t.next; + t.next += 1; + t.socks.insert(id, s); + id + } + + fn timeout(p: &J) -> Duration { + Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) + } + + /// `host:port` (After Effects' form) → socket addresses. + fn addr(host: &str) -> Result, String> { + let a = host.to_socket_addrs().map_err(|e| format!("bad address `{host}` (use host:port): {e}"))?; + Ok(a.collect()) + } + + /// Text ↔ bytes in the socket's encoding: UTF-8, else one byte per character (ASCII / + /// BINARY, as in After Effects). + fn encode(s: &str, utf8: bool) -> Vec { + if utf8 { s.as_bytes().to_vec() } else { s.chars().map(|c| (c as u32).min(255) as u8).collect() } + } + fn decode(b: &[u8], utf8: bool) -> String { + if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() } + } + + /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. + fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) { + let end = Instant::now() + limit; + let mut chunk = [0u8; 4096]; + while !done(buf) && !*eof { + let left = end.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1)))); + match s.read(&mut chunk) { + Ok(0) => *eof = true, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => { + if e.kind() != ErrorKind::Interrupted { + break; + } + } + Err(_) => *eof = true, + } + } + } + + pub(super) fn run(op: &str, id: u32, p: &J) -> J { + let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8")); + match op { + "open" => { + let host = p.get("host").and_then(J::as_str).unwrap_or(""); + let addrs = match addr(host) { + Ok(a) => a, + Err(e) => return err(e), + }; + let limit = timeout(p).max(Duration::from_millis(10)); + let mut last = "no address".to_string(); + for a in addrs { + match TcpStream::connect_timeout(&a, limit) { + Ok(s) => { + let _ = s.set_nodelay(true); + return json!({"id": add(Sock::Stream { s, buf: vec![], eof: false })}); + } + Err(e) => last = e.to_string(), + } + } + err(format!("cannot connect to {host}: {last}")) + } + "listen" => { + let port = p.get("port").and_then(J::as_u64).unwrap_or(0) as u16; + match TcpListener::bind(("127.0.0.1", port)).and_then(|l| l.set_nonblocking(true).map(|_| l)) { + Ok(l) => { + let port = l.local_addr().map(|a| a.port()).unwrap_or(port); + json!({"id": add(Sock::Listener(l)), "port": port}) + } + Err(e) => err(format!("cannot listen on port {port}: {e}")), + } + } + _ => { + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + if op == "close" { + t.socks.remove(&id); + return json!({}); + } + let Some(sock) = t.socks.get_mut(&id) else { + return match op { + "connected" => json!({"value": false}), + "eof" => json!({"value": true}), + _ => err("the socket is not open"), + }; + }; + match (op, sock) { + ("poll", Sock::Listener(l)) => match l.accept() { + Ok((s, peer)) => { + let _ = s.set_nonblocking(false); + let host = peer.to_string(); + let new = Sock::Stream { s, buf: vec![], eof: false }; + let nid = t.next; + t.next += 1; + t.socks.insert(nid, new); + json!({"id": nid, "host": host}) + } + Err(_) => json!({"id": null}), + }, + ("connected", Sock::Stream { s, buf, eof }) => { + if !*eof && buf.is_empty() { + // Peek without blocking to notice a closed peer. + let _ = s.set_nonblocking(true); + let mut b = [0u8; 1]; + match s.peek(&mut b) { + Ok(0) => *eof = true, + Err(e) if e.kind() != ErrorKind::WouldBlock => *eof = true, + _ => {} + } + let _ = s.set_nonblocking(false); + } + json!({"value": !(*eof && buf.is_empty())}) + } + ("connected", Sock::Listener(_)) => json!({"value": true}), + ("eof", Sock::Stream { buf, eof, .. }) => json!({"value": *eof && buf.is_empty()}), + ("read", Sock::Stream { s, buf, eof }) => { + let count = p.get("count").and_then(J::as_i64).unwrap_or(-1); + let limit = timeout(p); + if count < 0 { + fill(s, buf, eof, limit, |_| false); + let all = std::mem::take(buf); + json!({"data": decode(&all, utf8)}) + } else { + let n = count as usize; + fill(s, buf, eof, limit, |b| b.len() >= n); + let take = n.min(buf.len()); + let out: Vec = buf.drain(..take).collect(); + json!({"data": decode(&out, utf8)}) + } + } + ("readln", Sock::Stream { s, buf, eof }) => { + fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')); + let line: Vec = match buf.iter().position(|&c| c == b'\n') { + Some(i) => { + let mut l: Vec = buf.drain(..=i).collect(); + l.pop(); + if l.last() == Some(&b'\r') { + l.pop(); + } + l + } + None => std::mem::take(buf), + }; + json!({"data": decode(&line, utf8)}) + } + ("write", Sock::Stream { s, .. }) => { + let data = p.get("data").and_then(J::as_str).unwrap_or(""); + match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { + Ok(()) => json!({"ok": true}), + Err(e) => err(e), + } + } + ("host", Sock::Stream { s, .. }) => json!({"value": s.peer_addr().map(|a| a.to_string()).unwrap_or_default()}), + (op, _) => err(format!("`{op}` is not supported on this socket")), + } + } + } + } +} diff --git a/crates/script/src/tests_ui.rs b/crates/script/src/tests_ui.rs index 667a3242..460376ac 100644 --- a/crates/script/src/tests_ui.rs +++ b/crates/script/src/tests_ui.rs @@ -183,9 +183,9 @@ fn layout_and_handler_errors() { assert_eq!(r["ok"], false); assert!(r["error"]["message"].as_str().unwrap().contains("nope"), "{r}"); assert_eq!(s.script_ui.windows.len(), 1); - // Resource strings aren't supported (a clear error). - let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\")", "res.jsx"); - assert!(o.error.unwrap().message.contains("resource")); + // Resource strings build windows too (see tests_ui_more). + let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\").text", "res.jsx"); + assert_eq!(o.result, json!("x")); s.execute("scriptui.close", json!({})).unwrap(); } diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs new file mode 100644 index 00000000..29d92840 --- /dev/null +++ b/crates/script/src/tests_ui_more.rs @@ -0,0 +1,173 @@ +//! ScriptUI resource strings, onDraw graphics, live onChanging updates and sockets. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::scriptui::{DrawOp, PathSeg, WidgetKind, WindowKind}; +use serde_json::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 RESOURCE: &str = r#" +var w = new Window("palette { text: 'Res', orientation: 'column', alignChildren: ['fill', 'top'], \ + g: Group { orientation: 'row', b: Button { text: 'OK', properties: { name: 'okb' } }, cancel: Button { text: \"Can't\" } }, \ + nm: EditText { text: 'abc', characters: 10 }, \ + s: Slider { minvalue: 0, maxvalue: 10, value: 4 }, \ + dd: DropDownList { properties: { items: ['A', 'B'] }, selection: 1 }, \ + /* a panel */ p: Panel { text: 'Opts', preferredSize: [200, -1], c: Checkbox { text: 'On', value: true } } }"); +var extra = w.p.add("group { orientation: 'column', t: StaticText { text: 'hi' } }"); +w.g.b.onClick = function () { writeLn("ok " + w.nm.text + " " + w.s.value); }; +w.show(); +[w.text, w.g.orientation, w.g.b.text, w.g.cancel.text, w.s.maxvalue, w.dd.selection.text, w.p.c.value, extra.t.text, w.g.children.length, w.p.preferredSize.width].join("|") +"#; + +#[test] +fn resource_strings_build_windows_and_controls() { + let mut s = session(); + let o = run_code(&mut s, RESOURCE, "res.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("Res|row|OK|Can't|10|B|true|hi|2|200")); + let w = s.script_ui.windows[0].clone(); + assert_eq!((w.kind, w.title.as_str()), (WindowKind::Palette, "Res")); + let kinds: Vec = w.root.children.iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [WidgetKind::Group, WidgetKind::EditText, WidgetKind::Slider, WidgetKind::DropDownList, WidgetKind::Panel]); + assert_eq!(w.root.children[2].value, 4.0); + assert_eq!(w.root.children[3].items, ["A", "B"]); + assert_eq!(w.root.children[3].selection, [1]); + assert!(w.root.children[4].children[0].checked); + assert_eq!(w.root.children[4].children[1].children[0].text, "hi"); + // Elements are named after their resource keys (agents address them by it). + assert_eq!(w.root.children[1].name, "nm"); + s.execute("scriptui.set", json!({"widget": "nm", "value": "xyz"})).unwrap(); + let r = s.execute("scriptui.click", json!({"widget": "okb"})).unwrap(); + assert_eq!(r["output"], "ok xyz 4", "{r}"); + s.execute("scriptui.close", json!({})).unwrap(); + // Syntax errors say where. + let o = run_code(&mut s, "new Window(\"dialog { text: 'x' oops }\")", "bad.jsx"); + assert!(o.error.unwrap().message.contains("resource string")); + let o = run_code(&mut s, "new Window(\"panel { }\")", "bad.jsx"); + assert!(o.error.unwrap().message.contains("Bad window type")); +} + +const DRAW: &str = r#" +var w = new Window("palette", "Draw"); +var c = w.add("group"); +c.preferredSize = [100, 50]; +c.onDraw = function () { + var g = this.graphics; + var b = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0, 0, 1]); + g.newPath(); + g.rectPath(0, 0, this.size.width, this.size.height); + g.fillPath(b); + var p = g.newPen(g.PenType.SOLID_COLOR, [0, 0, 1], 2); + g.newPath(); + g.moveTo(0, 0); + g.lineTo(10, 10); + g.strokePath(p); + g.ellipsePath(20, 20, 10, 10); + g.drawString("Hi", p, 5, 6, ScriptUI.newFont("dialog", "BOLD", 20)); +}; +var m = c.graphics.measureString("Hello", ScriptUI.newFont("dialog", "REGULAR", 12)); +w.show(); +[m.width, m[1]].join(",") +"#; + +#[test] +fn on_draw_handlers_record_a_draw_list() { + let mut s = session(); + let o = run_code(&mut s, DRAW, "draw.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("35,15")); + let w = s.script_ui.windows[0].clone(); + let g = &w.root.children[0]; + assert!(g.handlers.contains(&"onDraw".to_string())); + assert_eq!(g.draw.len(), 3, "{:?}", g.draw); + // The handler saw the laid-out size. + assert_eq!(g.draw[0], DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path: vec![PathSeg::R { x: 0.0, y: 0.0, w: 100.0, h: 50.0 }] }); + match &g.draw[1] { + DrawOp::Stroke { color, width, path } => { + assert_eq!((*color, *width), (Some([0.0, 0.0, 1.0, 1.0]), 2.0)); + assert_eq!(path.len(), 2); + } + o => panic!("{o:?}"), + } + assert!(matches!(&g.draw[2], DrawOp::Text { text, size, style, .. } if text == "Hi" && *size == 20.0 && style == "BOLD")); + // scriptui.get carries it for agents. + let tree = s.execute("scriptui.get", json!({})).unwrap(); + assert_eq!(tree["root"]["children"][0]["draw"][0]["op"], "fill"); + s.execute("scriptui.close", json!({})).unwrap(); +} + +const LIVE: &str = r#" +var w = new Window("palette", "Live"); +var e = w.add("edittext", undefined, ""); +var sl = w.add("slider", undefined, 0, 0, 100); +var log = w.add("statictext", undefined, "-"); +e.onChanging = function () { writeLn("changing " + e.text); log.text = "typing " + e.text; }; +e.onChange = function () { writeLn("change " + e.text); }; +sl.onChanging = function () { writeLn("slide " + Math.round(sl.value)); }; +sl.onChange = function () { writeLn("slid " + Math.round(sl.value)); }; +w.show(); +"#; + +#[test] +fn on_changing_fires_per_keystroke_and_slider_step() { + let mut s = session(); + let o = run_code(&mut s, LIVE, "live.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "a", "changing": true})).unwrap(); + assert_eq!(r["output"], "changing a"); + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab", "changing": true})).unwrap(); + assert_eq!(r["output"], "changing ab"); + assert_eq!(s.script_ui.windows[0].root.children[2].text, "typing ab"); + // Committing the same text runs onChange only. + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab"})).unwrap(); + assert_eq!(r["output"], "change ab"); + // A committed edit without live updates runs both. + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "abc"})).unwrap(); + assert_eq!(r["output"], "changing abc\nchange abc"); + // Slider drags update the value live. + let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 40, "changing": true})).unwrap(); + assert_eq!(r["output"], "slide 40"); + assert_eq!(s.script_ui.windows[0].root.children[1].value, 40.0); + let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 41})).unwrap(); + assert_eq!(r["output"], "slide 41\nslid 41"); + s.execute("scriptui.close", json!({})).unwrap(); +} + +#[test] +fn sockets_talk_tcp_behind_the_network_preference() { + use std::io::{BufRead, BufReader, Write}; + let mut s = session(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut c, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(c.try_clone().unwrap()).read_line(&mut line).unwrap(); + c.write_all(format!("pong:{}\nrest", line.trim()).as_bytes()).unwrap(); + }); + let code = format!( + "var s = new Socket(); var ok = s.open('127.0.0.1:{port}'); s.writeln('ping'); var r = s.readln(); var rest = s.read(); var e = s.eof; s.close(); [ok, r, rest, e, s.connected].join('|')" + ); + // Off: no network. + let o = run_code(&mut s, &code, "net.jsx"); + assert!(o.error.unwrap().message.contains("Access Network")); + s.prefs.scripting.allow_scripts_write_files = true; + let o = run_code(&mut s, &code, "net.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("true|pong:ping|rest|true|false")); + server.join().unwrap(); + // Refused connections return false with an error; listen + poll serve clients. + let o = run_code(&mut s, "var s = new Socket(); s.timeout = 1; var ok = s.open('127.0.0.1:1'); [ok, s.error.length > 0].join('|')", "net.jsx"); + assert_eq!(o.result, json!("false|true")); + let o = run_code(&mut s, "var l = new Socket(); l.listen(0); var r = [l.port > 0, l.poll() === null].join('|'); l.close(); r", "net.jsx"); + assert_eq!(o.result, json!("true|true")); +} diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 580e287b..ddf3d35e 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -3,9 +3,10 @@ //! 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..` (and `scriptui..` when the script -//! named it). +//! 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. -use effectcraft_engine::scriptui::{ScriptWindow, Widget, WidgetKind, WindowKind, layout}; +use effectcraft_engine::scriptui::{DrawOp, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout}; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2}; use serde_json::{Value, json}; @@ -16,6 +17,8 @@ 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), } @@ -78,6 +81,27 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, 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); + 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); + 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); + } match w.kind { WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts), WidgetKind::Panel => { @@ -112,8 +136,16 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, 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); - if r.lost_focus() && buf != w.text { + // 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::(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::(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)); } @@ -145,6 +177,10 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, .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::(id)); @@ -203,6 +239,45 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, 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); + } +} + +/// Paint an onDraw draw list in `rect` (control coordinates → screen). +fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { + 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 } => { + let c = col(color, t.field_bg); + for (pts, _) in PathSeg::polylines(path, 48) { + let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); + p.add(egui::Shape::convex_polygon(pts, c, Stroke::NONE)); + } + } + 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 = 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 { x, y, w, h } => { + let r = Rect::from_min_size(at(*x, *y), vec2(*w as f32, *h as f32)); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + DrawOp::Os => {} + } + } } /// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds. @@ -216,6 +291,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { 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})), }; match crate::menus::invoke(app, ctx, cmd, p) {