Recolor Artwork: the Prominent Colors bar weighs the art's colors

With art selected, the Edit tab shows a Prominent Colors bar under the
wheel: a part for each row in its new color, as wide as the row's share
of the art, the largest first. Dragging the edge between two parts moves
width from one to the other; when the drag ends, the rows' current
colors move toward the new widths and the art previews the result. OK
keeps the recolor as one undo step.

- recolor.weigh {map, weights?, ids?} returns each row's share of the
  uses of the art's colors and, with weights, the rows with their
  current colors moved: one color at a time between rows that have a
  new color, while a move brings the shares nearer the weights, the
  color nearest in Lab to the row it goes to first. A row keeps its
  first color, the tints of one global swatch move together, colors
  that stay keep their order, and the command changes nothing. A use
  counts for the row recolor.apply recolors it with, and the work is
  bounded at 50,000,000 color-by-row comparisons.
- The New Color Group menu beside the link button has Save All Colors
  (the new colors in row order) and Save Prominent Colors (the bar's
  colors in its order); each saves a color group as its own undo step.
- The dialog's weights field drives the bar from ui.dialog.set. A new
  Colors count drops it.
This commit is contained in:
dotCooCoo
2026-11-12 22:48:57 -08:00
parent f062dd7ed2
commit fafd06c959
34 changed files with 874 additions and 23 deletions
+157
View File
@@ -567,6 +567,112 @@ pub fn theme_palette(samples: &[(Color, f32)], n: usize) -> Vec<Color> {
groups.into_iter().map(|g| g.2).collect()
}
/// Colors that [`weigh`] moves from one row to another as one: a color they are shown by, how
/// often the art uses them and the row they are in. A fixed unit (such as the color a row is
/// shown by) stays in its row.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WeighUnit {
pub color: Color,
pub uses: f64,
pub row: usize,
pub fixed: bool,
}
/// The unit-by-row comparisons one [`weigh`] makes, at most.
const WEIGH_WORK: usize = 50_000_000;
/// The uses of `u` that count: a positive finite number, else 0.
fn unit_uses(u: &WeighUnit) -> f64 {
if u.uses.is_finite() && u.uses > 0.0 { u.uses } else { 0.0 }
}
/// Each row's share of the uses of `units`, in percent (0 for every row when nothing is used).
/// Units whose row is past `rows`, or whose uses are not a positive finite number, count nothing.
pub fn row_shares(units: &[WeighUnit], rows: usize) -> Vec<f64> {
let mut sums = vec![0.0; rows];
for u in units {
if let Some(s) = sums.get_mut(u.row) {
*s += unit_uses(u);
}
}
let total: f64 = sums.iter().sum();
if total <= 0.0 {
return vec![0.0; rows];
}
sums.iter().map(|s| s / total * 100.0).collect()
}
/// For Recolor Artwork's Prominent Colors (our own rule): the row each of `units` goes to so that
/// each row's share of the uses comes as near its share of `weights` as whole units allow. One
/// unit moves at a time, while a move brings the rows' shares nearer their weights (the sum of
/// every row's distance from its weight falls); of those moves, the one taken is the unit nearest
/// in Lab (CIEDE2000) to the color of the row it goes to (`anchors`), compared with the row it
/// leaves; on a tie, the move that brings the shares nearest. Fixed units, units in a row past
/// `anchors` and units used no times stay where they are. Weights that are negative or not finite
/// count as 0; when no weight is positive, every unit stays. The moves stop after
/// [`WEIGH_WORK`] comparisons of a unit with a row, and when the movable units times the rows pass
/// it, every unit stays.
pub fn weigh(units: &[WeighUnit], anchors: &[Color], weights: &[f64]) -> Vec<usize> {
let mut row: Vec<usize> = units.iter().map(|u| u.row).collect();
let n = anchors.len();
let given = |r: usize| weights.get(r).copied().filter(|x| x.is_finite() && *x > 0.0).unwrap_or(0.0);
// Scaled by the largest weight first, so the sum stays finite.
let top = (0..n).map(given).fold(0.0, f64::max);
let w = |r: usize| if top > 0.0 { given(r) / top } else { 0.0 };
let sum_w: f64 = (0..n).map(w).sum();
let movable: Vec<usize> = (0..units.len()).filter(|&i| !units[i].fixed && units[i].row < n && unit_uses(&units[i]) > 0.0).collect();
let per_pass = movable.len().saturating_mul(n);
if sum_w <= 0.0 || n < 2 || movable.is_empty() || per_pass > WEIGH_WORK {
return row;
}
let mut sums = vec![0.0; n];
for u in units.iter().filter(|u| u.row < n) {
sums[u.row] += unit_uses(u);
}
let total: f64 = sums.iter().sum();
let target: Vec<f64> = (0..n).map(|r| w(r) / sum_w * total).collect();
let anchor_labs: Vec<Lab> = anchors.iter().map(|c| c.to_lab()).collect();
// Each movable unit's distance to each row's color.
let dist: Vec<Vec<f32>> = movable
.iter()
.map(|&i| {
let l = units[i].color.to_lab();
anchor_labs.iter().map(|a| delta_e2000(*a, l)).collect()
})
.collect();
// A move must bring the shares nearer by more than rounding.
let eps = total * 1e-9;
for _ in 0..WEIGH_WORK / per_pass {
let mut best: Option<(f32, f64, usize, usize)> = None;
for (m, &i) in movable.iter().enumerate() {
let (a, x) = (row[i], unit_uses(&units[i]));
let before_a = (sums[a] - target[a]).abs();
let after_a = (sums[a] - x - target[a]).abs();
for b in (0..n).filter(|&b| b != a) {
let gain = before_a + (sums[b] - target[b]).abs() - after_a - (sums[b] + x - target[b]).abs();
if gain <= eps {
continue;
}
let cost = dist[m][b] - dist[m][a];
let better = match best {
None => true,
Some((c, g, ..)) => cost < c || (cost == c && gain > g),
};
if better {
best = Some((cost, gain, m, b));
}
}
}
let Some((_, _, m, b)) = best else { break };
let i = movable[m];
let x = unit_uses(&units[i]);
sums[row[i]] -= x;
sums[b] += x;
row[i] = b;
}
row
}
/// A set of colours to snap to (Limit to Library), with their Lab values cached.
#[derive(Clone, Debug, Default)]
pub struct Palette(Vec<(Color, Lab)>);
@@ -911,4 +1017,55 @@ mod tests {
assert!(matches!(Rng::new(3).saturation_brightness(Color::lab(50.0, 40.0, 20.0)), Color::Lab { .. }));
assert_eq!(Rng::new(3).saturation_brightness(Color::gray(0.4)).to_hsb()[1], 0.0, "greys stay grey");
}
fn unit(color: Color, uses: f64, row: usize, fixed: bool) -> WeighUnit {
WeighUnit { color, uses, row, fixed }
}
#[test]
fn row_shares_are_percent_of_the_uses() {
let units = [unit(Color::rgb(1.0, 0.0, 0.0), 3.0, 0, true), unit(Color::rgb(0.0, 0.0, 1.0), 1.0, 1, true), unit(Color::WHITE, 5.0, 4, false)];
assert_eq!(row_shares(&units, 2), vec![75.0, 25.0]);
assert_eq!(row_shares(&[], 2), vec![0.0, 0.0]);
assert_eq!(row_shares(&[unit(Color::WHITE, f64::NAN, 0, false)], 1), vec![0.0]);
}
#[test]
fn weigh_moves_the_nearest_colors_toward_the_weights() {
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let units = [
unit(red, 1.0, 0, true),
unit(Color::rgb(0.9, 0.1, 0.3), 1.0, 0, false),
unit(Color::rgb(0.5, 0.0, 0.8), 1.0, 0, false),
unit(blue, 1.0, 1, true),
];
// Equal weights: the reddish purple, nearer blue than the red-pink is, moves.
assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.0]), vec![0, 0, 1, 1]);
// Most of the weight on blue: both movable colors go, the row's own color stays.
assert_eq!(weigh(&units, &[red, blue], &[0.0, 1.0]), vec![0, 1, 1, 1]);
// Weights already met, or none positive: nothing moves.
assert_eq!(weigh(&units, &[red, blue], &[3.0, 1.0]), vec![0, 0, 0, 1]);
assert_eq!(weigh(&units, &[red, blue], &[0.0, f64::NAN]), vec![0, 0, 0, 1]);
assert_eq!(weigh(&units, &[red, blue], &[1e308, 1e308]), vec![0, 0, 1, 1], "huge weights act as equal ones");
assert_eq!(weigh(&units, &[red], &[1.0]), vec![0, 0, 0, 1], "a row past the anchors keeps its units");
}
#[test]
fn weigh_takes_whole_units_and_stops_when_no_move_helps() {
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
// A heavy unit: moving it would overshoot by more than staying misses.
let units = [unit(red, 1.0, 0, true), unit(Color::rgb(0.8, 0.0, 0.2), 10.0, 0, false), unit(blue, 10.0, 1, true)];
assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.4]), vec![0, 0, 1]);
assert_eq!(weigh(&units, &[red, blue], &[1.0, 20.0]), vec![0, 1, 1]);
// Many units: the result still follows the weights.
let many: Vec<WeighUnit> = (0..400).map(|i| unit(Color::rgb(i as f32 / 400.0, 0.2, 1.0 - i as f32 / 400.0), 1.0, 0, i == 0)).collect();
let rows = weigh(&many, &[Color::rgb(0.0, 0.2, 1.0), Color::rgb(1.0, 0.2, 0.0)], &[1.0, 3.0]);
assert_eq!(rows.iter().filter(|r| **r == 1).count(), 300);
assert!(rows[399] == 1 && rows[1] == 0, "the reddest colors go to the red row");
// Past the work limit (8,000 units by 7,000 rows) nothing moves.
let units: Vec<WeighUnit> = (0..8_000).map(|i| unit(Color::WHITE, 1.0, 0, i == 0)).collect();
let anchors = vec![Color::BLACK; 7_000];
let weights = vec![1.0; 7_000];
assert!(weigh(&units, &anchors, &weights).iter().all(|r| *r == 0));
}
}
@@ -478,6 +478,7 @@ pub(super) const ALLOWED: &[&str] = &[
"recolor.pickTheme",
"recolor.randomize",
"recolor.reduce",
"recolor.weigh",
"select.add",
"select.all",
"select.allOnArtboard",
+153 -1
View File
@@ -7,7 +7,9 @@
use std::collections::{BTreeSet, HashMap};
use serde_json::{Value, json};
use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink};
use vectorcraft_color::recolor::{
ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, WeighUnit, cluster, row_shares, same_ink, weigh as weigh_rows,
};
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
@@ -63,6 +65,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
pick_theme
),
cmd!(
query "recolor.weigh",
"Prominent Colors",
[],
None,
"{map: rows [{from, to, …}] or {current: new} as recolor.apply, weights?: [numbers ≥ 0, one per row; an excluded row's is ignored, and at least one other must be above 0], ids?: as recolor.colors} → {map: the rows as given, each with its current colours (from) moved as below, shares: [one per row: its percent of the uses (recolor.colors' count) of the current colours of the rows that aren't excluded; 0 for an excluded row]} for Recolor Artwork's Prominent Colors (VectorCraft's own rule): without weights the rows stay as they are. With weights, current colours move one at a time between the rows that aren't excluded and have a new colour (to), while a move brings each row's share of the uses nearer its share of the weights, first the colour nearest in Lab to the first colour of the row it goes to compared with the row it leaves; a row's first colour stays in it, the tints of one global swatch in a row move together, and a use counts for the row recolor.apply recolors it with (rows with a new colour first, an exact colour before a hex one, the first row that lists it), else for the first other row that lists it. The colours that can move are compared with the rows at most 50,000,000 times in all, and when they times the rows that aren't excluded pass that, none move. Changes nothing",
has_doc,
weigh
),
]
}
@@ -1062,6 +1073,147 @@ fn colors(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "colors": v }))
}
/// `recolor.weigh`'s rows as given, each with `from` as a list (a `{current: new}` map gives a
/// row per entry). The rows have already been read by [`parse_rows`].
fn given_rows(p: &Value) -> Vec<Value> {
match p.get("map") {
Some(Value::Array(rows)) => rows
.iter()
.map(|r| {
let mut r = r.clone();
if let Some(f) = r.get("from").filter(|f| !f.is_array()).cloned() {
r["from"] = json!([f]);
}
r
})
.collect(),
Some(Value::Object(m)) => m.iter().map(|(k, v)| json!({"from": [k], "to": v})).collect(),
_ => vec![],
}
}
fn weigh(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "recolor.weigh";
let rows = parse_rows(p, C)?;
let weights = match p.get("weights") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) if a.len() == rows.len() => Some(
a.iter()
.map(|v| {
v.as_f64()
.filter(|x| x.is_finite() && *x >= 0.0)
.ok_or_else(|| bad(C, format!("each weight must be a number of 0 or more (got {v})")))
})
.collect::<Result<Vec<f64>>>()?,
),
Some(v) => return Err(bad(C, format!("`weights` must be a list of {} numbers, one per row (got {v})", rows.len()))),
};
let active: Vec<usize> = (0..rows.len()).filter(|&i| !rows[i].exclude).collect();
if let Some(w) = &weights
&& !active.is_empty()
&& !active.iter().any(|&i| w[i] > 0.0)
{
return Err(bad(C, "at least one row that isn't excluded needs a weight above 0"));
}
let (ids, only) = scope_ids(s, p, C)?;
let st = s.doc()?;
let mut used: Vec<Option<Used>> = used_colors(&st.doc, &ids, only.as_ref()).into_iter().map(Some).collect();
let mut by_key: HashMap<ColorKey, usize> = HashMap::new();
let mut by_hex: HashMap<String, Vec<usize>> = HashMap::new();
for (i, u) in used.iter().enumerate().filter_map(|(i, u)| Some((i, u.as_ref()?))) {
by_key.insert(ColorKey::of(&u.color), i);
by_hex.entry(u.color.to_hex()).or_default().push(i);
}
// Each current colour's uses and global swatch. A use counts where recolor.apply recolors it:
// rows with a new colour first, exact colours before hex colours, the first row that lists it;
// then the other rows, in the same way.
let mut take = |m: &Match| -> (usize, Option<String>) {
let hits: Vec<usize> = match m {
Match::Key(k) => by_key.get(k).copied().into_iter().collect(),
Match::Hex(h) => by_hex.get(h).cloned().unwrap_or_default(),
};
let (mut uses, mut link) = (0, None);
for u in hits.into_iter().filter_map(|i| used[i].take()) {
uses += u.count;
link = link.or(u.swatch);
}
(uses, link)
};
let mut found: Vec<Vec<(usize, Option<String>)>> = rows.iter().map(|r| vec![(0, None); r.from.len()]).collect();
for recolors in [true, false] {
for hex in [false, true] {
for (r, row) in rows.iter().enumerate().filter(|(_, r)| r.new_color().is_some() == recolors) {
for (e, m) in row.from.iter().enumerate().filter(|(_, m)| matches!(m, Match::Hex(_)) == hex) {
found[r][e] = take(m);
}
}
}
}
// The units that move: in each row that isn't excluded, the colours linked to one global
// swatch go together, each other colour on its own, and the unit of the first colour stays.
let (mut units, mut members): (Vec<WeighUnit>, Vec<(usize, Vec<usize>)>) = (vec![], vec![]);
for (n, &r) in active.iter().enumerate() {
let mut groups: Vec<(Option<&String>, Vec<usize>, usize)> = vec![];
for (e, (uses, link)) in found[r].iter().enumerate() {
match groups.iter_mut().find(|g| link.is_some() && g.0 == link.as_ref()) {
Some(g) => {
g.1.push(e);
g.2 += uses;
}
None => groups.push((link.as_ref(), vec![e], *uses)),
}
}
for (k, (_, entries, uses)) in groups.into_iter().enumerate() {
units.push(WeighUnit { color: rows[r].from[entries[0]].color(), uses: uses as f64, row: n, fixed: k == 0 });
members.push((r, entries));
}
}
if let Some(w) = &weights {
// Only rows with a new colour recolor what they hold, so only they give and take colours.
let movers: Vec<usize> = (0..active.len()).filter(|&n| rows[active[n]].new_color().is_some()).collect();
let anchors: Vec<Color> =
movers.iter().map(|&n| rows[active[n]].from.first().map(Match::color).or(rows[active[n]].to).unwrap_or_default()).collect();
let mut mover_of = vec![None; active.len()];
for (m, &n) in movers.iter().enumerate() {
mover_of[n] = Some(m);
}
let at: Vec<Option<usize>> = units.iter().map(|u| mover_of[u.row]).collect();
let sub: Vec<WeighUnit> = units.iter().zip(&at).filter_map(|(u, m)| Some(WeighUnit { row: (*m)?, ..*u })).collect();
let to = weigh_rows(&sub, &anchors, &movers.iter().map(|&n| w[active[n]]).collect::<Vec<_>>());
for ((u, _), m) in units.iter_mut().zip(&at).filter(|(_, m)| m.is_some()).zip(to) {
u.row = movers[m];
}
}
let mut out = given_rows(p);
if weights.is_some() {
// Each row's colours: those that stayed, in their order, then those that came to it.
let old: Vec<Vec<Value>> = out.iter().map(|r| r["from"].as_array().cloned().unwrap_or_default()).collect();
let mut stayed: Vec<Vec<usize>> = active.iter().map(|_| vec![]).collect();
let mut came: Vec<Vec<Value>> = active.iter().map(|_| vec![]).collect();
for (u, (r, entries)) in units.iter().zip(&members) {
if active[u.row] == *r {
stayed[u.row].extend(entries);
} else {
came[u.row].extend(entries.iter().filter_map(|&e| old[*r].get(e).cloned()));
}
}
for (n, &r) in active.iter().enumerate() {
stayed[n].sort_unstable();
let mut from: Vec<Value> = stayed[n].iter().filter_map(|&e| old[r].get(e).cloned()).collect();
from.append(&mut came[n]);
if let Some(row) = out.get_mut(r) {
row["from"] = Value::Array(from);
}
}
}
let shares = row_shares(&units, active.len());
let mut all = vec![0.0; rows.len()];
for (n, &r) in active.iter().enumerate() {
all[r] = shares[n];
}
Ok(json!({"map": out, "shares": all}))
}
/// The `method` parameter (`default` without one, or for null). A `method` that is not a string,
/// such as `5`, names no method.
fn method_param(p: &Value, cmd: &str, default: Method) -> Result<Method> {
+121
View File
@@ -1348,3 +1348,124 @@ fn theme_picker_refuses_bad_params() {
assert!(e.contains(msg), "{p}: {e}");
}
}
const RED: &str = "rgb 255 0 0";
const PURPLE: &str = "rgb 128 0 204";
const BLUE: &str = "rgb 0 0 255";
/// Two red rectangles, a purple one and a blue one, selected.
fn prominent_art(s: &mut Session) -> [NodeId; 4] {
let ids = [rect(s, json!("#ff0000")), rect(s, json!("#ff0000")), rect(s, json!("#8000cc")), rect(s, json!("#0000ff"))];
select_all(s);
ids
}
#[test]
fn weigh_gives_each_rows_share_of_the_uses() {
let mut s = session();
prominent_art(&mut s);
let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": BLUE, "to": null, "tint": 50}]);
let r = run(&mut s, "recolor.weigh", json!({"map": map}));
assert_eq!(r["shares"], json!([75.0, 25.0]));
assert_eq!(
r["map"],
json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": null, "tint": 50}]),
"rows as given, from as a list"
);
// An excluded row has no share, and the others share the uses.
let excl = json!([{"from": [RED, PURPLE], "to": null}, {"from": [BLUE], "to": null, "exclude": true}]);
assert_eq!(run(&mut s, "recolor.weigh", json!({"map": excl}))["shares"], json!([100.0, 0.0]));
// A current colour that no art uses counts nothing, and a use counts for the first row only.
let twice = json!({"map": [{"from": [RED, "#123456"]}, {"from": [RED, PURPLE, BLUE]}]});
assert_eq!(run(&mut s, "recolor.weigh", twice)["shares"], json!([50.0, 50.0]));
// As recolor.apply matches them: an exact colour before a hex one, a row with a new colour first.
let exact = json!({"map": [{"from": ["#ff0000", PURPLE], "to": "#00ff00"}, {"from": [RED, BLUE], "to": "#ffff00"}]});
assert_eq!(run(&mut s, "recolor.weigh", exact)["shares"], json!([25.0, 75.0]));
let recolored = json!({"map": [{"from": [RED, PURPLE]}, {"from": [RED, BLUE], "to": "#ffff00"}]});
assert_eq!(run(&mut s, "recolor.weigh", recolored)["shares"], json!([25.0, 75.0]));
}
#[test]
fn weigh_moves_colors_to_the_rows_weights_and_changes_nothing() {
let mut s = session();
let [_, _, purple, blue] = prominent_art(&mut s);
let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": "#ffff00"}]);
let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]}));
assert_eq!(r["map"], json!([{"from": [RED], "to": "#00ff00"}, {"from": [BLUE, PURPLE], "to": "#ffff00"}]));
assert_eq!(r["shares"], json!([50.0, 50.0]));
assert_eq!(fill(&s, purple).to_hex(), "#8000cc", "a query leaves the art alone");
// Recoloring with the weighed rows gives the moved colour its new row's colour.
select_all(&mut s);
run(&mut s, "recolor.apply", json!({"map": r["map"]}));
assert_eq!(fill(&s, purple).to_hex(), "#ffff00");
assert_eq!(fill(&s, blue).to_hex(), "#ffff00");
// A row's first colour stays, however small its weight.
let mut s = session();
prominent_art(&mut s);
let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [0, 1]}));
assert_eq!(r["map"][0]["from"], json!([RED]));
assert_eq!(r["shares"], json!([50.0, 50.0]));
// Excluded rows keep their colours and take none; their weights are ignored.
let three =
json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE], "to": "#000000", "exclude": true}, {"from": ["#00ff00"], "to": "#000000"}]);
let r = run(&mut s, "recolor.weigh", json!({"map": three, "weights": [1, 50, 1]}));
assert_eq!(r["map"][1]["from"], json!([BLUE]));
assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE]));
// A row without a new colour recolors nothing, so it neither gives nor takes colours.
let keep = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE]}, {"from": ["#00ff00"], "to": "#000000"}]);
let r = run(&mut s, "recolor.weigh", json!({"map": keep, "weights": [0, 1, 1]}));
assert_eq!(r["map"][1]["from"], json!([BLUE]));
assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE]));
}
#[test]
fn weigh_moves_the_tints_of_a_global_swatch_together() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 90, "k": 0}, "global": true}));
rect(&mut s, json!("#ff0000"));
let full = rect(&mut s, json!("#00ff00"));
let tint = rect(&mut s, json!("#00ff00"));
rect(&mut s, json!("#0000ff"));
run(&mut s, "paint.setFill", json!({"ids": [full.0], "swatch": "Brand"}));
run(&mut s, "paint.setFill", json!({"ids": [tint.0], "swatch": "Brand", "tint": 50}));
select_all(&mut s);
let brand: Vec<Value> = run(&mut s, "recolor.colors", json!({}))["colors"]
.as_array()
.unwrap()
.iter()
.filter(|c| c["swatch"] == "Brand")
.map(|c| c["key"].clone())
.collect();
assert_eq!(brand.len(), 2);
let map = json!([{"from": [RED, brand[0], brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]);
// Even weights would take one tint alone, which would split the swatch: nothing moves.
let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]}));
assert_eq!(r["map"][1]["from"], json!([BLUE]));
// The swatch's two colours move to the blue row as one.
let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 3]}));
assert_eq!(r["map"][1]["from"], json!([BLUE, brand[0], brand[1]]));
assert_eq!(r["shares"], json!([25.0, 75.0]));
// Colours that stay keep their order, a swatch's colours with another between them included.
let split = json!([{"from": [brand[0], RED, brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]);
let r = run(&mut s, "recolor.weigh", json!({"map": split, "weights": [3, 1]}));
assert_eq!(r["map"][0]["from"], json!([brand[0], RED, brand[1]]));
}
#[test]
fn weigh_refuses_bad_weights() {
let mut s = session();
prominent_art(&mut s);
let map = json!([{"from": [RED]}, {"from": [BLUE], "exclude": true}]);
for (p, msg) in [
(json!({"map": map, "weights": [1]}), "`weights` must be a list of 2 numbers, one per row"),
(json!({"map": map, "weights": "1, 1"}), "`weights` must be a list of 2 numbers"),
(json!({"map": map, "weights": [1, -1]}), "each weight must be a number of 0 or more (got -1)"),
(json!({"map": map, "weights": [1, "2"]}), "each weight must be a number of 0 or more"),
(json!({"map": map, "weights": [0, 5]}), "at least one row that isn't excluded needs a weight above 0"),
(json!({"weights": [1, 1]}), "missing `map`"),
(json!({"map": [{"to": "#ff0000"}]}), "each row needs `from`"),
] {
let e = s.execute("recolor.weigh", &p).unwrap_err().to_string();
assert!(e.contains(msg), "{p}: {e}");
}
}
+335 -22
View File
@@ -19,7 +19,13 @@
//! show the new colors on the smooth wheel, on a segmented wheel (solid patches of 24 hues by 5
//! saturations; its markers move as freely) or as color bars (one per new color, in row order: click
//! one to select it for the sliders, drag one to move its row, which changes no color); the dialog
//! opens on the smooth wheel. The preset menu sets up a 1, 2 or 3
//! opens on the smooth wheel. With art, the Prominent Colors bar under the wheel shows the new
//! colors, each part as wide as its row's share of the art's uses, the largest first; dragging the
//! edge between two parts moves width between them, and when the drag ends the rows' current
//! colors move toward those widths (`recolor.weigh`), and the art previews them. The New Color
//! Group menu beside the link button saves the new colors (Save All Colors, in row order) or the
//! bar's colors (Save Prominent Colors, in its order) as a color group right away, a step of its
//! own. The preset menu sets up a 1, 2 or 3
//! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites
//! the group with the new colours (renamed by `groupName`); without art only the group changes.
//! Opened on colours alone (the Color Guide's Edit or Apply Colors without art), OK saves the new
@@ -41,8 +47,9 @@
//! (`{saturation, brightness, temperature, luminosity}` in percent, sent as `recolor.apply`'s
//! `globalAdjust` in every slider mode), `display` (the Edit tab's `smooth`, `segmented` or `bars`;
//! unset: `smooth`), `themePicker` (the Color Theme Picker is on), `themePicks` (its picks, as
//! `recolor.pickTheme`'s `picks`; a new list recolors the rows). Changing `colors`, a preserve
//! flag or `limitTo` reduces the rows again.
//! `recolor.pickTheme`'s `picks`; a new list recolors the rows), `weights` (one per row, as
//! `recolor.weigh`'s; a new list moves the rows' current colors toward them). Changing `colors`, a
//! preserve flag or `limitTo` reduces the rows again (and drops `weights`).
use egui::{Rect, Sense, Ui, pos2, vec2};
use serde_json::{Value, json};
@@ -151,7 +158,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
"preserveWhite": kept("preserveWhite", true), "preserveBlack": kept("preserveBlack", true),
"preserveGrays": kept("preserveGrays", false), "limitTo": limit,
"group": group, "groupName": if new_group { Some(NEW_GROUP) } else { group }, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true,
"preview": true, THEME_PICKER: false, THEME_PICKS: [], "__art": art, "__assign": assign, "__newGroup": new_group,
"preview": true, THEME_PICKER: false, THEME_PICKS: [], WEIGHTS: null, "__art": art, "__assign": assign, "__newGroup": new_group,
});
let mut d = Dialog::new(KIND, fields);
// Without art the rows are there from the start: limit them now (art's rows snap as they reduce).
@@ -214,6 +221,182 @@ fn sync(app: &mut VectorcraftApp, d: &mut Dialog) {
d.fields.insert(DONE.into(), sig);
// New rows take the colors picked with the Color Theme Picker again.
d.fields.remove(THEME_DONE);
// Weights given for the old rows don't apply to the new ones.
d.fields.remove(WEIGHTS);
d.fields.remove(WEIGHED);
}
/// The fields of Prominent Colors: the weights the rows are to take (`recolor.weigh`'s `weights`,
/// one per row; a new list moves the rows' current colors), the weights they last took, each row's
/// share of the art's uses (`recolor.weigh`'s `shares`) and the rows' current colors those were
/// read for; a drag of a part's edge (each part's share of the bar while it lasts); and a request
/// from the New Color Group menu (`all` or `prominent`).
const WEIGHTS: &str = "weights";
const WEIGHED: &str = "__weighed";
const SHARES: &str = "__shares";
const SHARES_OF: &str = "__sharesOf";
const BAR_DRAG: &str = "__barDrag";
const SAVE_GROUP: &str = "__saveGroup";
/// Height of the Prominent Colors bar, and the smallest share of it a drag leaves a part.
const PROMINENT_H: f32 = 18.0;
const MIN_PART: f64 = 0.01;
/// The ids of the bar's part `n` and of the edge after part `k`, with `n` or `k`.
const PROMINENT_PART: &str = "recolor-prominent-part";
const PROMINENT_EDGE: &str = "recolor-prominent-edge";
/// Cancel the preview, so commands read the art's own colors; it runs again afterwards.
fn cancel_preview(app: &mut VectorcraftApp, d: &mut Dialog) {
if d.fields.remove(super::form::PREVIEWED).is_some() {
let _ = app.session.cancel_interaction();
}
}
/// With art: when `weights` changed, move the rows' current colors toward them
/// (`recolor.weigh`); when the rows' current colors changed, read each row's share of the art's
/// uses again.
fn sync_weights(app: &mut VectorcraftApp, d: &mut Dialog) {
if !d.bool("__art") {
return;
}
if let Some(w) = d.fields.get(WEIGHTS).filter(|w| !w.is_null()).cloned()
&& d.fields.get(WEIGHED) != Some(&w)
{
d.fields.insert(WEIGHED.into(), w.clone());
cancel_preview(app, d);
match app.session.execute("recolor.weigh", &json!({"map": rows(d), "weights": w})) {
Ok(r) => {
if let Some(m) = r["map"].as_array() {
d.fields.insert("rows".into(), Value::Array(m.clone()));
}
}
Err(e) => app.status(e.to_string()),
}
}
let rs = rows(d);
let of = Value::Array(rs.iter().map(|r| json!([r["from"], excluded(r)])).collect());
if d.fields.get(SHARES_OF) == Some(&of) {
return;
}
cancel_preview(app, d);
let shares = match app.session.execute("recolor.weigh", &json!({"map": rs})) {
Ok(r) => r["shares"].clone(),
Err(e) => {
app.status(e.to_string());
json!([])
}
};
d.fields.insert(SHARES.into(), shares);
d.fields.insert(SHARES_OF.into(), of);
}
/// The parts of the Prominent Colors bar: each row that isn't excluded and has a share of the
/// art's uses, with that share in percent, the largest share first (on a tie, the first row).
fn prominent_parts(d: &Dialog, rows: &[Value]) -> Vec<(usize, f64)> {
let shares = d.fields.get(SHARES).and_then(Value::as_array).cloned().unwrap_or_default();
let mut parts: Vec<(usize, f64)> = shares
.iter()
.enumerate()
.filter_map(|(i, s)| Some((i, s.as_f64().filter(|s| *s > 0.0)?)))
.filter(|(i, _)| rows.get(*i).is_some_and(|r| !excluded(r)))
.collect();
parts.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
parts
}
/// The Prominent Colors bar: a part per row of [`prominent_parts`] with the row's new color, as wide
/// as its share of the art's uses. A drag of the edge between two parts moves width from one to
/// the other (each keeps at least [`MIN_PART`] of the bar); when it ends, the parts' widths become
/// the rows' `weights`.
fn prominent_bar(ui: &mut Ui, d: &mut Dialog, rows: &[Value]) {
let t = Tokens::get(ui.ctx());
let parts = prominent_parts(d, rows);
let (bar, resp) = ui.allocate_exact_size(vec2(ui.available_width(), PROMINENT_H), Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, ui.is_enabled(), tl!("Prominent Colors")));
if parts.is_empty() {
ui.painter().rect_stroke(bar, 1.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
return;
}
let total: f64 = parts.iter().map(|p| p.1).sum();
let mut widths: Vec<f64> = match d.fields.get(BAR_DRAG).and_then(Value::as_array) {
Some(w) if w.len() == parts.len() => w.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(),
_ => parts.iter().map(|p| p.1 / total).collect(),
};
let w = f64::from(bar.width());
let edge = |k: usize, widths: &[f64]| bar.left() + (widths[..=k].iter().sum::<f64>() * w) as f32;
let mut left = bar.left();
for (n, (i, _)) in parts.iter().enumerate() {
let right = if n + 1 == parts.len() { bar.right() } else { edge(n, &widths) };
let r = Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom()));
let c = to_color(&rows[*i]).or_else(|| from_colors(&rows[*i]).first().copied()).unwrap_or_default();
chip(ui, r, c, false);
let share = (widths[n] * 100.0).round();
ui.interact(r, egui::Id::unique((PROMINENT_PART, n)), Sense::hover()).on_hover_text(format!("{} {}: {share}%", tl!("Color"), i + 1));
left = right;
}
let mut ended = false;
for k in 0..parts.len() - 1 {
let x = edge(k, &widths);
let handle = Rect::from_center_size(pos2(x, bar.center().y), vec2(8.0, PROMINENT_H));
let h = ui.interact(handle, egui::Id::unique((PROMINENT_EDGE, k)), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
if h.dragged() && w > 0.0 {
// Each part keeps MIN_PART, or half of the two parts' width when they are narrower.
let pair = widths[k] + widths[k + 1];
let least = MIN_PART.min(pair / 2.0);
let moved = (widths[k] + f64::from(h.drag_delta().x) / w).clamp(least, pair - least);
widths[k] = moved;
widths[k + 1] = pair - moved;
d.fields.insert(BAR_DRAG.into(), json!(widths));
}
if h.hovered() || h.dragged() {
ui.painter().vline(x, bar.y_range(), egui::Stroke::new(2.0, t.accent));
}
ended |= h.drag_stopped();
}
if ended && d.fields.remove(BAR_DRAG).is_some() {
let mut weights = vec![0.0; rows.len()];
for ((i, _), x) in parts.iter().zip(&widths) {
weights[*i] = (x * 10_000.0).round() / 100.0;
}
d.fields.insert(WEIGHTS.into(), json!(weights));
}
}
/// The colors a new color group takes from rows `rs`: each row's new color with the Global Adjust,
/// snapped to Limit to Library, as `recolor.apply` gives them to a group; a row without a new color
/// gives its first current color, which the art keeps.
fn group_colors<'a>(app: &VectorcraftApp, d: &Dialog, rs: impl Iterator<Item = &'a Value>) -> Vec<Value> {
let adjust = global_adjust(adjust_values(d));
let limit = d.str("limitTo");
let palette = Some(limit).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l));
rs.filter_map(|r| match to_color(r) {
None => from_colors(r).first().map(key),
Some(_) if adjust.is_identity() => Some(r["to"].clone()),
Some(c) => {
let c = adjust.apply(c);
Some(key(&palette.as_ref().map_or(c, |p| p.nearest(c))))
}
})
.collect()
}
/// The New Color Group menu's request: save the new colors of the rows that aren't excluded (Save
/// All Colors, in row order; a row without one gives its first current color) or of the Prominent Colors bar (Save Prominent Colors, in its order)
/// as a new color group, a step of its own (`swatch.newGroup`).
fn save_group(app: &mut VectorcraftApp, d: &mut Dialog, which: &str) {
let rs = rows(d);
let order: Vec<usize> = match which {
"prominent" => prominent_parts(d, &rs).into_iter().map(|p| p.0).collect(),
_ => (0..rs.len()).filter(|&i| !excluded(&rs[i])).collect(),
};
let colors = group_colors(app, d, order.iter().filter_map(|&i| rs.get(i)));
if colors.is_empty() {
return;
}
// The group is added to the document itself, not to the preview, which runs again afterwards.
cancel_preview(app, d);
if let Err(e) = app.run("swatch.newGroup", json!({"colors": colors, "keepModel": true})) {
app.status(e);
}
}
/// The fields of the Color Theme Picker: whether a click or drag on the canvas picks colors, and
@@ -401,6 +584,7 @@ fn apply_params(d: &Dialog) -> Value {
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
sync(app, d);
sync_theme(app, d);
sync_weights(app, d);
let t = Tokens::get(ui.ctx());
let picking = d.bool(THEME_PICKER);
ui.horizontal(|ui| {
@@ -444,6 +628,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
d.fields.insert("rows".into(), Value::Array(rs));
}
sync_weights(app, d);
if let Some(which) = d.fields.remove(SAVE_GROUP) {
save_group(app, d, which.as_str().unwrap_or_default());
}
if d.fields.get("group").is_some_and(Value::is_string) || d.bool("__newGroup") {
ui.add_space(6.0);
ui.horizontal(|ui| {
@@ -479,25 +667,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut d = d.clone();
sync(app, &mut d);
sync_theme(app, &mut d);
sync_weights(app, &mut d);
if d.bool("__newGroup") {
// The new colors with the Global Adjust, snapped to Limit to Library, as `recolor.apply`
// gives them to a group.
let adjust = global_adjust(adjust_values(&d));
let limit = d.str("limitTo");
let palette = Some(limit).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l));
let colors: Vec<Value> = rows(&d)
.into_iter()
.filter(|r| !excluded(r))
.map(|r| {
to_color(&r).filter(|_| !adjust.is_identity()).map_or_else(
|| r["to"].clone(),
|c| {
let c = adjust.apply(c);
key(&palette.as_ref().map_or(c, |p| p.nearest(c)))
},
)
})
.collect();
let colors = group_colors(app, &d, rows(&d).iter().filter(|r| !excluded(r)));
return form::commit_preview(app, "swatch.newGroup", json!({"name": d.str("groupName"), "colors": colors, "keepModel": true}));
}
let r = form::commit_preview(app, CMD, apply_params(&d))?;
@@ -1091,6 +1263,18 @@ fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value
if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() {
d.fields.insert("linked".into(), json!(!linked));
}
// New Color Group: save the wheel's colors or the Prominent Colors as a color group.
let button = widgets::icon_button(ui, "dc-folder", tl!("New Color Group"), false, 24.0);
let prominent = d.bool("__art") && !prominent_parts(d, rows).is_empty();
let saved = egui::Popup::menu(&button).show(|ui| {
if widgets::menu_item_name(ui, tl!("Save All Colors"), rows.iter().any(|r| !excluded(r)), false) {
return Some("all");
}
widgets::menu_item_name(ui, tl!("Save Prominent Colors"), prominent, false).then_some("prominent")
});
if let Some(which) = saved.and_then(|r| r.inner) {
d.fields.insert(SAVE_GROUP.into(), json!(which));
}
ui.add_space(8.0);
// The mode of the color sliders under the wheel: the selected new color in a color
// mode, or Global Adjust for all of them.
@@ -1122,6 +1306,11 @@ fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value
_ => w = widgets::harmony_wheel(ui, "recolor-wheel", WHEEL, &colors, marked),
});
});
// Prominent Colors: the art's new colors, each as wide as its share of the art.
if d.bool("__art") {
ui.add_space(6.0);
prominent_bar(ui, d, rows);
}
if let Some(k) = w.pressed {
d.fields.insert("__sel".into(), json!([active[k]]));
}
@@ -1625,6 +1814,130 @@ mod tests {
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
}
const RED: &str = "rgb 255 0 0";
const PURPLE: &str = "rgb 128 0 204";
const BLUE: &str = "rgb 0 0 255";
/// Two red squares, a purple one and a blue one, selected; Recolor Artwork open on them on the
/// Edit tab with the Exact method and the rows set: the blue to yellow, then the red and purple
/// to green (the purple's square id last). One frame drawn.
fn prominent_setup(app: &mut VectorcraftApp, ctx: &egui::Context) -> u64 {
app.run("file.new", json!({"width": 300, "height": 100})).unwrap();
let ids = [square(app, 10.0, "#ff0000"), square(app, 40.0, "#ff0000"), square(app, 70.0, "#8000cc"), square(app, 100.0, "#0000ff")];
app.run("select.set", json!({"ids": ids})).unwrap();
app.run("ui.recolorDialog", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("method".into(), json!(Method::Exact.id()));
d.fields.insert("tab".into(), json!("edit"));
crate::theme::install_fonts(ctx);
frame(app, ctx, 0.0, vec![], 0);
let rows = json!([{"from": [BLUE], "to": "rgb 255 255 0"}, {"from": [RED, PURPLE], "to": "rgb 0 255 0"}]);
app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), rows);
frame(app, ctx, 0.01, vec![], 0);
ids[2]
}
fn froms(app: &VectorcraftApp) -> Vec<Value> {
super::rows(app.ui.dialog.as_ref().unwrap()).iter().map(|r| r["from"].clone()).collect()
}
/// The Prominent Colors bar holds each row's share of the art's uses, the largest first; new
/// `weights` move the rows' current colors toward them, the art previews the result, and only
/// OK commits it, as one undo step.
#[test]
fn weights_move_the_rows_colors_and_the_art_previews_them() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
let purple = prominent_setup(&mut app, &ctx);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.fields[SHARES], json!([25.0, 75.0]));
assert_eq!(prominent_parts(d, &super::rows(d)), [(1, 75.0), (0, 25.0)], "the largest share first");
assert_eq!(fill_hex(&app, purple), "#00ff00", "the art previews the rows");
let undo = app.session.doc().unwrap().history.undo.len();
app.ui.dialog.as_mut().unwrap().fields.insert(WEIGHTS.into(), json!([50, 50]));
frame(&mut app, &ctx, 0.1, vec![], 0);
assert_eq!(froms(&app), [json!([BLUE, PURPLE]), json!([RED])]);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields[SHARES], json!([50.0, 50.0]));
assert_eq!(fill_hex(&app, purple), "#ffff00", "the moved color previews its new row's color");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "nothing is committed before OK");
// A new count of Colors reduces the rows again and drops the weights.
app.ui.dialog.as_mut().unwrap().fields.insert("colors".into(), json!(2));
frame(&mut app, &ctx, 0.2, vec![], 0);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get(WEIGHTS), None);
app.ui
.dialog
.as_mut()
.unwrap()
.fields
.insert("rows".into(), json!([{"from": [BLUE, PURPLE], "to": "rgb 255 255 0"}, {"from": [RED], "to": "rgb 0 255 0"}]));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(fill_hex(&app, purple), "#ffff00");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
}
/// Dragging the edge between two parts of the bar to the left gives the right-hand part more
/// weight; when the drag ends, the rows' current colors move.
#[test]
fn dragging_a_prominent_edge_moves_colors_between_rows() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
let purple = prominent_setup(&mut app, &ctx);
let edge = |ctx: &egui::Context| ctx.read_response(egui::Id::unique((PROMINENT_EDGE, 0usize))).map(|r| r.rect);
let mut last = None;
for n in 0..10 {
frame(&mut app, &ctx, 0.02 + f64::from(n) * 0.001, vec![], 0);
let r = edge(&ctx);
if r.is_some() && r == last {
break;
}
last = r;
}
let at = edge(&ctx).expect("the edge between the two parts").center();
let bar = ctx.read_response(egui::Id::unique((PROMINENT_PART, 0usize))).unwrap().rect;
// From 75% to 50%: a quarter of the bar to the left.
let to = at - vec2((bar.width() / 0.75) * 0.25, 0.0);
let button =
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at), button(at, true)], 0);
for k in 1..=5 {
frame(&mut app, &ctx, 0.1 + f64::from(k) * 0.02, vec![egui::Event::PointerMoved(at + (to - at) * (k as f32 / 5.0))], 0);
}
assert!(app.ui.dialog.as_ref().unwrap().fields.contains_key(BAR_DRAG), "the drag is under way");
assert_eq!(froms(&app)[0], json!([BLUE]), "the rows wait for the drag to end");
frame(&mut app, &ctx, 0.3, vec![button(to, false)], 0);
frame(&mut app, &ctx, 0.32, vec![], 0);
let d = app.ui.dialog.as_ref().unwrap();
let w = d.fields[WEIGHTS].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect::<Vec<_>>();
assert!((w[0] - 50.0).abs() < 2.0 && (w[1] - 50.0).abs() < 2.0, "{w:?}");
assert_eq!(froms(&app), [json!([BLUE, PURPLE]), json!([RED])]);
assert_eq!(fill_hex(&app, purple), "#ffff00");
}
/// The New Color Group menu's Save All Colors saves the new colors in row order and Save
/// Prominent Colors the bar's colors, the largest share first, each as its own step; the
/// preview of the recolor runs on.
#[test]
fn save_all_and_prominent_colors_make_color_groups() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
let purple = prominent_setup(&mut app, &ctx);
let groups = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatch_groups.len();
let last = |app: &VectorcraftApp| -> Vec<String> {
let doc = &app.session.doc().unwrap().doc;
doc.swatch_groups.last().unwrap().swatches.iter().map(|w| w.paint.color().unwrap().to_hex()).collect()
};
let (n, undo) = (groups(&app), app.session.doc().unwrap().history.undo.len());
for (which, colors, t) in [("all", ["#ffff00", "#00ff00"], 0.1), ("prominent", ["#00ff00", "#ffff00"], 0.2)] {
app.ui.dialog.as_mut().unwrap().fields.insert(SAVE_GROUP.into(), json!(which));
frame(&mut app, &ctx, t, vec![], 0);
frame(&mut app, &ctx, t + 0.01, vec![], 0);
assert_eq!(last(&app), colors, "{which}");
assert_eq!(fill_hex(&app, purple), "#00ff00", "{which}: the art still previews the rows");
}
assert_eq!(groups(&app), n + 2);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2, "a step for each group");
}
/// One frame with `modifiers` held.
fn frame_with(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<egui::Event>, modifiers: egui::Modifiers) {
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
+3
View File
@@ -2825,6 +2825,9 @@ digits Malayalam Малаялам
Reset the rows to the artwork's colors Нулиране на редовете към цветовете на илюстрацията
Color Theme Picker Избор на цветова тема
Click or drag across the art to pick its colors; Shift adds more Щракнете или плъзнете през илюстрацията, за да вземете цветовете ѝ; Shift добавя още
Prominent Colors Преобладаващи цветове
Save All Colors Запази всички цветове
Save Prominent Colors Запази преобладаващите цветове
Reset to Default Връщане към стандартните
Reset to White, Black Нулиране към бяло, черно
Reset with Mesh Нулиране с мрежа
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2849,6 +2849,9 @@ digits Malayalam Malajalam
Reset the rows to the artwork's colors Vrati redove na boje crteža
Color Theme Picker Birač teme boja
Click or drag across the art to pick its colors; Shift adds more Kliknite ili prevucite preko crteža da uzmete njegove boje; Shift dodaje još
Prominent Colors Istaknute boje
Save All Colors Sačuvaj sve boje
Save Prominent Colors Sačuvaj istaknute boje
Separate colors into rows Razdvoji boje u redove
Unlink harmony colors Prekini vezu boja harmonije
1 Color Job Posao s 1 bojom
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3607,6 +3607,9 @@ digits Malayalam Malaiàlam
Reset the rows to the artwork's colors Restableix les files als colors de l’obra
Color Theme Picker Selector de tema de colors
Click or drag across the art to pick its colors; Shift adds more Feu clic o arrossegueu sobre l’obra per agafar-ne els colors; Maj n’afegeix més
Prominent Colors Colors predominants
Save All Colors Desa tots els colors
Save Prominent Colors Desa els colors predominants
Reset to Default Restableix el valor predeterminat
Reset to default Restableix el valor predeterminat
Reset to Defaults Restableix els valors predeterminats
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2885,6 +2885,9 @@ digits Malayalam Malajálamské
Reset the rows to the artwork's colors Obnovit řádky na barvy kresby
Color Theme Picker Výběr barevného motivu
Click or drag across the art to pick its colors; Shift adds more Klepnutím nebo tažením přes kresbu převezmete její barvy; Shift přidá další
Prominent Colors Převládající barvy
Save All Colors Uložit všechny barvy
Save Prominent Colors Uložit převládající barvy
Separate colors into rows Rozdělit barvy do řádků
Unlink harmony colors Zrušit propojení barev harmonie
1 Color Job Zakázka s 1 barvou
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2669,6 +2669,9 @@ digits Malayalam Malayalam
Reset the rows to the artwork's colors Nulstil rækkerne til grafikkens farver
Color Theme Picker Farvetemavælger
Click or drag across the art to pick its colors; Shift adds more Klik eller træk hen over grafikken for at hente dens farver; Skift tilføjer flere
Prominent Colors Fremtrædende farver
Save All Colors Gem alle farver
Save Prominent Colors Gem fremtrædende farver
Separate colors into rows Opdel farver i rækker
Unlink harmony colors Fjern sammenkædning af harmonifarver
1 Color Job 1-farvejob
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2871,6 +2871,9 @@ digits Malayalam Malayalam
Reset the rows to the artwork's colors Zeilen auf die Farben der Grafik zurücksetzen
Color Theme Picker Farbthema-Auswahl
Click or drag across the art to pick its colors; Shift adds more Klicken oder ziehen Sie über die Grafik, um ihre Farben aufzunehmen; Umschalt fügt weitere hinzu
Prominent Colors Hervorstechende Farben
Save All Colors Alle Farben speichern
Save Prominent Colors Hervorstechende Farben speichern
Reset to Default Auf Standard zurücksetzen
Reset to White, Black Auf Weiß, Schwarz zurücksetzen
Reset with Mesh Mit Gitter zurücksetzen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3589,6 +3589,9 @@ digits Malayalam Μαλαγιάλαμ
Reset the rows to the artwork's colors Επαναφορά γραμμών στα χρώματα του σχεδίου
Color Theme Picker Επιλογέας χρωματικού θέματος
Click or drag across the art to pick its colors; Shift adds more Κάντε κλικ ή σύρετε πάνω στο σχέδιο για να πάρετε τα χρώματά του· το Shift προσθέτει κι άλλα
Prominent Colors Κυρίαρχα χρώματα
Save All Colors Αποθήκευση όλων των χρωμάτων
Save Prominent Colors Αποθήκευση κυρίαρχων χρωμάτων
Reset to White, Black Επαναφορά σε λευκό και μαύρο
Reset with Mesh Επαναφορά με πλέγμα
Reset with Mesh… Επαναφορά με πλέγμα…
Can't render this file because it is too large.
+3
View File
@@ -2904,6 +2904,9 @@ digits Malayalam Malayálam
Reset the rows to the artwork's colors Restablecer las filas a los colores de la ilustración
Color Theme Picker Selector de tema de color
Click or drag across the art to pick its colors; Shift adds more Haga clic o arrastre sobre la ilustración para tomar sus colores; Mayús añade más
Prominent Colors Colores destacados
Save All Colors Guardar todos los colores
Save Prominent Colors Guardar colores destacados
Reset to Default Restablecer valores predeterminados
Reset to White, Black Restablecer a blanco, negro
Reset with Mesh Restablecer con malla
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2829,6 +2829,9 @@ digits Malayalam Malajalam
Reset the rows to the artwork's colors Palauta rivit kuvituksen väreihin
Color Theme Picker Väriteeman valitsin
Click or drag across the art to pick its colors; Shift adds more Napsauta tai vedä kuvituksen yli poimiaksesi sen värit; Vaihto lisää useampia
Prominent Colors Hallitsevat värit
Save All Colors Tallenna kaikki värit
Save Prominent Colors Tallenna hallitsevat värit
Separate colors into rows Erottele värit riveille
Unlink harmony colors Poista harmoniavärien linkitys
1 Color Job 1 värin työ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2896,6 +2896,9 @@ digits Malayalam Malayalam
Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration
Color Theme Picker Sélecteur de thème de couleurs
Click or drag across the art to pick its colors; Shift adds more Cliquez ou faites glisser sur l’illustration pour prélever ses couleurs ; Maj en ajoute d’autres
Prominent Colors Couleurs dominantes
Save All Colors Enregistrer toutes les couleurs
Save Prominent Colors Enregistrer les couleurs dominantes
Reset to Default Rétablir les valeurs par défaut
Reset to White, Black Réinitialiser à blanc, noir
Reset with Mesh Réinitialiser avec un filet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3948,6 +3948,9 @@ antiAlias Sharp शार्प
Reset the rows to the artwork's colors आर्टवर्क के रंग के लिए पंक्तियों को रीसेट करें
Color Theme Picker रंग थीम पिकर
Click or drag across the art to pick its colors; Shift adds more आर्टवर्क के रंग लेने के लिए उस पर क्लिक करें या खींचें; Shift और जोड़ता है
Prominent Colors प्रमुख रंग
Save All Colors सभी रंग सेव करें
Save Prominent Colors प्रमुख रंग सेव करें
Reset to default डिफ़ॉल्ट पर रीसेट करें
Reset to Default डिफ़ॉल्ट पर रीसेट करें
Reset to Defaults डिफ़ॉल्ट पर रीसेट करें
Can't render this file because it is too large.
+3
View File
@@ -3454,6 +3454,9 @@ mark-style Japanese Japán
Reset the rows to the artwork's colors Sorok visszaállítása a grafika színeire
Color Theme Picker Színtéma-választó
Click or drag across the art to pick its colors; Shift adds more Kattintson vagy húzzon a grafikán a színei felvételéhez; a Shift továbbiakat ad hozzá
Prominent Colors Domináns színek
Save All Colors Összes szín mentése
Save Prominent Colors Domináns színek mentése
Reset to default Alapérték visszaállítása
Reset to Default Alapérték visszaállítása
Reset to Defaults Alapértékek visszaállítása
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2669,6 +2669,9 @@ digits Malayalam Malayalam
Reset the rows to the artwork's colors Setel ulang baris ke warna karya seni
Color Theme Picker Pemilih tema warna
Click or drag across the art to pick its colors; Shift adds more Klik atau seret melintasi karya seni untuk mengambil warnanya; Shift menambahkan lagi
Prominent Colors Warna menonjol
Save All Colors Simpan semua warna
Save Prominent Colors Simpan warna menonjol
Separate colors into rows Pisahkan warna ke dalam baris
Unlink harmony colors Lepaskan tautan warna harmoni
1 Color Job Pekerjaan 1 warna
Can't render this file because it contains an unexpected character in line 2412 and column 5.
+3
View File
@@ -2947,6 +2947,9 @@ digits Malayalam Malayalam
Reset the rows to the artwork's colors Reimposta le righe sui colori della grafica
Color Theme Picker Selettore tema colore
Click or drag across the art to pick its colors; Shift adds more Fai clic o trascina sulla grafica per prelevarne i colori; Maiusc ne aggiunge altri
Prominent Colors Colori prominenti
Save All Colors Salva tutti i colori
Save Prominent Colors Salva colori prominenti
Reset to Default Reimposta valori predefiniti
Reset to White, Black Reimposta su bianco, nero
Reset with Mesh Reimposta con trama
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2952,6 +2952,9 @@ mark-style Japanese 日本式
Reset the rows to the artwork's colors 行をアートワークのカラーにリセット
Color Theme Picker カラーテーマピッカー
Click or drag across the art to pick its colors; Shift adds more アートワークをクリックまたはドラッグしてカラーを取得します。Shift キーで追加します
Prominent Colors 主要カラー
Save All Colors すべてのカラーを保存
Save Prominent Colors 主要カラーを保存
Reset to Default 初期設定に戻す
Reset to White, Black 白、黒にリセット
Reset {name} {name}をリセット
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2585,6 +2585,9 @@ digits Malayalam მალაიალამური
Reset the rows to the artwork's colors სტრიქონებისთვის ნახატის ფერების დაბრუნება
Color Theme Picker ფერთა თემის ამრჩევი
Click or drag across the art to pick its colors; Shift adds more დააწკაპუნეთ ან გადაატარეთ ნახატზე მისი ფერების ასაღებად; Shift ამატებს კიდევ
Prominent Colors გამოკვეთილი ფერები
Save All Colors ყველა ფერის შენახვა
Save Prominent Colors გამოკვეთილი ფერების შენახვა
Separate colors into rows ფერების სტრიქონებად დაყოფა
Unlink harmony colors ჰარმონიის ფერების კავშირის გაწყვეტა
1 Color Job 1-ფერიანი ბეჭდვა
Can't render this file because it is too large.
+3
View File
@@ -2941,6 +2941,9 @@ mark-style Japanese 일본식
Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정
Color Theme Picker 색상 테마 피커
Click or drag across the art to pick its colors; Shift adds more 아트웍을 클릭하거나 드래그하여 색상을 가져옵니다. Shift 키로 더 추가합니다
Prominent Colors 주요 색상
Save All Colors 모든 색상 저장
Save Prominent Colors 주요 색상 저장
Reset to Default 기본값으로 재설정
Reset to White, Black 흰색, 검정으로 재설정
Reset {name} {name} 재설정
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2683,6 +2683,9 @@ digits Malayalam Malayalam
Reset the rows to the artwork's colors De rijen herstellen naar de kleuren van de artwork
Color Theme Picker Kleurthemakiezer
Click or drag across the art to pick its colors; Shift adds more Klik of sleep over het artwork om de kleuren ervan op te nemen; Shift voegt er meer toe
Prominent Colors Prominente kleuren
Save All Colors Alle kleuren opslaan
Save Prominent Colors Prominente kleuren opslaan
Separate colors into rows Kleuren over rijen verdelen
Unlink harmony colors Harmoniekleuren ontkoppelen
1 Color Job Opdracht in 1 kleur
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2794,6 +2794,9 @@ digits Malayalam Malajalam
Reset the rows to the artwork's colors Przywróć w wierszach kolory kompozycji
Color Theme Picker Próbnik motywu kolorów
Click or drag across the art to pick its colors; Shift adds more Kliknij lub przeciągnij po kompozycji, aby pobrać jej kolory; Shift dodaje kolejne
Prominent Colors Dominujące kolory
Save All Colors Zapisz wszystkie kolory
Save Prominent Colors Zapisz dominujące kolory
Separate colors into rows Rozdziel kolory na wiersze
Unlink harmony colors Odłącz kolory harmonii
1 Color Job Praca 1-kolorowa
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2442,6 +2442,9 @@ digits Malayalam Malaiala
Reset the rows to the artwork's colors Redefinir as linhas para as cores da arte
Color Theme Picker Seletor de Tema de Cores
Click or drag across the art to pick its colors; Shift adds more Clique ou arraste sobre a arte para pegar suas cores; Shift adiciona mais
Prominent Colors Cores Proeminentes
Save All Colors Salvar Todas as Cores
Save Prominent Colors Salvar Cores Proeminentes
Reset to Default Redefinir para o Padrão
Reset to White, Black Redefinir para Branco, Preto
Reset with Mesh… Redefinir com Malha…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2934,6 +2934,9 @@ digits Malayalam Малаялам
Reset the rows to the artwork's colors Сбросить строки к цветам рисунка
Color Theme Picker Выбор цветовой темы
Click or drag across the art to pick its colors; Shift adds more Щёлкните или проведите по рисунку, чтобы взять его цвета; Shift добавляет ещё
Prominent Colors Преобладающие цвета
Save All Colors Сохранить все цвета
Save Prominent Colors Сохранить преобладающие цвета
Reset to Default Сбросить к значениям по умолчанию
Reset to White, Black Сбросить к белому, чёрному
Reset with Mesh Сбросить с сеткой
Can't render this file because it is too large.
+3
View File
@@ -2840,6 +2840,9 @@ digits Malayalam Malayalam
Reset the rows to the artwork's colors Återställ raderna till teckningens färger
Color Theme Picker Färgtemaväljare
Click or drag across the art to pick its colors; Shift adds more Klicka eller dra över teckningen för att hämta dess färger; Skift lägger till fler
Prominent Colors Framträdande färger
Save All Colors Spara alla färger
Save Prominent Colors Spara framträdande färger
Reset to Default Återställ till standard
Reset to White, Black Återställ till vitt, svart
Reset with Mesh Återställ med nät
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -2708,6 +2708,9 @@ digits Malayalam Malayalam
Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın
Color Theme Picker Renk Teması Seçici
Click or drag across the art to pick its colors; Shift adds more Renklerini almak için resme tıklayın veya üzerinde sürükleyin; Shift daha fazlasını ekler
Prominent Colors Belirgin Renkler
Save All Colors Tüm Renkleri Kaydet
Save Prominent Colors Belirgin Renkleri Kaydet
Reset to Default Varsayılana Sıfırla
Reset to White, Black Beyaz, Siyah'a Sıfırla
Reset with Mesh Mesh ile Sıfırla
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -2939,6 +2939,9 @@ digits Malayalam Малаялам
Reset the rows to the artwork's colors Скинути рядки до кольорів малюнка
Color Theme Picker Вибір колірної теми
Click or drag across the art to pick its colors; Shift adds more Клацніть або проведіть по малюнку, щоб узяти його кольори; Shift додає ще
Prominent Colors Переважні кольори
Save All Colors Зберегти всі кольори
Save Prominent Colors Зберегти переважні кольори
Reset to Default Скинути до значень за замовчуванням
Reset to White, Black Скинути до білого, чорного
Reset with Mesh Скинути із сіткою
Can't render this file because it is too large.
+3
View File
@@ -3227,6 +3227,9 @@ mark-style Japanese Kiểu Nhật
Reset the rows to the artwork's colors Đặt lại các hàng về màu của tác phẩm
Color Theme Picker Bộ chọn bộ màu
Click or drag across the art to pick its colors; Shift adds more Nhấp hoặc kéo qua tác phẩm để lấy màu của nó; Shift để thêm
Prominent Colors Màu nổi bật
Save All Colors Lưu tất cả màu
Save Prominent Colors Lưu màu nổi bật
Reset to Default Đặt lại về mặc định
Reset to default Đặt lại về mặc định
Reset to White, Black Đặt lại về Trắng, Đen
Can't render this file because it contains an unexpected character in line 204 and column 5.
+3
View File
@@ -2804,6 +2804,9 @@ digits Malayalam 马拉雅拉姆数字
Reset the rows to the artwork's colors 将各行重置为图稿的颜色
Color Theme Picker 色彩主题拾取器
Click or drag across the art to pick its colors; Shift adds more 单击或拖过图稿以拾取其颜色;按住 Shift 可添加更多
Prominent Colors 主要颜色
Save All Colors 存储所有颜色
Save Prominent Colors 存储主要颜色
Reset to Default 恢复默认
Reset to White, Black 重置为白、黑
Reset with Mesh 以网格重置
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3999,6 +3999,9 @@ digits Malayalam 馬來亞拉姆數字
Reset the rows to the artwork's colors 將各列重設為圖稿的顏色
Color Theme Picker 色彩主題選取器
Click or drag across the art to pick its colors; Shift adds more 按一下或拖過圖稿以選取其顏色;按住 Shift 可加入更多
Prominent Colors 主要顏色
Save All Colors 儲存所有顏色
Save Prominent Colors 儲存主要顏色
Reset to Default 重設為預設值
Reset to White, Black 重設為白色、黑色
Reset with Mesh 以網格重設
Can't render this file because it contains an unexpected character in line 6 and column 47.
+24
View File
@@ -3719,6 +3719,30 @@ excluded take the palette as their new colors in row order, the colors repeating
{"name":"run_command","arguments":{"command":"recolor.pickTheme","params":{"picks":[{"at":[120,80]},{"rect":[200,40,320,140]}],"colors":4}}}
```
`recolor.weigh {map, weights?, ids?}` is the dialog's Prominent Colors bar. It takes rows as `recolor.apply` does and
returns them as `map`, each with `from` as a list, and `shares`: for each row, its percent of the uses (the `count` of
`recolor.colors`) of the current colors of the rows that are not excluded, and 0 for an excluded row. A use counts for
the row that `recolor.apply` recolors it with (rows with a new color first, an exact color before a hex color, then
the first row that lists it), and otherwise for the first other row that lists it. A current color that no art uses
counts nothing. Without `weights` the
rows come back unchanged. `weights` holds one number of 0 or more per row; an excluded row's weight is ignored, and
at least one other row needs a weight above 0. As VectorCraft's own rule, current colors then move one at a time
between the rows that are not excluded and have a new color (`to`), as long as a move brings each row's share of the uses nearer its share of the
weights. Of those moves, it takes first the color nearest in Lab to the first color of the row it goes to, compared
with the row it leaves. A row keeps its first color, and the tints of one global swatch in a row move together. The
colors that can move are compared with the rows at most 50,000,000 times in all; when those colors times the rows
that are not excluded pass that, no color moves. Colors that stay in a row keep their order, and the colors that come
to it follow them. The command changes nothing; `recolor.apply` with the returned `map` recolors the art. In the Recolor Artwork dialog the
bar sits under the Edit tab's wheel: each row with a share is a part as wide as that share, in the row's new color,
the largest first. Dragging the edge between two parts moves width between them, and when the drag ends the widths
become the `weights` field, which moves the rows' current colors and previews the result. The New Color Group menu
beside the link button runs `swatch.newGroup` right away, as its own undo step: Save All Colors saves the new colors
of the rows that are not excluded, in row order, and Save Prominent Colors saves the bar's colors in the bar's order.
```json
{"name":"run_command","arguments":{"command":"recolor.weigh","params":{"map":[{"from":["rgb 255 0 0","rgb 128 0 204"],"to":"#00ff00"},{"from":["rgb 0 0 255"],"to":"#ffff00"}],"weights":[1,1]}}}
```
The `recolor.*` commands refuse parameters of the wrong form. A `limitTo` or `method` that is not a string gets the
error for an unknown library or method. Other wrong-form parameters get an error that names them.
`includeImages`, `includePatterns`, a row's `exclude`, `order`, `saturationBrightness` and the `white`, `black` and
+2
View File
@@ -486,6 +486,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
- [x] Recolor Artwork › Color Theme Picker: with the button beside the tabs on, a click on the canvas takes the color palette of an object or image, a drag takes the palette of the art inside its marquee, and Shift adds more picks; the rows that aren't excluded take the palette as their new colors and the art previews them ([Recolor artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/recolor-artwork.html)). The number and order of the picked colors are VectorCraft's own rule (`recolor.pickTheme`). Tests: `theme_picks_give_the_rows_the_palette_of_the_art_picked` and `the_theme_picker_takes_clicks_and_shift_drags_on_the_canvas` in `crates/ui-egui/src/dialogs/recolor.rs`, `theme_picker_*` in `crates/engine/src/tests/recolor.rs`.
- [x] Recolor Artwork › Prominent Colors: with art, a bar under the Edit tab's wheel shows the new colors, each as wide as its share of the art; dragging the edge between two parts raises one color's weight and lowers the other's, and the art previews the result; the New Color Group menu has Save All Colors and Save Prominent Colors ([Recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/recolor-options-overview.html)). How a share is measured and how a weight moves colors between rows are VectorCraft's own rules (`recolor.weigh`). Tests: `weights_move_the_rows_colors_and_the_art_previews_them`, `dragging_a_prominent_edge_moves_colors_between_rows` and `save_all_and_prominent_colors_make_color_groups` in `crates/ui-egui/src/dialogs/recolor.rs`, `weigh_*` in `crates/engine/src/tests/recolor.rs`.
- [x] Recolor Artwork Edit color chips scroll within the screen, preserving the wheel and actions (#3416); 5, 20, 200 and 400 colors, resizing and UI scaling, final-chip editing and preview/history covered by egui_kittest.
- [x] Status messages and bounded readouts remain stable as values, canvas hover and background jobs change; ordinary values fit all registered languages and units, and optional groups yield in narrow windows (regressions; contributor rebuilt and confirmed the original-file status-bar scenario on 2026-10-19). Sample widths are cached per UI and refreshed only when language, document units or pixels per point changes.