diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index 93c0ad0f37..05c386e405 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -567,6 +567,112 @@ pub fn theme_palette(samples: &[(Color, f32)], n: usize) -> Vec { 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 { + 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 { + let mut row: Vec = 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 = (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 = (0..n).map(|r| w(r) / sum_w * total).collect(); + let anchor_labs: Vec = anchors.iter().map(|c| c.to_lab()).collect(); + // Each movable unit's distance to each row's color. + let dist: Vec> = 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 = (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 = (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)); + } } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index c4b21b1cb4..bf46bcee94 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -478,6 +478,7 @@ pub(super) const ALLOWED: &[&str] = &[ "recolor.pickTheme", "recolor.randomize", "recolor.reduce", + "recolor.weigh", "select.add", "select.all", "select.allOnArtboard", diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 931bcce681..f36486064c 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -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 { 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 { 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 { + 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 { + 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::>>()?, + ), + Some(v) => return Err(bad(C, format!("`weights` must be a list of {} numbers, one per row (got {v})", rows.len()))), + }; + let active: Vec = (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> = used_colors(&st.doc, &ids, only.as_ref()).into_iter().map(Some).collect(); + let mut by_key: HashMap = HashMap::new(); + let mut by_hex: HashMap> = 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) { + let hits: Vec = 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)>> = 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, Vec<(usize, Vec)>) = (vec![], vec![]); + for (n, &r) in active.iter().enumerate() { + let mut groups: Vec<(Option<&String>, Vec, 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 = (0..active.len()).filter(|&n| rows[active[n]].new_color().is_some()).collect(); + let anchors: Vec = + 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> = units.iter().map(|u| mover_of[u.row]).collect(); + let sub: Vec = 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::>()); + 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> = out.iter().map(|r| r["from"].as_array().cloned().unwrap_or_default()).collect(); + let mut stayed: Vec> = active.iter().map(|_| vec![]).collect(); + let mut came: Vec> = 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 = 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 { diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 4707ae98fd..a065d09c42 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -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 = 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}"); + } +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 8c68237b0c..495c38f8d9 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -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 { "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 = 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::() * 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) -> Vec { + 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 = 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 { 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 = 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 { + 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::>(); + 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 { + 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, modifiers: egui::Modifiers) { let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8edde76eb3..d3f8fe92e6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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 Нулиране с мрежа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index c9c207eaa2..bf1a65904c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0399e6c04a..3e590eadb1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e62070329c..751d06c66a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 47a78ef612..df2fbc011f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index af4489a637..ed11eca5cf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6ad6ff11c0..398d51f9db 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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… Επαναφορά με πλέγμα… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 41f92e14f7..380124c709 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index fd38382f63..8211efc0cd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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ö diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dcbe436fc3..dc7be09b13 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9b7a15bbad..dd0d8137e7 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -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 डिफ़ॉल्ट पर रीसेट करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6832a86707..7c1c82e97e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 45e05b9622..758903eb08 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 57f141f880..579098b1de 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 69b55c9121..6abc349cb3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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}をリセット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 1cbb2d1bf1..8a1605eb96 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -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-ფერიანი ბეჭდვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6135f8d5ec..b1359dba7a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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} 재설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 327aee902c..b4fe48049b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f277d51d65..cd618b1912 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index afd10a5fbc..38eb7122f3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2dca232079..4d069732ef 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Сбросить с сеткой diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e47c4e8662..6311bca512 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5983ec4bfe..310f7e8dae 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4c9326af72..6590c4bf76 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Скинути із сіткою diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fef0b8ff90..f485a03f51 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a0d941ada8..45e1c6d6fd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 以网格重置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b724e57351..c6b1b68402 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 以網格重設 diff --git a/docs/mcp.md b/docs/mcp.md index 20e37222c6..1f370735c6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5f10ea4841..8485de41ac 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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.