Merge pull request #2289 from onlykshitij/feat/script-panel-submenus

Scripts: subfolders of the Scripts and ScriptUI Panels folders become submenus
This commit is contained in:
BFlat
2026-10-30 01:23:53 +07:00
committed by GitHub
9 changed files with 324 additions and 24 deletions
+31
View File
@@ -366,6 +366,21 @@ impl ConfigStore for WebConfig {
v.sort();
v
}
/// The first name part of the keys under `dir/`.
fn list_dirs(&self, dir: &str) -> Vec<String> {
let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
let mut v: Vec<String> = self
.store
.lock()
.list(&pre)
.into_iter()
.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
}
fn dir(&self) -> Option<PathBuf> {
Some(PathBuf::from("/"))
}
@@ -533,6 +548,22 @@ mod tests {
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
}
/// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop.
#[test]
fn config_store_lists_folders() {
let c = WebConfig::new(store());
for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] {
c.write(name, "x").unwrap();
}
assert_eq!(c.list("Scripts"), ["Top.jsx"]);
assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]);
assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]);
let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() };
let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap();
let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect();
assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]);
}
#[test]
fn config_store_and_file_ops() {
let s = store();
+187 -14
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,11 @@ 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";
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
/// After Effects): never listed as File ▸ Scripts submenus.
const RESERVED_FOLDERS: [&str; 3] = [PANELS_FOLDER, "Startup", "Shutdown"];
/// 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 +117,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,
@@ -119,16 +125,23 @@ pub struct ScriptEntry {
pub source: &'static str,
}
/// Whether `name` ends in `.jsx` or `.js` (any letter case).
fn is_script(name: &str) -> bool {
let l = name.to_ascii_lowercase();
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 one of [`RESERVED_FOLDERS`]: `ScriptUI
/// Panels/…`, `Startup/…`, `Shutdown/…`). 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) && !RESERVED_FOLDERS.iter().any(|r| folder.eq_ignore_ascii_case(r)),
None => part(name),
}
}
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
@@ -155,8 +168,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" });
}
}
}
}
@@ -216,12 +234,19 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(scripts(s)))
}
/// `file.uninstallScript {name}`: delete an installed script or panel at the top of the Scripts
/// folders.
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
let c = "file.uninstallScript";
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
if !is_script(name) || !is_script_name(name) {
return Err(bad(c, format!("`{name}` is not a script file name")));
}
// A subfolder can be a link to files outside the settings folder (an extension's own
// folder), so only what Install puts at the top is removed, never a file through a folder.
if name.contains('/') {
return Err(bad(c, format!("`{name}` is in a subfolder: delete it there yourself (only scripts at the top of the Scripts folders are uninstalled)")));
}
let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?;
let mut removed = false;
for dir in [SCRIPTS_DIR, PANELS_DIR] {
@@ -316,13 +341,15 @@ 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}) });
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}`")))?;
// Only panels: a script opened as one would be remembered, then forgotten at the next launch.
let (_, code) = script_code(s, &name, Some(true)).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 {
@@ -338,6 +365,7 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"window": id, "result": out}))
}
/// The commands of File ▸ Scripts, ScriptUI panels and windows, and script settings.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -358,14 +386,22 @@ pub fn specs() -> Vec<CommandSpec> {
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|extension}]", list),
cmd!(
"file.uninstallScript",
"Uninstall Script",
[],
None,
"{name (a script at the top of a Scripts folder, not `Folder/File.jsx`)}",
always,
uninstall
),
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
),
@@ -504,13 +540,15 @@ mod tests {
}
}
/// Bundled and ordinary script file names are accepted; paths are not.
#[test]
fn script_names_are_bare_file_names() {
let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"];
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 {
@@ -528,6 +566,141 @@ mod tests {
}
}
/// `Folder/File.jsx` is accepted; deeper, upward, absolute and `\` names are not.
#[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();
}
/// Every script command takes `Folder/File.jsx` names and refuses names outside one folder level.
#[test]
fn scripts_in_a_subfolder_are_listed_run_and_opened_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())));
// A script that is not a panel doesn't open as one.
for name in ["Tools/Hello.jsx", "Rename Layers.jsx"] {
assert!(bad_params(&s.execute("window.scriptPanel", json!({"name": name}))), "{name}");
}
for name in [
"../x.jsx",
"a/b/c.jsx",
"a\\b.jsx",
"Motion Kit/sub/Deep.jsx",
"ScriptUI Panels/Top.jsx",
"Startup/Run.jsx",
"shutdown/Run.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());
// Uninstall removes only scripts at the top; one in a folder is deleted there by hand.
for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] {
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": name}))), "{name}");
}
assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
let _ = std::fs::remove_dir_all(&root);
}
/// A subfolder can be a link to an extension's own folder, outside the settings folder: its
/// scripts are listed and run from there, but uninstall never deletes through it.
#[cfg(unix)]
#[test]
fn uninstall_never_deletes_through_a_linked_subfolder() {
let (mut s, root) = session_in_tmp("linked");
std::fs::create_dir_all(root.join("extension")).unwrap();
std::fs::write(root.join("extension").join("Animate.jsx"), "animate").unwrap();
std::os::unix::fs::symlink(root.join("extension"), root.join("settings").join(PANELS_DIR).join("Motion Kit")).unwrap();
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}");
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
let r = s.execute("file.uninstallScript", json!({"name": "Motion Kit/Animate.jsx"}));
assert!(bad_params(&r), "{r:?}");
assert!(root.join("extension").join("Animate.jsx").is_file(), "the extension's own file was deleted");
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");
+27 -1
View File
@@ -82,7 +82,8 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
/// Recent Script Files), `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> {
@@ -167,6 +168,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 {
+23
View File
@@ -24,7 +24,9 @@ pub trait ConfigStore: Send + Sync {
}
Ok(text)
}
/// Write entry `name`, replacing any earlier text.
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
/// Remove entry `name`; a missing entry is no error.
fn remove(&self, name: &str) -> std::io::Result<()>;
/// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`),
/// sorted. Stores that can't list return nothing.
@@ -32,6 +34,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 +114,15 @@ 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()
}
/// The first name part of the keys under `dir/`.
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 +169,13 @@ impl ConfigStore for DirConfig {
v.sort();
v
}
/// The folders in `dir`, following links like [`ConfigStore::list`] does for files.
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),
+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
@@ -160,13 +160,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(),
+9 -3
View File
@@ -149,6 +149,7 @@ pub fn build_now(app: &EffectcraftApp, ctx: &egui::Context) -> NativeMenu {
/// Build the native menu spec for the app's current state.
pub fn build(app: &EffectcraftApp) -> NativeMenu {
/// Append the native nodes of `nodes` to `out`; item ids start with `path`.
fn rec(app: &EffectcraftApp, nodes: &[MenuNode], path: &str, out: &mut Vec<NativeNode>) {
let mut services_added = false;
for (i, n) in nodes.iter().enumerate() {
@@ -159,8 +160,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 {
@@ -174,7 +175,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(),
+14 -2
View File
@@ -12,12 +12,12 @@ 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) |
| File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same |
| 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 |
@@ -41,6 +41,11 @@ 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 `file.scripts.list` and `file.runScript`. One level of folders is
listed; deeper folders are not. `file.uninstallScript` removes only scripts at the top of the
Scripts folders: delete a script in a subfolder yourself. A subfolder may be a link to another
folder (an extension's own), and uninstall never deletes a file through it.
## ScriptUI panels
@@ -52,6 +57,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, and delete them from it yourself too.
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 |