mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 12:13:19 +00:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
a0ef97af40
commit
ad93abd484
@@ -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| {
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user