Merge pull request #5581 from dotCooCoo/fix/layer-color-refuses-alpha

Layers: a layer or gap color with an alpha part is refused instead of losing its alpha
This commit is contained in:
BFlat
2026-11-04 18:53:43 +07:00
committed by GitHub
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();
+5 -2
View File
@@ -1864,7 +1864,9 @@ already walks Image Trace. Short agent entry points:
covering source, including compound paths with shared curved boundaries. Expand keeps those face fills.
With `detect: true`, paint stops at the gaps that run
from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a
number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. The options are saved with the group, and the reply
number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. `color` (the Gap Preview
Color) is `#rrggbb` or a layer color's name; a hex color with an alpha part, `#rrggbbaa` or `#rgba`, is an error and
changes nothing. The options are saved with the group, and the reply
and `livePaint.info` list the gaps up to `size` wide that no path closes, as `[x0, y0, x1, y1]`. `livePaint.merge`
keeps the bottom group's options when its Gap Detection is on, and otherwise turns Gap Detection on with `small`
when another merged group has it on. When Gap Detection is off in every merged group, it stays off.
@@ -2600,7 +2602,8 @@ and `layerRows`, the rows highlighted in the panel.
- **Eye and lock**: `layer.setProps {ids?, visible?, locked?, name?}` for any rows, plus Layer Options for layers:
`template` (also locks the layer and dims its images to 50% unless given), `printable`, `preview` (off: the layer
draws and is clicked in outline, Ctrl/Cmd-click its eye), `dimImages` (0–100, or false) and `color` (an index 0–26,
`#rrggbb` or a preset name). Several rows change in one undo step (a drag down the eye or lock column).
`#rrggbb` or a preset name; a hex color with an alpha part, `#rrggbbaa` or `#rgba`, is an error, and so it is in
`layer.new` and `layer.newSublayer`). Several rows change in one undo step (a drag down the eye or lock column).
- **Dragging rows**: `layer.move {ids, target, place?: above|below|inside, copy?}` moves rows beside a row or into a
layer or group (on top of its contents), keeping their stacking order; `copy` (Alt-drag) moves copies. Layers go
only in layers or at the top level and objects in layers and groups (an object placed beside a top-level layer goes
+1
View File
@@ -832,6 +832,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Object › Arrange › Send to Current Layer returns the error "the target layer is locked or hidden" and leaves the selected objects where they are when the current layer is hidden or locked, or is inside a hidden or locked layer.
Selected objects are deselected, the key object included, when a row drag moves them into a hidden layer or group, when `node.move` moves them into a hidden or locked layer or group, when Merge Selected moves them into a hidden or locked layer, when Collect in New Layer moves them into a new layer inside a hidden or locked layer, when Flatten Artwork moves them into a locked layer, and when applying a data set hides them.
Layer Options for several highlighted rows shows the first row's settings, and OK changes only the settings set in the dialog: each row keeps its own color, Template, Lock, Show, Print, Preview and Dim Images otherwise (`crates/ui-egui/src/dialogs/layer_options.rs` tests).
As VectorCraft's own rule, a layer color (`layer.setProps`, `layer.new`, `layer.newSublayer`) or a Live Paint Gap Preview Color (`livePaint.gapOptions`) given as a hex color with an alpha part (`#rrggbbaa` or `#rgba`) is an error and changes nothing, instead of being set without its alpha. Regression tests: `a_layer_color_with_an_alpha_part_is_refused` in `crates/engine/src/tests/layers.rs` and `a_gap_color_with_an_alpha_part_is_refused` in `crates/engine/src/tests/build.rs`.
Panel Options › Locate Selected Objects (beyond Illustrator, off by default): when the last selected object changes on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as Locate Object does, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges is nearest a gridline on each axis (the center does not snap), #740, #1816; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't).
A move that Shift keeps level, upright or diagonal stays on that line when it snaps, with the Selection, Group Selection or Direct Selection tool. It slides along the line until the selection lands on the grid or on whole pixels, lines up with the other objects or is spaced evenly with them, or until the line passes an anchor or crosses a ruler guide near the point the selection was grabbed by. A level or upright move snaps along its own axis only.