diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 610b50c2d..cd03a4151 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -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::>()})) @@ -432,7 +432,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index e2bc451d9..1d0926f53 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -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 = 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(); diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 6d47c5511..f870be9ba 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -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 { + 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 { + 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::()), ParamSpec::NumberList { min, max, default, .. } => json!(default.iter().map(|x| x.clamp(*min, *max)).collect::>()), + 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> = 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| { diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index db743af15..29f126963 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -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>, + /// Makes the object a guide (`true`, as View › Guides › Make Guides does) or a plain path. + pub guide: Option, + /// Locks or unlocks the object (as Object › Lock and Unlock do). + pub locked: Option, } /// 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 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> { + 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). diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index c937120f8..5f7a0d4fb 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -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] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 740326c43..3e029b247 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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; diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 8bfcef3d3..ae011ca52 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -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 { + 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 = cur.as_array().into_iter().flatten().filter_map(Value::as_f64).map(number_text).collect(); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 56e59c753..c8b0bf6e2 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index e8de2d489..7e0c7a12b 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1213,6 +1213,13 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, 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) { diff --git a/docs/plugins.md b/docs/plugins.md index 298a44b85..c11dece82 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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.