Plug-ins: labels, units, groups, help and conditions for parameters (G17)

A plug-in's dialog labelled each field with its parameter's name spaced
out ("Line_spacing_mm:"), showed numbers without their unit and listed
at most 64 parameters in one column. A plug-in with many settings, such
as a generator's or an exporter's, could neither explain nor sort them,
and its dialog showed settings that don't apply to the choices made.

Each parameter may now say how dialogs show it, in keys that hosts
from before them ignore (plugins::dialog):

- label; unit, after the value ("0.25 mm"); help, on the label; and
  helpUrl, an https:// page opened from a button after the field.
- group: the parameters outside any group come first, then each group
  as a section that folds, in the order the groups first appear, the
  fields lined up down the dialog.
- visibleWhen: {"pattern": ["lines"]} shows a parameter only
  while each checkbox or choice it names, declared before it, has one
  of the values listed and is shown itself. Manifest::visible decides
  every parameter in one pass, in declaration order. A hidden parameter
  still goes to the plug-in with its value, so a run never depends on
  what the dialog showed.

Two types join number, int, bool and choice: string, a text field cut
to maxLength, and numberList, numbers typed separated by commas or
spaces, each clamped, minItems to maxItems of them. A plug-in declares
up to 256 parameters, at most 64 outside groups. The filter, live-effect
and exporter dialogs use every key. The Appearance panel's editor of a
plug-in effect uses the labels, units, help and conditions, and reads a
typed number list as the dialog does.

dialog.rs tests parsing, the round trip of a manifest written out,
sections, visibility (also of 256 overlapping conditions, which a
recursive check would take forever on) and the keys refused;
manifest.rs the new types and limits. tests_plugin_fields.rs draws a
dialog with kittest: labels and units, sections in order with their
fields lined up, visibleWhen hiding and showing rows, and the help
button opening its page.
appearance.rs checks the editor's labels, units, conditions and a
typed number list, and form.rs units after numbers (a unit starting
with a digit is spaced from it) and typed lists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un

# Conflicts:
#	docs/gaps.md
#	docs/target-app-parity.md
This commit is contained in:
Louise Fox authored and bflatastic committed 2026-10-20 08:17:53 +07:00
1 parent 30f372a220
commit c755909d67
37 files changed
+997 -112

No files matched your search

+307
View File
@@ -0,0 +1,307 @@
//! How the host's generated dialogs show a plug-in's parameters: the optional `label`, `unit`,
//! `group`, `help`, `helpUrl` and `visibleWhen` keys of each one (docs/plugins.md › Parameters).
//!
//! These keys are presentation only. A host from before them ignores them, so a plug-in that
//! declares them still installs there, and a plug-in is given every parameter whether its dialog
//! showed it or not: a run never depends on what was on screen. The dialogs of filters, live
//! effects and exporters draw the parameters outside any group first, then each group as a section,
//! in the order the groups first appear, and leave out a parameter while its `visibleWhen` isn't met.
//!
//! `visibleWhen` names parameters declared before the one it belongs to, so the parameter that
//! decides comes first in the dialog and conditions can't go round in a circle. A parameter is shown
//! when each one it names is shown and has one of the values listed for it.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::manifest::{Manifest, Param, ParamSpec};
use crate::{Error, Result};
/// Longest `label` or `group`, in characters.
pub const MAX_LABEL: usize = 64;
/// Longest `unit`, in characters.
pub const MAX_UNIT: usize = 16;
/// Longest `help`, in characters.
pub const MAX_HELP: usize = 1_000;
/// Longest `helpUrl`, in bytes.
pub const MAX_HELP_URL: usize = 512;
/// Most parameters one `visibleWhen` names.
pub const MAX_CONDITIONS: usize = 8;
/// Most values a `visibleWhen` lists for one parameter.
pub const MAX_CONDITION_VALUES: usize = 64;
/// One section of a dialog ([`Manifest::sections`]): its group (`None`: the parameters outside any
/// group) and its parameters, in declaration order.
pub type Section<'a> = (Option<&'a str>, Vec<&'a (String, Param)>);
/// How dialogs show one parameter (every key optional).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParamUi {
/// The field's label; without one, the name spaced out (`rowSpacing` → "Row Spacing").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// The unit the value is in, shown after the field (the plug-in's own: nothing is converted).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
/// The section of the dialog the parameter is in.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// What the parameter does, shown when the pointer rests on its label.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
/// An `https://` page about the parameter, opened by the help button after its field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help_url: Option<String>,
/// Shown only while each parameter named here has one of the values listed for it.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub visible_when: BTreeMap<String, Vec<Value>>,
}
/// Is `s` 1 to `max` characters, not all spaces, without control characters (`\n` too, unless
/// `lines`)?
fn printable(s: &str, max: usize, lines: bool) -> bool {
!s.trim().is_empty() && s.chars().count() <= max && s.chars().all(|c| !c.is_control() || (lines && c == '\n'))
}
impl ParamUi {
/// Checks the keys of parameter `name`, the `index`th of manifest `m`'s parameters.
pub(crate) fn validate(&self, m: &Manifest, index: usize, name: &str) -> Result<()> {
let bad = |what: &str| Err(Error::Manifest(format!("parameter `{name}`: {what}")));
let text = |v: &Option<String>, max: usize, lines: bool| v.as_deref().is_none_or(|s| printable(s, max, lines));
if !text(&self.label, MAX_LABEL, false) || !text(&self.group, MAX_LABEL, false) {
return bad(&format!("`label` and `group` must be 1-{MAX_LABEL} printable characters"));
}
if !text(&self.unit, MAX_UNIT, false) {
return bad(&format!("`unit` must be 1-{MAX_UNIT} printable characters"));
}
if !text(&self.help, MAX_HELP, true) {
return bad(&format!("`help` must be 1-{MAX_HELP} printable characters"));
}
if let Some(url) = &self.help_url {
let rest = url.strip_prefix("https://").unwrap_or_default();
if rest.is_empty() || url.len() > MAX_HELP_URL || url.chars().any(|c| c.is_whitespace() || c.is_control()) {
return bad(&format!("`helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes"));
}
}
if self.visible_when.len() > MAX_CONDITIONS {
return bad(&format!("`visibleWhen` names at most {MAX_CONDITIONS} parameters"));
}
let before = m.params.get(..index).unwrap_or_default();
for (other, values) in &self.visible_when {
let Some((_, p)) = before.iter().find(|(k, _)| k == other) else {
return bad(&format!("`visibleWhen` names `{other}`, which isn't a parameter declared before this one"));
};
if values.is_empty() || values.len() > MAX_CONDITION_VALUES {
return bad(&format!("`visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}`"));
}
let fits = |v: &Value| match &p.spec {
ParamSpec::Bool { .. } => v.is_boolean(),
ParamSpec::Choice { options, .. } => v.as_str().is_some_and(|s| options.iter().any(|o| o == s)),
_ => false,
};
if !values.iter().all(fits) {
return bad(&format!("`visibleWhen` takes `{other}`'s values, of a checkbox or a choice"));
}
}
Ok(())
}
}
impl Manifest {
/// The names of the parameters shown while the dialog's values are `values` (missing ones at
/// their defaults): those whose `visibleWhen` names only shown parameters, each with one of the
/// values listed for it.
///
/// One pass in declaration order decides each parameter once, from the parameters before it.
/// Following the conditions back instead would revisit a parameter once per path to it, and
/// overlapping conditions have exponentially many. A condition on a parameter declared later,
/// which only a manifest built in code can have, hides the parameter, so conditions that go
/// round in a circle hide every parameter on it.
pub fn visible(&self, values: &Map<String, Value>) -> BTreeSet<&str> {
let mut shown = BTreeSet::new();
for (name, p) in &self.params {
let met = p.ui.visible_when.iter().all(|(other, allowed)| {
shown.contains(other.as_str())
&& match values.get(other) {
Some(v) => allowed.contains(v),
None => self.param(other).is_some_and(|o| allowed.contains(&o.spec.default_value())),
}
});
if met {
shown.insert(name.as_str());
}
}
shown
}
/// Is parameter `name` shown while the dialog's values are `values`? See [`Manifest::visible`],
/// which a dialog calls once for all its parameters.
pub fn shown(&self, name: &str, values: &Map<String, Value>) -> bool {
self.visible(values).contains(name)
}
/// The parameter named `name`.
pub fn param(&self, name: &str) -> Option<&Param> {
self.params.iter().find(|(k, _)| k == name).map(|(_, p)| p)
}
/// The dialog's sections in order: the parameters outside any group (`None`), then each group
/// in the order it first appears, each with its parameters in declaration order.
pub fn sections(&self) -> Vec<Section<'_>> {
let mut out: Vec<Section> = Vec::new();
let ungrouped: Vec<_> = self.params.iter().filter(|(_, p)| p.ui.group.is_none()).collect();
if !ungrouped.is_empty() {
out.push((None, ungrouped));
}
for entry in &self.params {
let Some(group) = entry.1.ui.group.as_deref() else { continue };
match out.iter_mut().find(|(g, _)| *g == Some(group)) {
Some((_, members)) => members.push(entry),
None => out.push((Some(group), vec![entry])),
}
}
out
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
const RICH: &str = r#"{"id":"org.example.hatch","name":"Hatch","kind":"effect","params":{
"pattern":{"type":"choice","options":["lines","crosshatch","dots"],"default":"lines",
"label":"Pattern","group":"Fill","help":"How the shape is filled."},
"spacing":{"type":"number","min":0.1,"max":10,"default":0.25,"label":"Spacing","unit":"mm","group":"Fill",
"help":"Distance between the lines.\nSmaller is denser.","helpUrl":"https://example.org/params/spacing",
"visibleWhen":{"pattern":["lines","crosshatch"]}},
"outline":{"type":"bool","default":true,"label":"Outline"},
"outline_width":{"type":"number","min":0,"max":10,"default":0.5,"unit":"pt","group":"Outline","visibleWhen":{"outline":[true]}},
"outline_passes":{"type":"int","min":1,"max":4,"default":1,"group":"Outline",
"visibleWhen":{"outline":[true],"pattern":["lines"]}},
"seed":{"type":"int","min":0,"max":9}}}"#;
fn rich() -> Manifest {
Manifest::parse(RICH.as_bytes()).unwrap()
}
#[test]
fn keys_parse_and_round_trip_with_the_parameters() {
let m = rich();
let spacing = m.param("spacing").unwrap();
assert_eq!(
(spacing.ui.label.as_deref(), spacing.ui.unit.as_deref(), spacing.ui.group.as_deref()),
(Some("Spacing"), Some("mm"), Some("Fill"))
);
assert_eq!(spacing.ui.help_url.as_deref(), Some("https://example.org/params/spacing"));
assert_eq!(spacing.spec, ParamSpec::Number { min: 0.1, max: 10.0, default: 0.25 });
assert_eq!(m.param("seed").unwrap().ui, ParamUi::default());
// Written out, the manifest reads back as it was, parameters in their order.
assert_eq!(Manifest::parse(&serde_json::to_vec(&m).unwrap()).unwrap(), m);
assert_eq!(serde_json::to_value(&m).unwrap()["params"]["seed"], json!({"type": "int", "min": 0, "max": 9, "default": 0}));
}
#[test]
fn sections_put_ungrouped_parameters_first_then_groups_as_they_appear() {
let m = rich();
let names: Vec<(Option<&str>, Vec<&str>)> =
m.sections().into_iter().map(|(g, ps)| (g, ps.into_iter().map(|(k, _)| k.as_str()).collect())).collect();
assert_eq!(
names,
[(None, vec!["outline", "seed"]), (Some("Fill"), vec!["pattern", "spacing"]), (Some("Outline"), vec!["outline_width", "outline_passes"])]
);
}
#[test]
fn visible_when_follows_the_values_and_the_parameters_it_names() {
let m = rich();
let shown = |values: Value| -> Vec<&str> {
let values = values.as_object().cloned().unwrap_or_default();
m.params.iter().filter(|(k, _)| m.shown(k, &values)).map(|(k, _)| k.as_str()).collect()
};
// At the defaults: lines, with an outline.
assert_eq!(shown(json!({})), ["pattern", "spacing", "outline", "outline_width", "outline_passes", "seed"]);
assert_eq!(shown(json!({"pattern": "dots"})), ["pattern", "outline", "outline_width", "seed"]);
assert_eq!(shown(json!({"outline": false})), ["pattern", "spacing", "outline", "seed"]);
assert!(!m.shown("no_such_parameter", &Map::new()));
}
/// A manifest built in code rather than parsed can name parameters in a circle: they are
/// hidden instead of recursing forever.
#[test]
fn conditions_in_a_circle_hide_their_parameters() {
let mut m = rich();
let outline = m.params.iter().position(|(k, _)| k == "outline").unwrap();
// `outline` names `outline_width`, which names `outline`.
m.params[outline].1.ui.visible_when.insert("outline_width".into(), vec![json!(0.5)]);
assert!(!m.shown("outline", &Map::new()) && !m.shown("outline_width", &Map::new()));
assert!(m.shown("seed", &Map::new()) && m.shown("pattern", &Map::new()));
}
/// Conditions that each name several earlier parameters are checked once per parameter, not
/// once per path through them: 256 checkboxes, each shown while the 8 before it are checked,
/// would otherwise take longer than the age of the universe on every frame of the dialog.
#[test]
fn overlapping_conditions_are_checked_once_per_parameter() {
let params: Vec<String> = (0..256)
.map(|i: usize| {
let when: Vec<String> = (i.saturating_sub(MAX_CONDITIONS)..i).map(|j| format!(r#""p{j}":[true]"#)).collect();
format!(r#""p{i}":{{"type":"bool","default":true,"group":"G","visibleWhen":{{{}}}}}"#, when.join(","))
})
.collect();
let m = Manifest::parse(format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{}}}}}"#, params.join(",")).as_bytes()).unwrap();
let (done, finished) = std::sync::mpsc::channel();
std::thread::spawn(move || done.send(m.visible(&Map::new()).len()));
let shown = finished.recv_timeout(std::time::Duration::from_secs(10)).expect("the check took more than 10 s");
assert_eq!(shown, 256, "every checkbox is checked, so every parameter is shown");
// Unchecking the first hides the rest: each names it or one it hides.
let m = Manifest::parse(format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{}}}}}"#, params.join(",")).as_bytes()).unwrap();
let values = json!({"p0": false}).as_object().cloned().unwrap();
assert_eq!(m.visible(&values).into_iter().collect::<Vec<_>>(), ["p0"]);
}
#[test]
fn bad_keys_are_refused() {
let with = |key: &str, value: &str| {
format!(
r#"{{"id":"a","name":"x","kind":"filter","params":{{"mode":{{"type":"choice","options":["a","b"]}},
"on":{{"type":"bool"}},"n":{{"type":"number","min":0,"max":1,"default":0,"{key}":{value}}}}}}}"#
)
};
let long = format!("\"{}\"", "x".repeat(MAX_LABEL + 1));
for (key, value) in [
("label", r#""""#),
("label", r#"" ""#),
("label", r#""a\u0007b""#),
("label", long.as_str()),
("group", r#""line\nbreak""#),
("unit", r#""millimetres and more""#),
("help", r#""""#),
("helpUrl", r#""http://example.org""#),
("helpUrl", r#""https://""#),
("helpUrl", r#""https://exa mple.org""#),
("helpUrl", r#""javascript:alert(1)""#),
("visibleWhen", r#"{"missing":[true]}"#),
("visibleWhen", r#"{"n":[0]}"#),
("visibleWhen", r#"{"on":["yes"]}"#),
("visibleWhen", r#"{"mode":["c"]}"#),
("visibleWhen", r#"{"mode":[]}"#),
] {
let manifest = with(key, value);
assert!(Manifest::parse(manifest.as_bytes()).is_err(), "{key}: {value}");
}
// A parameter declared after the one that names it, and one naming itself.
let later = r#"{"id":"a","name":"x","kind":"filter","params":{"n":{"type":"number","min":0,"max":1,"default":0,
"visibleWhen":{"on":[true]}},"on":{"type":"bool"}}}"#;
let itself = r#"{"id":"a","name":"x","kind":"filter","params":{"on":{"type":"bool","visibleWhen":{"on":[true]}}}}"#;
for bad in [later, itself] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
let fine = with("help", r#""Two\nlines.""#);
assert!(Manifest::parse(fine.as_bytes()).is_ok());
}
}
+5 -2
View File
@@ -19,9 +19,11 @@
//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each
//! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole
//! document in a file format of its own. The process-wide [`registry`] holds the installed
//! plug-ins.
//! 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`]).
#![forbid(unsafe_code)]
pub mod dialog;
pub mod effect;
pub mod export;
pub mod manifest;
@@ -30,7 +32,8 @@ pub mod registry;
mod runtime;
pub mod wat;
pub use manifest::{Kind, Manifest, ParamSpec};
pub use dialog::ParamUi;
pub use manifest::{Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, Limits, Plugin};
/// What the host puts before a plug-in's id to name what it adds: its live effect
+165 -31
View File
@@ -1,14 +1,22 @@
//! The plug-in manifest (`vc_manifest`) and parameter schema.
//! The plug-in manifest (`vc_manifest`) and parameter schema: what each parameter holds
//! ([`ParamSpec`]) and how dialogs show it ([`ParamUi`], in [`crate::dialog`]).
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::dialog::ParamUi;
use crate::{Error, Result};
/// Longest manifest the host reads.
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
/// Most parameters a plug-in may declare.
pub const MAX_PARAMS: usize = 64;
pub const MAX_PARAMS: usize = 256;
/// Most parameters outside groups (`group`): a dialog of more is sorted into sections.
pub const MAX_UNGROUPED: usize = 64;
/// Longest text a `string` parameter may hold, in characters.
pub const MAX_TEXT: usize = 4_096;
/// Most numbers a `numberList` parameter may hold.
pub const MAX_LIST: usize = 256;
/// Most file extensions an exporter may declare.
pub const MAX_EXTENSIONS: usize = 8;
@@ -63,6 +71,27 @@ pub enum ParamSpec {
#[serde(default)]
default: Option<String>,
},
/// Text of at most `max_length` characters (a text field).
#[serde(rename = "string")]
Text {
#[serde(default)]
default: String,
#[serde(default = "text_max", rename = "maxLength")]
max_length: usize,
},
/// `min_items` to `max_items` numbers, each in `min..=max` (a field of numbers separated by
/// commas or spaces).
#[serde(rename_all = "camelCase")]
NumberList {
min: f64,
max: f64,
#[serde(default)]
default: Vec<f64>,
#[serde(default)]
min_items: usize,
#[serde(default = "list_max")]
max_items: usize,
},
}
fn int_min() -> i64 {
@@ -71,6 +100,28 @@ fn int_min() -> i64 {
fn int_max() -> i64 {
1_000_000
}
fn text_max() -> usize {
256
}
fn list_max() -> usize {
16
}
/// One declared parameter: what it holds, and how dialogs show it. In the manifest both are keys of
/// the same object: `{"type": "number", "min": 0, "max": 10, "default": 1, "unit": "mm"}`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Param {
#[serde(flatten)]
pub spec: ParamSpec,
#[serde(flatten)]
pub ui: ParamUi,
}
impl From<ParamSpec> for Param {
fn from(spec: ParamSpec) -> Self {
Param { spec, ui: ParamUi::default() }
}
}
impl ParamSpec {
/// The value a parameter takes when it isn't given (numeric defaults clamped into range).
@@ -80,6 +131,40 @@ impl ParamSpec {
ParamSpec::Int { min, max, default } => json!((*default).clamp(*min, *max)),
ParamSpec::Bool { default } => json!(default),
ParamSpec::Choice { options, default } => json!(default.clone().or_else(|| options.first().cloned()).unwrap_or_default()),
ParamSpec::Text { default, max_length } => json!(default.chars().take(*max_length).collect::<String>()),
ParamSpec::NumberList { min, max, default, .. } => json!(default.iter().map(|x| x.clamp(*min, *max)).collect::<Vec<f64>>()),
}
}
/// Checks the spec of parameter `k`.
fn validate(&self, k: &str) -> Result<()> {
let bad = |what: &str| Err(Error::Manifest(format!("parameter `{k}`: {what}")));
match self {
ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => bad("bad range"),
ParamSpec::Int { min, max, .. } if min > max => bad("bad range"),
ParamSpec::Choice { options, default } => {
if options.is_empty() || options.len() > 64 || options.iter().any(|o| o.is_empty() || o.len() > 64 || o.chars().any(char::is_control))
{
return bad("choices must be 1-64 non-empty, printable strings");
}
if default.as_ref().is_some_and(|d| !options.contains(d)) {
return bad("default is not one of the options");
}
Ok(())
}
ParamSpec::Text { default, max_length } if !(1..=MAX_TEXT).contains(max_length) || default.chars().count() > *max_length => {
bad(&format!("`maxLength` must be 1-{MAX_TEXT}, and the default no longer"))
}
ParamSpec::NumberList { min, max, default, min_items, max_items } => {
if !(min.is_finite() && max.is_finite()) || min > max || default.iter().any(|x| !x.is_finite()) {
return bad("bad range");
}
if !(1..=MAX_LIST).contains(max_items) || min_items > max_items || !(*min_items..=*max_items).contains(&default.len()) {
return bad(&format!("`minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many"));
}
Ok(())
}
_ => Ok(()),
}
}
}
@@ -101,7 +186,7 @@ pub struct Manifest {
pub author: String,
/// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest.
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
pub params: Vec<(String, ParamSpec)>,
pub params: Vec<(String, Param)>,
/// An exporter's file extensions, lower case and without the dot; the first is the one its
/// files get. Only exporters declare them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -146,28 +231,15 @@ impl Manifest {
return Err(Error::Manifest("`version`, `description` or `author` is too long".into()));
}
self.validate_extensions()?;
for (k, spec) in &self.params {
for (i, (k, p)) in self.params.iter().enumerate() {
if !valid_key(k) {
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
}
match spec {
ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => {
return Err(Error::Manifest(format!("parameter `{k}`: bad range")));
}
ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))),
ParamSpec::Choice { options, default } => {
if options.is_empty()
|| options.len() > 64
|| options.iter().any(|o| o.is_empty() || o.len() > 64 || o.chars().any(char::is_control))
{
return Err(Error::Manifest(format!("parameter `{k}`: choices must be 1-64 non-empty, printable strings")));
}
if default.as_ref().is_some_and(|d| !options.contains(d)) {
return Err(Error::Manifest(format!("parameter `{k}`: default is not one of the options")));
}
}
_ => {}
}
p.spec.validate(k)?;
p.ui.validate(self, i, k)?;
}
if self.params.iter().filter(|(_, p)| p.ui.group.is_none()).count() > MAX_UNGROUPED {
return Err(Error::Manifest(format!("at most {MAX_UNGROUPED} parameters outside groups: give the others a `group`")));
}
Ok(())
}
@@ -201,13 +273,17 @@ impl Manifest {
let parts: Vec<String> = self
.params
.iter()
.map(|(k, spec)| match spec {
.map(|(k, p)| match &p.spec {
ParamSpec::Number { min, max, default } => format!("{k}: {min}..{max} ({})", default.clamp(*min, *max)),
ParamSpec::Int { min, max, default } => format!("{k}: int {min}..{max} ({})", (*default).clamp(*min, *max)),
ParamSpec::Bool { default } => format!("{k}: bool ({default})"),
ParamSpec::Choice { options, .. } => {
let opts: Vec<String> = options.iter().map(|o| format!("{o:?}")).collect();
format!("{k}: {} ({})", opts.join("|"), spec.default_value().as_str().unwrap_or_default())
format!("{k}: {} ({})", opts.join("|"), p.spec.default_value().as_str().unwrap_or_default())
}
ParamSpec::Text { max_length, .. } => format!("{k}: string of at most {max_length} ({})", p.spec.default_value()),
ParamSpec::NumberList { min, max, min_items, max_items, .. } => {
format!("{k}: {min_items} to {max_items} numbers {min}..{max} ({})", p.spec.default_value())
}
})
.collect();
@@ -216,7 +292,7 @@ impl Manifest {
/// Every parameter at its default value.
pub fn defaults(&self) -> Map<String, Value> {
self.params.iter().map(|(k, spec)| (k.clone(), spec.default_value())).collect()
self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect()
}
/// Validates user `params` against the schema: missing keys take their defaults, numbers are
@@ -229,7 +305,8 @@ impl Manifest {
_ => return Err(Error::Params("plug-in parameters must be a JSON object".into())),
};
let mut out = Map::new();
for (k, spec) in &self.params {
for (k, p) in &self.params {
let spec = &p.spec;
let Some(v) = given.get(k).filter(|v| !v.is_null()) else {
out.insert(k.clone(), spec.default_value());
continue;
@@ -258,6 +335,17 @@ impl Manifest {
}
json!(s)
}
ParamSpec::Text { max_length, .. } => {
json!(v.as_str().ok_or_else(|| wrong("a string"))?.chars().take(*max_length).collect::<String>())
}
ParamSpec::NumberList { min, max, min_items, max_items, .. } => {
let items = v.as_array().ok_or_else(|| wrong("a list of numbers"))?;
if !(*min_items..=*max_items).contains(&items.len()) {
return Err(wrong(&format!("a list of {min_items} to {max_items} numbers")));
}
let numbers: Option<Vec<f64>> = items.iter().map(|x| x.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(*min, *max))).collect();
json!(numbers.ok_or_else(|| wrong("a list of numbers"))?)
}
};
out.insert(k.clone(), resolved);
}
@@ -266,10 +354,10 @@ impl Manifest {
}
/// Reads the `params` object keeping its key order (serde_json's `Map` would sort it).
fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<(String, ParamSpec)>, D::Error> {
fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<(String, Param)>, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Vec<(String, ParamSpec)>;
type Value = Vec<(String, Param)>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an object of parameter specs")
}
@@ -277,8 +365,8 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result
Ok(Vec::new())
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
let mut out: Vec<(String, ParamSpec)> = Vec::new();
while let Some((k, v)) = map.next_entry::<String, ParamSpec>()? {
let mut out: Vec<(String, Param)> = Vec::new();
while let Some((k, v)) = map.next_entry::<String, Param>()? {
if out.len() >= MAX_PARAMS {
return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})")));
}
@@ -293,7 +381,7 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result
d.deserialize_any(V)
}
fn params_as_object<S: serde::Serializer>(p: &[(String, ParamSpec)], s: S) -> std::result::Result<S::Ok, S::Error> {
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()))?;
for (k, v) in p {
@@ -413,6 +501,45 @@ mod tests {
}
}
#[test]
fn strings_and_number_lists_resolve_like_the_other_types() {
let m = Manifest::parse(
br#"{"id":"org.example.text","name":"Text","kind":"filter","params":{
"label":{"type":"string","default":"Rose","maxLength":8},
"angles":{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":3},
"free":{"type":"string"}}}"#,
)
.unwrap();
assert_eq!(Value::Object(m.defaults()), json!({"label": "Rose", "angles": [45.0, 135.0], "free": ""}));
let p = m.resolve_params(&json!({"label": "Sunflowers", "angles": [-10, 90, 400]})).unwrap();
assert_eq!((&p["label"], &p["angles"]), (&json!("Sunflowe"), &json!([0.0, 90.0, 360.0])), "cut and clamped");
for bad in [json!({"label": 3}), json!({"angles": []}), json!({"angles": [1, 2, 3, 4]}), json!({"angles": [1, "x"]}), json!({"angles": 5})] {
assert!(m.resolve_params(&bad).is_err(), "{bad}");
}
assert_eq!(
m.params_doc(),
r#"{label: string of at most 8 ("Rose"), angles: 1 to 3 numbers 0..360 ([45.0,135.0]), free: string of at most 256 ("")}"#
);
}
#[test]
fn up_to_256_parameters_with_at_most_64_outside_groups() {
let manifest = |n: usize, grouped: usize| {
let params: Vec<String> = (0..n)
.map(|i| match i < grouped {
true => format!(r#""p{i}":{{"type":"bool","group":"G{}"}}"#, i % 4),
false => format!(r#""p{i}":{{"type":"bool"}}"#),
})
.collect();
format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{}}}}}"#, params.join(","))
};
assert!(Manifest::parse(manifest(MAX_UNGROUPED, 0).as_bytes()).is_ok());
assert!(Manifest::parse(manifest(MAX_UNGROUPED + 1, 0).as_bytes()).is_err(), "too many outside groups");
let all = Manifest::parse(manifest(MAX_PARAMS, MAX_PARAMS - MAX_UNGROUPED).as_bytes()).unwrap();
assert_eq!(all.params.len(), MAX_PARAMS);
assert!(Manifest::parse(manifest(MAX_PARAMS + 1, MAX_PARAMS).as_bytes()).is_err(), "too many in all");
}
#[test]
fn rejects_bad_manifests() {
for bad in [
@@ -430,6 +557,13 @@ mod tests {
r#"{"id":"a","name":"x","kind":"filter","params":{"preview":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"a":{"type":"bool"},"a":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"t":{"type":"string","maxLength":0}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"t":{"type":"string","default":"abc","maxLength":2}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":1,"max":0}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"maxItems":0}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"maxItems":257}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"minItems":2,"maxItems":1}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"minItems":2,"default":[1]}}}"#,
] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
+2 -2
View File
@@ -154,7 +154,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
// Plug-in effects get fields from their parameter schema; colour adjustments their sliders.
let changed = match vectorcraft_plugins::effect::installed(&id) {
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
Some(plugin) => form::schema_fields(app, ui, d, plugin.manifest()),
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
None if id == GLASS => glass_fields(app, ui, d),
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
@@ -336,7 +336,7 @@ pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'
/// of these lists.
pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map<String, Value>, cmyk: bool) -> Option<Vec<(String, String)>> {
if let Some(plugin) = vectorcraft_plugins::effect::installed(effect) {
return plugin.manifest().params.iter().find(|(k, _)| k == key).and_then(|(_, spec)| match spec {
return plugin.manifest().param(key).and_then(|p| match &p.spec {
vectorcraft_plugins::ParamSpec::Choice { options, .. } => Some(options.iter().map(|o| (o.clone(), o.clone())).collect()),
_ => None,
});
+190 -53
View File
@@ -3,6 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_plugins::{Manifest, Param, ParamSpec};
use crate::state::Dialog;
use crate::theme::Tokens;
@@ -369,64 +370,175 @@ fn typed_number(x: f64) -> Value {
if x.fract() == 0.0 && x.abs() < 1e15 { json!(x as i64) } else { json!(x) }
}
/// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers
/// within their range, whole numbers, checkboxes and dropdowns, in declaration order, a row each.
/// Returns true when a value changed. The labels (from the parameter keys) and choices are the
/// plug-in's text, shown as they are. A plug-in may declare more of them
/// Editor for a plug-in's parameters from its manifest (the filter, live effect and exporter
/// dialogs): numbers within their range, whole numbers, checkboxes, dropdowns, text and lists of
/// numbers, a row each. A row is labelled as the manifest says (else from the parameter's name),
/// with its unit after the value and its help on the label and on a help button that opens its
/// page. Parameters outside any group come first, then each group as a section that folds
/// ([`Manifest::sections`]); a parameter whose `visibleWhen` the values don't meet is left out
/// ([`Manifest::visible`]). Returns true when a value changed. Labels, choices, groups and help are
/// the plug-in's text, shown as they are. A plug-in may declare more parameters
/// ([`MAX_PARAMS`](vectorcraft_plugins::manifest::MAX_PARAMS)) than the window has room for, and
/// the rows then scroll ([`super::modal::scrolled_rows`]).
pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, vectorcraft_plugins::ParamSpec)]) -> bool {
use vectorcraft_plugins::ParamSpec;
let t = Tokens::get(ui.ctx());
let mut changed = false;
super::modal::scrolled_rows(ui, "plugin-fields", |ui| {
egui::Grid::new("plugin-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
for (k, spec) in specs {
ui.label(egui::RichText::new(humanize(k)).color(t.text));
let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value());
let new = match spec {
ParamSpec::Number { min, max, .. } => {
let x = cur.as_f64().unwrap_or(*min);
crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, "", 2, 100.0).map(|x| json!(x))
}
ParamSpec::Int { min, max, .. } => {
let x = cur.as_i64().unwrap_or(*min).clamp(*min, *max);
// An f64 holds every whole number up to 2^53, so the numeric field (arrows, wheel, arithmetic) is exact there.
// Past it, the number typed is read as an integer.
const EXACT: i64 = 1 << 53;
if -EXACT <= *min && *max <= EXACT {
crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, "", 0, 100.0)
.map(|x| json!(x as i64))
} else {
let shown = x.to_string();
crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown);
crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1)
.and_then(|text| text.parse::<i64>().ok())
.map(|x| json!(x.clamp(*min, *max)))
}
}
ParamSpec::Bool { .. } => {
let b = cur.as_bool().unwrap_or(false);
crate::widgets::check_box(ui, &humanize(k), b, true).then(|| json!(!b))
}
ParamSpec::Choice { options, .. } => {
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0)
.and_then(|i| options.get(i))
.map(|o| json!(o))
}
};
if let Some(v) = new {
d.fields.insert(k.clone(), v);
changed = true;
}
ui.end_row();
}
});
});
pub(super) fn schema_fields(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, m: &Manifest) -> bool {
let (changed, help) = schema_rows(ui, d, m);
if let Some(url) = help {
app.open_url(&url);
}
changed
}
/// [`schema_fields`] → whether a value changed, and the help page asked for, left to the caller to
/// open.
pub(super) fn schema_rows(ui: &mut egui::Ui, d: &mut Dialog, m: &Manifest) -> (bool, Option<String>) {
let mut changed = false;
let mut help = None;
let visible = m.visible(&d.fields);
let sections = m.sections();
// Every section's label column is as wide as the longest label shown, so the fields line up
// down the dialog; the rows outside groups make up for the indent of a group's rows.
let font = egui::TextStyle::Body.resolve(ui.style());
let label_width = (m.params.iter().filter(|(k, _)| visible.contains(k.as_str())))
.map(|(k, p)| ui.painter().layout_no_wrap(param_label(k, p), font.clone(), egui::Color32::WHITE).size().x)
.fold(0.0, f32::max);
let indent = if sections.iter().any(|(group, _)| group.is_some()) { ui.spacing().indent } else { 0.0 };
super::modal::scrolled_rows(ui, "plugin-fields", |ui| {
for (section, (group, params)) in sections.into_iter().enumerate() {
let shown: Vec<&(String, Param)> = params.into_iter().filter(|(k, _)| visible.contains(k.as_str())).collect();
if shown.is_empty() {
continue;
}
let rows = |ui: &mut egui::Ui, label_width: f32| {
let grid = egui::Grid::new(("plugin-grid", section)).num_columns(2).spacing([12.0, 8.0]).min_col_width(label_width);
grid.show(ui, |ui| {
for (k, p) in shown {
let (row_changed, opened) = param_row(ui, d, k, p);
changed |= row_changed;
help = help.take().or(opened);
ui.end_row();
}
});
};
match group {
None => rows(ui, label_width + indent),
Some(g) => {
egui::CollapsingHeader::new(g).id_salt(("plugin-group", g)).default_open(true).show(ui, |ui| rows(ui, label_width));
}
}
}
});
(changed, help)
}
/// The label of parameter `k`'s row: its manifest `label`, else its name spaced out.
fn param_label(k: &str, p: &Param) -> String {
p.ui.label.as_ref().map_or_else(|| humanize(k), |l| format!("{l}:"))
}
/// One row of [`schema_fields`]: parameter `k`'s label and value field → whether the value changed,
/// and the help page asked for.
fn param_row(ui: &mut egui::Ui, d: &mut Dialog, k: &str, p: &Param) -> (bool, Option<String>) {
let t = Tokens::get(ui.ctx());
let label = param_label(k, p);
let shown_label = ui.label(egui::RichText::new(&label).color(t.text));
if let Some(help) = &p.ui.help {
shown_label.on_hover_text(help);
}
let cur = d.fields.get(k).cloned().unwrap_or_else(|| p.spec.default_value());
let unit = p.ui.unit.as_deref().unwrap_or_default();
let suffix = unit_suffix(unit);
let mut opened = None;
let new = ui
.horizontal(|ui| {
let (new, in_field) = value_field(ui, k, &label, &p.spec, &cur, &suffix);
if !in_field && !unit.is_empty() {
ui.label(egui::RichText::new(unit).color(t.text_dim));
}
if let Some(url) = &p.ui.help_url
&& crate::widgets::icon_button(ui, "external-link", tl!("Open this setting's help page"), false, 20.0).clicked()
{
opened = Some(url.clone());
}
new
})
.inner;
let changed = new.map(|v| d.fields.insert(k.to_string(), v)).is_some();
(changed, opened)
}
/// The field of parameter `k`'s value `cur` (a checkbox is called by the row's `label`) → the new
/// value when it changed, and whether the field shows `suffix` (numbers do; the other fields leave
/// the unit to a label after them).
fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: &Value, suffix: &str) -> (Option<Value>, bool) {
match spec {
ParamSpec::Number { min, max, .. } => {
let x = cur.as_f64().unwrap_or(*min);
(crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, suffix, 2, 100.0).map(|x| json!(x)), true)
}
ParamSpec::Int { min, max, .. } => {
let x = cur.as_i64().unwrap_or(*min).clamp(*min, *max);
// An f64 holds every whole number up to 2^53, so the numeric field (arrows, wheel, arithmetic) is exact there.
// Past it, the number typed is read as an integer.
const EXACT: i64 = 1 << 53;
if -EXACT <= *min && *max <= EXACT {
let field = crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, suffix, 0, 100.0);
(field.map(|x| json!(x as i64)), true)
} else {
let shown = x.to_string();
crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown);
let typed = crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1);
(typed.and_then(|text| text.parse::<i64>().ok()).map(|x| json!(x.clamp(*min, *max))), false)
}
}
ParamSpec::Bool { .. } => {
let b = cur.as_bool().unwrap_or(false);
(crate::widgets::check_box(ui, label, b, true).then(|| json!(!b)), false)
}
ParamSpec::Choice { options, .. } => {
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
let chosen = crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0);
(chosen.and_then(|i| options.get(i)).map(|o| json!(o)), false)
}
ParamSpec::Text { max_length, .. } => {
let typed = crate::widgets::text_field(ui, ("plugin-text", k), cur.as_str(), 160.0, 1);
(typed.map(|text| json!(text.chars().take(*max_length).collect::<String>())), false)
}
ParamSpec::NumberList { .. } => {
let typed = crate::widgets::text_field(ui, ("plugin-list", k), Some(&number_list_text(cur)), 160.0, 1);
(typed.and_then(|text| read_number_list(&text, spec)), false)
}
}
}
/// A `numberList` value as its field shows it: "45, 135".
pub(crate) fn number_list_text(cur: &Value) -> String {
let numbers: Vec<String> = cur.as_array().into_iter().flatten().filter_map(Value::as_f64).map(number_text).collect();
numbers.join(", ")
}
/// The list typed in a `numberList` parameter's field: numbers separated by commas, semicolons or
/// spaces, each clamped into the parameter's range. `None` (the value stays as it was) when a
/// number doesn't read, there are too few or too many, or `spec` isn't a number list.
pub(crate) fn read_number_list(text: &str, spec: &ParamSpec) -> Option<Value> {
let ParamSpec::NumberList { min, max, min_items, max_items, .. } = spec else { return None };
let parts = text.split([',', ';', ' ']).filter(|p| !p.trim().is_empty());
let list: Vec<f64> = parts.map(|p| vectorcraft_doc::parse_number(p).filter(|x| x.is_finite())).collect::<Option<_>>()?;
(*min_items..=*max_items).contains(&list.len()).then(|| json!(list.into_iter().map(|x| x.clamp(*min, *max)).collect::<Vec<f64>>()))
}
/// What a number field shows after its value for a plug-in parameter's `unit` (none: nothing): a
/// sign such as ° or % joins the number ("45°"), and any other unit is spaced from it ("0.4 mm",
/// "5 1/min"), so a unit starting with a digit isn't read as more of the number.
pub(crate) fn unit_suffix(unit: &str) -> String {
if unit.is_empty() || unit.starts_with(['°', '%', '′', '″']) { unit.to_string() } else { format!(" {unit}") }
}
/// A number of a list as [`value_field`] shows it: at most 4 decimals, without trailing zeros.
fn number_text(x: f64) -> String {
let s = format!("{x:.4}");
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
}
/// A field label from its camelCase key (`miterLimit` → "Miter Limit:").
pub(crate) fn humanize(k: &str) -> String {
let mut s = String::new();
@@ -715,6 +827,31 @@ pub(super) fn caption(ui: &mut egui::Ui, text: &str) {
ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim));
}
#[cfg(test)]
mod plugin_field_tests {
use super::*;
/// A unit reads after its number as it is written: a sign joins it ("45°", "50%"), anything
/// else is spaced, so a unit starting with a digit isn't read as more of the number.
#[test]
fn units_follow_the_number() {
let shown = |unit| format!("5{}", unit_suffix(unit));
assert_eq!([shown("mm"), shown("°"), shown("%"), shown("1/min"), shown("µm")], ["5 mm", "5°", "5%", "5 1/min", "5 µm"]);
}
#[test]
fn number_lists_read_as_typed_within_their_limits() {
let spec = ParamSpec::NumberList { min: 0.0, max: 360.0, default: vec![45.0], min_items: 1, max_items: 3 };
assert_eq!(number_list_text(&json!([45.0, 135.5, 0.125])), "45, 135.5, 0.125");
assert_eq!(read_number_list("10; 400 20", &spec), Some(json!([10.0, 360.0, 20.0])), "separators, and clamped");
assert_eq!(read_number_list(" 90 , 2*45 ", &spec), Some(json!([90.0, 90.0])));
for bad in ["", "1, 2, 3, 4", "1, x", "1, inf"] {
assert_eq!(read_number_list(bad, &spec), None, "{bad:?}");
}
assert_eq!(read_number_list("1", &ParamSpec::Bool { default: false }), None, "not a list");
}
}
#[cfg(test)]
mod typed_number_tests {
use super::*;
+3 -1
View File
@@ -120,7 +120,7 @@ pub(crate) use export_for_screens::{
KIND as EXPORT_FOR_SCREENS, formats as screen_formats, open_assets as open_export_for_screens_assets, saved_options as screen_saved_options,
saved_rows as screen_saved_rows,
};
pub(crate) use form::humanize;
pub(crate) use form::{humanize, number_list_text, read_number_list, unit_suffix};
pub(crate) use new_document::preset_name;
pub use new_document::{open as open_new_document, preset_card};
pub use png_options::open as open_raster_options;
@@ -580,6 +580,8 @@ mod tests_package;
#[cfg(test)]
mod tests_plugin_export;
#[cfg(test)]
mod tests_plugin_fields;
#[cfg(test)]
mod tests_raster_formats;
#[cfg(test)]
mod tests_svg_options_range;
+3 -3
View File
@@ -115,7 +115,7 @@ fn params(d: &Dialog) -> Value {
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(ui, d, &plugin.manifest().params);
form::schema_fields(app, ui, d, plugin.manifest());
let (label, p) = (d.str("__label"), params(d));
form::preview(app, ui, d, &label, CMD, p);
false
@@ -146,9 +146,9 @@ pub fn open_export(app: &mut VectorcraftApp, plugin: &Plugin, export: Value) {
app.ui.dialog = Some(Dialog { kind: EXPORT_KIND.into(), fields });
}
fn export_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
fn export_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let Some(plugin) = described(ui, d) else { return true };
form::schema_fields(ui, d, &plugin.manifest().params);
form::schema_fields(app, ui, d, plugin.manifest());
false
}
+14 -5
View File
@@ -522,7 +522,7 @@ fn given_headings_are_shown_as_they_are() {
struct PluginIntField {
ctx: egui::Context,
d: Dialog,
specs: Vec<(String, vectorcraft_plugins::ParamSpec)>,
m: vectorcraft_plugins::Manifest,
pos: egui::Pos2,
}
@@ -531,8 +531,17 @@ impl PluginIntField {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let specs = vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 })];
let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), specs, pos: egui::Pos2::ZERO };
let m = vectorcraft_plugins::Manifest {
id: "test.seed".into(),
name: "Seed".into(),
version: String::new(),
kind: vectorcraft_plugins::Kind::Filter,
description: String::new(),
author: String::new(),
params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())],
extensions: 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> {
match shape {
egui::Shape::Text(t) if t.galley.text() == text => Some(t.visual_bounding_rect()),
@@ -546,9 +555,9 @@ impl PluginIntField {
}
fn run(&mut self, events: Vec<egui::Event>) -> egui::FullOutput {
let (d, specs) = (&mut self.d, &self.specs);
let (d, m) = (&mut self.d, &self.m);
let mut out = self.ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
form::schema_fields(ui, d, specs);
form::schema_rows(ui, d, m);
});
out.textures_delta.clear();
out
+4 -3
View File
@@ -144,12 +144,13 @@ fn check_plugin_dialog(kind: &str, count: usize, check: impl FnOnce(&egui::Conte
/// settings is as tall as they are.
#[test]
fn a_plug_in_dialog_of_many_settings_fits_the_window() {
use vectorcraft_plugins::manifest::MAX_PARAMS;
// As many as a plug-in may declare outside groups.
use vectorcraft_plugins::manifest::MAX_UNGROUPED;
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
let shown = |texts: &[(String, Rect)], text: &str| texts.iter().find(|(t, r)| t == text && screen.contains_rect(*r)).map(|(_, r)| *r);
for kind in ["effect", "filter"] {
check_plugin_dialog(kind, MAX_PARAMS, |ctx, app, window, heading| {
let last = format!("Setting{}:", MAX_PARAMS - 1);
check_plugin_dialog(kind, MAX_UNGROUPED, |ctx, app, window, heading| {
let last = format!("Setting{}:", MAX_UNGROUPED - 1);
let texts = frame(ctx, app, vec![]);
let rect = rect_of(ctx, window);
assert!(screen.contains_rect(rect) && rect.height() > 0.75 * SCREEN.y, "{kind}: the dialog is at {rect:?} in the {SCREEN:?} window");
@@ -0,0 +1,171 @@
//! A plug-in's parameters in its dialog as its manifest presents them (docs/plugins.md ›
//! Parameters): labels, units after the values, help on the labels and a button to its page,
//! sections by group, text and lists of numbers, and parameters shown only while their
//! `visibleWhen` holds. The plug-in is removed after each test: plug-ins are process-wide.
use std::sync::{Arc, Mutex};
use egui::vec2;
use egui_kittest::kittest::Queryable;
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_plugins::wat as tpl;
use super::*;
/// A filter whose parameters use every presentation key.
fn manifest(id: &str) -> String {
format!(
r#"{{"id":"{id}","name":"Hatch Test","version":"1","kind":"filter","params":{{
"outline":{{"type":"bool","default":true,"label":"Outline"}},
"title":{{"type":"string","default":"Poster","maxLength":8,"label":"Title"}},
"pattern":{{"type":"choice","options":["lines","crosshatch","dots"],"default":"lines","label":"Pattern",
"group":"Fill","help":"How the shape is filled."}},
"spacing":{{"type":"number","min":0.1,"max":10,"default":0.25,"label":"Spacing","unit":"mm","group":"Fill",
"helpUrl":"https://example.org/params/spacing","visibleWhen":{{"pattern":["lines","crosshatch"]}}}},
"angles":{{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":4,"label":"Angles",
"unit":"°","group":"Fill","visibleWhen":{{"pattern":["lines","crosshatch"]}}}},
"width":{{"type":"number","min":0,"max":10,"default":0.5,"label":"Width","unit":"pt","group":"Outline",
"visibleWhen":{{"outline":[true]}}}}}}}}"#
)
}
/// The dialog of plug-in `id` in a kittest harness; the web pages it opens go to `opened`.
fn harness(id: &str, opened: Arc<Mutex<Vec<String>>>) -> Harness {
let services = crate::Services { open_url: Some(Box::new(move |u: &str| opened.lock().unwrap().push(u.to_string()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let wasm = wat::parse_str(tpl::module(&manifest(id), tpl::ECHO, "", 1)).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap();
app.run("plugin.dialog", json!({"id": id})).unwrap();
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 800.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut fonts, true) {
crate::theme::install_fonts(ui.ctx());
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(4);
h
}
type Harness = egui_kittest::Harness<'static, VectorcraftApp>;
/// The text label `text` (a checkbox is called by its row's label too).
fn label<'h>(h: &'h Harness, text: &'h str) -> Option<egui_kittest::Node<'h>> {
h.query_by_role_and_label(egui::accesskit::Role::Label, text)
}
fn set(h: &mut Harness, key: &str, value: Value) {
h.state_mut().ui.dialog.as_mut().expect("the dialog is open").fields.insert(key.into(), value);
h.run_steps(2);
}
fn remove(h: &mut Harness, id: &str) {
h.state_mut().run("plugin.remove", json!({"id": id})).unwrap();
}
#[test]
fn labels_units_sections_text_and_lists_come_from_the_manifest() {
let id = "test.ui.fields.shown";
let mut h = harness(id, Arc::default());
for text in ["Outline:", "Title:", "Pattern:", "Spacing:", "Angles:", "Width:", "°"] {
assert!(label(&h, text).is_some(), "{text} isn't shown");
}
for section in ["Fill", "Outline"] {
assert!(h.query_by_label(section).is_some(), "the {section} section isn't shown");
}
for value in ["Poster", "0.25 mm", "45, 135", "0.5 pt"] {
// A text field and the text inside it both carry the value.
assert!(h.query_all_by_value(value).next().is_some(), "the value {value} isn't shown");
}
// The parameters outside groups come first, then each group's section.
let top = |text: &str| label(&h, text).map(|n| n.rect().top()).unwrap_or_default();
let (fill, outline) = (h.get_by_label("Fill").rect().top(), h.get_by_label("Outline").rect().top());
assert!(top("Title:") < fill && fill < top("Spacing:") && top("Angles:") < outline && outline < top("Width:"));
// The fields line up down the dialog, in sections and out of them.
let left = |value: &str| h.query_all_by_value(value).next().map(|n| n.rect().left()).unwrap_or_default();
let (title, spacing, width) = (left("Poster"), left("0.25 mm"), left("0.5 pt"));
assert!((title - spacing).abs() < 0.5 && (spacing - width).abs() < 0.5, "fields at {title}, {spacing} and {width}");
remove(&mut h, id);
}
#[test]
fn visible_when_shows_a_parameter_only_while_its_condition_holds() {
let id = "test.ui.fields.visible";
let mut h = harness(id, Arc::default());
set(&mut h, "pattern", json!("dots"));
assert!(label(&h, "Spacing:").is_none() && label(&h, "Angles:").is_none(), "dots have no line spacing");
set(&mut h, "outline", json!(false));
assert!(label(&h, "Width:").is_none() && h.query_by_label("Outline").is_none(), "without an outline, nor its section");
set(&mut h, "pattern", json!("crosshatch"));
assert!(label(&h, "Spacing:").is_some());
// A hidden parameter still goes to the plug-in: the preview runs with every value.
let fields = &h.state().ui.dialog.as_ref().unwrap().fields;
assert_eq!(fields.get("width"), Some(&json!(0.5)));
remove(&mut h, id);
}
#[test]
fn the_help_button_opens_the_parameter_s_page() {
let id = "test.ui.fields.help";
let opened: Arc<Mutex<Vec<String>>> = Arc::default();
let mut h = harness(id, opened.clone());
let buttons = h.query_all_by_label("Open this setting's help page").count();
assert_eq!(buttons, 1, "only Spacing has a page");
h.get_by_label("Open this setting's help page").click();
h.run_steps(2);
assert_eq!(*opened.lock().unwrap(), ["https://example.org/params/spacing"]);
remove(&mut h, id);
}
/// `CRAFT_UI_PLUGIN_FIELDS_SHOT=<folder>` saves a picture of a dialog whose parameters use every
/// presentation key there, and the test plug-in beside it (`hatch-test-filter.wasm`) to install in
/// the app. Its parameter names are written as many plug-ins write them, in snake case, and it uses
/// only the types every host reads, so a host from before the keys installs it too and shows it as
/// it did.
#[test]
fn plugin_fields_picture() {
let Some(dir) = std::env::var_os("CRAFT_UI_PLUGIN_FIELDS_SHOT") else { return };
let dir = std::path::PathBuf::from(dir);
let manifest = r#"{"id":"org.example.hatch","name":"Hatch Fill (test plug-in)","version":"1","kind":"filter",
"description":"Fills the selected shapes with lines.","params":{
"pattern":{"type":"choice","options":["lines","crosshatch","dots"],"default":"lines",
"label":"Pattern","help":"How the shapes are filled."},
"line_angle":{"type":"number","min":0,"max":360,"default":45,"label":"Angle","unit":"°","group":"Lines",
"help":"The direction of the lines.","visibleWhen":{"pattern":["lines","crosshatch"]}},
"line_spacing_mm":{"type":"number","min":0.1,"max":10,"default":1.5,"label":"Spacing","unit":"mm","group":"Lines",
"help":"Distance between the lines. Smaller is denser.","helpUrl":"https://example.org/params/line_spacing_mm",
"visibleWhen":{"pattern":["lines","crosshatch"]}},
"pen_width_mm":{"type":"number","min":0.1,"max":5,"default":0.3,"label":"Pen width","unit":"mm","group":"Lines"},
"outline":{"type":"bool","default":true,"label":"Outline","group":"Outline"},
"outline_inset_mm":{"type":"number","min":-10,"max":10,"default":0,"label":"Inset","unit":"mm","group":"Outline",
"visibleWhen":{"outline":[true]}},
"outline_passes":{"type":"int","min":1,"max":4,"default":1,"label":"Passes","group":"Outline",
"visibleWhen":{"outline":[true]}}}}"#;
let src = tpl::module(manifest, tpl::ECHO, "", 1);
std::fs::write(dir.join("hatch-test-filter.wasm"), wat::parse_str(&src).unwrap()).unwrap();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(&src).unwrap())})).unwrap();
app.run("plugin.dialog", json!({"id": "org.example.hatch"})).unwrap();
let mut ready = false;
let mut h = egui_kittest::Harness::builder().with_size(vec2(720.0, 620.0)).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut ready, true) {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark);
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(6);
h.render().unwrap().save(dir.join("plugin-fields.png")).unwrap();
h.state_mut().run("plugin.remove", json!({"id": "org.example.hatch"})).unwrap();
}
+1
View File
@@ -1494,6 +1494,7 @@
Inspect Object Инспектиране на обекта
Install Plug-in Инсталиране на приставка
Install Plug-in… Инсталиране на приставка…
Open this setting's help page Отваряне на страницата с помощ за тази настройка
Installed plug-in {} Инсталирана приставка {}
Intensity: Интензивност:
Intent: Цел:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -305,6 +305,7 @@
Plug-ins Zásuvné moduly
No filter plug-ins installed Nejsou nainstalovány žádné filtry
Install Plug-in… Nainstalovat zásuvný modul…
Open this setting's help page Otevřít stránku nápovědy k tomuto nastavení
Reload Plug-ins Znovu načíst zásuvné moduly
Type Text
Font Písmo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -269,6 +269,7 @@
Plug-ins Plug-ins
No filter plug-ins installed Ingen filter-plug-ins installeret
Install Plug-in… Installér plug-in…
Open this setting's help page Åbn hjælpesiden for denne indstilling
Reload Plug-ins Genindlæs plug-ins
Type Tekst
Font Skrifttype
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1526,6 +1526,7 @@ file-filter Templates Vorlagen
Inspect Object Objekt untersuchen
Install Plug-in Plug-in installieren
Install Plug-in… Plug-in installieren…
Open this setting's help page Hilfeseite zu dieser Einstellung öffnen
Installed plug-in {} Plug-in {} installiert
Intensity: Intensität:
Intent: Renderpriorität:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1456,6 +1456,7 @@
Inspect Object Επιθεώρηση αντικειμένου
Install Plug-in Εγκατάσταση προσθέτου
Install Plug-in… Εγκατάσταση προσθέτου…
Open this setting's help page Άνοιγμα της σελίδας βοήθειας αυτής της ρύθμισης
Installed plug-in {} Εγκαταστάθηκε επέκταση {}
Intensity: Ένταση:
Intent: Σκοπός μετατροπής:
1 # Ελληνικά (el) — VectorCraft
1456
1457
1458
1459
1460
1461
1462
+1
View File
@@ -1520,6 +1520,7 @@
Inspect Object Inspeccionar objeto
Install Plug-in Instalar plugin
Install Plug-in… Instalar plugin…
Open this setting's help page Abrir la página de ayuda de este ajuste
Installed plug-in {} Se instaló el plugin {}
Intensity: Intensidad:
Intent: Propósito:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -302,6 +302,7 @@
Plug-ins Laajennukset
No filter plug-ins installed Suodinlaajennuksia ei ole asennettu
Install Plug-in… Asenna laajennus…
Open this setting's help page Avaa tämän asetuksen ohjesivu
Reload Plug-ins Lataa laajennukset uudelleen
Type Teksti
Font Fontti
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1519,6 +1519,7 @@
Inspect Object Inspecter l’objet
Install Plug-in Installer un module externe
Install Plug-in… Installer un module externe…
Open this setting's help page Ouvrir la page d’aide de ce réglage
Installed plug-in {} Module externe {} installé
Intensity: Intensité :
Intent: Mode de rendu :
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1911,6 +1911,7 @@ file-filter Templates Sablonok
Inspect Object Objektum vizsgálata
Install Plug-in Bővítmény telepítése
Install Plug-in… Bővítmény telepítése…
Open this setting's help page A beállítás súgóoldalának megnyitása
Installed plug-in {} Telepített bővítmény: {}
Intensity Erősség
Intensity: Erősség:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -269,6 +269,7 @@
Plug-ins Plug-in
No filter plug-ins installed Tidak ada plug-in filter yang terinstal
Install Plug-in… Instal plug-in…
Open this setting's help page Buka halaman bantuan pengaturan ini
Reload Plug-ins Muat ulang plug-in
Type Teks
Font Font
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
269
270
271
272
273
274
275
+1
View File
@@ -1544,6 +1544,7 @@ file-filter Templates Modelli
Inspect Object Ispeziona oggetto
Install Plug-in Installa plug-in
Install Plug-in… Installa plug-in…
Open this setting's help page Apri la pagina della guida di questa impostazione
Installed plug-in {} Plug-in {} installato
Intensity: Intensità:
Intent: Intento:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -289,6 +289,7 @@
Plug-ins プラグイン
No filter plug-ins installed フィルタープラグインはインストールされていません
Install Plug-in… プラグインをインストール…
Open this setting's help page この設定のヘルプページを開く
Reload Plug-ins プラグインを再読み込み
Type 書式
Font フォント
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -228,6 +228,7 @@
Plug-ins პლაგინები
No filter plug-ins installed ფილტრის პლაგინები არ არის დაყენებული
Install Plug-in… პლაგინის დაყენება…
Open this setting's help page ამ პარამეტრის დახმარების გვერდის გახსნა
Reload Plug-ins პლაგინების ხელახლა ჩატვირთვა
Type ტექსტი
Font შრიფტი
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -285,6 +285,7 @@
Plug-ins 플러그인
No filter plug-ins installed 설치된 필터 플러그인 없음
Install Plug-in… 플러그인 설치…
Open this setting's help page 이 설정의 도움말 페이지 열기
Reload Plug-ins 플러그인 다시 불러오기
Type 문자
Font 글꼴
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -283,6 +283,7 @@
Plug-ins Plug-ins
No filter plug-ins installed Geen filterplug-ins geïnstalleerd
Install Plug-in… Plug-in installeren…
Open this setting's help page Helppagina van deze instelling openen
Reload Plug-ins Plug-ins opnieuw laden
Type Type
Font Lettertype
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -266,6 +266,7 @@
Plug-ins Wtyczki
No filter plug-ins installed Brak zainstalowanych wtyczek filtrów
Install Plug-in… Zainstaluj wtyczkę…
Open this setting's help page Otwórz stronę pomocy dla tego ustawienia
Reload Plug-ins Wczytaj ponownie wtyczki
Type Tekst
Font Czcionka
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1315,6 +1315,7 @@
Inspect Object Inspecionar Objeto
Install Plug-in Instalar Plug-in
Install Plug-in… Instalar Plug-in…
Open this setting's help page Abrir a página de ajuda desta configuração
Intensity: Intensidade:
Intent: Intenção:
Interface Language Idioma da Interface
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1542,6 +1542,7 @@ file-filter Templates Шаблоны
Inspect Object Проверить объект
Install Plug-in Установить модуль
Install Plug-in… Установить модуль…
Open this setting's help page Открыть страницу справки по этому параметру
Installed plug-in {} Установлен модуль {}
Intensity: Интенсивность:
Intent: Назначение:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1496,6 +1496,7 @@
Inspect Object Inspektera objekt
Install Plug-in Installera plugin
Install Plug-in… Installera plugin…
Open this setting's help page Öppna hjälpsidan för den här inställningen
Installed plug-in {} Installerade plugin {}
Intensity: Intensitet:
Intent: Återgivningsmetod:
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -1538,6 +1538,7 @@ file-filter Templates Шаблони
Inspect Object Перевірити об’єкт
Install Plug-in Встановити модуль
Install Plug-in… Встановити модуль…
Open this setting's help page Відкрити сторінку довідки для цього параметра
Installed plug-in {} Встановлено модуль {}
Intensity: Інтенсивність:
Intent: Призначення:
Can't render this file because it contains an unexpected character in line 725 and column 40.
+1
View File
@@ -1787,6 +1787,7 @@ file-filter Templates Mẫu
Inspect Object Kiểm tra đối tượng
Install Plug-in Cài đặt plug-in
Install Plug-in… Cài đặt plug-in…
Open this setting's help page Mở trang trợ giúp của thiết lập này
Installed plug-in {} Đã cài plug-in {}
Intensity Cường độ
Intensity: Cường độ:
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1787
1788
1789
1790
1791
1792
1793
+1
View File
@@ -1479,6 +1479,7 @@ file-filter Templates 模板
Inspect Object 检查对象
Install Plug-in 安装插件
Install Plug-in… 安装增效工具…
Open this setting's help page 打开此设置的帮助页面
Installed plug-in {} 已安装增效工具 {}
Intensity: 强度:
Intent: 意图:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1515,6 +1515,7 @@ file-filter Templates 範本
Inspect Object 檢查物件
Install Plug-in 安裝增效模組
Install Plug-in… 安裝增效模組…
Open this setting's help page 開啟此設定的說明頁面
Installed plug-in {} 已安裝增效模組 {}
Intensity: 強度:
Intent: 目的:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+68 -3
View File
@@ -739,6 +739,10 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
let mut change: Option<(String, Value)> = None;
let mut edit = false;
let built_in = built_in_effect(&e.id);
// A plug-in effect's parameters are labelled, measured and shown as its manifest says.
let plugin = vectorcraft_plugins::effect::installed(&e.id);
let manifest = plugin.as_ref().map(|p| p.manifest());
let visible = manifest.map(|m| m.visible(&params));
let (unit, relative) = (app.session.general_unit(), params.get("relative").and_then(Value::as_bool).unwrap_or(false));
let cmyk = app.session.active().is_some_and(|s| s.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk);
egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin { left: (EYE_W + 12.0) as i8, right: 6, top: 4, bottom: 4 }).show(ui, |ui| {
@@ -747,9 +751,16 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
widgets::dim_label(ui, tl!("No options"));
}
for (key, v) in &params {
if visible.as_ref().is_some_and(|v| !v.contains(key.as_str())) {
continue;
}
let ui_keys = manifest.and_then(|m| m.param(key)).map(|p| &p.ui);
ui.horizontal(|ui| {
let label = param_label(key, built_in);
ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label.as_str()).size(11.5).color(t.text)).truncate());
let label = ui_keys.and_then(|u| u.label.clone()).unwrap_or_else(|| param_label(key, built_in));
let shown = ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label.as_str()).size(11.5).color(t.text)).truncate());
if let Some(help) = ui_keys.and_then(|u| u.help.as_deref()) {
shown.on_hover_text(help);
}
if e.id == "stylize.dropShadow" && key == "shade" {
let cur = v.as_str().unwrap_or_default();
for (label, value) in crate::dialogs::SHADES {
@@ -771,6 +782,19 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
}
return;
}
// A plug-in's number list is typed as its dialog's is: "45, 135".
if let Some(spec) = manifest.and_then(|m| m.param(key)).map(|p| &p.spec)
&& matches!(spec, vectorcraft_plugins::manifest::ParamSpec::NumberList { .. })
{
let typed = widgets::exact_text_field(ui, ("fx", item, k, key.as_str()), &crate::dialogs::number_list_text(v), 90.0);
if let Some(list) = typed.and_then(|text| crate::dialogs::read_number_list(&text, spec)) {
change = Some((key.clone(), list));
}
if let Some(unit) = ui_keys.and_then(|u| u.unit.as_deref()) {
widgets::dim_label(ui, unit);
}
return;
}
// Only our own effects show a `mode` parameter as the blend mode dropdown; a plug-in's `mode` stays a plain field.
if built_in && let Some(cur) = widgets::blend_param(key, v) {
if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) {
@@ -786,10 +810,11 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
}
Value::Number(n) => {
let (x, id) = (n.as_f64().unwrap_or(0.0), ("fx", item, k, key.as_str()));
let suffix = ui_keys.and_then(|u| u.unit.as_deref()).map(crate::dialogs::unit_suffix).unwrap_or_default();
let nx = if vectorcraft_effects::is_length(&e.id, key, relative) {
widgets::num_field(ui, id, Some(x), unit, 70.0)
} else {
widgets::plain_field(ui, id, x, "", 2, 70.0)
widgets::plain_field(ui, id, x, &suffix, 2, 70.0)
};
if let Some(nx) = nx {
change = Some((key.clone(), json!(nx)));
@@ -1164,6 +1189,46 @@ mod tests {
assert!(texts.iter().any(|s| s == "screen") && !texts.iter().any(|s| s == "Screen"), "{texts:?}");
}
/// A plug-in effect's editor labels its parameters, shows their units and leaves out those whose
/// `visibleWhen` isn't met, as the effect's dialog does, and reads a typed number list the same
/// way.
#[test]
fn a_plug_in_effect_s_editor_follows_its_manifest() {
use vectorcraft_plugins::wat as tpl;
let params = r#"{"outline":{"type":"bool","default":true,"label":"Outline"},
"spacing":{"type":"number","min":0.1,"max":10,"default":1.5,"label":"Spacing","unit":"mm","help":"Distance between the lines."},
"angles":{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":4,"unit":"°"},
"width":{"type":"number","min":0,"max":10,"default":0.5,"label":"Width","unit":"pt","visibleWhen":{"outline":[true]}}}"#;
let src = tpl::module(&tpl::manifest("test.ui.inlinekeys", "effect", params), tpl::ECHO, "", 1);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap();
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinekeys"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let texts = shown_texts(&mut app, &ctx);
for shown in ["Spacing", "1.5 mm", "Angles", "45, 135", "°", "Width", "0.5 pt"] {
assert!(texts.iter().any(|s| s == shown), "no {shown:?} in {texts:?}");
}
assert!(!texts.iter().any(|s| s == "spacing" || s == "width"), "the labels replace the names: {texts:?}");
// Without the outline, its width isn't shown.
app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"outline": false}})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(!texts.iter().any(|s| s == "Width") && texts.iter().any(|s| s == "Spacing"), "{texts:?}");
// A list typed in the field: separated by a semicolon and a space, and clamped.
click_text(&mut app, &ctx, "45, 135");
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
panel_frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
panel_frame(&mut app, &ctx, vec![egui::Event::Text("10; 400 20".into())]);
panel_frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
panel_frame(&mut app, &ctx, vec![]);
let angles = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["angles"].clone();
assert_eq!(angles, json!([10.0, 360.0, 20.0]));
app.run("plugin.remove", json!({"id": "test.ui.inlinekeys"})).unwrap();
}
/// Glass with a loaded texture lists Loaded Texture in its Texture menu, as its dialog does.
#[test]
fn the_effect_editor_lists_a_loaded_glass_texture() {
+8 -1
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of BLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain doublLine truncated
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
@@ -331,6 +331,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
extensions). It is given the document as the native file's JSON and its parameters, with a larger budget than a
filter's, which Background Export's Cancel stops; the host writes the file it returns, and nothing when it declines
or returns bad data. Engine, ABI and headless Export As regressions.
- **Done:** a plug-in's parameters say how its dialogs show them: `label`, `unit`, `help`, `helpUrl`, `group`
(sections that fold) and `visibleWhen` (shown only while the checkboxes and choices it names have one of the
listed values). `string` and `numberList` parameters join the others, and a plug-in declares up to 256, at most 64
outside groups. The filter, live-effect and exporter dialogs use them, and the Appearance panel's effect editor the
labels, units, help and conditions; hosts from before the keys ignore them. Manifest unit tests and kittest dialog
regressions.
- **Estimate:** 20–45 h for the APIs; the ecosystem itself isn't agent work.
## G18 Generative AI features
@@ -351,6 +357,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | G17: plug-in parameters with labels, units, help, groups and conditions (`visibleWhen`), `string` and `numberList` types, up to 256 per plug-in; manifest and kittest dialog regressions. No entry closed, estimates unchanged. |
| 2026-10-19 | minor | G17: exporter plug-ins, plug-in file formats that Export As and `document.export` write; engine, ABI and headless Export As regressions. No entry closed, estimates unchanged. |
| 2026-10-19 | minor | Cache status-readout sample widths per UI, language, document unit and pixels per point; regressions check reuse across live frames and refresh after display-setting changes. Scores and estimates unchanged. |
| 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. |
+30 -7
View File
@@ -39,9 +39,10 @@ its extensions that no built-in format has; `selectedOnly` hands it the selectio
In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload
Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with
parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns) with a live
preview; without parameters it runs at once. Parameters too many for the window's height scroll inside the dialog,
which keeps its heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use
parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns, text and lists
of numbers, labelled, explained and sorted into sections as its manifest says) with a live preview; without
parameters it runs at once. Parameters too many for the window's height scroll inside the dialog, which keeps its
heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use
Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the
same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs
a plug-in too (on the web this is how plug-ins get in).
@@ -111,10 +112,11 @@ pointers are offsets into the module's memory.
"author": "Example",
"description": "What it does.",
"params": {
"amount": {"type": "number", "min": 0, "max": 100, "default": 50},
"steps": {"type": "int", "min": 1, "max": 16, "default": 4},
"amount": {"type": "number", "min": 0, "max": 100, "default": 50, "unit": "%"},
"steps": {"type": "int", "min": 1, "max": 16, "default": 4, "label": "Steps per curve"},
"closed": {"type": "bool", "default": false},
"mode": {"type": "choice", "options": ["soft", "hard"], "default": "soft"}
"mode": {"type": "choice", "options": ["soft", "hard"], "default": "soft",
"help": "Soft keeps the corners round; hard keeps them sharp."}
}
}
```
@@ -130,7 +132,28 @@ pointers are offsets into the module's memory.
- `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
are also clamped to their range, including the implicit zero default of an `int` parameter.
are also clamped to their range, including the implicit zero default of an `int` parameter. Besides `number`,
`int`, `bool` and `choice` there are `string` (`{"type": "string", "default": "", "maxLength": 256}`: a text field;
longer text is cut to `maxLength`, at most 4,096 characters) and `numberList` (`{"type": "numberList", "min": 0,
"max": 360, "default": [45, 135], "minItems": 1, "maxItems": 16}`: numbers typed separated by commas or spaces,
each clamped to `min..max`; a list of too few or too many is an error, and `maxItems` is at most 256). A plug-in
declares up to 256 parameters, at most 64 of them outside groups.
Each parameter may also say how dialogs show it. Hosts from before these keys ignore them, so a plug-in that
declares them still installs there:
| Key | What the dialog does with it |
|---|---|
| `label` | Labels the field (1–64 characters). Without one, the label is the name spaced out (`rowSpacing` → Row Spacing). |
| `unit` | Shows the unit the value is in after it (1–16 characters, such as `mm` or `°`). Nothing is converted: the plug-in gets the number as typed. |
| `group` | Puts the parameter in a section that folds (1–64 characters). Parameters outside any group come first, then each group in the order it first appears. |
| `help` | Shows what the parameter does when the pointer rests on its label (1–1,000 characters; line breaks allowed). |
| `helpUrl` | Opens this `https://` page (at most 512 bytes) from a button after the field. |
| `visibleWhen` | `{"pattern": ["lines", "crosshatch"], "outline": [true]}` shows the parameter only while each parameter it names (a checkbox or a choice declared before it) has one of the values listed and is shown itself. |
A hidden parameter still goes to the plug-in with its value: what the dialog showed never changes a run. The
filter, live-effect and exporter dialogs use all of these keys. The Appearance panel's editor of a plug-in effect's
parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the parameters without sections or help buttons.
### Input
+4 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection andLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSLine truncated
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -833,6 +833,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.<id>`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.rs`. Scores and hours unchanged.
- **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.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.
- **Effect art after font and color settings changes:** when a font becomes available (fonts added, Refresh Font List, `text.rescanFonts`), the next frame the canvas draws outlines type in it where a geometry effect reshapes the type, a group that holds it or a symbol instance whose art holds it. When the color settings change (Edit › Color Settings, `edit.colorSettings`), the next frame shades objects with Revolve, Extrude & Bevel, Inflate or Rotate, and recolors objects and groups with a color adjustment, through the new settings.
@@ -848,6 +850,7 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Plug-ins: parameters with labels, units, help, groups and conditions in their dialogs, `string` and `numberList` types, up to 256 per plug-in (G17); manifest and kittest dialog regressions. Scores and hours unchanged. |
| 2026-10-19 | minor | Plug-ins: exporter plug-ins add write-only file formats to Export As and `document.export` (G17); engine, ABI and headless Export As regressions. Scores and hours unchanged. |
| 2026-10-19 | minor | Libraries panel: the list fills the panel and scrolls (also while a drag nears its edges); items drag before or after others of their kind and into or out of groups, and groups drag by their headers (`library.moveItem` `before`/`after`, `library.moveGroup`). Scores and estimates unchanged. |
| 2026-10-19 | minor | Cache status-readout sample widths per UI, language, document unit and pixels per point; regressions check reuse across live frames and refresh after display-setting changes. Scores and estimates unchanged. |