collective

Browse / github.com/storytold/effectcraft / patch

Scripts: File ▸ Scripts entries take keyboard shortcuts

Scripts: File ▸ Scripts entries take keyboard shortcuts Edit > Keyboard Shortcuts lists installed and bundled File > Scripts entries. Assigning a shortcut saves it in the active preset, displays it beside the script in the menu, and runs the script when pressed. ScriptUI panels remain excluded from this list. Installing or removing a script refreshes the shortcut table. The shortcut editor and commands also detect scripts copied into the Scripts folder while the application is running. A missing script cannot receive a shortcut, and Run Script File retains its separate binding. The regressions cover installation, removal, files copied into the Sc

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #2356, open)
Base
bc4b48f67deef2458e9dd3e3b0e592d5f75768a1
Size
+199 −17 in 12 files
Review
pending
Shared
2026-10-28
Id
16edbfd1631d0933ec17cd5a

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/scripts.rs crates/engine/src/cmd/settings.rs crates/engine/src/settings/session_settings.rs crates/engine/src/settings/shortcuts.rs crates/ui-egui/src/menus/bar.rs crates/ui-egui/src/native_menu.rs crates/ui-egui/src/panels/shortcut_editor.rs crates/ui-egui/tests/it/ui_scriptui.rs docs/gaps.md docs/plugins.md docs/preferences.md docs/scripting-parity.md

diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs--- a/crates/engine/src/cmd/scripts.rs+++ b/crates/engine/src/cmd/scripts.rs@@ -203,6 +203,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {     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}")))?;+    s.scripts_changed();     // 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")@@ -231,6 +232,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {             removed = true;         }     }+    s.scripts_changed();     if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } @@ -602,4 +604,64 @@ mod tests {         assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed");         let _ = std::fs::remove_dir_all(&root);     }++    /// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels),+    /// as soon as one is installed and until it is uninstalled; a key given to one is saved with+    /// the preset.+    #[test]+    fn file_scripts_entries_take_keyboard_shortcuts() {+        use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key};+        let (mut s, root) = session_in_tmp("shortcuts");+        let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx"));+        std::fs::write(&script, "hello").unwrap();+        std::fs::write(&panel, "panel").unwrap();+        let listed = |s: &Session, name: &str| {+            s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone()))+        };+        assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples");+        assert_eq!(listed(&s, "Hello.jsx"), None);+        s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap();+        s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap();+        assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![])));+        assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu");+        let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();+        let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"}));+        assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}");+        assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7"));+        let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap());+        assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()]));+        s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap();+        assert_eq!(listed(&s, "Hello.jsx"), None);+        let _ = std::fs::remove_dir_all(&root);+    }++    /// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand+    /// after the shortcut table was built; a script the folder doesn't hold (never there, or+    /// removed by hand) can't take a key, and Run Script File… keeps its own keys.+    #[test]+    fn shortcut_commands_find_scripts_copied_into_the_folder() {+        let (mut s, root) = session_in_tmp("copied");+        assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built");+        std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap();+        std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap();+        let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap();+        assert_eq!(list["count"], 1, "{list}");+        let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();+        assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}");+        let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"}));+        assert!(bad_params(&r), "{r:?}");+        // The binding-key form finds a script copied in since the last lookup, too.+        std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap();+        let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap();+        assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}");+        // Removed by hand: no longer in the folder.+        std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap();+        std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap();+        let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"}));+        assert!(bad_params(&r), "{r:?}");+        let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"}));+        assert!(bad_params(&r), "{r:?}");+        assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key");+        let _ = std::fs::remove_dir_all(&root);+    } }diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs--- a/crates/engine/src/cmd/settings.rs+++ b/crates/engine/src/cmd/settings.rs@@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {  // ---------------------------------------------------------------- shortcuts -/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`).-fn bindable(s: &Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {+/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A+/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it+/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error,+/// not Run Script File….+fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {     let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?;     let params = p.get("params").cloned().unwrap_or(Value::Null);-    let t = s.shortcuts();     let key = binding_key(c, &params);-    t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))+    let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {");+    if script {+        s.scripts_changed();+    }+    let t = s.shortcuts();+    let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) };+    found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) }  fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {@@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> { }  fn sc_list(s: &mut Session, p: &Value) -> Result<Value> {+    // Scripts copied into the Scripts folder since the table was built are listed too.+    s.scripts_changed();     let q = str_p(p, "query").unwrap_or("").to_lowercase();     let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false);     let keys_q = str_p(p, "keys").and_then(normalize);diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs--- a/crates/engine/src/settings/session_settings.rs+++ b/crates/engine/src/settings/session_settings.rs@@ -171,9 +171,19 @@ impl Session {         self.shortcut_table = std::sync::OnceLock::new();     } -    /// The active shortcut preset resolved against every bindable command.+    /// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the+    /// shortcut table.+    pub fn scripts_changed(&mut self) {+        self.shortcut_table = std::sync::OnceLock::new();+    }++    /// The active shortcut preset resolved against every bindable command, File ▸ Scripts'+    /// entries included.     pub fn shortcuts(&self) -> &ShortcutTable {-        self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands))+        self.shortcut_table.get_or_init(|| {+            let scripts: Vec<String> = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect();+            ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts)+        })     }      /// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel.diff --git a/crates/engine/src/settings/shortcuts.rs b/crates/engine/src/settings/shortcuts.rs--- a/crates/engine/src/settings/shortcuts.rs+++ b/crates/engine/src/settings/shortcuts.rs@@ -1,6 +1,7 @@ //! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts). //!-//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key+//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts+//! lists, is *bindable*: it has a binding key //! (the command id, plus its bound parameters for entries such as `app.settings //! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in //! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom@@ -142,6 +143,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {     out } +/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with+/// no default keys. A key is bound to the script's name, so it stays with that script when+/// scripts are added to or removed from the folder.+pub fn script_bindables(names: &[String]) -> Vec<Bindable> {+    names+        .iter()+        .map(|n| {+            let params = json!({"name": n});+            Bindable {+                key: binding_key("file.runScript", &params),+                command: "file.runScript".into(),+                params,+                label: n.clone(),+                path: vec!["File".into(), "Scripts".into()],+                scope: APP_SCOPE.into(),+                defaults: vec![],+            }+        })+        .collect()+}+ /// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it /// has no key. pub fn normalize(s: &str) -> Option<String> {@@ -421,7 +443,16 @@ pub struct ShortcutTable {  impl ShortcutTable {     pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable {-        let bindables = bindables(ui);+        Self::build_with_scripts(maps, ui, &[])+    }++    /// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed+    /// after the other File ▸ Scripts entries.+    pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable {+        let mut bindables = bindables(ui);+        let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1);+        let new: Vec<Bindable> = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect();+        bindables.splice(at..at, new);         let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect();         ShortcutTable { bindables, keys }     }diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs--- a/crates/ui-egui/src/menus/bar.rs+++ b/crates/ui-egui/src/menus/bar.rs@@ -162,7 +162,12 @@ fn menu_nodes(                         "recentPresets" => app.session.prefs.recent_presets.get(i).cloned(),                         _ => None,                     };-                    let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())));+                    let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str()));+                    // File ▸ Scripts entries show the key a script was given.+                    if let Some(s) = entry_shortcut(app, e) {+                        b = b.shortcut_text(shortcut_text(&s));+                    }+                    let r = ui.add_enabled(entry_enabled(app, e), b);                     let r = match &tip {                         Some(t) => r.on_hover_text(t),                         None => r,diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs--- a/crates/ui-egui/src/native_menu.rs+++ b/crates/ui-egui/src/native_menu.rs@@ -180,8 +180,9 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {                             label: e.label.clone(),                             command: e.command.clone(),                             params: e.params.clone(),-                            shortcut: None,-                            accelerator: None,+                            // File ▸ Scripts entries show the key a script was given.+                            shortcut: crate::menus::entry_shortcut(app, e),+                            accelerator: crate::menus::entry_shortcut(app, e).as_deref().and_then(accelerator),                             enabled: crate::menus::entry_enabled(app, e),                             checked: crate::menus::entry_checked(app, e),                         }));@@ -343,7 +344,12 @@ pub fn state_key(app: &EffectcraftApp) -> u64 {     s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h));     s.state.open_comps.hash(&mut h);     s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h);-    s.shortcuts().keys.len().hash(&mut h);+    // Each binding's keys: a key changed to another one gives the item a new accelerator.+    let shortcuts = s.shortcuts();+    for b in &shortcuts.bindables {+        b.key.hash(&mut h);+        shortcuts.keys.get(&b.key).hash(&mut h);+    }     s.keymaps.active.hash(&mut h);     let v = &app.ui.viewer;     (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h);@@ -705,6 +711,24 @@ mod tests {         assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj"));     } +    /// A script's key changed to another key changes the key, so the File ▸ Scripts entry gets+    /// the new accelerator even though the number of bindings stays the same.+    #[test]+    fn a_changed_shortcut_changes_the_state_key() {+        let mut app = app();+        let script = json!({"name": "Rename Layers.jsx"});+        let b = app.session.shortcuts().find(&effectcraft_engine::shortcuts::binding_key("file.runScript", &script)).cloned().unwrap();+        app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F7".to_string()]).unwrap();+        app.session.shortcuts_changed();+        let k0 = state_key(&app);+        app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F8".to_string()]).unwrap();+        app.session.shortcuts_changed();+        assert_ne!(state_key(&app), k0);+        let spec = build(&app);+        let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).unwrap();+        assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F8"));+    }+     /// #1630: a checked Window menu entry closes its panel; an unchecked one opens it.     #[test]     fn window_menu_entry_toggles_its_panel() {diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs--- a/crates/ui-egui/src/panels/shortcut_editor.rs+++ b/crates/ui-egui/src/panels/shortcut_editor.rs@@ -49,6 +49,8 @@ const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c);  pub fn open(app: &mut EffectcraftApp) {     app.dialog_state.shortcuts = EditorState::default();+    // Scripts copied into the Scripts folder since the table was built are listed too.+    app.session.scripts_changed();     app.dialog = Some(Dialog::Shortcuts); } diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs--- a/crates/ui-egui/tests/it/ui_scriptui.rs+++ b/crates/ui-egui/tests/it/ui_scriptui.rs@@ -113,6 +113,42 @@ fn panels_open_at_quit_dock_again_at_launch() {     assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#)); } +/// A script copied into the Scripts folder while the app runs is listed in Edit ▸ Keyboard+/// Shortcuts and takes a key there: File ▸ Scripts shows the key next to the script, and+/// pressing it runs the script as its menu entry does.+#[test]+fn a_key_given_to_a_script_runs_it() {+    use effectcraft_engine::config::{ConfigStore, MemoryConfig};+    let store = std::sync::Arc::new(MemoryConfig::default());+    let mut s = effectcraft_host::session();+    s.config = Some(store.clone());+    let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));+    h.run_steps(3);+    assert!(h.state().session.shortcuts().find(r#"file.runScript {"name":"Rename Layers.jsx"}"#).is_some(), "the shortcut table is built");+    store.write("Scripts/Add Comp.jsx", "app.project.items.addComp('From Key', 64, 64, 1, 1, 25);").unwrap();+    effectcraft_ui_egui::panels::shortcut_editor::open(h.state_mut());+    let script = json!({"name": "Add Comp.jsx"});+    let r = h.state_mut().session.execute("shortcuts.set", json!({"command": "file.runScript", "params": script, "keys": "Cmd+Alt+Shift+F7"})).unwrap();+    assert_eq!(r["command"], r#"file.runScript {"name":"Add Comp.jsx"}"#, "{r}");+    assert_eq!(r["keys"], json!(["Cmd+Alt+Shift+F7"]), "{r}");+    assert_eq!(r["conflicts"], json!([]), "{r}");+    h.state_mut().dialog = None;+    let spec = effectcraft_ui_egui::native_menu::build(h.state());+    let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).expect("the File ▸ Scripts entry");+    assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F7"));+    let (m, k, ..) = effectcraft_ui_egui::menus::bindings(&h.state().session)+        .into_iter()+        .find(|b| b.2 == "file.runScript" && b.3 == script)+        .expect("the key is bound to the script");+    let made = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name == "From Key").count();+    assert_eq!(made(&h), 0);+    for pressed in [true, false] {+        h.event(Event::Key { key: k, physical_key: Some(k), pressed, repeat: false, modifiers: m });+        h.step();+    }+    assert_eq!(made(&h), 1, "the key runs the script");+}+ /// 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]diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -299,8 +299,9 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie   on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins   ([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI   panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel-  and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard-  shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759,+  and open again at the next launch. Each File ▸ Scripts entry can be given a key in Edit ▸+  Keyboard Shortcuts. Missing: the Startup and Shutdown script folders and `.jsxbin`;+  `#include` / `#target` directives landed (#759,   [scripting-parity.md](scripting-parity.md)). - Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept   by name and applied to pairs of keyframes. Modelled on a third-party panel, they are not core: adiff --git a/docs/plugins.md b/docs/plugins.md--- a/docs/plugins.md+++ b/docs/plugins.md@@ -17,7 +17,7 @@ Guide): | 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 |-| 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 |+| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts under File ▸ Scripts (`file.runScript {"name": "<file name>"}`); a key given to one is saved with the preset, File ▸ Scripts shows it, and pressing it runs the script as its menu entry does | Same. After Effects binds a key to a script's place in the alphabetical list (`ExecuteScriptMenuItem01`…), and EffectCraft binds it to the script's file name. The Window menu entries of ScriptUI panels aren't offered | | 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 | | Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same |diff --git a/docs/preferences.md b/docs/preferences.md--- a/docs/preferences.md+++ b/docs/preferences.md@@ -189,7 +189,8 @@ Effect Controls uses compact, fixed 20 pt parameter rows, as After Effects does: Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific, both: split), modifier toggles, a searchable list of every command (all engine commands, menu-entries with bound parameters and the frontend's tool / panel commands), and a preset menu.+entries with bound parameters, the scripts File ▸ Scripts lists and the frontend's tool / panel+commands), and a preset menu.  - **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing   it creates a "Custom" copy. Custom presets store only their differences from the default anddiff --git a/docs/scripting-parity.md b/docs/scripting-parity.md--- a/docs/scripting-parity.md+++ b/docs/scripting-parity.md@@ -26,7 +26,7 @@ our own scripts; no real-world script library has been run against both apps. | 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 |-| Keyboard shortcuts for scripts | yes | no | missing | plugins.md |+| Keyboard shortcuts for scripts | yes: the scripts of the Scripts folder take keys, by their place in the alphabetical list | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts; a key stays with the script's file name | same | `file_scripts_entries_take_keyboard_shortcuts`, `a_key_given_to_a_script_runs_it` ([plugins.md](plugins.md)); ScriptUI panels in the Window menu take no key | | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | | Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — | | Agent control (MCP, control channel, every command) | none | yes | beyond | [agents.md](agents.md), [control-protocol.md](control-protocol.md); `list_effects` omits parameter types and ranges (#441) |