diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 57b3d4122..fab468c39 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -237,9 +237,10 @@ fn main() -> ExitCode { /// A headless session that remembers what the user keeps between runs: the PDF presets saved, the /// ICC profiles loaded, the plug-ins installed (#2168, #2174, #2541), the custom dictionary's words (#4936) and the Color Settings (#6335), in the folders -/// and files next to the preferences that the desktop app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply -/// as they do there. +/// and files next to the preferences that the desktop app uses too, and lists the scripts of the Scripts folder there +/// (`script.listInstalled`). `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there. fn with_stores(mut h: Headless) -> Headless { + prefs_dir::attach_scripts_dir(&mut h.session); prefs_dir::attach_profile_store(&mut h.session); prefs_dir::attach_color_settings(&mut h.session); prefs_dir::attach_plugin_store(&mut h.session); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index b97ee681f..bb94bad1c 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -463,6 +463,8 @@ fn main() -> std::process::ExitCode { let launch_dir = prefs_path().filter(|_| prefs_enabled()).and_then(|p| Some(p.parent()?.join("Launch"))); let stopped = safe_mode::begin_launch(launch_dir.as_deref(), safe_mode_flag); prefs_dir::attach_plugin_store(&mut session); + // File › Scripts lists the scripts of the Scripts folder next to the preferences. + prefs_dir::attach_scripts_dir(&mut session); let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); app.set_startup_update_check(true); load_prefs(&mut app, saved); diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 063d9b1aa..96e09f5e7 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -57,6 +57,14 @@ pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) { } } +/// Have File › Scripts and `script.listInstalled` list the scripts of the `Scripts` folder. The +/// folder is only read: the user makes it. +pub fn attach_scripts_dir(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("Scripts") { + session.attach_scripts_dir(dir); + } +} + /// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from /// now on there. After the preferences were restored: the folder is the one place the presets live. pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) { diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index aac077bb8..66f3f8bf8 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -63,6 +63,15 @@ pub fn specs() -> Vec { always, console_clear ), + cmd!( + query "script.listInstalled", + "List Installed Scripts", + [], + None, + "{} list the .js and .jsx files of the Scripts folder next to the settings, which File › Scripts lists, each subfolder as a submenu, in menu order; only names are read, never a script's text; links, hidden files, names starting with a dot and folders without a script are left out. VectorCraft's own limits: 2048 folder entries looked at, 512 scripts, 128 subfolders, 4 levels of subfolders, names of 255 bytes, labels of 128 characters, 250 ms → {folder: the Scripts folder's path | null (a session without one, and the web), scripts: [{path, label: the file name without its extension (the whole file name when another row of its folder has that label), folders: [the labels of the subfolders it is in]}], truncated: a limit was reached or a subfolder could not be read, unreadable: the folder is there and could not be read, or is a link}", + always, + list_installed + ), cmd!( query "script.check", "Check Script", @@ -173,6 +182,52 @@ fn console_clear(s: &mut Session, _: &Value) -> Result { Ok(json!({"cleared": s.clear_script_console()})) } +/// The error for a script that File › Scripts listed and that [`script::folder::read`] no longer +/// reads. +pub const NOT_LISTED: &str = "the script is no longer in the Scripts folder"; + +impl Session { + /// Have File › Scripts and `script.listInstalled` list the scripts of the folder `dir`. The + /// folder is only read, and never made. + pub fn attach_scripts_dir(&mut self, dir: std::path::PathBuf) { + self.scripts_dir = Some(dir); + } + + /// The Scripts folder ([`Session::attach_scripts_dir`]); none on the web, which has no folders. + pub fn scripts_dir(&self) -> Option<&std::path::Path> { + self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32")) + } + + /// The text of the script at `path`, which a listing of the Scripts folder gave + /// ([`script::folder::read`]), checked as `script.run` checks a file's. + pub fn installed_script(&self, path: &str) -> Result { + let root = self.scripts_dir().ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + crate::file_access::check_read(path).map_err(EngineError::Other)?; + // One byte more than a script may have shows that the file is too large. + let bytes = script::folder::read(root, std::path::Path::new(path), script::MAX_SOURCE_BYTES + 1) + .ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + if bytes.len() > script::MAX_SOURCE_BYTES { + return Err(EngineError::Other(TOO_LARGE.to_string())); + } + script_text(&bytes) + } +} + +fn list_installed(s: &mut Session, _: &Value) -> Result { + let Some(root) = s.scripts_dir() else { + return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); + }; + let folder = root.to_string_lossy(); + crate::file_access::check_read(&folder).map_err(EngineError::Other)?; + let listing = script::folder::list(root); + let scripts: Vec = listing + .scripts() + .into_iter() + .map(|(folders, label, path)| json!({"path": path.to_string_lossy(), "label": label, "folders": folders})) + .collect(); + Ok(json!({"folder": folder, "scripts": scripts, "truncated": listing.truncated, "unreadable": listing.unreadable})) +} + /// The folder a script is in, which its included files are read from. struct Folder { dir: std::path::PathBuf, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aa032dab4..091348a6b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -168,6 +168,9 @@ pub struct Session { /// Where the plug-ins `plugin.install` installs are kept; `None`: only for the process /// ([`Session::attach_plugin_store`]). plugin_dir: Option, + /// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder + /// ([`Session::attach_scripts_dir`]). + scripts_dir: Option, /// The plug-in ids whose grant in the plug-in store should be gone but could not be removed: /// [`Session::plugin_approver`] and a load of the store do not count those grants, and a successful /// [`Session::store_grant`] for the id takes it out. diff --git a/crates/engine/src/script/folder.rs b/crates/engine/src/script/folder.rs new file mode 100644 index 000000000..d071195df --- /dev/null +++ b/crates/engine/src/script/folder.rs @@ -0,0 +1,302 @@ +//! The Scripts folder: the `.js` and `.jsx` files File › Scripts lists, with each subfolder as a +//! submenu ([`list`]), and the read of one of them when it is chosen ([`read`]). +//! +//! A listing reads names and file types only, never a script's text. It follows no link: a symbolic +//! link or a Windows junction is left out, whether it is a file, a folder or the Scripts folder +//! itself, so a listed script is a plain file in plain folders under the Scripts folder. Names that +//! start with a dot, files and folders Windows marks hidden or system, names that are not Unicode +//! and names longer than [`MAX_NAME_BYTES`] are left out too, and so is a folder with no script in +//! it. +//! +//! The values here are VectorCraft's own limits: [`MAX_ENTRIES`], [`MAX_SCRIPTS`], [`MAX_FOLDERS`], +//! [`MAX_DEPTH`], [`MAX_NAME_BYTES`], [`MAX_LABEL_CHARS`] and [`MAX_SCAN`]. A listing that reaches +//! one of them, or that cannot read a subfolder or an entry of a folder, keeps what it found and +//! says so ([`Listing::truncated`]). + +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use crate::cmd::scripts::runs; + +/// The most folder entries one listing looks at, in the Scripts folder and its subfolders together. +pub const MAX_ENTRIES: usize = 2048; +/// The most scripts one listing holds. +pub const MAX_SCRIPTS: usize = 512; +/// The most subfolders one listing reads. +pub const MAX_FOLDERS: usize = 128; +/// How many levels of subfolders a listing reads below the Scripts folder. +pub const MAX_DEPTH: usize = 4; +/// The longest file or folder name a listing takes, in UTF-8 bytes. +pub const MAX_NAME_BYTES: usize = 255; +/// The most characters of a label; a longer one ends in `…`. +pub const MAX_LABEL_CHARS: usize = 128; +/// How long one listing may take before it stops with what it found. +pub const MAX_SCAN: Duration = Duration::from_millis(250); + +/// The limits of one listing. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + pub entries: usize, + pub scripts: usize, + pub folders: usize, + pub depth: usize, + pub time: Duration, +} + +impl Default for Limits { + fn default() -> Self { + Limits { entries: MAX_ENTRIES, scripts: MAX_SCRIPTS, folders: MAX_FOLDERS, depth: MAX_DEPTH, time: MAX_SCAN } + } +} + +/// One row of a listing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Entry { + /// A script: its label (the file name without its extension) and the file's path as the folder + /// gave it. + Script { label: String, path: PathBuf }, + /// A subfolder with at least one script in it or below it: its label (the folder's name) and + /// its rows. + Folder { label: String, entries: Vec }, +} + +/// What a Scripts folder holds. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Listing { + /// The rows of the folder itself, sorted ([`list_with`]). + pub entries: Vec, + /// Some scripts may be missing: a limit was reached or a subfolder could not be read. + pub truncated: bool, + /// The folder is there and could not be read, or is a link. + pub unreadable: bool, +} + +impl Listing { + /// Every script with the labels of the folders it is in, in menu order. + pub fn scripts(&self) -> Vec<(Vec<&str>, &str, &Path)> { + fn walk<'a>(entries: &'a [Entry], folders: &mut Vec<&'a str>, out: &mut Vec<(Vec<&'a str>, &'a str, &'a Path)>) { + for e in entries { + match e { + Entry::Script { label, path } => out.push((folders.clone(), label, path)), + Entry::Folder { label, entries } => { + folders.push(label); + walk(entries, folders, out); + folders.pop(); + } + } + } + } + let mut out = vec![]; + walk(&self.entries, &mut vec![], &mut out); + out + } +} + +/// What the folder `root` holds, within the default [`Limits`]. A folder that does not exist gives +/// an empty listing. +pub fn list(root: &Path) -> Listing { + let start = std::time::Instant::now(); + list_with(root, &Limits::default(), &mut || start.elapsed()) +} + +/// What the folder `root` holds, within `limits`; `elapsed` says how long the listing has taken. +/// Each level is sorted by label without regard to case, then by label, then by file name, with +/// scripts and subfolders mixed. Two rows of one level never share a label unless both were cut: +/// a script whose label another row has shows its whole file name. +pub fn list_with(root: &Path, limits: &Limits, elapsed: &mut dyn FnMut() -> Duration) -> Listing { + let unreadable = Listing { unreadable: true, ..Listing::default() }; + match std::fs::symlink_metadata(root) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Listing::default(), + Err(_) => return unreadable, + Ok(m) if m.file_type().is_symlink() || !m.is_dir() => return unreadable, + Ok(_) => {} + } + // Paths leave as text (menu params, `script.listInstalled`). + if root.to_str().is_none() { + return unreadable; + } + let mut scan = Scan { limits, elapsed, visited: 0, scripts: 0, folders: 0, truncated: false, stopped: false }; + match scan.level(root, 0) { + Some(entries) => Listing { entries, truncated: scan.truncated, unreadable: false }, + None => unreadable, + } +} + +/// A script or subfolder found in one folder, before the subfolders are read. +struct Found { + name: String, + label: String, + folder: bool, +} + +struct Scan<'a> { + limits: &'a Limits, + elapsed: &'a mut dyn FnMut() -> Duration, + visited: usize, + scripts: usize, + folders: usize, + truncated: bool, + /// The entry or time limit was reached: nothing more is read. + stopped: bool, +} + +impl Scan<'_> { + /// The rows of the folder `dir`, which is `depth` levels below the Scripts folder; `None` when + /// it cannot be read. + fn level(&mut self, dir: &Path, depth: usize) -> Option> { + let mut found: Vec = vec![]; + for entry in std::fs::read_dir(dir).ok()? { + if self.visited >= self.limits.entries || (self.elapsed)() > self.limits.time { + (self.truncated, self.stopped) = (true, true); + break; + } + self.visited += 1; + let Ok(entry) = entry else { + self.truncated = true; + continue; + }; + let Ok(name) = entry.file_name().into_string() else { continue }; + let Ok(kind) = entry.file_type() else { + self.truncated = true; + continue; + }; + if name.len() > MAX_NAME_BYTES || name.starts_with('.') || kind.is_symlink() || hidden(&entry) { + continue; + } + if kind.is_dir() { + found.push(Found { label: shown(&name), name, folder: true }); + } else if kind.is_file() && runs(&name) { + if self.scripts >= self.limits.scripts { + self.truncated = true; + continue; + } + self.scripts += 1; + let stem = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem); + found.push(Found { label: shown(stem), name, folder: false }); + } + } + // A script whose label another row of this folder has shows its whole file name, until no + // label is shared. Each pass gives at least one more script its whole file name, and names + // are unique within a folder, so this ends within `found.len()` passes. + loop { + let mut count = std::collections::HashMap::::new(); + for f in &found { + *count.entry(f.label.clone()).or_default() += 1; + } + let mut renamed = false; + for f in found.iter_mut().filter(|f| !f.folder) { + if count.get(&f.label).is_some_and(|n| *n > 1) { + let whole = shown(&f.name); + if f.label != whole { + f.label = whole; + renamed = true; + } + } + } + if !renamed { + break; + } + } + found.sort_by(|a, b| (a.label.to_lowercase(), &a.label, &a.name).cmp(&(b.label.to_lowercase(), &b.label, &b.name))); + let mut out = Vec::with_capacity(found.len()); + for f in found { + let path = dir.join(&f.name); + if !f.folder { + out.push(Entry::Script { label: f.label, path }); + continue; + } + if self.stopped { + continue; + } + if depth >= self.limits.depth || self.folders >= self.limits.folders { + self.truncated = true; + continue; + } + self.folders += 1; + match self.level(&path, depth + 1) { + Some(entries) if entries.is_empty() => {} + Some(entries) => out.push(Entry::Folder { label: f.label, entries }), + None => self.truncated = true, + } + } + Some(out) + } +} + +/// `name` as a menu shows it: control and bidirectional formatting characters written as escapes, +/// and at most [`MAX_LABEL_CHARS`] characters. +fn shown(name: &str) -> String { + vectorcraft_plugins::permissions::display_text(name, MAX_LABEL_CHARS) +} + +/// Does Windows mark `entry` hidden or system? +fn hidden(entry: &std::fs::DirEntry) -> bool { + entry.metadata().is_ok_and(|m| hidden_meta(&m)) +} + +/// Does Windows mark the file or folder of `m` hidden or system? +#[cfg(windows)] +fn hidden_meta(m: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + const HIDDEN_OR_SYSTEM: u32 = 0x2 | 0x4; + m.file_attributes() & HIDDEN_OR_SYSTEM != 0 +} + +/// Elsewhere a hidden file is one whose name starts with a dot, which the listing leaves out by name. +#[cfg(not(windows))] +fn hidden_meta(_: &std::fs::Metadata) -> bool { + false +} + +/// Is `path` a folder (`folder`) or a file, and not a link? With `shown`, also not one Windows +/// marks hidden or system, which a listing leaves out. +fn plain(path: &Path, folder: bool, shown: bool) -> bool { + std::fs::symlink_metadata(path) + .is_ok_and(|m| !m.file_type().is_symlink() && (if folder { m.is_dir() } else { m.is_file() }) && !(shown && hidden_meta(&m))) +} + +/// Is `path` a script a listing of `root` can hold now: a `.js` or `.jsx` file at most +/// [`MAX_DEPTH`] folders below `root`, where `root`, each folder on the way and the file are there +/// and none is a link, and neither the file nor a folder below `root` is marked hidden or system? +pub fn holds(root: &Path, path: &Path) -> bool { + let Ok(rest) = path.strip_prefix(root) else { return false }; + let names: Option> = rest + .components() + .map(|c| match c { + Component::Normal(name) => name.to_str().filter(|n| !n.starts_with('.') && n.len() <= MAX_NAME_BYTES), + _ => None, + }) + .collect(); + let Some(names) = names else { return false }; + let Some((file, folders)) = names.split_last() else { return false }; + if folders.len() > MAX_DEPTH || !runs(file) || !plain(root, true, false) { + return false; + } + let mut at = root.to_path_buf(); + for folder in folders { + at.push(folder); + if !plain(&at, true, true) { + return false; + } + } + at.push(file); + plain(&at, false, true) +} + +/// The first `max` bytes of the script at `path`, or `None` when [`holds`] does not hold for it +/// before the read and again after it, or it cannot be read. The two checks leave no time in which +/// a link stood in for the file or one of its folders for the whole read; a program that swaps one +/// in and back out between a check and the open can still have another file read. +pub fn read(root: &Path, path: &Path, max: usize) -> Option> { + if !holds(root, path) { + return None; + } + let file = std::fs::File::open(path).ok()?; + if !file.metadata().is_ok_and(|m| m.is_file()) { + return None; + } + let mut bytes = vec![]; + file.take(max as u64).read_to_end(&mut bytes).ok()?; + holds(root, path).then_some(bytes) +} diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 8f1918758..f2e4cc5ba 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -50,6 +50,8 @@ use crate::{ Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit, }; +pub mod folder; + /// The most bytes of source a run takes, as a file or as `source`. pub const MAX_SOURCE_BYTES: usize = 8 << 20; /// The instructions a run may execute when `maxFuel` is left out. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 898fec4e4..e34eb711b 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -86,6 +86,7 @@ impl Session { profile_dir: None, color_settings_file: None, plugin_dir: None, + scripts_dir: None, ignored_plugin_grants: Default::default(), plugin_output: None, plugin_install_approval: None, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c55327895..296b66bca 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -151,6 +151,7 @@ mod save; mod saveoptions; mod scalestrokes; mod script_console; +mod script_folder; mod script_include; mod script_run; mod scripts; diff --git a/crates/engine/src/tests/script_folder.rs b/crates/engine/src/tests/script_folder.rs new file mode 100644 index 000000000..b176b4f14 --- /dev/null +++ b/crates/engine/src/tests/script_folder.rs @@ -0,0 +1,324 @@ +//! The Scripts folder: what a listing holds and leaves out, its order and labels, its limits, the +//! read of a listed script, `script.listInstalled` and the automation read root. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::json; + +use crate::script::folder::{self, Entry, Limits, Listing}; +use crate::*; + +/// A fresh folder for the files of test `name`, holding an empty `Scripts` folder. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-script-folder-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Scripts")).unwrap(); + dir +} + +/// Writes the empty file `name` (folders included) under `root`. +fn touch(root: &Path, name: &str) -> PathBuf { + let path = root.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "").unwrap(); + path +} + +/// Makes `link` lead to the folder `target`: a symbolic link, or on Windows a junction, which needs +/// no privilege. +fn link_folder(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().unwrap(); + assert!(made.status.success(), "mklink /J: {}", String::from_utf8_lossy(&made.stderr)); + } +} + +/// A listing's rows as text: `label`, or `label/` followed by the folder's rows in brackets. +fn rows(entries: &[Entry]) -> String { + let row = |e: &Entry| match e { + Entry::Script { label, .. } => label.clone(), + Entry::Folder { label, entries } => format!("{label}/[{}]", rows(entries)), + }; + entries.iter().map(row).collect::>().join(", ") +} + +/// A listing within `limits` that takes no time. +fn list(root: &Path, limits: Limits) -> Listing { + folder::list_with(root, &limits, &mut || Duration::ZERO) +} + +#[test] +fn scripts_and_subfolders_are_listed_by_name_without_the_extension() { + let dir = folder("names"); + let root = dir.join("Scripts"); + for name in + ["b.js", "A.JSX", "Dessin (été) #1.jsx", "v1.2 draft.Js", "Zeta/inner.jsx", "Zeta/Deep/x.js", "notes.txt", "x.jsxbin", "lib.jsxinc", "js"] + { + touch(&root, name); + } + std::fs::create_dir_all(root.join("Empty/Also Empty")).unwrap(); + let listing = folder::list(&root); + assert_eq!(rows(&listing.entries), "A, b, Dessin (été) #1, v1.2 draft, Zeta/[Deep/[x], inner]"); + assert!(!listing.truncated && !listing.unreadable); + // Each script keeps the path the folder gave, whatever its label. + let paths: Vec<_> = + listing.scripts().into_iter().map(|(folders, label, path)| (folders.join("/"), label.to_string(), path.to_path_buf())).collect(); + assert_eq!(paths[0], (String::new(), "A".to_string(), root.join("A.JSX"))); + assert_eq!(paths[4], ("Zeta/Deep".to_string(), "x".to_string(), root.join("Zeta").join("Deep").join("x.js"))); + assert_eq!(paths.len(), 6); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_script_whose_label_another_row_has_shows_its_whole_file_name() { + let dir = folder("same"); + let root = dir.join("Scripts"); + for name in ["tidy.js", "tidy.jsx", "tidy.js.js", "tidy/x.js", "alone.jsx"] { + touch(&root, name); + } + // `tidy.js.js` is labeled `tidy.js` until `tidy.js` takes that label, then shows its whole name. + assert_eq!(rows(&folder::list(&root).entries), "alone, tidy/[x], tidy.js, tidy.js.js, tidy.jsx"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn control_characters_in_a_name_are_written_as_escapes_and_a_long_label_is_cut() { + let dir = folder("escapes"); + let root = dir.join("Scripts"); + // A right-to-left override, which would show the name backwards. + touch(&root, "a\u{202E}sj.js"); + let long = "n".repeat(200); + touch(&root, &format!("{long}.jsx")); + let listing = folder::list(&root); + let labels: Vec<&str> = listing.scripts().into_iter().map(|(_, label, _)| label).collect(); + assert_eq!(labels[0], "a\\u{202E}sj"); + assert_eq!(labels[1], format!("{}…", "n".repeat(folder::MAX_LABEL_CHARS - 1))); + assert!(folder::holds(&root, &root.join(format!("{long}.jsx")))); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn hidden_names_and_links_are_left_out() { + let dir = folder("hidden"); + let root = dir.join("Scripts"); + touch(&root, "shown.js"); + touch(&root, ".hidden.js"); + touch(&root, ".git/hook.js"); + let outside = touch(&dir, "Outside/secret.js"); + // A link out of the folder, and one back to the folder itself. + link_folder(outside.parent().unwrap(), &root.join("Out")); + link_folder(&root, &root.join("Loop")); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, root.join("linked.js")).unwrap(); + #[cfg(windows)] + { + let marked = touch(&root, "marked.js"); + assert!(std::process::Command::new("attrib").arg("+h").arg(&marked).status().unwrap().success()); + } + let listing = folder::list(&root); + assert_eq!(rows(&listing.entries), "shown"); + assert!(!listing.truncated && !listing.unreadable); + // Nor is a file behind a link read as a listed script. + assert!(folder::read(&root, &root.join("shown.js"), 10).is_some()); + for path in [root.join("Out").join("secret.js"), root.join("Loop").join("shown.js"), root.join(".hidden.js"), root.join("linked.js")] { + assert!(folder::read(&root, &path, 10).is_none(), "{}", path.display()); + } + // A script, or the folder it is in, that Windows marks hidden after a listing is not read. + #[cfg(windows)] + { + assert!(folder::read(&root, &root.join("marked.js"), 10).is_none()); + let inner = touch(&root, "Sub/inner.js"); + assert!(folder::read(&root, &inner, 10).is_some()); + assert!(std::process::Command::new("attrib").arg("+h").arg(root.join("Sub")).status().unwrap().success()); + assert!(folder::read(&root, &inner, 10).is_none()); + } + let _ = std::fs::remove_dir_all(root.join("Out")); + let _ = std::fs::remove_dir_all(root.join("Loop")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_folder_that_is_missing_is_empty_and_one_that_is_a_file_or_a_link_is_unreadable() { + let dir = folder("root"); + let root = dir.join("Scripts"); + touch(&root, "a.js"); + assert_eq!(folder::list(&dir.join("None")), Listing::default()); + let file = touch(&dir, "file"); + assert_eq!(folder::list(&file), Listing { unreadable: true, ..Listing::default() }); + let link = dir.join("Link"); + link_folder(&root, &link); + assert_eq!(folder::list(&link), Listing { unreadable: true, ..Listing::default() }); + assert!(folder::read(&link, &link.join("a.js"), 10).is_none()); + let _ = std::fs::remove_dir_all(&link); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn each_limit_stops_a_listing_with_what_it_found() { + let dir = folder("limits"); + let root = dir.join("Scripts"); + for n in 0..10 { + touch(&root, &format!("s{n}.js")); + } + let all = Limits::default(); + let entries = list(&root, Limits { entries: 4, ..all }); + assert_eq!((entries.scripts().len(), entries.truncated), (4, true)); + let scripts = list(&root, Limits { scripts: 3, ..all }); + assert_eq!((scripts.scripts().len(), scripts.truncated), (3, true)); + // The clock passes the time limit at the third entry. + let mut calls = 0; + let timed = folder::list_with(&root, &all, &mut || { + calls += 1; + if calls > 2 { all.time + Duration::from_millis(1) } else { Duration::ZERO } + }); + assert_eq!((timed.scripts().len(), timed.truncated), (2, true)); + let whole = list(&root, all); + assert_eq!((whole.scripts().len(), whole.truncated), (10, false)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn subfolders_past_the_fourth_level_or_the_folder_limit_are_left_out() { + let dir = folder("depth"); + let root = dir.join("Scripts"); + touch(&root, "1/2/3/4/kept.js"); + touch(&root, "1/2/3/4/5/deeper.js"); + let listing = folder::list(&root); + assert_eq!(rows(&listing.entries), "1/[2/[3/[4/[kept]]]]"); + assert!(listing.truncated); + assert!(folder::holds(&root, &root.join("1/2/3/4/kept.js"))); + assert!(!folder::holds(&root, &root.join("1/2/3/4/5/deeper.js"))); + let two = folder("folders"); + let root = two.join("Scripts"); + touch(&root, "a/x.js"); + touch(&root, "b/y.js"); + let listing = list(&root, Limits { folders: 1, ..Limits::default() }); + assert_eq!(rows(&listing.entries), "a/[x]"); + assert!(listing.truncated); + assert!(!folder::list(&root).truncated); + let _ = std::fs::remove_dir_all(dir); + let _ = std::fs::remove_dir_all(two); +} + +#[test] +fn a_folder_with_more_scripts_than_the_limit_lists_that_many() { + let dir = folder("many"); + let root = dir.join("Scripts"); + for n in 0..folder::MAX_SCRIPTS + 40 { + touch(&root, &format!("s{n:04}.js")); + } + let listing = list(&root, Limits::default()); + assert_eq!((listing.scripts().len(), listing.truncated), (folder::MAX_SCRIPTS, true)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_read_takes_only_a_script_that_is_still_a_file_under_the_folder() { + let dir = folder("read"); + let root = dir.join("Scripts"); + let script = touch(&root, "Sub/draw.jsx"); + std::fs::write(&script, "vc.log(1);").unwrap(); + assert_eq!(folder::read(&root, &script, 100).as_deref(), Some(&b"vc.log(1);"[..])); + assert_eq!(folder::read(&root, &script, 4).as_deref(), Some(&b"vc.l"[..])); + let outside = touch(&dir, "outside.js"); + touch(&root, "notes.txt"); + for path in [outside, root.join("Sub").join("..").join("..").join("outside.js"), root.join("notes.txt"), root.join("Sub"), root.join("gone.js")] { + assert!(folder::read(&root, &path, 100).is_none(), "{}", path.display()); + } + // A folder that took the script's place is not a script. + std::fs::remove_file(&script).unwrap(); + std::fs::create_dir(&script).unwrap(); + assert!(folder::read(&root, &script, 100).is_none()); + // Nor is the script read once a link stands in for its folder. + let other = touch(&dir, "Other/draw.jsx"); + std::fs::remove_dir_all(root.join("Sub")).unwrap(); + link_folder(other.parent().unwrap(), &root.join("Sub")); + assert!(folder::read(&root, &root.join("Sub").join("draw.jsx"), 100).is_none()); + let _ = std::fs::remove_dir_all(root.join("Sub")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn list_installed_gives_the_folder_and_its_scripts_in_menu_order() { + let dir = folder("command"); + let root = dir.join("Scripts"); + touch(&root, "Tidy Layers.jsx"); + touch(&root, "Export/All Artboards.js"); + let mut s = Session::new(); + assert_eq!( + s.execute("script.listInstalled", &json!({})).unwrap(), + json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false}) + ); + s.attach_scripts_dir(root.clone()); + let text = |p: &Path| p.to_string_lossy().into_owned(); + assert_eq!( + s.execute("script.listInstalled", &json!({})).unwrap(), + json!({ + "folder": text(&root), + "scripts": [ + {"path": text(&root.join("Export").join("All Artboards.js")), "label": "All Artboards", "folders": ["Export"]}, + {"path": text(&root.join("Tidy Layers.jsx")), "label": "Tidy Layers", "folders": []}, + ], + "truncated": false, + "unreadable": false, + }) + ); + assert!(!find_command("script.listInstalled").unwrap().journal); + assert!(s.execute("script.listInstalled", &json!({"folder": "x"})).is_err()); + // A folder that is not there yet lists nothing, and the listing does not make it. + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(s.execute("script.listInstalled", &json!({})).unwrap()["scripts"], json!([])); + assert!(!root.exists()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_installed_script_is_read_as_script_run_reads_a_file() { + let dir = folder("installed"); + let root = dir.join("Scripts"); + let text = |p: &Path| p.to_string_lossy().into_owned(); + let mut s = Session::new(); + let script = touch(&root, "draw.jsx"); + std::fs::write(&script, "\u{feff}vc.log(1);").unwrap(); + assert_eq!(s.installed_script(&text(&script)).unwrap_err().to_string(), cmd::scripts::NOT_LISTED, "no folder is attached"); + s.attach_scripts_dir(root.clone()); + assert_eq!(s.installed_script(&text(&script)).unwrap(), "vc.log(1);"); + std::fs::write(&script, "@JSXBIN@ES@2.0@").unwrap(); + assert!(s.installed_script(&text(&script)).unwrap_err().to_string().contains("compiled script")); + std::fs::write(&script, [0xff, 0xfe]).unwrap(); + assert_eq!(s.installed_script(&text(&script)).unwrap_err().to_string(), "the script is not UTF-8 text"); + std::fs::write(&script, vec![b' '; script::MAX_SOURCE_BYTES + 1]).unwrap(); + assert_eq!(s.installed_script(&text(&script)).unwrap_err().to_string(), cmd::scripts::TOO_LARGE); + std::fs::remove_file(&script).unwrap(); + assert_eq!(s.installed_script(&text(&script)).unwrap_err().to_string(), cmd::scripts::NOT_LISTED); + let outside = touch(&dir, "outside.js"); + assert_eq!(s.installed_script(&text(&outside)).unwrap_err().to_string(), cmd::scripts::NOT_LISTED); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() { + let dir = folder("roots"); + let root = dir.join("Scripts"); + let script = touch(&root, "draw.js"); + let work = dir.join("work"); + std::fs::create_dir_all(&work).unwrap(); + let mut s = Session::new(); + s.attach_scripts_dir(root.clone()); + let roots = crate::file_access::AutomationRoots::new(&[&work], &[]).unwrap().unwrap(); + let e = crate::file_access::confine(Some(&roots), || s.execute("script.listInstalled", &json!({}))).unwrap_err(); + assert!(e.to_string().contains("outside the read root"), "{e}"); + let e = crate::file_access::confine(Some(&roots), || s.installed_script(&script.to_string_lossy())).unwrap_err(); + assert!(e.to_string().contains("outside the read root"), "{e}"); + // A read root that holds the folder lets both through. + let roots = crate::file_access::AutomationRoots::new(&[&dir], &[]).unwrap().unwrap(); + crate::file_access::confine(Some(&roots), || { + assert_eq!(s.execute("script.listInstalled", &json!({})).unwrap()["scripts"][0]["label"], "draw"); + assert_eq!(s.installed_script(&script.to_string_lossy()).unwrap(), ""); + }); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1da653311..667b39aad 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -511,6 +511,8 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result // fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the // PDF are skipped without being kept. fn within_budgets(max_nodes: usize, max_values: usize, read: impl FnOnce() -> T) -> T { + #[cfg(test)] + DOCUMENT_READS.with(|n| n.set(n.get() + 1)); vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, || { vectorcraft_doc::node::with_node_budget(max_nodes, || vectorcraft_doc::node::with_buffered_value_budget(max_values, read)) }) @@ -527,9 +529,10 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result let (mut f, pdf_compatible) = match within_budgets(max_nodes, max_values, || Contents::::read_with_pdf_preference(file.json())) { Ok(read) => read, // That pass refuses a `pdfCompatible` that is not a boolean, and any other data error. - // The file is read again without the preference, and only then is its JSON scanned for - // the preference a second time, so such a file opens as before. - Err(ReadError::Json(e)) if e.is_data() => { + // When it stopped at the preference, the file is read again without it, and only then is + // its JSON scanned for the preference a second time, so such a file opens as before. Any + // other error, such as a document past a budget, is returned without a second read. + Err(ReadError::Json(e)) if e.is_data() && refused_preference_at(file.json(), &e) => { let f = within_budgets(max_nodes, max_values, || Contents::::read(file.json())).map_err(read_error)?; let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; (f, pdf_compatible) @@ -631,6 +634,51 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result thread_local! { /// Tests: how many times a file's JSON was scanned again for its PDF preference on this thread. static PDF_MEMBER_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Tests: how many times a file's document was read on this thread. + static DOCUMENT_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Whether reading `json`'s `pdfCompatible` as a boolean or null fails with a data error at the +/// line and column of `e`, that is, whether the read that gave `e` stopped at the preference. Only +/// the bytes up to that position, and the one there, are read, and they are skipped without being +/// kept. +fn refused_preference_at(json: &[u8], e: &serde_json::Error) -> bool { + // serde_json gives a 1-based line and the bytes before the position on that line. + let Some(line_start) = (match e.line() { + 0 => None, + 1 => Some(0), + n => json.iter().enumerate().filter(|(_, b)| **b == b'\n').nth(n - 2).map(|(i, _)| i + 1), + }) else { + return false; + }; + let end = line_start.saturating_add(e.column()).saturating_add(1).min(json.len()); + struct StrictPreference; + impl<'de> Visitor<'de> for StrictPreference { + type Value = (); + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a native file envelope") + } + + fn visit_map>(self, mut map: A) -> Result<(), A::Error> { + while let Some(key) = map.next_key::()? { + if key == "pdfCompatible" { + map.next_value::>()?; + } else { + map.next_value::()?; + } + } + Ok(()) + } + + fn visit_seq>(self, mut seq: A) -> Result<(), A::Error> { + while seq.next_element::()?.is_some() {} + Ok(()) + } + } + serde_json::Deserializer::from_slice(&json[..end]) + .deserialize_any(StrictPreference) + .is_err_and(|s| s.is_data() && s.line() == e.line() && s.column() == e.column()) } /// The saved PDF preference, falling back to the presence of its payload without retaining or @@ -842,9 +890,44 @@ mod tests { value["pdfCompatible"] = odd.clone(); assert_eq!(scans_for(&serde_json::to_vec(&value).unwrap()), (false, 1), "{odd}"); } - // A document that can't be read still fails, after the second read. + // A document that can't be read still fails. Read before the preference, it is not read + // a second time. value["document"] = serde_json::json!("not a document"); + DOCUMENT_READS.with(|n| n.set(0)); assert!(load_info(&serde_json::to_vec(&value).unwrap()).is_err()); + assert_eq!(DOCUMENT_READS.with(|n| n.get()), 1); + // Read after a preference that is not a boolean, it fails on the second read, which is + // the first to reach it. + let mut first = serde_json::to_vec(&value).unwrap(); + assert!(first.starts_with(br#"{"document""#)); + first.splice(1..1, br#""pdfCompatible":"yes","#.iter().copied()); + DOCUMENT_READS.with(|n| n.set(0)); + assert!(load_info(&first).is_err()); + assert_eq!(DOCUMENT_READS.with(|n| n.get()), 2); + } + + /// A document past a read budget is refused after one read, also in a file whose preference + /// is not a boolean. + #[test] + fn a_document_past_a_budget_is_read_once() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + for i in 0..20 { + let id = d.alloc_id(); + d.insert(Some(l), i, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + } + let n = d.node_count(); + let mut value: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + for preference in [serde_json::Value::Null, serde_json::json!(true), serde_json::json!("yes")] { + value["pdfCompatible"] = preference.clone(); + let bytes = serde_json::to_vec(&value).unwrap(); + DOCUMENT_READS.with(|r| r.set(0)); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bytes, n - 1, vectorcraft_doc::node::MAX_BUFFERED_VALUES) else { + panic!("one object past the budget must be refused") + }; + assert!(why.contains(&format!("more than {} objects", n - 1)), "{why}"); + assert_eq!(DOCUMENT_READS.with(|r| r.get()), 1, "{preference}"); + } } #[test] diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index a5bfcbf26..431e0aa97 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -105,8 +105,11 @@ impl Renderer { } } - /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). + /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). Type + /// set in one of the document's composite fonts draws in the document's definition, also when + /// no session has registered it (a document that is not in front, or read from a file). pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); self.render_as(doc, width, height, view, opts, false, 1) } @@ -258,8 +261,7 @@ impl Renderer { }; // `reset` keeps the threshold of the previous render: set it every time. ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; - self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() }; + self.raster = raster_settings(opts); if opts.artboards && !opts.outline { // Paper: white on screen, no ink on the ink planes. ctx.set_transform(f.view); @@ -340,6 +342,7 @@ impl Renderer { pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec { let (w, h) = region_pixels(region, scale); let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); self.render_as(doc, w, h, view, opts, true, 1).pixels } @@ -356,22 +359,62 @@ impl Renderer { /// [`Self::render_node`] with `opts` (e.g. [`RenderOptions::mark_undecoded_images`]). pub fn render_node_with(&mut self, doc: &Document, n: &Arc, w: u16, h: u16, view: Affine, opts: &RenderOptions) -> Option { + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); self.check_plugins(doc); self.check_symbols(doc); - let inv = view.inverse(); - let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let pixels = self.node_pixels(doc, n, (w, h), view, visible, opts, false, 1); + Some(Rendered { width: w as u32, height: h as u32, pixels }) + } + + /// Node `n` drawn into `w`×`h` transparent pixels through `view`, culled to `visible` (document + /// space); `forced` draws it whatever its visibility and bounds. With [`RenderOptions::supersample`] + /// and Art Optimized edges the art is drawn with hard edges at several times the size and + /// averaged down, and [`RenderOptions::precise`] picks the quality render mode, as in a page + /// render ([`Self::render_as`], [`Self::draw_frame`]). `cell` is as in [`Self::render_as`]. + #[allow(clippy::too_many_arguments)] + fn node_pixels( + &mut self, + doc: &Document, + n: &Arc, + (w, h): (u16, u16), + view: Affine, + visible: Rect, + opts: &RenderOptions, + forced: bool, + cell: u32, + ) -> Vec { + if opts.supersample + && opts.anti_alias == AntiAlias::Art + && let Some(k) = supersample_factor(u32::from(w), u32::from(h)) + { + let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; + // `supersample_factor` keeps each side within MAX_CONTEXT_SIDE, so it fits in a u16. + let (bw, bh) = (u32::from(w) * k, u32::from(h) * k); + let hairline = std::mem::replace(&mut self.hairline, f64::from(k)); + let big = self.node_pixels(doc, n, (bw as u16, bh as u16), Affine::scale(f64::from(k)) * view, visible, &hard, forced, k); + self.hairline = hairline; + return downsample(&big, bw as usize, bh as usize, k as usize); + } let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); let mut ctx = single_threaded_context(w, h); ctx.set_aliasing_threshold(opts.anti_alias.threshold()); let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output: view, plates: None }; + let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view; + let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output, plates: None }; + let raster = std::mem::replace(&mut self.raster, raster_settings(opts)); self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); - self.draw_arc(&mut ctx, &frame, n); - let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); + if forced { + self.draw_forced_arc(&mut ctx, &frame, n); + } else { + self.draw_arc(&mut ctx, &frame, n); + } + let pm = self.finish(&mut ctx, self.raster); self.end_frame(); - Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }) + self.raster = raster; + pm.data_as_u8_slice().to_vec() } /// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into @@ -395,6 +438,7 @@ impl Renderer { background: Option<[u8; 4]>, opts: &RenderOptions, ) -> Option { + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); self.check_plugins(doc); self.check_symbols(doc); let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?; @@ -402,28 +446,7 @@ impl Renderer { // A 1-pixel margin on each side, unless the thumbnail is too small to keep one. let s = (w as f64 - 2.0).max(1.0) / b.width().max(b.height()).max(1e-6); let view = Affine::translate((w as f64 / 2.0, w as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); - let mut ctx = single_threaded_context(w, w); - ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - let symbols = doc.symbol_boxes().quick(); - let frame = Frame { - mt: false, - doc, - symbols: &symbols, - view, - visible: b.inflate(1.0, 1.0), - px: 1.0 / s, - opts, - ink: Ink::Display, - output: view, - plates: None, - }; - self.begin_frame(); - (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); - self.clip_paths.clear(); - self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone())); - let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); - self.end_frame(); - let mut pixels = pm.data_as_u8_slice().to_vec(); + let mut pixels = self.node_pixels(doc, &Arc::new(n.clone()), (w, w), view, b.inflate(1.0, 1.0), opts, true, 1); if let Some(bg) = background { over_background(&mut pixels, bg); } @@ -431,6 +454,12 @@ impl Renderer { } } +/// The rasterizer settings for `opts`: the quality render mode when [`RenderOptions::precise`] is set. +fn raster_settings(opts: &RenderOptions) -> vello_cpu::RasterizerSettings { + let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; + vello_cpu::RasterizerSettings { render_mode, ..Default::default() } +} + /// Premultiplied RGBA `pixels` drawn over the solid color `background` (`[r, g, b, a]`, straight). pub(crate) fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) { let alpha = u32::from(a); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index a12e79e86..dd7198d53 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -952,6 +952,43 @@ fn node_renders_follow_the_anti_alias_option() { assert!(drawn(&thumb) && !partial(&thumb), "render_node_thumbnail_with: every alpha is 0 or 255"); } +/// Node renders follow `supersample` and `precise` as a page render does: two rectangles that meet +/// at x = 50.37 leave no seam when supersampled, and a node render gives the pixels of a page +/// render of the same art with the same options. +#[test] +fn node_renders_follow_the_supersample_and_precise_options() { + let mut d = Document::new(100.0, 100.0); + let left = rect_node(&mut d, Rect::new(10.0, 10.0, 50.37, 90.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + let right = rect_node(&mut d, Rect::new(50.37, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(0.2, 0.6, 0.9)), Paint::None, 0.0); + // Blended at half opacity over both, so the group composites offscreen. + let mut over = rect_node(&mut d, Rect::new(30.0, 60.0, 70.0, 80.0), Paint::solid(Color::rgb(0.9, 0.7, 0.1)), Paint::None, 0.0); + over.opacity = 0.5; + over.blend = vectorcraft_color::BlendMode::Multiply; + let group = Node::group(d.alloc_id(), vec![Arc::new(left), Arc::new(right), Arc::new(over)]); + let l = d.layers[0].id; + d.insert(Some(l), 0, group.clone()).unwrap(); + let node = Arc::new(group.clone()); + let page = |opts: &RenderOptions| RenderOptions { background: None, artboards: false, ..opts.clone() }; + let mut r = Renderer::new(); + for (supersample, precise) in [(false, false), (true, false), (false, true), (true, true)] { + let opts = RenderOptions { supersample, precise, ..Default::default() }; + let case = format!("supersample {supersample}, precise {precise}"); + let img = r.render_node_with(&d, &node, 100, 100, Affine::IDENTITY, &opts).unwrap(); + let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &page(&opts)); + assert!(img.pixels == whole.pixels, "{case}: render_node_with draws as a page render does"); + // Pixel column 50 at row 50: the black rectangle covers 37% of it and the blue one the rest. + let seam = img.pixels[(50 * 100 + 50) * 4 + 3]; + assert_eq!(seam == 255, supersample, "{case}: render_node_with seam alpha {seam}"); + // A 37-pixel thumbnail draws 0.4375 pixels a point, the seam at x = 18.66. + let thumb = r.render_node_thumbnail_with(&d, &group, 37, None, &opts).unwrap(); + let view = Affine::translate((18.5, 18.5)) * Affine::scale(0.4375) * Affine::translate((-50.0, -50.0)); + let whole = Renderer::new().render(&d, 37, 37, view, &page(&opts)); + assert!(thumb.pixels == whole.pixels, "{case}: the thumbnail draws as a page render does"); + let seam = thumb.pixels[(18 * 37 + 18) * 4 + 3]; + assert_eq!(seam == 255, supersample, "{case}: thumbnail seam alpha {seam}"); + } +} + #[test] fn opacity_mask_with_undecodable_image_leaves_out_object() { use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask}; diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 82c5c77ff..4ead4a5c1 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -138,6 +138,50 @@ fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() { assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty()); } +/// Type in one of a document's composite fonts draws in the document's definition when nothing has +/// registered it: in a render, an ink render, a thumbnail and a placed document's art, as type set +/// in the part's font draws. +/// The font database is left without it afterwards. +#[test] +fn a_documents_own_composite_font_draws_without_being_registered() { + use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts}; + let _adding = adding_fonts(); + const NAME: &str = "CF Unregistered"; + let db = vectorcraft_text::FontDb::global(); + let typed = |family: &str| { + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style)))); + let l = d.layers[0].id; + let id = d.insert(Some(l), 0, n).unwrap(); + (d, id) + }; + let (mut composite, id) = typed(NAME); + let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); + parts.roman = CompositePart::new("JetBrains Mono", "Regular"); + composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts }); + let (plain, plain_id) = typed("JetBrains Mono"); + assert!(db.composite_font(NAME).is_none()); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let view = Affine::IDENTITY; + assert_eq!( + Renderer::new().render(&composite, 300, 150, view, &opts).pixels, + Renderer::new().render(&plain, 300, 150, view, &opts).pixels, + "rendered" + ); + let region = Rect::new(0.0, 0.0, 300.0, 150.0); + assert_eq!( + Renderer::new().render_region_inks(&composite, region, 1.0, &opts), + Renderer::new().render_region_inks(&plain, region, 1.0, &opts), + "rendered as ink amounts" + ); + let thumbnail = |d: &Document, id| Renderer::new().render_thumbnail(d, id, 64).map(|r| r.pixels); + assert_eq!(thumbnail(&composite, id), thumbnail(&plain, plain_id), "thumbnail"); + let placed = |d: &Document| crate::placed_document::render_inside(&mut Renderer::new(), d, &d.layers[0], 300, 150, view).map(|r| r.pixels); + assert_eq!(placed(&composite), placed(&plain), "a placed document's art"); + assert!(db.composite_font(NAME).is_none(), "given back after each render"); +} + /// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes /// available: the glyph geometry kept for it is laid out again. #[test] diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 80614f909..8237bc877 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -341,6 +341,7 @@ impl VectorcraftApp { screen_color::poll(self, ctx); self.poll_finished_work(); crate::script_runs::poll(self); + crate::scripts_menu::poll(self, ctx); if self.session.script_running() && !self.session.script_waiting() { ctx.request_repaint(); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ee32cee33..33e3b8d8a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2279,6 +2279,8 @@ font-technology Variable Променливи Other Layers Други слоеве Other Libraries Други библиотеки Other Library… Друга библиотека… + Some scripts were not listed Някои скриптове не са изброени + The Scripts folder cannot be read Папката Scripts не може да бъде прочетена Other Script… Друг скрипт… Out of Gamut Warning Предупреждение за излизане извън цветовата гама Out of Web Color Warning Предупреждение за цвят извън уеб палитрата diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 0970a7092..35e272d82 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -62,6 +62,8 @@ Scripts Skripte Check Script… Provjeri skriptu… Check Script Provjeri skriptu + Some scripts were not listed Neke skripte nisu navedene + The Scripts folder cannot be read Folder Scripts se ne može pročitati Other Script… Druga skripta… Document Setup… Postavke dokumenta… Switch Units Promijeni jedinice diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 84b6833c4..4707088dc 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5201,6 +5201,9 @@ digits Malayalam Malaiàlam @msg the script is too large to run l'script és massa gran per executar-lo @msg a drag or an undo group is open; finish it before running a script hi ha un arrossegament o un grup de desfer obert; acabeu-lo abans d'executar un script @msg the script left a document that cannot be kept l'script ha deixat un document que no es pot conservar +@msg the script is no longer in the Scripts folder l'script ja no és a la carpeta Scripts + Some scripts were not listed Alguns scripts no s'han llistat + The Scripts folder cannot be read No es pot llegir la carpeta Scripts @msg the script's run is no longer available l'execució de l'script ja no està disponible @msg a failed command changed undo steps that could not be put back, so the script was stopped una ordre fallida ha canviat passos de desfer que no s'han pogut restaurar, així que l'script s'ha aturat @msg a recovery copy could not be written, so the script was not run no s'ha pogut escriure una còpia de recuperació, per tant l'script no s'ha executat diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 733a3e813..9de156d8b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4412,6 +4412,9 @@ imageTrace Outline Obrys @msg the script is too large to run skript je příliš velký na spuštění @msg a drag or an undo group is open; finish it before running a script je otevřené tažení nebo skupina kroků zpět; před spuštěním skriptu ji dokončete @msg the script left a document that cannot be kept skript zanechal dokument, který nelze zachovat +@msg the script is no longer in the Scripts folder skript už není ve složce Scripts + Some scripts were not listed Některé skripty nebyly uvedeny + The Scripts folder cannot be read Složku Scripts nelze přečíst @msg the script's run is no longer available běh skriptu již není k dispozici @msg a failed command changed undo steps that could not be put back, so the script was stopped neúspěšný příkaz změnil kroky zpět, které nebylo možné obnovit, proto byl skript zastaven @msg a recovery copy could not be written, so the script was not run záložní kopii se nepodařilo zapsat, proto skript nebyl spuštěn diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index d1871b2ed..ee7725eba 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -55,6 +55,8 @@ Scripts Scripter Check Script… Kontrollér script… Check Script Kontrollér script + Some scripts were not listed Nogle scripter blev ikke vist + The Scripts folder cannot be read Mappen Scripts kan ikke læses Other Script… Andet script… Document Setup… Dokumentopsætning… Switch Units Skift enheder diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8c75b25b6..a4e4f513a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4018,6 +4018,9 @@ theme Appearance Erscheinungsbild @msg the script is too large to run das Skript ist zu groß zum Ausführen @msg a drag or an undo group is open; finish it before running a script ein Ziehvorgang oder eine Rückgängig-Gruppe ist offen; schließen Sie sie ab, bevor Sie ein Skript ausführen @msg the script left a document that cannot be kept das Skript hat ein Dokument hinterlassen, das nicht behalten werden kann +@msg the script is no longer in the Scripts folder das Skript befindet sich nicht mehr im Ordner „Scripts“ + Some scripts were not listed Einige Skripte wurden nicht aufgeführt + The Scripts folder cannot be read Der Ordner „Scripts“ kann nicht gelesen werden @msg the script's run is no longer available der Lauf des Skripts ist nicht mehr verfügbar @msg a failed command changed undo steps that could not be put back, so the script was stopped ein fehlgeschlagener Befehl hat Rückgängig-Schritte geändert, die nicht wiederhergestellt werden konnten, daher wurde das Skript angehalten @msg a recovery copy could not be written, so the script was not run eine Wiederherstellungskopie konnte nicht geschrieben werden, daher wurde das Skript nicht ausgeführt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7813ee6cf..c8e427d9c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2258,6 +2258,9 @@ axis Both Και τα δύο @msg the script is too large to run το σενάριο είναι πολύ μεγάλο για εκτέλεση @msg a drag or an undo group is open; finish it before running a script είναι ανοιχτή μια μεταφορά ή ομάδα αναίρεσης· ολοκληρώστε την πριν εκτελέσετε σενάριο @msg the script left a document that cannot be kept το σενάριο άφησε ένα έγγραφο που δεν μπορεί να διατηρηθεί +@msg the script is no longer in the Scripts folder το σενάριο δεν βρίσκεται πλέον στον φάκελο Scripts + Some scripts were not listed Ορισμένα σενάρια δεν εμφανίζονται στη λίστα + The Scripts folder cannot be read Δεν είναι δυνατή η ανάγνωση του φακέλου Scripts @msg the script's run is no longer available η εκτέλεση του σεναρίου δεν είναι πλέον διαθέσιμη @msg a failed command changed undo steps that could not be put back, so the script was stopped μια εντολή που απέτυχε άλλαξε βήματα αναίρεσης που δεν ήταν δυνατό να επαναφερθούν, οπότε το σενάριο σταμάτησε @msg a recovery copy could not be written, so the script was not run δεν ήταν δυνατή η εγγραφή αντιγράφου ανάκτησης, οπότε το σενάριο δεν εκτελέστηκε diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d8bcecbc6..848e4cd7e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4102,6 +4102,9 @@ axis Both Ambos @msg the script is too large to run el script es demasiado grande para ejecutarlo @msg a drag or an undo group is open; finish it before running a script hay un arrastre o un grupo de deshacer abierto; termínelo antes de ejecutar un script @msg the script left a document that cannot be kept el script dejó un documento que no se puede conservar +@msg the script is no longer in the Scripts folder el script ya no está en la carpeta Scripts + Some scripts were not listed Algunos scripts no se han incluido en la lista + The Scripts folder cannot be read No se puede leer la carpeta Scripts @msg the script's run is no longer available la ejecución del script ya no está disponible @msg a failed command changed undo steps that could not be put back, so the script was stopped un comando fallido cambió pasos de deshacer que no se pudieron restaurar, así que el script se detuvo @msg a recovery copy could not be written, so the script was not run no se pudo escribir una copia de recuperación, así que el script no se ejecutó diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d3b9fb326..44bf2ed33 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4326,6 +4326,9 @@ imageTrace Outline Ääriviiva @msg the script is too large to run komentosarja on liian suuri suoritettavaksi @msg a drag or an undo group is open; finish it before running a script vetäminen tai kumousryhmä on kesken; viimeistele se ennen komentosarjan suorittamista @msg the script left a document that cannot be kept komentosarja jätti asiakirjan, jota ei voi säilyttää +@msg the script is no longer in the Scripts folder komentosarja ei ole enää Scripts-kansiossa + Some scripts were not listed Kaikkia komentosarjoja ei ole lueteltu + The Scripts folder cannot be read Scripts-kansiota ei voi lukea @msg the script's run is no longer available komentosarjan suoritus ei ole enää käytettävissä @msg a failed command changed undo steps that could not be put back, so the script was stopped epäonnistunut komento muutti kumoamisvaiheita, joita ei voitu palauttaa, joten komentosarja pysäytettiin @msg a recovery copy could not be written, so the script was not run palautuskopiota ei voitu kirjoittaa, joten komentosarjaa ei suoritettu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6db506204..acb89c073 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4091,6 +4091,9 @@ axis Both Les deux @msg the script is too large to run le script est trop volumineux pour être exécuté @msg a drag or an undo group is open; finish it before running a script un glissement ou un groupe d’annulation est ouvert ; terminez-le avant d’exécuter un script @msg the script left a document that cannot be kept le script a laissé un document qui ne peut pas être conservé +@msg the script is no longer in the Scripts folder le script ne se trouve plus dans le dossier Scripts + Some scripts were not listed Certains scripts ne sont pas répertoriés + The Scripts folder cannot be read Impossible de lire le dossier Scripts @msg the script's run is no longer available l’exécution du script n’est plus disponible @msg a failed command changed undo steps that could not be put back, so the script was stopped une commande en échec a modifié des étapes d’annulation qui n’ont pas pu être rétablies, le script a donc été arrêté @msg a recovery copy could not be written, so the script was not run une copie de récupération n’a pas pu être écrite, le script n’a donc pas été exécuté diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3fa226908..29e1d5092 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6191,6 +6191,9 @@ antiAlias Sharp शार्प @msg the script is too large to run स्क्रिप्ट चलाने के लिए बहुत बड़ी है @msg a drag or an undo group is open; finish it before running a script कोई ड्रैग या पूर्ववत समूह खुला है; स्क्रिप्ट चलाने से पहले उसे पूरा करें @msg the script left a document that cannot be kept स्क्रिप्ट ने ऐसा दस्तावेज़ छोड़ा है जिसे रखा नहीं जा सकता +@msg the script is no longer in the Scripts folder स्क्रिप्ट अब Scripts फ़ोल्डर में नहीं है + Some scripts were not listed कुछ स्क्रिप्ट सूचीबद्ध नहीं की गईं + The Scripts folder cannot be read Scripts फ़ोल्डर पढ़ा नहीं जा सकता @msg the script's run is no longer available स्क्रिप्ट का रन अब उपलब्ध नहीं है @msg a failed command changed undo steps that could not be put back, so the script was stopped एक विफल कमांड ने पूर्ववत चरण बदल दिए जिन्हें वापस नहीं रखा जा सका, इसलिए स्क्रिप्ट रोक दी गई @msg a recovery copy could not be written, so the script was not run रिकवरी प्रति नहीं लिखी जा सकी, इसलिए स्क्रिप्ट नहीं चलाई गई diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 27e22d0ad..0821f55a3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4899,6 +4899,9 @@ imageTrace Outline Körvonal @msg the script is too large to run a parancsfájl túl nagy a futtatáshoz @msg a drag or an undo group is open; finish it before running a script húzás vagy visszavonási csoport van folyamatban; fejezze be a parancsfájl futtatása előtt @msg the script left a document that cannot be kept a parancsfájl olyan dokumentumot hagyott hátra, amely nem tartható meg +@msg the script is no longer in the Scripts folder a parancsfájl már nincs a Scripts mappában + Some scripts were not listed Néhány szkript nem szerepel a listában + The Scripts folder cannot be read A Scripts mappa nem olvasható @msg the script's run is no longer available a parancsfájl futása már nem érhető el @msg a failed command changed undo steps that could not be put back, so the script was stopped egy sikertelen parancs olyan visszavonási lépéseket módosított, amelyeket nem lehetett visszaállítani, ezért a parancsfájl leállt @msg a recovery copy could not be written, so the script was not run a helyreállítási másolatot nem sikerült kiírni, ezért a parancsfájl nem futott le diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c4e97482a..5c5686750 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -55,6 +55,8 @@ Scripts Skrip Check Script… Periksa skrip… Check Script Periksa skrip + Some scripts were not listed Beberapa skrip tidak dicantumkan + The Scripts folder cannot be read Folder Scripts tidak dapat dibaca Other Script… Skrip lain… Document Setup… Pengaturan dokumen… Switch Units Ganti satuan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 987505a43..638e99ba8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4145,6 +4145,9 @@ axis Both Entrambi @msg the script is too large to run lo script è troppo grande per essere eseguito @msg a drag or an undo group is open; finish it before running a script è aperto un trascinamento o un gruppo di annullamento; completalo prima di eseguire uno script @msg the script left a document that cannot be kept lo script ha lasciato un documento che non può essere conservato +@msg the script is no longer in the Scripts folder lo script non è più nella cartella Scripts + Some scripts were not listed Alcuni script non sono stati elencati + The Scripts folder cannot be read Impossibile leggere la cartella Scripts @msg the script's run is no longer available l'esecuzione dello script non è più disponibile @msg a failed command changed undo steps that could not be put back, so the script was stopped un comando non riuscito ha modificato passaggi di annullamento che non è stato possibile ripristinare, quindi lo script è stato interrotto @msg a recovery copy could not be written, so the script was not run non è stato possibile scrivere una copia di recupero, quindi lo script non è stato eseguito diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d3c0b6603..871897158 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4875,6 +4875,9 @@ swatch Bright Violet ビビッドバイオレット @msg the script is too large to run スクリプトが大きすぎて実行できません @msg a drag or an undo group is open; finish it before running a script ドラッグまたは取り消しグループが開いています。スクリプトを実行する前に完了してください @msg the script left a document that cannot be kept スクリプトが保持できないドキュメントを残しました +@msg the script is no longer in the Scripts folder スクリプトは Scripts フォルダーにもうありません + Some scripts were not listed 一部のスクリプトは一覧に表示されていません + The Scripts folder cannot be read Scripts フォルダーを読み込めません @msg the script's run is no longer available スクリプトの実行は利用できなくなりました @msg a failed command changed undo steps that could not be put back, so the script was stopped 失敗したコマンドが元に戻せない取り消し手順を変更したため、スクリプトを停止しました @msg a recovery copy could not be written, so the script was not run 復元用のコピーを書き込めなかったため、スクリプトは実行されませんでした diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5a05ccec3..6df26b0d6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5190,6 +5190,9 @@ imageTrace Outline კონტური @msg the script is too large to run სკრიპტი გასაშვებად ძალიან დიდია @msg a drag or an undo group is open; finish it before running a script ღიაა გადათრევა ან გაუქმების ჯგუფი; დაასრულეთ სკრიპტის გაშვებამდე @msg the script left a document that cannot be kept სკრიპტმა დატოვა დოკუმენტი, რომლის შენახვა შეუძლებელია +@msg the script is no longer in the Scripts folder სკრიპტი აღარ არის Scripts საქაღალდეში + Some scripts were not listed ზოგიერთი სკრიპტი არ არის ჩამოთვლილი + The Scripts folder cannot be read Scripts საქაღალდის წაკითხვა შეუძლებელია @msg the script's run is no longer available სკრიპტის გაშვება აღარ არის ხელმისაწვდომი @msg a failed command changed undo steps that could not be put back, so the script was stopped წარუმატებელმა ბრძანებამ შეცვალა გაუქმების ნაბიჯები, რომელთა აღდგენა ვერ მოხერხდა, ამიტომ სკრიპტი შეჩერდა @msg a recovery copy could not be written, so the script was not run აღდგენის ასლის ჩაწერა ვერ მოხერხდა, ამიტომ სკრიპტი არ გაეშვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b0fc26d1f..78986b291 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4802,6 +4802,9 @@ theme Appearance 외관 @msg the script is too large to run 스크립트가 너무 커서 실행할 수 없습니다 @msg a drag or an undo group is open; finish it before running a script 드래그 또는 실행 취소 그룹이 열려 있습니다. 스크립트를 실행하기 전에 완료하세요 @msg the script left a document that cannot be kept 스크립트가 유지할 수 없는 문서를 남겼습니다 +@msg the script is no longer in the Scripts folder 스크립트가 더 이상 Scripts 폴더에 없습니다 + Some scripts were not listed 일부 스크립트가 나열되지 않았습니다 + The Scripts folder cannot be read Scripts 폴더를 읽을 수 없습니다 @msg the script's run is no longer available 스크립트 실행을 더 이상 사용할 수 없습니다 @msg a failed command changed undo steps that could not be put back, so the script was stopped 실패한 명령이 되돌릴 수 없는 실행 취소 단계를 변경하여 스크립트를 중지했습니다 @msg a recovery copy could not be written, so the script was not run 복구 사본을 쓸 수 없어 스크립트를 실행하지 않았습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c30a50e71..4080fb1c1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -55,6 +55,8 @@ Scripts Scripts Check Script… Script controleren… Check Script Script controleren + Some scripts were not listed Sommige scripts zijn niet weergegeven + The Scripts folder cannot be read De map Scripts kan niet worden gelezen Other Script… Ander script… Document Setup… Documentinstellingen… Switch Units Eenheden wisselen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8450cb654..efa27c770 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4235,6 +4235,9 @@ imageTrace Outline Kontur @msg the script is too large to run skrypt jest zbyt duży, aby go uruchomić @msg a drag or an undo group is open; finish it before running a script otwarte jest przeciąganie lub grupa cofania; zakończ je przed uruchomieniem skryptu @msg the script left a document that cannot be kept skrypt pozostawił dokument, którego nie można zachować +@msg the script is no longer in the Scripts folder skryptu nie ma już w folderze Scripts + Some scripts were not listed Niektóre skrypty nie zostały wyświetlone + The Scripts folder cannot be read Nie można odczytać folderu Scripts @msg the script's run is no longer available uruchomienie skryptu nie jest już dostępne @msg a failed command changed undo steps that could not be put back, so the script was stopped nieudane polecenie zmieniło kroki cofania, których nie udało się przywrócić, więc skrypt został zatrzymany @msg a recovery copy could not be written, so the script was not run nie udało się zapisać kopii odzyskiwania, więc skrypt nie został uruchomiony diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 00d9a8948..a18ebaf00 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5079,6 +5079,9 @@ axis Both Ambos @msg the script is too large to run o script é grande demais para ser executado @msg a drag or an undo group is open; finish it before running a script há um arraste ou um grupo de desfazer aberto; conclua-o antes de executar um script @msg the script left a document that cannot be kept o script deixou um documento que não pode ser mantido +@msg the script is no longer in the Scripts folder o script não está mais na pasta Scripts + Some scripts were not listed Alguns scripts não foram listados + The Scripts folder cannot be read Não é possível ler a pasta Scripts @msg the script's run is no longer available a execução do script não está mais disponível @msg a failed command changed undo steps that could not be put back, so the script was stopped um comando que falhou alterou etapas de desfazer que não puderam ser restauradas, então o script foi interrompido @msg a recovery copy could not be written, so the script was not run não foi possível gravar uma cópia de recuperação, portanto o script não foi executado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 24a6c74d5..00733aa5c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4139,6 +4139,9 @@ axis Both Оба @msg the script is too large to run скрипт слишком велик для выполнения @msg a drag or an undo group is open; finish it before running a script открыто перетаскивание или группа отмены; завершите их перед запуском скрипта @msg the script left a document that cannot be kept скрипт оставил документ, который нельзя сохранить +@msg the script is no longer in the Scripts folder скрипт больше не находится в папке Scripts + Some scripts were not listed Некоторые сценарии не показаны + The Scripts folder cannot be read Не удалось прочитать папку Scripts @msg the script's run is no longer available выполнение скрипта больше недоступно @msg a failed command changed undo steps that could not be put back, so the script was stopped неудавшаяся команда изменила шаги отмены, которые не удалось восстановить, поэтому скрипт остановлен @msg a recovery copy could not be written, so the script was not run не удалось записать копию для восстановления, поэтому скрипт не выполнен diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c038612c1..b01990b7f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4012,6 +4012,9 @@ axis Both Båda @msg the script is too large to run skriptet är för stort för att köras @msg a drag or an undo group is open; finish it before running a script en dragning eller ångragrupp är öppen; avsluta den innan du kör ett skript @msg the script left a document that cannot be kept skriptet lämnade ett dokument som inte kan behållas +@msg the script is no longer in the Scripts folder skriptet finns inte längre i mappen Scripts + Some scripts were not listed Vissa skript visas inte i listan + The Scripts folder cannot be read Mappen Scripts kan inte läsas @msg the script's run is no longer available skriptets körning är inte längre tillgänglig @msg a failed command changed undo steps that could not be put back, so the script was stopped ett misslyckat kommando ändrade ångrasteg som inte kunde återställas, så skriptet stoppades @msg a recovery copy could not be written, so the script was not run en återställningskopia kunde inte skrivas, så skriptet kördes inte diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 282990771..3d7a34da8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3894,6 +3894,9 @@ axis Both Her İkisi @msg the script is too large to run betik çalıştırılamayacak kadar büyük @msg a drag or an undo group is open; finish it before running a script bir sürükleme veya geri alma grubu açık; betiği çalıştırmadan önce tamamlayın @msg the script left a document that cannot be kept betik, saklanamayan bir belge bıraktı +@msg the script is no longer in the Scripts folder betik artık Scripts klasöründe değil + Some scripts were not listed Bazı komut dosyaları listelenmedi + The Scripts folder cannot be read Scripts klasörü okunamıyor @msg the script's run is no longer available betiğin çalıştırması artık kullanılamıyor @msg a failed command changed undo steps that could not be put back, so the script was stopped başarısız bir komut geri yüklenemeyen geri alma adımlarını değiştirdi, bu yüzden betik durduruldu @msg a recovery copy could not be written, so the script was not run kurtarma kopyası yazılamadı, bu nedenle betik çalıştırılmadı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 88bcb4702..4b67e0a3b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4129,6 +4129,9 @@ axis Both Обидві @msg the script is too large to run скрипт завеликий для виконання @msg a drag or an undo group is open; finish it before running a script відкрито перетягування або групу скасування; завершіть їх перед запуском скрипта @msg the script left a document that cannot be kept скрипт залишив документ, який не можна зберегти +@msg the script is no longer in the Scripts folder скрипт більше не міститься в папці Scripts + Some scripts were not listed Деякі сценарії не показано + The Scripts folder cannot be read Не вдалося прочитати папку Scripts @msg the script's run is no longer available виконання скрипта більше не доступне @msg a failed command changed undo steps that could not be put back, so the script was stopped невдала команда змінила кроки скасування, які не вдалося відновити, тому скрипт зупинено @msg a recovery copy could not be written, so the script was not run не вдалося записати копію для відновлення, тому скрипт не виконано diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 90c292bc9..8798c71a4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4548,6 +4548,9 @@ theme Appearance Giao diện @msg the script is too large to run tập lệnh quá lớn để chạy @msg a drag or an undo group is open; finish it before running a script đang có thao tác kéo hoặc nhóm hoàn tác; hãy hoàn thành trước khi chạy tập lệnh @msg the script left a document that cannot be kept tập lệnh để lại một tài liệu không thể giữ +@msg the script is no longer in the Scripts folder tập lệnh không còn trong thư mục Scripts + Some scripts were not listed Một số tập lệnh không được liệt kê + The Scripts folder cannot be read Không thể đọc thư mục Scripts @msg the script's run is no longer available lần chạy tập lệnh không còn khả dụng @msg a failed command changed undo steps that could not be put back, so the script was stopped một lệnh thất bại đã thay đổi các bước hoàn tác không thể khôi phục, nên tập lệnh đã bị dừng @msg a recovery copy could not be written, so the script was not run không thể ghi bản sao khôi phục nên tập lệnh không được chạy diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 77578eec1..9a5897441 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4582,6 +4582,9 @@ imageTrace Outline 轮廓 @msg the script is too large to run 脚本过大,无法运行 @msg a drag or an undo group is open; finish it before running a script 拖动或撤销组尚未结束;请在运行脚本前完成 @msg the script left a document that cannot be kept 脚本留下了无法保留的文档 +@msg the script is no longer in the Scripts folder 脚本已不在 Scripts 文件夹中 + Some scripts were not listed 部分脚本未列出 + The Scripts folder cannot be read 无法读取 Scripts 文件夹 @msg the script's run is no longer available 脚本运行已不可用 @msg a failed command changed undo steps that could not be put back, so the script was stopped 失败的命令更改了无法恢复的撤销步骤,因此脚本已停止 @msg a recovery copy could not be written, so the script was not run 无法写入恢复副本,因此未运行脚本 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bdec63590..ff7e9b118 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2410,6 +2410,9 @@ file-filter Templates 範本 @msg the script is too large to run 指令碼過大,無法執行 @msg a drag or an undo group is open; finish it before running a script 拖曳或還原群組尚未結束;請在執行指令碼前完成 @msg the script left a document that cannot be kept 指令碼留下了無法保留的文件 +@msg the script is no longer in the Scripts folder 指令碼已不在 Scripts 資料夾中 + Some scripts were not listed 部分指令碼未列出 + The Scripts folder cannot be read 無法讀取 Scripts 資料夾 @msg the script's run is no longer available 指令碼執行已無法使用 @msg a failed command changed undo steps that could not be put back, so the script was stopped 失敗的命令變更了無法還原的復原步驟,因此指令碼已停止 @msg a recovery copy could not be written, so the script was not run 無法寫入復原副本,因此未執行指令碼 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 932520b18..644125b54 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,7 @@ pub mod recovery; pub mod render_worker; pub mod screen_color; pub mod script_runs; +pub mod scripts_menu; mod scrub; pub mod shortcut_editor; pub mod shortcuts; diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index cbc1536fc..3f34af13a 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -23,6 +23,7 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { ctx.data_mut(|d| d.insert_temp::(field_key, field)); } let mut clicked: Option<(String, Value)> = None; + let mut file_open = false; let tree = menu_tree_named(app.session.prefs.font_names_in_english); // No application menu in the window (as in Illustrator): the bar starts at File on every // platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there. @@ -37,11 +38,21 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text); let resp = ui.add(egui::Button::new(text)); egui::Popup::menu(&resp).show(|ui| menu_body(app, ui, items, &mut clicked)); + if *title == "File" { + file_open = egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp)); + } titles.push(resp); } (ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles)) }) .inner; + // The File menu opening reads the Scripts folder again, for File › Scripts. + let file_key = egui::Id::unique("menu-bar-file-open"); + let file_was_open = ctx.data(|d| d.get_temp::(file_key)).unwrap_or(false); + ctx.data_mut(|d| d.insert_temp(file_key, file_open)); + if file_open && !file_was_open { + crate::scripts_menu::refresh(app, Some(&ctx)); + } // Whether a top-level menu is open: the docking renderer lowers the floating panels' OS windows // while one is, or the menu would open under them. ctx.data_mut(|d| d.insert_temp(menu_open_id(), open)); diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 5d203fe87..3c308970d 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -33,8 +33,11 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { Some(match it { Item::Sep => Entry::Sep, Item::Header(h) => Entry::Header(t(h)), - // A plug-in's submenu shows its name as it is, as its items do. - Item::Sub(label, children) if super::labels::holds_plugin_commands(children) => Entry::Sub(label, children), + // A plug-in's submenu shows its name as it is, as its items do, and so does a subfolder of + // the Scripts folder. + Item::Sub(label, children) if super::labels::holds_plugin_commands(children) || crate::scripts_menu::is_folder(children) => { + Entry::Sub(label, children) + } Item::Sub(label, children) => Entry::Sub(t(label), children), Item::Todo(label, sc) => Entry::Item(Row { command: None, @@ -127,6 +130,9 @@ fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Ve for it in items { match it { Item::Cmd(l, id, p) => { + if *id == crate::scripts_menu::COMMAND && !crate::scripts_menu::readable(p) { + continue; + } let Some((enabled, _)) = shown_state(app, id, p) else { continue }; out.push(MenuEntry { path: path.clone(), diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index c37a8268c..ab530b803 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -2,11 +2,12 @@ use super::state::{SUBJECT_COMMANDS, enabled, has_subject, waits_for_ime}; use super::{Value, VectorcraftApp, json}; /// What a menu click runs: items whose label ends with "…" and that carry default params open a -/// generic parameter dialog (fields = the defaults) instead of running immediately. +/// generic parameter dialog (fields = the defaults) instead of running immediately. A script of the +/// Scripts folder runs, whatever its name ends with. pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { let dialog = label.ends_with('…') && p.as_object().is_some_and(|o| !o.is_empty()) - && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog"); + && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog" | crate::scripts_menu::COMMAND); if dialog { return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p})); } diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index 148368a27..408e5f077 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -123,8 +123,8 @@ fn command_label(app: &VectorcraftApp, id: &str, label: &str) -> Option /// Does the menu item `id` show a name that is user, file or system data rather than an interface /// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a -/// plug-in, an SVG filter? Such names are shown as they are, never translated (a workspace the user calls -/// "Layers" stays "Layers"). +/// plug-in, an SVG filter, a script of the Scripts folder? Such names are shown as they are, never +/// translated (a workspace the user calls "Layers" stays "Layers"). pub(super) fn shows_a_name(id: &str, label: &str) -> bool { const SLOTS: [&str; 8] = [ "file.openRecent", @@ -138,7 +138,10 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool { ]; SLOTS.iter().any(|s| id.starts_with(s)) || saved_selection_slot(id).is_some() - || matches!(id, "text.setStyle" | "plugin.dialog" | "file.openWith" | "window.toolbar.custom" | "svgFilter.apply") + || matches!( + id, + "text.setStyle" | "plugin.dialog" | "file.openWith" | "window.toolbar.custom" | "svgFilter.apply" | crate::scripts_menu::COMMAND + ) || vectorcraft_engine::cmd::plugin::is_command_id(id) || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) @@ -328,12 +331,12 @@ pub(crate) fn holds_plugin_commands(children: &[Item]) -> bool { /// Every English string the menus, the command palette, the panel registry, the toolbar and the /// Preferences dialog can show: what a complete language catalog has to cover. Names that are user -/// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the -/// view angles are left out. +/// data (fonts, recent files, saved views, libraries, installed plug-ins, the scripts and subfolders +/// of the Scripts folder), the point sizes and the view angles are left out. pub fn menu_strings() -> std::collections::BTreeSet { use std::collections::BTreeSet; fn user_data(id: &str, params: &Value) -> bool { - hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params) + hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params) || id == crate::scripts_menu::COMMAND } fn walk(items: &[Item], under: &str, out: &mut BTreeSet) { for it in items { @@ -354,8 +357,8 @@ pub fn menu_strings() -> std::collections::BTreeSet { Item::Todo(l, _) | Item::Header(l) => { out.insert(l.to_string()); } - // A plug-in's submenu is named after the plug-in. - Item::Sub(_, children) if holds_plugin_commands(children) => walk(children, under, out), + // A plug-in's submenu is named after the plug-in, and a subfolder's after the folder. + Item::Sub(_, children) if holds_plugin_commands(children) || crate::scripts_menu::is_folder(children) => walk(children, under, out), Item::Sub(l, children) => { out.insert(l.to_string()); // Type › Font lists the installed families: names, not UI text. @@ -375,6 +378,7 @@ pub fn menu_strings() -> std::collections::BTreeSet { out.extend(crate::native_menu::MAC_LABELS.iter().map(|s| s.to_string())); out.extend(DYNAMIC_LABELS.iter().map(|s| s.to_string())); out.extend(CONTEXT_LABELS.iter().map(|s| s.to_string())); + out.extend(crate::scripts_menu::LABELS.iter().map(|s| s.to_string())); out.extend(UI_COMMANDS.iter().map(|c| c.1.to_string())); for c in vectorcraft_engine::cmd::command_specs() { out.insert(c.label.to_string()); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 8ea13cf65..536ad79eb 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -975,6 +975,7 @@ fn run_ui_part13(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(crate::trace_runs::hide_all(app)), "ui.checkScript" => crate::dialogs::script_check::command(app, p), "ui.runScript" => crate::dialogs::script_run::command(app, p), + crate::scripts_menu::COMMAND => crate::scripts_menu::run(app, p), "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), "ui.perspectivePresetsDialog" => { crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index a0e493561..bcb51b133 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -130,6 +130,57 @@ fn hovering_another_title_switches_the_open_menu() { assert_eq!(open_titles(&ctx, &t), vec![0], "a still pointer leaves the open menu alone"); } +#[test] +fn opening_the_file_menu_reads_the_scripts_folder_again() { + let dir = std::env::temp_dir().join(format!("vc-menu-bar-scripts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = vectorcraft_engine::Session::new(); + session.attach_scripts_dir(dir.clone()); + let mut app = VectorcraftApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + // The scripts File › Scripts lists. + let listed = || { + let (_, file) = menu_tree().into_iter().find(|(title, _)| *title == "File").unwrap(); + let scripts = file.iter().find_map(|item| match item { + Item::Sub("Scripts", items) => Some(items.clone()), + _ => None, + }); + scripts + .unwrap() + .iter() + .filter_map(|item| if let Item::Cmd(label, crate::scripts_menu::COMMAND, _) = item { Some(*label) } else { None }) + .collect::>() + }; + // One frame of the bar, then the listing a read it started gave. + let frame = |app: &mut VectorcraftApp| { + let titles = bar_frame(app, &ctx, vec![]); + crate::scripts_menu::wait(); + titles + }; + std::fs::write(dir.join("a.js"), "").unwrap(); + frame(&mut app); + let titles = frame(&mut app); + assert!(listed().is_empty(), "the bar reads nothing while its menus are closed"); + egui::Popup::open_id(&ctx, titles[0].1); + frame(&mut app); + assert_eq!(listed(), ["a"]); + // While File stays open the folder is not read again. + std::fs::write(dir.join("b.js"), "").unwrap(); + frame(&mut app); + assert_eq!(listed(), ["a"]); + // Nor when another menu opens; the next time File opens it is. + egui::Popup::open_id(&ctx, titles[1].1); + let t = frame(&mut app); + assert_eq!((open_titles(&ctx, &t), listed()), (vec![1], vec!["a"])); + egui::Popup::open_id(&ctx, titles[0].1); + frame(&mut app); + assert_eq!(listed(), ["a", "b"]); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_popup_covering_a_title_does_not_switch_menus() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index d2489d7b8..947093aba 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -62,7 +62,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Export Selection…", "file.exportSelection"), Sep, c("Package…", "ui.packageDialog"), - sub("Scripts", vec![c("Check Script…", "ui.checkScript"), c("Other Script…", "ui.runScript")]), + sub("Scripts", crate::scripts_menu::items()), Sep, c("Document Setup…", "file.documentSetup"), sub( diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f3a032923..f1513f8df 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1532,6 +1532,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+F12", "{path? | source?, name?} run a .js or .jsx file (script.run), after the warning unless Preferences › General › Show External Script Warning is off (dialog `scriptRun`: `name`, `script`, `dontShow`); without a file pick one (the web opens the browser's file picker)", ), + ( + crate::scripts_menu::COMMAND, + "Run Script", + "", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (script.run with the file's text, under the file's name), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + ), ( "ui.checkScript", "Check Script…", diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 8d1b7ee08..56baee51a 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -590,7 +590,7 @@ pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) { /// What the menus' rows depend on, cheaply (no allocation): commands run, the active document, /// its revision (edits, selection and view changes), history and clipboard, the language, -/// shortcuts, plug-ins, an open file dialog and the fonts (Type › Font lists the loaded fonts +/// shortcuts, plug-ins, the Scripts folder's listing, an open file dialog and the fonts (Type › Font lists the loaded fonts /// until the installed ones are scanned, then all of them, #5483). `input` forces a change on frames with a click or /// a key press, which covers what the hash doesn't list (panels shown, a tool picked). While an /// interaction is open (for example a move, scale or shape drag, a dialog's or the font menu's @@ -608,7 +608,7 @@ fn state_hash(app: &VectorcraftApp, input: Option) -> u64 { } (app.system_paste, app.last_effect.is_some(), app.ui.recent_files.len(), app.ui.recent_files.first(), app.recent_fonts().len()).hash(&mut h); app.file_dialog_open().hash(&mut h); - (crate::i18n::current().code(), menus::plugin_revision()).hash(&mut h); + (crate::i18n::current().code(), menus::plugin_revision(), crate::scripts_menu::revision()).hash(&mut h); crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed).hash(&mut h); let fonts = vectorcraft_text::FontDb::global(); (fonts.generation(), fonts.installed_fonts_ready()).hash(&mut h); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index e895bef6c..d5bf7b609 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -17,8 +17,9 @@ pub fn items() -> Vec<(String, String, String)> { let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) }; items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string())); } - // `plugin.dialog` runs nothing without an id: [`build`] lists each filter instead. - for c in menus::UI_COMMANDS.iter().filter(|c| c.0 != "plugin.dialog") { + // `plugin.dialog` runs nothing without an id: [`build`] lists each filter instead. A script of + // the Scripts folder runs from File › Scripts, by its path. + for c in menus::UI_COMMANDS.iter().filter(|c| c.0 != "plugin.dialog" && c.0 != crate::scripts_menu::COMMAND) { items.push((c.1.to_string(), c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string())); } for tool in vectorcraft_tools::catalog::all_tools() { diff --git a/crates/ui-egui/src/scripts_menu.rs b/crates/ui-egui/src/scripts_menu.rs new file mode 100644 index 000000000..e62a5d887 --- /dev/null +++ b/crates/ui-egui/src/scripts_menu.rs @@ -0,0 +1,254 @@ +//! File › Scripts: the scripts of the Scripts folder next to the settings +//! ([`vectorcraft_engine::Session::attach_scripts_dir`]), each `.js` or `.jsx` file by its name +//! without the extension and each subfolder as a submenu, above Check Script… and Other Script…. +//! +//! The folder is read on another thread ([`vectorcraft_engine::script::folder::list`]: names only, +//! within its limits), one read at a time: when the app starts, each time its window becomes the +//! active one, and each time the File menu of the window's menu bar opens ([`refresh`]). The menus show the last finished +//! listing ([`items`]), so a script added to the folder is listed without a restart. +//! +//! `ui.runInstalledScript {path}` runs a listed script (`script.run`) without the warning Other +//! Script… shows: the file was put in the folder to be run from the menu. The path is one a +//! listing gave; a file that is no longer a plain script under the folder is not run. + +use std::path::Path; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use serde_json::{Value, json}; +use vectorcraft_engine::script::folder::{Entry, Listing}; + +use crate::VectorcraftApp; +use crate::menus::Item; + +/// The command a listed script's menu item runs. +pub const COMMAND: &str = "ui.runInstalledScript"; +/// The menu's note under a listing that may lack scripts. +pub const TRUNCATED: &str = "Some scripts were not listed"; +/// The menu's note when the folder is there and cannot be read. +pub const UNREADABLE: &str = "The Scripts folder cannot be read"; +/// The notes, for the catalogs ([`crate::menus::menu_strings`]). +pub const LABELS: &[&str] = &[TRUNCATED, UNREADABLE]; + +#[derive(Default)] +struct State { + /// The last finished listing. + listing: Arc, + /// How many finished listings differed from the one before. + revision: u64, + /// The listing being read on another thread. + reading: Option>, + /// Another read was asked for while one was under way; [`poll`] starts it when that one ends. + pending: bool, + /// A first read was asked for. + started: bool, + /// The window was the active one at the last [`poll`]. + focused: bool, +} + +/// The listing the menus show. One per process in the app; one per thread in unit tests, which run +/// in parallel threads, each with its own folder. +fn state() -> MutexGuard<'static, State> { + #[cfg(not(test))] + let state: &'static Mutex = { + static S: std::sync::OnceLock> = std::sync::OnceLock::new(); + S.get_or_init(Default::default) + }; + #[cfg(test)] + let state: &'static Mutex = { + thread_local!(static S: &'static Mutex = Box::leak(Box::default())); + S.with(|s| *s) + }; + state.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The most different names the menu shows in one session. Each is kept for the rest of the +/// session (menu items hold `&'static str` labels), at most 128 characters each. +pub const MAX_NAMES: usize = 16_384; + +/// Leak-once interning of the names the menu shows; `None` for a new name once [`MAX_NAMES`] are +/// kept, so renaming scripts over and over keeps the memory bounded. +pub(crate) fn intern(s: &str) -> Option<&'static str> { + #[cfg(not(test))] + let set: &'static Mutex> = { + static SET: std::sync::OnceLock>> = std::sync::OnceLock::new(); + SET.get_or_init(Default::default) + }; + #[cfg(test)] + let set: &'static Mutex> = { + thread_local!(static SET: &'static Mutex> = Box::leak(Box::default())); + SET.with(|s| *s) + }; + let mut set = set.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(v) = set.get(s) { + return Some(v); + } + if set.len() >= MAX_NAMES { + return None; + } + let v: &'static str = Box::leak(s.to_string().into_boxed_str()); + set.insert(v); + Some(v) +} + +/// Changes whenever the menus' listing changes (the macOS menu bar follows it). +pub fn revision() -> u64 { + state().revision +} + +/// Read the Scripts folder again on another thread, unless the session has no folder (the web, and +/// a run without preferences). While a read is under way, another read starts when it ends +/// ([`poll`]). `ctx` is asked to draw when the read ends. +pub fn refresh(app: &VectorcraftApp, ctx: Option<&egui::Context>) { + let Some(root) = app.session.scripts_dir().map(Path::to_path_buf) else { return }; + let mut state = state(); + if state.reading.is_some() { + state.pending = true; + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = ctx.cloned(); + let spawned = std::thread::Builder::new().name("vectorcraft-scripts".into()).spawn(move || { + // The app's own folder, read for the person at the keyboard. + let listing = vectorcraft_engine::file_access::unconfined(|| vectorcraft_engine::script::folder::list(&root)); + if tx.send(listing).is_ok() + && let Some(ctx) = ctx + { + ctx.request_repaint(); + } + }); + if spawned.is_ok() { + state.reading = Some(rx); + } +} + +/// Once per iteration: start a read on the first one and when the window becomes the active one, +/// take in a read that ended, and then start a read asked for while it was under way. +pub fn poll(app: &VectorcraftApp, ctx: &egui::Context) { + let focused = ctx.input(|i| i.focused); + let start = { + let mut state = state(); + let start = !state.started || (focused && !state.focused); + (state.started, state.focused) = (true, focused); + start + }; + if start { + refresh(app, Some(ctx)); + } + take(false); + let again = { + let mut state = state(); + let again = state.pending && state.reading.is_none(); + if again { + state.pending = false; + } + again + }; + if again { + refresh(app, Some(ctx)); + } +} + +/// Take in the read under way: when it has ended, or with `wait` once it ends. +fn take(wait: bool) { + let mut state = state(); + let Some(reading) = &state.reading else { return }; + let listing = if wait { reading.recv().map_err(|_| TryRecvError::Disconnected) } else { reading.try_recv() }; + match listing { + Ok(listing) => { + if *state.listing != listing { + state.listing = Arc::new(listing); + state.revision += 1; + } + state.reading = None; + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => state.reading = None, + } +} + +/// Wait for the read under way and take it in. +#[cfg(test)] +pub(crate) fn wait() { + take(true); +} + +/// Wait for the read under way to end and leave its listing to be taken in, so the read is under +/// way until the next [`poll`]. +#[cfg(test)] +pub(crate) fn end_untaken() { + let mut state = state(); + let Some(reading) = &state.reading else { return }; + let (tx, rx) = std::sync::mpsc::channel(); + if let Ok(listing) = reading.recv() { + let _ = tx.send(listing); + } + state.reading = Some(rx); +} + +/// File › Scripts' items: the listed scripts and subfolders, a note when some may be missing or +/// the folder cannot be read, then Check Script… and Other Script…. +pub fn items() -> Vec { + // A row whose name is past [`MAX_NAMES`] is left out (`dropped`). + fn level(entries: &[Entry], dropped: &mut bool) -> Vec { + let mut items = Vec::with_capacity(entries.len()); + for e in entries { + let item = match e { + Entry::Script { label, path } => intern(label).map(|l| Item::Cmd(l, COMMAND, json!({ "path": path.to_string_lossy() }))), + Entry::Folder { label, entries } => { + let inner = level(entries, dropped); + intern(label).filter(|_| !inner.is_empty()).map(|l| Item::Sub(l, inner)) + } + }; + match item { + Some(item) => items.push(item), + None => *dropped = true, + } + } + items + } + let listing = state().listing.clone(); + let mut dropped = false; + let mut items = level(&listing.entries, &mut dropped); + if listing.truncated || dropped { + items.push(Item::Header(TRUNCATED)); + } + if listing.unreadable { + items.push(Item::Header(UNREADABLE)); + } + if !items.is_empty() { + items.push(Item::Sep); + } + items.extend([Item::Cmd("Check Script…", "ui.checkScript", Value::Null), Item::Cmd("Other Script…", "ui.runScript", Value::Null)]); + items +} + +/// Is `children` a subfolder's submenu, named after the folder? Its first item is a listed script +/// or another subfolder, and it has no Other Script…, which only File › Scripts itself has. +pub fn is_folder(children: &[Item]) -> bool { + let listed = match children.first() { + Some(Item::Cmd(_, id, _)) => *id == COMMAND, + Some(Item::Sub(_, inner)) => is_folder(inner), + _ => false, + }; + listed && !children.iter().any(|c| matches!(c, Item::Cmd(_, "ui.runScript", _))) +} + +/// May the request being served read the listed script `p` names? Under an automation read root +/// (a control client's request), a script outside the root is left out of `ui.menu.list`, as +/// `script.listInstalled` refuses to list the folder there. +pub fn readable(p: &Value) -> bool { + p.get("path").and_then(Value::as_str).is_some_and(|path| vectorcraft_engine::file_access::check_read(path).is_ok()) +} + +/// `ui.runInstalledScript {path}`: run the listed script at `path`. A script that is no longer in +/// the folder is not run, and the folder is read again. +pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { + let path = p.get("path").and_then(Value::as_str).ok_or("missing `path`")?; + let source = app.session.installed_script(path).map_err(|e| { + let ctx = app.ui_ctx.clone(); + refresh(app, ctx.as_ref()); + e.to_string() + })?; + app.run("script.run", json!({"source": source, "name": vectorcraft_engine::cmd::fileio::file_name(path)})) +} diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 261a25054..c8e60c14f 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -40,6 +40,7 @@ mod screenmode; mod screenshots; mod script_check; mod script_run; +mod scripts_menu; mod scrub; mod selectall; mod selectescape; diff --git a/crates/ui-egui/src/tests/scripts_menu.rs b/crates/ui-egui/src/tests/scripts_menu.rs new file mode 100644 index 000000000..3dc66acca --- /dev/null +++ b/crates/ui-egui/src/tests/scripts_menu.rs @@ -0,0 +1,374 @@ +//! File › Scripts lists the Scripts folder: its scripts and subfolders above Check Script… and +//! Other Script…, names shown as they are, a listed script run without the warning, a script that +//! is gone, the notes, the macOS menu bar and the reads of the folder. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use crate::menus::{self, Entry, Item}; +use crate::native_menu::{self, Node}; +use crate::{VectorcraftApp, script_runs, scripts_menu}; + +/// A fresh folder for test `name`, holding an empty `Scripts` folder. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vc-scripts-menu-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Scripts")).unwrap(); + dir +} + +/// An app with a document whose Scripts folder is `dir`'s. +fn app(dir: &std::path::Path) -> VectorcraftApp { + let mut session = Session::new(); + session.attach_scripts_dir(dir.join("Scripts")); + let mut app = VectorcraftApp::new(session, crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app +} + +/// Reads the folder again and waits for the listing. +fn read(app: &VectorcraftApp) { + scripts_menu::refresh(app, None); + scripts_menu::wait(); +} + +/// Writes the script `name` (`/` between its folders) in `dir`'s Scripts folder; returns its path +/// as a listing gives it. +fn write(dir: &std::path::Path, name: &str, text: &str) -> String { + let path = name.split('/').fold(dir.join("Scripts"), |path, part| path.join(part)); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, text).unwrap(); + path.to_string_lossy().into_owned() +} + +/// File › Scripts' items. +fn scripts() -> Vec { + let (_, file) = menus::menu_tree().into_iter().find(|(title, _)| *title == "File").unwrap(); + file.into_iter() + .find_map(|item| match item { + Item::Sub("Scripts", items) => Some(items), + _ => None, + }) + .unwrap() +} + +/// Menu items as text: a command's label, `label/[…]` for a submenu, `(text)` for a note and `-` +/// for a separator. +fn shape(items: &[Item]) -> String { + let one = |item: &Item| match item { + Item::Cmd(label, ..) | Item::Todo(label, _) => label.to_string(), + Item::Sub(label, items) => format!("{label}/[{}]", shape(items)), + Item::Header(text) => format!("({text})"), + Item::Sep => "-".to_string(), + }; + items.iter().map(one).collect::>().join(", ") +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +fn finish(app: &mut VectorcraftApp) { + for _ in 0..10_000 { + if !app.session.script_running() { + return; + } + script_runs::poll(app); + } + panic!("the script did not end"); +} + +const DRAW: &str = "vc.run('shape.rectangle', {x:0,y:0,width:10,height:10}); vc.run('shape.rectangle', {x:20,y:0,width:10,height:10});"; + +#[test] +fn the_folders_scripts_are_listed_above_check_script_and_other_script() { + let dir = folder("listed"); + let app = app(&dir); + assert_eq!(shape(&scripts()), "Check Script…, Other Script…", "before the first listing"); + read(&app); + assert_eq!(shape(&scripts()), "Check Script…, Other Script…", "an empty folder"); + let draw = write(&dir, "Draw Two.jsx", DRAW); + write(&dir, "Export/All Artboards.js", ""); + write(&dir, "notes.txt", ""); + read(&app); + let items = scripts(); + assert_eq!(shape(&items), "Draw Two, Export/[All Artboards], -, Check Script…, Other Script…"); + assert!(matches!(&items[0], Item::Cmd(_, scripts_menu::COMMAND, p) if *p == json!({ "path": draw })), "{:?}", items[0]); + // A script added to the folder is listed by the next read, with no restart. + write(&dir, "Added.js", ""); + assert!(shape(&scripts()).starts_with("Draw Two"), "the menus keep the last listing"); + read(&app); + assert!(shape(&scripts()).starts_with("Added, Draw Two")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_listed_script_runs_without_the_warning_as_one_undo_step() { + let dir = folder("run"); + let mut app = app(&dir); + write(&dir, "Dessin (été) #1.JSX", DRAW); + read(&app); + let items = scripts(); + let Item::Cmd(label, id, p) = &items[0] else { panic!("{:?}", items[0]) }; + let undo = app.session.doc().unwrap().history.undo.len(); + assert!(app.session.prefs.show_external_script_warning); + let (id, p) = menus::click_target(label, id, p); + menus::invoke(&mut app, &id, p); + assert!(app.ui.dialog.is_none(), "no warning for a script of the folder"); + assert!(app.session.script_running()); + finish(&mut app); + assert_eq!(objects(&app), 2); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(app.ui.status, "Ran Dessin (été) #1.JSX"); + assert!(app.session.prefs.show_external_script_warning, "Other Script… still warns"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_script_runs_its_own_file_whatever_its_name_ends_with() { + let dir = folder("names"); + let mut app = app(&dir); + // A label that ends like a menu item that asks for its parameters first. + let dots = write(&dir, "Draw….js", DRAW); + write(&dir, "Draw.js", "vc.run('shape.rectangle', {x:0,y:0,width:1,height:1});"); + read(&app); + let items = scripts(); + assert_eq!(shape(&items[..2]), "Draw, Draw…"); + let Item::Cmd(label, id, p) = &items[1] else { panic!("{:?}", items[1]) }; + assert_eq!(menus::click_target(label, id, p), (scripts_menu::COMMAND.to_string(), json!({ "path": dots }))); + menus::run_ui_command(&mut app, id, p).unwrap().unwrap(); + finish(&mut app); + assert_eq!(objects(&app), 2, "the file the item names ran, not its neighbor"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn script_and_folder_names_are_shown_as_they_are_in_every_language() { + let dir = folder("language"); + let app = app(&dir); + write(&dir, "Layers.js", ""); + write(&dir, "Export/Layers.jsx", ""); + read(&app); + let de = crate::i18n::Lang::from_code("de").unwrap(); + assert!(crate::i18n::tr(de, "Layers") != "Layers" && crate::i18n::tr(de, "Export") != "Export", "German translates both words"); + crate::i18n::set_current(de); + let items = scripts(); + let labels: Vec = items + .iter() + .filter_map(|item| match menus::entry(&app, item)? { + Entry::Sub(label, _) => Some(label.to_string()), + Entry::Item(row) => Some(row.label.to_string()), + _ => None, + }) + .collect(); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_eq!(labels[..2], ["Export", "Layers"]); + assert_ne!(labels[2], "Check Script…", "the fixed items are translated"); + // The catalogs hold the notes, and no file or folder name. + let strings = menus::menu_strings(); + assert!(scripts_menu::LABELS.iter().all(|note| strings.contains(*note))); + assert!(strings.contains("Layers") && !strings.contains("All Artboards")); + write(&dir, "Zed/All Artboards.js", ""); + read(&app); + assert!(!menus::menu_strings().contains("All Artboards") && !menus::menu_strings().contains("Zed")); + assert!(!crate::palette::items().iter().any(|(_, id, _)| id == scripts_menu::COMMAND), "a script runs from its menu item"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_script_that_left_the_folder_is_not_run_and_the_folder_is_read_again() { + let dir = folder("gone"); + let mut app = app(&dir); + let gone = write(&dir, "gone.js", DRAW); + write(&dir, "kept.js", ""); + read(&app); + std::fs::remove_file(&gone).unwrap(); + let e = menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": gone })).unwrap().unwrap_err(); + assert_eq!(e, "the script is no longer in the Scripts folder"); + assert!(!app.session.script_running()); + scripts_menu::wait(); + assert_eq!(shape(&scripts()), "kept, -, Check Script…, Other Script…"); + // A file outside the folder is not one of its scripts, and a call needs a path. + let outside = dir.join("outside.js"); + std::fs::write(&outside, DRAW).unwrap(); + let e = menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": outside.to_string_lossy() })).unwrap().unwrap_err(); + assert_eq!(e, "the script is no longer in the Scripts folder"); + assert!(menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({})).unwrap().is_err()); + assert!(menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": 3 })).unwrap().is_err()); + finish(&mut app); + assert_eq!(objects(&app), 0); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_read_after_a_script_that_left_the_folder_asks_for_a_frame() { + let dir = folder("gone-frame"); + let mut app = app(&dir); + let gone = write(&dir, "gone.js", DRAW); + read(&app); + std::fs::remove_file(&gone).unwrap(); + let ctx = egui::Context::default(); + let (tx, frames) = std::sync::mpsc::channel(); + ctx.set_request_repaint_callback(move |_| { + let _ = tx.send(()); + }); + app.ui_ctx = Some(ctx); + assert!(menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": gone })).unwrap().is_err()); + // With the app's copy of the context dropped, only the read's thread can still ask for a + // frame; the channel closes without one when that thread ends without asking. + app.ui_ctx = None; + assert_eq!(frames.recv(), Ok(()), "the finished read asks for a frame so an idle window takes it in"); + scripts_menu::wait(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_menu_notes_a_listing_that_stopped_and_a_folder_it_cannot_read() { + let dir = folder("notes"); + let app = app(&dir); + for n in 0..vectorcraft_engine::script::folder::MAX_SCRIPTS + 1 { + write(&dir, &format!("s{n:04}.js"), ""); + } + read(&app); + let items = scripts(); + assert_eq!(items.len(), vectorcraft_engine::script::folder::MAX_SCRIPTS + 4); + assert_eq!(shape(&items[items.len() - 4..]), format!("({}), -, Check Script…, Other Script…", scripts_menu::TRUNCATED)); + // A file where the folder should be. + std::fs::remove_dir_all(dir.join("Scripts")).unwrap(); + std::fs::write(dir.join("Scripts"), "").unwrap(); + read(&app); + assert_eq!(shape(&scripts()), format!("({}), -, Check Script…, Other Script…", scripts_menu::UNREADABLE)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_menu_listing_under_a_read_root_leaves_out_scripts_outside_it() { + use vectorcraft_engine::file_access::{AutomationRoots, confine}; + let dir = folder("read-root"); + let app = app(&dir); + let path = write(&dir, "Export/All Artboards.js", ""); + read(&app); + let scripts = || menus::menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some(scripts_menu::COMMAND)).collect::>(); + let listed = scripts(); + assert_eq!(listed.len(), 1); + assert_eq!( + (&listed[0].path, &listed[0].label, &listed[0].params), + (&vec!["File".to_string(), "Scripts".into(), "Export".into()], &"All Artboards".to_string(), &json!({ "path": path })) + ); + let work = dir.join("work"); + std::fs::create_dir_all(&work).unwrap(); + let outside = AutomationRoots::new(&[&work], &[]).unwrap().unwrap(); + assert!(confine(Some(&outside), scripts).is_empty()); + let inside = AutomationRoots::new(&[&dir], &[]).unwrap().unwrap(); + assert_eq!(confine(Some(&inside), scripts).len(), 1); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn names_past_the_session_limit_are_left_out_with_the_note() { + let dir = folder("names-limit"); + let app = app(&dir); + write(&dir, "Kept.js", ""); + read(&app); + assert_eq!(shape(&scripts()), "Kept, -, Check Script…, Other Script…"); + // Names other listings showed fill what one session keeps. + for n in 0..scripts_menu::MAX_NAMES { + if scripts_menu::intern(&format!("renamed {n}")).is_none() { + break; + } + } + write(&dir, "New.js", ""); + write(&dir, "Folder/Inside.js", ""); + read(&app); + assert_eq!(shape(&scripts()), format!("Kept, ({}), -, Check Script…, Other Script…", scripts_menu::TRUNCATED)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_macos_menu_bar_lists_the_same_scripts_and_follows_a_new_listing() { + let dir = folder("native"); + let app = app(&dir); + let path = write(&dir, "Export/All Artboards.js", ""); + let before = scripts_menu::revision(); + read(&app); + assert_eq!(scripts_menu::revision(), before + 1); + let bar = native_menu::from_tree(&app, &menus::menu_tree()); + let file = bar.menus.iter().find(|m| m.title == "File").unwrap(); + let scripts = file.children.iter().find_map(|n| match n { + Node::Submenu { label, children } if label == "Scripts" => Some(children), + _ => None, + }); + let Some([Node::Submenu { label, children }, Node::Separator, Node::Item(check), Node::Item(other)]) = scripts.map(Vec::as_slice) else { + panic!("{scripts:?}") + }; + assert_eq!((label.as_str(), check.command, other.command), ("Export", Some("ui.checkScript"), Some("ui.runScript"))); + let [Node::Item(script)] = children.as_slice() else { panic!("{children:?}") }; + assert_eq!((script.label.as_str(), script.command, &script.params), ("All Artboards", Some(scripts_menu::COMMAND), &json!({ "path": path }))); + assert!(script.enabled && script.shortcut.is_none()); + // A read that finds the same scripts changes nothing the menu bar would rebuild for. + read(&app); + assert_eq!(scripts_menu::revision(), before + 1); + write(&dir, "More.jsx", ""); + read(&app); + assert_eq!(scripts_menu::revision(), before + 2); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_folder_is_read_at_the_first_frame_and_when_the_window_becomes_active() { + let dir = folder("focus"); + let app = app(&dir); + write(&dir, "first.js", ""); + let ctx = egui::Context::default(); + let frame = |focused: bool| { + let mut out = ctx.run_ui(egui::RawInput { focused, ..Default::default() }, |ui| scripts_menu::poll(&app, ui.ctx())); + out.textures_delta.clear(); + scripts_menu::wait(); + }; + let listed = || shape(&scripts()); + frame(true); + assert!(listed().starts_with("first, -"), "{}", listed()); + // While the window stays active, or stays in the background, the folder is left alone. + write(&dir, "second.js", ""); + frame(true); + frame(false); + frame(false); + assert!(listed().starts_with("first, -"), "{}", listed()); + frame(true); + assert!(listed().starts_with("first, second, -"), "{}", listed()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_read_asked_for_during_another_runs_when_that_one_ends() { + let dir = folder("pending"); + let app = app(&dir); + write(&dir, "first.js", ""); + let ctx = egui::Context::default(); + let frame = || { + let mut out = ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |ui| scripts_menu::poll(&app, ui.ctx())); + out.textures_delta.clear(); + }; + frame(); + scripts_menu::wait(); + // A read that has passed the folder when a script is added, and the File menu opening then. + scripts_menu::refresh(&app, None); + scripts_menu::end_untaken(); + write(&dir, "added.js", ""); + scripts_menu::refresh(&app, None); + frame(); + scripts_menu::wait(); + assert!(shape(&scripts()).starts_with("added, first, -"), "{}", shape(&scripts())); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_app_without_a_scripts_folder_keeps_the_two_fixed_items() { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + read(&app); + assert_eq!(shape(&scripts()), "Check Script…, Other Script…"); + let e = menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": "draw.js" })).unwrap().unwrap_err(); + assert_eq!(e, "the script is no longer in the Scripts folder"); + assert_eq!(menus::UI_COMMANDS.iter().filter(|c| c.0 == scripts_menu::COMMAND).count(), 1); + let _: Value = app.run("script.listInstalled", json!({})).unwrap(); +} diff --git a/docs/gaps.md b/docs/gaps.md index b16bed608..8b6a32a29 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -689,8 +689,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G6 Automation and scripting -- **Missing:** the document object model for scripts, the Scripts folder and script directives (#1183 is - open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, and +- **Missing:** the document object model for scripts and script directives (#1183 is + open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, + File › Scripts lists the scripts of the Scripts folder, and Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and export, and full agent control over MCP are done. - **Estimate:** 4–8 h. diff --git a/docs/mcp.md b/docs/mcp.md index 5edb74d7e..cedaa01a4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1682,6 +1682,55 @@ at the bottom of the panel and in its menu. Output goes on while the panel is cl vectorcraft-cli run --cmd script.run --params '{"source": "$.writeln(\"layers: \", 3);"}' ``` +File › Scripts lists the scripts of the `Scripts` folder next to the preferences: `~/Library/Application +Support/VectorCraft/Scripts` on macOS, `%APPDATA%\VectorCraft\Scripts` on Windows, and +`$XDG_CONFIG_HOME/vectorcraft/Scripts` or `~/.config/vectorcraft/Scripts` on Linux and BSD. VectorCraft does not make +the folder: create it, and put `.js` or `.jsx` files in it. Each file is a menu item named after the file without its +extension, and each subfolder is a submenu that holds the scripts in it. The help page +https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html and +the page https://ai-scripting.docsforadobe.dev/introduction/executingScripts/ describe a Scripts folder listed this +way. The listed scripts come first in the menu, above Check Script… and Other Script…. Choosing one runs the file as +`script.run` runs a file, without the warning that Other Script… shows. + +The rest of this section is VectorCraft's own rules: + +- The folder is read when the app starts, each time its window becomes the active one, and, where the menus are in + the window (Windows, Linux and BSD), each time the File menu opens. A script added while VectorCraft runs is listed + after the next of these. A read takes names only, never a script's text, and runs off the UI thread, one read at a + time. +- Each level is sorted by label without regard to case, with scripts and subfolders mixed. A script whose label + another row of its folder has (`Tidy.js` beside `Tidy.jsx`, or beside a subfolder `Tidy`) shows its whole file name. +- A symbolic link or a Windows junction is never followed: a linked file or subfolder is left out, and a `Scripts` + folder that is itself a link is not read. Names that start with a dot, files and folders Windows marks hidden or + system, names that are not Unicode, names longer than 255 bytes and subfolders without a script are left out too, + and a listed script that is marked hidden or system, or lies in a folder marked so, by the time it is chosen does + not run. +- One read looks at 2,048 folder entries at most, lists 512 scripts and reads 128 subfolders, 4 levels deep, for at + most 250 ms. A label longer than 128 characters is cut, and control and bidirectional formatting characters in a + name are shown as `\u{…}` escapes. When a read stops at a limit or cannot read a subfolder, the menu keeps what it + found and shows `Some scripts were not listed`. The menu also shows that note, and leaves the rows out, when a + read finds new names after the menus have shown 16,384 different script and folder names since VectorCraft started. + When the folder is there and cannot be read, the menu shows + `The Scripts folder cannot be read`. +- A listed file is checked again when it is chosen. A file that is gone, is no longer a `.js` or `.jsx` file under + the folder, or is or lies behind a link ends with `the script is no longer in the Scripts folder`, and nothing runs. +- The web build has no Scripts folder, and neither has a run with `VECTORCRAFT_NO_PREFS` set. + +`script.listInstalled {}` returns the same listing: `{folder, scripts: [{path, label, folders}], truncated, +unreadable}`, with the scripts in menu order and `folders` the labels of the subfolders a script is in. `folder` is +`null` without a Scripts folder. `ui.runInstalledScript {path}` runs one of them by the `path` the listing gave, as +its menu item does. Under `--automation-read-root`, `script.listInstalled` lists the folder only when the folder lies +inside a read root, `ui.runInstalledScript` runs a script only when the script lies inside one, and `ui.menu.list` +leaves out the listed scripts outside the read roots. + +```json +{"name":"run_command","arguments":{"command":"script.listInstalled","params":{}}} +``` + +```sh +vectorcraft-cli run --cmd script.listInstalled --params '{}' +``` + ## Transparency and opacity masks Transparency and opacity-mask commands take `ids` (or `id`), so they need no selection. Opacity is a percentage diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b8e15f7b2..5434e327d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -729,6 +729,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, and `#strict on`, `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).