mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 09:59:47 -06:00
Recolor Artwork: color sliders edit the selected row's new color in a chosen mode
The Assign tab gets a Color Mode menu (HSB, RGB, CMYK, Lab, and Tint when the row's new color is a spot color of the Limit to Library library) and its sliders under the rows. They edit the first selected row's new color and are disabled with no row selected. Once they have edited a color in a mode, the preview and OK send recolor.apply with model "new", so the art takes the color in that mode. recolor.apply rows take an optional tint (percent, default 100): art linked to the row's global or spot library swatch takes that share of its tint. recolor.randomize keeps a row's tint.
This commit is contained in:
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Recolor Artwork",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
|
||||
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -260,6 +260,9 @@ struct Row {
|
||||
from: Vec<Match>,
|
||||
to: Option<Color>,
|
||||
exclude: bool,
|
||||
/// The tint (0..1) of a global or spot swatch the new color is (with `limitTo`): art linked to
|
||||
/// the swatch takes this share of the tint the method gives it.
|
||||
tint: f32,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
@@ -280,7 +283,9 @@ fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
|
||||
};
|
||||
let cur = |v: &Value| Match::of(v).ok_or_else(|| bad(cmd, format!("bad current colour {v}")));
|
||||
match p.get("map") {
|
||||
Some(Value::Object(m)) => m.iter().map(|(k, v)| Ok(Row { from: vec![cur(&json!(k))?], to: new(Some(v))?, exclude: false })).collect(),
|
||||
Some(Value::Object(m)) => {
|
||||
m.iter().map(|(k, v)| Ok(Row { from: vec![cur(&json!(k))?], to: new(Some(v))?, exclude: false, tint: 1.0 })).collect()
|
||||
}
|
||||
Some(Value::Array(rows)) => rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
@@ -289,7 +294,8 @@ fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
|
||||
Some(v) => vec![cur(v)?],
|
||||
None => return Err(bad(cmd, "each row needs `from`")),
|
||||
};
|
||||
Ok(Row { from, to: new(r.get("to"))?, exclude: switch_or(r, "exclude", false, cmd)? })
|
||||
let tint = (super::number_or(r, "tint", 100.0, cmd)? / 100.0).clamp(0.0, 1.0) as f32;
|
||||
Ok(Row { from, to: new(r.get("to"))?, exclude: switch_or(r, "exclude", false, cmd)?, tint })
|
||||
})
|
||||
.collect(),
|
||||
_ => Err(bad(cmd, "missing `map` (rows [{from, to}] or {current: new})")),
|
||||
@@ -303,6 +309,8 @@ struct Link {
|
||||
name: String,
|
||||
global: bool,
|
||||
color: Color,
|
||||
/// The row's tint of the swatch (0..1).
|
||||
tint: f32,
|
||||
}
|
||||
|
||||
/// A global swatch of the color group `recolor.apply` rewrites: its name, the color it is
|
||||
@@ -338,9 +346,12 @@ impl ColorMap {
|
||||
let Some(to) = r.new_color() else { continue };
|
||||
let i = out.rows.len();
|
||||
let colors: Vec<Color> = r.from.iter().map(Match::color).collect();
|
||||
out.rows.push(RowMap::new(method, &colors, to).with_own_model(own_model));
|
||||
let link = links.get(n).cloned().flatten();
|
||||
// Colors that can't link to the swatch take the row's tint of it too.
|
||||
let mapped = link.as_ref().filter(|l| l.global && l.tint < 1.0).map_or(to, |l| to.tinted(l.tint));
|
||||
out.rows.push(RowMap::new(method, &colors, mapped).with_own_model(own_model));
|
||||
out.tos.push(to);
|
||||
out.links.push(links.get(n).cloned().flatten());
|
||||
out.links.push(link);
|
||||
for m in &r.from {
|
||||
match m {
|
||||
Match::Key(k) => out.keys.entry(*k).or_insert(i),
|
||||
@@ -365,7 +376,8 @@ impl ColorMap {
|
||||
}
|
||||
|
||||
/// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot
|
||||
/// swatch links its colours to it (at their tints; shades and other hues stay unlinked), a
|
||||
/// swatch links its colours to it (at their tints times the row's tint; shades and other hues
|
||||
/// stay unlinked), a
|
||||
/// process swatch gives its colour in its own model; a color linked to a global swatch of the
|
||||
/// rewritten group that takes the row's new color keeps its link (at its tint in the row) when
|
||||
/// the row gives it the new color in that color's model;
|
||||
@@ -388,7 +400,7 @@ impl ColorMap {
|
||||
}
|
||||
let new = match (l, row.tint(*c)) {
|
||||
(Some(l), Some(t)) if l.global => {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let t = (t * l.tint).clamp(0.0, 1.0);
|
||||
let shown = l.color.tinted(t);
|
||||
let changed = link.as_deref() != Some(l.name.as_str()) || *tint != t || *c != shown;
|
||||
(*c, *link, *tint) = (shown, Some(l.name.clone()), t);
|
||||
@@ -487,7 +499,8 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut added = vec![];
|
||||
let links: Vec<Option<Link>> = row_swatches
|
||||
.iter()
|
||||
.map(|w| {
|
||||
.zip(&rows)
|
||||
.map(|(w, row)| {
|
||||
let w = w.as_ref()?;
|
||||
let name = match d.swatch(&w.name) {
|
||||
Some(x) if x.paint == w.paint => w.name.clone(),
|
||||
@@ -499,7 +512,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let color = d.global_color(&name).filter(|_| w.global).or(w.paint.color())?;
|
||||
Some(Link { name, global: w.global, color })
|
||||
Some(Link { name, global: w.global, color, tint: row.tint })
|
||||
})
|
||||
.collect();
|
||||
// The group's global swatches that its rewrite gives a color: art linked to one of them
|
||||
@@ -558,6 +571,9 @@ fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
if r.exclude {
|
||||
row["exclude"] = json!(true);
|
||||
}
|
||||
if r.tint < 1.0 {
|
||||
row["tint"] = json!(f64::from((r.tint * 10_000.0).round()) / 100.0);
|
||||
}
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -537,6 +537,33 @@ fn recolor_to_a_library_spot_colour_links_it_and_adds_the_swatch() {
|
||||
assert_eq!(r["swatches"], json!(["Leaf"]));
|
||||
}
|
||||
|
||||
/// A row's `tint` gives the art linked to the row's spot color that share of its tint;
|
||||
/// randomize keeps it on the row, and a tint that is not a number is refused.
|
||||
#[test]
|
||||
fn a_rows_tint_tints_the_spot_color_the_art_links_to() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Ink 101", "color": {"c": 0.0, "m": 1.0, "y": 0.0, "k": 0.0}, "spot": true}));
|
||||
let data = run(&mut s, "swatch.library.save", json!({"names": ["Ink 101"], "name": "Inks"}))["data"].as_str().unwrap().to_string();
|
||||
let lib = run(&mut s, "swatch.library.load", json!({"data": data, "name": "inks.vcswatches"}))["library"].as_str().unwrap().to_string();
|
||||
run(&mut s, "swatch.delete", json!({"names": ["Ink 101"]}));
|
||||
let red = rect(&mut s, json!("#e02010"));
|
||||
select_all(&mut s);
|
||||
let map = json!([{"from": ["rgb 224 32 16"], "to": "cmyk 0 100 0 0", "tint": 40}]);
|
||||
run(&mut s, "recolor.apply", json!({"map": map, "limitTo": lib}));
|
||||
match s.doc().unwrap().doc.node(red).unwrap().appearance.fill_paint() {
|
||||
vectorcraft_color::Paint::Solid { color, swatch, tint } => {
|
||||
assert_eq!(swatch.as_deref(), Some("Ink 101"), "linked to the spot color");
|
||||
assert!((tint - 0.4).abs() < 1e-4, "at the row's tint: {tint}");
|
||||
assert!(close(color, Color::cmyk(0.0, 1.0, 0.0, 0.0).tinted(0.4)), "{color:?}");
|
||||
}
|
||||
p => panic!("{p:?}"),
|
||||
}
|
||||
let r = run(&mut s, "recolor.randomize", json!({"map": map, "order": false, "saturationBrightness": false}));
|
||||
assert_eq!(r["map"][0]["tint"], json!(40.0), "{r}");
|
||||
let e = s.execute("recolor.apply", &json!({"map": [{"from": ["rgb 224 32 16"], "to": "#ff0000", "tint": "40"}]})).unwrap_err();
|
||||
assert!(e.to_string().contains("`tint` must be a number"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_param_chooses_the_model_of_the_new_colour() {
|
||||
// RGB art in a CMYK document: by default a CMYK replacement comes out RGB (#2170).
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
//! Grays (they open as OK on art last left them), the rows (click to select, Shift or Cmd/Ctrl-click
|
||||
//! to add; a row's arrow excludes or includes it; the hex field edits its new colour, and a
|
||||
//! double-click on it opens the Color Picker,
|
||||
//! whose OK sets it in the model it was picked in), Merge and Separate, Randomize order or
|
||||
//! whose OK sets it in the model it was picked in), the color mode menu and its sliders (HSB, RGB,
|
||||
//! CMYK or Lab, and Tint when the new color is a spot color of the library), which edit the
|
||||
//! first selected row's new color (once they have, OK gives the art each new color in its own
|
||||
//! model), Merge and Separate, Randomize order or
|
||||
//! saturation and brightness, Reset, the Method and Limit to Library (its folder button imports a
|
||||
//! library file and limits to it; a library's colours apply as its swatches: a spot colour stays a
|
||||
//! spot colour, and joins the document's swatches). Edit tab: a hue and
|
||||
@@ -16,11 +19,13 @@
|
||||
//! Opened on colours alone (the Color Guide's Edit or Apply Colors without art), OK saves the new
|
||||
//! colours as a colour group named `groupName`.
|
||||
//!
|
||||
//! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?}]), `colors` (null: Auto, or a
|
||||
//! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id,
|
||||
//! "document" for the document's swatches, "" for none), `group`, `groupName`, `tab` (`assign` or
|
||||
//! `edit`), `rule` (harmony id), `linked`, `preview`. Changing `colors`, a preserve flag or
|
||||
//! `limitTo` reduces the rows again.
|
||||
//! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?, tint?: percent of a spot
|
||||
//! color}]), `colors` (null: Auto, or a count), `method`, `preserveWhite`, `preserveBlack`,
|
||||
//! `preserveGrays`, `limitTo` (library id, "document" for the document's swatches, "" for none),
|
||||
//! `group`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`,
|
||||
//! `sliderMode` (`hsb`, `rgb`, `cmyk`, `lab` or `tint`; unset: the row color's own model, HSB for
|
||||
//! a gray and Tint for a spot color). Changing `colors`, a preserve flag or `limitTo` reduces the
|
||||
//! rows again.
|
||||
|
||||
use egui::{Rect, Sense, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -32,6 +37,7 @@ use vectorcraft_engine::cmd::swatchlib;
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
use crate::panels::c32;
|
||||
use crate::panels::color::{Mode, components, from_components, track_color};
|
||||
use crate::panels::swatches::{DOCUMENT_SWATCHES, colour_libraries, limit_key};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
@@ -56,11 +62,21 @@ const CURRENT_W: f32 = 250.0;
|
||||
/// Diameter of the Edit tab's colour wheel.
|
||||
const WHEEL: f32 = 220.0;
|
||||
/// Height of the rows table, and of either tab (so switching tabs doesn't resize the dialog).
|
||||
const TABLE_H: f32 = 220.0;
|
||||
const TABLE_H: f32 = 182.0;
|
||||
const TAB_H: f32 = 400.0;
|
||||
const PRESETS: [&str; 5] = ["Custom", "1 Color Job", "2 Color Job", "3 Color Job", "Color Library"];
|
||||
/// The id of row `i`'s new colour chip, with `i`.
|
||||
const NEW_CHIP: &str = "recolor-new";
|
||||
/// The color modes of the sliders under the rows; a row whose new color is a spot color of the
|
||||
/// library also has Tint.
|
||||
const SLIDER_MODES: [Mode; 4] = [Mode::Hsb, Mode::Rgb, Mode::Cmyk, Mode::Lab];
|
||||
/// Widths of a color slider and of its number field.
|
||||
const SLIDER_W: f32 = 170.0;
|
||||
const SLIDER_FIELD_W: f32 = 48.0;
|
||||
/// Set once the sliders have edited a new color in a mode: OK then sends `model: "new"`.
|
||||
const SLID: &str = "__slid";
|
||||
/// The slider values last shown: `{row, key, mode, v}` (HSB keeps its hue at no saturation).
|
||||
const COMPS: &str = "__comps";
|
||||
|
||||
/// Open Recolor Artwork (`ui.recolorDialog {colors?, library?, group?}`) on the selected art and,
|
||||
/// with `group`, on that colour group: art is reduced to as many rows as the group has colours,
|
||||
@@ -187,6 +203,14 @@ fn to_color(row: &Value) -> Option<Color> {
|
||||
color_value(&row["to"])
|
||||
}
|
||||
|
||||
/// Give `row` the new color `c`, without a tint of the spot color it had.
|
||||
fn set_to(row: &mut Value, c: &Color) {
|
||||
row["to"] = key(c);
|
||||
if let Some(r) = row.as_object_mut() {
|
||||
r.remove("tint");
|
||||
}
|
||||
}
|
||||
|
||||
fn excluded(row: &Value) -> bool {
|
||||
row["exclude"].as_bool().unwrap_or(false)
|
||||
}
|
||||
@@ -208,6 +232,10 @@ fn apply_params(d: &Dialog) -> Value {
|
||||
json!({"group": g, "groupColors": colors, "rename": rename})
|
||||
});
|
||||
let mut p = json!({"map": rs, "method": d.str("method"), "limitTo": d.str("limitTo")});
|
||||
// The art takes a color edited with the sliders in the mode they showed.
|
||||
if d.bool(SLID) {
|
||||
p["model"] = json!("new");
|
||||
}
|
||||
if let (Some(Value::Object(g)), Some(p)) = (group, p.as_object_mut()) {
|
||||
p.extend(g.into_iter().filter(|(_, v)| !v.is_null()));
|
||||
}
|
||||
@@ -265,7 +293,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
pub(super) fn with_new_color(app: &VectorcraftApp, mut d: Dialog, i: usize, c: Color) -> Dialog {
|
||||
let mut rs = rows(&d);
|
||||
if let Some(r) = rs.get_mut(i) {
|
||||
r["to"] = key(&c);
|
||||
set_to(r, &c);
|
||||
snap(app, &d, &mut rs);
|
||||
d.fields.insert("rows".into(), Value::Array(rs));
|
||||
}
|
||||
@@ -393,7 +421,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
|
||||
let (nr, _) = ui.allocate_exact_size(vec2(46.0, 20.0), Sense::hover());
|
||||
let nresp = ui.interact(nr, egui::Id::unique((NEW_CHIP, i)), Sense::click());
|
||||
let to = to_color(row).unwrap_or_default();
|
||||
chip(ui, nr, to, off);
|
||||
chip(ui, nr, to.tinted(row_tint(row)), off);
|
||||
if nresp.clicked() {
|
||||
clicked = Some(i);
|
||||
}
|
||||
@@ -406,7 +434,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
|
||||
if let Some(h) = widgets::hex_field_named(ui, ("recolor-hex", i), Some(&hex_name), &hex)
|
||||
&& let Some(c) = crate::panels::color::parse_hex(&h)
|
||||
{
|
||||
row["to"] = key(&keep_model(to, c));
|
||||
set_to(row, &keep_model(to, c));
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
@@ -461,7 +489,11 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
|
||||
if widgets::icon_button(ui, "rotate-ccw", tl!("Reset the rows to the artwork's colors"), false, 24.0).clicked() {
|
||||
d.fields.remove("__reduced");
|
||||
}
|
||||
let target = SliderTarget::of(app, d, rows);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| mode_menu(ui, d, &target));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
changed |= sliders(app, ui, d, rows);
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("recolor-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Method:")).color(t.text_dim));
|
||||
@@ -530,6 +562,181 @@ fn arrow(ui: &Ui, r: Rect, on: bool, hovered: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tint (0..1) `row` gives its spot color (1 without one).
|
||||
fn row_tint(row: &Value) -> f32 {
|
||||
row["tint"].as_f64().map_or(1.0, |t| (t / 100.0).clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
/// The `sliderMode` id of `m` (`hsb`, `rgb`, `cmyk`, `lab`).
|
||||
fn mode_id(m: Mode) -> String {
|
||||
serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The name of the spot color of the library (Limit to Library) that new color `c` is.
|
||||
fn spot_of(app: &VectorcraftApp, d: &Dialog, c: &Color) -> Option<String> {
|
||||
let lib = d.str("limitTo");
|
||||
if lib.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let k = ColorKey::of(c);
|
||||
swatchlib::library_swatches(&app.session, &lib)?
|
||||
.into_iter()
|
||||
.find(|w| w.spot && w.paint.color().is_some_and(|x| ColorKey::of(&x) == k))
|
||||
.map(|w| w.name)
|
||||
}
|
||||
|
||||
/// The mode the sliders show new color `c` in, `None` for Tint: the mode picked in the menu, else
|
||||
/// `c`'s own model (HSB for a gray), or Tint for a spot color (`spot`).
|
||||
fn slider_mode(d: &Dialog, c: &Color, spot: bool) -> Option<Mode> {
|
||||
let picked = d.str("sliderMode");
|
||||
match SLIDER_MODES.into_iter().find(|m| mode_id(*m) == picked) {
|
||||
Some(m) => Some(m),
|
||||
None if spot => None,
|
||||
None => Some(match Mode::of(c) {
|
||||
Mode::Grayscale => Mode::Hsb,
|
||||
m => m,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The values the sliders show for row `i`'s new color `c` in `mode`: those they set last while
|
||||
/// the color is the one they set, else `c`'s.
|
||||
fn slider_values(d: &Dialog, rows: &[Value], i: usize, c: &Color, mode: Mode) -> Vec<f32> {
|
||||
let held = d.fields.get(COMPS).filter(|s| s["row"] == json!(i) && s["key"] == rows[i]["to"] && s["mode"] == json!(mode_id(mode)));
|
||||
let v: Option<Vec<f32>> = held.and_then(|s| s["v"].as_array()).map(|a| a.iter().filter_map(Value::as_f64).map(|x| x as f32).collect());
|
||||
v.filter(|v| v.len() == mode.labels().len()).unwrap_or_else(|| components(mode, c))
|
||||
}
|
||||
|
||||
/// What the color sliders edit: the first selected row, its new color, and the name of the
|
||||
/// library's spot color that color is.
|
||||
struct SliderTarget {
|
||||
row: Option<usize>,
|
||||
color: Option<Color>,
|
||||
spot: Option<String>,
|
||||
}
|
||||
|
||||
impl SliderTarget {
|
||||
fn of(app: &VectorcraftApp, d: &Dialog, rows: &[Value]) -> Self {
|
||||
let row = selection(d).into_iter().filter(|i| *i < rows.len()).min();
|
||||
let color = row.and_then(|i| to_color(&rows[i]));
|
||||
let spot = color.and_then(|c| spot_of(app, d, &c));
|
||||
Self { row, color, spot }
|
||||
}
|
||||
|
||||
/// The mode the sliders show (`None`: Tint).
|
||||
fn mode(&self, d: &Dialog) -> Option<Mode> {
|
||||
slider_mode(d, &self.color.unwrap_or_default(), self.spot.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// The color mode menu of the sliders (HSB, RGB, CMYK, Lab, and Tint for a spot color), drawn
|
||||
/// right to left: the menu, then its label.
|
||||
fn mode_menu(ui: &mut Ui, d: &mut Dialog, target: &SliderTarget) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut labels: Vec<&str> = SLIDER_MODES.iter().map(|m| m.label()).collect();
|
||||
if target.spot.is_some() {
|
||||
labels.push("Tint");
|
||||
}
|
||||
let current = target.mode(d).map_or("Tint", Mode::label);
|
||||
// The label is drawn after the menu (right-to-left), so it cannot name it by adjacency.
|
||||
let chosen = ui
|
||||
.add_enabled_ui(target.color.is_some(), |ui| {
|
||||
widgets::dropdown_named(ui, "recolor-slider-mode", Some(tl!("Color Mode:")), current, &labels, 90.0)
|
||||
})
|
||||
.inner;
|
||||
if let Some(i) = chosen {
|
||||
d.fields.insert("sliderMode".into(), json!(SLIDER_MODES.get(i).map_or_else(|| "tint".to_string(), |m| mode_id(*m))));
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Color Mode:")).color(t.text_dim));
|
||||
}
|
||||
|
||||
/// One color slider in the grid: its label, its rail with `track` and its number field, showing
|
||||
/// `shown` with `suffix`. Returns the whole number in `shown`'s range that a drag, a key or the
|
||||
/// field sets.
|
||||
fn slider_cell(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + Copy,
|
||||
label: &str,
|
||||
shown: widgets::SliderValue,
|
||||
suffix: &str,
|
||||
track: &dyn Fn(f32) -> egui::Color32,
|
||||
) -> Option<f64> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (lo, hi) = (*shown.range.start(), *shown.range.end());
|
||||
let value = shown.value;
|
||||
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(label).size(12.5).color(t.text)));
|
||||
let unit = ((value - lo) / (hi - lo)) as f32;
|
||||
let (dragged, _, keyed) = widgets::color_slider_stepped(ui, ("recolor-slider", id), label, Some(shown), unit, SLIDER_W, track);
|
||||
let typed = widgets::plain_field_named(ui, ("recolor-slider-field", id), Some(label), value, suffix, 0, SLIDER_FIELD_W);
|
||||
dragged.map(|x| (lo + f64::from(x) * (hi - lo)).round()).or(keyed).or(typed).map(|v| v.clamp(lo, hi)).filter(|v| (v - value).abs() > 1e-6)
|
||||
}
|
||||
|
||||
/// The color sliders under the rows, two to a line: they edit the first selected row's new color
|
||||
/// (the others keep theirs) in the mode of [`mode_menu`], or its tint of a spot color of the
|
||||
/// library. Without a selected row they are disabled. Returns true when they changed a row.
|
||||
fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool {
|
||||
let target = SliderTarget::of(app, d, rows);
|
||||
let mode = target.mode(d);
|
||||
let mut changed = false;
|
||||
let top = ui.cursor().top();
|
||||
let lines = mode.map_or(1, |m| m.labels().len().div_ceil(2));
|
||||
ui.add_enabled_ui(target.color.is_some(), |ui| {
|
||||
egui::Grid::new("recolor-sliders").num_columns(6).spacing([6.0, 4.0]).show(ui, |ui| {
|
||||
let (Some(i), Some(c)) = (target.row, target.color) else {
|
||||
// Nothing to edit: the mode's sliders, disabled.
|
||||
let mode = mode.unwrap_or(Mode::Hsb);
|
||||
let input = Tokens::get(ui.ctx()).input;
|
||||
let grey = move |_: f32| input;
|
||||
for (n, lbl) in mode.labels().iter().enumerate() {
|
||||
let shown = widgets::SliderValue::field(0.0, f64::from(mode.min(n))..=f64::from(mode.max(n)), mode.suffix(n), 0);
|
||||
slider_cell(ui, n, lbl, shown, mode.suffix(n), &grey);
|
||||
if n % 2 == 1 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(mode) = mode else {
|
||||
// Tint of the spot color, and its name.
|
||||
let tint = f64::from(row_tint(&rows[i])) * 100.0;
|
||||
let shown = widgets::SliderValue::field(tint, 0.0..=100.0, "%", 0);
|
||||
if let Some(v) = slider_cell(ui, usize::MAX, "T", shown, "%", &|x| c32(&c.tinted(x))) {
|
||||
rows[i]["tint"] = json!(v);
|
||||
changed = true;
|
||||
}
|
||||
ui.label(egui::RichText::new(target.spot.as_deref().unwrap_or_default()).color(Tokens::get(ui.ctx()).text));
|
||||
return;
|
||||
};
|
||||
let comps = slider_values(d, rows, i, &c, mode);
|
||||
let mut set: Option<Vec<f32>> = None;
|
||||
for (n, lbl) in mode.labels().iter().enumerate() {
|
||||
let v = comps.get(n).copied().unwrap_or(0.0);
|
||||
let shown = widgets::SliderValue::field(f64::from(v), f64::from(mode.min(n))..=f64::from(mode.max(n)), mode.suffix(n), 0);
|
||||
if let Some(nv) = slider_cell(ui, n, lbl, shown, mode.suffix(n), &|x| c32(&track_color(mode, &comps, n, x))) {
|
||||
let mut c2 = comps.clone();
|
||||
c2[n] = nv as f32;
|
||||
set = Some(c2);
|
||||
}
|
||||
if n % 2 == 1 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
if let Some(c2) = set {
|
||||
// The mode stays as the edit changes the color's model (a gray edited in HSB is RGB).
|
||||
d.fields.insert("sliderMode".into(), json!(mode_id(mode)));
|
||||
set_to(&mut rows[i], &from_components(mode, &c2));
|
||||
d.fields.insert(COMPS.into(), json!({"row": i, "key": rows[i]["to"], "mode": mode_id(mode), "v": c2}));
|
||||
d.fields.insert(SLID.into(), json!(true));
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
// Room for two lines of sliders whatever the mode, so the dialog keeps its height.
|
||||
let per = (ui.cursor().top() - top) / lines as f32;
|
||||
ui.add_space(per * (2 - lines.min(2)) as f32);
|
||||
changed
|
||||
}
|
||||
|
||||
/// Merge the selected rows into the first of them (its new colour stays).
|
||||
fn merge(rows: &mut Vec<Value>, sel: &[usize]) {
|
||||
let mut sel: Vec<usize> = sel.iter().copied().filter(|i| *i < rows.len()).collect();
|
||||
|
||||
@@ -226,6 +226,144 @@ fn edit_harness(colors: &[&str]) -> egui_kittest::Harness<'static, VectorcraftAp
|
||||
h
|
||||
}
|
||||
|
||||
/// Recolor Artwork open on the Assign tab of `app` (its art selected), drawn by a kittest harness.
|
||||
fn assign_harness(mut app: VectorcraftApp, open: Value) -> egui_kittest::Harness<'static, VectorcraftApp> {
|
||||
app.run("ui.recolorDialog", open).unwrap();
|
||||
set(&mut app, "method", json!("exact"));
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 800.0)).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
dialogs::show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Clicks the color slider named `name` at `at` (0 to 1) along its rail.
|
||||
fn click_slider(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, name: &str, at: f32) {
|
||||
let r = h.get_by_role_and_label(egui::accesskit::Role::Slider, name).rect();
|
||||
let pos = egui::pos2(r.left() + 4.0 + at * (r.width() - 8.0), r.center().y);
|
||||
h.input_mut().events.extend(press(pos, 1));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// The fill of the first object of the document.
|
||||
fn first_fill(app: &VectorcraftApp) -> vectorcraft_color::Paint {
|
||||
let d = &app.session.doc().unwrap().doc;
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node(id).unwrap().appearance.fill_paint()
|
||||
}
|
||||
|
||||
/// The color mode menu's sliders edit the first selected row's new color in the mode picked (the
|
||||
/// other row keeps its color), the art previews it, and OK gives the art the color in that mode:
|
||||
/// a CMYK color on RGB art makes it CMYK. Cancel leaves the art as it was, with no undo step.
|
||||
#[test]
|
||||
fn the_color_sliders_edit_the_selected_rows_new_color_in_their_mode() {
|
||||
for confirm in [true, false] {
|
||||
let mut app = app();
|
||||
art(&mut app, &["#0000ff", "#ff0000"]);
|
||||
let before = first_fill(&app);
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
let mut h = assign_harness(app, json!({}));
|
||||
let rows = field(h.state(), "rows");
|
||||
let blue = 0;
|
||||
assert_eq!(rows[blue]["from"], json!(["rgb 0 0 255"]), "the first object's blue is the first row");
|
||||
// Both rows selected, the second one first: the sliders edit the first row.
|
||||
set(h.state_mut(), "__sel", json!([1, blue]));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "R").is_some(), "RGB art shows the RGB sliders");
|
||||
set(h.state_mut(), "sliderMode", json!("cmyk"));
|
||||
h.run_steps(2);
|
||||
click_slider(&mut h, "C", 0.5);
|
||||
let edited = field(h.state(), "rows");
|
||||
let to = edited[blue]["to"].as_str().unwrap().to_string();
|
||||
assert!(to.starts_with("cmyk 50 "), "the first selected row takes C 50%: {to}");
|
||||
assert_eq!(edited[1], rows[1], "the other selected row keeps its new color");
|
||||
let Some(Color::Cmyk { c, .. }) = first_fill(h.state()).color() else {
|
||||
panic!("the art previews the CMYK color: {:?}", first_fill(h.state()))
|
||||
};
|
||||
assert!((c - 0.5).abs() < 1e-3, "{c}");
|
||||
h.get_by_label(if confirm { "OK" } else { "Cancel" }).click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.dialog.is_none());
|
||||
let app = h.state();
|
||||
if confirm {
|
||||
assert_eq!(first_fill(app).color(), vectorcraft_engine::cmd::color_value(&json!(to)), "OK keeps the color, in CMYK");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
} else {
|
||||
assert_eq!(first_fill(app), before, "Cancel leaves the art unchanged");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "and records no undo step");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A gray row shows HSB, and the sliders stay in HSB once an edit makes the color RGB.
|
||||
#[test]
|
||||
fn a_gray_row_keeps_the_hsb_sliders_while_it_is_edited() {
|
||||
let mut app = app();
|
||||
art(&mut app, &["gray 40"]);
|
||||
let mut h = assign_harness(app, json!({}));
|
||||
set(h.state_mut(), "__sel", json!([0]));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "H").is_some(), "a gray row shows HSB");
|
||||
click_slider(&mut h, "S", 0.5);
|
||||
let to = field(h.state(), "rows")[0]["to"].clone();
|
||||
assert!(to.as_str().is_some_and(|k| k.starts_with("rgb ")), "{to}");
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "H").is_some(), "still HSB after the edit");
|
||||
assert_eq!(field(h.state(), "sliderMode"), json!("hsb"));
|
||||
}
|
||||
|
||||
/// With Limit to Library, a row whose new color is a spot color of the library shows Tint, whose T
|
||||
/// slider sets the row's tint of it; OK links the art to the spot color at that tint.
|
||||
#[test]
|
||||
fn the_tint_slider_sets_a_spot_rows_tint() {
|
||||
let mut app = app();
|
||||
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap();
|
||||
let data = app.run("swatch.library.save", json!({"names": ["Ink"], "name": "Inks"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let lib = app.run("swatch.library.load", json!({"data": data, "name": "inks.vcswatches"})).unwrap()["library"].as_str().unwrap().to_string();
|
||||
app.run("swatch.delete", json!({"names": ["Ink"]})).unwrap();
|
||||
art(&mut app, &["cmyk 0 100 0 0"]);
|
||||
let mut h = assign_harness(app, json!({"library": lib}));
|
||||
set(h.state_mut(), "__sel", json!([0]));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "T").is_some(), "a spot row shows Tint");
|
||||
click_slider(&mut h, "T", 0.4);
|
||||
assert_eq!(field(h.state(), "rows")[0]["tint"], json!(40.0));
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(2);
|
||||
match first_fill(h.state()) {
|
||||
vectorcraft_color::Paint::Solid { swatch, tint, .. } => {
|
||||
assert_eq!(swatch.as_deref(), Some("Ink"), "linked to the spot color");
|
||||
assert!((tint - 0.4).abs() < 1e-4, "at the row's tint: {tint}");
|
||||
}
|
||||
p => panic!("{p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog keeps its height when the tab or the sliders' color mode changes.
|
||||
#[test]
|
||||
fn switching_tabs_or_slider_modes_keeps_the_dialog_height() {
|
||||
let mut app = app();
|
||||
art(&mut app, &["#ff0000", "#0000ff"]);
|
||||
let mut h = assign_harness(app, json!({}));
|
||||
set(h.state_mut(), "__sel", json!([0]));
|
||||
let height = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>, k: &str, v: Value| {
|
||||
set(h.state_mut(), k, v);
|
||||
h.run_steps(6);
|
||||
h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "recolor")))).unwrap().height()
|
||||
};
|
||||
let assign = height(&mut h, "sliderMode", json!("cmyk"));
|
||||
for (k, v) in [("sliderMode", json!("hsb")), ("sliderMode", json!("lab")), ("tab", json!("edit")), ("tab", json!("assign"))] {
|
||||
assert_eq!(height(&mut h, k, v.clone()), assign, "{k} {v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Assign tab's per-row hex fields are named by their row, not all "Color": a screen reader can
|
||||
/// tell the second from the first.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user