diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index f02d6f6aa..30d7302a7 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -350,8 +350,16 @@ pub fn command_specs() -> &'static [CommandSpec] { }) } +/// The command `id`: a built-in one, or a command an installed plug-in adds +/// ([`plugin::command_specs`]). pub fn find_command(id: &str) -> Option<&'static CommandSpec> { - command_specs().iter().find(|c| c.id == id) + command_specs().iter().find(|c| c.id == id).or_else(|| plugin::find_spec(id)) +} + +/// Runs command `spec`'s implementation with `p`. A plug-in command's runs the plug-in +/// ([`plugin::run_command`]), which needs the command's id. +pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result { + if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) } } // ---------- param helpers ---------- diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 95f8c3e8f..089cf8d18 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -206,7 +206,7 @@ pub(crate) fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result< /// Run a command's implementation without the enablement check or journaling. pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result { let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; - (c.run)(s, p) + super::run_spec(s, c, p) } /// Merge the undo entries pushed since the history had `from` entries into one labelled step. diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 565eded7f..53fdd5ad2 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -19,12 +19,19 @@ //! a filter does only what they allow: without `doc.read` it is given no objects, without //! `doc.write` an output that changes the document is refused, and without `state` it neither //! reads nor writes the data it keeps with the document. +//! +//! A filter or a live effect may add menu commands of its own (its manifest's `commands`). Each +//! is the command `plugin..` while the plug-in is installed +//! ([`command_specs`], which [`super::find_command`] and the command listings read): under +//! Object › Plug-ins › the plug-in's name (Effect › Plug-ins for a live effect's), journaled so +//! actions record it, and run as a filter run of the plug-in ([`run_command`]) with the command's +//! id in `_context`, its own checked parameters and the plug-in's permissions, as one undo step. -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry}; +use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry}; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; @@ -58,6 +65,18 @@ pub fn describe(p: &Plugin) -> Value { if m.budget == Budget::Heavy { v["budget"] = json!("heavy"); } + if !m.commands.is_empty() { + let commands: Vec = m + .commands + .iter() + .map(|c| { + let run = m.for_command(c); + json!({"id": c.id, "command": command_id(&m.id, &c.id), "label": c.shown_label(), "params": run.params_doc(), + "paramsSchema": serde_json::to_value(&run).ok().and_then(|v| v.get("params").cloned())}) + }) + .collect(); + v["commands"] = json!(commands); + } v } @@ -88,6 +107,8 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result> { /// like any other command. pub struct FilterRun { plugin: Arc, + /// The plug-in command it runs (`plugin..`), if it isn't `plugin.run`'s run. + command: Option, input: Vec, params: Vec, /// The objects the plug-in is given, and the container new objects go in. @@ -129,8 +150,18 @@ impl FilterRun { )); } } - // Bad parameters fail before anything runs. - let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); + Self::prepare_with(s, plugin, None, C, p) + } + + /// The run of `plugin`, or of its `command`, that command `c`'s params `p` ask for, on the + /// active document. Bad parameters fail before anything runs. + fn prepare_with(s: &Session, plugin: Arc, command: Option, c: &str, p: &Value) -> Result { + let m = plugin.manifest(); + let resolved = match &command { + Some(cmd) => m.for_command(cmd).resolve_params(&plugin_params(p)), + None => m.resolve_params(&plugin_params(p)), + }; + let mut params = Value::Object(resolved.map_err(|e| bad(c, e.to_string()))?); let may = m.permissions(); let st = s.doc()?; let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); @@ -143,10 +174,15 @@ impl FilterRun { // What the plug-in keeps with the document, as it last wrote it. let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); + // A command run says which command it is. + if let Some(cmd) = &command { + params["_context"]["mode"] = json!("command"); + params["_context"]["command"] = json!(cmd.id); + } let input = objects::encode_input(&st.doc, &inputs); - let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; + let params = serde_json::to_vec(¶ms).map_err(|e| bad(c, e.to_string()))?; let document = (st.uid, st.revision); - Ok(FilterRun { plugin, input, params, inputs, parent, document }) + Ok(FilterRun { plugin, command, input, params, inputs, parent, document }) } /// The plug-in, for its name and budget. @@ -197,7 +233,9 @@ impl FilterRun { } let may_write = may.contains(&Permission::DocWrite); let (inputs, parent) = (self.inputs, self.parent); - let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { + // A command's undo step is named after the command. + let label = self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label); + let ids = s.edit(&label, |d, sel| { let before = (!may_write).then(|| d.layers.clone()); let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?; if before.is_some_and(|b| b != d.layers) { @@ -209,7 +247,11 @@ impl FilterRun { sel.set(ids.iter().copied().filter(|&id| d.node(id).is_some_and(objects::selectable))); Ok(ids) })?; - Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::>()})) + let mut out = json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::>()}); + if let Some(cmd) = &self.command { + out["command"] = json!(cmd.id); + } + Ok(out) } } @@ -239,6 +281,140 @@ fn run(s: &mut Session, p: &Value) -> Result { r.finish(s, &output) } +/// The command id of plug-in `plugin`'s command `command`: `plugin..`. +pub fn command_id(plugin: &str, command: &str) -> String { + format!("{}{plugin}.{command}", vectorcraft_plugins::PREFIX) +} + +/// Is `id` shaped as a plug-in command's id (`plugin..`)? This module's +/// own `plugin.*` commands have no dot after the prefix, and a command id has none. +pub fn is_command_id(id: &str) -> bool { + id.strip_prefix(vectorcraft_plugins::PREFIX).is_some_and(|rest| rest.contains('.')) +} + +/// The installed plug-in and its command that command id `id` names. +pub fn command_of(id: &str) -> Option<(Arc, Command)> { + let (plugin, command) = id.strip_prefix(vectorcraft_plugins::PREFIX)?.rsplit_once('.')?; + let p = registry::get(plugin)?; + let c = p.manifest().command(command)?.clone(); + Some((p, c)) +} + +/// The plug-in commands of the installed plug-ins, the registry revision they were listed at, and +/// a count that changes whenever the list does ([`commands_generation`]). +struct Listed { + revision: Option, + generation: u64, + specs: Vec<&'static CommandSpec>, +} + +static LISTED: RwLock = RwLock::new(Listed { revision: None, generation: 0, specs: Vec::new() }); + +/// Every command spec made for a plug-in command, made once: specs hold static strings, so memory +/// stays bounded by the plug-in commands ever installed. +static MADE: Mutex> = Mutex::new(Vec::new()); + +/// The commands the installed plug-ins add, in plug-in id order and then in each manifest's order. +pub fn command_specs() -> Vec<&'static CommandSpec> { + let revision = registry::revision(); + if let Ok(l) = LISTED.read() + && l.revision == Some(revision) + { + return l.specs.clone(); + } + let specs: Vec<&'static CommandSpec> = + registry::list().iter().flat_map(|p| p.manifest().commands.iter().map(|c| spec_of(p, c)).collect::>()).collect(); + let mut l = LISTED.write().unwrap_or_else(|e| e.into_inner()); + if l.specs.len() != specs.len() || l.specs.iter().zip(&specs).any(|(a, b)| !std::ptr::eq(*a, *b)) { + l.generation += 1; + } + l.revision = Some(revision); + l.specs.clone_from(&specs); + specs +} + +/// Changes whenever the plug-in commands [`command_specs`] lists change, for caches built from +/// them (the keyboard shortcuts' catalogue). +pub fn commands_generation() -> u64 { + command_specs(); + LISTED.read().map(|l| l.generation).unwrap_or_else(|e| e.into_inner().generation) +} + +/// The spec of installed plug-in command `id`. +pub fn find_spec(id: &str) -> Option<&'static CommandSpec> { + if !is_command_id(id) { + return None; + } + command_specs().into_iter().find(|c| c.id == id) +} + +/// The command spec of plug-in `p`'s command `c`: its id, its label as menus show it (escaped and +/// capped), its menu (Object › Plug-ins › the plug-in's name, or Effect › Plug-ins › it for a live +/// effect: never a menu the manifest names) and its params description. +fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec { + let m = p.manifest(); + let id = command_id(&m.id, &c.id); + let label = c.shown_label(); + let top = if m.kind == Kind::Effect { "Effect" } else { "Object" }; + let name = permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME); + let mut fields = String::from("ids?: [..] (default: the selection), params?: {…} (its parameters; or give them as top-level keys)"); + let doc = m.for_command(c).params_doc(); + if let Some(own) = doc.strip_prefix('{').and_then(|d| d.strip_suffix('}')).filter(|d| !d.is_empty()) { + fields.push_str(", "); + fields.push_str(own); + } + let params = format!( + "{{{fields}}} run “{label}”, a command of plug-in {}, on the selected paths and compound paths as plugin.run runs a filter, with _context.command {:?}: one undo step → {{plugin, command, ids}}", + m.id, c.id + ); + let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); + let same = |s: &&&'static CommandSpec| s.id == id && s.label == label && s.menu == [top, "Plug-ins", name.as_str()] && s.params == params; + if let Some(s) = made.iter().find(same) { + return s; + } + let menu: &'static [&'static str] = Box::leak(vec![top, "Plug-ins", registry::intern(&name)].into_boxed_slice()); + let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec { + id: registry::intern(&id), + label: registry::intern(&label), + menu, + shortcut: None, + params: registry::intern(¶ms), + enabled: has_doc, + run: unrouted, + journal: true, + })); + made.push(spec); + spec +} + +/// A plug-in command's `run`: plug-in commands run through [`super::run_spec`], which passes +/// [`run_command`] the command id. +fn unrouted(_: &mut Session, _: &Value) -> Result { + Err(EngineError::Internal { cmd: "plugin".into(), msg: "a plug-in command runs through Session::execute".into() }) +} + +/// Runs plug-in command `id` (`plugin..`): a filter run of the plug-in on +/// `ids` (else the selection) with the command's own parameters, checked before it runs, and +/// `_context.command`, under the plug-in's permissions and budget, applied as one undo step named +/// after the command → `{plugin, command, ids}`. A run that fails (a trap, an exhausted budget, an +/// output it may not make) leaves the document as it was, and the error names the plug-in and the +/// command. +pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result { + // An output handed in is for `plugin.run` alone. + s.plugin_output = None; + let (plugin, command) = command_of(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + let failed = { + let (name, label) = (permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME), command.shown_label()); + move |e: EngineError| match e { + EngineError::Cancelled => EngineError::Cancelled, + e => EngineError::Other(format!("“{name}” › {label}: {e}")), + } + }; + let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?; + let output = r.run().map_err(&failed)?; + r.finish(s, &output).map_err(failed) +} + /// Admits the effect records a filter returned for object `n` (written, its effects still its own) /// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed /// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object @@ -493,7 +669,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 41946c0eb..c15ebf41d 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -313,9 +313,10 @@ impl Session { true } - /// Commands with enablement (for menus, palette, MCP `list_commands`). + /// Commands with enablement (for menus, palette, MCP `list_commands`), the installed plug-ins' + /// commands last. pub fn commands(&self) -> Vec { - command_specs().iter().map(|c| c.info(self)).collect() + command_specs().iter().chain(crate::cmd::plugin::command_specs()).map(|c| c.info(self)).collect() } } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index f993ac585..d78c11d04 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -91,7 +91,7 @@ impl Session { if let Some(ids) = &select_first { s.execute("select.set", &serde_json::json!({ "ids": ids }))?; } - let r = (spec.run)(s, params); + let r = cmd::run_spec(s, spec, params); if r.is_err() && let Some((uid, selection, layer)) = selected_before && let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 36694ef87..2dfd61b48 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -854,3 +854,139 @@ fn a_cancelled_filter_run_changes_nothing() { assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); assert_eq!(s.doc().unwrap().doc, before); } + +/// The manifest of a filter `id` named "Cmd Test" of `kind` with `commands` (a JSON list). +fn with_commands(id: &str, kind: &str, commands: &str) -> String { + format!(r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"{kind}","commands":{commands}}}"#) +} + +/// Two commands: Grow, with a parameter, and Tidy, whose `menu` names another menu. +const GROW_AND_TIDY: &str = r#"[{"id":"grow","label":"Grow","params":{"by":{"type":"number","min":0,"max":10,"default":2}}}, + {"id":"tidy","label":"Tidy","menu":"File"}]"#; + +/// A plug-in that adds a triangle when it runs as its command `grow` (`_context.command`) and +/// returns its input unchanged otherwise. +fn grower(id: &str) -> String { + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + tpl::when_given(&with_commands(id, "filter", GROW_AND_TIDY), tpl::Given::Params, r#""command":"grow""#, triangle) +} + +/// The command listing's entry for `id`. +fn listed(s: &Session, id: &str) -> Option { + s.commands().into_iter().find(|c| c.id == id) +} + +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() +} + +/// A plug-in's commands are commands of their own while it is installed: listed under Object › +/// Plug-ins › its name whatever menu the manifest names, run with their checked parameters and +/// `_context.command` as one undo step named after them, and gone once it is removed or replaced +/// by a module without them. +#[test] +fn plugin_commands_are_registered_run_and_removed() { + let mut s = session(); + let id = "test.engine.cmds"; + let (grow, tidy) = ("plugin.test.engine.cmds.grow", "plugin.test.engine.cmds.tidy"); + let info = install_wat(&mut s, &grower(id)); + assert_eq!(info["commands"][0]["command"], grow); + assert_eq!(info["commands"][1]["label"], "Tidy"); + for (cmd, label) in [(grow, "Grow"), (tidy, "Tidy")] { + let c = listed(&s, cmd).unwrap_or_else(|| panic!("{cmd} is listed")); + assert_eq!((c.label, c.menu.as_slice()), (label, &["Object", "Plug-ins", "Cmd Test"][..]), "{cmd}"); + assert!(c.enabled, "{cmd}"); + } + assert!(crate::cmd::find_command(grow).is_some()); + + run(&mut s, "select.none", json!({})); + let (before, undo) = (objects(&s), s.doc().unwrap().history.undo.len()); + let r = run(&mut s, grow, json!({"by": 3})); + assert_eq!((r["plugin"].as_str(), r["command"].as_str()), (Some(id), Some("grow"))); + assert_eq!(objects(&s), before + 1, "the command added its triangle"); + let st = s.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Grow")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(objects(&s), before); + // Its parameters are checked before it runs. + let e = s.execute(grow, &json!({"by": "lots"})).unwrap_err(); + assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == grow), "{e}"); + assert_eq!(objects(&s), before); + // Another command of the same plug-in is told apart by its id. + run(&mut s, tidy, json!({})); + assert_eq!(objects(&s), before, "tidy returns its input unchanged"); + + // Replaced by a module without commands, its commands are gone; installed again, back. + install_wat(&mut s, &tpl::module(&tpl::manifest(id, "filter", "{}"), tpl::ECHO, "", 1)); + assert!(listed(&s, grow).is_none() && crate::cmd::find_command(grow).is_none()); + install_wat(&mut s, &grower(id)); + assert!(listed(&s, grow).is_some()); + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(listed(&s, grow).is_none() && listed(&s, tidy).is_none()); + assert!(s.execute(grow, &json!({})).is_err(), "removed, it no longer runs"); +} + +/// A command run is journaled like any other, so an action records it with its parameters and +/// plays it back. +#[test] +fn actions_record_and_play_plugin_commands() { + let mut s = session(); + let id = "test.engine.cmds.actions"; + let grow = crate::cmd::plugin::command_id(id, "grow"); + install_wat(&mut s, &grower(id)); + run(&mut s, "select.none", json!({})); + let before = objects(&s); + let start = s.journal.len(); + run(&mut s, &grow, json!({"by": 4})); + let steps = s.journal_for_action(start); + assert_eq!(steps, [(grow.clone(), json!({"by": 4}))]); + run(&mut s, "edit.undo", json!({})); + let commands: Vec = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect(); + // The triangle a run adds is selected, and the next run would be given it: deselect between. + let none = json!({"command": "select.none", "params": {}}); + run(&mut s, "command.batch", json!({"label": "Grow Twice", "commands": [commands[0].clone(), none, commands[0].clone()]})); + assert_eq!(objects(&s), before + 2, "both steps ran"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A command that traps leaves the document and its history as they were, and the error names +/// the plug-in and the command. +#[test] +fn a_failing_plugin_command_changes_nothing_and_names_itself() { + let mut s = session(); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "select.all", json!({})); + let id = "test.engine.cmds.trap"; + install_wat(&mut s, &tpl::module(&with_commands(id, "filter", r#"[{"id":"boom","label":"Boom"}]"#), "unreachable", "", 1)); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute(&crate::cmd::plugin::command_id(id, "boom"), &json!({})).unwrap_err().to_string(); + assert!(e.contains("“Cmd Test” › Boom: ") && e.contains("trapped"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A live effect's commands are listed under Effect › Plug-ins, run under its permissions (it may +/// not change the document), and labels show bidirectional characters escaped. A malformed +/// `commands` entry is refused at install, and the error names it. +#[test] +fn effect_commands_labels_and_malformed_entries() { + let mut s = session(); + let id = "test.engine.cmds.effect"; + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + install_wat(&mut s, &tpl::returning(&with_commands(id, "effect", "[{\"id\":\"flip\",\"label\":\"Flip\u{202E}txt\"}]"), triangle)); + let c = listed(&s, &crate::cmd::plugin::command_id(id, "flip")).expect("listed"); + assert_eq!((c.label, c.menu.as_slice()), ("Flip\\u{202E}txt", &["Effect", "Plug-ins", "Cmd Test"][..])); + run(&mut s, "select.none", json!({})); + let before = s.doc().unwrap().doc.clone(); + let e = s.execute(&crate::cmd::plugin::command_id(id, "flip"), &json!({})).unwrap_err().to_string(); + assert!(e.contains("wasn't granted `doc.write`"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + run(&mut s, "plugin.remove", json!({"id": id})); + + let bad = id.replace("effect", "bad"); + let wasm = wat::parse_str(tpl::module(&with_commands(&bad, "filter", r#"[{"id":"ok","label":"A"},{"id":"Bad","label":"B"}]"#), tpl::ECHO, "", 1)); + let e = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm.unwrap())})).unwrap_err().to_string(); + assert!(e.contains("command `Bad`"), "{e}"); + assert!(vectorcraft_plugins::registry::get(&bad).is_none()); +} diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1f29627e0..4b853889d 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -320,4 +320,78 @@ proptest! { vectorcraft_plugins::registry::remove("fuzz.permissions"); vectorcraft_plugins::registry::revoke("fuzz.permissions"); } + + /// Manifests with hostile `commands` (junk of every type, bad and repeated ids, labels with + /// control and bidirectional override characters, a `menu` naming another menu, junk + /// parameters) parse to an error or to commands whose menu labels show no hidden character and + /// that sit under Plug-ins alone; installing one and running each of its commands ends as an + /// error or an edit, never a panic, and the document then renders and exports. + #[test] + fn hostile_manifest_commands_never_crash( + kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + entries in prop::collection::vec( + prop_oneof![ + 3 => ( + prop_oneof![Just(json!("grow")), Just(json!("a_1")), Just(json!("Grow")), Just(json!("")), Just(json!("a.b")), arb_junk()], + prop_oneof![ + 3 => "[a-zA-Z\u{202E}\u{2066}\u{200F} ]{0,70}".prop_map(Value::from), + 1 => "[a-z\u{0007}\n]{1,8}".prop_map(Value::from), + 1 => arb_junk(), + ], + prop_oneof![Just(Value::Null), Just(json!("File")), arb_junk()], + prop_oneof![Just(Value::Null), Just(json!({"by": {"type": "number", "min": 0, "max": 10, "default": 2}})), arb_junk()], + ) + .prop_map(|(id, label, menu, params)| { + let mut c = json!({"id": id, "label": label}); + if !menu.is_null() { + c["menu"] = menu; + } + if !params.is_null() { + c["params"] = params; + } + c + }), + 1 => arb_junk(), + ], + 0..5, + ), + junk in prop::option::weighted(0.1, arb_junk()), + given in prop_oneof![Just(json!({})), Just(json!({"by": 3})), arb_junk()], + ) { + let mut m = json!({"id": "fuzz.commands", "name": "Fuzz \u{202E}Commands", "kind": kind}); + if kind == "exporter" { + m["extensions"] = json!(["fz"]); + } + m["commands"] = junk.unwrap_or(json!(entries)); + let text = m.to_string(); + let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); + let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; + if let Ok(man) = &parsed { + for c in &man.commands { + let shown = c.shown_label(); + prop_assert!(!shown.chars().any(|ch| ch.is_control() || matches!(ch, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); + prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + } + } + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap(); + let r = catch_quiet(|| { + let mut ran = vec![]; + if install(&mut s, &module).is_ok() { + for c in s.commands().into_iter().filter(|c| c.id.starts_with("plugin.fuzz.commands.")) { + ran.push((c.id, c.menu.clone())); + let _ = s.execute(c.id, &given); + } + } + ran + }); + let ran = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?; + prop_assert!(ran.is_empty() || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text); + for (id, menu) in &ran { + prop_assert!(menu.len() == 3 && menu[1] == "Plug-ins" && matches!(menu[0], "Object" | "Effect"), "{id}: {menu:?}"); + } + renders(&s, "after hostile commands")?; + vectorcraft_plugins::registry::remove("fuzz.commands"); + } } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 9e6f9e934..f7fde222d 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -22,7 +22,9 @@ //! document in a file format of its own. The process-wide [`registry`] holds the installed //! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show //! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is -//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. +//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. A +//! filter or a live effect may also add menu commands of its own ([`Command`]), which the host +//! runs through `vc_run` with the command's id in `_context`. #![forbid(unsafe_code)] pub mod dialog; @@ -36,7 +38,7 @@ mod runtime; pub mod wat; pub use dialog::ParamUi; -pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; +pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; /// What the host puts before a plug-in's id to name what it adds: its live effect diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 75a76cdbb..fe543d7ea 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -20,6 +20,10 @@ pub const MAX_TEXT: usize = 4_096; pub const MAX_LIST: usize = 256; /// Most file extensions an exporter may declare. pub const MAX_EXTENSIONS: usize = 8; +/// Most menu commands a plug-in may add (`commands`). +pub const MAX_COMMANDS: usize = 32; +/// Longest command id, in characters. +pub const MAX_COMMAND_ID: usize = 32; /// What a plug-in is. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -172,6 +176,30 @@ pub struct Param { pub ui: ParamUi, } +/// A menu command a plug-in adds (`commands`): the host lists it under Object › Plug-ins › the +/// plug-in's name (Effect › Plug-ins for a live effect's), runs it as the command +/// `plugin..`, and calls the plug-in's `vc_run` with `_context.command` set to +/// `id` and the parameters it declares. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Command { + /// `[a-z][a-z0-9_]*`, at most [`MAX_COMMAND_ID`] characters, unique within the manifest. + pub id: String, + /// The menu label: 1 to 64 printable characters, shown with bidirectional characters escaped + /// ([`crate::permissions::display_text`]). + pub label: String, + /// Parameters in declaration (dialog) order, declared as the manifest's own are. + #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] + pub params: Vec<(String, Param)>, +} + +impl Command { + /// The label as menus and errors show it: control and bidirectional characters escaped, at + /// most [`crate::permissions::MAX_SHOWN_NAME`] characters. + pub fn shown_label(&self) -> String { + crate::permissions::display_text(self.label.trim(), crate::permissions::MAX_SHOWN_NAME) + } +} + impl From for Param { fn from(spec: ParamSpec) -> Self { Param { spec, ui: ParamUi::default() } @@ -260,6 +288,9 @@ pub struct Manifest { /// How much a run may compute: only a filter may be [`Budget::Heavy`]. #[serde(default, skip_serializing_if = "Budget::is_light")] pub budget: Budget, + /// The menu commands it adds, in menu order (a filter or a live effect only). + #[serde(default, deserialize_with = "ordered_commands", skip_serializing_if = "Vec::is_empty")] + pub commands: Vec, } impl Manifest { @@ -288,9 +319,21 @@ impl Manifest { params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, + commands: Vec::new(), } } + /// The manifest command `c` runs with: this one with `c`'s label as its name and `c`'s + /// parameters as its own, so dialogs show them and [`Manifest::resolve_params`] checks them. + pub fn for_command(&self, c: &Command) -> Manifest { + Manifest { name: c.label.clone(), params: c.params.clone(), commands: Vec::new(), ..self.clone() } + } + + /// The command with id `id`. + pub fn command(&self, id: &str) -> Option<&Command> { + self.commands.iter().find(|c| c.id == id) + } + /// Checks what the module declared: its id, name and text lengths, its extensions, a budget its /// kind may have (only a filter may be heavy), and each parameter and its dialog layout. fn validate(&self) -> Result<()> { @@ -320,6 +363,12 @@ impl Manifest { return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into())); } } + self.validate_params()?; + self.validate_commands() + } + + /// Checks each parameter and its dialog layout. + fn validate_params(&self) -> Result<()> { for (i, (k, p)) in self.params.iter().enumerate() { if !valid_key(k) { return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); @@ -333,6 +382,47 @@ impl Manifest { Ok(()) } + /// A filter or a live effect lists at most [`MAX_COMMANDS`] commands, each with a distinct + /// id, a printable label and parameters declared as the manifest's own are. A heavy filter + /// lists none. The error names the command: its id, or its place in the list. + fn validate_commands(&self) -> Result<()> { + if self.commands.is_empty() { + return Ok(()); + } + if self.kind == Kind::Exporter { + return Err(Error::Manifest("only a filter or a live effect declares `commands`".into())); + } + if self.budget == Budget::Heavy { + return Err(Error::Manifest("a heavy filter runs in the background, so it can't declare `commands`".into())); + } + if self.commands.len() > MAX_COMMANDS { + return Err(Error::Manifest(format!("at most {MAX_COMMANDS} `commands`"))); + } + for (i, c) in self.commands.iter().enumerate() { + let name = command_name(i, &c.id); + if !valid_command_id(&c.id) { + return Err(Error::Manifest(format!( + "command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter" + ))); + } + let label = c.label.trim(); + if label.is_empty() || label.chars().count() > 64 || label.chars().any(char::is_control) { + return Err(Error::Manifest(format!("command {name}: `label` must be 1-64 printable characters"))); + } + if self.commands.get(..i).is_some_and(|before| before.iter().any(|b| b.id == c.id)) { + return Err(Error::Manifest(format!("command {name} is declared twice"))); + } + self.for_command(c).validate_params().map_err(|e| { + let e = match e { + Error::Manifest(why) => why, + other => other.to_string(), + }; + Error::Manifest(format!("command {name}: {e}")) + })?; + } + Ok(()) + } + /// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case /// letters and digits; other kinds list none. fn validate_extensions(&self) -> Result<()> { @@ -488,6 +578,45 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result d.deserialize_any(V) } +/// Reads the `commands` list one entry at a time, so a malformed entry is an error that names it +/// (its id, or its place in the list), and a list longer than [`MAX_COMMANDS`] is refused before +/// its entries are read. +fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + use serde::de::Error as _; + let entries = match Value::deserialize(d)? { + Value::Null => return Ok(Vec::new()), + Value::Array(entries) => entries, + _ => return Err(D::Error::custom("`commands` must be a list of {id, label, params?}")), + }; + if entries.len() > MAX_COMMANDS { + return Err(D::Error::custom(format!("at most {MAX_COMMANDS} `commands`"))); + } + entries + .into_iter() + .enumerate() + .map(|(i, v)| { + let name = command_name(i, v.get("id").and_then(Value::as_str).unwrap_or_default()); + serde_json::from_value::(v).map_err(|e| D::Error::custom(format!("command {name}: {e}"))) + }) + .collect() +} + +/// How errors name the command at place `i` with id `id`: the id in backticks, escaped and capped +/// as prompts show text, or `[i]` when it has none. +fn command_name(i: usize, id: &str) -> String { + match id { + "" => format!("[{i}]"), + id => format!("`{}`", crate::permissions::display_text(id, crate::permissions::MAX_SHOWN_FIELD)), + } +} + +/// `[a-z][a-z0-9_]*`, 1 to [`MAX_COMMAND_ID`] characters. +fn valid_command_id(id: &str) -> bool { + id.len() <= MAX_COMMAND_ID + && id.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) + && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} + fn params_as_object(p: &[(String, Param)], s: S) -> std::result::Result { use serde::ser::SerializeMap; let mut m = s.serialize_map(Some(p.len()))?; @@ -663,6 +792,68 @@ mod tests { } } + /// A filter or a live effect lists menu commands with their own parameters; a `menu` key is + /// ignored. A malformed entry, a bad or repeated id, a label that isn't printable and too many + /// commands are refused, and the error names the command. + #[test] + fn commands_are_checked_and_errors_name_them() { + let m = Manifest::parse( + br#"{"id":"org.example.tidy","name":"Tidy","kind":"filter","commands":[ + {"id":"tidy_all","label":"Tidy All","menu":"File"}, + {"id":"grow","label":"Grow","params":{"by":{"type":"number","min":0,"max":10,"default":2},"mode":{"type":"bool"}}}]}"#, + ) + .unwrap(); + assert_eq!(m.commands.iter().map(|c| c.id.as_str()).collect::>(), ["tidy_all", "grow"]); + let grow = m.command("grow").unwrap(); + assert_eq!(grow.params.iter().map(|p| p.0.as_str()).collect::>(), ["by", "mode"]); + let run = m.for_command(grow); + assert_eq!((run.name.as_str(), run.id.as_str()), ("Grow", "org.example.tidy")); + assert_eq!(Value::Object(run.resolve_params(&json!({"by": 50})).unwrap()), json!({"by": 10.0, "mode": false})); + let written = serde_json::to_value(&m).unwrap(); + assert_eq!(written["commands"][1]["params"]["by"]["max"], json!(10.0)); + assert!(written["commands"][0].get("menu").is_none(), "a menu key is not kept"); + assert!(serde_json::to_value(Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap()).unwrap().get("commands").is_none()); + let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","commands":[{"id":"c","label":"C"}]}"#).unwrap(); + assert_eq!(effect.commands.len(), 1); + let label = |l: &str| Command { id: "c".into(), label: l.into(), params: Vec::new() }.shown_label(); + assert_eq!(label("Flip\u{202E}txt.exe"), "Flip\\u{202E}txt.exe"); + + let many: Vec = (0..=MAX_COMMANDS).map(|i| format!(r#"{{"id":"c{i}","label":"C"}}"#)).collect(); + let too_many = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{}]}}"#, many.join(",")); + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"ok","label":"A"},{"label":"B"}]}"#, "command [1]: missing field `id`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"nolabel"}]}"#, "command `nolabel`: missing field `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"Upper","label":"A"}]}"#, "command `Upper`: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"9lives","label":"A"}]}"#, "command `9lives`: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"a.b","label":"A"}]}"#, "command `a.b`: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"","label":"A"}]}"#, "command [0]: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":" "}]}"#, "command `x`: `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"a\nb"}]}"#, "command `x`: `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A"},{"id":"x","label":"B"}]}"#, "command `x` is declared twice"), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}]}"#, + "command `x`: parameter `r`: bad range", + ), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"id":{"type":"bool"}}}]}"#, + "command `x`: invalid parameter", + ), + (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0]"), + (r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, "must be a list"), + (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"commands":[{"id":"x","label":"A"}]}"#, "only a filter or a live effect"), + (r#"{"id":"a","name":"x","kind":"filter","budget":"heavy","commands":[{"id":"x","label":"A"}]}"#, "heavy filter"), + (too_many.as_str(), "at most 32 `commands`"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + let long_id = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{{"id":"{}","label":"A"}}]}}"#, "c".repeat(MAX_COMMAND_ID + 1)); + assert!(Manifest::parse(long_id.as_bytes()).is_err()); + let hidden = + Manifest::parse("{\"id\":\"a\",\"name\":\"x\",\"kind\":\"filter\",\"commands\":[{\"id\":\"x\u{202E}\",\"label\":\"A\"}]}".as_bytes()); + assert!(hidden.unwrap_err().to_string().contains("command `x\\u{202E}`"), "an id in an error is escaped"); + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index eef4787ee..eebb29018 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -6,14 +6,16 @@ //! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live //! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs //! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes: -//! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › +//! it runs in the background ([`crate::plugin_runs`]). A filter or live effect with commands of +//! its own is a submenu named after it, holding its item and then its commands; a command with +//! parameters opens the same dialog for them ([`open_command`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its //! Export button writes the file. use std::sync::Arc; use serde_json::{Map, Value, json}; -use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, registry}; +use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, permissions, registry}; use super::{DialogSpec, form}; use crate::VectorcraftApp; @@ -38,12 +40,30 @@ fn label(name: &str, has_params: bool) -> &'static str { registry::intern(&format!("{name}{dots}")) } -/// Object › Plug-ins: the installed object filters, then Install Plug-in… and Reload Plug-ins. +/// A filter or live effect `plugin`'s item `own`, as the menu lists it: alone, or for a plug-in +/// with commands of its own, a submenu named after it holding `own` and then its commands. Each +/// command runs its command id (`plugin..`), with an ellipsis when it has parameters, +/// whose dialog [`open_command`] opens. +fn with_commands(plugin: &Plugin, own: Item) -> Item { + let m = plugin.manifest(); + if m.commands.is_empty() { + return own; + } + let mut items = vec![own, Item::Sep]; + items.extend(m.commands.iter().map(|c| { + let id = registry::intern(&vectorcraft_engine::cmd::plugin::command_id(&m.id, &c.id)); + Item::Cmd(label(&c.shown_label(), !c.params.is_empty()), id, Value::Null) + })); + Item::Sub(registry::intern(&permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME)), items) +} + +/// Object › Plug-ins: the installed object filters (each with its commands, if it has any), then +/// Install Plug-in… and Reload Plug-ins. pub fn object_menu() -> Vec { let mut items: Vec = registry::list() .iter() .filter(|p| p.manifest().kind == Kind::Filter) - .map(|p| Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() }))) + .map(|p| with_commands(p, Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() })))) .collect(); if items.is_empty() { items.push(Item::Header(NO_FILTERS)); @@ -56,14 +76,20 @@ pub fn object_menu() -> Vec { items } -/// Effect › Plug-ins: the live-effect plug-ins (`None` while none is installed). Effects without -/// options apply at once, as built-in ones do. +/// Effect › Plug-ins: the live-effect plug-ins, each with its commands if it has any (`None` +/// while none is installed). Effects without options apply at once, as built-in ones do. pub fn effect_menu() -> Option { let items: Vec = vectorcraft_effects::plugin_effects() .into_iter() - .map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { - true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), - false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + .map(|e| { + let own = match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }; + match vectorcraft_plugins::effect::plugin_id(e.id).and_then(registry::get) { + Some(plugin) => with_commands(&plugin, own), + None => own, + } }) .collect(); (!items.is_empty()).then_some(Item::Sub("Plug-ins", items)) @@ -113,8 +139,44 @@ fn described(ui: &mut egui::Ui, d: &Dialog) -> Option> { Some(plugin) } +/// Opens the dialog of plug-in command `id` (`plugin..`), given no params, +/// when it has parameters and a document is open: its fields at their defaults (`__command` is the +/// command id) with a live Preview, and OK keeps the run as one undo step. False for any other +/// command, which then runs as it was given. +pub fn open_command(app: &mut VectorcraftApp, id: &str) -> bool { + let Some((plugin, c)) = vectorcraft_engine::cmd::plugin::command_of(id) else { return false }; + if c.params.is_empty() || app.session.active().is_none() { + return false; + } + let mut fields = fields_for(&plugin.manifest().for_command(&c)); + fields.insert("__command".into(), json!(id)); + fields.insert("__label".into(), json!(c.shown_label())); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + true +} + +/// The plug-in command dialog `d` is for (`__command`), and the manifest it runs with; `None` for +/// a filter's own dialog. +fn command_of(d: &Dialog) -> Option { + let (plugin, c) = vectorcraft_engine::cmd::plugin::command_of(&d.str("__command"))?; + Some(plugin.manifest().for_command(&c)) +} + +/// The command dialog `d` runs: its plug-in command, or `plugin.run`. +fn command(d: &Dialog) -> String { + match d.str("__command") { + c if c.is_empty() => CMD.to_string(), + c => c, + } +} + +/// The params of the command dialog `d` runs ([`command`]). fn params(d: &Dialog) -> Value { - json!({"id": d.str("__plugin"), "params": form::params(d)}) + match d.str("__command").is_empty() { + true => json!({"id": d.str("__plugin"), "params": form::params(d)}), + false => json!({"params": form::params(d)}), + } } /// Runs filter `plugin` with `plugin.run`'s params `p`: at once, or in the background when it is @@ -126,15 +188,20 @@ fn run(app: &mut VectorcraftApp, plugin: &Plugin, p: Value) -> Result bool { let Some(plugin) = described(ui, d) else { return true }; - form::schema_fields(app, ui, d, plugin.manifest()); + let run_with = command_of(d); + if run_with.is_none() && !d.str("__command").is_empty() { + return true; + } + form::schema_fields(app, ui, d, run_with.as_ref().unwrap_or(plugin.manifest())); match plugin.manifest().budget { Budget::Light => { let (label, p) = (d.str("__label"), params(d)); - form::preview(app, ui, d, &label, CMD, p); + form::preview(app, ui, d, &label, &command(d), p); } // No Preview: each run may take minutes. Budget::Heavy => { @@ -157,7 +224,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } r } - _ => form::commit_preview(app, CMD, params(d)), + _ => form::commit_preview(app, &command(d), params(d)), } } diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index c65b4e602..7360b3a32 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -375,9 +375,13 @@ pub fn candidates() -> Vec { .collect() } -/// Keyboard Shortcuts' entry `key`. +/// Keyboard Shortcuts' entry `key`. The commands of installed plug-ins are looked up in +/// [`shortcut_editor::entries`] as it is now. fn entry(key: &str) -> Option<&'static Entry> { static BY_KEY: OnceLock> = OnceLock::new(); + if vectorcraft_engine::cmd::plugin::is_command_id(key) { + return shortcut_editor::entries().iter().find(|e| e.key == key); + } BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.as_str(), e)).collect()).get(key).copied() } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index f0f7cef6d..0202d8db8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -597,6 +597,7 @@ impl PluginIntField { params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), + commands: Vec::new(), }; let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO }; fn number_rect(shape: &egui::Shape, text: &str) -> Option { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9e2fc7837..4af530924 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5819,6 +5819,14 @@ digits Malayalam Malaiàlam @msg unknown permission `{shown}` permís desconegut `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res +@msg a heavy filter runs in the background, so it can't declare `commands` un filtre pesat s’executa en segon pla, de manera que no pot declarar `commands` +@msg at most {MAX_COMMANDS} `commands` com a màxim {MAX_COMMANDS} `commands` +@msg command {name} is declared twice l’ordre {name} està declarada dues vegades +@msg command {name}: `label` must be 1-64 printable characters ordre {name}: `label` ha de tenir 1-64 caràcters imprimibles +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ordre {name}: fes servir un id d’1-{MAX_COMMAND_ID} caràcters d’a-z 0-9 _ que comenci per una lletra +@msg command {name}: {e} ordre {name}: {e} +@msg only a filter or a live effect declares `commands` només un filtre o un efecte dinàmic declara `commands` +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res @msg “{name}” is not a file «{name}» no és un fitxer axis Both Tots dos diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5975f088a..9bfb0d884 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5809,6 +5809,14 @@ axis Y Axis osa Y @msg unknown permission `{shown}` neznámé oprávnění `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo +@msg a heavy filter runs in the background, so it can't declare `commands` těžký filtr běží na pozadí, takže nemůže deklarovat `commands` +@msg at most {MAX_COMMANDS} `commands` nejvýše {MAX_COMMANDS} `commands` +@msg command {name} is declared twice příkaz {name} je deklarován dvakrát +@msg command {name}: `label` must be 1-64 printable characters příkaz {name}: `label` musí mít 1-64 tisknutelných znaků +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter příkaz {name}: použijte id o 1-{MAX_COMMAND_ID} znacích z a-z 0-9 _, které začíná písmenem +@msg command {name}: {e} příkaz {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` deklaruje jen filtr nebo živý efekt +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno # Actions panel: action sets (#3712). New Set… Nová sada… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c0c2717b..2a9994019 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5391,6 +5391,14 @@ axis Y Axis Y-Achse @msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert +@msg a heavy filter runs in the background, so it can't declare `commands` ein schwerer Filter läuft im Hintergrund und kann daher keine `commands` deklarieren +@msg at most {MAX_COMMANDS} `commands` höchstens {MAX_COMMANDS} `commands` +@msg command {name} is declared twice der Befehl {name} ist doppelt deklariert +@msg command {name}: `label` must be 1-64 printable characters Befehl {name}: `label` muss 1-64 druckbare Zeichen haben +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter Befehl {name}: Verwenden Sie eine ID aus 1-{MAX_COMMAND_ID} Zeichen aus a-z 0-9 _, die mit einem Buchstaben beginnt +@msg command {name}: {e} Befehl {name}: {e} +@msg only a filter or a live effect declares `commands` nur ein Filter oder ein Live-Effekt deklariert `commands` +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert # Actions panel: action sets (#3712). New Set… Neuer Satz… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 79f4b0cc0..8764bfe73 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5760,6 +5760,14 @@ axis Y Axis Άξονας Y @msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα +@msg a heavy filter runs in the background, so it can't declare `commands` ένα βαρύ φίλτρο εκτελείται στο παρασκήνιο, οπότε δεν μπορεί να δηλώνει `commands` +@msg at most {MAX_COMMANDS} `commands` το πολύ {MAX_COMMANDS} `commands` +@msg command {name} is declared twice η εντολή {name} δηλώνεται δύο φορές +@msg command {name}: `label` must be 1-64 printable characters εντολή {name}: το `label` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter εντολή {name}: χρησιμοποιήστε αναγνωριστικό 1-{MAX_COMMAND_ID} χαρακτήρων από a-z 0-9 _ που αρχίζει με γράμμα +@msg command {name}: {e} εντολή {name}: {e} +@msg only a filter or a live effect declares `commands` μόνο ένα φίλτρο ή ένα δυναμικό εφέ δηλώνει `commands` +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα # Actions panel: action sets (#3712). New Set… Νέο σύνολο… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 420254d25..4752576ba 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5750,6 +5750,14 @@ axis Y Axis Eje Y @msg unknown permission `{shown}` permiso desconocido `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada +@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesado se ejecuta en segundo plano, así que no puede declarar `commands` +@msg at most {MAX_COMMANDS} `commands` como máximo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice el comando {name} está declarado dos veces +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` debe tener 1-64 caracteres imprimibles +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que empiece por una letra +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` solo un filtro o un efecto interactivo declara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada # Actions panel: action sets (#3712). New Set… Nuevo conjunto… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 0cbec9246..f76d6ce09 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5839,6 +5839,14 @@ axis Y Axis Y-akseli @msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu +@msg a heavy filter runs in the background, so it can't declare `commands` raskas suodin suoritetaan taustalla, joten se ei voi määritellä `commands`-kohtaa +@msg at most {MAX_COMMANDS} `commands` enintään {MAX_COMMANDS} `commands` +@msg command {name} is declared twice komento {name} on määritelty kahdesti +@msg command {name}: `label` must be 1-64 printable characters komento {name}: `label`-kentässä on oltava 1-64 tulostettavaa merkkiä +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter komento {name}: käytä 1-{MAX_COMMAND_ID} merkin tunnusta merkeistä a-z 0-9 _, joka alkaa kirjaimella +@msg command {name}: {e} komento {name}: {e} +@msg only a filter or a live effect declares `commands` vain suodin tai live-tehoste määrittelee `commands`-kohdan +@msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu # Actions panel: action sets (#3712). New Set… Uusi sarja… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f87b0cde3..c53f5c4d5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5804,6 +5804,14 @@ axis Y Axis Axe Y @msg unknown permission `{shown}` autorisation inconnue `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@msg a heavy filter runs in the background, so it can't declare `commands` un filtre lourd s’exécute en arrière-plan, il ne peut donc pas déclarer `commands` +@msg at most {MAX_COMMANDS} `commands` au plus {MAX_COMMANDS} `commands` +@msg command {name} is declared twice la commande {name} est déclarée deux fois +@msg command {name}: `label` must be 1-64 printable characters commande {name} : `label` doit compter 1-64 caractères imprimables +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter commande {name} : utilisez un id de 1-{MAX_COMMAND_ID} caractères parmi a-z 0-9 _ commençant par une lettre +@msg command {name}: {e} commande {name} : {e} +@msg only a filter or a live effect declares `commands` seul un filtre ou un effet dynamique déclare `commands` +@msg “{name}” › {label}: {e} « {name} » › {label} : {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). New Set… Nouveau jeu… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index a045da15b..6242c9d51 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5194,6 +5194,14 @@ @msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला +@msg a heavy filter runs in the background, so it can't declare `commands` भारी फ़िल्टर पृष्ठभूमि में चलता है, इसलिए वह `commands` घोषित नहीं कर सकता +@msg at most {MAX_COMMANDS} `commands` अधिकतम {MAX_COMMANDS} `commands` +@msg command {name} is declared twice कमांड {name} दो बार घोषित है +@msg command {name}: `label` must be 1-64 printable characters कमांड {name}: `label` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter कमांड {name}: a-z 0-9 _ के 1-{MAX_COMMAND_ID} वर्णों वाली ऐसी id इस्तेमाल करें जो अक्षर से शुरू हो +@msg command {name}: {e} कमांड {name}: {e} +@msg only a filter or a live effect declares `commands` केवल फ़िल्टर या लाइव इफ़ेक्ट ही `commands` घोषित करता है +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @msg “{name}” is not a file "{name}" एक फ़ाइल नहीं है @msg ({note}) ({more}) ({note}) ({more}) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 031eb3791..9d66d791f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5792,6 +5792,14 @@ axis Y Axis Y tengely @msg unknown permission `{shown}` ismeretlen engedély: `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott +@msg a heavy filter runs in the background, so it can't declare `commands` a nehéz szűrő a háttérben fut, ezért nem deklarálhat `commands` értéket +@msg at most {MAX_COMMANDS} `commands` legfeljebb {MAX_COMMANDS} `commands` +@msg command {name} is declared twice a(z) {name} parancs kétszer van deklarálva +@msg command {name}: `label` must be 1-64 printable characters {name} parancs: a `label` 1-64 nyomtatható karakter legyen +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} parancs: használj 1-{MAX_COMMAND_ID} karakteres, a-z 0-9 _ karakterekből álló, betűvel kezdődő azonosítót +@msg command {name}: {e} {name} parancs: {e} +@msg only a filter or a live effect declares `commands` `commands` értéket csak szűrő vagy élő effektus deklarálhat +@msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve # Actions panel: action sets (#3712). New Set… Új készlet… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e5ee7d472..4927181d4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5425,6 +5425,14 @@ axis Y Axis Asse Y @msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla +@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesante viene eseguito in background, quindi non può dichiarare `commands` +@msg at most {MAX_COMMANDS} `commands` al massimo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice il comando {name} è dichiarato due volte +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve avere 1-64 caratteri stampabili +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id di 1-{MAX_COMMAND_ID} caratteri tra a-z 0-9 _ che inizi con una lettera +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` solo un filtro o un effetto dinamico dichiara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla # Actions panel: action sets (#3712). New Set… Nuovo set… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 355da7a35..517fa0e4a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5490,6 +5490,14 @@ axis Y Axis Y 軸 @msg unknown permission `{shown}` 不明な権限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした +@msg a heavy filter runs in the background, so it can't declare `commands` 重いフィルターはバックグラウンドで実行されるため、`commands` を宣言できません +@msg at most {MAX_COMMANDS} `commands` `commands` は最大 {MAX_COMMANDS} 個です +@msg command {name} is declared twice コマンド {name} が 2 回宣言されています +@msg command {name}: `label` must be 1-64 printable characters コマンド {name}: `label` は印刷可能な 1-64 文字にしてください +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter コマンド {name}: a-z 0-9 _ からなり英字で始まる 1-{MAX_COMMAND_ID} 文字の ID を使用してください +@msg command {name}: {e} コマンド {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` を宣言できるのはフィルターかライブ効果だけです +@msg “{name}” › {label}: {e} 「{name}」 › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした # Actions panel: action sets (#3712). New Set… 新規セット… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 237b5586a..6bc3b90cb 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5732,6 +5732,14 @@ axis Y Axis Y ღერძი @msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა +@msg a heavy filter runs in the background, so it can't declare `commands` მძიმე ფილტრი ფონურ რეჟიმში მუშაობს, ამიტომ მას არ შეუძლია `commands`-ის გამოცხადება +@msg at most {MAX_COMMANDS} `commands` მაქსიმუმ {MAX_COMMANDS} `commands` +@msg command {name} is declared twice ბრძანება {name} ორჯერაა გამოცხადებული +@msg command {name}: `label` must be 1-64 printable characters ბრძანება {name}: `label` უნდა შედგებოდეს 1-64 დასაბეჭდი სიმბოლოსგან +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ბრძანება {name}: გამოიყენეთ 1-{MAX_COMMAND_ID} სიმბოლოიანი id a-z 0-9 _ სიმბოლოებიდან, რომელიც ასოთი იწყება +@msg command {name}: {e} ბრძანება {name}: {e} +@msg only a filter or a live effect declares `commands` `commands`-ს მხოლოდ ფილტრი ან ცოცხალი ეფექტი აცხადებს +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა # Actions panel: action sets (#3712). New Set… ახალი ნაკრები… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5ca65c5f3..a402f57a7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5386,6 +5386,14 @@ axis Y Axis Y축 @msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다 +@msg a heavy filter runs in the background, so it can't declare `commands` 무거운 필터는 백그라운드에서 실행되므로 `commands`를 선언할 수 없습니다 +@msg at most {MAX_COMMANDS} `commands` `commands`는 최대 {MAX_COMMANDS}개입니다 +@msg command {name} is declared twice 명령 {name}이(가) 두 번 선언되었습니다 +@msg command {name}: `label` must be 1-64 printable characters 명령 {name}: `label`은 인쇄 가능한 문자 1-64자여야 합니다 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 명령 {name}: a-z 0-9 _로 이루어지고 문자로 시작하는 1-{MAX_COMMAND_ID}자 ID를 사용하세요 +@msg command {name}: {e} 명령 {name}: {e} +@msg only a filter or a live effect declares `commands` 필터나 라이브 효과만 `commands`를 선언합니다 +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 # Actions panel: action sets (#3712). New Set… 새 세트… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f9c6ca1ee..6ae0b2493 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5662,6 +5662,14 @@ axis Y Axis Oś Y @msg unknown permission `{shown}` nieznane uprawnienie `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono +@msg a heavy filter runs in the background, so it can't declare `commands` ciężki filtr działa w tle, więc nie może deklarować `commands` +@msg at most {MAX_COMMANDS} `commands` najwyżej {MAX_COMMANDS} `commands` +@msg command {name} is declared twice polecenie {name} jest zadeklarowane dwa razy +@msg command {name}: `label` must be 1-64 printable characters polecenie {name}: `label` musi mieć 1-64 drukowalnych znaków +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter polecenie {name}: użyj id z 1-{MAX_COMMAND_ID} znaków spośród a-z 0-9 _, zaczynającego się od litery +@msg command {name}: {e} polecenie {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` deklaruje tylko filtr lub efekt na żywo +@msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano # Actions panel: action sets (#3712). New Set… Nowy zestaw… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d22b6b4d1..a543fcdd7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5724,6 +5724,14 @@ axis Y Axis Eixo Y @msg unknown permission `{shown}` permissão desconhecida `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado +@msg a heavy filter runs in the background, so it can't declare `commands` um filtro pesado é executado em segundo plano, então não pode declarar `commands` +@msg at most {MAX_COMMANDS} `commands` no máximo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice o comando {name} está declarado duas vezes +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve ter 1-64 caracteres imprimíveis +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: use um id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que comece com uma letra +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` só um filtro ou um efeito dinâmico declara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado # Actions panel: action sets (#3712). New Set… Novo Conjunto… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 695853915..e56b2ee60 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5420,6 +5420,14 @@ axis Y Axis Ось Y @msg unknown permission `{shown}` неизвестное разрешение `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено +@msg a heavy filter runs in the background, so it can't declare `commands` тяжёлый фильтр выполняется в фоне, поэтому не может объявлять `commands` +@msg at most {MAX_COMMANDS} `commands` не более {MAX_COMMANDS} `commands` +@msg command {name} is declared twice команда {name} объявлена дважды +@msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` должен содержать 1-64 печатаемых символа +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: используйте id из 1-{MAX_COMMAND_ID} символов a-z 0-9 _, начинающийся с буквы +@msg command {name}: {e} команда {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` объявляет только фильтр или динамический эффект +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено # Actions panel: action sets (#3712). New Set… Новый набор… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 019067d99..991b26195 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5832,6 +5832,14 @@ axis Y Axis Y-axel @msg unknown permission `{shown}` okänd behörighet `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades +@msg a heavy filter runs in the background, so it can't declare `commands` ett tungt filter körs i bakgrunden, så det kan inte deklarera `commands` +@msg at most {MAX_COMMANDS} `commands` högst {MAX_COMMANDS} `commands` +@msg command {name} is declared twice kommandot {name} deklareras två gånger +@msg command {name}: `label` must be 1-64 printable characters kommando {name}: `label` måste ha 1-64 utskrivbara tecken +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter kommando {name}: använd ett id med 1-{MAX_COMMAND_ID} tecken av a-z 0-9 _ som börjar med en bokstav +@msg command {name}: {e} kommando {name}: {e} +@msg only a filter or a live effect declares `commands` bara ett filter eller en live-effekt deklarerar `commands` +@msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades # Actions panel: action sets (#3712). New Set… Ny uppsättning… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c0091946..4e6e96614 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5766,6 +5766,14 @@ axis Y Axis Y ekseni @msg unknown permission `{shown}` bilinmeyen izin `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi +@msg a heavy filter runs in the background, so it can't declare `commands` ağır bir filtre arka planda çalışır, bu yüzden `commands` bildiremez +@msg at most {MAX_COMMANDS} `commands` en fazla {MAX_COMMANDS} `commands` +@msg command {name} is declared twice {name} komutu iki kez bildirilmiş +@msg command {name}: `label` must be 1-64 printable characters {name} komutu: `label` 1-64 yazdırılabilir karakter olmalıdır +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} komutu: a-z 0-9 _ karakterlerinden oluşan ve harfle başlayan 1-{MAX_COMMAND_ID} karakterlik bir kimlik kullanın +@msg command {name}: {e} {name} komutu: {e} +@msg only a filter or a live effect declares `commands` `commands` yalnızca bir filtre veya canlı efekt tarafından bildirilir +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi # Actions panel: action sets (#3712). New Set… Yeni Set… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ae36d2e9e..ce2c6d787 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5405,6 +5405,14 @@ axis Y Axis Вісь Y @msg unknown permission `{shown}` невідомий дозвіл `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено +@msg a heavy filter runs in the background, so it can't declare `commands` важкий фільтр виконується у фоні, тому не може оголошувати `commands` +@msg at most {MAX_COMMANDS} `commands` щонайбільше {MAX_COMMANDS} `commands` +@msg command {name} is declared twice команду {name} оголошено двічі +@msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` має містити 1-64 друковані символи +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: використайте id з 1-{MAX_COMMAND_ID} символів a-z 0-9 _, що починається з літери +@msg command {name}: {e} команда {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` оголошує лише фільтр або динамічний ефект +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено # Actions panel: action sets (#3712). New Set… Новий набір… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5a83ec144..fbc972fff 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5406,6 +5406,14 @@ axis Y Axis Trục Y @msg unknown permission `{shown}` quyền không xác định `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi +@msg a heavy filter runs in the background, so it can't declare `commands` bộ lọc nặng chạy ở chế độ nền nên không thể khai báo `commands` +@msg at most {MAX_COMMANDS} `commands` tối đa {MAX_COMMANDS} `commands` +@msg command {name} is declared twice lệnh {name} được khai báo hai lần +@msg command {name}: `label` must be 1-64 printable characters lệnh {name}: `label` phải có 1-64 ký tự in được +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter lệnh {name}: hãy dùng id gồm 1-{MAX_COMMAND_ID} ký tự từ a-z 0-9 _ và bắt đầu bằng một chữ cái +@msg command {name}: {e} lệnh {name}: {e} +@msg only a filter or a live effect declares `commands` chỉ bộ lọc hoặc hiệu ứng trực tiếp mới khai báo `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt # Actions panel: action sets (#3712). New Set… Bộ mới… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index de6f8ea37..466e2afcb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5647,6 +5647,14 @@ axis Y Axis Y 轴 @msg unknown permission `{shown}` 未知权限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改 +@msg a heavy filter runs in the background, so it can't declare `commands` 重型滤镜在后台运行,因此不能声明 `commands` +@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 个 `commands` +@msg command {name} is declared twice 命令 {name} 声明了两次 +@msg command {name}: `label` must be 1-64 printable characters 命令 {name}:`label` 必须是 1-64 个可打印字符 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 命令 {name}:请使用由 a-z 0-9 _ 组成、以字母开头的 1-{MAX_COMMAND_ID} 个字符的 ID +@msg command {name}: {e} 命令 {name}:{e} +@msg only a filter or a live effect declares `commands` 只有滤镜或实时效果才能声明 `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 # Actions panel: action sets (#3712). New Set… 新建组… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 14b66871f..a3e9d119b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5443,6 +5443,14 @@ axis Y Axis Y 軸 @msg unknown permission `{shown}` 未知權限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更 +@msg a heavy filter runs in the background, so it can't declare `commands` 重型濾鏡在背景執行,因此不能宣告 `commands` +@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 個 `commands` +@msg command {name} is declared twice 指令 {name} 宣告了兩次 +@msg command {name}: `label` must be 1-64 printable characters 指令 {name}:`label` 必須是 1-64 個可列印字元 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 指令 {name}:請使用由 a-z 0-9 _ 組成、以字母開頭的 1-{MAX_COMMAND_ID} 個字元的 ID +@msg command {name}: {e} 指令 {name}:{e} +@msg only a filter or a live effect declares `commands` 只有濾鏡或即時效果才能宣告 `commands` +@msg “{name}” › {label}: {e} 「{name}」 › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 # Actions panel: action sets (#3712). New Set… 新增組… diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 572c6b175..844db0a98 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -7,7 +7,7 @@ //! outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`, `Period`, `~` for the backtick key, //! `=` for Plus with modifiers). Inside an [`Overrides`] keys and texts are the crate's. -use std::sync::{OnceLock, RwLock}; +use std::sync::{Mutex, OnceLock, RwLock}; use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Lookup, Mods, Overrides, Platform, Scope, Scopes}; use serde_json::{Value, json}; @@ -212,11 +212,25 @@ pub(crate) fn per_platform(cells: &'static [OnceLock; 3], p: Platform, mak cell.get_or_init(make) } -/// The keymap with the default shortcuts for [`platform`], built once per platform. +/// The keymap with the default shortcuts for [`platform`], built once per platform, and again for +/// each set of plug-in commands installed ([`vectorcraft_engine::cmd::plugin::commands_generation`]). pub fn template() -> &'static Keymap { static TEMPLATES: [OnceLock; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; + static WITH_PLUGINS: Mutex> = Mutex::new(Vec::new()); let p = platform(); - per_platform(&TEMPLATES, p, || keymap_for(p)) + let generation = vectorcraft_engine::cmd::plugin::commands_generation(); + if generation == 0 { + return per_platform(&TEMPLATES, p, || keymap_for(p)); + } + let mut made = WITH_PLUGINS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((_, _, km)) = made.iter().find(|(q, g, _)| *q == p && *g == generation) { + return km; + } + made.retain(|(_, g, _)| *g == generation); + // Kept for the rest of the process: one keymap for each set of plug-in commands installed. + let km: &'static Keymap = Box::leak(Box::new(keymap_for(p))); + made.push((p, generation, km)); + km } /// The default keymap of `p`. The chords the operating system takes can't be assigned. diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index e9cfc538a..36811ba4c 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -33,6 +33,8 @@ 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), Item::Sub(label, children) => Entry::Sub(t(label), children), Item::Todo(label, sc) => Entry::Item(Row { command: None, diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 073b1130e..0a0695e0e 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -152,6 +152,10 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); return; } + // A plug-in command with parameters asks for them, from its menu item or its shortcut. + if p.as_object().is_none_or(|o| o.is_empty()) && crate::dialogs::plugin::open_command(app, id) { + return; + } if open_view_dialog(app, id, &p) || open_object_dialog(app, id, &p) { return; } diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index b8d314346..6141352ab 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -139,15 +139,18 @@ 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" | "window.toolbar.custom") + || 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)) } /// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or -/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds -/// while another thread installs or removes plug-ins. +/// an Effect › Plug-ins effect) or one of its commands, by the plug-in's own text? Told by the item +/// itself, so it holds while another thread installs or removes plug-ins. pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { - id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() + id == "plugin.dialog" + || vectorcraft_engine::cmd::plugin::is_command_id(id) + || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() } /// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled @@ -307,6 +310,11 @@ pub const DYNAMIC_LABELS: &[&str] = &[ "Lock Grid", ]; +/// Is `children` a plug-in's submenu: does it hold a command the plug-in declares? +pub(crate) fn holds_plugin_commands(children: &[Item]) -> bool { + children.iter().any(|c| matches!(c, Item::Cmd(_, id, _) if vectorcraft_engine::cmd::plugin::is_command_id(id))) +} + /// 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 @@ -335,6 +343,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), Item::Sub(l, children) => { out.insert(l.to_string()); // Type › Font lists the installed families: names, not UI text. diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index fda2e1f7f..54ff9986e 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -11,7 +11,8 @@ use crate::{VectorcraftApp, menus}; pub fn items() -> Vec<(String, String, String)> { let mut items = vec![]; // Aliases kept for older scripts run a command that is listed already. - for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)) { + let plugin_commands = vectorcraft_engine::cmd::plugin::command_specs(); + for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)).chain(plugin_commands) { let path = c.menu.join(" › "); 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())); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index ee2b6ea83..07a6af693 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -66,10 +66,13 @@ pub(crate) use ui_mirror; pub(crate) struct Mirror { pub keymap: Keymap, text: HashMap>, + /// The plug-in commands the keymap holds ([`vectorcraft_engine::cmd::plugin::commands_generation`]). + plugins: u64, } impl Mirror { fn new(overrides: Overrides) -> Mirror { + let plugins = vectorcraft_engine::cmd::plugin::commands_generation(); let keymap = crate::keymap::template().clone().with_overrides(overrides); let text = keymap .overrides() @@ -80,20 +83,36 @@ impl Mirror { (key, t) }) .collect(); - Mirror { keymap, text } + Mirror { keymap, text, plugins } } } ui_mirror!(fn store() -> Option>); -/// The mirror: the keymap in effect. +/// The mirror: the keymap in effect. It is made again when the plug-in commands change. pub(crate) fn current() -> Arc { - static DEFAULTS: [OnceLock>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; - store() - .read() - .ok() - .and_then(|m| m.clone()) - .unwrap_or_else(|| crate::keymap::per_platform(&DEFAULTS, crate::keymap::platform(), || Arc::new(Mirror::new(Overrides::new()))).clone()) + static DEFAULTS: Mutex>> = Mutex::new(Vec::new()); + let plugins = vectorcraft_engine::cmd::plugin::commands_generation(); + if let Some(m) = store().read().ok().and_then(|m| m.clone()) { + if m.plugins == plugins { + return m; + } + let fresh = Arc::new(Mirror::new(m.keymap.overrides().clone())); + if let Ok(mut m) = store().write() { + *m = Some(fresh.clone()); + } + GENERATION.fetch_add(1, Ordering::Relaxed); + return fresh; + } + let p = crate::keymap::platform(); + let mut defaults = DEFAULTS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(m) = defaults.iter().find(|m| m.plugins == plugins && m.keymap.platform() == p) { + return m.clone(); + } + defaults.retain(|m| m.plugins == plugins); + let m = Arc::new(Mirror::new(Overrides::new())); + defaults.push(m.clone()); + m } /// Leak-once interning for override strings (bounded by the chords a user ever assigns). @@ -113,7 +132,11 @@ pub fn sync(ui: &UiState) { let same = store() .read() .map(|m| match &*m { - Some(m) => *m.keymap.overrides() == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(), + Some(m) => { + *m.keymap.overrides() == ui.shortcut_overrides + && m.keymap.platform() == crate::keymap::platform() + && m.plugins == vectorcraft_engine::cmd::plugin::commands_generation() + } None => ui.shortcut_overrides.is_empty(), }) .unwrap_or(false); @@ -210,6 +233,7 @@ pub fn command_for_key(key: &str) -> Option<&'static str> { let c = crate::keymap::lookup(¤t().keymap, &vc_chord(key)?)?; vectorcraft_engine::command_specs() .iter() + .chain(vectorcraft_engine::cmd::plugin::command_specs()) .map(|c| c.id) .chain(menus::UI_COMMANDS.iter().map(|c| c.0)) .find(|id| *id == c.id && c.params.is_null()) @@ -334,8 +358,37 @@ pub struct Entry { pub is_tool: bool, } -/// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists. +/// Every tool, command and panel the editor can give a shortcut, followed by the commands of the +/// installed plug-ins; [`listed`] says which it lists. The list is made again when the plug-in +/// commands change ([`vectorcraft_engine::cmd::plugin::commands_generation`]). pub fn entries() -> &'static [Entry] { + static WITH_PLUGINS: Mutex> = Mutex::new(None); + let base = built_in_entries(); + let generation = vectorcraft_engine::cmd::plugin::commands_generation(); + if generation == 0 { + return base; + } + let mut with = WITH_PLUGINS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((g, e)) = *with + && g == generation + { + return e; + } + let mut v = base.to_vec(); + v.extend(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { + key: c.id.into(), + label: c.label.into(), + group: c.menu.join(" › "), + is_tool: false, + })); + // Kept for the rest of the process: one list for each set of plug-in commands installed. + let e: &'static [Entry] = Box::leak(v.into_boxed_slice()); + *with = Some((generation, e)); + e +} + +/// [`entries`] without the plug-in commands. +fn built_in_entries() -> &'static [Entry] { static E: OnceLock> = OnceLock::new(); E.get_or_init(|| { let mut v: Vec = vectorcraft_tools::catalog::all_tools() @@ -379,6 +432,7 @@ pub fn entries() -> &'static [Entry] { } v }) + .as_slice() } /// One Effect menu item a shortcut can be given (#3296): the effect's id, the command its item runs @@ -863,24 +917,30 @@ struct DialogView { keymap: Keymap, texts: Vec>, conflicts: usize, + /// The plug-in commands [`entries`] held ([`vectorcraft_engine::cmd::plugin::commands_generation`]). + plugins: u64, } impl DialogView { fn new(overrides: Overrides) -> DialogView { + let plugins = vectorcraft_engine::cmd::plugin::commands_generation(); let keymap = keymap_with(&overrides); let backtick = backtick_text(); let texts = entries().iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect(); let conflicts = conflicts_in(&keymap).len(); - DialogView { source: stored::to_value(keymap.overrides()), backtick, keymap, texts, conflicts } + DialogView { source: stored::to_value(keymap.overrides()), backtick, keymap, texts, conflicts, plugins } } - /// The view of `d`'s working set, from the cache unless the set or the backtick key's - /// character changed. The dialog's `overrides` field is left in its stored form. + /// The view of `d`'s working set, from the cache unless the set, the backtick key's character + /// or the plug-in commands changed. The dialog's `overrides` field is left in its stored form. fn of(ctx: &egui::Context, d: &mut Dialog) -> Arc { let id = egui::Id::unique("dialog-shortcuts-view"); let source = d.fields.get("overrides").unwrap_or(&Value::Null); let backtick = backtick_text(); - if let Some(v) = ctx.data(|m| m.get_temp::>(id)).filter(|v| v.source == *source && v.backtick == backtick) { + let plugins = vectorcraft_engine::cmd::plugin::commands_generation(); + if let Some(v) = + ctx.data(|m| m.get_temp::>(id)).filter(|v| v.source == *source && v.backtick == backtick && v.plugins == plugins) + { return v; } let v = Arc::new(DialogView::new(dialog_overrides(d))); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f6ceaec9d..6ad83deda 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -97,7 +97,7 @@ pub(crate) fn is_modifier(key: Key) -> bool { /// command's params: `{}`, or `{panel}` for the panel shortcuts (`window.panel`). pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json::Value)> { let mut v = vec![]; - for c in vectorcraft_engine::command_specs() { + for c in vectorcraft_engine::command_specs().iter().chain(vectorcraft_engine::cmd::plugin::command_specs()) { if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) { v.push((sc, c.id, json!({}))); } diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index dbb32769a..3f898f49f 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -183,3 +183,87 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "replace: false is kept"); app.run("plugin.remove", json!({"id": id})).unwrap(); } + +/// A plug-in with two commands: Grow, which adds a triangle (`_context.command` is `grow`), and +/// Size, with a parameter, whose `menu` names another menu. +fn commands_plugin(id: &str) -> Vec { + let manifest = format!( + r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"filter","commands":[{{"id":"grow","label":"Grow"}}, + {{"id":"size","label":"Size","menu":"File","params":{{"by":{{"type":"number","min":0,"max":10,"default":2}}}}}}]}}"# + ); + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + wat::parse_str(tpl::when_given(&manifest, tpl::Given::Params, r#""command":"grow""#, triangle)).unwrap() +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() +} + +/// A plug-in's commands are items of Object › Plug-ins › its name (never the menu a manifest +/// names), entries of Edit › Keyboard Shortcuts with no shortcut of their own, and gone from both +/// once it is removed. A command with parameters opens its dialog. +#[test] +fn plugin_commands_are_menu_items_and_shortcut_entries() { + let mut app = app(); + let id = "test.ui.cmds"; + let (grow, size) = ("plugin.test.ui.cmds.grow", "plugin.test.ui.cmds.size"); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&commands_plugin(id))})).unwrap(); + let entries = menus::menu_entries(&app); + let under = ["Object", "Plug-ins", "Cmd Test"]; + let item = entry(&entries, &under, "Grow").expect("Grow is listed"); + assert_eq!((item.command.as_deref(), item.enabled), (Some(grow), true)); + assert!( + entry(&entries, &under, "Size…").is_some(), + "Size is listed: {:?}", + entries.iter().filter(|e| e.path == under).map(|e| &e.label).collect::>() + ); + assert!(!entries.iter().any(|e| e.command.as_deref() == Some(size) && e.path.first().map(String::as_str) != Some("Object")), "not in File"); + assert!(!menus::menu_strings().contains("Cmd Test"), "the plug-in's name is not text to translate"); + + let listed = crate::shortcut_editor::entries().iter().find(|e| e.key == grow).expect("in Keyboard Shortcuts"); + assert_eq!((listed.label.as_str(), listed.group.as_str()), ("Grow", "Object › Plug-ins › Cmd Test")); + assert_eq!(crate::shortcut_editor::default_of(grow), None, "a manifest gives no shortcut"); + assert!(crate::keymap::template().bindable(&crate::keymap::command_of(grow)).is_some(), "a shortcut can be given to it"); + + // Size asks for its parameter; OK runs it as one undo step named after it. + let undo = app.session.doc().unwrap().history.undo.len(); + menus::invoke(&mut app, size, json!({})); + let d = app.ui.dialog.as_ref().expect("Size opens its dialog"); + assert_eq!((d.str("__command"), d.str("__label"), d.fields.get("by")), (size.to_string(), "Size".to_string(), Some(&json!(2.0)))); + frame(&mut app); + dialogs::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Size")); + + app.run("plugin.remove", json!({"id": id})).unwrap(); + assert!(menus::menu_entries(&app).iter().all(|e| e.command.as_deref() != Some(grow))); + assert!(crate::shortcut_editor::entries().iter().all(|e| e.key != grow)); +} + +/// A shortcut the user gives a plug-in command runs it. +#[test] +fn a_shortcut_runs_a_plugin_command() { + let mut app = app(); + let id = "test.ui.cmds.keys"; + let grow = vectorcraft_engine::cmd::plugin::command_id(id, "grow"); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&commands_plugin(id))})).unwrap(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, &grow, "Cmd+Alt+Shift+F9"); + crate::shortcut_editor::sync(&app.ui); + assert_eq!(crate::shortcut_editor::command_for_key("Cmd+Alt+Shift+F9"), Some(grow.as_str())); + let before = objects(&app); + let m = if crate::keymap::platform().is_mac() { egui::Modifiers::MAC_CMD } else { egui::Modifiers::CTRL } | egui::Modifiers::COMMAND; + let m = m | egui::Modifiers::ALT | egui::Modifiers::SHIFT; + let events = vec![ + egui::Event::ModifiersChanged(m), + egui::Event::Key { key: egui::Key::F9, physical_key: Some(egui::Key::F9), pressed: true, repeat: false, modifiers: m }, + ]; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + crate::shortcuts::handle(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!(objects(&app), before + 1, "the command added its triangle: {}", app.ui.status); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} diff --git a/docs/mcp.md b/docs/mcp.md index 030d4fe33..0945d7070 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5925,11 +5925,18 @@ filter without `doc.write` or `state` that returns output needing them gets an e permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty value or the folder the preference already holds, and the web build does not check folders. +A filter or live effect may declare commands of its own ([plugins.md](plugins.md#commands)): each is the command +`plugin..` while the plug-in is installed, listed by `command.list` and in its `plugin.list` entry +(`commands: [{id, command, label, params, paramsSchema}]`). Run it with `{ids?, params?}` (its parameters may also +be top-level keys): the parameters are checked before the plug-in runs, a run is one undo step named after the +command, and a failing run returns an error that starts with the plug-in's name and the command's label and leaves +the document as it was. ```json {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} {"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} +{"name":"run_command","arguments":{"command":"plugin.org.example.tidy.grow","params":{"by":3}}} ``` ## Colour adjustments diff --git a/docs/plugins.md b/docs/plugins.md index 96f8ac6e3..d44a1b291 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,7 +23,7 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | | `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | @@ -50,6 +50,29 @@ a plug-in too (on the web this is how plug-ins get in). A heavy filter asks befo background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). +### Commands + +A filter or a live effect may declare commands of its own in its manifest (`commands`, [below](#manifest)). Each +becomes the command `plugin..` while the plug-in is installed, and is gone once the plug-in is removed or +replaced by a module without it. + +- **Menus.** A plug-in with commands is a submenu of **Object › Plug-ins** (a live effect: **Effect › Plug-ins**) + named after the plug-in. The submenu holds the plug-in's own item, then its commands. A command with parameters has + an ellipsis and opens a dialog for them, with a live preview, as a filter's own item does. A manifest cannot put a + command in another menu: a `menu` key in a command is ignored. +- **Keyboard Shortcuts.** Edit › Keyboard Shortcuts lists each command under its submenu, and the user can give it a + shortcut. A manifest cannot give a command a shortcut. +- **Actions and agents.** A command run is recorded in an action like any other command and plays back with its + parameters. `run`, MCP and `command.batch` call it as `plugin..` with `{"ids"?: [...], "params"?: {…}}` + (its parameters may also be top-level keys). `command.list` lists it, and `plugin.list` lists a plug-in's commands + as `commands: [{id, command, label, params, paramsSchema}]`. +- **Runs.** The parameters are checked against the command's declarations before the plug-in runs. The plug-in runs + through `vc_run` on the selection (or `ids`) as a filter run does, with `_context.mode` `"command"` and + `_context.command` set to the command's id ([Parameters](#parameters)), under the plug-in's + [permissions](#permissions): a live effect's command has no `doc.write`, so an output that changes the document is + refused. One run is one undo step named after the command. A run that traps or fails leaves the document as it was, + and the error starts with the plug-in's name and the command's label (`“Cmd Test” › Grow: …`). + **Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every `*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; the web build installs from bytes). On the desktop, Choose… beside the field picks the folder; the dialog refuses a @@ -207,6 +230,11 @@ pointers are offsets into the module's memory. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. +- `commands` (filters and live effects; not heavy filters): up to 32 commands of the plug-in's own + ([Commands](#commands)), each `{"id": "grow", "label": "Grow", "params"?: {…}}`. An `id` is 1–32 characters from + `a-z 0-9 _` that starts with a letter, and appears once. A `label` is 1–64 printable characters; menus show it with + control and bidirectional characters escaped. `params` are declared as the plug-in's own `params` are. A malformed + entry is refused when the module is installed, and the error names the command by its id (or its place, `[0]`). - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults @@ -299,7 +327,8 @@ type and `blob` but without their data, which follows the JSON in a saved file; "_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792], "documentData": {"pens": 4}}} ``` -`mode` is `"filter"`, `"effect"` or `"export"`; `bounds` the selection's geometric bounds (a live effect: the reference +`mode` is `"filter"`, `"effect"`, `"export"` or `"command"` (a [command](#commands) run, whose `command` is the +command's id); `bounds` the selection's geometric bounds (a live effect: the reference box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6ee68a15f..04f4a6433 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1429,6 +1429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions, is one undo step named after the command, and on failure leaves the document as it was with an error naming the plug-in and the command. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.