//! 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. The script runs from its file, so its /// `#include` lines and `$.evalFile` read beside it. pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let path = p.get("path").and_then(Value::as_str).ok_or("missing `path`")?; 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!({ "path": path })) }