mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -1456,6 +1456,7 @@
|
||||
Inspect Object Επιθεώρηση αντικειμένου
|
||||
Install Plug-in Εγκατάσταση προσθέτου
|
||||
Install Plug-in… Εγκατάσταση προσθέτου…
|
||||
Open this setting's help page Άνοιγμα της σελίδας βοήθειας αυτής της ρύθμισης
|
||||
Installed plug-in {} Εγκαταστάθηκε επέκταση {}
|
||||
Intensity: Ένταση:
|
||||
Intent: Σκοπός μετατροπής:
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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 độ:
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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(¶ms));
|
||||
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 ¶ms {
|
||||
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
File diff suppressed because one or more lines are too long
+30
-7
@@ -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
Reference in New Issue
Block a user