Plug-ins: a plug-in adds menu commands of its own under Object › Plug-ins

A filter or live effect may declare up to 32 `commands` in its manifest,
each `{id, label, params?}`. While the plug-in is installed each one is the
command `plugin.<id>.<command>`: listed by `command.list` and in the
plug-in's `plugin.list` entry, run through `run`, MCP and `command.batch`
with its parameters checked before the plug-in runs, and recorded in
actions. A run goes through `vc_run` on the selection with
`_context.mode` "command" and `_context.command`, under the plug-in's
permissions, as one undo step named after the command. A failing run
leaves the document as it was, and its error starts with the plug-in's
name and the command's label. Removing or replacing the plug-in removes
its commands.

In the app, a plug-in with commands is a submenu named after it under
Object › Plug-ins (Effect › Plug-ins for a live effect), holding its own
item and then its commands; a `menu` key in a manifest is ignored. A
command with parameters opens a dialog for them from its menu item or a
shortcut. Edit › Keyboard Shortcuts lists the commands for the user to
give a shortcut; a manifest cannot set one. Labels and names are shown
with control and bidirectional characters escaped, and a malformed entry
is refused by name when the module is installed.

Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-07 17:32:54 -08:00
parent 1f352bc7c1
commit ed5de5edcb
45 changed files with 1104 additions and 56 deletions
+9 -1
View File
@@ -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<Value> {
if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) }
}
// ---------- param helpers ----------
+1 -1
View File
@@ -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<Value> {
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.
+185 -9
View File
@@ -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.<plug-in id>.<command id>` 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<Value> = 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<Arc<Plugin>> {
/// like any other command.
pub struct FilterRun {
plugin: Arc<Plugin>,
/// The plug-in command it runs (`plugin.<id>.<command>`), if it isn't `plugin.run`'s run.
command: Option<Command>,
input: Vec<u8>,
params: Vec<u8>,
/// 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<Plugin>, command: Option<Command>, c: &str, p: &Value) -> Result<FilterRun> {
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(&params).map_err(|e| bad(C, e.to_string()))?;
let params = serde_json::to_vec(&params).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::<Vec<_>>()}))
let mut out = json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
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<Value> {
r.finish(s, &output)
}
/// The command id of plug-in `plugin`'s command `command`: `plugin.<plugin>.<command>`.
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.<plug-in id>.<command id>`)? 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<Plugin>, 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<u64>,
generation: u64,
specs: Vec<&'static CommandSpec>,
}
static LISTED: RwLock<Listed> = 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<Vec<&'static CommandSpec>> = 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::<Vec<_>>()).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(&params),
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<Value> {
Err(EngineError::Internal { cmd: "plugin".into(), msg: "a plug-in command runs through Session::execute".into() })
}
/// Runs plug-in command `id` (`plugin.<plug-in id>.<command id>`): 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<Value> {
// 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<CommandSpec> {
"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.<id>), format? and extensions? (an exporter's format id, plugin.<id>, 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.<id>), format? and extensions? (an exporter's format id, plugin.<id>, 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.<id>.<command id>, 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
),
+3 -2
View File
@@ -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<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
command_specs().iter().chain(crate::cmd::plugin::command_specs()).map(|c| c.info(self)).collect()
}
}
+1 -1
View File
@@ -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)
+136
View File
@@ -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<crate::cmd::CommandInfo> {
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<Value> = 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());
}
+74
View File
@@ -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");
}
}
+4 -2
View File
@@ -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
+191
View File
@@ -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.<plug-in id>.<id>`, 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<ParamSpec> 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<Command>,
}
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<Vec<Command>, 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::<Command>(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<S: serde::Serializer>(p: &[(String, Param)], s: S) -> std::result::Result<S::Ok, S::Error> {
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::<Vec<_>>(), ["tidy_all", "grow"]);
let grow = m.command("grow").unwrap();
assert_eq!(grow.params.iter().map(|p| p.0.as_str()).collect::<Vec<_>>(), ["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<String> = (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(
+82 -15
View File
@@ -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.<id>.<command>`), 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<Item> {
let mut items: Vec<Item> = 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<Item> {
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<Item> {
let items: Vec<Item> = 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<Arc<Plugin>> {
Some(plugin)
}
/// Opens the dialog of plug-in command `id` (`plugin.<plug-in id>.<command id>`), 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<Manifest> {
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<Value, Str
}
}
/// The plug-in's description and fields, then the live Preview; a heavy filter has a note instead
/// that it runs in the background, since a run may take minutes.
/// The plug-in's description and fields (a command's: its own parameters), then the live
/// Preview; a heavy filter has a note instead that it runs in the background, since a run may take
/// minutes. A command dialog closes once the plug-in no longer has the command.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> 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<Value, String> {
}
r
}
_ => form::commit_preview(app, CMD, params(d)),
_ => form::commit_preview(app, &command(d), params(d)),
}
}
@@ -375,9 +375,13 @@ pub fn candidates() -> Vec<Candidate> {
.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<HashMap<&'static str, &'static Entry>> = 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()
}
+1
View File
@@ -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<egui::Rect> {
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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… Νέο σύνολο…
Can't render this file because it contains an unexpected character in line 163 and column 5.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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})
Can't render this file because it is too large.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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… 新規セット…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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… ახალი ნაკრები…
Can't render this file because it is too large.
+8
View File
@@ -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… 새 세트…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -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… Новый набор…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8
View File
@@ -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… Новий набір…
Can't render this file because it contains an unexpected character in line 177 and column 5.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 199 and column 5.
+8
View File
@@ -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… 新建组…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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… 新增組…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+17 -3
View File
@@ -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<T>(cells: &'static [OnceLock<T>; 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<Keymap>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()];
static WITH_PLUGINS: Mutex<Vec<(Platform, u64, &'static Keymap)>> = 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.
+2
View File
@@ -33,6 +33,8 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option<Entry<'a>> {
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,
+4
View File
@@ -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;
}
+13 -3
View File
@@ -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<String> {
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.
+2 -1
View File
@@ -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()));
+74 -14
View File
@@ -66,10 +66,13 @@ pub(crate) use ui_mirror;
pub(crate) struct Mirror {
pub keymap: Keymap,
text: HashMap<String, Option<&'static str>>,
/// 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<Arc<Mirror>>);
/// 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<Mirror> {
static DEFAULTS: [OnceLock<Arc<Mirror>>; 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<Vec<Arc<Mirror>>> = 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(&current().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<Option<(u64, &'static [Entry])>> = 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<Vec<Entry>> = OnceLock::new();
E.get_or_init(|| {
let mut v: Vec<Entry> = 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<Option<String>>,
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<DialogView> {
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::<Arc<DialogView>>(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::<Arc<DialogView>>(id)).filter(|v| v.source == *source && v.backtick == backtick && v.plugins == plugins)
{
return v;
}
let v = Arc::new(DialogView::new(dialog_overrides(d)));
+1 -1
View File
@@ -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!({})));
}
+84
View File
@@ -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<u8> {
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::<Vec<_>>()
);
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();
}
+7
View File
@@ -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.<id>.<command>` 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
+31 -2
View File
@@ -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.<id>.<command>` 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.<id>.<command>` 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
+1
View File
@@ -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.<id>.<command>` 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.