mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 22:28:09 +00:00
Merge pull request #5630 from d8abyte-dk/plugins-color-param-guide-locked
Plug-ins: color parameters, and filters that make guides and lock
This commit is contained in:
@@ -185,7 +185,7 @@ impl FilterRun {
|
||||
if let Some(data) = out.document_data {
|
||||
d.set_plugin_data(&m.id, data);
|
||||
}
|
||||
sel.set(ids.iter().copied());
|
||||
sel.set(ids.iter().copied().filter(|&id| d.node(id).is_some_and(objects::selectable)));
|
||||
Ok(ids)
|
||||
})?;
|
||||
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
@@ -432,7 +432,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
|
||||
@@ -102,6 +102,26 @@ fn object_filters_add_replace_and_generate_objects() {
|
||||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().id, made);
|
||||
}
|
||||
|
||||
/// A generator can make guides and lock what it adds, in its one undo step; those objects are left
|
||||
/// out of the selection that follows, as Make Guides and Object › Lock leave them.
|
||||
#[test]
|
||||
fn filters_make_guides_and_locked_objects_left_unselected() {
|
||||
let mut s = session();
|
||||
let out = json!({"objects": [
|
||||
{"path": triangle(), "name": "Guide", "guide": true},
|
||||
{"path": triangle(), "name": "Locked", "locked": true},
|
||||
{"path": triangle(), "name": "Plain"}]});
|
||||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.guides", "filter", "{}"), &out.to_string()));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.guides"}));
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||||
assert!(matches!(node(&s, ids[0]).kind, vectorcraft_doc::NodeKind::Path { guide: true, .. }));
|
||||
assert!(node(&s, ids[1]).locked);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.objects, [ids[2]]);
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_plugins_leave_the_document_and_history_alone() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -113,6 +113,28 @@ pub enum ParamSpec {
|
||||
#[serde(default = "list_max")]
|
||||
max_items: usize,
|
||||
},
|
||||
/// A color (a color button that opens a color picker). Its default, and a value passed to
|
||||
/// `plugin.run`, is a color object as paints hold one (`{"model": "rgb", "r", "g", "b"}`, or
|
||||
/// `cmyk`, `gray`, `lab`) or a hex string (`"#3a7bd5"`); the plug-in always gets a color object.
|
||||
Color {
|
||||
#[serde(default, deserialize_with = "color_default")]
|
||||
default: vectorcraft_color::Color,
|
||||
},
|
||||
}
|
||||
|
||||
/// A color parameter's value: a color object or a hex string, its components clamped into range.
|
||||
pub fn color_value(v: &Value) -> Option<vectorcraft_color::Color> {
|
||||
let c = match v {
|
||||
Value::String(s) => vectorcraft_color::Color::from_hex(s)?,
|
||||
Value::Object(_) => serde_json::from_value(v.clone()).ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(crate::objects::clamp_color(c))
|
||||
}
|
||||
|
||||
fn color_default<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<vectorcraft_color::Color, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
color_value(&v).ok_or_else(|| serde::de::Error::custom("a color default must be a color object or a hex string such as \"#3a7bd5\""))
|
||||
}
|
||||
|
||||
fn int_min() -> i64 {
|
||||
@@ -154,6 +176,7 @@ impl ParamSpec {
|
||||
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>>()),
|
||||
ParamSpec::Color { default } => serde_json::to_value(default).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +344,7 @@ impl Manifest {
|
||||
ParamSpec::NumberList { min, max, min_items, max_items, .. } => {
|
||||
format!("{k}: {min_items} to {max_items} numbers {min}..{max} ({})", p.spec.default_value())
|
||||
}
|
||||
ParamSpec::Color { default } => format!("{k}: color object or hex ({})", default.to_hex()),
|
||||
})
|
||||
.collect();
|
||||
format!("{{{}}}", parts.join(", "))
|
||||
@@ -382,6 +406,10 @@ impl Manifest {
|
||||
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"))?)
|
||||
}
|
||||
ParamSpec::Color { .. } => {
|
||||
let c = color_value(v).ok_or_else(|| wrong("a color object or a hex string such as \"#3a7bd5\""))?;
|
||||
serde_json::to_value(c).map_err(|e| Error::Params(format!("`{k}`: {e}")))?
|
||||
}
|
||||
};
|
||||
out.insert(k.clone(), resolved);
|
||||
}
|
||||
@@ -580,6 +608,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A color's default and value may be a color object or a hex string; the plug-in always gets
|
||||
/// a color object, its components clamped. Without a default it is black.
|
||||
#[test]
|
||||
fn colors_resolve_to_color_objects() {
|
||||
let m = Manifest::parse(
|
||||
br##"{"id":"org.example.grid","name":"Grid","kind":"filter","params":{
|
||||
"line":{"type":"color","default":"#ff0000"},
|
||||
"ink":{"type":"color","default":{"model":"cmyk","c":1,"m":0,"y":0,"k":2}},
|
||||
"plain":{"type":"color"}}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
Value::Object(m.defaults()),
|
||||
json!({"line": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0},
|
||||
"ink": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 1.0},
|
||||
"plain": {"model": "rgb", "r": 0.0, "g": 0.0, "b": 0.0}})
|
||||
);
|
||||
let p = m.resolve_params(&json!({"line": "#0000ff", "ink": {"model": "gray", "k": 0.5}})).unwrap();
|
||||
assert_eq!(p["line"], json!({"model": "rgb", "r": 0.0, "g": 0.0, "b": 1.0}));
|
||||
assert_eq!(p["ink"], json!({"model": "gray", "k": 0.5}));
|
||||
for bad in [json!({"line": "not a color"}), json!({"line": 3}), json!({"line": {"model": "hsv", "h": 1}})] {
|
||||
assert!(m.resolve_params(&bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(m.params_doc().starts_with("{line: color object or hex (#ff0000)"));
|
||||
assert!(Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"color","default":"nope"}}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_to_256_parameters_with_at_most_64_outside_groups() {
|
||||
let manifest = |n: usize, grouped: usize| {
|
||||
|
||||
@@ -52,6 +52,10 @@ pub struct OutObject {
|
||||
/// The object's effects, in order, replacing its own (not its fills' or strokes'). The host
|
||||
/// admits them before they reach the document ([`AdmitEffects`]).
|
||||
pub effects: Option<Vec<Effect>>,
|
||||
/// Makes the object a guide (`true`, as View › Guides › Make Guides does) or a plain path.
|
||||
pub guide: Option<bool>,
|
||||
/// Locks or unlocks the object (as Object › Lock and Unlock do).
|
||||
pub locked: Option<bool>,
|
||||
}
|
||||
|
||||
/// One stroke of an [`OutObject`]. `None` fields keep the stroke's value (a new stroke's
|
||||
@@ -195,7 +199,7 @@ fn unit(v: f32) -> f32 {
|
||||
}
|
||||
|
||||
/// `c` with its components in range.
|
||||
fn clamp_color(c: Color) -> Color {
|
||||
pub(crate) fn clamp_color(c: Color) -> Color {
|
||||
match c {
|
||||
Color::Rgb { r, g, b } => Color::Rgb { r: unit(r), g: unit(g), b: unit(b) },
|
||||
Color::Cmyk { c, m, y, k } => Color::Cmyk { c: unit(c), m: unit(m), y: unit(y), k: unit(k) },
|
||||
@@ -320,6 +324,11 @@ fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject>
|
||||
if let Some(op) = opt(o, "opacity") {
|
||||
out.opacity = Some(unit(op.as_f64().ok_or_else(|| out_err(at, "`opacity` must be a number"))? as f32));
|
||||
}
|
||||
let flag = |key: &str| -> Result<Option<bool>> {
|
||||
opt(o, key).map(|v| v.as_bool().ok_or_else(|| out_err(at, format!("`{key}` must be true or false")))).transpose()
|
||||
};
|
||||
out.guide = flag("guide")?;
|
||||
out.locked = flag("locked")?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -573,9 +582,44 @@ fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str, admit: AdmitE
|
||||
if let Some(effects) = o.effects {
|
||||
n.appearance.effects = admit(&effects, n).map_err(|why| out_err(&format!("{at}.effects"), why))?;
|
||||
}
|
||||
if let Some(on) = o.guide {
|
||||
set_guide(n, on);
|
||||
}
|
||||
if let Some(locked) = o.locked {
|
||||
n.locked = locked;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Makes path or compound path `n` a guide or a plain path, as Make Guides and Release Guides do: a
|
||||
/// compound path's members carry the flag.
|
||||
fn set_guide(n: &mut Node, on: bool) {
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { guide, clipping: false, .. } => *guide = on,
|
||||
NodeKind::Compound { children, .. } => {
|
||||
for c in children.iter_mut() {
|
||||
set_guide(Arc::make_mut(c), on);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `n` is a guide: a guide path, or a compound path of guide paths.
|
||||
fn is_guide(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide, .. } => *guide,
|
||||
NodeKind::Compound { children, .. } => !children.is_empty() && children.iter().all(|c| is_guide(c)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a filter's result `n` is selected after the run: locked objects and guides aren't, as
|
||||
/// Object › Lock and Make Guides leave them deselected.
|
||||
pub fn selectable(n: &Node) -> bool {
|
||||
!n.locked && !is_guide(n)
|
||||
}
|
||||
|
||||
/// The paths and compound paths a filter reads from the selection `selected`, in paint order:
|
||||
/// selected ones, and those inside selected groups and layers; hidden, locked, guide and clipping
|
||||
/// paths are left out (so are paths inside compound paths, blends and other live objects).
|
||||
|
||||
@@ -151,6 +151,33 @@ fn geometry_type_and_strokes_change() {
|
||||
assert!(n.appearance.stroke().is_none());
|
||||
}
|
||||
|
||||
/// `guide` and `locked` make objects guides (a compound path's members too) and lock them, or undo
|
||||
/// both; locked objects and guides are not [`selectable`]. Other values are refused.
|
||||
#[test]
|
||||
fn objects_become_guides_and_lock() {
|
||||
let (mut d, ids, layer) = doc();
|
||||
let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect());
|
||||
let out = json!({"objects": [
|
||||
{"id": ids[0].0, "guide": true},
|
||||
{"id": ids[1].0, "locked": true},
|
||||
{"name": "G", "type": "compound", "path": two, "guide": true, "locked": true}]});
|
||||
let made = apply_output(&mut d, &ids[..2], decode_json(out).unwrap(), Some(layer), &keep).unwrap();
|
||||
let a = d.node(ids[0]).unwrap();
|
||||
assert!(matches!(a.kind, NodeKind::Path { guide: true, .. }) && !a.locked && !selectable(a));
|
||||
let b = d.node(ids[1]).unwrap();
|
||||
assert!(b.locked && matches!(b.kind, NodeKind::Path { guide: false, .. }) && !selectable(b));
|
||||
let g = d.node(made[2]).unwrap();
|
||||
let NodeKind::Compound { children, .. } = &g.kind else { panic!("not a compound: {:?}", g.kind) };
|
||||
assert!(g.locked && children.iter().all(|c| matches!(c.kind, NodeKind::Path { guide: true, .. })));
|
||||
// And back: a plain, unlocked, selectable path.
|
||||
let out = json!({"objects": [{"id": ids[0].0, "guide": false, "locked": false}]});
|
||||
apply_output(&mut d, &ids[..1], decode_json(out).unwrap(), None, &keep).unwrap();
|
||||
assert!(selectable(d.node(ids[0]).unwrap()));
|
||||
for bad in [json!({"guide": 1}), json!({"locked": "yes"})] {
|
||||
assert!(decode_json(json!({"objects": [bad]})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A paint handed back as it was keeps its swatch link and any other is unlinked; a pattern paint
|
||||
/// must name a pattern the document has.
|
||||
#[test]
|
||||
|
||||
@@ -142,7 +142,7 @@ pub(crate) use export_for_screens::{
|
||||
import_presets as screen_import_presets, open_assets as open_export_for_screens_assets, preset_list as screen_preset_list,
|
||||
saved_options as screen_saved_options, saved_rows as screen_saved_rows,
|
||||
};
|
||||
pub(crate) use form::{humanize, number_list_text, read_number_list, unit_suffix};
|
||||
pub(crate) use form::{humanize, number_list_text, plugin_color_button, read_number_list, unit_suffix};
|
||||
pub(crate) use new_document::preset_name;
|
||||
pub use new_document::{create_without_dialog as new_document_without_dialog, open as open_new_document, preset_card};
|
||||
pub use png_options::open as open_raster_options;
|
||||
|
||||
@@ -633,9 +633,19 @@ fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: &
|
||||
let typed = crate::widgets::text_field_named(ui, ("plugin-list", k), Some(label), Some(&number_list_text(cur)), 160.0, 1);
|
||||
(typed.and_then(|text| read_number_list(&text, spec)), false)
|
||||
}
|
||||
ParamSpec::Color { .. } => (plugin_color_button(ui, ("plugin-color", k), cur), false),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plug-in `color` parameter's button, showing `cur` (a color object or hex) and opening the color
|
||||
/// picker → the color picked, as the color object the plug-in gets.
|
||||
pub(crate) fn plugin_color_button(ui: &mut egui::Ui, id: impl std::hash::Hash + std::fmt::Debug, cur: &Value) -> Option<Value> {
|
||||
let [r, g, b, _] = vectorcraft_plugins::manifest::color_value(cur).unwrap_or_default().to_rgba8(1.0);
|
||||
let mut rgb = [r, g, b];
|
||||
let changed = ui.push_id(id, |ui| ui.color_edit_button_srgb(&mut rgb).changed()).inner;
|
||||
changed.then(|| serde_json::to_value(vectorcraft_color::Color::rgb8(rgb[0], rgb[1], rgb[2])).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
@@ -665,6 +665,35 @@ fn plugin_integer_field_with_small_bounds_keeps_numeric_editing() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A plug-in's `color` parameter shows as a color button filled with its value, whether the value
|
||||
/// is a color object or a hex string.
|
||||
#[test]
|
||||
fn plugin_color_parameter_shows_a_color_button() {
|
||||
fn filled(shape: &egui::Shape, c: egui::Color32) -> bool {
|
||||
match shape {
|
||||
egui::Shape::Rect(r) => r.fill == c,
|
||||
egui::Shape::Vec(shapes) => shapes.iter().any(|s| filled(s, c)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let m = vectorcraft_plugins::Manifest::parse(
|
||||
br##"{"id":"test.grid","name":"Grid","kind":"filter","params":{"line":{"type":"color","default":"#000000"}}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let want = egui::Color32::from_rgb(0x3a, 0x7b, 0xd5);
|
||||
for cur in [json!({"model": "rgb", "r": 0x3a as f32 / 255.0, "g": 0x7b as f32 / 255.0, "b": 0xd5 as f32 / 255.0}), json!("#3a7bd5")] {
|
||||
let mut d = Dialog::new(plugin::KIND, json!({ "line": cur }));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
form::schema_rows(ui, &mut d, &m);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(out.shapes.iter().any(|s| filled(&s.shape, want)), "{cur}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -1213,6 +1213,13 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A plug-in's color is the color button its dialog shows.
|
||||
if manifest.and_then(|m| m.param(key)).is_some_and(|p| matches!(p.spec, vectorcraft_plugins::manifest::ParamSpec::Color { .. })) {
|
||||
if let Some(c) = crate::dialogs::plugin_color_button(ui, ("fx-color", item, k, key.as_str()), v) {
|
||||
change = Some((key.clone(), c));
|
||||
}
|
||||
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) {
|
||||
|
||||
+12
-5
@@ -25,7 +25,7 @@ This page is the contract for plug-in authors and the reference for the host (`c
|
||||
|---|---|---|
|
||||
| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters)) |
|
||||
| `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). |
|
||||
| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected. |
|
||||
| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. |
|
||||
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. |
|
||||
| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have. |
|
||||
| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. |
|
||||
@@ -40,8 +40,8 @@ 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, text and lists
|
||||
of numbers, labelled, explained and sorted into sections as its manifest says) with a live preview; without
|
||||
parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns, text, lists
|
||||
of numbers and colors, 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
|
||||
@@ -168,8 +168,12 @@ pointers are offsets into the module's memory.
|
||||
`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 clamped to `min..max`; a list of too few or too many is an error, and `maxItems` is at most 256) and `color`
|
||||
(`{"type": "color", "default": "#3a7bd5"}`: a color button that opens a color picker; the default, black when left
|
||||
out, and a value given to `plugin.run` may be a color object as paints hold one or a hex string, and the plug-in
|
||||
always gets a color object, its components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; the
|
||||
picker gives RGB). Hosts from before `color` refuse a manifest that uses it. 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:
|
||||
@@ -278,6 +282,9 @@ For an **object filter**, the output is the new state of the input objects:
|
||||
strokes, full opacity.
|
||||
- Input objects missing from the output are deleted. The order of the input objects themselves doesn't change.
|
||||
- `type` turns a path into a compound path (one member per subpath) or back; `name: ""` clears the name.
|
||||
- `guide: true` makes the object a guide, as View › Guides › Make Guides does (a compound path's members become
|
||||
guide paths), and `guide: false` a plain path again; `locked: true` locks it and `locked: false` unlocks it. Objects
|
||||
the run locked or made guides are left out of the selection that follows. Hosts from before these keys ignore them.
|
||||
- A `path` returned as it was keeps a live shape live (a rectangle keeps its corner widgets); a changed one makes it a
|
||||
plain path.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user