Scripts: subfolders of the Scripts and ScriptUI Panels folders become submenus

An extension that ships several panels no longer adds one Window menu entry per panel:
`Scripts/ScriptUI Panels/Motion Kit/Animate.jsx` shows as Window > Motion Kit > Animate.jsx,
and a subfolder of `Scripts/` is a submenu of File > Scripts. Top-level scripts are unchanged.

- Script names may be `Folder/File.jsx`: exactly one folder, each part a bare name (no `..`,
  `\`, NUL or empty part), never `ScriptUI Panels/...` under `Scripts/`.
- window.scriptPanel, file.runScript, file.uninstallScript, file.scripts.list and
  scriptui_panels.json take such names; a panel's tab is titled after its file.
- ConfigStore::list_dirs (DirConfig, MemoryConfig) lists the subfolders.
- menus::dynamic_nodes groups a folder's entries into a MenuNode::Submenu; the egui and native
  menus draw it with their submenu code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
onlykshitij
2026-10-24 18:05:59 +05:30
co-authored by Claude Opus 5.5
parent eb5108786b
commit bdcd2dc051
8 changed files with 228 additions and 22 deletions
+129 -12
View File
@@ -5,7 +5,8 @@
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) 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.
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
//! * **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`]).
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
@@ -22,6 +23,8 @@ 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";
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
const PANELS_FOLDER: &str = "ScriptUI Panels";
/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
@@ -111,7 +114,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
/// 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.
/// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take.
pub name: String,
/// In the ScriptUI Panels folder: opens as a dockable panel.
pub panel: bool,
@@ -124,11 +127,17 @@ fn is_script(name: &str) -> bool {
l.ends_with(".jsx") || l.ends_with(".js")
}
/// Whether `name` can name an installed script: a bare file name, never a path (names are joined
/// onto the Scripts folders of the settings store, which takes them as relative paths), and on
/// Windows none of the names [`windows_unsafe_name`] lists.
/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a
/// subfolder of a Scripts folder (one level, and not `ScriptUI Panels/…`, the panels' own
/// folder). Each part is a bare name, never a path (names are joined onto the Scripts folders of
/// the settings store, which takes them as relative paths), and on Windows none of the names
/// [`windows_unsafe_name`] lists.
fn is_script_name(name: &str) -> bool {
is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name))
let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n));
match name.split_once('/') {
Some((folder, file)) => part(folder) && part(file) && !folder.eq_ignore_ascii_case(PANELS_FOLDER),
None => part(name),
}
}
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
@@ -155,8 +164,13 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
let mut v: Vec<ScriptEntry> = 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) && is_script_name(n)) {
v.push(ScriptEntry { name: n, panel, source: "installed" });
// The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`.
let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/")));
for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) {
let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}"));
for n in names.filter(|n| is_script(n) && is_script_name(n)) {
v.push(ScriptEntry { name: n, panel, source: "installed" });
}
}
}
}
@@ -312,7 +326,8 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
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();
let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f);
let title = file.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}) });
@@ -355,13 +370,13 @@ pub fn specs() -> Vec<CommandSpec> {
|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|extension}]", list),
crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", 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",
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel",
always,
open_panel
),
@@ -506,7 +521,8 @@ mod tests {
for name in BUNDLED.iter().map(|b| b.0).chain(more) {
assert!(is_script(name) && is_script_name(name), "{name}");
}
for name in PATHS {
// `a/b.jsx` is a script in a folder (see below).
for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") {
assert!(!is_script_name(name), "{name:?}");
}
for name in WINDOWS_ONLY {
@@ -524,6 +540,107 @@ mod tests {
}
}
#[test]
fn script_names_may_have_one_folder() {
for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] {
assert!(is_script_name(name), "{name:?}");
}
// Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`.
let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"];
for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) {
assert!(!is_script_name(name), "{name:?}");
}
for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] {
assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}");
}
}
/// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`.
fn put(root: &std::path::Path, rel: &str, code: &str) {
let p = root.join("settings").join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, code).unwrap();
}
#[test]
fn scripts_in_a_subfolder_are_listed_run_opened_and_uninstalled_as_folder_and_file() {
let (mut s, root) = session_in_tmp("folders");
put(&root, "Scripts/Tools/Hello.jsx", "hello");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate");
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top");
// Two folders deep: neither listed nor run.
put(&root, "Scripts/a/b/c.jsx", "too deep");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep");
let list = s.execute("file.scripts.list", json!({})).unwrap();
let installed: Vec<(&str, bool)> =
list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect();
assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]);
assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello");
// A panel in a folder is titled after its file.
let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
let ran = r["result"]["ran"].as_str().unwrap_or_default();
assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}");
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
for name in ["../x.jsx", "a/b/c.jsx", "a\\b.jsx", "Motion Kit/sub/Deep.jsx", "ScriptUI Panels/Top.jsx", "Tools/../../../outside.jsx"] {
assert_eq!(script_code(&s, name, None), None, "{name:?}");
for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] {
let r = s.execute(c, json!({"name": name}));
assert!(bad_params(&r), "{c} `{name}`: {r:?}");
}
}
assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
assert!(root.join("outside.jsx").is_file());
for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] {
s.execute("file.uninstallScript", json!({"name": name})).unwrap();
}
assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| !root.join("settings").join(p).exists()));
let _ = std::fs::remove_dir_all(&root);
}
/// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder
/// sorts; the entries outside folders stay as they were.
#[test]
fn a_subfolder_is_a_submenu() {
use crate::menus::{MenuNode, dynamic, dynamic_nodes};
let (s, root) = session_in_tmp("submenus");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", "");
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "");
put(&root, "Scripts/Tools/Hello.jsx", "");
let cx = Default::default();
let item =
|label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null});
let menus = [
(
"scriptPanels",
json!({"type": "submenu", "label": "Motion Kit", "children": [
item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"),
item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"),
]}),
),
("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})),
];
for (menu, folder) in menus {
let (nodes, _) = dynamic_nodes(&s, menu, &cx);
let (entries, _) = dynamic(&s, menu, &cx);
let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect();
assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::<Vec<_>>(), "{menu}");
let folders: Vec<Value> = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect();
assert_eq!(folders, [folder], "{menu}");
}
let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx);
let labels: Vec<&str> = panels
.iter()
.filter_map(|n| match n {
MenuNode::Item(e) => Some(e.label.as_str()),
MenuNode::Submenu { label, .. } => Some(label),
_ => None,
})
.collect();
assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn uninstall_takes_a_script_name_not_a_path() {
let (mut s, root) = session_in_tmp("uninstall");
+19
View File
@@ -32,6 +32,11 @@ pub trait ConfigStore: Send + Sync {
let _ = dir;
vec![]
}
/// Names of the folders directly in `dir`, sorted, like [`ConfigStore::list`].
fn list_dirs(&self, dir: &str) -> Vec<String> {
let _ = dir;
vec![]
}
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
fn dir(&self) -> Option<PathBuf> {
@@ -107,6 +112,14 @@ impl ConfigStore for MemoryConfig {
let Ok(m) = self.files.lock() else { return vec![] };
m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect()
}
fn list_dirs(&self, dir: &str) -> Vec<String> {
let pre = format!("{}/", dir.trim_end_matches('/'));
let Ok(m) = self.files.lock() else { return vec![] };
let mut v: Vec<String> = m.keys().filter_map(|k| k.strip_prefix(&pre)?.split_once('/')).map(|(d, _)| d.to_string()).filter(|d| !d.is_empty()).collect();
v.sort();
v.dedup();
v
}
}
/// Files in one directory, written atomically.
@@ -153,6 +166,12 @@ impl ConfigStore for DirConfig {
v.sort();
v
}
fn list_dirs(&self, dir: &str) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] };
let mut v: Vec<String> = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
v.sort();
v
}
fn remove(&self, name: &str) -> std::io::Result<()> {
match std::fs::remove_file(self.dir.join(name)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
+27 -1
View File
@@ -75,7 +75,8 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
/// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
/// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
/// `scriptPanels` (ScriptUI panels).
/// `scriptPanels` (ScriptUI panels; a script in a subfolder is labelled `Folder/File.jsx` here,
/// and [`dynamic_nodes`] puts it in the folder's submenu).
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
use serde_json::json;
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
@@ -153,6 +154,31 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<
}
}
/// [`dynamic`] as menu nodes, which is what frontends draw: scripts and panels in a subfolder
/// (`Motion Kit/Animate.jsx`) go into a submenu named after the folder (`Motion Kit ▸
/// Animate.jsx`), placed where the folder sorts among the other entries.
pub fn dynamic_nodes(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuNode>, Option<&'static str>) {
let (entries, empty) = dynamic(s, name, cx);
let scripts = matches!(name, "scripts" | "scriptPanels");
let mut out: Vec<MenuNode> = vec![];
for mut e in entries {
let Some((folder, file)) = e.label.split_once('/').filter(|_| scripts).map(|(d, f)| (d.to_string(), f.to_string())) else {
out.push(MenuNode::Item(e));
continue;
};
e.label = file;
let open = out.iter_mut().find_map(|n| match n {
MenuNode::Submenu { label, children } if *label == folder => Some(children),
_ => None,
});
match open {
Some(children) => children.push(MenuNode::Item(e)),
None => out.push(MenuNode::Submenu { label: folder, children: vec![MenuNode::Item(e)] }),
}
}
(out, empty)
}
/// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or
/// workspace, like After Effects).
pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String {
+24
View File
@@ -86,3 +86,27 @@ fn open_panels_open_again_at_the_next_launch() {
let mut s = session(&store);
assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []}));
}
/// A panel in a subfolder (Window ▸ Motion Kit ▸ Animate.jsx) is `Motion Kit/Animate.jsx`: it opens
/// titled after its file and opens again at the next launch.
#[test]
fn a_panel_in_a_subfolder_opens_and_opens_again_at_the_next_launch() {
let store = Arc::new(MemoryConfig::default());
store.write(&format!("{PANELS_DIR}/Motion Kit/Animate.jsx"), PANEL).unwrap();
let mut s = session(&store);
let list = s.execute("file.scripts.list", json!({})).unwrap();
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}");
let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
let w = s.script_ui.window(r["window"].as_u64().unwrap() as u32).unwrap();
assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Animate"));
assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));
// A saved name that leaves the folder, or goes two folders deep, fails and is forgotten.
store.write(OPEN_PANELS_FILE, &json!({"open": ["../Top.jsx", "Motion Kit/Animate.jsx", "a/b/c.jsx"]}).to_string()).unwrap();
drop(s);
let mut s = session(&store);
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
assert_eq!(r["opened"], json!(["Motion Kit/Animate.jsx"]));
assert_eq!(r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["../Top.jsx", "a/b/c.jsx"]);
assert!(panel_window(&s, "Motion Kit/Animate.jsx").is_some());
assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));
}
+8 -3
View File
@@ -2209,13 +2209,18 @@ fn menu_nodes(
// saved workspaces.
let ws = app.ui.workspace.clone();
let saved = app.saved_workspace_names();
let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws, &saved));
if entries.is_empty()
let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &dyn_ctx(&ws, &saved));
if nodes.is_empty()
&& let Some(e) = empty
{
ui.add_enabled(false, egui::Button::new((gutter(false), e)));
}
for (i, e) in entries.iter().enumerate() {
for (i, n) in nodes.iter().enumerate() {
// A folder of scripts or panels: a submenu.
let MenuNode::Item(e) = n else {
menu_nodes(app, ui, std::slice::from_ref(n), clicked, nav, depth);
continue;
};
let tip = match name.as_str() {
"recentProjects" => app.session.prefs.recent_projects.get(i).cloned(),
"recentFootage" => app.session.prefs.recent_footage.get(i).cloned(),
+8 -3
View File
@@ -138,8 +138,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
// Recent projects / footage / presets, undo history, shortcut slots, viewers,
// saved workspaces.
let saved = app.saved_workspace_names();
let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved));
if entries.is_empty()
let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved));
if nodes.is_empty()
&& let Some(e) = empty
{
out.push(NativeNode::Item(NativeItem {
@@ -153,7 +153,12 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
checked: None,
}));
}
for (k, e) in entries.iter().enumerate() {
for (k, n) in nodes.iter().enumerate() {
// A folder of scripts or panels: a submenu.
let MenuNode::Item(e) = n else {
rec(app, std::slice::from_ref(n), &format!("{id}.d{k}"), out);
continue;
};
out.push(NativeNode::Item(NativeItem {
id: format!("{id}.d{k}"),
label: e.label.clone(),
+12 -2
View File
@@ -12,11 +12,11 @@ Guide):
| After Effects | EffectCraft | |
|---|---|---|
| Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) |
| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same |
| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples; a subfolder (one level) is a submenu named after it | Same |
| File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) |
| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same |
| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap |
| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap |
| Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points |
| `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same |
@@ -40,6 +40,9 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs
folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart).
* `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs
one; `file.uninstallScript {name}` removes an installed one.
* A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts
are named `Folder/File.jsx` in these commands. One level of folders is listed; deeper folders
are not.
## ScriptUI panels
@@ -51,6 +54,13 @@ other panel. Closing the tab closes the panel (its `onClose` runs). Panels open
quits open again at the next launch (`window.restoreScriptPanels`, which the desktop and web apps
run at start-up; the list is `scriptui_panels.json` in the settings folder).
To group panels, for example the several panels of one extension, make a subfolder of
`Scripts/ScriptUI Panels/` and put them in it: `Scripts/ScriptUI Panels/My Tools/Animate.jsx` is
listed as Window ▸ My Tools ▸ Animate.jsx, its tab is titled Animate, and commands name it
`My Tools/Animate.jsx` (`window.scriptPanel {name}`). One level of folders is listed. Install
ScriptUI Panel… copies a panel to the top of the folder; make the subfolder and move panels into it
yourself.
Run from File ▸ Scripts instead, the same script gets no panel, so it should make a floating
palette itself. A minimal panel:
+1 -1
View File
@@ -22,7 +22,7 @@ our own scripts; no real-world script library has been run against both apps.
| `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging |
| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` |
| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) |
| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) |
| Startup / Shutdown folders | yes | not run | missing | plugins.md |
| `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths |
| `.jsxbin` | yes | no | out of scope | No public specification |