From 0567eaffbf15bb7054d754fa4674954c628c4c02 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 17:20:56 -0800 Subject: [PATCH] Recolor: the Global Adjust sliders change every color at once The Edit tab's color mode menu offers Global Adjust, with Saturation, Brightness, Temperature and Luminosity sliders from -100% to 100% that open at 0%, which changes nothing. One slider changes every recolored color, each one separately, in its own model; the rows keep their new colors and the art previews the change. The Edit tab also shows the color sliders of the selected new color in the other modes. recolor.apply takes globalAdjust {saturation, brightness, temperature, luminosity}. Excluded rows and colors no row holds stay as they are, a rewritten color group takes the same change, and with Limit to Library each row's new color takes it before it snaps to the library. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/color/src/recolor.rs | 142 ++++++++++++++++++++++ crates/engine/src/cmd/recolor.rs | 64 ++++++++-- crates/engine/src/tests/recolor.rs | 115 ++++++++++++++++++ crates/ui-egui/src/dialogs/recolor.rs | 167 ++++++++++++++++++++++---- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/tests/recolor.rs | 67 ++++++++++- docs/control-protocol.md | 7 +- docs/mcp.md | 19 ++- docs/target-app-parity.md | 2 +- 34 files changed, 590 insertions(+), 41 deletions(-) diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index e88f767a5..53cb12478 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -255,6 +255,77 @@ pub fn shaded(c: Color, f: f32) -> Color { } } +/// Recolor Artwork's Global Adjust: the same change to every color, by four amounts from -1 to 1 +/// (-100% to +100%), where 0 leaves a color as it is. The steps run in this order: +/// +/// 1. Saturation multiplies HSB saturation by 1 + `saturation` (-1 gives a gray, 1 doubles it). +/// 2. Brightness moves HSB brightness that share of the way to its top (above 0) or to black +/// (below 0). +/// 3. Temperature adds 0.2 × `temperature` to display red and takes as much from blue (warmer +/// above 0, cooler below). +/// 4. Luminosity moves Lab lightness that share of the way to 100 (above 0) or to 0 (below 0), +/// with Lab a and b, and so the Lab hue, kept. +/// +/// The result is in the model of the color it changes, with its components clamped to their +/// ranges. An amount that is not finite counts as 0. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GlobalAdjust { + pub saturation: f32, + pub brightness: f32, + pub temperature: f32, + pub luminosity: f32, +} + +impl GlobalAdjust { + /// The four amounts, in order, each finite and within -1..=1. + fn amounts(&self) -> [f32; 4] { + [self.saturation, self.brightness, self.temperature, self.luminosity].map(|v| if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 }) + } + + /// Whether every color stays as it is (all four amounts 0). + pub fn is_identity(&self) -> bool { + self.amounts().iter().all(|v| *v == 0.0) + } + + /// `c` with the adjustment, in `c`'s model; `c` itself when [`GlobalAdjust::is_identity`]. + pub fn apply(&self, c: Color) -> Color { + let [sat, bri, temp, lum] = self.amounts(); + if [sat, bri, temp, lum].iter().all(|v| *v == 0.0) { + return c; + } + // `v` moved the share `a` of the way to `top` (a > 0) or to 0 (a < 0). + let toward = |v: f32, a: f32, top: f32| if a >= 0.0 { v + (top - v) * a } else { v * (1.0 + a) }; + let mut out = c; + if sat != 0.0 || bri != 0.0 { + let [h, s, v] = out.to_hsb(); + out = Color::from_hsb(h, (s * (1.0 + sat)).clamp(0.0, 1.0), toward(v.clamp(0.0, 1.0), bri, 1.0)); + } + if temp != 0.0 { + let [r, g, b] = out.to_rgb(); + out = Color::rgb((r + 0.2 * temp).clamp(0.0, 1.0), g.clamp(0.0, 1.0), (b - 0.2 * temp).clamp(0.0, 1.0)); + } + if lum != 0.0 { + let Lab { l, a, b } = match out { + Color::Lab { l, a, b } => Lab::new(l, a, b), + _ => out.to_lab(), + }; + out = Color::lab(toward(l.clamp(0.0, 100.0), lum, 100.0), a, b); + } + clamped(keep_model(c, out)) + } +} + +/// `c` with its components in their ranges: RGB, CMYK and Gray 0..1, Lab lightness 0..100. +fn clamped(c: Color) -> Color { + let u = |v: f32| v.clamp(0.0, 1.0); + match c { + Color::Rgb { r, g, b } => Color::rgb(u(r), u(g), u(b)), + Color::Cmyk { c, m, y, k } => Color::cmyk(u(c), u(m), u(y), u(k)), + Color::Gray { k } => Color::gray(u(k)), + Color::Lab { l, a, b } => Color::lab(l.clamp(0.0, 100.0), a, b), + } +} + /// One row of Recolor Artwork prepared for mapping: its current colours take `to` by `method`. #[derive(Clone, Copy, Debug)] pub struct RowMap { @@ -615,6 +686,77 @@ mod tests { assert!(row.iter().all(|c| kept.map(*c).model() == c.model())); } + fn adjust(saturation: f32, brightness: f32, temperature: f32, luminosity: f32) -> GlobalAdjust { + GlobalAdjust { saturation, brightness, temperature, luminosity } + } + + #[test] + fn global_adjust_at_zero_leaves_every_color_as_it_is() { + let zero = GlobalAdjust::default(); + assert!(zero.is_identity()); + assert!(adjust(f32::NAN, 0.0, f32::INFINITY, 0.0).is_identity(), "amounts that aren't finite count as 0"); + for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.1234, 0.5, 0.0, 0.07), Color::gray(0.4), Color::lab(55.0, 60.5, -40.25)] { + assert_eq!(zero.apply(c), c); + } + assert!(!adjust(0.0, 0.0, 0.0, 0.01).is_identity()); + } + + #[test] + fn global_adjust_saturation_and_brightness_reach_their_ends() { + let c = Color::rgb(0.8, 0.4, 0.2); + let [h, s, v] = c.to_hsb(); + let gray = adjust(-1.0, 0.0, 0.0, 0.0).apply(c).to_hsb(); + assert!(gray[1].abs() < 1e-4 && (gray[2] - v).abs() < 1e-4, "-100% saturation gives a gray as bright: {gray:?}"); + let more = adjust(0.5, 0.0, 0.0, 0.0).apply(c).to_hsb(); + assert!((more[0] - h).abs() < 0.5 && more[1] > s && (more[1] - (s * 1.5).min(1.0)).abs() < 1e-3, "{more:?}"); + assert!(adjust(-1.0, 0.0, 0.0, 0.0).apply(Color::rgb(0.5, 0.5, 0.5)) == Color::rgb(0.5, 0.5, 0.5), "a gray stays gray"); + assert!(adjust(0.0, -1.0, 0.0, 0.0).apply(c).to_hsb()[2] < 1e-4, "-100% brightness is black"); + assert!((adjust(0.0, 1.0, 0.0, 0.0).apply(c).to_hsb()[2] - 1.0).abs() < 1e-4, "+100% brightness is full brightness"); + let half = adjust(0.0, 0.5, 0.0, 0.0).apply(c).to_hsb(); + assert!((half[2] - (v + (1.0 - v) * 0.5)).abs() < 1e-3 && (half[1] - s).abs() < 1e-3, "{half:?}"); + } + + #[test] + fn global_adjust_temperature_warms_and_cools() { + let c = Color::rgb(0.5, 0.5, 0.5); + let warm = adjust(0.0, 0.0, 1.0, 0.0).apply(c).to_rgb(); + let cool = adjust(0.0, 0.0, -1.0, 0.0).apply(c).to_rgb(); + assert!(warm[0] > warm[1] && warm[1] > warm[2], "warmer: more red, less blue {warm:?}"); + assert!(cool[2] > cool[1] && cool[1] > cool[0], "cooler: more blue, less red {cool:?}"); + let edge = adjust(0.0, 0.0, 1.0, 0.0).apply(Color::rgb(1.0, 0.0, 0.0)); + assert_eq!(edge, Color::rgb(1.0, 0.0, 0.0), "clamped at the ends of the channels"); + } + + #[test] + fn global_adjust_luminosity_keeps_the_hue() { + let lab = Color::lab(50.0, 30.0, -20.0); + assert_eq!(adjust(0.0, 0.0, 0.0, 0.5).apply(lab), Color::lab(75.0, 30.0, -20.0), "a Lab color keeps a and b exactly"); + assert_eq!(adjust(0.0, 0.0, 0.0, -0.5).apply(lab), Color::lab(25.0, 30.0, -20.0)); + let c = Color::rgb(0.6, 0.35, 0.3); + let (before, after) = (c.to_lab(), adjust(0.0, 0.0, 0.0, 0.2).apply(c).to_lab()); + assert!(after.l > before.l, "lighter: {after:?}"); + assert!((after.hue() - before.hue()).abs() < 2.0, "hue kept: {} vs {}", after.hue(), before.hue()); + let darkest = adjust(0.0, 0.0, 0.0, -1.0).apply(c).to_lab(); + assert!(darkest.l < before.l * 0.5, "-100% is far darker: {darkest:?}"); + assert_eq!(adjust(0.0, 0.0, 0.0, -1.0).apply(Color::gray(0.4)), Color::gray(1.0), "-100% makes a gray black"); + } + + #[test] + fn global_adjust_keeps_the_model_and_the_ranges() { + let all = adjust(1.0, 1.0, 1.0, 1.0); + for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.2, 0.9, 0.1, 0.3), Color::gray(0.4), Color::lab(55.0, 60.0, -40.0)] { + let out = all.apply(c); + assert_eq!(out.model(), c.model(), "{c:?} → {out:?}"); + let comps: Vec = match out { + Color::Rgb { r, g, b } => vec![r, g, b], + Color::Cmyk { c, m, y, k } => vec![c, m, y, k], + Color::Gray { k } => vec![k], + Color::Lab { l, .. } => vec![l / 100.0], + }; + assert!(comps.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{out:?}"); + } + } + #[test] fn palette_snaps_to_the_nearest_colour() { let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]); diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index b1fd2489e..75b2cc73b 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; -use vectorcraft_color::recolor::{ColorKey, Method, Preserve, Rng, RowMap, cluster}; +use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster}; use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::swatches::{node_colors, node_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}", has_doc, apply ), @@ -224,6 +224,30 @@ fn reduce(s: &mut Session, p: &Value) -> Result { Ok(json!({"map": map, "preserved": kept.iter().map(|u| key_str(&u.color)).collect::>(), "method": method.id()})) } +/// The `globalAdjust` parameter: `{saturation?, brightness?, temperature?, luminosity?}`, each a +/// percentage from -100 to 100 (0 when left out or null), as amounts from -1 to 1. +fn adjust_param(p: &Value, cmd: &str) -> Result { + let given = match p.get("globalAdjust") { + None | Some(Value::Null) => return Ok(GlobalAdjust::default()), + Some(v @ Value::Object(_)) => v, + Some(_) => return Err(bad(cmd, "`globalAdjust` must be an object")), + }; + let amount = |key: &str| -> Result { + let x = super::number_or(given, key, 0.0, cmd)?; + let (min, max) = (-100, 100); + if !(f64::from(min)..=f64::from(max)).contains(&x) { + return Err(bad(cmd, format!("`{key}` must be between {min} and {max} (got {x})"))); + } + Ok((x / 100.0) as f32) + }; + Ok(GlobalAdjust { + saturation: amount("saturation")?, + brightness: amount("brightness")?, + temperature: amount("temperature")?, + luminosity: amount("luminosity")?, + }) +} + /// A current colour of a row: an exact colour, or every colour shown as a hex. enum Match { Key(ColorKey), @@ -370,15 +394,25 @@ struct ColorMap { hexes: HashMap, /// Set when a colour took a new colour of another model in its own model. converted: std::cell::Cell, + /// The Global Adjust every mapped colour takes after its row's new colour. + adjust: GlobalAdjust, } impl ColorMap { /// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none); `group`: the /// global swatches of the color group being rewritten; `model`: the model rule of a row without - /// its own. - fn new(rows: &[Row], method: Method, model: ModelRule, links: &[Option], group: Vec) -> Self { - let mut out = - Self { rows: vec![], links: vec![], tos: vec![], group, keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() }; + /// its own; `adjust`: the Global Adjust of each mapped colour. + fn new(rows: &[Row], method: Method, model: ModelRule, links: &[Option], group: Vec, adjust: GlobalAdjust) -> Self { + let mut out = Self { + rows: vec![], + links: vec![], + tos: vec![], + group, + keys: HashMap::new(), + hexes: HashMap::new(), + converted: Default::default(), + adjust, + }; for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) { let Some(to) = r.new_color() else { continue }; let i = out.rows.len(); @@ -387,7 +421,8 @@ impl ColorMap { // Colors that can't link to the swatch take the row's tint of it too. let mapped = link.as_ref().filter(|l| l.global && l.tint < 1.0).map_or(to, |l| to.tinted(l.tint)); out.rows.push(RowMap::new(method, &colors, mapped).with_own_model(r.model.unwrap_or(model) != ModelRule::Keep)); - out.tos.push(to); + // Compared with the group's new colours, which take the adjustment too. + out.tos.push(adjust.apply(to)); out.links.push(link); for m in &r.from { match m { @@ -409,7 +444,7 @@ impl ColorMap { if r.converts(c) { self.converted.set(true); } - r.map(c) + self.adjust.apply(r.map(c)) } /// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot @@ -448,7 +483,7 @@ impl ColorMap { if row.converts(*c) { self.converted.set(true); } - row.map(*c) + self.adjust.apply(row.map(*c)) } }; // A color its row leaves as it is still loses its link to a swatch of the rewritten group @@ -468,9 +503,13 @@ fn apply(s: &mut Session, p: &Value) -> Result { let method = method_param(p, C, Method::Exact)?; let palette = super::swatchlib::limit_param(s, p, C)?; let mut rows = parse_rows(p, C)?; + let adjust = adjust_param(p, C)?; + // With Limit to Library a row's new colour takes the Global Adjust before it snaps, so the art + // still takes the library's colours; otherwise every colour the rows map takes it. + let mapped_adjust = if palette.is_some() { GlobalAdjust::default() } else { adjust }; if let Some(pl) = &palette { for r in &mut rows { - r.to = r.to.map(|c| pl.nearest(c)); + r.to = r.to.map(|c| pl.nearest(adjust.apply(c))); } } // With Limit to Library, a row's new colour is a swatch of the library: the art takes that @@ -510,11 +549,12 @@ fn apply(s: &mut Session, p: &Value) -> Result { .iter() .map(|v| { color_value(v) + .map(|c| adjust.apply(c)) .map(|c| in_doc_model(palette.as_ref().map_or(c, |pl| pl.nearest(c)))) .ok_or_else(|| bad(C, format!("bad colour {v} in groupColors"))) }) .collect::>()?, - None => rows.iter().filter_map(Row::new_color).collect(), + None => rows.iter().filter_map(Row::new_color).map(|c| mapped_adjust.apply(c)).collect(), }; let rename = text_param(p, "rename", C)?; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); @@ -553,7 +593,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { .collect() }) .unwrap_or_default(); - let map = ColorMap::new(&rows, method, model, &links, kept); + let map = ColorMap::new(&rows, method, model, &links, kept, mapped_adjust); let f = |c: Color| map.map(c); let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 565405d58..cadf9314a 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -721,3 +721,118 @@ fn document_model_converts_the_colours_of_a_group_too() { run(&mut s, "recolor.apply", json!({"map": [], "group": "Theme", "groupColors": ["#00ff00"], "model": "document"})); assert!(group(&s).iter().all(|c| c.model() == vectorcraft_color::cms::Model::Cmyk), "{:?}", group(&s)); } + +fn stroke(s: &Session, id: NodeId) -> Color { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_paint().color().unwrap() +} + +/// Global Adjust changes every colour the rows recolour, each one separately (the colours of a +/// row of several, fills and strokes alike), in its own model, as one undo step; an excluded +/// row's colours and preserved colours stay. +#[test] +fn global_adjust_changes_every_recolored_color() { + let mut s = session(); + let red = rect(&mut s, json!("#ff0000")); + let pink = rect(&mut s, json!("#ff8080")); + let cmyk = rect(&mut s, json!({"c": 100, "m": 0, "y": 0, "k": 0})); + let kept = rect(&mut s, json!("#00ff00")); + run(&mut s, "paint.setStroke", json!({"ids": [red.0], "color": "#0000ff", "width": 2})); + select_all(&mut s); + let map = json!([ + {"from": ["rgb 255 0 0", "rgb 255 128 128"], "to": "rgb 255 0 0"}, + {"from": ["cmyk 100 0 0 0"], "to": "cmyk 100 0 0 0"}, + {"from": ["rgb 0 0 255"], "to": "rgb 0 0 255"}, + {"from": ["rgb 0 255 0"], "to": "rgb 0 255 0", "exclude": true}, + ]); + let undo = s.doc().unwrap().history.undo.len(); + let sat = |c: Color| c.to_hsb()[1]; + let saturations = [fill(&s, red), fill(&s, pink), fill(&s, cmyk), stroke(&s, red)].map(sat); + let r = run(&mut s, "recolor.apply", json!({"map": map, "method": "scaleTints", "globalAdjust": {"saturation": -50}})); + assert_eq!(r["changed"], 4, "three fills and a stroke: {r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let after = [fill(&s, red), fill(&s, pink), fill(&s, cmyk), stroke(&s, red)].map(sat); + for (n, what) in ["red", "pink", "cyan", "the stroke"].iter().enumerate() { + // CMYK reads back through the profile-free conversion, so its saturation is near half, not exact. + assert!((after[n] - saturations[n] * 0.5).abs() < 0.05, "{what} has about half its saturation: {} → {}", saturations[n], after[n]); + } + assert!(close(fill(&s, red), Color::rgb(1.0, 0.5, 0.5)), "{:?}", fill(&s, red)); + assert!(close(fill(&s, pink), Color::rgb(1.0, 0.75, 0.75)), "each colour of a row changes on its own: {:?}", fill(&s, pink)); + assert!(matches!(fill(&s, cmyk), Color::Cmyk { .. }), "a CMYK colour stays CMYK: {:?}", fill(&s, cmyk)); + assert_eq!(fill(&s, kept).to_hex(), "#00ff00", "an excluded row keeps its colours"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(fill(&s, red).to_hex(), "#ff0000"); + assert_eq!(fill(&s, pink).to_hex(), "#ff8080"); + assert_eq!(stroke(&s, red).to_hex(), "#0000ff"); + // Brightness -100 makes every recolored color black; the rows' new colors are taken first. + run(&mut s, "recolor.apply", json!({"map": [{"from": ["#ff0000"], "to": "#00ff00"}], "globalAdjust": {"brightness": -100}})); + assert_eq!(fill(&s, red).to_hex(), "#000000"); + assert_eq!(fill(&s, pink).to_hex(), "#ff8080", "a colour no row holds stays"); +} + +/// Global Adjust at 0 changes nothing, a global swatch's link included, and a non-zero adjustment +/// gives the art the adjusted colour without the link. +#[test] +fn global_adjust_at_zero_keeps_colors_and_links() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2878f0", "global": true})); + let art = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [art.0], "swatch": "Brand"})); + select_all(&mut s); + let link = |s: &Session| match s.doc().unwrap().doc.node(art).unwrap().appearance.fill_paint() { + vectorcraft_color::Paint::Solid { swatch, .. } => swatch, + p => panic!("a solid fill: {p:?}"), + }; + let before = fill(&s, art); + let zero = json!({"saturation": 0, "brightness": 0, "temperature": 0, "luminosity": 0}); + run(&mut s, "recolor.apply", json!({"map": {"rgb 40 120 240": "rgb 40 120 240"}, "globalAdjust": zero})); + assert_eq!(fill(&s, art), before); + assert_eq!(link(&s).as_deref(), Some("Brand"), "the link stays"); + run(&mut s, "recolor.apply", json!({"map": {"rgb 40 120 240": "rgb 40 120 240"}, "globalAdjust": {"temperature": 50}})); + let [r, _, b] = fill(&s, art).to_rgb(); + let [r0, _, b0] = before.to_rgb(); + assert!(r > r0 && b < b0, "warmer: {:?}", fill(&s, art)); + assert_eq!(link(&s), None, "the adjusted colour is no longer the swatch's"); +} + +/// With Limit to Library each row's new colour takes the Global Adjust and then snaps to the +/// library; a colour group rewritten with the rows' new colours takes the adjustment too. +#[test] +fn global_adjust_with_a_library_and_a_group() { + let mut s = session(); + let art = rect(&mut s, json!("#f01020")); + select_all(&mut s); + run(&mut s, "recolor.apply", json!({"map": {"#f01020": "#f01020"}, "limitTo": "Web Safe 216", "globalAdjust": {"brightness": -100}})); + assert_eq!(fill(&s, art).to_hex(), "#000000", "black, a colour of the library"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["#f01020"]})); + run(&mut s, "recolor.apply", json!({"map": [{"from": ["#f01020"], "to": "#f01020"}], "group": "Theme", "globalAdjust": {"saturation": -100}})); + let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap().clone(); + let c = g.swatches[0].paint.color().unwrap(); + assert!(c.to_hsb()[1] < 1e-3, "the group's colour is adjusted: {c:?}"); + assert_eq!(c, fill(&s, art), "as the art is"); +} + +/// `globalAdjust` must be an object of numbers from -100 to 100; a refusal changes nothing and +/// records no undo step. A null counts as left out. +#[test] +fn global_adjust_refuses_params_of_the_wrong_form() { + let mut s = session(); + let art = rect(&mut s, json!("#ff0000")); + select_all(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + let map = json!({"#ff0000": "#ff0000"}); + for (adjust, msg) in [ + (json!(5), "`globalAdjust` must be an object"), + (json!({"saturation": "50"}), "`saturation` must be a number"), + (json!({"brightness": 101}), "`brightness` must be between -100 and 100 (got 101)"), + (json!({"luminosity": -100.5}), "`luminosity` must be between -100 and 100 (got -100.5)"), + ] { + let e = s.execute("recolor.apply", &json!({"map": map, "globalAdjust": adjust})).unwrap_err().to_string(); + assert!(e.contains(msg), "{adjust}: {e}"); + } + assert_eq!(fill(&s, art).to_hex(), "#ff0000", "nothing changed"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + run(&mut s, "recolor.apply", json!({"map": map, "globalAdjust": null})); + run(&mut s, "recolor.apply", json!({"map": map, "globalAdjust": {"saturation": null, "temperature": -100}})); + assert_ne!(fill(&s, art).to_hex(), "#ff0000", "an amount at the end of the range applies"); +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 76f3a15e8..1f319ca96 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -13,7 +13,9 @@ //! library file and limits to it; a library's colours apply as its swatches: a spot colour stays a //! spot colour, and joins the document's swatches). Edit tab: a hue and //! saturation wheel with a marker per new colour (drag one; with linked handles they all turn and -//! scale together), a brightness slider and the harmony rules. The preset menu sets up a 1, 2 or 3 +//! scale together), a brightness slider, the harmony rules, and the color sliders with their mode +//! menu, whose Global Adjust shows Saturation, Brightness, Temperature and Luminosity (-100% to +//! 100%, 0% changes nothing), which change every new color at once. 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 @@ -24,13 +26,15 @@ //! `preserveGrays`, `limitTo` (library id, "document" for the document's swatches, "" for none), //! `group`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`, //! `sliderMode` (`hsb`, `rgb`, `cmyk`, `lab` or `tint`; unset: the row color's own model, HSB for -//! a gray and Tint for a spot color). Changing `colors`, a preserve flag or `limitTo` reduces the -//! rows again. +//! a gray and Tint for a spot color; `global`: Global Adjust, on the Edit tab), `globalAdjust` +//! (`{saturation, brightness, temperature, luminosity}` in percent, sent as `recolor.apply`'s +//! `globalAdjust` in every slider mode). Changing `colors`, a preserve flag or `limitTo` reduces +//! the rows again. use egui::{Rect, Sense, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_color::harmony::{Harmony, move_on_wheel}; -use vectorcraft_color::recolor::{ColorKey, Method}; +use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method}; use vectorcraft_color::{Color, keep_model}; use vectorcraft_engine::cmd::color_value; use vectorcraft_engine::cmd::swatchlib; @@ -70,9 +74,18 @@ const NEW_CHIP: &str = "recolor-new"; /// The color modes of the sliders under the rows; a row whose new color is a spot color of the /// library also has Tint. const SLIDER_MODES: [Mode; 4] = [Mode::Hsb, Mode::Rgb, Mode::Cmyk, Mode::Lab]; -/// Widths of a color slider and of its number field. +/// Widths of a color slider's label, of the slider and of its number field. +const SLIDER_LABEL_W: f32 = 12.0; const SLIDER_W: f32 = 170.0; const SLIDER_FIELD_W: f32 = 48.0; +/// The `sliderMode` of the Edit tab's Global Adjust sliders, and the menu's name for it. +const GLOBAL: &str = "global"; +const GLOBAL_ADJUST: &str = "Global Adjust"; +/// The `globalAdjust` field's keys (and `recolor.apply`'s), in the order of the sliders. +const ADJUSTS: [&str; 4] = ["saturation", "brightness", "temperature", "luminosity"]; +/// Widths of a Global Adjust slider's label and of the slider. +const GLOBAL_LABEL_W: f32 = 76.0; +const GLOBAL_SLIDER_W: f32 = 110.0; /// The slider values last shown: `{row, key, mode, v}` (HSB keeps its hue at no saturation). const COMPS: &str = "__comps"; @@ -260,6 +273,11 @@ fn apply_params(d: &Dialog) -> Value { }); // A row edited with the sliders carries `model: "new"`: its art takes the mode they showed. let mut p = json!({"map": rs, "method": d.str("method"), "limitTo": d.str("limitTo")}); + // The Global Adjust amounts, kept while the sliders show another mode; at 0% they are left out. + let values = adjust_values(d); + if values.iter().any(|v| *v != 0.0) { + p["globalAdjust"] = Value::Object(ADJUSTS.iter().zip(values).map(|(k, v)| (k.to_string(), json!(v))).collect()); + } if let (Some(Value::Object(g)), Some(p)) = (group, p.as_object_mut()) { p.extend(g.into_iter().filter(|(_, v)| !v.is_null())); } @@ -288,7 +306,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.add_space(4.0); let mut rs = rows(d); let top = ui.cursor().top(); - let (changed, pick) = if d.str("tab") == "edit" { (edit_tab(ui, d, &mut rs), None) } else { assign_tab(app, ui, d, &mut rs) }; + let (changed, pick) = if d.str("tab") == "edit" { (edit_tab(app, ui, d, &mut rs), None) } else { assign_tab(app, ui, d, &mut rs) }; if d.str("tab") != "edit" { ui.add_space((TAB_H - (ui.cursor().top() - top)).max(0.0)); } @@ -335,7 +353,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut d = d.clone(); sync(app, &mut d); if d.bool("__newGroup") { - let colors: Vec = rows(&d).into_iter().filter(|r| !excluded(r)).map(|r| r["to"].clone()).collect(); + // The new colors with the Global Adjust, as `recolor.apply` gives them to a group. + let adjust = global_adjust(adjust_values(&d)); + 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| key(&adjust.apply(c)))) + .collect(); 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))?; @@ -529,7 +553,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut } } let target = SliderTarget::of(app, d, rows); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| mode_menu(ui, d, &target)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| mode_menu(ui, d, &target, false)); }); ui.add_space(6.0); changed |= sliders(app, ui, d, rows); @@ -686,30 +710,84 @@ impl SliderTarget { } } -/// The color mode menu of the sliders (HSB, RGB, CMYK, Lab, and Tint for a spot color), drawn -/// right to left: the menu, then its label. -fn mode_menu(ui: &mut Ui, d: &mut Dialog, target: &SliderTarget) { +/// The color mode menu of the sliders (HSB, RGB, CMYK, Lab, Tint for a spot color, and with +/// `global` Global Adjust), drawn right to left: the menu, then its label. +fn mode_menu(ui: &mut Ui, d: &mut Dialog, target: &SliderTarget, global: bool) { let t = Tokens::get(ui.ctx()); let mut labels: Vec<&str> = SLIDER_MODES.iter().map(|m| m.label()).collect(); if target.spot.is_some() { labels.push("Tint"); } - let current = target.mode(d).map_or("Tint", Mode::label); + if global { + labels.push(GLOBAL_ADJUST); + } + let adjusting = global && d.str("sliderMode") == GLOBAL; + let current = if adjusting { GLOBAL_ADJUST } else { target.mode(d).map_or("Tint", Mode::label) }; // The label is drawn after the menu (right-to-left), so it cannot name it by adjacency. let chosen = ui - .add_enabled_ui(target.color.is_some(), |ui| { - widgets::dropdown_named(ui, "recolor-slider-mode", Some(tl!("Color Mode:")), current, &labels, 90.0) + .add_enabled_ui(target.color.is_some() || global, |ui| { + widgets::dropdown_named(ui, "recolor-slider-mode", Some(tl!("Color Mode:")), current, &labels, if global { 110.0 } else { 90.0 }) }) .inner; if let Some(i) = chosen { - d.fields.insert("sliderMode".into(), json!(SLIDER_MODES.get(i).map_or_else(|| "tint".to_string(), |m| mode_id(*m)))); + let id = match SLIDER_MODES.get(i) { + Some(m) => mode_id(*m), + None if labels.get(i) == Some(&GLOBAL_ADJUST) => GLOBAL.to_string(), + None => "tint".to_string(), + }; + d.fields.insert("sliderMode".into(), json!(id)); } ui.label(egui::RichText::new(tl!("Color Mode:")).color(t.text_dim)); } -/// One color slider in the grid: its label, its rail with `track` and its number field, showing -/// `shown` with `suffix`. Returns the whole number in `shown`'s range that a drag, a key or the -/// field sets. +/// The Global Adjust amounts, in percent from -100 to 100, of `ADJUSTS`' keys in the +/// `globalAdjust` field (0 for one it lacks). +fn adjust_values(d: &Dialog) -> [f64; 4] { + let given = d.fields.get("globalAdjust"); + ADJUSTS.map(|k| given.and_then(|g| g.get(k)).and_then(Value::as_f64).unwrap_or(0.0).clamp(-100.0, 100.0)) +} + +/// The Global Adjust of `values` (percent, in the order of `ADJUSTS`). +fn global_adjust(values: [f64; 4]) -> GlobalAdjust { + let [saturation, brightness, temperature, luminosity] = values.map(|v| (v / 100.0) as f32); + GlobalAdjust { saturation, brightness, temperature, luminosity } +} + +/// The Global Adjust sliders, two to a line: Saturation, Brightness, Temperature and Luminosity, +/// each from -100% to 100% (0% changes nothing). They change every new color at once, so they +/// edit no row; each rail shows its amounts on `sample`. +fn global_sliders(ui: &mut Ui, d: &mut Dialog, sample: Color) { + let labels = [tl!("Saturation"), tl!("Brightness"), tl!("Temperature"), tl!("Luminosity")]; + let values = adjust_values(d); + let mut set: Option<(usize, f64)> = None; + egui::Grid::new("recolor-global-adjust").num_columns(6).spacing([6.0, 4.0]).show(ui, |ui| { + for (n, lbl) in labels.iter().enumerate() { + let shown = widgets::SliderValue::field(values[n], -100.0..=100.0, "%", 0); + // The rail: `sample` with this slider's amount alone. + let track = |x: f32| { + let mut one = [0.0; 4]; + one[n] = f64::from(x) * 200.0 - 100.0; + c32(&global_adjust(one).apply(sample)) + }; + if let Some(v) = slider_cell(ui, ("global", n), lbl, shown, "%", &track, GLOBAL_LABEL_W, GLOBAL_SLIDER_W) { + set = Some((n, v)); + } + if n % 2 == 1 { + ui.end_row(); + } + } + }); + if let Some((n, v)) = set { + let mut values = values; + values[n] = v; + d.fields.insert("globalAdjust".into(), Value::Object(ADJUSTS.iter().zip(values).map(|(k, v)| (k.to_string(), json!(v))).collect())); + } +} + +/// One color slider in the grid: its label `label_w` wide, its rail with `track` `slider_w` wide +/// and its number field, showing `shown` with `suffix`. Returns the whole number in `shown`'s +/// range that a drag, a key or the field sets. +#[allow(clippy::too_many_arguments)] fn slider_cell( ui: &mut Ui, id: impl std::hash::Hash + Copy, @@ -717,13 +795,15 @@ fn slider_cell( shown: widgets::SliderValue, suffix: &str, track: &dyn Fn(f32) -> egui::Color32, + label_w: f32, + slider_w: f32, ) -> Option { let t = Tokens::get(ui.ctx()); let (lo, hi) = (*shown.range.start(), *shown.range.end()); let value = shown.value; - ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(label).size(12.5).color(t.text))); + ui.add_sized(vec2(label_w, 22.0), egui::Label::new(egui::RichText::new(label).size(12.5).color(t.text))); let unit = ((value - lo) / (hi - lo)) as f32; - let (dragged, _, keyed) = widgets::color_slider_stepped(ui, ("recolor-slider", id), label, Some(shown), unit, SLIDER_W, track); + let (dragged, _, keyed) = widgets::color_slider_stepped(ui, ("recolor-slider", id), label, Some(shown), unit, slider_w, track); let typed = widgets::plain_field_named(ui, ("recolor-slider-field", id), Some(label), value, suffix, 0, SLIDER_FIELD_W); dragged.map(|x| (lo + f64::from(x) * (hi - lo)).round()).or(keyed).or(typed).map(|v| v.clamp(lo, hi)).filter(|v| (v - value).abs() > 1e-6) } @@ -746,7 +826,7 @@ fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value] let gray = move |_: f32| input; for (n, lbl) in mode.labels().iter().enumerate() { let shown = widgets::SliderValue::field(0.0, f64::from(mode.min(n))..=f64::from(mode.max(n)), mode.suffix(n), 0); - slider_cell(ui, n, lbl, shown, mode.suffix(n), &gray); + slider_cell(ui, n, lbl, shown, mode.suffix(n), &gray, SLIDER_LABEL_W, SLIDER_W); if n % 2 == 1 { ui.end_row(); } @@ -757,7 +837,7 @@ fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value] // Tint of the spot color, and its name. let tint = f64::from(row_tint(&rows[i])) * 100.0; let shown = widgets::SliderValue::field(tint, 0.0..=100.0, "%", 0); - if let Some(v) = slider_cell(ui, usize::MAX, "T", shown, "%", &|x| c32(&spot_tinted(app, c, x))) { + if let Some(v) = slider_cell(ui, usize::MAX, "T", shown, "%", &|x| c32(&spot_tinted(app, c, x)), SLIDER_LABEL_W, SLIDER_W) { rows[i]["tint"] = json!(v); changed = true; } @@ -769,7 +849,8 @@ fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value] for (n, lbl) in mode.labels().iter().enumerate() { let v = comps.get(n).copied().unwrap_or(0.0); let shown = widgets::SliderValue::field(f64::from(v), f64::from(mode.min(n))..=f64::from(mode.max(n)), mode.suffix(n), 0); - if let Some(nv) = slider_cell(ui, n, lbl, shown, mode.suffix(n), &|x| c32(&track_color(mode, &comps, n, x))) { + if let Some(nv) = slider_cell(ui, n, lbl, shown, mode.suffix(n), &|x| c32(&track_color(mode, &comps, n, x)), SLIDER_LABEL_W, SLIDER_W) + { let mut c2 = comps.clone(); c2[n] = nv as f32; set = Some(c2); @@ -845,13 +926,14 @@ fn move_color(rows: &mut [Value], i: usize, hsb: [f32; 3], linked: bool) { const DRAG: &str = "__drag"; /// The Edit tab. Returns true when the rows changed. -fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool { +fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool { let top = ui.cursor().top(); let t = Tokens::get(ui.ctx()); let mut changed = false; let linked = d.bool("linked"); let active = active_rows(rows); let base = selection(d).into_iter().find(|i| active.contains(i)).or(active.first().copied()); + let target = SliderTarget::of(app, d, rows); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Harmony Rules:")).color(t.text_dim)); let rule = Harmony::parse(&d.str("rule")).unwrap_or(Harmony::Complementary); @@ -870,6 +952,10 @@ fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool { if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() { d.fields.insert("linked".into(), json!(!linked)); } + 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. + mode_menu(ui, d, &target, true); }); }); ui.add_space(8.0); @@ -899,6 +985,12 @@ fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool { d.fields.remove(DRAG); } ui.add_space(6.0); + if d.str("sliderMode") == GLOBAL { + let sample = base.and_then(|b| to_color(&rows[b])).unwrap_or(Color::rgb(0.8, 0.45, 0.25)); + global_sliders(ui, d, sample); + } else { + changed |= sliders(app, ui, d, rows); + } // The new colours: click one to make it the base colour. let min_height = (TAB_H - (ui.cursor().top() - top)).max(0.0); super::modal::scrolled_rows(ui, "recolor-edit-colors", |ui| { @@ -957,6 +1049,33 @@ mod tests { assert!(apply_params(d).get("model").is_none()); } + /// `recolor.apply` gets the Global Adjust amounts only when one of them is not 0%, whatever + /// mode the sliders show. + #[test] + fn apply_params_send_global_adjust_only_when_it_changes_colors() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.recolorDialog", json!({"colors": ["#ff8000"]})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(apply_params(d).get("globalAdjust").is_none(), "no field"); + d.fields.insert("globalAdjust".into(), json!({"saturation": 0, "brightness": 0})); + assert!(apply_params(d).get("globalAdjust").is_none(), "all 0%"); + d.fields.insert("globalAdjust".into(), json!({"temperature": 25, "luminosity": 500})); + d.fields.insert("sliderMode".into(), json!("hsb")); + assert_eq!( + apply_params(d)["globalAdjust"], + json!({"saturation": 0.0, "brightness": 0.0, "temperature": 25.0, "luminosity": 100.0}), + "every amount, clamped to 100%" + ); + // Colors opened alone become a new color group with the adjustment. + d.fields.insert("globalAdjust".into(), json!({"saturation": -100})); + let d = d.clone(); + confirm(&mut app, &d).unwrap(); + let g = app.session.doc().unwrap().doc.swatch_groups.last().expect("a new color group"); + let c = g.swatches[0].paint.color().unwrap(); + assert!(c.to_hsb()[1] < 1e-3, "{c:?}"); + } + #[test] fn merge_and_separate_rows() { let mut r = rows(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f3f6cbfbb..9d353951b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1244,6 +1244,7 @@ font-technology Variable Променливи Get Preferences Извличане на предпочитания Get Text Range Вземане на текстов диапазон Global Глобален + Global Adjust Глобална корекция Global Brush Dimensions Глобални размери на четката Global Process Color Глобален процесен цвят Global Process Color, CMYK Глобален процесен цвят, CMYK diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d7efa9c0e..8951eb226 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2138,6 +2138,7 @@ digits Malayalam Malajalam Color Mode: Način boja: Convert Pretvori Global Globalno + Global Adjust Globalno podešavanje Green Zelena Shifts the tints of global and spot colors; other colors stay. Pomjera nijanse globalnih i spot boja; ostale boje ostaju. Tint Preliv @@ -4260,6 +4261,7 @@ imageTrace Outline Obris @msg `library` is a library id or name (see {list}) `library` je ID ili naziv biblioteke (vidi {list}) @msg `mode` applies to freeform gradients `mode` se primjenjuje na gradijente slobodnog oblika @msg `preserve` must be an object `preserve` mora biti objekat +@msg `globalAdjust` must be an object `globalAdjust` mora biti objekat @msg `sampleSize` must be 1, 3 or 5 `sampleSize` mora biti 1, 3 ili 5 @msg `stops` must be an array `stops` mora biti niz @msg `svg` must be an object of SVG options (see document.formats) `svg` mora biti objekat SVG opcija (vidi document.formats) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6172d6a..08bf73630 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1699,6 +1699,7 @@ digits Malayalam Malaiàlam Glass Vidre Glass… Vidre… Global Global + Global Adjust Ajust global Global Brush Dimensions Dimensions globals del pinzell Global Edit Options Opcions globals d’edició Global Process Color Color de procés global @@ -4776,6 +4777,7 @@ digits Malayalam Malaiàlam @msg `newName` is empty `newName` és buit @msg `pathfinder` must be an object `pathfinder` ha de ser un objecte @msg `preserve` must be an object `preserve` ha de ser un objecte +@msg `globalAdjust` must be an object `globalAdjust` ha de ser un objecte @msg `sampleSize` must be 1, 3 or 5 `sampleSize` ha de ser 1, 3 o 5 @msg `select` must be true or false `select` ha de ser cert o fals @msg `stops` must be an array `stops` ha de ser una matriu diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 34c7f4e03..55f2c26ec 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2166,6 +2166,7 @@ digits Malayalam Malajálamské Color Mode: Barevný režim: Convert Převést Global Globální + Global Adjust Globální úprava Green Zelená Shifts the tints of global and spot colors; other colors stay. Posune tóny globálních a přímých barev; ostatní barvy zůstanou. Tint Tón @@ -4299,6 +4300,7 @@ imageTrace Outline Obrys @msg `library` is a library id or name (see {list}) `library` je ID nebo název knihovny (viz {list}) @msg `mode` applies to freeform gradients `mode` platí pro volné přechody @msg `preserve` must be an object `preserve` musí být objekt +@msg `globalAdjust` must be an object `globalAdjust` musí být objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musí být 1, 3 nebo 5 @msg `stops` must be an array `stops` musí být pole @msg `svg` must be an object of SVG options (see document.formats) `svg` musí být objekt s možnostmi SVG (viz document.formats) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 5a2391342..f5f717ed5 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1969,6 +1969,7 @@ digits Malayalam Malayalam Color Mode: Farvetilstand: Convert Konvertér Global Global + Global Adjust Global justering Green Grøn Shifts the tints of global and spot colors; other colors stay. Flytter nuancerne af globale farver og spotfarver; andre farver forbliver uændrede. Tint Nuance diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 101bbefac..831a355b2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1268,6 +1268,7 @@ file-filter Templates Vorlagen Get Preferences Voreinstellungen abrufen Get Text Range Textbereich abrufen Global Global + Global Adjust Globale Anpassung Global Brush Dimensions Globale Pinselabmessungen Global Process Color Globale Prozessfarbe Global Process Color, CMYK Globale Prozessfarbe, CMYK @@ -3906,6 +3907,7 @@ theme Appearance Erscheinungsbild @msg `library` is a library id or name (see {list}) `library` ist die ID oder der Name einer Bibliothek (siehe {list}) @msg `mode` applies to freeform gradients `mode` gilt für Freiformverläufe @msg `preserve` must be an object `preserve` muss ein Objekt sein +@msg `globalAdjust` must be an object `globalAdjust` muss ein Objekt sein @msg `sampleSize` must be 1, 3 or 5 `sampleSize` muss 1, 3 oder 5 sein @msg `stops` must be an array `stops` muss ein Array sein @msg `svg` must be an object of SVG options (see document.formats) `svg` muss ein Objekt mit SVG-Optionen sein (siehe document.formats) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e704faade..0c1adf7cf 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1208,6 +1208,7 @@ font-technology Variable Μεταβλητές Get Preferences Λήψη προτιμήσεων Get Text Range Λήψη περιοχής κειμένου Global Καθολικό + Global Adjust Καθολική ρύθμιση Global Brush Dimensions Καθολικές διαστάσεις πινέλου Global Process Color Καθολικό χρώμα τετραχρωμίας Global Process Color, CMYK Καθολικό χρώμα τετραχρωμίας, CMYK @@ -2208,6 +2209,7 @@ axis Both Και τα δύο @msg `library` is a library id or name (see {list}) Το `library` είναι αναγνωριστικό ή όνομα βιβλιοθήκης (δείτε {list}) @msg `mode` applies to freeform gradients Το `mode` ισχύει για ντεγκραντέ ελεύθερης μορφής @msg `preserve` must be an object Το `preserve` πρέπει να είναι αντικείμενο +@msg `globalAdjust` must be an object Το `globalAdjust` πρέπει να είναι αντικείμενο @msg `sampleSize` must be 1, 3 or 5 Το `sampleSize` πρέπει να είναι 1, 3 ή 5 @msg `stops` must be an array Το `stops` πρέπει να είναι πίνακας @msg `svg` must be an object of SVG options (see document.formats) Το `svg` πρέπει να είναι αντικείμενο επιλογών SVG (δείτε document.formats) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4ebddbe9a..a93fd5d22 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1287,6 +1287,7 @@ font-technology Variable Variables Get Preferences Obtener preferencias Get Text Range Obtener rango de texto Global Global + Global Adjust Ajuste global Global Brush Dimensions Dimensiones globales de pincel Global Process Color Color de cuatricromía global Global Process Color, CMYK Color de cuatricromía global, CMYK @@ -3992,6 +3993,7 @@ axis Both Ambos @msg `library` is a library id or name (see {list}) `library` es el id o el nombre de una biblioteca (consulta {list}) @msg `mode` applies to freeform gradients `mode` se aplica a los degradados de forma libre @msg `preserve` must be an object `preserve` debe ser un objeto +@msg `globalAdjust` must be an object `globalAdjust` debe ser un objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5 @msg `stops` must be an array `stops` debe ser una lista @msg `svg` must be an object of SVG options (see document.formats) `svg` debe ser un objeto con opciones de SVG (consulta document.formats) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7042b21f2..f5c916709 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2125,6 +2125,7 @@ digits Malayalam Malajalam Color Mode: Väritila: Convert Muunna Global Globaali + Global Adjust Yleissäätö Green Vihreä Shifts the tints of global and spot colors; other colors stay. Siirtää globaalien värien ja spottivärien sävyjä; muut värit säilyvät. Tint Sävy @@ -4213,6 +4214,7 @@ imageTrace Outline Ääriviiva @msg `library` is a library id or name (see {list}) `library` on kirjaston tunnus tai nimi (ks. {list}) @msg `mode` applies to freeform gradients `mode` koskee vapaamuotoisia liukuvärejä @msg `preserve` must be an object `preserve` on oltava objekti +@msg `globalAdjust` must be an object `globalAdjust` on oltava objekti @msg `sampleSize` must be 1, 3 or 5 `sampleSize` on oltava 1, 3 tai 5 @msg `stops` must be an array `stops` on oltava taulukko @msg `svg` must be an object of SVG options (see document.formats) `svg` on oltava SVG-asetusten objekti (ks. document.formats) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 48eb621eb..d590de258 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1281,6 +1281,7 @@ font-technology Variable Variables Get Preferences Obtenir les préférences Get Text Range Obtenir une plage de texte Global Globale + Global Adjust Réglage global Global Brush Dimensions Dimensions globales des formes Global Process Color Couleur quadrichromique globale Global Process Color, CMYK Couleur quadrichromique globale, CMJN @@ -3982,6 +3983,7 @@ axis Both Les deux @msg `library` is a library id or name (see {list}) `library` est l’identifiant ou le nom d’une bibliothèque (voir {list}) @msg `mode` applies to freeform gradients `mode` s’applique aux dégradés de forme libre @msg `preserve` must be an object `preserve` doit être un objet +@msg `globalAdjust` must be an object `globalAdjust` doit être un objet @msg `sampleSize` must be 1, 3 or 5 `sampleSize` doit valoir 1, 3 ou 5 @msg `stops` must be an array `stops` doit être un tableau @msg `svg` must be an object of SVG options (see document.formats) `svg` doit être un objet d’options SVG (voir document.formats) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 81f948263..23d51e8ea 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1602,6 +1602,7 @@ file-filter Templates Sablonok Glass Üveg Glass… Üveg… Global Globális + Global Adjust Globális beállítás Global Brush Dimensions Globális ecsetméretek Global Process Color Globális skálaszín Global Process Color, CMYK Globális skálaszín, CMYK @@ -4521,6 +4522,7 @@ imageTrace Outline Körvonal @msg `library` is a library id or name (see {list}) a `library` könyvtárazonosító vagy -név lehet (lásd: {list}) @msg `mode` applies to freeform gradients a `mode` a szabad formájú színátmenetekre vonatkozik @msg `preserve` must be an object `preserve`: objektum kell +@msg `globalAdjust` must be an object `globalAdjust`: objektum kell @msg `sampleSize` must be 1, 3 or 5 `sampleSize`: 1, 3 vagy 5 kell @msg `stops` must be an array `stops`: tömb kell @msg `svg` must be an object of SVG options (see document.formats) `svg`: SVG-beállításokat tartalmazó objektum kell (lásd: document.formats) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 89633ae99..ce1ce4436 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1969,6 +1969,7 @@ digits Malayalam Malayalam Color Mode: Mode warna: Convert Konversi Global Global + Global Adjust Penyesuaian global Green Hijau Shifts the tints of global and spot colors; other colors stay. Menggeser tint warna global dan warna spot; warna lainnya tetap. Tint Tint diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6acfaf3c9..33b2c9779 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1298,6 +1298,7 @@ file-filter Templates Modelli Get Preferences Ottieni preferenze Get Text Range Ottieni intervallo di testo Global Globale + Global Adjust Regolazione globale Global Brush Dimensions Dimensioni pennello globali Global Process Color Colore quadricromia globale Global Process Color, CMYK Colore quadricromia globale, CMYK @@ -4033,6 +4034,7 @@ axis Both Entrambi @msg `library` is a library id or name (see {list}) `library` è l'id o il nome di una libreria (vedi {list}) @msg `mode` applies to freeform gradients `mode` si applica alle sfumature a forma libera @msg `preserve` must be an object `preserve` deve essere un oggetto +@msg `globalAdjust` must be an object `globalAdjust` deve essere un oggetto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve essere 1, 3 o 5 @msg `stops` must be an array `stops` deve essere un array @msg `svg` must be an object of SVG options (see document.formats) `svg` deve essere un oggetto di opzioni SVG (vedi document.formats) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4bf3cd6bd..18b45acef 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1662,6 +1662,7 @@ digits Malayalam マラヤーラム数字 Get Preferences 環境設定を取得 Get Text Range テキスト範囲を取得 Global グローバル + Global Adjust グローバル調整 Global Brush Dimensions ブラシサイズ (共通) Global Process Color グローバルプロセスカラー Go To Link リンク元へ移動 @@ -4149,6 +4150,7 @@ swatch Bright Violet ビビッドバイオレット @msg `groupColors` must be an array `groupColors` には配列を指定してください @msg `mode` applies to freeform gradients `mode` はフリーフォームグラデーションにのみ適用されます @msg `preserve` must be an object `preserve` にはオブジェクトを指定してください +@msg `globalAdjust` must be an object `globalAdjust` にはオブジェクトを指定してください @msg `stops` must be an array `stops` には配列を指定してください @msg `{k}` must be [x, y] `{k}` は [x, y] の形式で指定してください @msg a gradient needs at least two stops グラデーションには 2 つ以上の分岐点が必要です diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index cf411b072..437a7f132 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1870,6 +1870,7 @@ digits Malayalam მალაიალამური Color Mode: ფერის რეჟიმი: Convert გარდაქმნა Global გლობალური + Global Adjust გლობალური კორექცია Green მწვანე Shifts the tints of global and spot colors; other colors stay. ცვლის გლობალური და სპოტ-ფერების ტონირებებს; სხვა ფერები უცვლელი რჩება. Tint ტონირება @@ -3778,6 +3779,7 @@ font-technology Variable ცვლადი @msg `library` is a library id or name (see {list}) `library` არის ბიბლიოთეკის id ან სახელი (იხ. {list}) @msg `mode` applies to freeform gradients `mode` ეხება თავისუფალი ფორმის გრადიენტებს @msg `preserve` must be an object `preserve` უნდა იყოს ობიექტი +@msg `globalAdjust` must be an object `globalAdjust` უნდა იყოს ობიექტი @msg `sampleSize` must be 1, 3 or 5 `sampleSize` უნდა იყოს 1, 3 ან 5 @msg `stops` must be an array `stops` უნდა იყოს მასივი @msg `svg` must be an object of SVG options (see document.formats) `svg` უნდა იყოს SVG-ის პარამეტრების ობიექტი (იხ. document.formats) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 81f6c600c..ac3d41d18 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1652,6 +1652,7 @@ digits Malayalam 말라얄람어 Get Preferences 환경 설정 가져오기 Get Text Range 텍스트 범위 가져오기 Global 전역 + Global Adjust 전체 조정 Global Brush Dimensions 전역 브러시 치수 Global Process Color 전역 프로세스 색상 Go To Link 링크로 이동 @@ -4076,6 +4077,7 @@ theme Appearance 외관 @msg `groupColors` must be an array `groupColors`는 배열이어야 합니다 @msg `mode` applies to freeform gradients `mode`는 자유형 그레이디언트에 적용됩니다 @msg `preserve` must be an object `preserve`는 개체여야 합니다 +@msg `globalAdjust` must be an object `globalAdjust`는 개체여야 합니다 @msg `stops` must be an array `stops`는 배열이어야 합니다 @msg `{k}` must be [x, y] `{k}`은(는) [x, y]여야 합니다 @msg a gradient needs at least two stops 그레이디언트에는 정지점이 두 개 이상 필요합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 344853683..8bdbcaa5f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1983,6 +1983,7 @@ digits Malayalam Malayalam Color Mode: Kleurmodus: Convert Converteren Global Globaal + Global Adjust Globale aanpassing Green Groen Shifts the tints of global and spot colors; other colors stay. Verschuift de tinten van globale kleuren en spotkleuren; andere kleuren blijven ongewijzigd. Tint Tint diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 14825f83c..c5530da3f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2083,6 +2083,7 @@ digits Malayalam Malajalam Color Mode: Tryb koloru: Convert Konwertuj Global Globalny + Global Adjust Dopasowanie globalne Green Zielony Shifts the tints of global and spot colors; other colors stay. Przesuwa tinty kolorów globalnych i dodatkowych; pozostałe kolory bez zmian. Tint Tinta @@ -4122,6 +4123,7 @@ imageTrace Outline Kontur @msg `library` is a library id or name (see {list}) `library` to identyfikator lub nazwa biblioteki (patrz {list}) @msg `mode` applies to freeform gradients `mode` dotyczy gradientów dowolnych @msg `preserve` must be an object `preserve` musi być obiektem +@msg `globalAdjust` must be an object `globalAdjust` musi być obiektem @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musi wynosić 1, 3 lub 5 @msg `stops` must be an array `stops` musi być tablicą @msg `svg` must be an object of SVG options (see document.formats) `svg` musi być obiektem opcji SVG (patrz document.formats) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 96a31c3e5..d134a0be7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1134,6 +1134,7 @@ Get Preferences Obter Preferências Get Text Range Obter Intervalo de Texto Global Global + Global Adjust Ajuste Global Global Brush Dimensions Dimensões Globais do Pincel Global Process Color Cor Global de Processo Glyphs Glifos @@ -3574,6 +3575,7 @@ axis Both Ambos @msg `library` is a library id or name (see {list}) `library` é o id ou o nome de uma biblioteca (veja {list}) @msg `mode` applies to freeform gradients `mode` se aplica a gradientes de forma livre @msg `preserve` must be an object `preserve` deve ser um objeto +@msg `globalAdjust` must be an object `globalAdjust` deve ser um objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve ser 1, 3 ou 5 @msg `stops` must be an array `stops` deve ser uma matriz @msg `svg` must be an object of SVG options (see document.formats) `svg` deve ser um objeto com opções de SVG (veja document.formats) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fdb51b80d..9162379d9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1295,6 +1295,7 @@ file-filter Templates Шаблоны Get Preferences Получить настройки Get Text Range Получить диапазон текста Global Глобальный + Global Adjust Глобальная настройка Global Brush Dimensions Глобальные размеры кисти Global Process Color Глобальный составной цвет Global Process Color, CMYK Глобальный составной цвет, CMYK @@ -4028,6 +4029,7 @@ axis Both Оба @msg `library` is a library id or name (see {list}) `library` — это идентификатор или имя библиотеки (см. {list}) @msg `mode` applies to freeform gradients `mode` применяется к произвольным градиентам @msg `preserve` must be an object `preserve` должно быть объектом +@msg `globalAdjust` must be an object `globalAdjust` должно быть объектом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` должно быть 1, 3 или 5 @msg `stops` must be an array `stops` должно быть массивом @msg `svg` must be an object of SVG options (see document.formats) `svg` должно быть объектом параметров SVG (см. document.formats) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e2dddf022..3d57ae9ad 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1246,6 +1246,7 @@ font-technology Variable Variabla Get Preferences Hämta inställningar Get Text Range Hämta textintervall Global Global + Global Adjust Global justering Global Brush Dimensions Globala penseldimensioner Global Process Color Global processfärg Global Process Color, CMYK Global processfärg, CMYK @@ -3903,6 +3904,7 @@ axis Both Båda @msg `library` is a library id or name (see {list}) `library` är ett biblioteks-id eller biblioteksnamn (se {list}) @msg `mode` applies to freeform gradients `mode` gäller för fria gradienter @msg `preserve` must be an object `preserve` måste vara ett objekt +@msg `globalAdjust` must be an object `globalAdjust` måste vara ett objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` måste vara 1, 3 eller 5 @msg `stops` must be an array `stops` måste vara en array @msg `svg` must be an object of SVG options (see document.formats) `svg` måste vara ett objekt med SVG-alternativ (se document.formats) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9ecf2ccf9..d547b2b35 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1224,6 +1224,7 @@ font-technology Variable Değişken Get Preferences Tercihleri Al Get Text Range Metin Aralığını Al Global Küresel + Global Adjust Genel Ayarlama Global Brush Dimensions Küresel Fırça Boyutları Global Process Color Global Proses Rengi Global Process Color, CMYK Küresel Proses Rengi, CMYK @@ -3786,6 +3787,7 @@ axis Both Her İkisi @msg `library` is a library id or name (see {list}) 'kitaplık' bir kitaplık kimliği veya adıdır (bkz. {list}) @msg `mode` applies to freeform gradients "mod" serbest biçimli degradeler için geçerlidir @msg `preserve` must be an object `preserve` bir nesne olmalıdır +@msg `globalAdjust` must be an object `globalAdjust` bir nesne olmalıdır @msg `sampleSize` must be 1, 3 or 5 "sampleSize" 1, 3 veya 5 olmalıdır @msg `stops` must be an array "duraklar" bir dizi olmalıdır @msg `svg` must be an object of SVG options (see document.formats) `svg`, SVG seçeneklerinin bir nesnesi olmalıdır (bkz. document.formats) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d348440a2..18247cb53 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1292,6 +1292,7 @@ file-filter Templates Шаблони Get Preferences Отримати налаштування Get Text Range Отримати діапазон тексту Global Глобальний + Global Adjust Глобальне налаштування Global Brush Dimensions Глобальні розміри пензля Global Process Color Глобальний складовий колір Global Process Color, CMYK Глобальний складовий колір, CMYK @@ -4017,6 +4018,7 @@ axis Both Обидві @msg `library` is a library id or name (see {list}) `library` — це ідентифікатор або ім’я бібліотеки (див. {list}) @msg `mode` applies to freeform gradients `mode` застосовується до довільних градієнтів @msg `preserve` must be an object `preserve` має бути об’єктом +@msg `globalAdjust` must be an object `globalAdjust` має бути об’єктом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` має бути 1, 3 або 5 @msg `stops` must be an array `stops` має бути масивом @msg `svg` must be an object of SVG options (see document.formats) `svg` має бути об’єктом параметрів SVG (див. document.formats) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 09652ede2..9ee468d56 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1503,6 +1503,7 @@ file-filter Templates Mẫu Glass Kính Glass… Kính… Global Toàn cục + Global Adjust Điều chỉnh toàn cục Global Brush Dimensions Kích thước cọ toàn cục Global Process Color Màu xử lý toàn cục Global Process Color, CMYK Màu xử lý toàn cục, CMYK @@ -4173,6 +4174,7 @@ theme Appearance Giao diện @msg `library` is a library id or name (see {list}) `library` là id hoặc tên thư viện (xem {list}) @msg `mode` applies to freeform gradients `mode` áp dụng cho chuyển sắc tự do @msg `preserve` must be an object `preserve` phải là một đối tượng +@msg `globalAdjust` must be an object `globalAdjust` phải là một đối tượng @msg `sampleSize` must be 1, 3 or 5 `sampleSize` phải là 1, 3 hoặc 5 @msg `stops` must be an array `stops` phải là một mảng @msg `svg` must be an object of SVG options (see document.formats) `svg` phải là đối tượng tùy chọn SVG (xem document.formats) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 841bd4528..2e6b58886 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1236,6 +1236,7 @@ file-filter Templates 模板 Get Preferences 获取首选项 Get Text Range 获取文本范围 Global 全局 + Global Adjust 全局调整 Global Brush Dimensions 全局画笔尺寸 Global Process Color 全局印刷色 Global Process Color, CMYK 全局印刷色,CMYK @@ -3878,6 +3879,7 @@ imageTrace Outline 轮廓 @msg `groupColors` must be an array `groupColors` 必须是数组 @msg `mode` applies to freeform gradients `mode` 仅适用于任意形状渐变 @msg `preserve` must be an object `preserve` 必须是对象 +@msg `globalAdjust` must be an object `globalAdjust` 必须是对象 @msg `stops` must be an array `stops` 必须是数组 @msg `{k}` must be [x, y] `{k}` 必须是 [x, y] @msg a gradient needs at least two stops 渐变至少需要两个色标 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a515d59a7..9f2adef1c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1262,6 +1262,7 @@ file-filter Templates 範本 Get Preferences 取得偏好設定 Get Text Range 取得文字範圍 Global 全域 + Global Adjust 整體調整 Global Brush Dimensions 整體筆刷尺寸 Global Process Color 整體印刷色 Global Process Color, CMYK 整體印刷色,CMYK @@ -1801,6 +1802,7 @@ file-filter Templates 範本 @msg `groupColors` must be an array `groupColors` 必須是陣列 @msg `mode` applies to freeform gradients `mode` 只適用於任意形狀漸層 @msg `preserve` must be an object `preserve` 必須是物件 +@msg `globalAdjust` must be an object `globalAdjust` 必須是物件 @msg `stops` must be an array `stops` 必須是陣列 @msg `{k}` must be [x, y] `{k}` 必須是 [x, y] @msg a gradient needs at least two stops 漸層至少需要兩個色標 diff --git a/crates/ui-egui/src/tests/recolor.rs b/crates/ui-egui/src/tests/recolor.rs index 7c2046718..1786b7a61 100644 --- a/crates/ui-egui/src/tests/recolor.rs +++ b/crates/ui-egui/src/tests/recolor.rs @@ -572,11 +572,76 @@ fn switching_tabs_or_slider_modes_keeps_the_dialog_height() { h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "recolor")))).unwrap().height() }; let assign = height(&mut h, "sliderMode", json!("cmyk")); - for (k, v) in [("sliderMode", json!("hsb")), ("sliderMode", json!("lab")), ("tab", json!("edit")), ("tab", json!("assign"))] { + for (k, v) in [ + ("sliderMode", json!("hsb")), + ("sliderMode", json!("lab")), + ("tab", json!("edit")), + ("sliderMode", json!("global")), + ("sliderMode", json!("rgb")), + ("tab", json!("assign")), + ] { assert_eq!(height(&mut h, k, v.clone()), assign, "{k} {v}"); } } +/// The fills of the document's objects, in order. +fn fills(app: &VectorcraftApp) -> Vec> { + let d = &app.session.doc().unwrap().doc; + d.layers[0].children().unwrap().iter().map(|n| d.node(n.id).unwrap().appearance.fill_paint().color()).collect() +} + +/// The Edit tab's color mode menu offers Global Adjust: Saturation, Brightness, Temperature and +/// Luminosity open at 0%, which changes nothing; one slider changes every recolored color at once +/// (the rows keep their new colors) and the art previews it; back at 0% the art has its own colors +/// again; the amounts stay while the tab or the mode changes; OK keeps the change as one undo step +/// and Cancel leaves the art as it was. +#[test] +fn global_adjust_changes_every_color_at_once() { + for confirm in [true, false] { + let mut h = edit_harness(&["#ff0000", "#0000ff", "#00ff00"]); + let before = fills(h.state()); + let undo = h.state().session.doc().unwrap().history.undo.len(); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Color Mode:").click(); + h.run_steps(2); + h.get_by_label("Global Adjust").click(); + h.run_steps(3); + assert_eq!(field(h.state(), "sliderMode"), json!("global")); + for name in ["Saturation", "Temperature", "Luminosity"] { + assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, name).is_some(), "{name}"); + } + // Global Adjust's Brightness and the wheel's. + assert_eq!(h.get_all_by_role_and_label(egui::accesskit::Role::Slider, "Brightness").count(), 2); + assert_eq!(fills(h.state()), before, "at 0% the art keeps its colors"); + let rows = field(h.state(), "rows"); + click_slider(&mut h, "Saturation", 0.0); + assert_eq!(field(h.state(), "globalAdjust")["saturation"], json!(-100.0)); + assert_eq!(field(h.state(), "rows"), rows, "the rows keep their new colors"); + let gray = |app: &VectorcraftApp| fills(app).iter().all(|c| c.is_some_and(|c| c.to_hsb()[1] < 1e-3)); + assert!(gray(h.state()), "every color previews gray: {:?}", fills(h.state())); + click_slider(&mut h, "Saturation", 0.5); + assert_eq!(field(h.state(), "globalAdjust")["saturation"], json!(0.0)); + assert_eq!(fills(h.state()), before, "back at 0% the art has its own colors"); + click_slider(&mut h, "Saturation", 0.0); + for (k, v) in [("tab", json!("assign")), ("tab", json!("edit")), ("sliderMode", json!("hsb")), ("sliderMode", json!("global"))] { + set(h.state_mut(), k, v); + h.run_steps(3); + } + assert_eq!(field(h.state(), "globalAdjust")["saturation"], json!(-100.0), "the amount stays"); + assert!(gray(h.state()), "and still applies"); + h.get_by_label(if confirm { "OK" } else { "Cancel" }).click(); + h.run_steps(2); + assert!(h.state().ui.dialog.is_none()); + let app = h.state(); + if confirm { + assert!(gray(app), "OK keeps the change: {:?}", fills(app)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + } else { + assert_eq!(fills(app), before, "Cancel leaves the art unchanged"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "and records no undo step"); + } + } +} + /// The Assign tab's per-row hex fields are named by their row, not all "Color": a screen reader can /// tell the second from the first. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8f0943230..ad3a44d50 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -259,7 +259,12 @@ as `recolor.reduce` returns them), `colors` (null for Auto), `method`, `preserve `sliderMode` (the mode of the color sliders under the rows: `hsb`, `rgb`, `cmyk`, `lab`, or `tint` for a row whose new color is a spot color of the library; unset, they show the new color's own model). The sliders edit the first selected row's new color and give that row `model: "new"`, so the preview and confirm give only that row's art the color in -the sliders' mode; another edit of the row's new color, Reset and rows reduced again remove it. Setting +the sliders' mode; another edit of the row's new color, Reset and rows reduced again remove it. The Edit tab shows the +same sliders under the wheel, and its mode menu also has Global Adjust (`sliderMode: "global"`): Saturation, +Brightness, Temperature and Luminosity sliders that set `globalAdjust` (`{saturation, brightness, temperature, +luminosity}`, percent from -100 to 100, 0 by default). The dialog sends a non-zero `globalAdjust` as `recolor.apply`'s +`globalAdjust` in every slider mode, so one slider changes every recolored color at once and the rows keep their new +colors; colors opened alone are saved as a group with the change. Setting `colors`, a preserve flag or `limitTo` reduces the rows again (also on `ui.dialog.confirm`). The dialog previews `recolor.apply` on the canvas; confirm keeps it as one undo step (with a group, the group is rewritten in the same step; with no art selected only the group changes). In the Swatches panel, double-clicking a colour group's folder, diff --git a/docs/mcp.md b/docs/mcp.md index f1e55b9fd..92d031ba5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1929,7 +1929,7 @@ the objects `select.all` takes, which leaves out hidden and locked art, and the and locked objects inside a group are left out too, also in isolation mode. `recolor.reduce {colors?, method?, preserve?, limitTo?, ids?}` groups those colours into rows of similar colours (k-means in Lab, weighted by use; tints of a global swatch share its row; White and Black are preserved by default) and returns a map of rows -`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, model?, limitTo?, group?, groupColors?, rename?}`. +`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, model?, limitTo?, group?, groupColors?, rename?, globalAdjust?}`. For scripts and MCP, `model` selects the replacement color model: `keep` (default) retains each original color's model, `new` takes the replacement's model, and `document` converts replacements and edited color groups to the document's model. A row's own `model` (the same values) takes the @@ -1953,11 +1953,26 @@ is linked to a swatch rewritten with another color keeps its row's new color, ev linked to the swatch, as does art whose color the row converts into the art's own color model. Without `group`, the swatch keeps its color and the recolored art is no longer linked to it. +`globalAdjust: {saturation?, brightness?, temperature?, luminosity?}` is the dialog's Global Adjust: each amount is a +percentage from -100 to 100 (0 when left out), and all four at 0 change nothing. Every color that a row which is not +excluded recolors takes the same change after its row's new color, in this order: saturation multiplies HSB +saturation by 1 + saturation/100; brightness moves HSB brightness that share of the way to full brightness (above 0) +or to black (below 0); temperature adds 0.2 × temperature/100 to red and subtracts it from blue; luminosity moves Lab +lightness that share of the way to 100 or to 0 and keeps Lab a and b, so the hue stays. Each color keeps its own +model, and a changed color is no longer linked to its swatch. The colors a `group` is rewritten with take the same +change. With `limitTo`, each row's new color takes the change before it snaps to the library, so the art still takes +the library's colors. Colors that no row holds, such as preserved White and Black, and excluded rows stay as they are. + +```json +{"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":{"#ff0000":"#ff0000","#0000ff":"#0000ff"},"globalAdjust":{"saturation":-40,"temperature":25}}}} +``` + 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 `grays` switches of `preserve` must be true or false; `colors` and `seed` must be whole numbers; `group` and `rename` -must be strings, `groupColors` must be an array and `preserve` must be an object. Each refusal leaves the document unchanged and records no undo step. A `null` +must be strings, `groupColors` must be an array, `preserve` must be an object, and `globalAdjust` must be an object +whose amounts are numbers from -100 to 100. Each refusal leaves the document unchanged and records no undo step. A `null` counts as a value left out except for `recolor.apply`'s `model` and a `map` row's `model`, which must be strings when given. ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 40e1d1267..3a9260f93 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -123,7 +123,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939); the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies and excluding a match with Shift-click, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) | | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, and PDF gradients mixing a spot color with RGB or Lab stops | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, and PDF gradients mixing a spot color with RGB or Lab stops | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |