//! File ▸ Scripts and ScriptUI. //! //! * **Installed scripts** live in the settings store under `Scripts/` (File ▸ Scripts ▸ Install //! Script File…) and `Scripts/ScriptUI Panels/` (Install ScriptUI Panel…), next to the //! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts //! lists them with the bundled sample scripts ([`SAMPLES`]) and runs them by name; ScriptUI //! panels appear at the bottom of the Window menu and open as dockable panels //! (`window.scriptPanel`), running with `this` = the panel as in After Effects. //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). use serde::Serialize; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, str_p}; use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; /// Sample scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, code). pub const SAMPLES: &[(&str, bool, &str)] = &[ ("Create Null at Selected Layers.jsx", false, include_str!("../../scripts/Create Null at Selected Layers.jsx")), ("Rename Layers.jsx", false, include_str!("../../scripts/Rename Layers.jsx")), ("Render Queue Batch.jsx", false, include_str!("../../scripts/Render Queue Batch.jsx")), ("Sort Layers by In Point.jsx", false, include_str!("../../scripts/Sort Layers by In Point.jsx")), ("Layer Tools.jsx", true, include_str!("../../scripts/ScriptUI Panels/Layer Tools.jsx")), ]; /// A script File ▸ Scripts (or the Window menu, for panels) offers. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct ScriptEntry { /// File name (`Rename Layers.jsx`): what the menus show and commands take. pub name: String, /// In the ScriptUI Panels folder: opens as a dockable panel. pub panel: bool, /// `installed` or `sample`. pub source: &'static str, } fn is_script(name: &str) -> bool { let l = name.to_ascii_lowercase(); l.ends_with(".jsx") || l.ends_with(".js") } /// Installed scripts and panels, then the samples not shadowed by an installed one, by name. pub fn scripts(s: &Session) -> Vec { let mut v: Vec = vec![]; if let Some(cfg) = &s.config { for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { for n in cfg.list(dir).into_iter().filter(|n| is_script(n)) { v.push(ScriptEntry { name: n, panel, source: "installed" }); } } } for (name, panel, _) in SAMPLES { if !v.iter().any(|e| e.name == *name && e.panel == *panel) { v.push(ScriptEntry { name: name.to_string(), panel: *panel, source: "sample" }); } } v.sort_by(|a, b| a.panel.cmp(&b.panel).then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))); v } /// The code of an installed or sample script / panel. pub fn script_code(s: &Session, name: &str, panel: Option) -> Option<(bool, String)> { if let Some(cfg) = &s.config { for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { if panel.is_some_and(|p| p != is_panel) { continue; } if let Some(code) = cfg.read(&format!("{dir}/{name}")) { return Some((is_panel, code)); } } } SAMPLES.iter().find(|(n, p, _)| *n == name && panel.is_none_or(|w| w == *p)).map(|(_, p, c)| (*p, c.to_string())) } fn install(s: &mut Session, p: &Value, panel: bool) -> Result { let c = if panel { "file.installScriptUIPanel" } else { "file.installScript" }; let path = str_p(p, "path").ok_or_else(|| bad(c, "missing `path` (a .jsx / .js script)"))?; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); if !is_script(&name) { return Err(bad(c, "scripts are .jsx or .js files (.jsxbin is not supported)")); } let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; let code = String::from_utf8(bytes).map_err(|_| bad(c, "the script is not UTF-8 text"))?; let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR }; cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?; // After Effects asks for a restart; our menus pick it up at once. s.toast(if panel { format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu") } else { format!("Installed script {name}: find it in File ▸ Scripts") }); Ok(json!({"name": name, "panel": panel})) } fn list(s: &mut Session, _: &Value) -> Result { Ok(json!(scripts(s))) } fn uninstall(s: &mut Session, p: &Value) -> Result { let c = "file.uninstallScript"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?; let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?; let mut removed = false; for dir in [SCRIPTS_DIR, PANELS_DIR] { let key = format!("{dir}/{name}"); if cfg.read(&key).is_some() { cfg.remove(&key).map_err(|e| EngineError::Other(e.to_string()))?; removed = true; } } if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } /// Run an installed or sample script by name (File ▸ Scripts ▸ ). A ScriptUI panel run /// this way gets `this` = a floating palette-like panel window. pub(crate) fn run_named(s: &mut Session, name: &str) -> Result { let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or sample script `{name}`")))?; if panel { return open_panel(s, &json!({"name": name})); } super::file_more::run_js_named(s, &code, name) } /// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script. pub fn panel_wrapper(title: &str, code: &str) -> String { // On one line, so line numbers in errors stay those of the file. format!("(function () {{ {code}\n}}).call(__uiDockPanel({}));", Value::String(title.to_string())) } /// Window ▸ : run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string(); let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string(); if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) { let id = w.id; s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) }); return Ok(json!({"window": id, "open": true})); } let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?; let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?; let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id); if let Some(id) = id { s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) }); } Ok(json!({"window": id, "result": out})) } pub fn specs() -> Vec { vec![ cmd!( "file.installScript", "Install Script File...", ["File", "Scripts"], None, "{path (.jsx / .js)} → copies it to the Scripts folder; it appears in File ▸ Scripts", always, |s, p| install(s, p, false) ), cmd!( "file.installScriptUIPanel", "Install ScriptUI Panel...", ["File", "Scripts"], None, "{path (.jsx / .js)} → copies it to the ScriptUI Panels folder; it appears in the Window menu", always, |s, p| install(s, p, true) ), cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall), crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample}]", list), cmd!( "window.scriptPanel", "ScriptUI Panel", [], None, "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel", always, open_panel ), crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list), 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…}}", scriptui::get ), cmd!( "scriptui.click", "Click Script Window Control", [], None, "{window?: id | title, widget: id | \"#id\" | properties.name | text} (buttons, checkboxes, radio buttons, tabs)", always, scriptui::click ), cmd!( "scriptui.set", "Set Script Window Control", [], None, "{window?, widget, value: text | number | bool | item index | item text} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", always, scriptui::set ), cmd!( "scriptui.close", "Close Script Window", [], None, "{window?, result? (a dialog's show() returns it; default 2 = Cancel)}", always, scriptui::close ), ] }