Merge pull request #3172 from KaboomFox/p2-rich-params

Plug-ins: labels, units, groups, help and conditions for parameters (G17)
This commit is contained in:
BFlat
2026-10-20 08:27:36 +07:00
committed by GitHub
37 changed files with 997 additions and 112 deletions
+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
File diff suppressed because one or more lines are too long
+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
File diff suppressed because one or more lines are too long