From fe258915e3583c5d18a19b2058ae4805baf088e6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 22:54:56 +0700 Subject: [PATCH 1/4] M3.30: Swatches panel multi-selection, find field and drag and drop Clicking a colour group's folder selects the whole group. Show Find Field filters the panel by name. Tiles and list rows drag to reorder swatches, move them into or out of colour groups or reorder groups (new command swatch.move, one undo step); swatches and Fill/Stroke proxies dropped on art paint the object under the pointer (paint.setFill/setStroke with its id), and a proxy dropped on the panel becomes a swatch. List rows show kind and colour-mode icons and take double-clicks; spot colours get a dot in the grid. The search field widget is shared with the Layers panel. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/swatch.rs | 82 +++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_swatchcmds.rs | 78 ++++ crates/ui-egui/src/canvas.rs | 61 ++- crates/ui-egui/src/panels/layers.rs | 17 +- crates/ui-egui/src/panels/swatches.rs | 554 ++++++++++++++++++++++---- crates/ui-egui/src/widgets.rs | 33 +- docs/mcp.md | 3 + 8 files changed, 731 insertions(+), 99 deletions(-) create mode 100644 crates/engine/src/tests_swatchcmds.rs diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 0aedd729..4182f58a 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -59,6 +59,15 @@ pub fn specs() -> Vec { has_doc, swatch_list ), + cmd!( + "swatch.move", + "Move Swatch", + ["Window", "Swatches"], + None, + "{name? | names?: [swatch names, in order; or colour group names to reorder the groups], to?: index in the destination (they go before the swatch or group now there; default: the end), group?: destination colour group (default: the ungrouped swatches; groups hold solid colours only)} reorder swatches or move them into or out of colour groups, as one undo step → {moved}", + has_doc, + swatch_move + ), ] } @@ -101,8 +110,8 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": name })) } -fn swatch_delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "swatch.delete"; +/// The `names` list and `name` parameters, without repeats; at least one is required. +fn names_param(p: &Value, cmd: &str) -> Result> { let mut names: Vec = vec![]; for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) { if !names.contains(&n) { @@ -110,8 +119,14 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { } } if names.is_empty() { - return Err(bad(C, "give `name` or `names`")); + return Err(bad(cmd, "give `name` or `names`")); } + Ok(names) +} + +fn swatch_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.delete"; + let names = names_param(p, C)?; let d = &s.doc()?.doc; // Global swatches going away (named ones and those in named groups): their links are dropped. let mut global: Vec = vec![]; @@ -223,6 +238,67 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": name, "swatches": members, "linked": linked})) } +/// The name of the item that items moving to index `to` of `list` go before: the first one at or +/// after `to` that isn't moving (`None`: the end). +fn anchor(list: &[T], to: Option, moving: &[String], name: impl Fn(&T) -> &str) -> Option { + list.iter().skip(to?).map(&name).find(|n| !moving.iter().any(|m| m == n)).map(str::to_string) +} + +/// Insert `items` into `list` before the item named `anchor` (at the end without one). +fn insert_before(list: &mut Vec, items: Vec, anchor: Option<&str>, name: impl Fn(&T) -> &str) { + let at = anchor.and_then(|a| list.iter().position(|x| name(x) == a)).unwrap_or(list.len()); + list.splice(at..at, items); +} + +fn swatch_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.move"; + let names = names_param(p, C)?; + let to = p.get("to").and_then(Value::as_u64).map(|v| v as usize); + let group = str_param(p, "group").map(str::to_string); + let moved = names.len(); + s.edit("Move Swatch", |d, _| { + let is_group = |d: &Document, n: &str| d.swatch_groups.iter().any(|g| g.name == n); + if names.iter().all(|n| is_group(d, n)) { + if group.is_some() { + return Err(bad(C, "colour groups can't go into colour groups")); + } + let at = anchor(&d.swatch_groups, to, &names, |g| &g.name); + let mut items = vec![]; + for n in &names { + if let Some(i) = d.swatch_groups.iter().position(|g| g.name == *n) { + items.push(d.swatch_groups.remove(i)); + } + } + insert_before(&mut d.swatch_groups, items, at.as_deref(), |g| &g.name); + return Ok(()); + } + for n in &names { + let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?; + if sw.paint.is_none() { + return Err(bad(C, format!("`{n}` can't be moved"))); + } + if group.is_some() && sw.paint.color().is_none() { + return Err(bad(C, format!("`{n}`: colour groups hold solid colours only"))); + } + } + let dest = match &group { + Some(g) => Some(d.swatch_groups.iter().position(|x| x.name == *g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?), + None => None, + }; + fn list(d: &mut Document, dest: Option) -> &mut Vec { + match dest { + Some(i) => &mut d.swatch_groups[i].swatches, + None => &mut d.swatches, + } + } + let at = anchor(list(d, dest), to, &names, |w| &w.name); + let items: Vec = names.iter().filter_map(|n| d.remove_swatch(n)).collect(); + insert_before(list(d, dest), items, at.as_deref(), |w| &w.name); + Ok(()) + })?; + Ok(json!({ "moved": moved })) +} + fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string(); let new = s.edit("Duplicate Swatch", |d, _| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6ea09da7..374e50c6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -772,6 +772,8 @@ mod tests_prefs; #[cfg(test)] mod tests_strokegeom; #[cfg(test)] +mod tests_swatchcmds; +#[cfg(test)] mod tests_swatches; #[cfg(test)] mod tests_textedit; diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests_swatchcmds.rs new file mode 100644 index 00000000..989357e8 --- /dev/null +++ b/crates/engine/src/tests_swatchcmds.rs @@ -0,0 +1,78 @@ +//! Swatches panel commands: moving swatches between colour groups, the panel menu's Add Used +//! Colors, Select All Unused, Merge, Ungroup and Sort by Kind, and colours following the +//! document's colour mode. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The swatch names of colour group `group`, or of the ungrouped swatches. +fn names(s: &Session, group: Option<&str>) -> Vec { + let d = doc(s); + let list = match group { + Some(g) => &d.swatch_groups.iter().find(|x| x.name == g).unwrap().swatches, + None => &d.swatches, + }; + list.iter().map(|w| w.name.clone()).collect() +} + +#[test] +fn move_between_groups_is_one_undo_step() { + let mut s = session(); + let before = doc(&s).clone(); + let undo = undo_len(&s); + // Two ungrouped colours into Brights, before its second swatch, in the order given. + run(&mut s, "swatch.move", json!({"names": ["Orange", "Red"], "group": "Brights", "to": 1})); + assert_eq!(names(&s, Some("Brights"))[..4], ["Bright Red", "Orange", "Red", "Bright Yellow"]); + assert!(!names(&s, None).contains(&"Red".to_string())); + assert_eq!(undo_len(&s), undo + 1); + // Out of a group to the front of the ungrouped colours (`to` counts the current entries). + run(&mut s, "swatch.move", json!({"name": "Bright Green", "to": 1})); + assert_eq!(names(&s, None)[..3], ["[None]", "Bright Green", "White"]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); + // Reordering within a list; past the end goes last. + run(&mut s, "swatch.move", json!({"name": "White", "to": 999})); + assert_eq!(names(&s, None).last().map(String::as_str), Some("White")); +} + +#[test] +fn move_reorders_groups_and_refuses_bad_moves() { + let mut s = session(); + run(&mut s, "swatch.move", json!({"name": "Brights", "to": 0})); + let groups: Vec<&str> = doc(&s).swatch_groups.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(groups, ["Brights", "Grays"]); + let undo = undo_len(&s); + for p in [ + json!({"name": "Sunset", "group": "Grays"}), + json!({"name": "[None]"}), + json!({"name": "Grays", "group": "Brights"}), + json!({"names": ["Red", "Grays"]}), + json!({"name": "Red", "group": "Nope"}), + json!({}), + ] { + assert!(s.execute("swatch.move", &p).is_err(), "{p}"); + } + assert_eq!(undo_len(&s), undo, "failed moves leave no undo step"); + assert!(doc(&s).swatch("Sunset").is_some_and(|w| matches!(w.paint, Paint::Gradient(_)))); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7382f4f2..7997e985 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -136,6 +136,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { handle_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); + paint_drop(app, ui, &resp, &xf); let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -667,14 +668,38 @@ fn node_outline(n: &Node) -> BezPath { bp } +/// The topmost editable object under document point `p` at `zoom` (3 px tolerance). +fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { + let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false }; + vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt) +} + +/// Swatches and Fill/Stroke proxies dropped on art paint the object under the pointer (the active +/// proxy's fill or stroke); a chip of the dragged paint follows the pointer meanwhile. +fn paint_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) { + use crate::panels::swatches; + swatches::drag_preview(app, ui.ctx()); + let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return }; + // Not through a floating panel over the canvas. + if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) { + return; + } + let Some(mut params) = swatches::released_paint(app, resp) else { return }; + let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; + params["ids"] = json!([hit.leaf.0]); + params["focus"] = json!(false); + if let Err(e) = app.run(crate::panels::proxy_cmd(app, false), params) { + app.status(e); + } +} + fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(h) = app.hover_doc else { return }; if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") { return; } let Some(st) = app.session.active() else { return }; - let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false }; - let Some(hit) = vectorcraft_doc::hit::hit_test(&st.doc, h, opt) else { return }; + let Some(hit) = hit_at(app, h, xf.zoom) else { return }; let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf }; if st.selection.contains(id) { return; @@ -1081,4 +1106,36 @@ mod tests { assert_eq!(app.view().unwrap().center, after.center); assert_eq!(app.session.active().unwrap().doc.art_bounds(), None); } + #[test] + fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() { + use crate::panels::swatches::SwatchDrag; + use vectorcraft_color::{Color, Paint}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let release = |at: Pos2| { + vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ] + }; + let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); + let drag = SwatchDrag { grabbed: "Red".into(), names: vec!["Red".into()], groups: false }; + // Off the art nothing happens. + egui::DragAndDrop::set_payload(&ctx, drag.clone()); + frame(&mut app, &ctx, release(xf.to_screen(Point::new(300.0, 250.0)))); + assert_eq!(fill(&app), Paint::solid(Color::WHITE)); + egui::DragAndDrop::set_payload(&ctx, drag); + frame(&mut app, &ctx, release(xf.to_screen(Point::new(100.0, 100.0)))); + assert_eq!(fill(&app), Paint::solid(Color::from_hex("#ed1c24").unwrap()), "paint.setFill with the hit id"); + assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was"); + // A proxy's paint works the same. + egui::DragAndDrop::set_payload(&ctx, Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + frame(&mut app, &ctx, release(xf.to_screen(Point::new(60.0, 60.0)))); + assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + } } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 585c4ba8..fee74ddb 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -25,22 +25,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut expanded: HashSet = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect()); let mut actions: Vec<(String, serde_json::Value)> = vec![]; // Search field ("Search All"). - let search_id = egui::Id::new("layers-search"); - let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); - egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, t.input_border)) - .corner_radius(egui::CornerRadius::same(2)) - .inner_margin(egui::Margin::symmetric(8, 5)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut query) - .frame(egui::Frame::NONE) - .hint_text(egui::RichText::new("Search All").italics()) - .desired_width(ui.available_width()), - ); - }); - ui.data_mut(|d| d.insert_temp(search_id, query.clone())); + crate::widgets::search_field(ui, egui::Id::new("layers-search"), "Search All"); ui.add_space(6.0); let h = ui.available_height() - 34.0; egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| { diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 5018fd29..84686a65 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -1,9 +1,14 @@ -//! Swatches panel: proxy, Recent Colors, thumbnail grid (15.5 pt tiles on a 17 pt pitch) or list, -//! colour groups as folders, None/Registration first, bottom bar and panel menu. +//! Swatches panel: proxy, Recent Colors, find field, thumbnail grid (15.5 pt tiles on a 17 pt pitch) +//! or list, colour groups as folders, None/Registration first, bottom bar and panel menu. +//! +//! Swatches drag to reorder, into and out of colour groups (`swatch.move`) and onto art (the active +//! proxy's paint command with the object's id); a Fill/Stroke proxy dropped on the panel becomes a +//! swatch (`swatch.new`). -use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use serde_json::json; -use vectorcraft_color::Paint; +use egui::{Color32, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use vectorcraft_color::{Color, GradientKind, Paint}; +use vectorcraft_doc::Document; use super::{active_paint, pstate, set_pstate}; use crate::theme::Tokens; @@ -90,29 +95,38 @@ impl Entry { Entry::Swatch { name, .. } | Entry::Folder(name) => name, } } + fn is_folder(&self) -> bool { + matches!(self, Entry::Folder(_)) + } } const REGISTRATION: &str = "[Registration]"; -fn entries(app: &VectorcraftApp, kind: Kind) -> Vec { +/// The panel's rows for the kind filter and the find field's `query` (a case-insensitive name +/// match; a colour group whose name matches shows all its swatches). +fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec { let Some(st) = app.session.active() else { return vec![] }; let d = &st.doc; + let q = query.trim().to_lowercase(); + let found = |n: &str| q.is_empty() || n.to_lowercase().contains(&q); let mut out = vec![]; let sw = |s: &vectorcraft_color::Swatch| Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global: s.global, spot: s.spot }; - // None first, then Registration, then the rest (Illustrator's order). + // None first, then Registration, then the rest (the reference app's order). let (specials, rest): (Vec<_>, Vec<_>) = d.swatches.iter().partition(|s| s.paint.is_none()); - for s in specials.iter().filter(|s| kind.accepts(&s.paint, false)) { + for s in specials.iter().filter(|s| kind.accepts(&s.paint, false) && found(&s.name)) { out.push(sw(s)); } - if matches!(kind, Kind::All | Kind::Color) { + if matches!(kind, Kind::All | Kind::Color) && found(REGISTRATION) { out.push(Entry::Registration); } - for s in rest.iter().filter(|s| kind.accepts(&s.paint, false)) { + for s in rest.iter().filter(|s| kind.accepts(&s.paint, false) && found(&s.name)) { out.push(sw(s)); } for g in &d.swatch_groups { - let items: Vec<_> = g.swatches.iter().filter(|s| kind.accepts(&s.paint, true)).collect(); - if items.is_empty() && kind != Kind::Groups && kind != Kind::All { + let all = found(&g.name); + let items: Vec<_> = g.swatches.iter().filter(|s| kind.accepts(&s.paint, true) && (all || found(&s.name))).collect(); + // Empty groups show only among all swatches or groups, and when the find field names them. + if items.is_empty() && !(all && matches!(kind, Kind::Groups | Kind::All)) { continue; } out.push(Entry::Folder(g.name.clone())); @@ -121,15 +135,20 @@ fn entries(app: &VectorcraftApp, kind: Kind) -> Vec { out } +/// The paint command params that apply swatch (or Registration) `name`; `None` for colour groups +/// and unknown names. +fn swatch_params(d: &Document, name: &str) -> Option { + if name == REGISTRATION { + return Some(json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}})); + } + Some(if d.swatch(name)?.paint.is_none() { json!({"none": true}) } else { json!({"swatch": name}) }) +} + /// Apply a clicked swatch to the active proxy (Alt: the inactive one). fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { - let params = match e { - Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}), - Entry::Swatch { paint, .. } if paint.is_none() => json!({"none": true}), - Entry::Swatch { name, .. } => json!({"swatch": name}), - Entry::Folder(_) => return, - }; - super::apply_click(app, ui, params); + if let Some(params) = app.session.active().and_then(|st| swatch_params(&st.doc, e.name())) { + super::apply_click(app, ui, params); + } } /// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). @@ -187,6 +206,82 @@ fn draw_folder(ui: &Ui, r: Rect) { icons::paint(ui, "dc-folder", r.expand(1.0), t.icon); } +/// The ink colours of the CMYK glyph (cyan, magenta, yellow, black) and the RGB glyph's bars. +const CMYK_INKS: [Color32; 4] = [Color32::from_rgb(0, 174, 239), Color32::from_rgb(236, 0, 140), Color32::from_rgb(255, 242, 0), Color32::BLACK]; +const RGB_BARS: [Color32; 3] = [Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 200, 0), Color32::from_rgb(0, 0, 255)]; + +/// A swatch's kind in words, for list tooltips ("Global Process Color, CMYK"). +fn describe(paint: &Paint, global: bool, spot: bool) -> String { + match paint { + Paint::Solid { color, .. } => { + let kind = if spot { + "Spot Color" + } else if global { + "Global Process Color" + } else { + "Process Color" + }; + format!("{kind}, {}", color.model_name()) + } + Paint::Gradient(g) => format!("{} Gradient", g.gradient.kind.label()), + Paint::Pattern { .. } => "Pattern".into(), + Paint::None => "None".into(), + } +} + +/// The kind and colour-mode icons at the right of list row `r`: spot (a dot in a ring), global (a +/// square with a filled corner) or process colours (a square), gradients and patterns; then a +/// colour's model (CMYK as four ink quarters, RGB as three bars, Gray as a grey square). +fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bool) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + let mode = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0)); + let kind = mode.translate(vec2(-16.0, 0.0)); + let line = Stroke::new(1.0, t.icon); + match paint { + Paint::Solid { color, .. } => { + if spot { + p.circle_stroke(kind.center(), 4.5, line); + p.circle_filled(kind.center(), 1.8, t.icon); + } else { + p.rect_stroke(kind, 0.0, line, StrokeKind::Inside); + if global { + let c = kind.right_bottom(); + p.add(Shape::convex_polygon(vec![c, c - vec2(6.0, 0.0), c - vec2(0.0, 6.0)], t.icon, Stroke::NONE)); + } + } + match color { + Color::Cmyk { .. } => { + let q = mode.size() / 2.0; + for (i, ink) in CMYK_INKS.into_iter().enumerate() { + p.rect_filled(Rect::from_min_size(mode.min + vec2((i % 2) as f32 * q.x, (i / 2) as f32 * q.y), q), 0.0, ink); + } + } + Color::Rgb { .. } => { + let w = mode.width() / 3.0; + for (i, bar) in RGB_BARS.into_iter().enumerate() { + p.rect_filled(Rect::from_min_size(mode.min + vec2(i as f32 * w, 0.0), vec2(w, mode.height())), 0.0, bar); + } + } + Color::Gray { .. } => { + p.rect_filled(mode, 0.0, Color32::from_gray(128)); + } + } + p.rect_stroke(mode, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + } + Paint::Gradient(g) => { + let icon = match g.gradient.kind { + GradientKind::Linear => "dc-grad-linear", + GradientKind::Radial => "dc-grad-radial", + GradientKind::Freeform => "dc-grad-freeform", + }; + icons::paint(ui, icon, kind.expand(1.0), t.icon); + } + Paint::Pattern { .. } => icons::paint(ui, "grid-3x3", kind.expand(1.0), t.icon), + Paint::None => {} + } +} + /// The swatches and colour groups selected in the panel, in click order, without names that no /// longer exist (deleted, renamed or undone). fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec { @@ -196,6 +291,16 @@ fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec { names } +/// The names the panel highlights: `sel` plus the swatches of selected colour groups (clicking a +/// folder selects the whole group). +fn highlighted<'a>(d: &'a Document, sel: &'a [String]) -> Vec<&'a str> { + let mut out: Vec<&str> = sel.iter().map(String::as_str).collect(); + for g in d.swatch_groups.iter().filter(|g| sel.contains(&g.name)) { + out.extend(g.swatches.iter().map(|w| w.name.as_str())); + } + out +} + /// The selection after a click on `name`: Cmd/Ctrl toggles it, Shift extends from the last clicked /// name over `order` (the names as displayed), a plain click selects it alone. fn click_selection(ui: &Ui, mut sel: Vec, order: &[&str], name: &str, m: egui::Modifiers) -> Vec { @@ -224,6 +329,223 @@ fn click_selection(ui: &Ui, mut sel: Vec, order: &[&str], name: &str, m: sel } +/// What the Swatches panel drags: the swatch, None, Registration or colour group under the pointer +/// when the drag started (it paints art it's dropped on) and the names it moves: the panel +/// selection when the grabbed one is part of it (only colour groups, or only swatches, like the +/// grabbed one), in panel order. +#[derive(Clone, Debug)] +pub(crate) struct SwatchDrag { + pub grabbed: String, + pub names: Vec, + /// The names are colour groups. + pub groups: bool, +} + +/// A drag released on the panel. +enum Drop { + /// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or + /// at the end (`None`). + Move { names: Vec, target: Option, after: bool }, + /// A Fill/Stroke proxy's paint dropped on row `target` (or between rows) becomes a swatch. + New { paint: Paint, target: Option }, +} + +/// A drag payload of type `T` released on `resp`. (egui's release takes any payload, so the type is +/// checked first: a payload of another type stays for the next drop target.) +fn released(resp: &Response) -> Option> { + resp.dnd_hover_payload::()?; + resp.dnd_release_payload::() +} + +/// What the tiles or rows saw this frame. +#[derive(Default)] +struct TileEvents { + clicked: Option<(Entry, egui::Modifiers)>, + /// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing). + edit: Option, + drop: Option, + /// A drag is held over a tile or row. + over: bool, +} + +/// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e` +/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it +/// starts a [`SwatchDrag`]; a drag held over it shows where it would land (before or after it by +/// the pointer's half, or into a folder) and a release there sets the drop. +fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) { + let name = e.name(); + if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() { + ev.edit = Some(name.to_string()); + } + if resp.drag_started() { + let with_sel = sel.iter().any(|s| s == name); + let names = items + .iter() + .filter(|x| x.is_folder() == e.is_folder() && (x.name() == name || (with_sel && sel.iter().any(|s| s == x.name())))) + .map(|x| x.name().to_string()) + .collect(); + egui::DragAndDrop::set_payload(ui.ctx(), SwatchDrag { grabbed: name.to_string(), names, groups: e.is_folder() }); + } + let moving = resp.dnd_hover_payload::().filter(|d| !d.names.iter().any(|n| n == name)); + if moving.is_some() || resp.dnd_hover_payload::().is_some() { + ev.over = true; + let t = Tokens::get(ui.ctx()); + let r = resp.rect; + let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center()); + let after = if list { at.y > r.center().y } else { at.x > r.center().x }; + // A proxy's paint, or swatches over a folder, go into it; otherwise a bar marks the slot. + if moving.as_ref().is_none_or(|d| e.is_folder() && !d.groups) { + ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); + } else if list { + let y = if after { r.bottom() } else { r.top() }; + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); + } else { + let x = if after { r.right() + 0.75 } else { r.left() - 0.75 }; + ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent)); + } + let target = Some(name.to_string()); + if let Some(d) = moving.and_then(|_| released::(&resp)) { + ev.drop = Some(Drop::Move { names: d.names.clone(), target, after }); + } else if let Some(p) = released::(&resp) { + ev.drop = Some(Drop::New { paint: (*p).clone(), target }); + } + } + let resp = match e { + Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")), + Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| { + ui.label(format!("{name} ({})", describe(paint, *global, *spot))); + }), + _ => resp.on_hover_text(name), + }; + if resp.clicked() { + ev.clicked = Some((e.clone(), ui.input(|i| i.modifiers))); + } +} + +/// Drops between and after the tiles (`zone`: the list's viewport) go to the end of the ungrouped +/// swatches (colour groups to the end of the groups); the list is outlined while one is held there. +fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) { + let held = zone.dnd_hover_payload::().is_some() || zone.dnd_hover_payload::().is_some(); + if held && !ev.over { + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if let Some(d) = released::(zone) { + ev.drop = Some(Drop::Move { names: d.names.clone(), target: None, after: false }); + } else if let Some(p) = released::(zone) { + ev.drop = Some(Drop::New { paint: (*p).clone(), target: None }); + } +} + +/// The colour group `name` is, or the one swatch `name` belongs to. +fn group_of(d: &Document, name: &str) -> Option { + if d.swatch_groups.iter().any(|g| g.name == name) { + return Some(name.to_string()); + } + d.swatch_group_of(name).map(|g| d.swatch_groups[g].name.clone()) +} + +/// `swatch.move` params for dropping `names` before or `after` row `target`, into folder `target` +/// (swatches), or at the end (`target` None); `None` when nothing would move. Only solid colours go +/// into colour groups; None and Registration stay put. +fn move_params(d: &Document, names: &[String], target: Option<&str>, after: bool) -> Option { + if target.is_some_and(|t| names.iter().any(|n| n == t)) { + return None; + } + let group_index = |n: &str| d.swatch_groups.iter().position(|g| g.name == n); + if names.iter().all(|n| group_index(n).is_some()) { + let to = match target { + None => d.swatch_groups.len(), + // Over a group's folder or one of its swatches: next to that group; else before the first. + Some(t) => group_index(t).or_else(|| d.swatch_group_of(t)).map_or(0, |i| i + usize::from(after)), + }; + return Some(json!({"names": names, "to": to})); + } + let (group, to) = match target { + Some(t) if group_index(t).is_some() => (Some(t), None), + Some(REGISTRATION) => (None, Some(d.swatches.iter().position(|w| !w.paint.is_none()).unwrap_or(0))), + Some(t) => { + let (group, list) = match d.swatch_group_of(t) { + Some(g) => (Some(d.swatch_groups[g].name.as_str()), &d.swatch_groups[g].swatches), + None => (None, &d.swatches), + }; + (group, list.iter().position(|w| w.name == t).map(|i| i + usize::from(after))) + } + None => (None, None), + }; + let movable: Vec<&String> = + names.iter().filter(|n| d.swatch(n).is_some_and(|w| !w.paint.is_none() && (group.is_none() || w.paint.color().is_some()))).collect(); + (!movable.is_empty()).then(|| json!({"names": movable, "to": to, "group": group})) +} + +/// Run a drop on the panel: move the swatches or make a swatch of the proxy's paint (a colour +/// dropped on a folder or a grouped swatch joins that group). +fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { + let Some(d) = app.session.active().map(|st| &st.doc) else { return }; + let (cmd, params) = match drop { + Drop::Move { names, target, after } => match move_params(d, &names, target.as_deref(), after) { + Some(p) => ("swatch.move", p), + None => return, + }, + Drop::New { paint, target } => { + let mut p = super::paint_params(&paint); + if paint.color().is_some() + && let Some(g) = target.and_then(|t| group_of(d, &t)) + { + p["group"] = json!(g); + } + ("swatch.new", p) + } + }; + if let Err(e) = app.run(cmd, params) { + app.status(e); + } +} + +/// Paint command params for swatches or a Fill/Stroke proxy released on `resp`: the grabbed swatch +/// (`None` for a colour group), or the proxy's paint (a gradient fits the new object afresh). +pub(crate) fn released_paint(app: &VectorcraftApp, resp: &Response) -> Option { + if let Some(d) = released::(resp) { + return swatch_params(&app.session.active()?.doc, &d.grabbed); + } + let mut paint = (*released::(resp)?).clone(); + if let Paint::Gradient(g) = &mut paint { + g.geom = None; + } + Some(super::paint_params(&paint)) +} + +/// While swatches or a Fill/Stroke proxy are dragged, a chip of the grabbed paint follows the +/// pointer. +pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { + let (paint, registration) = if let Some(d) = egui::DragAndDrop::payload::(ctx) { + match app.session.active().and_then(|st| st.doc.swatch(&d.grabbed)) { + Some(w) => (w.paint.clone(), false), + None if d.grabbed == REGISTRATION => (Paint::None, true), + None => return, + } + } else if let Some(p) = egui::DragAndDrop::payload::(ctx) { + ((*p).clone(), false) + } else { + return; + }; + let Some(at) = ctx.pointer_hover_pos() else { return }; + let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); + area.interactable(false).show(ctx, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + if registration { + draw_registration(ui, r); + } else { + swatch_tile(ui, r, &paint, false, false); + pattern_thumb(app, ui, r.shrink(1.0), &paint); + } + }); +} + +/// The id of the find field's text (shown with the panel menu's Show Find Field). +fn find_id() -> egui::Id { + egui::Id::new("swatch-find") +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if app.session.active().is_none() { @@ -247,7 +569,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { super::recent_colors_row(app, ui); widgets::divider(ui); widgets::subheader(ui, "Swatch Tiles"); - let items = entries(app, kind); + let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), "Find") } else { String::new() }; + let items = entries(app, kind, &query); let active = active_paint(app); let active_swatch = match &active { Paint::Solid { swatch: Some(n), .. } => Some(n.clone()), @@ -258,17 +581,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Without a selection the swatch of the active paint is highlighted. let fallback: Vec = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] }; let sel = if selected.is_empty() { &fallback } else { &selected }; - let is_sel = |name: &str| sel.iter().any(|s| s == name); + let lit = app.session.active().map(|st| highlighted(&st.doc, sel)).unwrap_or_default(); + let is_sel = |name: &str| lit.contains(&name); let (tile, pitch) = view.tile(); - let mut clicked: Option<(Entry, egui::Modifiers)> = None; - // Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing). - let mut edit: Option = None; + let mut ev = TileEvents::default(); widgets::list_box(ui, |ui| { - egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(if view == View::LargeThumb { 200.0 } else { 150.0 }).show(ui, |ui| { + let max_height = if view == View::LargeThumb { 200.0 } else { 150.0 }; + let out = egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(max_height).show(ui, |ui| { ui.set_width(ui.available_width()); if view.is_list() { for e in &items { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click()); + let (_, r) = ui.allocate_space(vec2(ui.available_width(), pitch)); + let resp = ui.interact(r, tile_id(e), Sense::click_and_drag()); let name = e.name(); if is_sel(name) { ui.painter().rect_filled(r, 0.0, t.row_selected); @@ -278,9 +602,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile)); match e { Entry::Registration => draw_registration(ui, chip), - Entry::Swatch { paint, .. } => { + Entry::Swatch { paint, global, spot, .. } => { swatch_tile(ui, chip, paint, false, false); pattern_thumb(app, ui, chip, paint); + list_icons(ui, r, paint, *global, *spot); } Entry::Folder(_) => draw_folder(ui, chip), } @@ -291,27 +616,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::FontId::proportional(12.0), t.text, ); - // Kind markers at the right: global (white corner) / spot (dot) / process. - if let Entry::Swatch { global, spot, paint, .. } = e { - let mk = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0)); - if *spot { - ui.painter().circle_filled(mk.center(), 3.5, t.icon); - } else if matches!(paint, Paint::Gradient(_)) { - icons::paint(ui, "dc-grad-linear", mk, t.icon); - } else if *global { - ui.painter().add(egui::Shape::convex_polygon( - vec![mk.left_bottom(), mk.right_bottom(), mk.right_top()], - t.icon, - Stroke::NONE, - )); - } - } - if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() { - edit = Some(name.to_string()); - } - if resp.on_hover_text(name).clicked() { - clicked = Some((e.clone(), ui.input(|i| i.modifiers))); - } + tile_input(ui, resp, e, &items, sel, true, &mut ev); } } else { let w = ui.available_width(); @@ -320,7 +625,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut rows: Vec> = vec![vec![]]; for e in &items { // A group folder starts a new row. - if matches!(e, Entry::Folder(_)) && col > 0 { + if e.is_folder() && col > 0 { rows.push(vec![]); col = 0; } @@ -335,23 +640,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); for (c, e) in row.into_iter().enumerate() { let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); - let resp = ui.interact(cell, tile_id(e), Sense::click()); + let resp = ui.interact(cell, tile_id(e), Sense::click_and_drag()); let name = e.name(); match e { Entry::Registration => draw_registration(ui, cell), - Entry::Swatch { paint, global, .. } => { + Entry::Swatch { paint, global, spot, .. } => { swatch_tile(ui, cell, paint, is_sel(name), resp.hovered()); pattern_thumb(app, ui, cell.shrink(1.0), paint); - if resp.double_clicked() { - edit = Some(name.to_string()); - } + // Global colours get a white corner; spot colours a dot in it. if *global { - let k = cell.shrink(1.0); - ui.painter().add(egui::Shape::convex_polygon( - vec![k.right_bottom(), k.right_bottom() - vec2(5.0, 0.0), k.right_bottom() - vec2(0.0, 5.0)], - egui::Color32::WHITE, - Stroke::NONE, - )); + let k = cell.shrink(1.0).right_bottom(); + let corner = vec![k, k - vec2(5.0, 0.0), k - vec2(0.0, 5.0)]; + ui.painter().add(Shape::convex_polygon(corner, Color32::WHITE, Stroke::NONE)); + if *spot { + ui.painter().circle_filled(k - vec2(1.6, 1.6), 1.0, Color32::BLACK); + } } } Entry::Folder(_) => { @@ -361,22 +664,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } } - let resp = match e { - Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")), - _ => resp.on_hover_text(name), - }; - if resp.clicked() { - clicked = Some((e.clone(), ui.input(|i| i.modifiers))); - } + tile_input(ui, resp, e, &items, sel, false, &mut ev); } } } }); + let zone = ui.interact(out.inner_rect, ui.id().with("swatch-drop"), Sense::hover()); + zone_input(ui, &zone, &mut ev); }); - if let Some(name) = edit { + if let Some(name) = ev.edit { app.run("ui.swatchOptions", json!({"name": name})).ok(); } - let selected = match clicked { + if let Some(drop) = ev.drop { + apply_drop(app, drop); + } + let selected = match ev.clicked { Some((e, m)) => { let order: Vec<&str> = items.iter().map(Entry::name).collect(); let sel = click_selection(ui, selected, &order, e.name(), m); @@ -392,7 +694,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { bottom(app, ui, &selected); } -/// The interaction id of a tile in the thumbnail views (stable per swatch or group name). +/// The interaction id of a tile or list row (stable per swatch or group name). fn tile_id(e: &Entry) -> egui::Id { egui::Id::new(("swatch-tile", e.name())) } @@ -463,12 +765,7 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { /// The colour group the selection points at: the last selected group, or the group of the last /// selected swatch. fn target_group(app: &VectorcraftApp, sel: &[String]) -> Option { - let d = &app.session.active()?.doc; - let last = sel.last()?; - if d.swatch_groups.iter().any(|g| g.name == *last) { - return Some(last.clone()); - } - d.swatch_group_of(last).map(|g| d.swatch_groups[g].name.clone()) + group_of(&app.session.active()?.doc, sel.last()?) } /// New Swatch from the active paint (a colour goes into the selected colour group): opens the @@ -523,6 +820,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } ui.separator(); + let find: bool = pstate(ui.ctx(), "swatch-show-find"); + if menu_item(ui, "Show Find Field", true, find) { + set_pstate(ui.ctx(), "swatch-show-find", !find); + } + ui.separator(); if menu_item(ui, "Swatch Options…", opts.is_some(), false) && let Some(n) = opts { @@ -752,6 +1054,106 @@ mod tests { assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()) }); } + /// The centre of the tile or row of `name` as laid out by the last frame. + fn tile_center(ctx: &egui::Context, name: &str) -> Pos2 { + ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center() + } + + /// Press at `from`, drag to `to` and release there, a frame per step from `time`. + fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, time: f64) { + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + frame(app, ctx, vec![Event::PointerMoved(from), button(from, true)], time, show); + frame(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], time + 0.1, show); + frame(app, ctx, vec![Event::PointerMoved(to)], time + 0.2, show); + frame(app, ctx, vec![Event::PointerMoved(to), button(to, false)], time + 0.3, show); + } + + fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec { + let d = &app.session.active().unwrap().doc; + let list = match group { + Some(g) => &d.swatch_groups.iter().find(|x| x.name == g).unwrap().swatches, + None => &d.swatches, + }; + list.iter().map(|w| w.name.clone()).collect() + } + + #[test] + fn find_field_filters_and_shift_extends_over_what_is_shown() { + let mut app = app(); + let ctx = context(); + set_pstate(&ctx, "swatch-show-find", true); + ctx.data_mut(|d| d.insert_temp(find_id(), "BRIGHT".to_string())); + frame(&mut app, &ctx, vec![], 0.0, show); + assert!(ctx.read_response(egui::Id::new(("swatch-tile", "Red"))).is_none(), "Red doesn't match"); + click_tile(&mut app, &ctx, "Bright Red", Modifiers::NONE, 1.0); + click_tile(&mut app, &ctx, "Bright Blue", Modifiers::SHIFT, 2.0); + assert_eq!(selected(&ctx), ["Bright Red", "Bright Yellow", "Bright Green", "Bright Blue"]); + // A group whose name matches shows all its swatches. + ctx.data_mut(|d| d.insert_temp(find_id(), "grays".to_string())); + frame(&mut app, &ctx, vec![], 3.0, show); + frame(&mut app, &ctx, vec![], 3.5, show); + assert!(ctx.read_response(egui::Id::new(("swatch-tile", "K=50"))).is_some()); + assert!(ctx.read_response(egui::Id::new(("swatch-tile", "Bright Red"))).is_none()); + } + + #[test] + fn list_view_rows_take_double_clicks_and_folder_clicks_select_the_group() { + let mut app = app(); + let ctx = context(); + set_pstate(&ctx, "swatch-view", View::SmallList); + double_click_tile(&mut app, &ctx, "Red"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND), "double-click in a list opens Swatch Options"); + let d = &app.session.active().unwrap().doc; + let sel = vec!["Brights".to_string()]; + assert!(highlighted(d, &sel).contains(&"Bright Violet"), "a selected folder highlights its swatches"); + assert_eq!(describe(&Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, false), "Global Process Color, CMYK"); + assert_eq!(describe(&Paint::solid(Color::gray(0.5)), true, true), "Spot Color, Grayscale"); + } + + #[test] + fn dragging_tiles_reorders_and_moves_into_groups_as_one_undo_step_each() { + let mut app = app(); + let ctx = context(); + frame(&mut app, &ctx, vec![], 0.0, show); + let undo = app.session.doc().unwrap().history.undo.len(); + // Onto the left half of Red: before it. + let (from, red) = (tile_center(&ctx, "Amber"), tile_center(&ctx, "Red")); + drag(&mut app, &ctx, from, red - vec2(4.0, 0.0), 1.0); + let n = names_of(&app, None); + assert_eq!(n[n.iter().position(|x| x == "Red").unwrap() - 1], "Amber"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Onto a folder: into that group, at its end. (Two frames, so tile rects are current.) + frame(&mut app, &ctx, vec![], 2.0, show); + frame(&mut app, &ctx, vec![], 2.5, show); + drag(&mut app, &ctx, tile_center(&ctx, "Amber"), tile_center(&ctx, "Grays"), 3.0); + assert_eq!(names_of(&app, Some("Grays")).last().map(String::as_str), Some("Amber")); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); + // A selection drags together, in panel order; a gradient can't join a group. + set_pstate(&ctx, "swatch-selected", vec!["Lime".to_string(), "Sunset".to_string(), "Orange".to_string()]); + frame(&mut app, &ctx, vec![], 4.0, show); + frame(&mut app, &ctx, vec![], 4.5, show); + drag(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Bright Red") - vec2(4.0, 0.0), 5.0); + assert_eq!(names_of(&app, Some("Brights"))[..3], ["Orange", "Lime", "Bright Red"]); + assert!(names_of(&app, None).contains(&"Sunset".to_string())); + } + + #[test] + fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { + let mut app = app(); + let ctx = context(); + frame(&mut app, &ctx, vec![], 0.0, show); + let at = tile_center(&ctx, "Bright Red"); + egui::DragAndDrop::set_payload(&ctx, Paint::solid(Color::from_hex("#123456").unwrap())); + let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show); + assert_eq!( + names_of(&app, Some("Brights")).last().map(String::as_str), + Some("R=18 G=52 B=86"), + "dropped on a group's colour: into that group" + ); + assert!(egui::DragAndDrop::payload::(&ctx).is_none()); + } + #[test] fn kind_filter() { let solid = Paint::solid(Color::BLACK); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a065000b..29f8a737 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -255,8 +255,14 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool let s = size * 0.62; let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s)); let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); - let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click()); - let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click()); + let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click_and_drag()); + let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click_and_drag()); + // A proxy drags its paint (onto the Swatches panel or art), unless it shows "?". + for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] { + if resp.drag_started() && !mixed { + egui::DragAndDrop::set_payload(ui.ctx(), paint.clone()); + } + } let question = |ui: &Ui, r: Rect, color: Color32| { ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(r.height() * 0.8), color); }; @@ -844,6 +850,29 @@ pub fn swatch_tile(ui: &Ui, rect: Rect, paint: &Paint, selected: bool, hovered: } } +/// A recessed search field with an italic `hint` (Layers' Search All, Swatches' Find), keeping its +/// text in egui memory under `id`. Returns the text. +pub fn search_field(ui: &mut Ui, id: egui::Id, hint: &str) -> String { + let t = Tokens::get(ui.ctx()); + let mut query: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(egui::CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(8, 5)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut query) + .id(id.with("edit")) + .frame(egui::Frame::NONE) + .hint_text(egui::RichText::new(hint).italics()) + .desired_width(ui.available_width()), + ); + }); + ui.data_mut(|d| d.insert_temp(id, query.clone())); + query +} + /// A bordered list box (Swatches tiles, Appearance rows, Artboards list). pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { let t = Tokens::get(ui.ctx()); diff --git a/docs/mcp.md b/docs/mcp.md index 345bdde9..89ae6946 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -94,6 +94,9 @@ swatches and colour groups in one undo step (art using a deleted global swatch k `swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient or pattern; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected art's colours (by default as global swatches the art links to). +`swatch.move {names, to?, group?}` reorders swatches or moves them into or out of a colour group (as dragging +them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art +in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `ids: [the object under the pointer]`. ## Resources From bdf8f704af549f0296a329d061e2ea46e390ca74 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:03:56 +0700 Subject: [PATCH 2/4] M3.31: Swatches panel menu adds used colours, selects unused, merges, ungroups and sorts by kind New commands: swatch.addUsedColors {selection?, global?} adds a swatch for each colour the art uses that no solid swatch has (optionally global, the art linking to them); swatch.unused lists the swatches nothing uses (links, colours, gradients and patterns in art, symbols, pattern tiles and graphic styles count); swatch.merge keeps the first colour and relinks the others' art; swatch.ungroup and swatch.sortByKind (process, spot, gradients, patterns). Each edit is one undo step; the panel menu items are enabled. Document::visit_paints walks every paint of a document. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/swatches.rs | 17 +++ crates/engine/src/cmd/swatch.rs | 201 +++++++++++++++++++++++--- crates/engine/src/tests_swatchcmds.rs | 115 +++++++++++++++ crates/ui-egui/src/panels/swatches.rs | 49 ++++++- docs/mcp.md | 3 + 5 files changed, 363 insertions(+), 22 deletions(-) diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 6ff1c173..4e99240b 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -105,6 +105,23 @@ impl Document { } } +impl Document { + /// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles + /// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint. + pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) { + let mut tree = |n: &Node| { + n.walk(&mut |m| { + node_paints(m).for_each(&mut *f); + if let NodeKind::Mesh(mesh) = &m.kind { + mesh.points.iter().for_each(|p| f(&Paint::solid(p.color))); + } + }) + }; + self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art)).for_each(|n| tree(n)); + self.graphic_styles.iter().flat_map(|gs| &gs.appearance.items).for_each(|it| f(item_paint(it))); + } +} + /// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0", /// "R=255 G=128 B=0", "Gray K=40"). pub fn color_name(c: Color) -> String { diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 4182f58a..63b35f35 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -68,6 +68,51 @@ pub fn specs() -> Vec { has_doc, swatch_move ), + cmd!( + "swatch.addUsedColors", + "Add Used Colors", + ["Window", "Swatches"], + None, + "{selection?: false (only the selected art's colours; default: all the art's), global?: false (the new swatches are global and the matching unlinked colours in that art link to them)} add a swatch for each colour used (fills, strokes, text, gradient stops, mesh points) that no solid swatch has yet, as one undo step → {added: [names], linked}", + has_doc, + swatch_add_used + ), + cmd!( + query "swatch.unused", + "Select All Unused", + ["Window", "Swatches"], + None, + "{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count). None is never listed", + has_doc, + swatch_unused + ), + cmd!( + "swatch.merge", + "Merge Swatches", + ["Window", "Swatches"], + None, + "{names: [two or more solid-colour swatches]} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), as one undo step → {name, merged: [deleted names], relinked}", + has_doc, + swatch_merge + ), + cmd!( + "swatch.ungroup", + "Ungroup Color Group", + ["Window", "Swatches"], + None, + "{name: colour group} move its swatches to the end of the ungrouped swatches and remove the group, as one undo step → {swatches: [names]}", + has_doc, + swatch_ungroup + ), + cmd!( + "swatch.sortByKind", + "Sort by Kind", + ["Window", "Swatches"], + None, + "{} order the swatches of each list by kind (None first, then process colours, spot colours, gradients, patterns; colour groups stay after them), keeping their order within a kind, as one undo step", + has_doc, + swatch_sort_by_kind + ), ] } @@ -176,6 +221,27 @@ fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec< } } +/// Link the solid paints in the subtrees of `ids` whose colour is one of `links`' to that swatch, +/// unless they already link to an existing swatch. Returns the number of paints linked. +fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> usize { + if links.is_empty() { + return 0; + } + let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); + d.map_solid_paints_in(ids, &mut |c, link| { + if link.as_ref().is_some_and(|l| live.contains(l)) { + return false; + } + match links.iter().find(|(lc, _)| lc == c) { + Some((_, n)) => { + *link = Some(n.clone()); + true + } + None => false, + } + }) +} + fn swatch_new_group(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.newGroup"; let mut names = str_list(p, "swatches"); @@ -216,23 +282,7 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { } let members: Vec = group.swatches.iter().map(|w| w.name.clone()).collect(); d.swatch_groups.push(group); - let linked = if links.is_empty() { - 0 - } else { - let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); - d.map_solid_paints_in(&sel.objects, &mut |c, link| { - if link.as_ref().is_some_and(|l| live.contains(l)) { - return false; - } - match links.iter().find(|(lc, _)| lc == c) { - Some((_, n)) => { - *link = Some(n.clone()); - true - } - None => false, - } - }) - }; + let linked = link_colors(d, &sel.objects, &links); Ok((name, members, linked)) })?; Ok(json!({"name": name, "swatches": members, "linked": linked})) @@ -481,3 +531,120 @@ fn swatch_sort(s: &mut Session, _: &Value) -> Result { })?; ok() } + +fn swatch_add_used(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.addUsedColors"; + let global = bool_or(p, "global", false); + let only_selection = bool_or(p, "selection", false); + let (added, linked) = s.edit("Add Used Colors", |d, sel| { + let ids: Vec = if only_selection { sel.objects.clone() } else { d.layers.iter().map(|l| l.id).collect() }; + if only_selection && ids.is_empty() { + return Err(bad(C, "select artwork to add its colours")); + } + let (mut linked_to, mut colors) = (vec![], vec![]); + artwork_colors(d, &ids, false, &mut linked_to, &mut colors); + // Colours a solid swatch already has are skipped. + colors.retain(|c| !d.swatches_iter().any(|w| w.paint.color() == Some(*c))); + let mut links = vec![]; + for c in colors { + let name = d.free_swatch_name(&color_name(c)); + d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false }); + links.push((c, name)); + } + let linked = if global { link_colors(d, &ids, &links) } else { 0 }; + Ok((links.into_iter().map(|(_, n)| n).collect::>(), linked)) + })?; + Ok(json!({"added": added, "linked": linked})) +} + +fn swatch_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let (mut links, mut colors, mut gradients, mut patterns) = (vec![], vec![], vec![], vec![]); + d.visit_paints(&mut |p| match p { + Paint::Solid { swatch: Some(l), .. } if d.swatch(l).is_some() => links.push(l.clone()), + Paint::Solid { color, .. } => colors.push(*color), + Paint::Gradient(g) => { + links.extend(g.swatch.clone()); + colors.extend(g.gradient.stops.iter().map(|s| s.color)); + gradients.push(g.gradient.clone()); + } + Paint::Pattern { pattern, .. } => patterns.push(pattern.clone()), + Paint::None => {} + }); + let used = |w: &Swatch| { + links.contains(&w.name) + || match &w.paint { + Paint::None => true, + Paint::Solid { color, .. } => colors.contains(color), + Paint::Gradient(g) => gradients.contains(&g.gradient), + Paint::Pattern { pattern, .. } => patterns.contains(pattern), + } + }; + let names: Vec<&str> = d.swatches_iter().filter(|w| !used(w)).map(|w| w.name.as_str()).collect(); + Ok(json!({ "names": names })) +} + +fn swatch_merge(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.merge"; + let names = names_param(p, C)?; + let d = &s.doc()?.doc; + for n in &names { + if d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?.paint.color().is_none() { + return Err(bad(C, format!("`{n}` isn't a solid colour"))); + } + } + let (keep, merged) = names.split_first().ok_or_else(|| bad(C, "give two or more swatches"))?; + if merged.is_empty() { + return Err(bad(C, "give two or more swatches")); + } + let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?; + let (color, link) = (kept.paint.color().unwrap_or_default(), kept.global.then(|| keep.clone())); + let mut relink = |c: &mut Color, l: &mut Option| { + if !l.as_ref().is_some_and(|l| merged.contains(l)) { + return false; + } + *c = color; + *l = link.clone(); + true + }; + let relinked = s.edit("Merge Swatches", |d, _| { + for n in merged { + d.remove_swatch(n); + } + Ok(d.map_solid_paints(&mut relink)) + })?; + map_default_paints(s, &mut relink); + Ok(json!({"name": keep, "merged": merged, "relinked": relinked})) +} + +fn swatch_ungroup(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.ungroup"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); + let moved = s.edit("Ungroup Color Group", |d, _| { + let i = d.swatch_groups.iter().position(|g| g.name == name).ok_or_else(|| bad(C, format!("no colour group `{name}`")))?; + let g = d.swatch_groups.remove(i); + let names: Vec = g.swatches.iter().map(|w| w.name.clone()).collect(); + d.swatches.extend(g.swatches); + Ok(names) + })?; + Ok(json!({ "swatches": moved })) +} + +fn swatch_sort_by_kind(s: &mut Session, _: &Value) -> Result { + let rank = |w: &Swatch| match &w.paint { + Paint::None => 0, + Paint::Solid { .. } if !w.spot => 1, + Paint::Solid { .. } => 2, + Paint::Gradient(_) => 3, + Paint::Pattern { .. } => 4, + }; + s.edit("Sort Swatches", |d, _| { + // A stable sort: swatches keep their order within a kind. + d.swatches.sort_by_key(rank); + for g in &mut d.swatch_groups { + g.swatches.sort_by_key(rank); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests_swatchcmds.rs index 989357e8..457bc3f8 100644 --- a/crates/engine/src/tests_swatchcmds.rs +++ b/crates/engine/src/tests_swatchcmds.rs @@ -76,3 +76,118 @@ fn move_reorders_groups_and_refuses_bad_moves() { assert_eq!(undo_len(&s), undo, "failed moves leave no undo step"); assert!(doc(&s).swatch("Sunset").is_some_and(|w| matches!(w.paint, Paint::Gradient(_)))); } + +fn rect_filled(s: &mut Session, fill: Value) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + let mut p = fill; + p["ids"] = json!([id.0]); + run(s, "paint.setFill", p); + run(s, "paint.setStroke", json!({"ids": [id.0], "none": true})); + id +} + +fn fill(s: &Session, id: NodeId) -> Paint { + doc(s).node(id).unwrap().appearance.fill_paint() +} + +#[test] +fn add_used_colors_skips_colours_that_have_swatches() { + let mut s = session(); + rect_filled(&mut s, json!({"color": "#123456"})); + rect_filled(&mut s, json!({"color": "#ed1c24"})); + rect_filled(&mut s, json!({"swatch": "Orange"})); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.addUsedColors", json!({})); + assert_eq!(r, json!({"added": ["R=18 G=52 B=86"], "linked": 0}), "Red and Orange are swatches already"); + assert_eq!(undo_len(&s), undo + 1); + assert_eq!(run(&mut s, "swatch.addUsedColors", json!({}))["added"], json!([])); + // Only the selection's colours, as global swatches its paints link to. + let d = rect_filled(&mut s, json!({"color": "#abcdef"})); + rect_filled(&mut s, json!({"color": "#fedcba"})); + run(&mut s, "select.set", json!({"ids": [d.0]})); + let r = run(&mut s, "swatch.addUsedColors", json!({"selection": true, "global": true})); + assert_eq!(r, json!({"added": ["R=171 G=205 B=239"], "linked": 1})); + assert_eq!(fill(&s, d), Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()) }); + assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global); +} + +#[test] +fn unused_leaves_out_linked_colours_gradients_and_patterns_in_use() { + let mut s = session(); + let all = run(&mut s, "swatch.unused", json!({})); + assert!(!all["names"].as_array().unwrap().contains(&json!("[None]"))); + assert!(all["names"].as_array().unwrap().contains(&json!("Sunset"))); + run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true})); + rect_filled(&mut s, json!({"swatch": "Teal"})); + run(&mut s, "swatch.edit", json!({"name": "Teal", "color": "#010203"})); + rect_filled(&mut s, json!({"swatch": "Sunset"})); + rect_filled(&mut s, json!({"color": "#ed1c24"})); + let r = rect_filled(&mut s, json!({"color": "#ff0000"})); + run(&mut s, "select.set", json!({"ids": [r.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + rect_filled(&mut s, json!({"swatch": "Dots"})); + let names = run(&mut s, "swatch.unused", json!({}))["names"].clone(); + let names: Vec<&str> = names.as_array().unwrap().iter().filter_map(Value::as_str).collect(); + for used in ["Teal", "Sunset", "Red", "Dots", "White"] { + assert!(!names.contains(&used), "{used} is used: {names:?}"); + } + for unused in ["Orange", "Ocean", "Bright Red", "K=50"] { + assert!(names.contains(&unused), "{unused} is unused: {names:?}"); + } +} + +#[test] +fn merge_keeps_the_first_and_relinks_the_others_art() { + let mut s = session(); + for n in ["Red", "Orange"] { + run(&mut s, "swatch.edit", json!({"name": n, "global": true})); + } + let a = rect_filled(&mut s, json!({"swatch": "Red"})); + let b = rect_filled(&mut s, json!({"swatch": "Orange"})); + let red = doc(&s).swatch("Red").unwrap().paint.color().unwrap(); + let before = doc(&s).clone(); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.merge", json!({"names": ["Red", "Orange"]})); + assert_eq!(r, json!({"name": "Red", "merged": ["Orange"], "relinked": 1})); + assert!(doc(&s).swatch("Orange").is_none()); + assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()) }); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); + // A kept swatch that isn't global leaves the merged swatches' art unlinked, in its colour. + run(&mut s, "swatch.merge", json!({"names": ["Lime", "Red", "Bright Red"]})); + let lime = doc(&s).swatch("Lime").unwrap().paint.color().unwrap(); + assert_eq!(fill(&s, a), Paint::solid(lime)); + assert!(doc(&s).swatch("Red").is_none() && doc(&s).swatch("Bright Red").is_none()); + for bad in [json!({"names": ["Lime"]}), json!({"names": ["Lime", "Sunset"]}), json!({"names": ["Lime", "Nope"]})] { + assert!(s.execute("swatch.merge", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn ungroup_and_sort_by_kind_are_one_undo_step_each() { + let mut s = session(); + let undo = undo_len(&s); + assert_eq!(run(&mut s, "swatch.ungroup", json!({"name": "Brights"}))["swatches"].as_array().unwrap().len(), 5); + assert!(doc(&s).swatch_groups.iter().all(|g| g.name != "Brights")); + assert_eq!(names(&s, None).last().map(String::as_str), Some("Bright Violet")); + assert!(s.execute("swatch.ungroup", &json!({"name": "Red"})).is_err()); + // A gradient first, a spot colour among the process ones. + run(&mut s, "swatch.move", json!({"name": "Ocean", "to": 0})); + run(&mut s, "swatch.edit", json!({"name": "Orange", "spot": true})); + run(&mut s, "swatch.sortByKind", json!({})); + assert_eq!(undo_len(&s), undo + 4); + let rank = |w: &vectorcraft_color::Swatch| match &w.paint { + Paint::None => 0, + Paint::Solid { .. } if !w.spot => 1, + Paint::Solid { .. } => 2, + Paint::Gradient(_) => 3, + Paint::Pattern { .. } => 4, + }; + let ranks: Vec = doc(&s).swatches.iter().map(rank).collect(); + assert!(ranks.is_sorted(), "{ranks:?}"); + let n = names(&s, None); + assert_eq!((n[0].as_str(), n[1].as_str()), ("[None]", "White"), "the order within a kind is kept"); + assert_eq!(n[n.iter().position(|x| x == "Orange").unwrap() + 1], "Ocean", "the spot colour comes after the process ones, then the gradients"); +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 84686a65..b43a1379 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -713,6 +713,18 @@ fn editable(app: &VectorcraftApp, sel: &[String]) -> Option { } } +/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first). +fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + sel.iter().filter(|n| st.doc.swatch(n).is_some_and(|w| w.paint.color().is_some())).cloned().collect() +} + +/// The first selected colour group (Ungroup Color Group). +fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option { + let d = &app.session.active()?.doc; + sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned() +} + /// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click). fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { let params = json!({"names": names}); @@ -801,18 +813,36 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { { app.run("swatch.duplicate", json!({"name": n})).ok(); } - menu_item(ui, "Merge Swatches", false, false); + // The first selected colour is kept. + let merge = mergeable(app, &selected); + if menu_item(ui, "Merge Swatches", merge.len() > 1, false) { + app.run("swatch.merge", json!({ "names": merge })).ok(); + } if menu_item(ui, "Delete Swatch", !del.is_empty(), false) { delete(app, del, false); } - menu_item(ui, "Ungroup Color Group", false, false); - menu_item(ui, "Select All Unused", false, false); - menu_item(ui, "Add Used Colors", false, false); + let group = selected_group(app, &selected); + if menu_item(ui, "Ungroup Color Group", group.is_some(), false) + && let Some(g) = group + { + app.run("swatch.ungroup", json!({ "name": g })).ok(); + } + if menu_item(ui, "Select All Unused", true, false) + && let Ok(r) = app.run("swatch.unused", json!({})) + { + let names: Vec = r["names"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + set_pstate(ui.ctx(), "swatch-selected", names); + } + if menu_item(ui, "Add Used Colors", true, false) { + app.run("swatch.addUsedColors", json!({})).ok(); + } ui.separator(); if menu_item(ui, "Sort by Name", true, false) { app.run("swatch.sortByName", json!({})).ok(); } - menu_item(ui, "Sort by Kind", false, false); + if menu_item(ui, "Sort by Kind", true, false) { + app.run("swatch.sortByKind", json!({})).ok(); + } ui.separator(); for (v, label) in View::ALL { if menu_item(ui, label, true, v == view) { @@ -1154,6 +1184,15 @@ mod tests { assert!(egui::DragAndDrop::payload::(&ctx).is_none()); } + #[test] + fn menu_targets_follow_the_panel_selection() { + let app = app(); + let sel: Vec = ["Red", "Sunset", "Grays", "Orange", "Brights"].map(String::from).to_vec(); + assert_eq!(mergeable(&app, &sel), ["Red", "Orange"], "Merge Swatches takes the selected colours, first kept"); + assert_eq!(selected_group(&app, &sel).as_deref(), Some("Grays")); + assert_eq!(selected_group(&app, &sel[..2]), None); + } + #[test] fn kind_filter() { let solid = Paint::solid(Color::BLACK); diff --git a/docs/mcp.md b/docs/mcp.md index 89ae6946..2f74c42e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -97,6 +97,9 @@ art's colours (by default as global swatches the art links to). `swatch.move {names, to?, group?}` reorders swatches or moves them into or out of a colour group (as dragging them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `ids: [the object under the pointer]`. +The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the +names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and +`swatch.sortByKind`. ## Resources From f48ba2638043e870e0693e0a5aab3a8199a841c0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:10:15 +0700 Subject: [PATCH 3/4] M3.32: the document colour mode governs new colours A CMYK document (file.new {colorMode: "cmyk"}, Document::new_with_mode) starts with CMYK default swatches (default_swatches(model)), and RGB colours and gradient stops applied to it are stored as CMYK (Gray stays Gray) unless paint.setFill/setStroke get keepModel; the Color panel keeps the model picked on its sliders. RGB documents keep colours as given. Harmonies and Color Guide variations keep the base colour's model, Recolor Artwork the replaced colour's, and Edit Colors blends between models take the document's, through a shared Color::in_model / keep_model. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/harmony.rs | 24 +++++++++---- crates/color/src/lib.rs | 49 ++++++++++++++++++++++++++ crates/color/src/swatch.rs | 49 ++++++++++++++++++++++++-- crates/doc/src/lib.rs | 21 +++++++++-- crates/engine/src/cmd/colorcmds.rs | 28 ++++----------- crates/engine/src/cmd/edit.rs | 11 +++--- crates/engine/src/cmd/paint.rs | 22 +++++++++--- crates/engine/src/cmd/recolor.rs | 6 ++-- crates/engine/src/tests_swatchcmds.rs | 50 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/color.rs | 3 +- docs/mcp.md | 4 +++ 11 files changed, 221 insertions(+), 46 deletions(-) diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs index 01336d20..fe170d8f 100644 --- a/crates/color/src/harmony.rs +++ b/crates/color/src/harmony.rs @@ -1,6 +1,6 @@ //! Colour harmony rules (the Color Guide panel). -use crate::Color; +use crate::{Color, keep_model}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Harmony { @@ -40,10 +40,10 @@ impl Harmony { Harmony::Shades => "Shades", } } - /// Colours of the harmony, base colour first. + /// Colours of the harmony, base colour first, in the base colour's model. pub fn apply(self, base: Color) -> Vec { let [h, s, v] = base.to_hsb(); - let hue = |d: f32| Color::from_hsb(h + d, s, v); + let hue = |d: f32| keep_model(base, Color::from_hsb(h + d, s, v)); match self { Harmony::Complementary => vec![base, hue(180.0)], Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)], @@ -53,18 +53,19 @@ impl Harmony { Harmony::Triad => vec![base, hue(120.0), hue(240.0)], Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)], Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)], - Harmony::Shades => (0..5).map(|i| Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0))).collect(), + Harmony::Shades => (0..5).map(|i| keep_model(base, Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0)))).collect(), } } } -/// Tints and shades row for the Color Guide variation grid. +/// Tints and shades row for the Color Guide variation grid, in the base colour's model. pub fn variations(base: Color, steps: usize) -> Vec { let n = steps.max(1) as f32; (0..steps) .map(|i| { let t = (i as f32 + 1.0) / (n + 1.0); - if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) } + let c = if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) }; + keep_model(base, c) }) .collect() } @@ -80,4 +81,15 @@ mod tests { assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00"); assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6); } + + #[test] + fn harmonies_keep_the_base_model() { + let base = Color::cmyk(0.0, 1.0, 1.0, 0.0); + for h in Harmony::ALL { + assert!(h.apply(base).iter().all(|c| matches!(c, Color::Cmyk { .. })), "{}", h.label()); + } + let Color::Cmyk { c, m, y, .. } = Harmony::Complementary.apply(base)[1] else { unreachable!() }; + assert!(c > m && c > y, "the complement of red is a cyan: {c} {m} {y}"); + assert!(variations(Color::gray(0.4), 4).iter().all(|c| matches!(c, Color::Gray { .. }))); + } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index a115f88e..a3dc3692 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -205,6 +205,36 @@ impl Color { let b = other.to_rgb(); Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) } + /// The colour model this colour is authored in. + pub fn model(&self) -> cms::Model { + match self { + Color::Rgb { .. } => cms::Model::Rgb, + Color::Cmyk { .. } => cms::Model::Cmyk, + Color::Gray { .. } => cms::Model::Gray, + } + } + /// This colour expressed in `model` with the profile-free formulas Edit Colors uses (RGB from + /// the display colour, CMYK by [`Color::to_cmyk`], Gray as ink from luminance); unchanged when it + /// is in `model` already. + pub fn in_model(self, model: cms::Model) -> Color { + if self.model() == model { + return self; + } + match model { + cms::Model::Rgb => { + let [r, g, b] = self.to_rgb(); + Color::rgb(r, g, b) + } + cms::Model::Cmyk => { + let [c, m, y, k] = self.to_cmyk(); + Color::cmyk(c, m, y, k) + } + cms::Model::Gray => { + let [r, g, b] = self.to_rgb(); + Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0)) + } + } + } pub fn model_name(&self) -> &'static str { match self { Color::Rgb { .. } => "RGB", @@ -214,6 +244,12 @@ impl Color { } } +/// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations +/// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from. +pub fn keep_model(orig: Color, new: Color) -> Color { + new.in_model(orig.model()) +} + /// What fills or strokes an object. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] @@ -322,6 +358,19 @@ mod tests { assert_eq!(Color::rgb(1.0, 1.0, 0.0).invert_keep_model(), Color::rgb(0.0, 0.0, 1.0)); } + #[test] + fn colours_convert_between_models_and_keep_them() { + use cms::Model; + let red = Color::rgb(1.0, 0.0, 0.0); + assert_eq!(red.in_model(Model::Cmyk), Color::cmyk(0.0, 1.0, 1.0, 0.0)); + assert_eq!(red.in_model(Model::Rgb), red, "already RGB: unchanged"); + assert_eq!(Color::gray(0.25).in_model(Model::Gray), Color::gray(0.25)); + assert_eq!(Color::WHITE.in_model(Model::Gray), Color::gray(0.0)); + assert_eq!(keep_model(Color::cmyk(0.1, 0.2, 0.3, 0.0), red).model(), Model::Cmyk); + assert_eq!(keep_model(Color::gray(0.5), red).model(), Model::Gray); + assert_eq!(keep_model(red, Color::rgb(0.0, 0.5, 1.0)), Color::rgb(0.0, 0.5, 1.0)); + } + #[test] fn paint_serde() { let p = Paint::solid(Color::rgb8(10, 20, 30)); diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index 8423371a..e87b5555 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; +use crate::cms::Model; use crate::{Color, Gradient, GradientKind, GradientStop, Paint}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -26,9 +27,33 @@ fn solid(name: &str, hex: &str) -> Swatch { Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false } } +/// The default swatches of a document in colour `model`: [`base_swatches`] with their RGB colours +/// (gradient stops too) as whole-percent CMYK in a CMYK document. Greys stay Gray. +pub fn default_swatches(model: Model) -> (Vec, Vec) { + let (mut s, mut g) = base_swatches(); + if model == Model::Cmyk { + let cmyk = |c: &mut Color| { + if let Color::Rgb { .. } = c { + let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() / 100.0; + let [cc, m, y, k] = c.to_cmyk(); + *c = Color::cmyk(pct(cc), pct(m), pct(y), pct(k)); + } + }; + for w in s.iter_mut().chain(g.iter_mut().flat_map(|g| g.swatches.iter_mut())) { + match &mut w.paint { + Paint::Solid { color, .. } => cmyk(color), + Paint::Gradient(gp) => gp.gradient.stops.iter_mut().for_each(|st| cmyk(&mut st.color)), + _ => {} + } + } + } + (s, g) +} + /// The default document swatches: None, Registration-like black, white, black, a spectrum, greys, -/// gradients. (Composition is ours; Illustrator's layout grammar — None first, then specials — is kept.) -pub fn default_swatches() -> (Vec, Vec) { +/// gradients. (The composition is ours; the reference app's layout grammar — None first, then +/// specials — is kept.) +fn base_swatches() -> (Vec, Vec) { let mut s = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, solid("White", "#ffffff"), solid("Black", "#000000")]; let spectrum = [ @@ -110,10 +135,28 @@ mod tests { #[test] fn defaults_start_with_none() { - let (s, g) = default_swatches(); + let (s, g) = default_swatches(Model::Rgb); assert!(s[0].paint.is_none()); assert!(s.len() > 30); assert_eq!(g.len(), 2); assert_eq!(g[0].swatches.len(), 9); } + + #[test] + fn cmyk_defaults_are_cmyk_with_the_same_names() { + let (rgb, _) = default_swatches(Model::Rgb); + let (s, g) = default_swatches(Model::Cmyk); + assert_eq!(s.iter().map(|w| &w.name).collect::>(), rgb.iter().map(|w| &w.name).collect::>()); + let colors = s.iter().chain(g.iter().flat_map(|g| &g.swatches)).flat_map(|w| match &w.paint { + Paint::Solid { color, .. } => vec![*color], + Paint::Gradient(gp) => gp.gradient.stops.iter().map(|st| st.color).collect(), + _ => vec![], + }); + assert!(colors.clone().all(|c| !matches!(c, Color::Rgb { .. }))); + assert!(colors.clone().any(|c| matches!(c, Color::Gray { .. })), "greys stay Gray"); + let white = s.iter().find(|w| w.name == "White").and_then(|w| w.paint.color()); + assert_eq!(white, Some(Color::cmyk(0.0, 0.0, 0.0, 0.0))); + let black = s.iter().find(|w| w.name == "Black").and_then(|w| w.paint.color()); + assert_eq!(black, Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b242ae03..998627d5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -206,6 +206,16 @@ pub enum ColorMode { Cmyk, } +impl ColorMode { + /// The colour model new colours take in a document of this mode. + pub fn model(self) -> vectorcraft_color::cms::Model { + match self { + ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb, + ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { pub id: u32, @@ -342,15 +352,20 @@ fn ppi72() -> f64 { pub const FORMAT_VERSION: u32 = 1; impl Document { - /// A new document with one artboard of `size` and one layer ("Layer 1"). + /// A new RGB document with one artboard of `size` and one layer ("Layer 1"). pub fn new(width: f64, height: f64) -> Self { - let (swatches, swatch_groups) = vectorcraft_color::default_swatches(); + Self::new_with_mode(width, height, ColorMode::Rgb) + } + + /// [`Document::new`] in colour `mode`, with that mode's default swatches. + pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self { + let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model()); let mut d = Self { version: FORMAT_VERSION, title: "Untitled-1".into(), template: false, units: Unit::Points, - color_mode: ColorMode::Rgb, + color_mode: mode, artboards: vec![Artboard { id: 1, name: "Artboard 1".into(), diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 632c49e8..630bef24 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::cms::Model; +use vectorcraft_color::{Color, Paint, keep_model}; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; use super::edit::selected_roots; @@ -100,29 +101,12 @@ pub(crate) fn invert(c: Color) -> Color { c.invert_keep_model() } -/// Express `new` in the colour model of `orig`. -fn keep_model(orig: Color, new: Color) -> Color { - match orig { - Color::Rgb { .. } => { - let [r, g, b] = new.to_rgb(); - Color::rgb(r, g, b) - } - Color::Cmyk { .. } => to_cmyk(new), - Color::Gray { .. } => to_gray(new), - } -} - pub(crate) fn to_cmyk(c: Color) -> Color { - let [c, m, y, k] = c.to_cmyk(); - Color::cmyk(c, m, y, k) + c.in_model(Model::Cmyk) } pub(crate) fn to_gray(c: Color) -> Color { - if let Color::Gray { .. } = c { - return c; - } - let [r, g, b] = c.to_rgb(); - Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0)) + c.in_model(Model::Gray) } /// Apply `f` to a paint; returns whether anything changed. @@ -318,6 +302,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { } let first = d.node(ids[0]).and_then(fill_color).unwrap_or_default(); let last = d.node(*ids.last().unwrap()).and_then(fill_color).unwrap_or_default(); + // Ends in one model blend in it; mixed ones give colours in the document's model. + let model = if first.model() == last.model() { first.model() } else { d.color_mode.model() }; let n = ids.len(); let label = match order { BlendOrder::Stack => "Blend Front to Back", @@ -327,7 +313,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { let changed = s.edit(label, |d, _| { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { - let c = lerp_model(first, last, i as f32 / (n - 1) as f32); + let c = lerp_model(first, last, i as f32 / (n - 1) as f32).in_model(model); if let Some(node) = d.node_mut(*id) { changed += map_node_colors(node, &|_| c, true, false); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4f269465..02ba945f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "New…", ["File"], Some("Cmd+N"), - "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\"}", + "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}", always, file_new ), @@ -58,14 +58,15 @@ fn file_new(s: &mut Session, p: &Value) -> Result { if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 { return Err(bad("file.new", "width/height must be positive and within the canvas")); } - let mut d = Document::new(w, h); + let mode = match str_param(p, "colorMode") { + Some(m) if m.eq_ignore_ascii_case("cmyk") => vectorcraft_doc::ColorMode::Cmyk, + _ => vectorcraft_doc::ColorMode::Rgb, + }; + let mut d = Document::new_with_mode(w, h, mode); d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled()); if let Some(u) = str_param(p, "units") { d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?; } - if str_param(p, "colorMode").is_some_and(|m| m.eq_ignore_ascii_case("cmyk")) { - d.color_mode = vectorcraft_doc::ColorMode::Cmyk; - } let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; for i in 1..n { let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index a7e27132..32f6834a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), @@ -261,8 +261,21 @@ fn stroke_param(s: &Session, p: &Value) -> bool { p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active) } -/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. +/// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in +/// an RGB one, or with `keepModel`). +fn new_color_model(s: &Session, p: &Value) -> Option { + let mode = s.active()?.doc.color_mode; + (mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model()) +} + +/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A +/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]). pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { + let model = new_color_model(s, p); + let new_color = |c: Color| match (c, model) { + (Color::Rgb { .. }, Some(m)) => c.in_model(m), + _ => c, + }; if bool_or(p, "none", false) { return Ok(Some(Paint::None)); } @@ -282,12 +295,13 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { return Ok(Some(paint)); } if let Some(g) = p.get("gradient") { - let gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; + let mut gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; + gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color)); return Ok(Some(Paint::Gradient(Box::new(gp)))); } if let Some(c) = p.get("color") { let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?; - return Ok(Some(Paint::solid(c))); + return Ok(Some(Paint::solid(new_color(c)))); } Ok(None) } diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 2ab32e3a..d76f9b1e 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{Color, Paint, keep_model}; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use super::*; @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included)", + "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included); each new colour keeps the model of the one it replaces", has_selection, apply ), @@ -81,7 +81,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { if let Some(n) = d.node_mut(*id) { visit_paints(n, &mut |c| { if let Some(to) = map.get(&c.to_hex()) { - *c = *to; + *c = keep_model(*c, *to); changed += 1; } }); diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests_swatchcmds.rs index 457bc3f8..96ce2936 100644 --- a/crates/engine/src/tests_swatchcmds.rs +++ b/crates/engine/src/tests_swatchcmds.rs @@ -191,3 +191,53 @@ fn ungroup_and_sort_by_kind_are_one_undo_step_each() { assert_eq!((n[0].as_str(), n[1].as_str()), ("[None]", "White"), "the order within a kind is kept"); assert_eq!(n[n.iter().position(|x| x == "Orange").unwrap() + 1], "Ocean", "the spot colour comes after the process ones, then the gradients"); } + +#[test] +fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { + use vectorcraft_color::Color; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"})); + assert_eq!(doc(&s).color_mode, vectorcraft_doc::ColorMode::Cmyk); + assert!(doc(&s).swatches_iter().filter_map(|w| w.paint.color()).all(|c| !matches!(c, Color::Rgb { .. }))); + assert_eq!(doc(&s).swatch("Black").and_then(|w| w.paint.color()), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + let a = rect_filled(&mut s, json!({"color": "#ff0000"})); + assert_eq!(fill(&s, a), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0))); + // Gray stays Gray; keepModel keeps RGB; gradient stops follow the document too. + run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": {"gray": 0.4}})); + assert_eq!(fill(&s, a), Paint::solid(Color::gray(0.4))); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": "#ff0000", "keepModel": true})); + assert_eq!(fill(&s, a), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + run( + &mut s, + "paint.setFill", + json!({"ids": [a.0], "gradient": {"stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#0000ff"}]}}), + ); + let Paint::Gradient(g) = fill(&s, a) else { panic!("a gradient") }; + assert!(g.gradient.stops.iter().all(|st| matches!(st.color, Color::Cmyk { .. }))); + // A swatch made from a hex colour is CMYK as well; the document round-trips natively. + let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].clone(); + assert_eq!(name, "C=100 M=0 Y=100 K=0"); + let json = serde_json::to_string(doc(&s)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), *doc(&s)); +} + +#[test] +fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() { + use vectorcraft_color::Color; + let mut s = session(); + assert_eq!(doc(&s).swatch("Black").and_then(|w| w.paint.color()), Some(Color::rgb(0.0, 0.0, 0.0))); + let a = rect_filled(&mut s, json!({"color": "#ff0000"})); + assert_eq!(fill(&s, a), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let b = rect_filled(&mut s, json!({"color": {"c": 0.0, "m": 1.0, "y": 0.0, "k": 0.0}})); + assert_eq!(fill(&s, b), Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), "an RGB document doesn't convert CMYK either"); + // Recolor Artwork keeps the replaced colour's model. + run(&mut s, "select.set", json!({"ids": [b.0]})); + let hex = Color::cmyk(0.0, 1.0, 0.0, 0.0).to_hex(); + run(&mut s, "recolor.apply", json!({"map": {hex: "#00ff00"}})); + assert!(matches!(fill(&s, b).color(), Some(Color::Cmyk { .. }))); + // Blending ends of different models gives the document's model. + let c = rect_filled(&mut s, json!({"color": "#808080"})); + run(&mut s, "select.set", json!({"ids": [a.0, c.0, b.0]})); + run(&mut s, "edit.colors.blendFrontToBack", json!({})); + assert!(matches!(fill(&s, c).color(), Some(Color::Rgb { .. }))); +} diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 07903388..1d10c87e 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -304,7 +304,8 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: } _ => { let cmd = super::proxy_cmd(app, behind); - live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind}), phase); + // The panel's sliders work in the model picked there, even in a CMYK document. + live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind, "keepModel": true}), phase); } } } diff --git a/docs/mcp.md b/docs/mcp.md index 2f74c42e..5b95c4c9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -100,6 +100,10 @@ in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `i The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and `swatch.sortByKind`. +Document colour mode: `file.new {colorMode: "cmyk"}` starts a CMYK document with CMYK default swatches, and RGB +colours applied to it (`paint.setFill`/`setStroke` colours and gradient stops, `swatch.new`) are stored as CMYK; +Gray stays Gray, and `keepModel: true` keeps a colour as given. RGB documents keep colours as given. Harmonies, Edit +Colors blends, inversions and Recolor Artwork keep each colour's model (a blend between models takes the document's). ## Resources From 4e60eabc00fc95d47fbdb8b8ac77b16addd050b5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:12:24 +0700 Subject: [PATCH 4/4] M3.30: swatches dropped back on themselves stay put A drag released on one of the dragged swatches no longer falls through to the list's drop zone (which moved them to the end). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/panels/swatches.rs | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7997e985..3825d092 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1106,6 +1106,7 @@ mod tests { assert_eq!(app.view().unwrap().center, after.center); assert_eq!(app.session.active().unwrap().doc.art_bounds(), None); } + #[test] fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() { use crate::panels::swatches::SwatchDrag; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index b43a1379..465c7e7f 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -386,8 +386,12 @@ fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String .collect(); egui::DragAndDrop::set_payload(ui.ctx(), SwatchDrag { grabbed: name.to_string(), names, groups: e.is_folder() }); } - let moving = resp.dnd_hover_payload::().filter(|d| !d.names.iter().any(|n| n == name)); - if moving.is_some() || resp.dnd_hover_payload::().is_some() { + let moving = resp.dnd_hover_payload::(); + // Swatches dropped on one of themselves stay where they are. + if moving.as_ref().is_some_and(|d| d.names.iter().any(|n| n == name)) { + ev.over = true; + released::(&resp); + } else if moving.is_some() || resp.dnd_hover_payload::().is_some() { ev.over = true; let t = Tokens::get(ui.ctx()); let r = resp.rect; @@ -1165,6 +1169,13 @@ mod tests { drag(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Bright Red") - vec2(4.0, 0.0), 5.0); assert_eq!(names_of(&app, Some("Brights"))[..3], ["Orange", "Lime", "Bright Red"]); assert!(names_of(&app, None).contains(&"Sunset".to_string())); + // Dropped back on itself: nothing moves. + let (before, undo) = (names_of(&app, None), app.session.doc().unwrap().history.undo.len()); + frame(&mut app, &ctx, vec![], 6.0, show); + frame(&mut app, &ctx, vec![], 6.5, show); + let white = tile_center(&ctx, "White"); + drag(&mut app, &ctx, white, white + vec2(2.0, 0.0), 7.0); + assert_eq!((names_of(&app, None), app.session.doc().unwrap().history.undo.len()), (before, undo)); } #[test]