mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
The Ease Presets panel (#254) is modelled on a third-party panel, so, as with third-party tools in After Effects, it isn't core any more: it is `extensions/scriptui-panels/Ease Presets.jsx`, a ScriptUI panel bundled as an optional extension (Window ▸ Ease Presets.jsx), written only against the public scripting API. Clicking a preset eases every pair of neighbouring selected keyframes in one undo step (per dimension, along the motion path for spatial properties, the keys' other sides as they play); the fields edit the working curve, which the panel draws; From Keys, Save, Rename and Delete as before. The twelve built-in curves are unchanged. Scripting API fixes it needed (After Effects parity): - `app.settings` lasts between runs (`script_settings.json` in the settings folder; `script.settings.get` / `script.settings.save`). - Per-key methods (`setTemporalEaseAtKey`, `setInterpolationTypeAtKey`, `removeKey`, the continuous / auto-Bezier / roving setters) no longer change the user's key selection (they selected the key they edited). - `keyInTemporalEase` / `keyOutTemporalEase` report linear and auto-Bezier sides as they play, `keyIn/OutSpatialTangent` the tangents the motion path plays. - `selectedProperties` includes the properties of selected keyframes. - `isInterpolationTypeValid` is false for Linear / Bezier on values that only hold. Removed from core: Window ▸ Ease Presets, the `keys.easePreset.*` commands, `engine::ease_presets` and the `ease_presets.json` store, `keyframe::EaseCurve` (nothing else used it) and the egui panel. User presets saved by the v0.6.0 panel move into the panel's app.settings section the first time settings load (the old file is left as is). Docs: plugins.md (bundled extensions), parity.md, gaps.md G8, agents.md. Test: app_settings_last_between_runs, per_key_methods_keep_the_key_selection_and_report_eases_as_they_play, spatial_tangents_are_reported_as_the_path_plays (each fails before), the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step, a_typed_curve_scales_to_each_dimension_and_follows_motion_paths, user_presets_save_rename_delete_and_persist, presets_saved_by_the_old_core_panel_carry_over (script, through the panel's controls), ease_presets_panel_eases_the_selected_keys_from_its_list (UI: the panel docks and a click on a preset eases the keys). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
12 KiB
Rust
227 lines
12 KiB
Rust
//! 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<String> {
|
|
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 ▸ <panel>: 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();
|
|
}
|
|
|
|
/// A ScriptUI panel open when the app quit opens again, docked, at the next launch (the
|
|
/// frontends call `window.restoreScriptPanels` before building the app).
|
|
#[test]
|
|
fn panels_open_at_quit_dock_again_at_launch() {
|
|
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
|
|
let store = std::sync::Arc::new(MemoryConfig::default());
|
|
store.write("Scripts/ScriptUI Panels/Hello Panel.jsx", "this.add('button', undefined, 'Hi', { name: 'hi' });").unwrap();
|
|
store.write(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE, r#"{"open": ["Hello Panel.jsx"]}"#).unwrap();
|
|
let mut s = effectcraft_host::session();
|
|
s.config = Some(store.clone());
|
|
s.execute("window.restoreScriptPanels", json!({})).unwrap();
|
|
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
|
h.run_steps(3);
|
|
let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Hello Panel.jsx").expect("the panel's window").id;
|
|
assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks");
|
|
assert!(h.state().auto.find(&format!("scriptui.{panel}.hi")).is_some(), "its controls are drawn");
|
|
// Closing its tab forgets it for the next launch.
|
|
h.state_mut().close_panel(PanelKind::ScriptPanel(panel));
|
|
h.run_steps(2);
|
|
assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#));
|
|
}
|
|
|
|
/// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a
|
|
/// preset in its list eases the selected keyframes in one undo step.
|
|
#[test]
|
|
fn ease_presets_panel_eases_the_selected_keys_from_its_list() {
|
|
use effectcraft_engine::keyframe::{Ease, Interp};
|
|
use egui_kittest::kittest::Queryable;
|
|
let mut h = harness();
|
|
let s = &mut h.state_mut().session;
|
|
let l = s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap();
|
|
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
|
|
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
|
}
|
|
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
|
|
let cx = effectcraft_engine::menus::DynCtx::default();
|
|
let (panels, _) = effectcraft_engine::menus::dynamic(s, "scriptPanels", &cx);
|
|
assert!(panels.iter().any(|e| e.label == "Ease Presets.jsx"), "listed in the Window menu");
|
|
s.execute("window.scriptPanel", json!({"name": "Ease Presets.jsx"})).unwrap();
|
|
h.run_steps(3);
|
|
let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Ease Presets.jsx").unwrap().id;
|
|
assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks");
|
|
h.state_mut().ui.maximized = Some(PanelKind::ScriptPanel(panel));
|
|
h.run_steps(3);
|
|
let steps = h.state().session.history.undo.len();
|
|
let item = h.query_by_label("Ease In-Out").expect("the Ease In-Out preset").rect().center();
|
|
h.input_mut().events.push(Event::PointerMoved(item));
|
|
for pressed in [true, false] {
|
|
h.input_mut().events.push(Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
|
}
|
|
h.run_steps(3);
|
|
let op = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().clone();
|
|
let eased = Ease { speed: 0.0, influence: 0.5 };
|
|
assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease[0]), (Interp::Bezier, eased));
|
|
assert_eq!((op.keys[1].in_interp, op.keys[1].in_ease[0]), (Interp::Bezier, eased));
|
|
assert_eq!(h.state().session.history.undo.len(), steps + 1);
|
|
}
|
|
|
|
#[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);
|
|
}
|