Recolor Artwork: a slider edit changes the color mode of its own row only

The color sliders now mark the row they edit with `model: "new"`
instead of a dialog-wide flag, and `recolor.apply` reads a row's own
`model` before the command's. Rows the sliders did not edit keep each
color's model, so with RGB art and a CMYK color group, a slider edit of
one row no longer turns the other rows' art CMYK. Another edit of the
row's new color, Reset and rows reduced again remove the mark, and
`recolor.randomize` moves it with its new color.
This commit is contained in:
dotCooCoo
2026-11-01 02:06:46 -08:00
parent 1323965312
commit 9b853004d5
6 changed files with 157 additions and 58 deletions
+61 -31
View File
@@ -263,6 +263,41 @@ struct Row {
/// 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,
/// The row's own `model`, which takes the place of the command's for its colors.
model: Option<ModelRule>,
}
/// The color model a row's colors take (`model`): each color's own, the new color's, or the
/// document's.
#[derive(Clone, Copy, PartialEq)]
enum ModelRule {
Keep,
New,
Document,
}
impl ModelRule {
/// The `model` of `v` (a row or the command's parameters); a given `model` must be a string.
fn param(v: &Value, cmd: &str) -> Result<Option<Self>> {
match v.get("model") {
None => Ok(None),
Some(Value::String(m)) => match m.as_str() {
"keep" => Ok(Some(Self::Keep)),
"new" => Ok(Some(Self::New)),
"document" => Ok(Some(Self::Document)),
other => Err(bad(cmd, format!("unknown model `{other}` (keep, new or document)"))),
},
Some(other) => Err(bad(cmd, format!("bad model {other} (keep, new or document)"))),
}
}
fn id(self) -> &'static str {
match self {
Self::Keep => "keep",
Self::New => "new",
Self::Document => "document",
}
}
}
impl Row {
@@ -284,7 +319,7 @@ 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, tint: 1.0 })).collect()
m.iter().map(|(k, v)| Ok(Row { from: vec![cur(&json!(k))?], to: new(Some(v))?, exclude: false, tint: 1.0, model: None })).collect()
}
Some(Value::Array(rows)) => rows
.iter()
@@ -295,7 +330,8 @@ fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
None => return Err(bad(cmd, "each row needs `from`")),
};
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 })
let model = ModelRule::param(r, cmd)?;
Ok(Row { from, to: new(r.get("to"))?, exclude: switch_or(r, "exclude", false, cmd)?, tint, model })
})
.collect(),
_ => Err(bad(cmd, "missing `map` (rows [{from, to}] or {current: new})")),
@@ -338,8 +374,9 @@ struct ColorMap {
impl ColorMap {
/// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none); `group`: the
/// global swatches of the color group being rewritten.
fn new(rows: &[Row], method: Method, own_model: bool, links: &[Option<Link>], group: Vec<GroupSwatch>) -> Self {
/// global swatches of the color group being rewritten; `model`: the model rule of a row without
/// its own.
fn new(rows: &[Row], method: Method, model: ModelRule, links: &[Option<Link>], group: Vec<GroupSwatch>) -> Self {
let mut out =
Self { rows: vec![], links: vec![], tos: vec![], group, keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() };
for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) {
@@ -349,7 +386,7 @@ impl ColorMap {
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.rows.push(RowMap::new(method, &colors, mapped).with_own_model(r.model.unwrap_or(model) != ModelRule::Keep));
out.tos.push(to);
out.links.push(link);
for m in &r.from {
@@ -453,26 +490,14 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let scope = Scope { fill: true, stroke: true, ..Scope::of(p) };
let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" };
// `model`: keep each colour's model (default), take the new colour's own, or the document's.
// A given `model` must be a string; only a missing one means "keep".
let model = match p.get("model") {
None => "keep",
Some(Value::String(m)) => m.as_str(),
Some(other) => return Err(bad(C, format!("bad model {other} (keep, new or document)"))),
};
let mut doc_model = None;
let own_model = match model {
"keep" => false,
"new" => true,
"document" => {
let m = s.doc()?.doc.color_mode.model();
doc_model = Some(m);
for r in &mut rows {
r.to = r.to.map(|c| c.in_model(m));
}
true
}
other => return Err(bad(C, format!("unknown model `{other}` (keep, new or document)"))),
};
// A given `model` must be a string; only a missing one means "keep". A row's own `model`
// takes its place for that row.
let model = ModelRule::param(p, C)?.unwrap_or(ModelRule::Keep);
let mode_model = s.doc()?.doc.color_mode.model();
for r in rows.iter_mut().filter(|r| r.model.unwrap_or(model) == ModelRule::Document) {
r.to = r.to.map(|c| c.in_model(mode_model));
}
let doc_model = (model == ModelRule::Document).then_some(mode_model);
// The colours a colour group takes follow the model too (`document` converts them).
let in_doc_model = |c: Color| doc_model.map_or(c, |m| c.in_model(m));
let group_colors = match p.get("groupColors") {
@@ -528,7 +553,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
.collect()
})
.unwrap_or_default();
let map = ColorMap::new(&rows, method, own_model, &links, kept);
let map = ColorMap::new(&rows, method, model, &links, kept);
let f = |c: Color| map.map(c);
let lf = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| map.map_link(c, link, tint);
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
@@ -555,17 +580,18 @@ fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
let rows = parse_rows(p, C)?;
let mut rng = Rng::new(super::menucmds::whole_or(p, "seed", 0, C)?);
let (order, saturation_brightness) = (switch_or(p, "order", true, C)?, switch_or(p, "saturationBrightness", false, C)?);
// Each new colour with its row's tint: a tint moves with its colour, and goes when the colour changes.
let mut news: Vec<(Color, f32)> = rows.iter().filter_map(|r| r.new_color().map(|c| (c, r.tint))).collect();
// Each new colour with its row's tint and model: a tint moves with its colour, and goes when
// the colour changes; a model moves with its colour.
let mut news: Vec<(Color, f32, Option<ModelRule>)> = rows.iter().filter_map(|r| r.new_color().map(|c| (c, r.tint, r.model))).collect();
if order {
rng.shuffle(&mut news);
}
if saturation_brightness {
news = news
.into_iter()
.map(|(c, tint)| {
.map(|(c, tint, model)| {
let n = rng.saturation_brightness(c);
(n, if ColorKey::of(&n) == ColorKey::of(&c) { tint } else { 1.0 })
(n, if ColorKey::of(&n) == ColorKey::of(&c) { tint } else { 1.0 }, model)
})
.collect();
}
@@ -573,7 +599,8 @@ fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
let map: Vec<Value> = rows
.iter()
.map(|r| {
let (to, tint) = if r.new_color().is_some() { news.next().map_or((None, 1.0), |(c, t)| (Some(c), t)) } else { (r.to, r.tint) };
let (to, tint, model) =
if r.new_color().is_some() { news.next().map_or((None, 1.0, None), |(c, t, m)| (Some(c), t, m)) } else { (r.to, r.tint, r.model) };
let mut row = json!({"from": r.from.iter().map(Match::to_json).collect::<Vec<_>>(), "to": to.as_ref().map(key_str)});
if r.exclude {
row["exclude"] = json!(true);
@@ -581,6 +608,9 @@ fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
if tint < 1.0 {
row["tint"] = json!(f64::from((tint * 10_000.0).round()) / 100.0);
}
if let Some(m) = model {
row["model"] = json!(m.id());
}
row
})
.collect();
+34
View File
@@ -624,6 +624,40 @@ fn model_param_must_be_a_string_when_given() {
assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed");
}
/// A row's own `model` takes the place of the command's for that row's colors; a bad one fails
/// before editing; `recolor.randomize` moves it with its new color.
#[test]
fn a_rows_model_applies_to_its_own_colors() {
let mut s = session();
let (a, b) = (rect(&mut s, json!("#ff00ff")), rect(&mut s, json!("#00ffff")));
select_all(&mut s);
let row = |from: &str, to: Value| json!({"from": [from], "to": to});
let mut first = row("#ff00ff", json!({"c": 0, "m": 1, "y": 0, "k": 0}));
first["model"] = json!("new");
let map = json!([first, row("#00ffff", json!({"c": 1, "m": 0, "y": 0, "k": 0}))]);
run(&mut s, "recolor.apply", json!({"map": map}));
assert_eq!(fill(&s, a), Color::cmyk(0.0, 1.0, 0.0, 0.0), "the row with `new` takes the CMYK color");
assert_eq!(fill(&s, b).model(), vectorcraft_color::cms::Model::Rgb, "the other row keeps RGB");
run(&mut s, "edit.undo", json!({}));
let mut keep = map.clone();
keep[0]["model"] = json!("keep");
run(&mut s, "recolor.apply", json!({"map": keep, "model": "new"}));
assert_eq!(fill(&s, a).model(), vectorcraft_color::cms::Model::Rgb, "the row's `keep` wins over the command's `new`");
assert_eq!(fill(&s, b), Color::cmyk(1.0, 0.0, 0.0, 0.0));
run(&mut s, "edit.undo", json!({}));
for bad in [json!("nope"), json!(42)] {
let mut m = map.clone();
m[0]["model"] = bad.clone();
assert!(s.execute("recolor.apply", &json!({"map": m})).is_err(), "{bad}");
}
assert_eq!(fill(&s, a).to_hex(), "#ff00ff", "nothing changed");
let r = run(&mut s, "recolor.randomize", json!({"map": map, "seed": 1}));
let out = r["map"].as_array().unwrap();
let moved = out.iter().find(|r| r.get("model").is_some()).expect("the model stays on a row");
assert_eq!((&moved["to"], &moved["model"]), (&json!("cmyk 0 100 0 0"), &json!("new")), "{r}");
assert_eq!(out.iter().filter(|r| r.get("model").is_some()).count(), 1, "{r}");
}
/// Recolor Artwork's commands refuse a parameter of the wrong form, such as `limitTo: 5` or
/// `includeImages: "false"`, with an error that names it; the art keeps its color and no undo
/// step is recorded. A null counts as a value left out.
+21 -18
View File
@@ -73,8 +73,6 @@ 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";
@@ -180,7 +178,6 @@ fn sync(app: &mut VectorcraftApp, d: &mut Dialog) {
d.fields.insert("rows".into(), Value::Array(rows));
d.fields.insert("__sel".into(), json!([]));
// The new rows hold no slider edit.
d.fields.remove(SLID);
d.fields.remove(COMPS);
}
Err(e) => app.status(e.to_string()),
@@ -210,11 +207,13 @@ 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.
/// Give `row` the new color `c`, without a tint of the spot color it had or the model a slider
/// edit gave it.
fn set_to(row: &mut Value, c: &Color) {
row["to"] = key(c);
if let Some(r) = row.as_object_mut() {
r.remove("tint");
r.remove("model");
}
}
@@ -222,16 +221,19 @@ fn set_to(row: &mut Value, c: &Color) {
const SHUFFLED: &str = "__shuffled";
/// Give each row of `rows` whose current colors and new color are those of the same row of
/// `before` that row's tint again, when it has none: a change that snaps back to the same new
/// color keeps the tint of it.
/// `before` that row's tint and model again, when it has none: a change that snaps back to the
/// same new color keeps the tint of it and the model a slider edit gave it.
fn keep_tints(before: &[Value], rows: &mut [Value]) {
for (r, b) in rows.iter_mut().zip(before) {
if r["from"] == b["from"]
&& r["to"] == b["to"]
&& r.get("tint").is_none()
&& let Some(t) = b.get("tint")
{
r["tint"] = t.clone();
if r["from"] != b["from"] || r["to"] != b["to"] {
continue;
}
for k in ["tint", "model"] {
if r.get(k).is_none()
&& let Some(v) = b.get(k)
{
r[k] = v.clone();
}
}
}
}
@@ -256,11 +258,8 @@ fn apply_params(d: &Dialog) -> Value {
let rename = (!name.trim().is_empty() && name != g).then_some(name);
json!({"group": g, "groupColors": colors, "rename": rename})
});
// A row edited with the sliders carries `model: "new"`: its art takes the mode they showed.
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()));
}
@@ -522,8 +521,12 @@ 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");
d.fields.remove(SLID);
d.fields.remove(COMPS);
// The rows keep no slider edit, also when they cannot be reduced again.
let saved = d.fields.get_mut("rows").and_then(Value::as_array_mut).into_iter().flatten();
for r in rows.iter_mut().chain(saved).filter_map(Value::as_object_mut) {
r.remove("model");
}
}
let target = SliderTarget::of(app, d, rows);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| mode_menu(ui, d, &target));
@@ -780,7 +783,7 @@ fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]
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));
rows[i]["model"] = json!("new");
changed = true;
}
});
+33 -3
View File
@@ -497,7 +497,7 @@ fn reset_after_a_slider_edit_keeps_the_arts_model() {
set(h.state_mut(), "__sel", json!([0]));
h.run_steps(2);
click_slider(&mut h, "C", 0.5);
assert_eq!(field(h.state(), "__slid"), json!(true), "the sliders edited the row");
assert_eq!(field(h.state(), "rows")[0]["model"], json!("new"), "the sliders edited the row");
h.get_by_role_and_label(egui::accesskit::Role::Button, "Reset the rows to the artwork's colors").click();
h.run_steps(3);
assert_eq!(field(h.state(), "rows")[0]["from"], json!(["rgb 255 0 255"]), "Reset reads the art's colors, not the preview's");
@@ -518,17 +518,47 @@ fn a_new_color_count_after_a_slider_edit_keeps_the_arts_model() {
set(h.state_mut(), "__sel", json!([0]));
h.run_steps(2);
click_slider(&mut h, "C", 0.5);
assert_eq!(field(h.state(), "__slid"), json!(true), "the sliders edited the row");
assert_eq!(field(h.state(), "rows")[0]["model"], json!("new"), "the sliders edited the row");
set(h.state_mut(), "colors", json!(1));
h.run_steps(3);
assert_eq!(field(h.state(), "rows").as_array().map(Vec::len), Some(1), "the rows are rebuilt");
assert_eq!(field(h.state(), "__slid"), Value::Null, "the slider edit is discarded");
assert_eq!(field(h.state(), "rows")[0].get("model"), None, "the slider edit is discarded");
h.get_by_label("OK").click();
h.run_steps(2);
assert!(h.state().ui.dialog.is_none());
assert!(matches!(first_fill(h.state()).color(), Some(Color::Rgb { .. })), "the art stays RGB: {:?}", first_fill(h.state()));
}
/// The fill of the `n`th object of the document.
fn fill_of(app: &VectorcraftApp, n: usize) -> vectorcraft_color::Paint {
let d = &app.session.doc().unwrap().doc;
let id = d.layers[0].children().unwrap()[n].id;
d.node(id).unwrap().appearance.fill_paint()
}
/// A slider edit of one row gives only that row's art the slider's mode: with RGB art and a CMYK
/// color group, OK makes the edited row's art CMYK and leaves the other row's art RGB.
#[test]
fn a_slider_edit_changes_the_model_of_its_own_row_only() {
let mut app = app();
app.run("swatch.newGroup", json!({"name": "Mix", "colors": ["cmyk 0 100 0 0", "cmyk 100 0 0 0"], "keepModel": true})).unwrap();
art(&mut app, &["#ff00ff", "#00ffff"]);
let mut h = assign_harness(app, json!({"group": "Mix"}));
let rows = field(h.state(), "rows");
let magenta = (0..2).find(|i| rows[*i]["from"] == json!(["rgb 255 0 255"])).expect("a row of the magenta art");
set(h.state_mut(), "__sel", json!([magenta]));
h.run_steps(2);
click_slider(&mut h, "C", 0.5);
let rows = field(h.state(), "rows");
assert_eq!(rows[magenta]["model"], json!("new"), "the edited row: {rows}");
assert_eq!(rows[1 - magenta].get("model"), None, "the other row: {rows}");
h.get_by_label("OK").click();
h.run_steps(2);
assert!(h.state().ui.dialog.is_none());
assert!(matches!(fill_of(h.state(), 0).color(), Some(Color::Cmyk { .. })), "the edited row's art is CMYK: {:?}", fill_of(h.state(), 0));
assert!(matches!(fill_of(h.state(), 1).color(), Some(Color::Rgb { .. })), "the other row's art stays RGB: {:?}", fill_of(h.state(), 1));
}
/// 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() {
+3 -2
View File
@@ -253,12 +253,13 @@ 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?, tint?}]`
or `"document"`, `""` for the first library; `group`: a colour group, Edit or Apply Color Group). Fields: `rows` (`[{from: [keys], to: key, exclude?, tint?, model?}]`
as `recolor.reduce` returns them), `colors` (null for Auto), `method`, `preserveWhite`, `preserveBlack`,
`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
row's new color and give that row `model: "new"`, so the preview and confirm give only that row's art the color in
the sliders' mode; another edit of the row's new color, Reset and rows reduced again remove it. 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,9 +1919,10 @@ 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. 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.
and edited color groups to the document's model. A row's own `model` (the same values) takes the
place of the command's for that row's colors. Invalid values fail before editing. The Recolor
Artwork dialog gives a row `model: "new"` when its color sliders edit the 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
@@ -1930,7 +1931,7 @@ the others turn by the same hue). `limitTo` snaps new colours to a swatch librar
`recolor.randomize {map, order?, saturationBrightness?, seed?}` shuffles or varies new colours; a row with
`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. `recolor.randomize`
moves a row's `tint` with its new color and removes it from a color it changes. `swatch.editGroup {group, colors, rename?}` rewrites a colour group in one undo
moves a row's `tint` with its new color and removes it from a color it changes, and moves a row's `model` with its new color. `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