Merge pull request #5574 from dotCooCoo/fix/dialog-color-refuses-alpha

Dialogs: a color with an alpha part is refused instead of losing its alpha
This commit is contained in:
BFlat
2026-11-04 18:53:27 +07:00
committed by GitHub
8 changed files with 174 additions and 13 deletions
@@ -13,7 +13,7 @@ use vectorcraft_color::Color;
use vectorcraft_doc::LAYER_COLORS;
use vectorcraft_tools::builder::LP_BUCKET_WIDTH_RANGE;
use super::tile_edge_color::resolve;
use super::tile_edge_color::{checked, resolve};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
@@ -78,8 +78,11 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// OK: set the tool's options.
/// OK: set the tool's options, or an error for a `color` that is not a color.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if d.fields.contains_key("color") {
checked(&d.str("color"))?;
}
let values: Map<String, Value> = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
run_and_close(app, "tool.setOption", json!({ "tool": TOOL, "values": values }))
}
@@ -148,4 +151,24 @@ mod tests {
app.select_tool(TOOL);
assert!(!hover(&mut app, 151.0).iter().any(|o| matches!(o, Overlay::Path { .. })));
}
/// A highlight color with an alpha part is refused and leaves the tool's options as they were;
/// `#rrggbb` still sets them.
#[test]
fn a_color_with_an_alpha_part_is_refused() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("tool.options", json!({"tool": TOOL})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("color".into(), json!("#ff000080"));
d.fields.insert("width".into(), json!(8));
let err = super::super::confirm(&mut app).unwrap_err();
assert_eq!(err, "`#ff000080` is not a colour (#rrggbb or a preset name)");
assert!(app.ui.dialog.is_some(), "the dialog stays open");
let o = app.session.tool_options_of(TOOL);
assert_eq!((o["color"].as_str(), o["width"].as_f64()), (Some("#ff2a2a"), Some(4.0)), "nothing changes");
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#ff0000"));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.tool_options_of(TOOL)["color"], json!("#ff0000"));
}
}
+61 -3
View File
@@ -15,7 +15,7 @@ use vectorcraft_doc::{GapSize, LAYER_COLORS};
use vectorcraft_geom::{BezPath, Point};
use vectorcraft_tools::Overlay;
use super::tile_edge_color::resolve;
use super::tile_edge_color::{checked, has_alpha, resolve};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
@@ -116,7 +116,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
ui.add_space(6.0);
form::check(ui, d, "closeGaps", tl!("Close Gaps With Paths"));
let p = params(d);
let mut p = params(d);
// OK refuses a color with an alpha part, so the preview leaves each group's own color.
if has_alpha(&d.str("color")) {
p["color"] = Value::Null;
}
if let Some(r) = form::preview_result(app, ui, d, "Gap Options", CMD, p) {
d.fields.insert(GAPS.into(), r["gaps"].clone());
}
@@ -127,6 +131,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
checked(&d.str("color"))?;
form::commit_preview(app, CMD, params(d))
}
@@ -134,7 +139,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
/// off or another dialog is open).
pub(crate) fn overlays(app: &VectorcraftApp) -> Vec<Overlay> {
let Some(d) = app.ui.dialog.as_ref().filter(|d| d.kind == KIND && d.bool("preview")) else { return vec![] };
let [r, g, b, _] = resolve(&d.str("color")).unwrap_or(Color::BLACK).to_rgba8(1.0);
let [r, g, b, _] = checked(&d.str("color")).unwrap_or(Color::BLACK).to_rgba8(1.0);
let gaps = d.fields.get(GAPS).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
gaps.iter()
.filter_map(|gap| {
@@ -257,4 +262,57 @@ mod tests {
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("size"), d.f64("custom", 0.0)), ("large".to_string(), GapSize::LARGE));
}
/// A Gap Preview Color with an alpha part is refused and leaves the group's options as they
/// were; `#rrggbb` still sets them.
#[test]
fn a_color_with_an_alpha_part_is_refused() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap();
app.run("select.all", json!({})).unwrap();
let g = app.run("livePaint.make", json!({})).unwrap()["id"].as_u64().unwrap();
app.run("ui.livePaintGapOptions", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("color".into(), json!("#ff000080"));
d.fields.insert("detect".into(), json!(true));
let depth = app.session.doc().unwrap().history.undo.len();
let err = crate::dialogs::confirm(&mut app).unwrap_err();
assert_eq!(err, "`#ff000080` is not a colour (#rrggbb or a preset name)");
assert!(app.ui.dialog.is_some(), "the dialog stays open");
assert_eq!((options(&app, g), app.session.doc().unwrap().history.undo.len()), (GapOptions::default(), depth), "nothing changes");
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#ff0000"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(options(&app, g).color, [0xff, 0, 0]);
}
/// With Preview on, a Gap Preview Color with an alpha part is not previewed: the group keeps
/// its own color, the gaps draw in black, OK refuses the color and Cancel rolls the preview back.
#[test]
fn a_color_with_an_alpha_part_is_not_previewed() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
// An open path round a square, its end 2 pt short of its start.
let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(x, y)| json!({"x": x, "y": y}));
let p = app.run("path.create", json!({ "anchors": anchors })).unwrap();
app.run("select.set", json!({"ids": [p["id"]]})).unwrap();
let g = app.run("livePaint.make", json!({})).unwrap()["id"].as_u64().unwrap();
let own = options(&app, g).color;
app.run("ui.livePaintGapOptions", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("preview".into(), json!(true));
d.fields.insert("detect".into(), json!(true));
d.fields.insert("color".into(), json!("#ff000080"));
let depth = app.session.doc().unwrap().history.undo.len();
frame(&mut app);
assert_eq!((options(&app, g).detect, options(&app, g).color), (true, own), "the group keeps its own color");
let shown = overlays(&app);
assert!(matches!(&shown[..], [Overlay::Path { color: [0, 0, 0], .. }]), "{shown:?}");
let err = crate::dialogs::confirm(&mut app).unwrap_err();
assert_eq!(err, "`#ff000080` is not a colour (#rrggbb or a preset name)");
assert!(app.ui.dialog.is_some(), "the dialog stays open");
crate::dialogs::cancel(&mut app);
let undo = app.session.doc().unwrap().history.undo.len();
assert_eq!((options(&app, g), undo), (GapOptions::default(), depth), "Cancel rolls the preview back");
}
}
@@ -12,7 +12,7 @@ use vectorcraft_color::Color;
use vectorcraft_doc::LAYER_COLORS;
use vectorcraft_tools::builder::LP_SELECT_WIDTH_RANGE;
use super::tile_edge_color::resolve;
use super::tile_edge_color::{checked, resolve};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
@@ -74,8 +74,11 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// OK: set the tool's options.
/// OK: set the tool's options, or an error for a `color` that is not a color.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if d.fields.contains_key("color") {
checked(&d.str("color"))?;
}
let values: Map<String, Value> = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
run_and_close(app, "tool.setOption", json!({ "tool": TOOL, "values": values }))
}
@@ -139,4 +142,24 @@ mod tests {
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.bool("selectFills"), d.str("color")), (false, "#4fff4f".to_string()));
}
/// A highlight color with an alpha part is refused and leaves the tool's options as they were;
/// `#rrggbb` still sets them.
#[test]
fn a_color_with_an_alpha_part_is_refused() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("tool.options", json!({"tool": TOOL})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("color".into(), json!("#ff000080"));
d.fields.insert("width".into(), json!(8));
let err = super::super::confirm(&mut app).unwrap_err();
assert_eq!(err, "`#ff000080` is not a colour (#rrggbb or a preset name)");
assert!(app.ui.dialog.is_some(), "the dialog stays open");
let o = app.session.tool_options_of(TOOL);
assert_eq!((o["color"].as_str(), o["width"].as_f64()), (Some("#ff2a2a"), Some(4.0)), "nothing changes");
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#ff0000"));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.tool_options_of(TOOL)["color"], json!("#ff0000"));
}
}
+50 -1
View File
@@ -32,6 +32,23 @@ pub(super) fn resolve(color: &str) -> Option<Color> {
}
}
/// Whether `color` is an 8-digit hex color, `#rrggbbaa` (with or without `#`).
pub(super) fn has_alpha(color: &str) -> bool {
let color = color.trim();
let digits = color.strip_prefix('#').unwrap_or(color);
digits.len() == 8 && digits.chars().all(|c| c.is_ascii_hexdigit())
}
/// `color` as [`resolve`] reads it, or an error naming the forms it takes. A hex color with an
/// alpha part (`#rrggbbaa` or `#rgba`) is an error.
pub(super) fn checked(color: &str) -> Result<Color, String> {
let refused = || format!("`{color}` is not a colour (#rrggbb or a preset name)");
if has_alpha(color) {
return Err(refused());
}
resolve(color).ok_or_else(refused)
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let color = resolve(&d.str("color")).unwrap_or(Color::BLACK);
let [r, g, b, _] = color.to_rgba8(1.0);
@@ -58,7 +75,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let color = d.str("color");
let hex = resolve(&color).ok_or_else(|| format!("`{color}` is not a colour (#rrggbb or a preset name)"))?.to_hex();
let hex = checked(&color)?.to_hex();
run_and_close(app, "prefs.set", json!({ "key": "patternTileEdgeColor", "value": hex }))
}
@@ -90,4 +107,36 @@ mod tests {
app.run("ui.tileEdgeColor", json!({})).unwrap();
assert!(set(&mut app, "nope").is_err());
}
/// A hex color with an alpha part is refused and leaves the preference as it was; `#rrggbb`
/// still sets it.
#[test]
fn a_color_with_an_alpha_part_is_refused() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("ui.tileEdgeColor", json!({})).unwrap();
let before = app.session.prefs.pattern_tile_edge_color.clone();
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#ff000080"));
let err = super::super::confirm(&mut app).unwrap_err();
assert_eq!(err, "`#ff000080` is not a colour (#rrggbb or a preset name)");
assert_eq!(app.session.prefs.pattern_tile_edge_color, before);
assert!(app.ui.dialog.is_some(), "the dialog stays open");
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#ff0000"));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.prefs.pattern_tile_edge_color, "#ff0000");
}
/// The swatches the dialogs draw with `resolve` show an 8-digit color by its RGB, while
/// `checked` refuses it; preset names and `#rrggbb` pass both.
#[test]
fn only_checked_refuses_a_color_with_an_alpha_part() {
let red = Color::rgb8(0xff, 0, 0).to_rgba8(1.0);
assert_eq!(resolve("#ff000080").map(|c| c.to_rgba8(1.0)), Some(red));
assert_eq!(resolve("ff000080").map(|c| c.to_rgba8(1.0)), Some(red));
assert!(checked("#ff000080").is_err() && checked(" ff000080 ").is_err());
assert!(checked("#f008").is_err());
assert_eq!(checked("#ff0000").map(|c| c.to_rgba8(1.0)), Ok(red));
for (name, [r, g, b]) in LAYER_COLORS.iter() {
assert_eq!(checked(name).map(|c| c.to_rgba8(1.0)), Ok([*r, *g, *b, 255]), "{name}");
}
}
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -320,6 +320,7 @@ The User Defined libraries are `window.userBrushLibrary1`…`10` in Window → B
Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`) opens the `tileEdgeColor` dialog (field
`color`: `#rrggbb` or a preset name such as "Light Blue"); `ui.dialog.confirm` sets the preference
`patternTileEdgeColor` (`prefs.set`), the colour pattern editing mode draws the tile edge and the swatch bounds in.
A `color` with an alpha part (`#rrggbbaa` or `#rgba`) is an error, and the preference does not change.
Window title bar: on Windows and Linux the window has no OS decorations and the app bar is the title bar. Its
caption buttons (Minimize, Maximize/Restore, Close) sit at the bar's right end, 46 pt each; they are window chrome,
+8 -2
View File
@@ -1868,13 +1868,18 @@ already walks Image Trace. Short agent entry points:
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.
The Gap Options dialog (`ui.livePaintGapOptions`) takes `color` as `#rrggbb` or a layer color's name; its OK gives
an error for a `color` with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing. With Preview on, a
`#rrggbbaa` color is not previewed: the groups keep their own color, and the previewed gaps draw in black.
`view.livePaintGaps` (View › Show Live Paint Gaps, or Hide Live Paint Gaps while it is on) turns the drawing of
those gaps on or off. While it is on and edges show, the canvas draws the gaps of each selected Live Paint group in
that group's Gap Preview Color. The reply is the new state (`true` when on).
The Live Paint Selection tool's options (`tool.setOption {tool: "livePaintSelection", values}`; double-clicking the
tool opens them, dialog `livePaintSelectionOptions`) are `selectFills` and `selectStrokes` (both on), `color` (the
highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the highlight's width, 1 to 20 screen
pixels; 4). With `selectStrokes` off, Alt-click selects the face under the pointer as a click does; with
pixels; 4). The dialog's OK gives an error for a `color` with an alpha part (`#rrggbbaa` or `#rgba`) and changes
nothing.
With `selectStrokes` off, Alt-click selects the face under the pointer as a click does; with
`selectFills` off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click selects
no face or edge. A drag of 3 screen pixels or more draws a marquee, and the release runs `select.set {ids}` with
the faces and edges it touches (a face or edge partly inside counts, and so does a face that holds the whole
@@ -1887,7 +1892,8 @@ already walks Image Trace. Short agent entry points:
The Live Paint Bucket's options (`tool.setOption {tool: "livePaintBucket", values}`; double-clicking the tool opens
them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `highlight` (on), `color` (the
highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the face highlight's width, 1 to 20 screen
pixels; 4; an edge's highlight is half as wide). With `paintFills` alone on, a click runs `livePaint.fill` and Shift-click runs `livePaint.strokeEdge`.
pixels; 4; an edge's highlight is half as wide). The dialog's OK gives an error for a `color` with an alpha part
(`#rrggbbaa` or `#rgba`) and changes nothing. With `paintFills` alone on, a click runs `livePaint.fill` and Shift-click runs `livePaint.strokeEdge`.
With `paintStrokes` alone on, a click runs `livePaint.strokeEdge` on the edge within 4 screen pixels of the pointer,
or nothing when none is in reach, and Shift-click runs `livePaint.fill`. With both on, a click runs
`livePaint.strokeEdge` when an edge is within 4 screen pixels of the pointer and `livePaint.fill` otherwise, and
+1
View File
@@ -1007,6 +1007,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else 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.
View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)).
Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`).
As VectorCraft's own rule, OK in Live Paint Selection Tool Options, Live Paint Bucket Options, Gap Options and Tile Edge Color gives an error for a color with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing.
A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected.
A drag with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected.
When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`.