Plug-ins: compound guides aren't filter inputs; colors follow the working RGB space

A compound path a filter made a guide was still given to later filters, which
could change or delete it; compound paths of guide paths are now left out as
guide paths are. Hex colors and the color button's choice are display sRGB and
are now converted into the working RGB space, so they show as given whatever
the working profile; a color default is kept as written and read when used.

Follow-up to #5630.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
d8abyte-dk
2026-11-05 00:42:23 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent a0ef97af40
commit ad93abd484
5 changed files with 80 additions and 20 deletions
+53 -11
View File
@@ -117,26 +117,31 @@ pub enum ParamSpec {
/// `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,
/// As the manifest gives it (`null`: black), read with [`color_value`] when used, so a hex
/// default follows the working RGB space in use then, not the one at install.
#[serde(default)]
default: Value,
},
}
/// A color parameter's value: a color object or a hex string, its components clamped into range.
/// A color parameter's value: a color object, or a hex string (display sRGB, converted into the
/// active working RGB space as the color picker's choices are), its components clamped into range.
pub fn color_value(v: &Value) -> Option<vectorcraft_color::Color> {
use vectorcraft_color::{Color, cms};
let c = match v {
Value::String(s) => vectorcraft_color::Color::from_hex(s)?,
Value::String(s) => match Color::from_hex(s)? {
Color::Rgb { r, g, b } => {
let [r, g, b] = cms::active().srgb_to_rgb([r, g, b]);
Color::rgb(r, g, b)
}
other => other,
},
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 {
-1_000_000
}
@@ -176,7 +181,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(),
ParamSpec::Color { default } => serde_json::to_value(color_value(default).unwrap_or_default()).unwrap_or_default(),
}
}
@@ -196,6 +201,9 @@ impl ParamSpec {
}
Ok(())
}
ParamSpec::Color { default } if !default.is_null() && color_value(default).is_none() => {
bad("a color default must be a color object or a hex string such as \"#3a7bd5\"")
}
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"))
}
@@ -344,7 +352,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()),
ParamSpec::Color { default } => format!("{k}: color object or hex ({})", color_value(default).unwrap_or_default().to_hex()),
})
.collect();
format!("{{{}}}", parts.join(", "))
@@ -635,6 +643,40 @@ mod tests {
assert!(Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"color","default":"nope"}}}"#).is_err());
}
/// A hex color is display sRGB: in a wide-gamut working RGB space it becomes the working RGB
/// color that shows as that hex, read when used, while the manifest keeps the default as written.
#[test]
fn hex_colors_follow_the_working_rgb_space() {
const CHILD: &str = "VECTORCRAFT_TEST_PLUGIN_COLOR_PROFILE";
if std::env::var_os(CHILD).is_none() {
// Color settings are process-wide: exercise this profile in a separate test process.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "manifest::tests::hex_colors_follow_the_working_rgb_space", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"isolated color profile test failed:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
return;
}
use vectorcraft_color::{Color, cms};
let m =
Manifest::parse(br##"{"id":"org.example.grid","name":"Grid","kind":"filter","params":{"line":{"type":"color","default":"#3a7bd5"}}}"##)
.unwrap();
cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
for c in [m.defaults()["line"].clone(), m.resolve_params(&json!({"line": "#3a7bd5"})).unwrap()["line"].clone()] {
let c: Color = serde_json::from_value(c).unwrap();
assert_ne!(c, Color::rgb8(0x3a, 0x7b, 0xd5), "converted into the working space");
assert_eq!(c.to_hex(), "#3a7bd5", "shows as the hex given");
}
assert_eq!(serde_json::to_value(&m).unwrap()["params"]["line"]["default"], json!("#3a7bd5"), "kept as written");
cms::set_active(&cms::ColorSettings::default()).unwrap();
}
#[test]
fn up_to_256_parameters_with_at_most_64_outside_groups() {
let manifest = |n: usize, grouped: usize| {
+4 -3
View File
@@ -621,8 +621,9 @@ pub fn selectable(n: &Node) -> bool {
}
/// 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).
/// selected ones, and those inside selected groups and layers; hidden, locked, guide (compound
/// guides too) and clipping paths are left out (so are paths inside compound paths, blends and
/// other live objects).
pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec<NodeId> {
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, out: &mut Vec<NodeId>) {
for n in list {
@@ -631,7 +632,7 @@ pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec<NodeId> {
}
let picked = inside || sel.contains(&n.id);
match &n.kind {
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked => out.push(n.id),
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !is_guide(n) => out.push(n.id),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out),
_ => {}
}
+13
View File
@@ -151,6 +151,19 @@ fn geometry_type_and_strokes_change() {
assert!(n.appearance.stroke().is_none());
}
/// A compound path made a guide is left out of a later filter's inputs, as guide paths are; a
/// plain compound path is still one of them.
#[test]
fn compound_guides_are_not_filter_targets() {
let (mut d, _, layer) = doc();
let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect());
let out = json!({"objects": [{"type": "compound", "path": two.clone(), "guide": true}, {"type": "compound", "path": two}]});
let made = apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &keep).unwrap();
let targets = filter_targets(&d, &[layer]);
assert!(!targets.contains(&made[0]), "the compound guide");
assert!(targets.contains(&made[1]), "the plain compound path");
}
/// `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]
+4 -2
View File
@@ -638,12 +638,14 @@ fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: &
}
/// 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.
/// picker → the color picked, as the color object the plug-in gets: the picker's display sRGB in the
/// working RGB space, so the color shows as it was picked whatever the working profile.
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())
let picked = || crate::screen_color::sampled(&vectorcraft_color::cms::active(), rgb.map(|v| f32::from(v) / 255.0));
changed.then(|| serde_json::to_value(picked()).unwrap_or_default())
}
/// A `numberList` value as its field shows it: "45, 135".
+6 -4
View File
@@ -171,8 +171,10 @@ pointers are offsets into the module's memory.
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
always gets a color object, its components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; a
hex string and the picker's choice are display colors, given as the working RGB color that shows as them, so in a
working RGB space other than sRGB the numbers differ from the hex). 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
@@ -194,8 +196,8 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet
### Input
`input` is UTF-8 JSON: `{"objects": [...]}`. An object filter gets the selected paths and compound paths in paint
order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide and clipping
paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never
order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound
guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never
touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one
object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts
from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a