diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 1b88bce7..6ef64920 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -116,6 +116,8 @@ pub struct Widget { /// 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, } @@ -156,8 +158,37 @@ pub enum DrawOp { }, /// `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 }, + /// `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 { @@ -207,6 +238,67 @@ impl PathSeg { 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 { @@ -689,4 +781,55 @@ mod tests { 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); + } } diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 57e0f445..2f406741 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -281,9 +281,34 @@ function DropDownList() {} function ListBox() {} function TabbedPanel() {} function Tab() {} -function ScriptUIImage() {} +// ScriptUIImage: an image file (`src`) or embedded image bytes (`data`, a binary string as in +// resource-string images: one character per byte). +function ScriptUIImage(src, data, name) { + this.__img = true; + this.src = src || null; + this.data = data || null; + this.name = name || (src ? String(src).replace(/^.*[\\/]/, "") : ""); + this.pathname = src || ""; + this.format = data ? (data.charCodeAt(0) === 0x89 ? "png" : "jpeg") : String(src || "").replace(/^.*\./, "").toLowerCase(); +} +ScriptUIImage.prototype.toString = function () { return "[object ScriptUIImage]"; }; function ListItem() {} +// Anything ScriptUI accepts as an image: a ScriptUIImage, a File, a path (or resource name), or +// a binary string holding PNG / JPEG bytes. +function __uiImage(v) { + if (v === undefined || v === null || typeof v === "number" || typeof v === "boolean") return null; + if (v.__img) return v; + if (v.fsName !== undefined) return new ScriptUIImage(String(v.fsName), null, String(v.name || "")); + var s = String(v); + if ((s.charCodeAt(0) === 0x89 && s.substr(1, 3) === "PNG") || (s.charCodeAt(0) === 0xff && s.charCodeAt(1) === 0xd8)) return new ScriptUIImage(null, s, ""); + return s ? new ScriptUIImage(s, null, "") : null; +} +function __uiImageJson(v) { + var img = __uiImage(v); + return img ? { src: img.src, data: img.data, name: img.name } : null; +} + var ScriptUI = { version: "6.2.2", frameworkName: "EffectCraft", @@ -293,7 +318,7 @@ var ScriptUI = { PenType: { SOLID_COLOR: 0, THEME_COLOR: 1 }, Alignment: { LEFT: "left", RIGHT: "right", TOP: "top", BOTTOM: "bottom", CENTER: "center", FILL: "fill" }, newFont: function (name, style, size) { return { name: name, style: style, size: size }; }, - newImage: function () { return {}; }, + newImage: function (normal) { return __uiImage(normal); }, getResourceText: function (t) { return t; }, events: { createEvent: function (t) { return { type: t }; } } }; @@ -347,7 +372,7 @@ function __uiGraphics() { 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) }); }, + drawImage: function (img, x, y, w, h) { this.__push({ op: "image", image: __uiImageJson(img), 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); @@ -397,6 +422,11 @@ function __uiInit(o, type, win, parent, bounds, text, props) { o.__wid = __uiNewId(); } o.properties = props || {}; + // Image controls and icon buttons take an image where other controls take their text. + if ((type === "image" || type === "iconbutton") && __uiImage(text)) { + o.image = __uiImage(text); + text = ""; + } o.text = text === undefined || text === null ? "" : String(text); o.enabled = true; o.visible = true; @@ -459,7 +489,8 @@ function __uiProto() { if (!ctor || t === "window") throw __err("add(): unknown control type " + type); var c = Object.create(__ecGlobal[ctor].prototype); // Value arguments after `text`: slider (value, min, max), progressbar (value, max), lists (items). - __uiInit(c, t, this.window, this, bounds, typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text, props); + var arg = t === "image" || t === "iconbutton" ? text : typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text; + __uiInit(c, t, this.window, this, bounds, arg, props); if ((t === "dropdownlist" || t === "listbox") && text instanceof Array) { for (var i = 0; i < text.length; i++) c.add("item", text[i]); c.text = ""; @@ -718,6 +749,7 @@ function __uiJson(c) { } 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 === "image" || c.type === "iconbutton") && c.image) o.image = __uiImageJson(c.image); if (c.type === "edittext") { o.multiline = !!c.__multiline; o.readOnly = !!c.__readonly; diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index 66574ce0..609b767b 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -127,10 +127,24 @@ pub(crate) fn native_modal(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> // ---------------------------------------------------------------- script-host threads pub(crate) enum ToHost { + /// Run the script (script-host threads exist only off the web: the browser runs scripts + /// inline). + #[cfg(not(target_arch = "wasm32"))] Start(Session), Event(Session, ScriptUiEvent), } +impl ToHost { + /// The session a message carries (given back when the host has gone). + fn into_session(self) -> Session { + match self { + #[cfg(not(target_arch = "wasm32"))] + ToHost::Start(s) => s, + ToHost::Event(s, _) => s, + } + } +} + pub(crate) struct FromHost { session: Session, outcome: crate::Outcome, @@ -191,6 +205,7 @@ fn yield_wait(outcome: crate::Outcome, windows: Vec, done: bool) - } } +#[cfg(not(target_arch = "wasm32"))] fn host_main(name: String, code: String, link: Link) { let Ok(ToHost::Start(session)) = link.rx.recv() else { return }; let mut a = Active::new(session, &name); @@ -248,7 +263,9 @@ fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome { } } -/// Run a script file on a new script-host thread (see the module docs). +/// Run a script file on a new script-host thread (see the module docs). Not on the web, where +/// scripts run inline. +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::ScriptRequest) -> crate::Outcome { let host = new_host_id(); let (to_tx, to_rx) = std::sync::mpsc::channel(); @@ -259,8 +276,8 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri if spawned.is_err() { return crate::runtime::run_inline(session, req); } - if let Err(std::sync::mpsc::SendError(ToHost::Start(s) | ToHost::Event(s, _))) = to_tx.send(ToHost::Start(std::mem::take(session))) { - *session = s; + if let Err(std::sync::mpsc::SendError(msg)) = to_tx.send(ToHost::Start(std::mem::take(session))) { + *session = msg.into_session(); return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() }; } receive(session, host, HostEnd { tx: to_tx, rx: from_rx }) @@ -302,8 +319,8 @@ pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result) { let t = app.tokens; + let services = app.session.services.clone(); let rect = rect_of(origin, w.bounds); register(app, win, w, rect); let id = egui::Id::new(("sui", win, w.id)); @@ -87,20 +89,20 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, 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); + paint_draw(ui, rect, &w.draw, &t, &*services); draw_children(app, ui, origin, win, w, acts); return; } let clickable = matches!(w.kind, WidgetKind::Button | WidgetKind::IconButton | WidgetKind::Checkbox | WidgetKind::RadioButton); let r = ui.interact(rect, id, if clickable && w.enabled { Sense::click() } else { Sense::hover() }); - paint_draw(ui, rect, &w.draw, &t); + paint_draw(ui, rect, &w.draw, &t, &*services); if tip(r).clicked() && clickable { acts.push(Act::Click(win, w.id)); } return; } if custom && w.kind.is_container() { - paint_draw(ui, rect, &w.draw, &t); + paint_draw(ui, rect, &w.draw, &t, &*services); } match w.kind { WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts), @@ -117,6 +119,9 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, } WidgetKind::Button | WidgetKind::IconButton => { let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, egui::Button::new(&w.text))).inner; + if let Some(img) = &w.image { + paint_image(ui, rect.shrink(3.0), img, &*services); + } if tip(r).clicked() { acts.push(Act::Click(win, w.id)); } @@ -236,16 +241,18 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, } } WidgetKind::Image | WidgetKind::Unknown => { - ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + if !w.image.as_ref().is_some_and(|img| paint_image(ui, rect, img, &*services)) { + ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } } } if custom && !w.kind.is_container() { - paint_draw(ui, rect, &w.draw, &t); + paint_draw(ui, rect, &w.draw, &t, &*services); } } /// Paint an onDraw draw list in `rect` (control coordinates → screen). -fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { +fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: &dyn Services) { let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); let col = |c: &Option<[f32; 4]>, theme: Color32| match c { Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(), @@ -255,10 +262,19 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { for op in ops { match op { DrawOp::Fill { color, path } => { + // Any path shape (concave, self-intersecting, with holes): non-zero winding + // triangles, not egui's convex-only polygon fill. let c = col(color, t.field_bg); - 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)); + let mut mesh = egui::Mesh::default(); + for tri in PathSeg::fill_triangles(path, 48, false) { + let base = mesh.vertices.len() as u32; + for q in tri { + mesh.colored_vertex(at(q[0], q[1]), c); + } + mesh.add_triangle(base, base + 1, base + 2); + } + if !mesh.is_empty() { + p.add(egui::Shape::mesh(mesh)); } } DrawOp::Stroke { color, width, path } => { @@ -271,15 +287,53 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { 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::Image { image, x, y, w, h } => { + let tex = image.as_ref().and_then(|i| image_texture(ui.ctx(), i, services)); + let size = match &tex { + Some(tx) if *w <= 0.0 || *h <= 0.0 => tx.size_vec2(), + _ => vec2(*w as f32, *h as f32), + }; + let r = Rect::from_min_size(at(*x, *y), size); + match tex { + Some(tx) => { + p.image(tx.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + } } DrawOp::Os => {} } } } +/// A ScriptUIImage as a texture (decoded once and kept; `None` when it can't be read or +/// decoded). +fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) -> Option { + let id = egui::Id::new(("scriptui-image", img)); + if let Some(cached) = ctx.data(|d| d.get_temp::>(id)) { + return cached; + } + let tex = img.bytes(services).and_then(|b| image::load_from_memory(&b).ok()).map(|d| { + let rgba = d.to_rgba8(); + let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); + ctx.load_texture(format!("scriptui-image-{}", img.name), ci, egui::TextureOptions::LINEAR) + }); + ctx.data_mut(|d| d.insert_temp(id, tex.clone())); + tex +} + +/// An image control's or icon button's image, fitted (aspect kept) and centred in `r`. +fn paint_image(ui: &egui::Ui, r: Rect, img: &ImageRef, services: &dyn Services) -> bool { + let Some(tex) = image_texture(ui.ctx(), img, services) else { return false }; + let s = tex.size_vec2(); + let k = (r.width() / s.x.max(1.0)).min(r.height() / s.y.max(1.0)).min(1.0); + let dst = Rect::from_center_size(r.center(), s * k); + ui.painter().with_clip_rect(r.intersect(ui.clip_rect())).image(tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + true +} + /// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds. fn snug(r: Rect, text: &str) -> Rect { let w = (text.chars().count() as f32 * 7.0 + 26.0).min(r.width()); diff --git a/crates/ui-egui/tests/ui_scriptui.rs b/crates/ui-egui/tests/ui_scriptui.rs index bbfd9e56..9ec9c5ee 100644 --- a/crates/ui-egui/tests/ui_scriptui.rs +++ b/crates/ui-egui/tests/ui_scriptui.rs @@ -111,3 +111,58 @@ fn history_panel_jumps_between_branches() { click(&mut h, &format!("history.state.{c}")); assert_eq!(layer_names(&h), ["C", "A"]); } + +/// onDraw fills of concave paths and real images (drawImage, image controls), rendered. +#[test] +fn on_draw_concave_fills_and_images_render() { + let dir = std::env::temp_dir().join(format!("effectcraft-scriptui-img-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("green.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 255, 0, 255])).save(&png).unwrap(); + let png = png.to_string_lossy().replace('\\', "/"); + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let code = format!( + r#" + var w = new Window("palette", "Paint"); + var c = w.add("group"); + c.preferredSize = [120, 80]; + c.onDraw = function () {{ + var g = this.graphics; + g.newPath(); + // An L: the top-right quarter is a notch (a convex-only fill would cover it). + g.moveTo(0, 0); g.lineTo(60, 0); g.lineTo(60, 40); g.lineTo(120, 40); g.lineTo(120, 80); g.lineTo(0, 80); g.closePath(); + g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, [1, 0, 0, 1])); + g.drawImage(ScriptUI.newImage(File("{png}")), 90, 5, 20, 20); + }}; + var im = w.add("image", undefined, File("{png}")); + im.preferredSize = [40, 40]; + w.show(); + "# + ); + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "paint.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(4); + let win = h.state().session.script_ui.windows[0].clone(); + let g = &win.root.children[0]; + assert!(matches!(&g.draw[1], effectcraft_engine::scriptui::DrawOp::Image { image: Some(i), .. } if i.src.as_deref() == Some(png.as_str()))); + assert_eq!(win.root.children[1].image.as_ref().and_then(|i| i.src.clone()).as_deref(), Some(png.as_str())); + let img = h.render().expect("render"); + if let Ok(d) = std::env::var("EC_SNAPSHOT_DIR") { + img.save(format!("{d}/scriptui_paint.png")).unwrap(); + } + let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0; + let gr = rect(&h, &format!("scriptui.{}.{}", win.id, g.id)); + let red = |c: [u8; 4]| c[0] > 200 && c[1] < 60 && c[2] < 60; + let green = |c: [u8; 4]| c[1] > 200 && c[0] < 60 && c[2] < 60; + assert!(red(px(gr.min + vec2(20.0, 20.0))), "inside the L: {:?}", px(gr.min + vec2(20.0, 20.0))); + assert!(red(px(gr.min + vec2(100.0, 60.0))), "the L's foot"); + assert!(!red(px(gr.min + vec2(75.0, 30.0))), "the notch stays empty: {:?}", px(gr.min + vec2(75.0, 30.0))); + assert!(green(px(gr.min + vec2(100.0, 15.0))), "drawImage: {:?}", px(gr.min + vec2(100.0, 15.0))); + let ir = rect(&h, &format!("scriptui.{}.{}", win.id, win.root.children[1].id)); + assert!(green(px(ir.center())), "image control: {:?}", px(ir.center())); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +}