Layers: a layer or gap color with an alpha part is refused instead of losing its alpha

layer.setProps, layer.new, layer.newSublayer and livePaint.gapOptions read
an 8-digit hex color such as #ff000080 as #ff0000 and set it without its
alpha. They now refuse it with the error a bad color already gets, and
change nothing. #rgba was already refused. #rrggbb, #rgb, a preset index
and a preset name are read as before.
This commit is contained in:
dotCooCoo
2026-11-04 18:29:17 +07:00
committed by bflatastic
parent 12508e6c60
commit d79f5ccb75
6 changed files with 62 additions and 6 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gap Options",
[],
None,
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
has_selection_or_ids,
lp_gap_options
),
+10 -3
View File
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Options…",
["Window", "Layers"],
None,
"{ids?|id? (default: the highlighted rows, else the current layer), name? (with prefs.identifyObjectsBy xmlId: a valid XML ID, stored as the object name), visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step",
"{ids?|id? (default: the highlighted rows, else the current layer), name? (with prefs.identifyObjectsBy xmlId: a valid XML ID, stored as the object name), visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name (a hex color with an alpha part, #rrggbbaa or #rgba, is an error)} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step",
has_doc,
set_props
),
@@ -297,7 +297,8 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
NewLayers::of(d).make(d, name)
}
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case). A hex color
/// with an alpha part (`#rrggbbaa` or `#rgba`) is an error.
pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
match v {
Value::Number(n) => match n.as_u64() {
@@ -308,7 +309,13 @@ pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, St
if let Some(i) = LAYER_COLORS.iter().position(|(name, _)| name.eq_ignore_ascii_case(s.trim())) {
return Ok(LayerColor::Preset(i as u8));
}
let c = vectorcraft_color::Color::from_hex(s).ok_or_else(|| format!("`{s}` is not a colour (#rrggbb or a preset name)"))?;
let refused = || format!("`{s}` is not a colour (#rrggbb or a preset name)");
// `Color::from_hex` reads `#rrggbbaa` as `#rrggbb`, dropping the alpha.
let digits = s.trim();
if digits.strip_prefix('#').unwrap_or(digits).len() == 8 {
return Err(refused());
}
let c = vectorcraft_color::Color::from_hex(s).ok_or_else(refused)?;
let [r, g, b, _] = c.to_rgba8(1.0);
Ok(LayerColor::Custom([r, g, b]))
}
+19
View File
@@ -2244,6 +2244,25 @@ fn live_paint_gap_detection_stops_paint_at_a_gap() {
assert!(node(&s, g).extras().live_paint_gaps.is_none());
}
/// A Gap Preview Color with an alpha part (`#rrggbbaa` or `#rgba`) is refused and changes nothing;
/// `#rrggbb` and a layer color's name still set it.
#[test]
fn a_gap_color_with_an_alpha_part_is_refused() {
let mut s = session();
let g = live_square_with_a_gap(&mut s);
let depth = s.doc().unwrap().history.undo.len();
for bad in ["#00ff0080", "00ff0080", "#0f08"] {
let err = s.execute("livePaint.gapOptions", &json!({"id": g.0, "color": bad})).unwrap_err().to_string();
assert!(err.contains(&format!("`{bad}` is not a colour (#rrggbb or a preset name)")), "{bad}: {err}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), depth);
assert_eq!(gap_options(&s, g).color, [0xff, 0x4f, 0x4f]);
s.execute("livePaint.gapOptions", &json!({"id": g.0, "color": "#00ff00"})).unwrap();
assert_eq!(gap_options(&s, g).color, [0, 0xff, 0]);
s.execute("livePaint.gapOptions", &json!({"id": g.0, "color": "Green"})).unwrap();
assert_eq!(gap_options(&s, g).color, [0x4f, 0xff, 0x4f]);
}
#[test]
fn medium_and_large_gaps_stop_paint_at_wider_gaps() {
// Squares whose ends stop 5 pt and 9 pt short of their starts: each size closes the gaps up
+26
View File
@@ -375,6 +375,32 @@ fn layer_options_validate_and_template_locks_and_dims() {
assert_eq!((summary["preview"].clone(), summary["dimImages"].clone()), (json!(false), json!(30)));
}
/// A layer color with an alpha part (`#rrggbbaa` or `#rgba`) is refused by Layer Options, New
/// Layer and New Sublayer and changes nothing; `#rrggbb` and a preset name still set it.
#[test]
fn a_layer_color_with_an_alpha_part_is_refused() {
let mut s = session();
let (layer, ..) = tree(&mut s);
let color = |s: &Session| match node(s, layer).kind {
NodeKind::Layer { color, .. } => color,
_ => panic!("not a layer"),
};
let before = (color(&s), undos(&s));
for bad in ["#ff000080", "ff000080", " #FF000080 ", "#f008"] {
let err = s.execute("layer.setProps", &json!({"id": layer.0, "color": bad})).unwrap_err().to_string();
assert!(err.contains(&format!("`{bad}` is not a colour (#rrggbb or a preset name)")), "{bad}: {err}");
assert!(s.execute("layer.new", &json!({"color": bad})).is_err(), "{bad}");
assert!(s.execute("layer.newSublayer", &json!({"parent": layer.0, "color": bad})).is_err(), "{bad}");
}
assert_eq!((color(&s), undos(&s)), before);
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "#ff0000"}));
assert_eq!(color(&s), LayerColor::Custom([0xff, 0, 0]));
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "Green"}));
assert_eq!(color(&s), LayerColor::Preset(2));
let new = id(&run(&mut s, "layer.new", json!({"color": "#00ff00"})));
assert!(matches!(node(&s, new).kind, NodeKind::Layer { color: LayerColor::Custom([0, 0xff, 0]), .. }));
}
#[test]
fn new_layers_go_above_the_current_one_at_its_level() {
let mut s = session();