Swatches: a swatch made from a color the app shows keeps that color's model in a CMYK document

Since #2664, swatch.new separates an RGB color into CMYK through the
color settings in a CMYK document unless keepModel is given. The Color
Guide, Color Themes and Recolor Artwork send keepModel when they save
colors they show, but the Color panel menu's Create New Swatch, the
Swatches panel and the Gradient panel did not. An RGB object's color
saved from them became a CMYK swatch, and a gradient's RGB stops
became CMYK stops.

These now send keepModel: Create New Swatch, an Alt-click on New
Swatch, the New Swatch dialog for a gradient, a Fill or Stroke proxy
or the Gradient panel's thumbnail dropped on the Swatches panel or
Alt-dropped on a swatch of its kind, and the Gradient panel's Add to
Swatches and Save to Swatches. A new swatch_paint_params returns
paint_params with keepModel: true added for an unlinked color or a
gradient. swatch.new without keepModel still converts RGB input.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-21 16:46:13 +07:00
committed by bflatastic
parent 67448e9245
commit 54ee0b6251
6 changed files with 135 additions and 8 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ pub fn open(app: &mut VectorcraftApp, spot: bool, global: bool, group: Option<&s
json!({"color": color, "mode": mode_id(Mode::of(color)), "spot": spot, "global": spot || global, "group": group.unwrap_or_default()})
}
Paint::None => return Err("pick a colour, gradient or pattern to save as a swatch".into()),
other => json!({"__paint": crate::panels::paint_params(other)}),
other => json!({"__paint": crate::panels::swatch_paint_params(other)}),
};
fields["name"] = json!(name);
fields["__auto"] = json!(name);
+49 -2
View File
@@ -734,10 +734,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Create New Swatch…"), color.is_some(), false)
&& let Some(c) = color
{
// A tint saves a tint swatch ("Name 40%").
// A tint saves a tint swatch ("Name 40%"); a color keeps its model.
let p = match tgt.tint() {
Some(t) => json!({"swatch": t.swatch, "tint": t.tint * 100.0}),
None => json!({"color": color_json(&c)}),
None => json!({"color": color_json(&c), "keepModel": true}),
};
app.run("swatch.new", p).ok();
}
@@ -839,6 +839,53 @@ mod tests {
assert_eq!(g.freeform.map(|f| f.points[0].color), Some(orange));
}
/// One headless frame of the panel menu; returns its painted labels.
fn menu_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(String, Rect)> {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(236.0, 400.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| menu(app, ui));
out.textures_delta.clear();
crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
}
/// Click the panel menu's item `label`.
fn click_menu_item(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
let labels = menu_frame(app, ctx, vec![]);
let at = labels.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label} in the menu"));
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
menu_frame(app, ctx, vec![egui::Event::PointerMoved(at), button(true)]);
menu_frame(app, ctx, vec![button(false)]);
}
/// In a CMYK document the panel menu's Create New Swatch saves the color the panel shows in
/// that color's model: an RGB object gives an RGB swatch, and CMYK, gray and spot colors give
/// swatches of their own colors.
#[test]
fn create_new_swatch_keeps_the_shown_colors_model_in_a_cmyk_document() {
let mut app = app();
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 60, "y": 90, "k": 0}, "spot": true})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let newest = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatches.last().cloned().expect("a swatch");
let ink = app.session.doc().unwrap().doc.swatch("Ink").and_then(|w| w.paint.color()).unwrap();
for (fill, model) in [
(json!({"color": "#336699", "keepModel": true}), vectorcraft_color::cms::Model::Rgb),
(json!({"color": {"c": 10, "m": 20, "y": 30, "k": 5}}), vectorcraft_color::cms::Model::Cmyk),
(json!({"color": {"gray": 0.4}}), vectorcraft_color::cms::Model::Gray),
(json!({"swatch": "Ink"}), ink.model()),
] {
app.run("paint.setFill", fill.clone()).unwrap();
let shown = crate::panels::current_paints(&app).0.color().expect("a solid fill");
assert_eq!(shown.model(), model, "{fill}");
let count = app.session.doc().unwrap().doc.swatches.len();
click_menu_item(&mut app, &ctx, "Create New Swatch…");
assert_eq!(app.session.doc().unwrap().doc.swatches.len(), count + 1, "{fill}: one new swatch");
let w = newest(&app);
assert_eq!(w.paint.color(), Some(shown), "{fill}: the swatch has the shown color, {}", w.name);
}
}
fn paints_hex(app: &VectorcraftApp) -> (String, String) {
let (f, s) = crate::panels::current_paints(app);
(f.color().map(|c| c.to_hex()).unwrap_or_default(), s.color().map(|c| c.to_hex()).unwrap_or_default())
+19 -2
View File
@@ -189,9 +189,9 @@ fn write_stops(app: &mut VectorcraftApp, params: Value, select: Option<usize>, p
}
}
/// Save the gradient as a swatch.
/// Save the gradient as a swatch; its stops keep their color models.
fn save_to_swatches(app: &mut VectorcraftApp, g: &GradientPaint) {
app.run("swatch.new", super::paint_params(&Paint::Gradient(Box::new(g.clone())))).ok();
app.run("swatch.new", super::swatch_paint_params(&Paint::Gradient(Box::new(g.clone())))).ok();
}
/// The document's gradient swatches (groups included): name and gradient.
@@ -791,6 +791,23 @@ mod tests {
assert_eq!(g.freeform.map(|f| f.points[1].color), Some(orange));
}
/// In a CMYK document Add to Swatches and Save to Swatches keep the gradient's RGB stops RGB.
#[test]
fn a_gradient_saved_as_a_swatch_keeps_its_stops_models_in_a_cmyk_document() {
let mut app = app(json!({"color": "#000000"}));
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
let stops = json!([{"offset": 0, "color": "#ff8000"}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 1}}]);
app.run("paint.setFill", json!({"gradient": {"stops": stops}, "keepModel": true})).unwrap();
let g = current(&app).expect("a gradient fill");
let colors = |g: &GradientPaint| g.gradient.stops.iter().map(|s| s.color).collect::<Vec<_>>();
assert_eq!(colors(&g), [Color::rgb8(255, 128, 0), Color::cmyk(0.0, 0.0, 0.0, 1.0)]);
save_to_swatches(&mut app, &g);
let Some(Paint::Gradient(saved)) = app.session.doc().unwrap().doc.swatches.last().map(|w| w.paint.clone()) else {
panic!("a gradient swatch")
};
assert_eq!(colors(&saved), colors(&g));
}
#[test]
fn dragged_stops_move_copy_or_swap() {
let g = Gradient::default();
+10
View File
@@ -505,6 +505,16 @@ pub(crate) fn paint_params(p: &Paint) -> Value {
}
}
/// [`paint_params`] for a swatch made from paint the app shows: a color and a gradient's stops
/// and points keep their color models (`keepModel`), in a CMYK document too.
pub(crate) fn swatch_paint_params(p: &Paint) -> Value {
let mut v = paint_params(p);
if matches!(p, Paint::Solid { swatch: None, .. } | Paint::Gradient(_)) {
v["keepModel"] = json!(true);
}
v
}
/// Egui colour of a document colour.
pub(crate) fn c32(c: &Color) -> egui::Color32 {
let [r, g, b, _] = c.to_rgba8(1.0);
+55 -3
View File
@@ -570,10 +570,10 @@ fn apply_drop(app: &mut VectorcraftApp, drop: Drop) {
},
// Alt: an edited gradient (or colour) replaces the swatch it is dropped on.
Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => {
("swatch.edit", json!({"name": t, "paint": super::paint_params(&paint)}))
("swatch.edit", json!({"name": t, "paint": super::swatch_paint_params(&paint)}))
}
Drop::New { paint, target, .. } => {
let mut p = super::paint_params(&paint);
let mut p = super::swatch_paint_params(&paint);
if paint.color().is_some()
&& let Some(g) = target.and_then(|t| group_of(d, &t))
{
@@ -988,7 +988,7 @@ fn new_swatch(app: &mut VectorcraftApp, sel: &[String], m: egui::Modifiers) {
app.run("ui.newSwatch", json!({"spot": spot, "global": global, "group": group})).ok();
return;
}
let mut params = super::paint_params(&paint);
let mut params = super::swatch_paint_params(&paint);
if paint.color().is_some() {
params["spot"] = json!(spot);
params["global"] = json!(global);
@@ -1542,6 +1542,58 @@ mod tests {
}
}
/// In a CMYK document a swatch made from an RGB fill keeps RGB, as the Color Guide's copies
/// do: Alt-click on New Swatch, the New Swatch dialog, the Fill proxy dropped on the panel and
/// Alt-dropped on a color swatch, and the same for a gradient's stops.
#[test]
fn swatches_made_from_an_rgb_fill_stay_rgb_in_a_cmyk_document() {
let mut app = app();
let ctx = context();
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
let newest = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatches.last().map(|w| w.paint.clone()).expect("a swatch");
let stop_colors = |p: &Paint| match p {
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect::<Vec<_>>(),
other => panic!("a gradient: {other:?}"),
};
let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| {
frame(app, &ctx, vec![], time, show);
let at = tile_center(&ctx, name);
egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(paint));
let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m };
frame_with(app, &ctx, vec![Event::PointerMoved(at), release], m, time + 0.5, show);
};
let rgb = Color::from_hex("#2a6fb0").unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
app.run("paint.setFill", json!({"color": "#2a6fb0", "keepModel": true})).unwrap();
new_swatch(&mut app, &[], Modifiers::ALT);
assert_eq!(newest(&app), Paint::solid(rgb), "Alt-click on New Swatch");
new_swatch(&mut app, &[], Modifiers::NONE);
dialog_frame(&mut app, &ctx);
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(newest(&app), Paint::solid(rgb), "the New Swatch dialog");
drop_on(&mut app, "Red", Paint::solid(rgb), Modifiers::NONE, 3.0);
assert_eq!(newest(&app), Paint::solid(rgb), "the proxy dropped on the panel: {}", app.ui.status);
drop_on(&mut app, "Red", Paint::solid(rgb), Modifiers::ALT, 5.0);
assert_eq!(app.session.doc().unwrap().doc.swatch("Red").map(|w| w.paint.color()), Some(Some(rgb)), "Alt-dropped on Red");
// A gradient's RGB stops.
let stops = json!([{"offset": 0, "color": "#ff8000"}, {"offset": 1, "color": "#2a6fb0"}]);
app.run("paint.setFill", json!({"gradient": {"stops": stops}, "keepModel": true})).unwrap();
let grad = crate::panels::current_paints(&app).0;
let rgb_stops = vec![Color::from_hex("#ff8000").unwrap(), rgb];
assert_eq!(stop_colors(&grad), rgb_stops);
new_swatch(&mut app, &[], Modifiers::ALT);
assert_eq!(stop_colors(&newest(&app)), rgb_stops, "Alt-click on New Swatch");
new_swatch(&mut app, &[], Modifiers::NONE);
frame(&mut app, &ctx, vec![], 6.0, |app, ui| crate::dialogs::show(app, ui.ctx()));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(stop_colors(&newest(&app)), rgb_stops, "the New Swatch dialog");
drop_on(&mut app, "Red", grad.clone(), Modifiers::NONE, 7.0);
assert_eq!(stop_colors(&newest(&app)), rgb_stops, "the gradient dropped on the panel");
drop_on(&mut app, "Ocean", grad, Modifiers::ALT, 9.0);
let ocean = app.session.doc().unwrap().doc.swatch("Ocean").map(|w| w.paint.clone()).expect("Ocean");
assert_eq!(stop_colors(&ocean), rgb_stops, "Alt-dropped on Ocean");
}
#[test]
fn new_color_group_dialog_makes_a_group_from_swatches_or_artwork() {
let mut app = app();
+1
View File
@@ -689,6 +689,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement.
- **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13.
- **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C59 M12 Y3 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only.
- **Swatches made from a shown color in a CMYK document:** the Color panel menu's Create New Swatch, an Alt-click on the Swatches panel's New Swatch button, the New Swatch dialog for a gradient, a Fill or Stroke proxy or the Gradient panel's thumbnail dropped on the Swatches panel (or Alt-dropped on a swatch of its kind), and the Gradient panel's Add to Swatches and Save to Swatches keep each color's model, as the Color Guide's and Color Themes' saved colors do: an RGB object's color gives an RGB swatch, and a gradient's RGB stops stay RGB.
- **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there.
- **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/stroke_visibility.rs`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.