//! M13.1 UI: ScriptUI dialogs, palettes and dockable panels drawn from the session's script //! windows (clicks reach the script's handlers), the branching History panel, and the Window / //! File ▸ Scripts menus listing scripts (egui_kittest, UI logic only). use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::PanelKind; use egui::{Event, Rect, pos2, vec2}; use egui_kittest::Harness; use serde_json::json; fn harness() -> Harness<'static, EffectcraftApp> { 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)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); h } fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) } fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { let c = rect(h, id).center(); h.input_mut().events.push(Event::PointerMoved(c)); for pressed in [true, false] { h.input_mut().events.push(Event::PointerButton { pos: c, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); } h.run_steps(3); } fn layer_names(h: &Harness<'_, EffectcraftApp>) -> Vec { h.state().session.active_comp().map(|c| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default() } #[test] fn script_windows_are_drawn_and_clickable() { let mut h = harness(); let code = r#" var w = new Window("palette", "Quick Solid"); var n = w.add("edittext", undefined, "Card"); n.characters = 10; var go = w.add("button", undefined, "Add", { name: "add" }); go.onClick = function () { app.project.activeItem.layers.addSolid([0, 1, 0], n.text, 50, 50, 1); }; w.show(); "#; let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "quick.jsx"})).unwrap(); assert_eq!(r["ok"], true, "{r}"); h.run_steps(3); let win = h.state().session.script_ui.windows[0].id; // Every control has an automation id (by id and by name). for id in [format!("scriptui.{win}"), format!("scriptui.{win}.1"), format!("scriptui.{win}.2"), format!("scriptui.{win}.add")] { assert!(h.state().auto.find(&id).is_some(), "missing {id}"); } click(&mut h, &format!("scriptui.{win}.add")); assert_eq!(layer_names(&h), ["Card"]); // A modal dialog: the script waits in show() until OK is clicked. let code = r#" var d = new Window("dialog", "Confirm"); d.add("statictext", undefined, "Make a null?"); var g = d.add("group"); g.add("button", undefined, "Cancel", { name: "cancel" }); g.add("button", undefined, "OK", { name: "ok" }); if (d.show() === 1) app.project.activeItem.layers.addNull().name = "Yes"; "#; let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "confirm.jsx"})).unwrap(); assert_eq!(r["waiting"], true, "{r}"); h.run_steps(3); let d = h.state().session.script_ui.windows.iter().find(|w| w.modal).unwrap().id; click(&mut h, &format!("scriptui.{d}.ok")); assert_eq!(layer_names(&h), ["Yes", "Card"]); assert!(h.state().session.script_ui.windows.iter().all(|w| !w.modal)); // Window ▸ : the sample ScriptUI panel docks as its own tab. h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap(); h.run_steps(3); let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().id; assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks"); assert!(h.state().auto.find(&format!("scriptui.{panel}")).is_some()); // The menus list scripts and panels. let cx = effectcraft_engine::menus::DynCtx::default(); let (panels, _) = effectcraft_engine::menus::dynamic(&h.state().session, "scriptPanels", &cx); assert!(panels.iter().any(|e| e.label == "Layer Tools.jsx" && e.command == "window.scriptPanel")); let (scripts, _) = effectcraft_engine::menus::dynamic(&h.state().session, "scripts", &cx); assert!(scripts.iter().any(|e| e.label == "Rename Layers.jsx" && e.command == "file.runScript")); // Closing the panel's tab closes its script window. h.state_mut().close_panel(PanelKind::ScriptPanel(panel)); h.run_steps(2); assert!(h.state().session.script_ui.window(panel).is_none()); h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } #[test] fn history_panel_jumps_between_branches() { let mut h = harness(); for n in ["A", "B"] { h.state_mut().session.execute("layer.newSolid", json!({"name": n, "color": "#ffffff", "width": 10, "height": 10})).unwrap(); } h.state_mut().session.undo(); h.state_mut().session.execute("layer.newSolid", json!({"name": "C", "color": "#ffffff", "width": 10, "height": 10})).unwrap(); assert_eq!(layer_names(&h), ["C", "A"]); h.state_mut().show_panel(PanelKind::History); h.run_steps(3); // Original, New Composition, A, C, and B on its branch. let states = h.state().session.history_tree(); assert_eq!(states.len(), 5); let b = states.iter().find(|n| n.depth == 1).unwrap().index; click(&mut h, &format!("history.state.{b}")); assert_eq!(layer_names(&h), ["B", "A"]); let c = h.state().session.history_tree().iter().find(|n| n.depth == 1).unwrap().index; 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); }