mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge pull request #2291 from dotCooCoo/feat/recent-script-files
Scripts: Recent Script Files lists the last ten scripts and Cmd+Alt+Shift+D reruns the newest
This commit is contained in:
@@ -151,6 +151,10 @@ impl Services for WebServices {
|
||||
fn is_dir(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
/// The table's keys are the paths themselves.
|
||||
fn full_path(&self, path: &str) -> Option<String> {
|
||||
Some(path.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
|
||||
|
||||
@@ -430,6 +430,20 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
|
||||
/// `{"method": "engine.execute", "params": {"id", "params"}}`).
|
||||
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// File ▸ Scripts ▸ Recent Script Files ▸ <script> (0 is the most recent).
|
||||
if let Some(i) = p.get("recent") {
|
||||
let i = i.as_u64().ok_or_else(|| bad("file.runScript", format!("`recent` must be an index, not {i}")))?;
|
||||
let script = match s.prefs.recent_scripts.get(usize::try_from(i).unwrap_or(usize::MAX)) {
|
||||
Some(x) => x.clone(),
|
||||
None if s.prefs.recent_scripts.is_empty() => return Err(bad("file.runScript", "no recent script files")),
|
||||
None => return Err(bad("file.runScript", format!("no recent script file #{i}"))),
|
||||
};
|
||||
let q = match script {
|
||||
crate::prefs::RecentScript::Path(path) => json!({"path": path}),
|
||||
crate::prefs::RecentScript::Name(name) => json!({"name": name}),
|
||||
};
|
||||
return run_script(s, &q);
|
||||
}
|
||||
// File ▸ Scripts ▸ <installed or sample script>.
|
||||
if let Some(name) = str_p(p, "name")
|
||||
&& p.get("path").is_none()
|
||||
@@ -446,15 +460,22 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("file.runScript", JSXBIN));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let full = s.services.full_path(path);
|
||||
let code = String::from_utf8_lossy(&bytes).to_string();
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let out = run_js(s, &code, path, false)?;
|
||||
return report_js(s, &name, out);
|
||||
let result = report_js(s, &name, out)?;
|
||||
if let Some(full) = full {
|
||||
remember_script(s, crate::prefs::RecentScript::Path(full));
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
let mut script_path = None;
|
||||
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
|
||||
(Some(Value::Array(a)), _) => a.clone(),
|
||||
(_, Some(path)) => {
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
script_path = s.services.full_path(path);
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
match serde_json::from_str::<Value>(&text) {
|
||||
Ok(Value::Array(a)) => a,
|
||||
@@ -487,9 +508,19 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
|
||||
results.push(r);
|
||||
}
|
||||
if let Some(path) = script_path {
|
||||
remember_script(s, crate::prefs::RecentScript::Path(path));
|
||||
}
|
||||
Ok(json!({"steps": results.len(), "results": results}))
|
||||
}
|
||||
|
||||
/// Remember a script that runs for File ▸ Scripts ▸ Recent Script Files.
|
||||
pub(super) fn remember_script(s: &mut Session, script: crate::prefs::RecentScript) {
|
||||
s.prefs.push_recent_script(script);
|
||||
s.save_prefs();
|
||||
s.prefs_revision += 1;
|
||||
}
|
||||
|
||||
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
|
||||
/// command scripts.
|
||||
fn is_js_script(path: &str) -> bool {
|
||||
@@ -1017,7 +1048,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Script File...",
|
||||
["File", "Scripts"],
|
||||
None,
|
||||
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | steps: [{command, params}]}",
|
||||
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | recent: index (File ▸ Scripts ▸ Recent Script Files, 0 = the most recent) | steps: [{command, params}]}",
|
||||
always,
|
||||
run_script
|
||||
),
|
||||
|
||||
@@ -235,13 +235,17 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Run an installed or bundled script by name (File ▸ Scripts ▸ `<name>`). A ScriptUI panel run
|
||||
/// this way gets `this` = a floating palette-like panel window.
|
||||
/// this way gets `this` = a floating palette-like panel window. A script listed under File ▸
|
||||
/// Scripts runs before a panel of the same name.
|
||||
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
|
||||
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
|
||||
let found = script_code(s, name, Some(false)).or_else(|| script_code(s, name, None));
|
||||
let (panel, code) = found.ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
|
||||
if panel {
|
||||
return open_panel(s, &json!({"name": name}));
|
||||
}
|
||||
super::file_more::run_js_named(s, &code, name)
|
||||
let result = super::file_more::run_js_named(s, &code, name)?;
|
||||
super::file_more::remember_script(s, crate::prefs::RecentScript::Name(name.to_string()));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script.
|
||||
@@ -553,6 +557,175 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// File ▸ Scripts ▸ Recent Script Files: the ten scripts run last, newest first, kept in the
|
||||
/// settings; Cmd+Alt+Shift+D runs the first one again.
|
||||
#[test]
|
||||
fn recent_script_files_run_again() {
|
||||
use crate::prefs::{MAX_RECENT_SCRIPTS, PREFS_FILE, Prefs, RecentScript};
|
||||
let (mut s, root) = session_in_tmp("recent");
|
||||
assert_eq!(crate::menus::dynamic(&s, "recentScripts", &Default::default()), (vec![], Some("No Recent Script Files")));
|
||||
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 0}))));
|
||||
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"recent": 0})), Some("Cmd+Alt+Shift+D"));
|
||||
let tool = root.join("Tool.jsx");
|
||||
std::fs::write(&tool, "tool").unwrap();
|
||||
let tool = tool.to_string_lossy().into_owned();
|
||||
s.execute("file.runScript", json!({"path": tool})).unwrap();
|
||||
s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap();
|
||||
// A ScriptUI panel opened from File ▸ Scripts is not a recent script file.
|
||||
std::fs::write(root.join("settings").join(PANELS_DIR).join("Panel.jsx"), "panel").unwrap();
|
||||
s.execute("file.runScript", json!({"name": "Panel.jsx"})).unwrap();
|
||||
let (path, name) = (RecentScript::Path(tool.clone()), RecentScript::Name("Rename Layers.jsx".into()));
|
||||
assert_eq!(s.prefs.recent_scripts, [name.clone(), path.clone()]);
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
|
||||
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Rename Layers.jsx", "Tool.jsx"]);
|
||||
assert_eq!((e[1].command.as_str(), &e[1].params), ("file.runScript", &json!({"recent": 1})));
|
||||
// Running one again runs that file and moves it to the top.
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], "tool");
|
||||
assert_eq!(s.prefs.recent_scripts, [path.clone(), name.clone()]);
|
||||
let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3;
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
|
||||
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 2}))));
|
||||
assert!(bad_params(&s.execute("file.runScript", json!({"recent": "Tool.jsx"}))));
|
||||
// A file named like a bundled script runs again as that file.
|
||||
let local = root.join("Rename Layers.jsx");
|
||||
std::fs::write(&local, "local").unwrap();
|
||||
s.execute("file.runScript", json!({"path": local.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "local");
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
|
||||
// Ten at most, saved with the settings.
|
||||
let mut last = String::new();
|
||||
for i in 0..MAX_RECENT_SCRIPTS + 2 {
|
||||
let f = root.join(format!("Step {i}.jsonl"));
|
||||
std::fs::write(&f, "").unwrap();
|
||||
last = f.to_string_lossy().into_owned();
|
||||
s.execute("file.runScript", json!({"path": last})).unwrap();
|
||||
}
|
||||
assert_eq!(s.prefs.recent_scripts.len(), MAX_RECENT_SCRIPTS);
|
||||
assert_eq!(s.prefs.recent_scripts[0], RecentScript::Path(last));
|
||||
let saved = std::fs::read_to_string(root.join("settings").join(PREFS_FILE)).unwrap();
|
||||
assert_eq!(Prefs::from_json(&saved).recent_scripts, s.prefs.recent_scripts);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_script_history_uses_the_selected_script() {
|
||||
let (mut s, root) = session_in_tmp("recent-panel-collision");
|
||||
let name = "Rename Layers.jsx";
|
||||
std::fs::write(root.join("settings").join(PANELS_DIR).join(name), "panel").unwrap();
|
||||
assert!(super::scripts(&s).iter().any(|entry| entry.name == name && !entry.panel));
|
||||
assert!(script_code(&s, name, None).unwrap().0);
|
||||
let sample = BUNDLED.iter().find(|b| b.0 == name).unwrap().3;
|
||||
assert_eq!(s.execute("file.runScript", json!({"name": name})).unwrap()["ran"], sample);
|
||||
assert!(super::open_panel_names(&s).is_empty(), "the listed script runs instead of the panel");
|
||||
assert_eq!(s.prefs.recent_scripts, [crate::prefs::RecentScript::Name(name.to_string())]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_script_history_keeps_successful_entries_when_scripts_fail() {
|
||||
fn fail_or_echo(_: &mut Session, request: &ScriptRequest) -> Value {
|
||||
if request.code == "fail" { json!({"error": {"message": "script failed"}}) } else { json!({"ran": request.code}) }
|
||||
}
|
||||
let (mut s, root) = session_in_tmp("recent-failed");
|
||||
s.script = Some(fail_or_echo);
|
||||
let good = root.join("good.jsonl");
|
||||
std::fs::write(&good, "[]").unwrap();
|
||||
s.execute("file.runScript", json!({"path": good})).unwrap();
|
||||
let remembered = s.prefs.recent_scripts.clone();
|
||||
for (name, code) in [("parse.jsonl", "{"), ("step.jsonl", r#"{"command":"does.not.exist"}"#), ("missing-command.jsonl", "{}"), ("runtime.jsx", "fail")]
|
||||
{
|
||||
let path = root.join(name);
|
||||
std::fs::write(&path, code).unwrap();
|
||||
assert!(s.execute("file.runScript", json!({"path": path})).is_err(), "{name}");
|
||||
assert_eq!(s.prefs.recent_scripts, remembered, "{name}");
|
||||
}
|
||||
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Failed.jsx"), "fail").unwrap();
|
||||
assert!(s.execute("file.runScript", json!({"name": "Failed.jsx"})).is_err());
|
||||
assert_eq!(s.prefs.recent_scripts, remembered);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
/// Reads relative paths from a working directory that a test can change.
|
||||
struct InFolder(std::sync::Mutex<PathBuf>);
|
||||
|
||||
impl InFolder {
|
||||
fn at(&self, path: &str) -> PathBuf {
|
||||
self.0.lock().unwrap().join(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Services for InFolder {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
std::fs::read(self.at(path))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::write(self.at(path), data)
|
||||
}
|
||||
fn full_path(&self, path: &str) -> Option<String> {
|
||||
Some(self.at(path).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads from a folder but can't make paths absolute.
|
||||
struct NoFullPath(PathBuf);
|
||||
|
||||
impl crate::Services for NoFullPath {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
std::fs::read(self.0.join(path))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::write(self.0.join(path), data)
|
||||
}
|
||||
fn full_path(&self, _path: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A script whose path can't be made absolute still runs, and Recent Script Files does not
|
||||
/// keep its relative path.
|
||||
#[test]
|
||||
fn unresolved_script_paths_are_not_remembered() {
|
||||
let (mut s, root) = session_in_tmp("recent-unresolved");
|
||||
std::fs::write(root.join("Tool.jsx"), "a").unwrap();
|
||||
std::fs::write(root.join("steps.jsonl"), "[]").unwrap();
|
||||
s.services = Arc::new(NoFullPath(root.clone()));
|
||||
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
|
||||
s.execute("file.runScript", json!({"path": "steps.jsonl"})).unwrap();
|
||||
assert!(s.prefs.recent_scripts.is_empty(), "{:?}", s.prefs.recent_scripts);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A script run from a relative path runs the same file again from Recent Script Files after
|
||||
/// the working directory changes, and two scripts with the same file name show their paths.
|
||||
#[test]
|
||||
fn recent_script_files_keep_the_file_that_ran() {
|
||||
let (mut s, root) = session_in_tmp("recent-relative");
|
||||
let (a, b) = (root.join("a"), root.join("b"));
|
||||
for (dir, code) in [(&a, "a"), (&b, "b")] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("Tool.jsx"), code).unwrap();
|
||||
}
|
||||
let services = Arc::new(InFolder(std::sync::Mutex::new(a.clone())));
|
||||
s.services = services.clone();
|
||||
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
|
||||
*services.0.lock().unwrap() = b.clone();
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "a");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
|
||||
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Tool.jsx"]);
|
||||
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "b");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
|
||||
let (a, b) = (a.join("Tool.jsx").to_string_lossy().into_owned(), b.join("Tool.jsx").to_string_lossy().into_owned());
|
||||
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Native script paths are kept absolute.
|
||||
#[test]
|
||||
fn native_full_paths_are_absolute() {
|
||||
use crate::Services;
|
||||
assert!(crate::FsServices.full_path("Tool.jsx").is_some_and(|p| std::path::Path::new(&p).is_absolute()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_scripts_are_read_or_removed() {
|
||||
// A settings file in the Scripts folder that is not a script (.jsx / .js).
|
||||
|
||||
@@ -61,6 +61,12 @@ fn file_name(p: &str) -> String {
|
||||
std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())
|
||||
}
|
||||
|
||||
/// The labels of a recent-files list: `names[i]`, or the whole of `full[i]` when another entry
|
||||
/// has the same name, so entries for two files named `Tool.jsx` tell them apart.
|
||||
fn distinct_labels(names: Vec<String>, full: &[String]) -> Vec<String> {
|
||||
names.iter().zip(full).map(|(n, f)| if names.iter().filter(|m| *m == n).count() > 1 { f.clone() } else { n.clone() }).collect()
|
||||
}
|
||||
|
||||
fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry {
|
||||
MenuEntry { label, command: command.into(), params, shortcut: None, alt_shortcuts: vec![] }
|
||||
}
|
||||
@@ -72,26 +78,34 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
|
||||
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
|
||||
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `recentScripts` (File ▸ Scripts ▸
|
||||
/// 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).
|
||||
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> {
|
||||
list.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let n =
|
||||
if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) };
|
||||
dyn_entry(n, cmd, json!({"index": i}))
|
||||
})
|
||||
.collect()
|
||||
let names = list
|
||||
.iter()
|
||||
.map(
|
||||
|p| if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) },
|
||||
)
|
||||
.collect();
|
||||
distinct_labels(names, list).into_iter().enumerate().map(|(i, n)| dyn_entry(n, cmd, json!({"index": i}))).collect()
|
||||
};
|
||||
match name {
|
||||
"recentProjects" => (indexed(&s.prefs.recent_projects, "file.openRecent", false), Some("No Recent Projects")),
|
||||
"recentFootage" => (indexed(&s.prefs.recent_footage, "file.importRecent", false), Some("No Recent Footage")),
|
||||
"recentPresets" => (indexed(&s.prefs.recent_presets, "anim.applyRecentPreset", true), Some("No Recent Presets")),
|
||||
"recentScripts" => {
|
||||
let full: Vec<String> = s.prefs.recent_scripts.iter().map(|r| r.text().to_string()).collect();
|
||||
let names = full.iter().map(|p| file_name(p)).collect();
|
||||
(
|
||||
distinct_labels(names, &full).into_iter().enumerate().map(|(i, n)| dyn_entry(n, "file.runScript", json!({"recent": i}))).collect(),
|
||||
Some("No Recent Script Files"),
|
||||
)
|
||||
}
|
||||
"history" => (
|
||||
s.history.undo.iter().rev().enumerate().map(|(i, (l, _))| dyn_entry(format!("Undo {l}"), "edit.history", json!({"steps": i + 1}))).collect(),
|
||||
Some("No History"),
|
||||
@@ -576,6 +590,8 @@ File
|
||||
Install Script File... | file.installScript
|
||||
Install ScriptUI Panel... | file.installScriptUIPanel
|
||||
Run Script File... | file.runScript
|
||||
Recent Script Files
|
||||
@dynamic:recentScripts
|
||||
---
|
||||
Create Proxy
|
||||
Still... | file.createProxy {"kind":"still"}
|
||||
|
||||
@@ -37,6 +37,13 @@ pub trait Services: Send + Sync {
|
||||
fn is_dir(&self, path: &str) -> bool {
|
||||
std::path::Path::new(path).is_dir()
|
||||
}
|
||||
/// `path` as a list of recent files keeps it (File ▸ Scripts ▸ Recent Script Files): a
|
||||
/// relative path is made absolute against the working directory, so it opens the same file
|
||||
/// after the working directory changes. `None` when the path can't be made absolute (the
|
||||
/// working directory can't be read); the script is then not added to the list.
|
||||
fn full_path(&self, path: &str) -> Option<String> {
|
||||
std::path::absolute(path).ok().map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Native filesystem.
|
||||
|
||||
@@ -28,6 +28,7 @@ pub use choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES
|
||||
pub use dialog::{Item, Kind, Page, pages, pages_json, todo_keys};
|
||||
pub use document::{migrate, page_id, section_key};
|
||||
pub use queries::{CacheBudgets, WEB_LAYER_CACHE, WEB_MEDIA_CACHE, mask_color};
|
||||
pub use recent::{MAX_RECENT_SCRIPTS, RecentScript};
|
||||
pub use sections::{
|
||||
Appearance, Audio, AutoSave, CompPreset, CompositionPrefs, Disk, Export, FacePrefs, General, Grids, Import, LabelPref, Memory, Previews, ProjectPrefs,
|
||||
RotoPrefs, Scripting, Startup, ThreeD, TypePrefs, Video,
|
||||
@@ -74,6 +75,8 @@ pub struct Prefs {
|
||||
/// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest
|
||||
/// first.
|
||||
pub recent_presets: Vec<String>,
|
||||
/// File ▸ Scripts ▸ Recent Script Files, newest first, at most [`MAX_RECENT_SCRIPTS`].
|
||||
pub recent_scripts: Vec<RecentScript>,
|
||||
/// Character panel: recently used font families, newest first.
|
||||
pub recent_fonts: Vec<String>,
|
||||
/// Effects & Presets: the starred effects' ids, in the order starred.
|
||||
@@ -117,6 +120,7 @@ impl Default for Prefs {
|
||||
recent_projects: vec![],
|
||||
recent_footage: vec![],
|
||||
recent_presets: vec![],
|
||||
recent_scripts: vec![],
|
||||
recent_fonts: vec![],
|
||||
effects_favorites: vec![],
|
||||
preview: crate::preview::PreviewSettings::default(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES, WINDOW_GRAPHICS, choices, is_choice, theme_family};
|
||||
use super::recent::MAX_EFFECTS_FAVORITES;
|
||||
use super::recent::{MAX_EFFECTS_FAVORITES, MAX_RECENT_SCRIPTS};
|
||||
use super::sections::{CompPreset, default_labels, parse_hex};
|
||||
use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json};
|
||||
|
||||
@@ -146,6 +146,7 @@ impl Prefs {
|
||||
self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0);
|
||||
let n = self.general.recent_items as usize;
|
||||
self.recent_projects.truncate(n);
|
||||
self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
|
||||
}
|
||||
|
||||
/// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything.
|
||||
@@ -211,6 +212,7 @@ impl Prefs {
|
||||
recent_projects: std::mem::take(&mut self.recent_projects),
|
||||
recent_footage: std::mem::take(&mut self.recent_footage),
|
||||
recent_presets: std::mem::take(&mut self.recent_presets),
|
||||
recent_scripts: std::mem::take(&mut self.recent_scripts),
|
||||
recent_fonts: std::mem::take(&mut self.recent_fonts),
|
||||
effects_favorites: std::mem::take(&mut self.effects_favorites),
|
||||
preview: std::mem::take(&mut self.preview),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Prefs;
|
||||
|
||||
impl Prefs {
|
||||
@@ -45,6 +47,34 @@ impl Prefs {
|
||||
pub fn push_recent_preset(&mut self, path: &str) {
|
||||
push_mru(&mut self.recent_presets, path, self.general.recent_items as usize);
|
||||
}
|
||||
|
||||
/// Remember a script that ran for File ▸ Scripts ▸ Recent Script Files.
|
||||
pub fn push_recent_script(&mut self, script: RecentScript) {
|
||||
self.recent_scripts.retain(|x| *x != script);
|
||||
self.recent_scripts.insert(0, script);
|
||||
self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
|
||||
}
|
||||
}
|
||||
|
||||
/// Most scripts File ▸ Scripts ▸ Recent Script Files lists.
|
||||
pub const MAX_RECENT_SCRIPTS: usize = 10;
|
||||
|
||||
/// A File ▸ Scripts ▸ Recent Script Files entry, stored as the `file.runScript` parameter that
|
||||
/// ran it: `{"path": …}` for a script file, `{"name": …}` for an installed or bundled script.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RecentScript {
|
||||
Path(String),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl RecentScript {
|
||||
/// The file's path or the script's name.
|
||||
pub fn text(&self) -> &str {
|
||||
match self {
|
||||
RecentScript::Path(s) | RecentScript::Name(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most effects Effects & Presets keeps starred (a hand-edited settings file can hold more).
|
||||
|
||||
@@ -105,6 +105,21 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
// File ▸ Scripts ▸ Recent Script Files lists its scripts at run time; the first one, the most
|
||||
// recent, has a key.
|
||||
let recent = json!({"recent": 0});
|
||||
push(
|
||||
Bindable {
|
||||
key: binding_key("file.runScript", &recent),
|
||||
command: "file.runScript".into(),
|
||||
params: recent,
|
||||
label: "Run Most Recent Script File".into(),
|
||||
path: vec!["File".into(), "Scripts".into(), "Recent Script Files".into()],
|
||||
scope: APP_SCOPE.into(),
|
||||
defaults: normalize("Cmd+Alt+Shift+D").into_iter().collect(),
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
for c in crate::command_specs() {
|
||||
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
|
||||
continue;
|
||||
|
||||
@@ -708,6 +708,11 @@ fn dynamic_menus_and_window_viewer_labels() {
|
||||
s.prefs.push_recent("/p/One.ecproj");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
|
||||
assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent"));
|
||||
// Two projects with the same file name show their paths.
|
||||
s.prefs.push_recent("/q/One.ecproj");
|
||||
s.prefs.push_recent("/p/Two.ecproj");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
|
||||
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Two.ecproj", "/q/One.ecproj", "/p/One.ecproj"]);
|
||||
assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len());
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.state.selected_layers = vec![LayerId(a)];
|
||||
|
||||
@@ -469,6 +469,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "安装脚本文件..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "安装 ScriptUI 面板..."),
|
||||
("file.runScript", "Run Script File...", "运行脚本文件..."),
|
||||
("", "Recent Script Files", "最近的脚本文件"),
|
||||
("", "Create Proxy", "创建代理"),
|
||||
("file.createProxy", "Still...", "静止图像..."),
|
||||
("file.createProxy", "Movie...", "影片..."),
|
||||
@@ -1104,6 +1105,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "安裝指令碼檔案..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "安裝 ScriptUI 面板..."),
|
||||
("file.runScript", "Run Script File...", "執行指令碼檔案..."),
|
||||
("", "Recent Script Files", "最近使用的指令碼檔案"),
|
||||
("", "Create Proxy", "建立代理"),
|
||||
("file.createProxy", "Still...", "靜態影像..."),
|
||||
("file.createProxy", "Movie...", "影片..."),
|
||||
@@ -1736,6 +1738,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "スクリプトファイルをインストール..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUIパネルをインストール..."),
|
||||
("file.runScript", "Run Script File...", "スクリプトファイルを実行..."),
|
||||
("", "Recent Script Files", "最近使用したスクリプトファイル"),
|
||||
("", "Create Proxy", "プロキシを作成"),
|
||||
("file.createProxy", "Still...", "静止画..."),
|
||||
("file.createProxy", "Movie...", "ムービー..."),
|
||||
@@ -2371,6 +2374,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "Встановити файл скрипту..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Встановити панель ScriptUI..."),
|
||||
("file.runScript", "Run Script File...", "Виконати файл скрипту..."),
|
||||
("", "Recent Script Files", "Нещодавні файли скриптів"),
|
||||
("", "Create Proxy", "Створити проксі"),
|
||||
("file.createProxy", "Still...", "Зображення..."),
|
||||
("file.createProxy", "Movie...", "Відео..."),
|
||||
@@ -3007,6 +3011,7 @@ const SPANISH: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "Instalar archivo de script..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar panel de ScriptUI..."),
|
||||
("file.runScript", "Run Script File...", "Ejecutar archivo de script..."),
|
||||
("", "Recent Script Files", "Archivos de script recientes"),
|
||||
("", "Create Proxy", "Crear proxy"),
|
||||
("file.createProxy", "Still...", "Imagen fija..."),
|
||||
("file.createProxy", "Movie...", "Película..."),
|
||||
@@ -3641,6 +3646,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "Instalar arquivo de script..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar painel ScriptUI..."),
|
||||
("file.runScript", "Run Script File...", "Executar arquivo de script..."),
|
||||
("", "Recent Script Files", "Arquivos de script recentes"),
|
||||
("", "Create Proxy", "Criar proxy"),
|
||||
("file.createProxy", "Still...", "Imagem fixa..."),
|
||||
("file.createProxy", "Movie...", "Filme..."),
|
||||
@@ -4283,6 +4289,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "Установить файл скрипта..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Установить панель ScriptUI..."),
|
||||
("file.runScript", "Run Script File...", "Выполнить файл скрипта..."),
|
||||
("", "Recent Script Files", "Недавние файлы скриптов"),
|
||||
("", "Create Proxy", "Создать суррогат"),
|
||||
("file.createProxy", "Still...", "Стоп-кадр..."),
|
||||
("file.createProxy", "Movie...", "Видео..."),
|
||||
@@ -4917,6 +4924,7 @@ const FRENCH: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "Installer un fichier de script..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Installer un panneau ScriptUI..."),
|
||||
("file.runScript", "Run Script File...", "Exécuter un fichier de script..."),
|
||||
("", "Recent Script Files", "Fichiers de script récents"),
|
||||
("", "Create Proxy", "Créer un proxy"),
|
||||
("file.createProxy", "Still...", "Image fixe..."),
|
||||
("file.createProxy", "Movie...", "Vidéo..."),
|
||||
@@ -5551,6 +5559,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "Skriptdatei installieren..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI-Bedienfeld installieren..."),
|
||||
("file.runScript", "Run Script File...", "Skriptdatei ausführen..."),
|
||||
("", "Recent Script Files", "Zuletzt verwendete Skriptdateien"),
|
||||
("", "Create Proxy", "Proxy erstellen"),
|
||||
("file.createProxy", "Still...", "Standbild..."),
|
||||
("file.createProxy", "Movie...", "Film..."),
|
||||
@@ -6185,6 +6194,7 @@ const CZECH: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "Nainstalovat soubor skriptu..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Nainstalovat panel ScriptUI..."),
|
||||
("file.runScript", "Run Script File...", "Spustit soubor skriptu..."),
|
||||
("", "Recent Script Files", "Nedávné soubory skriptů"),
|
||||
("", "Create Proxy", "Vytvořit zástupný soubor"),
|
||||
("file.createProxy", "Still...", "Statický snímek..."),
|
||||
("file.createProxy", "Movie...", "Video..."),
|
||||
@@ -6816,6 +6826,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
|
||||
("file.installScript", "Install Script File...", "სკრიპტის ფაილის დაყენება..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI პანელის დაყენება..."),
|
||||
("file.runScript", "Run Script File...", "სკრიპტის ფაილის გაშვება..."),
|
||||
("", "Recent Script Files", "ბოლო სკრიპტის ფაილები"),
|
||||
("", "Create Proxy", "პროქსის შექმნა"),
|
||||
("file.createProxy", "Still...", "სტატიკური კადრი..."),
|
||||
("file.createProxy", "Movie...", "ვიდეო..."),
|
||||
|
||||
@@ -160,6 +160,7 @@ fn menu_nodes(
|
||||
"recentProjects" => app.session.prefs.recent_projects.get(i).cloned(),
|
||||
"recentFootage" => app.session.prefs.recent_footage.get(i).cloned(),
|
||||
"recentPresets" => app.session.prefs.recent_presets.get(i).cloned(),
|
||||
"recentScripts" => app.session.prefs.recent_scripts.get(i).map(|r| r.text().to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())));
|
||||
|
||||
@@ -548,7 +548,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
|
||||
"file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])),
|
||||
"file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])),
|
||||
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
|
||||
"file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||
"file.runScript" if params.get("name").is_none() && params.get("recent").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||
"file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])),
|
||||
"effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
|
||||
@@ -299,6 +299,25 @@ fn save_dialogs_offer_the_file_extension() {
|
||||
assert_eq!(*asked.borrow(), ["project Frame.png"]);
|
||||
}
|
||||
|
||||
/// File ▸ Scripts ▸ Recent Script Files and its key run a script again without a file dialog.
|
||||
#[test]
|
||||
fn recent_script_files_run_without_a_dialog() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
let dir = std::env::temp_dir().join(format!("ec-recent-script-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("New Comp.jsonl");
|
||||
std::fs::write(&script, r#"{"command": "comp.new", "params": {"name": "From Script", "width": 64, "height": 36, "frameRate": 30, "duration": 1}}"#)
|
||||
.unwrap();
|
||||
app.session.execute("file.runScript", json!({"path": script.to_string_lossy()})).unwrap();
|
||||
let (m, k, id, params) =
|
||||
bindings(&app.session).into_iter().find(|b| b.2 == "file.runScript" && b.3 == json!({"recent": 0})).expect("the most recent script has a key");
|
||||
assert_eq!((m, k), parse_shortcut("Cmd+Alt+Shift+D").unwrap());
|
||||
assert_eq!(invoke(&mut app, &ctx, &id, params).unwrap()["steps"], 1);
|
||||
assert_eq!(app.session.project.items.values().filter(|i| i.name.starts_with("From Script")).count(), 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #511: with a host's non-blocking dialogs, a command's file dialog opens without running
|
||||
/// a nested event loop inside the frame (a drag over the window then aborted the app on
|
||||
/// macOS); the command runs on a later frame with the answer, and a cancelled one not at all.
|
||||
|
||||
@@ -340,6 +340,7 @@ pub fn state_key(app: &EffectcraftApp) -> u64 {
|
||||
s.journal.last().map(|j| &j.0).hash(&mut h);
|
||||
s.prefs.recent_projects.hash(&mut h);
|
||||
(&s.prefs.recent_footage, &s.prefs.recent_presets).hash(&mut h);
|
||||
s.prefs.recent_scripts.hash(&mut h);
|
||||
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);
|
||||
|
||||
@@ -14,6 +14,7 @@ Guide):
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -27,6 +27,7 @@ our own scripts; no real-world script library has been run against both apps.
|
||||
| `#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 |
|
||||
| Recent Script Files | File ▸ Scripts ▸ Recent Script Files (10 scripts), a key to rerun | Same list, kept in the settings; Cmd+Alt+Shift+D reruns the most recent script | same | `recent_script_files_run_again` ([plugins.md](plugins.md)) |
|
||||
| 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) |
|
||||
|
||||
Reference in New Issue
Block a user