Recolor Artwork: color sliders edit the selected row's new color in a chosen mode

The Assign tab gets a Color Mode menu (HSB, RGB, CMYK, Lab, and Tint when
the row's new color is a spot color of the Limit to Library library) and
its sliders under the rows. They edit the first selected row's new color
and are disabled with no row selected. Once they have edited a color in a
mode, the preview and OK send recolor.apply with model "new", so the art
takes the color in that mode.

recolor.apply rows take an optional tint (percent, default 100): art linked
to the row's global or spot library swatch takes that share of its tint.
recolor.randomize keeps a row's tint.
This commit is contained in:
dotCooCoo
2026-10-31 21:42:42 -07:00
parent 58e98510d1
commit 46cb088eae
7 changed files with 418 additions and 26 deletions
+25 -9
View File
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
has_doc,
apply
),
@@ -260,6 +260,9 @@ struct Row {
from: Vec<Match>,
to: Option<Color>,
exclude: bool,
/// The tint (0..1) of a global or spot swatch the new color is (with `limitTo`): art linked to
/// the swatch takes this share of the tint the method gives it.
tint: f32,
}
impl Row {
@@ -280,7 +283,9 @@ fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
};
let cur = |v: &Value| Match::of(v).ok_or_else(|| bad(cmd, format!("bad current colour {v}")));
match p.get("map") {
Some(Value::Object(m)) => m.iter().map(|(k, v)| Ok(Row { from: vec![cur(&json!(k))?], to: new(Some(v))?, exclude: false })).collect(),
Some(Value::Object(m)) => {
m.iter().map(|(k, v)| Ok(Row { from: vec![cur(&json!(k))?], to: new(Some(v))?, exclude: false, tint: 1.0 })).collect()
}
Some(Value::Array(rows)) => rows
.iter()
.map(|r| {
@@ -289,7 +294,8 @@ fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
Some(v) => vec![cur(v)?],
None => return Err(bad(cmd, "each row needs `from`")),
};
Ok(Row { from, to: new(r.get("to"))?, exclude: switch_or(r, "exclude", false, cmd)? })
let tint = (super::number_or(r, "tint", 100.0, cmd)? / 100.0).clamp(0.0, 1.0) as f32;
Ok(Row { from, to: new(r.get("to"))?, exclude: switch_or(r, "exclude", false, cmd)?, tint })
})
.collect(),
_ => Err(bad(cmd, "missing `map` (rows [{from, to}] or {current: new})")),
@@ -303,6 +309,8 @@ struct Link {
name: String,
global: bool,
color: Color,
/// The row's tint of the swatch (0..1).
tint: f32,
}
/// A global swatch of the color group `recolor.apply` rewrites: its name, the color it is
@@ -338,9 +346,12 @@ impl ColorMap {
let Some(to) = r.new_color() else { continue };
let i = out.rows.len();
let colors: Vec<Color> = r.from.iter().map(Match::color).collect();
out.rows.push(RowMap::new(method, &colors, to).with_own_model(own_model));
let link = links.get(n).cloned().flatten();
// Colors that can't link to the swatch take the row's tint of it too.
let mapped = link.as_ref().filter(|l| l.global && l.tint < 1.0).map_or(to, |l| to.tinted(l.tint));
out.rows.push(RowMap::new(method, &colors, mapped).with_own_model(own_model));
out.tos.push(to);
out.links.push(links.get(n).cloned().flatten());
out.links.push(link);
for m in &r.from {
match m {
Match::Key(k) => out.keys.entry(*k).or_insert(i),
@@ -365,7 +376,8 @@ impl ColorMap {
}
/// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot
/// swatch links its colours to it (at their tints; shades and other hues stay unlinked), a
/// swatch links its colours to it (at their tints times the row's tint; shades and other hues
/// stay unlinked), a
/// process swatch gives its colour in its own model; a color linked to a global swatch of the
/// rewritten group that takes the row's new color keeps its link (at its tint in the row) when
/// the row gives it the new color in that color's model;
@@ -388,7 +400,7 @@ impl ColorMap {
}
let new = match (l, row.tint(*c)) {
(Some(l), Some(t)) if l.global => {
let t = t.clamp(0.0, 1.0);
let t = (t * l.tint).clamp(0.0, 1.0);
let shown = l.color.tinted(t);
let changed = link.as_deref() != Some(l.name.as_str()) || *tint != t || *c != shown;
(*c, *link, *tint) = (shown, Some(l.name.clone()), t);
@@ -487,7 +499,8 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mut added = vec![];
let links: Vec<Option<Link>> = row_swatches
.iter()
.map(|w| {
.zip(&rows)
.map(|(w, row)| {
let w = w.as_ref()?;
let name = match d.swatch(&w.name) {
Some(x) if x.paint == w.paint => w.name.clone(),
@@ -499,7 +512,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let color = d.global_color(&name).filter(|_| w.global).or(w.paint.color())?;
Some(Link { name, global: w.global, color })
Some(Link { name, global: w.global, color, tint: row.tint })
})
.collect();
// The group's global swatches that its rewrite gives a color: art linked to one of them
@@ -558,6 +571,9 @@ fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
if r.exclude {
row["exclude"] = json!(true);
}
if r.tint < 1.0 {
row["tint"] = json!(f64::from((r.tint * 10_000.0).round()) / 100.0);
}
row
})
.collect();
+27
View File
@@ -537,6 +537,33 @@ fn recolor_to_a_library_spot_colour_links_it_and_adds_the_swatch() {
assert_eq!(r["swatches"], json!(["Leaf"]));
}
/// A row's `tint` gives the art linked to the row's spot color that share of its tint;
/// randomize keeps it on the row, and a tint that is not a number is refused.
#[test]
fn a_rows_tint_tints_the_spot_color_the_art_links_to() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink 101", "color": {"c": 0.0, "m": 1.0, "y": 0.0, "k": 0.0}, "spot": true}));
let data = run(&mut s, "swatch.library.save", json!({"names": ["Ink 101"], "name": "Inks"}))["data"].as_str().unwrap().to_string();
let lib = run(&mut s, "swatch.library.load", json!({"data": data, "name": "inks.vcswatches"}))["library"].as_str().unwrap().to_string();
run(&mut s, "swatch.delete", json!({"names": ["Ink 101"]}));
let red = rect(&mut s, json!("#e02010"));
select_all(&mut s);
let map = json!([{"from": ["rgb 224 32 16"], "to": "cmyk 0 100 0 0", "tint": 40}]);
run(&mut s, "recolor.apply", json!({"map": map, "limitTo": lib}));
match s.doc().unwrap().doc.node(red).unwrap().appearance.fill_paint() {
vectorcraft_color::Paint::Solid { color, swatch, tint } => {
assert_eq!(swatch.as_deref(), Some("Ink 101"), "linked to the spot color");
assert!((tint - 0.4).abs() < 1e-4, "at the row's tint: {tint}");
assert!(close(color, Color::cmyk(0.0, 1.0, 0.0, 0.0).tinted(0.4)), "{color:?}");
}
p => panic!("{p:?}"),
}
let r = run(&mut s, "recolor.randomize", json!({"map": map, "order": false, "saturationBrightness": false}));
assert_eq!(r["map"][0]["tint"], json!(40.0), "{r}");
let e = s.execute("recolor.apply", &json!({"map": [{"from": ["rgb 224 32 16"], "to": "#ff0000", "tint": "40"}]})).unwrap_err();
assert!(e.to_string().contains("`tint` must be a number"), "{e}");
}
#[test]
fn model_param_chooses_the_model_of_the_new_colour() {
// RGB art in a CMYK document: by default a CMYK replacement comes out RGB (#2170).
+217 -10
View File
@@ -5,7 +5,10 @@
//! Grays (they open as OK on art last left them), the rows (click to select, Shift or Cmd/Ctrl-click
//! to add; a row's arrow excludes or includes it; the hex field edits its new colour, and a
//! double-click on it opens the Color Picker,
//! whose OK sets it in the model it was picked in), Merge and Separate, Randomize order or
//! whose OK sets it in the model it was picked in), the color mode menu and its sliders (HSB, RGB,
//! CMYK or Lab, and Tint when the new color is a spot color of the library), which edit the
//! first selected row's new color (once they have, OK gives the art each new color in its own
//! model), Merge and Separate, Randomize order or
//! saturation and brightness, Reset, the Method and Limit to Library (its folder button imports a
//! library file and limits to it; a library's colours apply as its swatches: a spot colour stays a
//! spot colour, and joins the document's swatches). Edit tab: a hue and
@@ -16,11 +19,13 @@
//! Opened on colours alone (the Color Guide's Edit or Apply Colors without art), OK saves the new
//! colours as a colour group named `groupName`.
//!
//! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?}]), `colors` (null: Auto, or a
//! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id,
//! "document" for the document's swatches, "" for none), `group`, `groupName`, `tab` (`assign` or
//! `edit`), `rule` (harmony id), `linked`, `preview`. Changing `colors`, a preserve flag or
//! `limitTo` reduces the rows again.
//! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?, tint?: percent of a spot
//! color}]), `colors` (null: Auto, or a count), `method`, `preserveWhite`, `preserveBlack`,
//! `preserveGrays`, `limitTo` (library id, "document" for the document's swatches, "" for none),
//! `group`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`,
//! `sliderMode` (`hsb`, `rgb`, `cmyk`, `lab` or `tint`; unset: the row color's own model, HSB for
//! a gray and Tint for a spot color). Changing `colors`, a preserve flag or `limitTo` reduces the
//! rows again.
use egui::{Rect, Sense, Ui, pos2, vec2};
use serde_json::{Value, json};
@@ -32,6 +37,7 @@ use vectorcraft_engine::cmd::swatchlib;
use super::{DialogSpec, form};
use crate::panels::c32;
use crate::panels::color::{Mode, components, from_components, track_color};
use crate::panels::swatches::{DOCUMENT_SWATCHES, colour_libraries, limit_key};
use crate::state::Dialog;
use crate::theme::{self, Tokens};
@@ -56,11 +62,21 @@ const CURRENT_W: f32 = 250.0;
/// Diameter of the Edit tab's colour wheel.
const WHEEL: f32 = 220.0;
/// Height of the rows table, and of either tab (so switching tabs doesn't resize the dialog).
const TABLE_H: f32 = 220.0;
const TABLE_H: f32 = 182.0;
const TAB_H: f32 = 400.0;
const PRESETS: [&str; 5] = ["Custom", "1 Color Job", "2 Color Job", "3 Color Job", "Color Library"];
/// The id of row `i`'s new colour chip, with `i`.
const NEW_CHIP: &str = "recolor-new";
/// The color modes of the sliders under the rows; a row whose new color is a spot color of the
/// library also has Tint.
const SLIDER_MODES: [Mode; 4] = [Mode::Hsb, Mode::Rgb, Mode::Cmyk, Mode::Lab];
/// Widths of a color slider and of its number field.
const SLIDER_W: f32 = 170.0;
const SLIDER_FIELD_W: f32 = 48.0;
/// Set once the sliders have edited a new color in a mode: OK then sends `model: "new"`.
const SLID: &str = "__slid";
/// The slider values last shown: `{row, key, mode, v}` (HSB keeps its hue at no saturation).
const COMPS: &str = "__comps";
/// Open Recolor Artwork (`ui.recolorDialog {colors?, library?, group?}`) on the selected art and,
/// with `group`, on that colour group: art is reduced to as many rows as the group has colours,
@@ -187,6 +203,14 @@ fn to_color(row: &Value) -> Option<Color> {
color_value(&row["to"])
}
/// Give `row` the new color `c`, without a tint of the spot color it had.
fn set_to(row: &mut Value, c: &Color) {
row["to"] = key(c);
if let Some(r) = row.as_object_mut() {
r.remove("tint");
}
}
fn excluded(row: &Value) -> bool {
row["exclude"].as_bool().unwrap_or(false)
}
@@ -208,6 +232,10 @@ fn apply_params(d: &Dialog) -> Value {
json!({"group": g, "groupColors": colors, "rename": rename})
});
let mut p = json!({"map": rs, "method": d.str("method"), "limitTo": d.str("limitTo")});
// The art takes a color edited with the sliders in the mode they showed.
if d.bool(SLID) {
p["model"] = json!("new");
}
if let (Some(Value::Object(g)), Some(p)) = (group, p.as_object_mut()) {
p.extend(g.into_iter().filter(|(_, v)| !v.is_null()));
}
@@ -265,7 +293,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
pub(super) fn with_new_color(app: &VectorcraftApp, mut d: Dialog, i: usize, c: Color) -> Dialog {
let mut rs = rows(&d);
if let Some(r) = rs.get_mut(i) {
r["to"] = key(&c);
set_to(r, &c);
snap(app, &d, &mut rs);
d.fields.insert("rows".into(), Value::Array(rs));
}
@@ -393,7 +421,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
let (nr, _) = ui.allocate_exact_size(vec2(46.0, 20.0), Sense::hover());
let nresp = ui.interact(nr, egui::Id::unique((NEW_CHIP, i)), Sense::click());
let to = to_color(row).unwrap_or_default();
chip(ui, nr, to, off);
chip(ui, nr, to.tinted(row_tint(row)), off);
if nresp.clicked() {
clicked = Some(i);
}
@@ -406,7 +434,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
if let Some(h) = widgets::hex_field_named(ui, ("recolor-hex", i), Some(&hex_name), &hex)
&& let Some(c) = crate::panels::color::parse_hex(&h)
{
row["to"] = key(&keep_model(to, c));
set_to(row, &keep_model(to, c));
changed = true;
}
});
@@ -461,7 +489,11 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
if widgets::icon_button(ui, "rotate-ccw", tl!("Reset the rows to the artwork's colors"), false, 24.0).clicked() {
d.fields.remove("__reduced");
}
let target = SliderTarget::of(app, d, rows);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| mode_menu(ui, d, &target));
});
ui.add_space(6.0);
changed |= sliders(app, ui, d, rows);
ui.add_space(8.0);
egui::Grid::new("recolor-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
ui.label(egui::RichText::new(tl!("Method:")).color(t.text_dim));
@@ -530,6 +562,181 @@ fn arrow(ui: &Ui, r: Rect, on: bool, hovered: bool) {
}
}
/// The tint (0..1) `row` gives its spot color (1 without one).
fn row_tint(row: &Value) -> f32 {
row["tint"].as_f64().map_or(1.0, |t| (t / 100.0).clamp(0.0, 1.0) as f32)
}
/// The `sliderMode` id of `m` (`hsb`, `rgb`, `cmyk`, `lab`).
fn mode_id(m: Mode) -> String {
serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
}
/// The name of the spot color of the library (Limit to Library) that new color `c` is.
fn spot_of(app: &VectorcraftApp, d: &Dialog, c: &Color) -> Option<String> {
let lib = d.str("limitTo");
if lib.is_empty() {
return None;
}
let k = ColorKey::of(c);
swatchlib::library_swatches(&app.session, &lib)?
.into_iter()
.find(|w| w.spot && w.paint.color().is_some_and(|x| ColorKey::of(&x) == k))
.map(|w| w.name)
}
/// The mode the sliders show new color `c` in, `None` for Tint: the mode picked in the menu, else
/// `c`'s own model (HSB for a gray), or Tint for a spot color (`spot`).
fn slider_mode(d: &Dialog, c: &Color, spot: bool) -> Option<Mode> {
let picked = d.str("sliderMode");
match SLIDER_MODES.into_iter().find(|m| mode_id(*m) == picked) {
Some(m) => Some(m),
None if spot => None,
None => Some(match Mode::of(c) {
Mode::Grayscale => Mode::Hsb,
m => m,
}),
}
}
/// The values the sliders show for row `i`'s new color `c` in `mode`: those they set last while
/// the color is the one they set, else `c`'s.
fn slider_values(d: &Dialog, rows: &[Value], i: usize, c: &Color, mode: Mode) -> Vec<f32> {
let held = d.fields.get(COMPS).filter(|s| s["row"] == json!(i) && s["key"] == rows[i]["to"] && s["mode"] == json!(mode_id(mode)));
let v: Option<Vec<f32>> = held.and_then(|s| s["v"].as_array()).map(|a| a.iter().filter_map(Value::as_f64).map(|x| x as f32).collect());
v.filter(|v| v.len() == mode.labels().len()).unwrap_or_else(|| components(mode, c))
}
/// What the color sliders edit: the first selected row, its new color, and the name of the
/// library's spot color that color is.
struct SliderTarget {
row: Option<usize>,
color: Option<Color>,
spot: Option<String>,
}
impl SliderTarget {
fn of(app: &VectorcraftApp, d: &Dialog, rows: &[Value]) -> Self {
let row = selection(d).into_iter().filter(|i| *i < rows.len()).min();
let color = row.and_then(|i| to_color(&rows[i]));
let spot = color.and_then(|c| spot_of(app, d, &c));
Self { row, color, spot }
}
/// The mode the sliders show (`None`: Tint).
fn mode(&self, d: &Dialog) -> Option<Mode> {
slider_mode(d, &self.color.unwrap_or_default(), self.spot.is_some())
}
}
/// The color mode menu of the sliders (HSB, RGB, CMYK, Lab, and Tint for a spot color), drawn
/// right to left: the menu, then its label.
fn mode_menu(ui: &mut Ui, d: &mut Dialog, target: &SliderTarget) {
let t = Tokens::get(ui.ctx());
let mut labels: Vec<&str> = SLIDER_MODES.iter().map(|m| m.label()).collect();
if target.spot.is_some() {
labels.push("Tint");
}
let current = target.mode(d).map_or("Tint", Mode::label);
// The label is drawn after the menu (right-to-left), so it cannot name it by adjacency.
let chosen = ui
.add_enabled_ui(target.color.is_some(), |ui| {
widgets::dropdown_named(ui, "recolor-slider-mode", Some(tl!("Color Mode:")), current, &labels, 90.0)
})
.inner;
if let Some(i) = chosen {
d.fields.insert("sliderMode".into(), json!(SLIDER_MODES.get(i).map_or_else(|| "tint".to_string(), |m| mode_id(*m))));
}
ui.label(egui::RichText::new(tl!("Color Mode:")).color(t.text_dim));
}
/// One color slider in the grid: its label, its rail with `track` and its number field, showing
/// `shown` with `suffix`. Returns the whole number in `shown`'s range that a drag, a key or the
/// field sets.
fn slider_cell(
ui: &mut Ui,
id: impl std::hash::Hash + Copy,
label: &str,
shown: widgets::SliderValue,
suffix: &str,
track: &dyn Fn(f32) -> egui::Color32,
) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let (lo, hi) = (*shown.range.start(), *shown.range.end());
let value = shown.value;
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(label).size(12.5).color(t.text)));
let unit = ((value - lo) / (hi - lo)) as f32;
let (dragged, _, keyed) = widgets::color_slider_stepped(ui, ("recolor-slider", id), label, Some(shown), unit, SLIDER_W, track);
let typed = widgets::plain_field_named(ui, ("recolor-slider-field", id), Some(label), value, suffix, 0, SLIDER_FIELD_W);
dragged.map(|x| (lo + f64::from(x) * (hi - lo)).round()).or(keyed).or(typed).map(|v| v.clamp(lo, hi)).filter(|v| (v - value).abs() > 1e-6)
}
/// The color sliders under the rows, two to a line: they edit the first selected row's new color
/// (the others keep theirs) in the mode of [`mode_menu`], or its tint of a spot color of the
/// library. Without a selected row they are disabled. Returns true when they changed a row.
fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool {
let target = SliderTarget::of(app, d, rows);
let mode = target.mode(d);
let mut changed = false;
let top = ui.cursor().top();
let lines = mode.map_or(1, |m| m.labels().len().div_ceil(2));
ui.add_enabled_ui(target.color.is_some(), |ui| {
egui::Grid::new("recolor-sliders").num_columns(6).spacing([6.0, 4.0]).show(ui, |ui| {
let (Some(i), Some(c)) = (target.row, target.color) else {
// Nothing to edit: the mode's sliders, disabled.
let mode = mode.unwrap_or(Mode::Hsb);
let input = Tokens::get(ui.ctx()).input;
let grey = move |_: f32| input;
for (n, lbl) in mode.labels().iter().enumerate() {
let shown = widgets::SliderValue::field(0.0, f64::from(mode.min(n))..=f64::from(mode.max(n)), mode.suffix(n), 0);
slider_cell(ui, n, lbl, shown, mode.suffix(n), &grey);
if n % 2 == 1 {
ui.end_row();
}
}
return;
};
let Some(mode) = mode else {
// Tint of the spot color, and its name.
let tint = f64::from(row_tint(&rows[i])) * 100.0;
let shown = widgets::SliderValue::field(tint, 0.0..=100.0, "%", 0);
if let Some(v) = slider_cell(ui, usize::MAX, "T", shown, "%", &|x| c32(&c.tinted(x))) {
rows[i]["tint"] = json!(v);
changed = true;
}
ui.label(egui::RichText::new(target.spot.as_deref().unwrap_or_default()).color(Tokens::get(ui.ctx()).text));
return;
};
let comps = slider_values(d, rows, i, &c, mode);
let mut set: Option<Vec<f32>> = None;
for (n, lbl) in mode.labels().iter().enumerate() {
let v = comps.get(n).copied().unwrap_or(0.0);
let shown = widgets::SliderValue::field(f64::from(v), f64::from(mode.min(n))..=f64::from(mode.max(n)), mode.suffix(n), 0);
if let Some(nv) = slider_cell(ui, n, lbl, shown, mode.suffix(n), &|x| c32(&track_color(mode, &comps, n, x))) {
let mut c2 = comps.clone();
c2[n] = nv as f32;
set = Some(c2);
}
if n % 2 == 1 {
ui.end_row();
}
}
if let Some(c2) = set {
// The mode stays as the edit changes the color's model (a gray edited in HSB is RGB).
d.fields.insert("sliderMode".into(), json!(mode_id(mode)));
set_to(&mut rows[i], &from_components(mode, &c2));
d.fields.insert(COMPS.into(), json!({"row": i, "key": rows[i]["to"], "mode": mode_id(mode), "v": c2}));
d.fields.insert(SLID.into(), json!(true));
changed = true;
}
});
});
// Room for two lines of sliders whatever the mode, so the dialog keeps its height.
let per = (ui.cursor().top() - top) / lines as f32;
ui.add_space(per * (2 - lines.min(2)) as f32);
changed
}
/// Merge the selected rows into the first of them (its new colour stays).
fn merge(rows: &mut Vec<Value>, sel: &[usize]) {
let mut sel: Vec<usize> = sel.iter().copied().filter(|i| *i < rows.len()).collect();
+138
View File
@@ -226,6 +226,144 @@ fn edit_harness(colors: &[&str]) -> egui_kittest::Harness<'static, VectorcraftAp
h
}
/// Recolor Artwork open on the Assign tab of `app` (its art selected), drawn by a kittest harness.
fn assign_harness(mut app: VectorcraftApp, open: Value) -> egui_kittest::Harness<'static, VectorcraftApp> {
app.run("ui.recolorDialog", open).unwrap();
set(&mut app, "method", json!("exact"));
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 800.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut fonts, true) {
crate::theme::install_fonts(ui.ctx());
return;
}
dialogs::show(app, ui.ctx());
},
app,
);
h.run_steps(4);
h
}
/// Clicks the color slider named `name` at `at` (0 to 1) along its rail.
fn click_slider(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, name: &str, at: f32) {
let r = h.get_by_role_and_label(egui::accesskit::Role::Slider, name).rect();
let pos = egui::pos2(r.left() + 4.0 + at * (r.width() - 8.0), r.center().y);
h.input_mut().events.extend(press(pos, 1));
h.run_steps(3);
}
/// The fill of the first object of the document.
fn first_fill(app: &VectorcraftApp) -> vectorcraft_color::Paint {
let d = &app.session.doc().unwrap().doc;
let id = d.layers[0].children().unwrap()[0].id;
d.node(id).unwrap().appearance.fill_paint()
}
/// The color mode menu's sliders edit the first selected row's new color in the mode picked (the
/// other row keeps its color), the art previews it, and OK gives the art the color in that mode:
/// a CMYK color on RGB art makes it CMYK. Cancel leaves the art as it was, with no undo step.
#[test]
fn the_color_sliders_edit_the_selected_rows_new_color_in_their_mode() {
for confirm in [true, false] {
let mut app = app();
art(&mut app, &["#0000ff", "#ff0000"]);
let before = first_fill(&app);
let undo = app.session.doc().unwrap().history.undo.len();
let mut h = assign_harness(app, json!({}));
let rows = field(h.state(), "rows");
let blue = 0;
assert_eq!(rows[blue]["from"], json!(["rgb 0 0 255"]), "the first object's blue is the first row");
// Both rows selected, the second one first: the sliders edit the first row.
set(h.state_mut(), "__sel", json!([1, blue]));
h.run_steps(2);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "R").is_some(), "RGB art shows the RGB sliders");
set(h.state_mut(), "sliderMode", json!("cmyk"));
h.run_steps(2);
click_slider(&mut h, "C", 0.5);
let edited = field(h.state(), "rows");
let to = edited[blue]["to"].as_str().unwrap().to_string();
assert!(to.starts_with("cmyk 50 "), "the first selected row takes C 50%: {to}");
assert_eq!(edited[1], rows[1], "the other selected row keeps its new color");
let Some(Color::Cmyk { c, .. }) = first_fill(h.state()).color() else {
panic!("the art previews the CMYK color: {:?}", first_fill(h.state()))
};
assert!((c - 0.5).abs() < 1e-3, "{c}");
h.get_by_label(if confirm { "OK" } else { "Cancel" }).click();
h.run_steps(2);
assert!(h.state().ui.dialog.is_none());
let app = h.state();
if confirm {
assert_eq!(first_fill(app).color(), vectorcraft_engine::cmd::color_value(&json!(to)), "OK keeps the color, in CMYK");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
} else {
assert_eq!(first_fill(app), before, "Cancel leaves the art unchanged");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "and records no undo step");
}
}
}
/// A gray row shows HSB, and the sliders stay in HSB once an edit makes the color RGB.
#[test]
fn a_gray_row_keeps_the_hsb_sliders_while_it_is_edited() {
let mut app = app();
art(&mut app, &["gray 40"]);
let mut h = assign_harness(app, json!({}));
set(h.state_mut(), "__sel", json!([0]));
h.run_steps(2);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "H").is_some(), "a gray row shows HSB");
click_slider(&mut h, "S", 0.5);
let to = field(h.state(), "rows")[0]["to"].clone();
assert!(to.as_str().is_some_and(|k| k.starts_with("rgb ")), "{to}");
assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "H").is_some(), "still HSB after the edit");
assert_eq!(field(h.state(), "sliderMode"), json!("hsb"));
}
/// With Limit to Library, a row whose new color is a spot color of the library shows Tint, whose T
/// slider sets the row's tint of it; OK links the art to the spot color at that tint.
#[test]
fn the_tint_slider_sets_a_spot_rows_tint() {
let mut app = app();
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap();
let data = app.run("swatch.library.save", json!({"names": ["Ink"], "name": "Inks"})).unwrap()["data"].as_str().unwrap().to_string();
let lib = app.run("swatch.library.load", json!({"data": data, "name": "inks.vcswatches"})).unwrap()["library"].as_str().unwrap().to_string();
app.run("swatch.delete", json!({"names": ["Ink"]})).unwrap();
art(&mut app, &["cmyk 0 100 0 0"]);
let mut h = assign_harness(app, json!({"library": lib}));
set(h.state_mut(), "__sel", json!([0]));
h.run_steps(2);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "T").is_some(), "a spot row shows Tint");
click_slider(&mut h, "T", 0.4);
assert_eq!(field(h.state(), "rows")[0]["tint"], json!(40.0));
h.get_by_label("OK").click();
h.run_steps(2);
match first_fill(h.state()) {
vectorcraft_color::Paint::Solid { swatch, tint, .. } => {
assert_eq!(swatch.as_deref(), Some("Ink"), "linked to the spot color");
assert!((tint - 0.4).abs() < 1e-4, "at the row's tint: {tint}");
}
p => panic!("{p:?}"),
}
}
/// The dialog keeps its height when the tab or the sliders' color mode changes.
#[test]
fn switching_tabs_or_slider_modes_keeps_the_dialog_height() {
let mut app = app();
art(&mut app, &["#ff0000", "#0000ff"]);
let mut h = assign_harness(app, json!({}));
set(h.state_mut(), "__sel", json!([0]));
let height = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>, k: &str, v: Value| {
set(h.state_mut(), k, v);
h.run_steps(6);
h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "recolor")))).unwrap().height()
};
let assign = height(&mut h, "sliderMode", json!("cmyk"));
for (k, v) in [("sliderMode", json!("hsb")), ("sliderMode", json!("lab")), ("tab", json!("edit")), ("tab", json!("assign"))] {
assert_eq!(height(&mut h, k, v.clone()), assign, "{k} {v}");
}
}
/// The Assign tab's per-row hex fields are named by their row, not all "Color": a screen reader can
/// tell the second from the first.
#[test]
+5 -2
View File
@@ -253,9 +253,12 @@ and spot colours exist; the dialog then stays open.
Recolor Artwork: `ui.recolorDialog {colors?, library?, group?}` opens the `recolor` dialog on the selected art
(`colors`: a count for an n-colour job, or colours to assign as the new colours; `library`: Limit to Library, a library
or `"document"`, `""` for the first library; `group`: a colour group, Edit or Apply Color Group). Fields: `rows` (`[{from: [keys], to: key, exclude?}]`
or `"document"`, `""` for the first library; `group`: a colour group, Edit or Apply Color Group). Fields: `rows` (`[{from: [keys], to: key, exclude?, tint?}]`
as `recolor.reduce` returns them), `colors` (null for Auto), `method`, `preserveWhite`, `preserveBlack`,
`preserveGrays`, `limitTo`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`. Setting
`preserveGrays`, `limitTo`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`,
`sliderMode` (the mode of the color sliders under the rows: `hsb`, `rgb`, `cmyk`, `lab`, or `tint` for a row whose new
color is a spot color of the library; unset, they show the new color's own model). The sliders edit the first selected
row's new color; once they have, the preview and confirm send `recolor.apply` with `model: "new"`. Setting
`colors`, a preserve flag or `limitTo` reduces the rows again (also on `ui.dialog.confirm`). The dialog previews
`recolor.apply` on the canvas; confirm keeps it as one undo step (with a group, the group is rewritten in the same
step; with no art selected only the group changes). In the Swatches panel, double-clicking a colour group's folder,
+5 -4
View File
@@ -1919,16 +1919,17 @@ tints of a global swatch share its row; White and Black are preserved by default
`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, model?, limitTo?, group?, groupColors?, rename?}`.
For scripts and MCP, `model` selects the replacement color model: `keep` (default) retains each
original color's model, `new` takes the replacement's model, and `document` converts replacements
and edited color groups to the document's model. Invalid values fail before editing. This parameter
is not a control in the Recolor Artwork dialog. A returned `note` reports a model conversion in
`keep` mode.
and edited color groups to the document's model. Invalid values fail before editing. The Recolor
Artwork dialog sends `new` once its color sliders have edited a row's new color in a color mode. A
returned `note` reports a model conversion in `keep` mode.
`method` says how a row's colours take its new colour: `exact`, `preserveTints` (tints of the row's darkest colour
stay tints), `scaleTints` (every colour becomes a tint as light, relative to the darkest), `tintsShades` (lighter and
darker than the row's average become tints and shades) or `hueShift` (the most saturated colour takes the new colour,
the others turn by the same hue). `limitTo` snaps new colours to a swatch library's nearest colour;
`recolor.randomize {map, order?, saturationBrightness?, seed?}` shuffles or varies new colours; a row with
`exclude: true` keeps its colours. `swatch.editGroup {group, colors, rename?}` rewrites a colour group in one undo
`exclude: true` keeps its colours. With `limitTo`, a row's `tint` (a percentage, default 100) gives the art
linked to the global or spot swatch its new color is that share of its tint; `tint` must be a number. `swatch.editGroup {group, colors, rename?}` rewrites a colour group in one undo
step (art linked to its global swatches follows), and `recolor.apply` with `group` recolours the art and rewrites the
group together. Art linked to one of the group's global swatches stays linked when the swatch is rewritten with its
row's new color (the default without `groupColors` when the rows follow the group's order), so a later `swatch.edit` still
+1 -1
View File
@@ -123,7 +123,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939); the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies and excluding a match with Shift-click, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) |
| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, and PDF gradients mixing a spot color with RGB or Lab stops | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, and PDF gradients mixing a spot color with RGB or Lab stops | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) |
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |