mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Plug-ins: labels, units, groups, help and conditions for parameters (G17)
A plug-in's dialog labelled each field with its parameter's name spaced
out ("Line_spacing_mm:"), showed numbers without their unit and listed
at most 64 parameters in one column. A plug-in with many settings, such
as a generator's or an exporter's, could neither explain nor sort them,
and its dialog showed settings that don't apply to the choices made.
Each parameter may now say how dialogs show it, in keys that hosts
from before them ignore (plugins::dialog):
- label; unit, after the value ("0.25 mm"); help, on the label; and
helpUrl, an https:// page opened from a button after the field.
- group: the parameters outside any group come first, then each group
as a section that folds, in the order the groups first appear, the
fields lined up down the dialog.
- visibleWhen: {"pattern": ["lines"]} shows a parameter only
while each checkbox or choice it names, declared before it, has one
of the values listed and is shown itself. Manifest::visible decides
every parameter in one pass, in declaration order. A hidden parameter
still goes to the plug-in with its value, so a run never depends on
what the dialog showed.
Two types join number, int, bool and choice: string, a text field cut
to maxLength, and numberList, numbers typed separated by commas or
spaces, each clamped, minItems to maxItems of them. A plug-in declares
up to 256 parameters, at most 64 outside groups. The filter, live-effect
and exporter dialogs use every key. The Appearance panel's editor of a
plug-in effect uses the labels, units, help and conditions, and reads a
typed number list as the dialog does.
dialog.rs tests parsing, the round trip of a manifest written out,
sections, visibility (also of 256 overlapping conditions, which a
recursive check would take forever on) and the keys refused;
manifest.rs the new types and limits. tests_plugin_fields.rs draws a
dialog with kittest: labels and units, sections in order with their
fields lined up, visibleWhen hiding and showing rows, and the help
button opening its page.
appearance.rs checks the editor's labels, units, conditions and a
typed number list, and form.rs units after numbers (a unit starting
with a digit is spaced from it) and typed lists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
# Conflicts:
# docs/gaps.md
# docs/target-app-parity.md
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