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:
BFlat
2026-11-04 22:55:34 +07:00
committed by GitHub
10 changed files with 208 additions and 9 deletions
+2 -2
View File
@@ -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
),
+20
View File
@@ -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();
+55
View File
@@ -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| {
+45 -1
View File
@@ -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).
+27
View File
@@ -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]
+1 -1
View File
@@ -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;
+10
View File
@@ -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();
+29
View File
@@ -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();
+7
View File
@@ -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
View File
@@ -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.