From 3082be6ecf47b762ed636bcb7339351511187b05 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:15:46 +0700 Subject: [PATCH] =?UTF-8?q?M3.12:=20delete=20swatches=20and=20colour=20gro?= =?UTF-8?q?ups=20=E2=80=94=20Shift/Cmd-click=20selects=20several=20swatche?= =?UTF-8?q?s=20and=20groups=20in=20the=20panel,=20Delete=20asks=20first=20?= =?UTF-8?q?(Alt-click=20skips),=20swatch.delete=20{names}=20removes=20them?= =?UTF-8?q?=20in=20one=20undo=20step=20and=20art=20using=20a=20deleted=20g?= =?UTF-8?q?lobal=20swatch=20keeps=20its=20colour,=20unlinked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shared confirmation dialog (`confirm`) that runs a command on OK. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/swatch.rs | 83 ++++++-- crates/engine/src/tests_swatches.rs | 36 ++++ crates/ui-egui/src/dialogs/confirm.rs | 42 ++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/panels/swatches.rs | 274 +++++++++++++++++++------- docs/control-protocol.md | 3 + docs/mcp.md | 3 +- 7 files changed, 349 insertions(+), 94 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/confirm.rs diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index f72c72e0..48dd4d8b 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -20,7 +20,15 @@ pub fn specs() -> Vec { has_doc, swatch_new ), - cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete), + cmd!( + "swatch.delete", + "Delete Swatch", + ["Window", "Swatches"], + None, + "{name? | names?: [swatch or colour group names] (a group goes with its swatches), unlink?: true (art using a deleted global swatch keeps its colour, unlinked; false keeps the stale link)} delete in one undo step → {deleted, unlinked: paints unlinked}", + has_doc, + swatch_delete + ), cmd!( "swatch.newGroup", "New Color Group", @@ -82,15 +90,42 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { } fn swatch_delete(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string(); - s.edit("Delete Swatch", |d, _| { - d.swatches.retain(|sw| sw.name != name); - for g in &mut d.swatch_groups { - g.swatches.retain(|sw| sw.name != name); + const C: &str = "swatch.delete"; + 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) { + names.push(n); } - Ok(()) + } + if names.is_empty() { + return Err(bad(C, "give `name` or `names`")); + } + 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![]; + for n in &names { + let swatches: Vec<&Swatch> = match d.swatch_groups.iter().find(|g| g.name == *n) { + Some(g) => g.swatches.iter().collect(), + None => vec![d.swatch(n).ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?], + }; + if swatches.iter().any(|w| w.paint.is_none()) { + return Err(bad(C, format!("`{n}` can't be deleted"))); + } + global.extend(swatches.iter().filter(|w| w.global).map(|w| w.name.clone())); + } + if !bool_or(p, "unlink", true) { + global.clear(); + } + let mut unlink = |_: &mut Color, link: &mut Option| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some(); + let unlinked = s.edit("Delete Swatch", |d, _| { + d.swatch_groups.retain(|g| !names.contains(&g.name)); + for n in &names { + d.remove_swatch(n); + } + Ok(d.map_solid_paints(&mut unlink)) })?; - ok() + map_default_paints(s, &mut unlink); + Ok(json!({"deleted": names, "unlinked": unlinked})) } /// The default name of a new solid swatch: its values in its own colour model. @@ -120,8 +155,7 @@ fn free_name(d: &Document, base: &str) -> String { } fn swatch_new_group(s: &mut Session, p: &Value) -> Result { - let names: Vec = - p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + let names = str_list(p, "swatches"); let colors: Vec = p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default(); let requested = str_param(p, "name").map(str::to_string); let name = s.edit("New Color Group", |d, _| { @@ -162,6 +196,11 @@ fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": new})) } +/// A list-of-strings parameter (missing or non-string entries are skipped). +fn str_list(p: &Value, key: &str) -> Vec { + p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() +} + /// A trimmed, non-empty name parameter. fn name_param(p: &Value, key: &str) -> Option { str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) @@ -193,7 +232,7 @@ struct Relink { to: String, /// The new colour of a global swatch (linked paints take it). color: Option, - /// false: drop the links (the swatch stopped being global or is deleted); paints keep their colour. + /// false: drop the links (the swatch stopped being global); paints keep their colour. keep: bool, } @@ -218,16 +257,20 @@ impl Relink { renamed || recolored } - /// Apply to the default fill and stroke for new art, except during a live preview (Cancel rolls - /// the document back, not the session). fn defaults(&self, s: &mut Session) { - if s.in_interaction() { - return; - } - for p in [&mut s.paint.fill, &mut s.paint.stroke] { - if let Paint::Solid { color, swatch } = p { - self.apply(color, swatch); - } + map_default_paints(s, &mut |c, l| self.apply(c, l)); + } +} + +/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live +/// preview (Cancel rolls the document back, not the session). +fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) { + if s.in_interaction() { + return; + } + for p in [&mut s.paint.fill, &mut s.paint.stroke] { + if let Paint::Solid { color, swatch } = p { + f(color, swatch); } } } diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index aab7203f..9dc710fc 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -218,3 +218,39 @@ fn swatch_list_reports_kinds_and_groups() { assert_eq!(grays["groups"][0]["name"], "Grays"); assert!(s.execute("swatch.list", &json!({"group": "Nope"})).is_err()); } + +#[test] +fn deleting_a_global_swatch_unlinks_art_that_keeps_its_colour() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true})); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Brand"})); + run(&mut s, "select.set", json!({"ids": []})); + run(&mut s, "paint.setFill", json!({"swatch": "Brand"})); + let before = doc(&s).clone(); + assert_eq!(run(&mut s, "swatch.delete", json!({"name": "Brand"})), json!({"deleted": ["Brand"], "unlinked": 1})); + assert!(doc(&s).swatch("Brand").is_none()); + assert_eq!(fill_of(&s, a), Paint::solid(Color::from_hex("#2a6fb0").unwrap())); + assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#2a6fb0").unwrap()), "the default fill is unlinked too"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before, "one undo step restores swatch and link"); + // unlink: false keeps the stale link. + assert_eq!(run(&mut s, "swatch.delete", json!({"name": "Brand", "unlink": false}))["unlinked"], 0); + assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand")); +} + +#[test] +fn deleting_several_swatches_and_groups() { + let mut s = session(); + let r = run(&mut s, "swatch.delete", json!({"names": ["Red", "Brights", "Sunset"]})); + assert_eq!(r["deleted"], json!(["Red", "Brights", "Sunset"])); + let d = doc(&s); + assert!(d.swatch("Red").is_none() && d.swatch("Sunset").is_none() && d.swatch("Bright Red").is_none()); + assert!(d.swatch_groups.iter().all(|g| g.name != "Brights") && d.swatch_groups.iter().any(|g| g.name == "Grays")); + assert!(s.execute("swatch.delete", &json!({"name": "[None]"})).is_err(), "None stays"); + assert!(s.execute("swatch.delete", &json!({"names": ["Orange", "Nope"]})).is_err(), "unknown names fail the whole call"); + assert!(doc(&s).swatch("Orange").is_some()); + assert!(s.execute("swatch.delete", &json!({})).is_err()); + run(&mut s, "edit.undo", json!({})); + assert!(doc(&s).swatch("Bright Red").is_some() && doc(&s).swatch("Red").is_some()); +} diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs new file mode 100644 index 00000000..10ac1155 --- /dev/null +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -0,0 +1,42 @@ +//! A question answered with OK or Cancel; OK runs a command ([`ask`]). Used for confirmations such +//! as deleting swatches. +//! +//! Fields: `message` (the question, shown as the heading), `detail` (an optional line under it), +//! `__command` and `__params` (the command OK runs). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; + +/// The dialog kind of a confirmation. +pub const KIND: &str = "confirm"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |d| d.str("message"), + body: |_, ui, d| { + let detail = d.str("detail"); + if !detail.is_empty() { + ui.label(egui::RichText::new(detail).color(Tokens::get(ui.ctx()).text_dim)); + } + false + }, + confirm, + max_width: Some(420.0), + ..DialogSpec::FORM +}; + +/// Ask `message` (with an optional `detail` line); OK runs `command` with `params`. +pub fn ask(app: &mut VectorcraftApp, message: &str, detail: &str, command: &str, params: Value) { + let fields = json!({"message": message, "detail": detail, "__command": command, "__params": params}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +/// Closes before running, so a dialog the command opens stays open. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({})); + app.ui.dialog = None; + app.run(&d.str("__command"), params) +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 36167848..1c62bbde 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -10,6 +10,7 @@ mod about; mod all_tools; mod artboard_options; mod command; +pub mod confirm; mod document_setup; mod effect; mod export_for_screens; @@ -129,6 +130,7 @@ registry! { Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)), FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC, + Confirm: [confirm::KIND] => confirm::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index f56bf834..e029cbf0 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -83,6 +83,15 @@ enum Entry { Folder(String), } +impl Entry { + fn name(&self) -> &str { + match self { + Entry::Registration => REGISTRATION, + Entry::Swatch { name, .. } | Entry::Folder(name) => name, + } + } +} + const REGISTRATION: &str = "[Registration]"; fn entries(app: &VectorcraftApp, kind: Kind) -> Vec { @@ -185,8 +194,41 @@ fn draw_folder(ui: &Ui, r: Rect) { icons::paint(ui, "dc-folder", r.expand(1.0), t.icon); } -fn selected_name(ui: &Ui) -> Option { - pstate::>(ui.ctx(), "swatch-selected") +/// 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 { + let Some(st) = app.session.active() else { return vec![] }; + let mut names: Vec = pstate(ui.ctx(), "swatch-selected"); + names.retain(|n| n == REGISTRATION || st.doc.swatch_name_taken(n)); + names +} + +/// 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 { + let anchor: String = pstate(ui.ctx(), "swatch-anchor"); + let pos = |n: &str| order.iter().position(|o| *o == n); + if m.shift + && let (Some(a), Some(b)) = (pos(&anchor), pos(name)) + { + for n in &order[a.min(b)..=a.max(b)] { + if !sel.iter().any(|s| s == n) { + sel.push(n.to_string()); + } + } + return sel; + } + set_pstate(ui.ctx(), "swatch-anchor", name.to_string()); + if !m.command { + return vec![name.to_string()]; + } + match sel.iter().position(|s| s == name) { + Some(i) => { + sel.remove(i); + } + None => sel.push(name.to_string()), + } + sel } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -219,9 +261,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Paint::None => Some("[None]".to_string()), _ => None, }; - let sel = selected_name(ui).or(active_swatch); + let selected = selection(app, ui); + // Without a selection the swatch of the active paint is highlighted. + let sel: Vec = if selected.is_empty() { active_swatch.into_iter().collect() } else { selected.clone() }; + let is_sel = |name: &str| sel.iter().any(|s| s == name); let (tile, pitch) = view.tile(); - let mut clicked: Option = None; + 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; widgets::list_box(ui, |ui| { @@ -230,13 +275,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if view.is_list() { for e in &items { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click()); - let name = match e { - Entry::Registration => REGISTRATION.to_string(), - Entry::Swatch { name, .. } => name.clone(), - Entry::Folder(n) => n.clone(), - }; - let is_sel = sel.as_deref() == Some(name.as_str()); - if is_sel { + let name = e.name(); + if is_sel(name) { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); @@ -253,7 +293,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().text( pos2(chip.right() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, - &name, + name, egui::FontId::proportional(12.0), t.text, ); @@ -272,13 +312,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { )); } } - if let Entry::Swatch { name, .. } = e - && resp.double_clicked() - { - edit = Some(name.clone()); + if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() { + edit = Some(name.to_string()); } - if resp.on_hover_text(&name).clicked() && !matches!(e, Entry::Folder(_)) { - clicked = Some(e.clone()); + if resp.on_hover_text(name).clicked() { + clicked = Some((e.clone(), ui.input(|i| i.modifiers))); } } } else { @@ -304,18 +342,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { 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 (name, tip) = match e { - Entry::Registration => (REGISTRATION.to_string(), "[Registration]".to_string()), - Entry::Swatch { name, .. } => (name.clone(), name.clone()), - Entry::Folder(n) => (n.clone(), format!("Color Group: {n}")), - }; + let name = e.name(); match e { Entry::Registration => draw_registration(ui, cell), Entry::Swatch { paint, global, .. } => { - swatch_tile(ui, cell, paint, sel.as_deref() == Some(name.as_str()), resp.hovered()); + 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.clone()); + edit = Some(name.to_string()); } if *global { let k = cell.shrink(1.0); @@ -326,10 +360,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { )); } } - Entry::Folder(_) => draw_folder(ui, cell), + Entry::Folder(_) => { + draw_folder(ui, cell); + if is_sel(name) { + ui.painter().rect_stroke(cell.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); + } + } } - if resp.on_hover_text(tip).clicked() && !matches!(e, Entry::Folder(_)) { - clicked = Some(e.clone()); + 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))); } } } @@ -339,37 +382,56 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(name) = edit { app.run("ui.swatchOptions", json!({"name": name})).ok(); } - if let Some(e) = clicked { - let name = match &e { - Entry::Registration => REGISTRATION.to_string(), - Entry::Swatch { name, .. } => name.clone(), - Entry::Folder(n) => n.clone(), - }; - set_pstate(ui.ctx(), "swatch-selected", Some(name)); - apply(app, ui, &e); - } - bottom(app, ui); + let selected = match clicked { + Some((e, m)) => { + let order: Vec<&str> = items.iter().map(Entry::name).collect(); + let sel = click_selection(ui, selected, &order, e.name(), m); + set_pstate(ui.ctx(), "swatch-selected", sel.clone()); + // A plain click also applies the swatch; modifier clicks only select. + if !(m.shift || m.command) { + apply(app, ui, &e); + } + sel + } + None => selected, + }; + bottom(app, ui, &selected); } /// The interaction id of a tile in the thumbnail views (stable per swatch or group name). fn tile_id(e: &Entry) -> egui::Id { - let name = match e { - Entry::Registration => REGISTRATION, - Entry::Swatch { name, .. } | Entry::Folder(name) => name, + egui::Id::new(("swatch-tile", e.name())) +} + +/// The selected swatches and groups that can be deleted (all but None and Registration). +fn deletable(sel: &[String]) -> Vec { + sel.iter().filter(|n| !n.starts_with('[')).cloned().collect() +} + +/// The selected swatch when exactly one swatch with an editor is selected (not None, Registration +/// or a group). +fn editable(app: &VectorcraftApp, sel: &[String]) -> Option { + match sel { + [n] if !n.starts_with('[') && app.session.active().is_some_and(|st| st.doc.swatch(n).is_some()) => Some(n.clone()), + _ => None, + } +} + +/// 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}); + if now { + app.run("swatch.delete", params).ok(); + return; + } + let message = match names.as_slice() { + [n] => format!("Delete “{n}”?"), + _ => format!("Delete these {} swatches and groups?", names.len()), }; - egui::Id::new(("swatch-tile", name)) + crate::dialogs::confirm::ask(app, &message, "Art using a deleted global swatch keeps its colour.", "swatch.delete", params); } -fn deletable(ui: &Ui) -> Option { - selected_name(ui).filter(|n| !n.starts_with('[')) -} - -/// The selected swatch when it has an editor (any swatch but None and Registration). -fn editable(app: &VectorcraftApp, ui: &Ui) -> Option { - deletable(ui).filter(|n| app.session.active().is_some_and(|st| st.doc.swatch(n).is_some())) -} - -fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { +fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { let kind: Kind = pstate(ui.ctx(), "swatch-kind"); widgets::bottom_bar(ui, |ui| { widgets::icon_button_enabled(ui, "library", "Swatch Libraries (on the roadmap)", false, false, 24.0); @@ -381,7 +443,7 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); - let opts = editable(app, ui); + let opts = editable(app, sel); if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked() && let Some(n) = opts { @@ -389,19 +451,16 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); if widgets::icon_button(ui, "dc-folder", "New Color Group", false, 24.0).clicked() { - let names: Vec = deletable(ui).into_iter().collect(); - app.run("swatch.newGroup", json!({"swatches": names})).ok(); + app.run("swatch.newGroup", json!({"swatches": deletable(sel)})).ok(); } // Ctrl/Cmd-click makes a spot colour (Alt-click skips the dialog). if widgets::icon_button(ui, "dc-new-item", "New Swatch", false, 24.0).clicked() { new_swatch(app, ui.input(|i| i.modifiers.command)); } - let del = deletable(ui); - if widgets::icon_button_enabled(ui, "trash-2", "Delete Swatch", false, del.is_some(), 24.0).clicked() - && let Some(n) = del - && app.run("swatch.delete", json!({"name": n})).is_ok() - { - set_pstate::>(ui.ctx(), "swatch-selected", None); + // Alt-click deletes without asking. + let del = deletable(sel); + if widgets::icon_button_enabled(ui, "trash-2", "Delete Swatch", false, !del.is_empty(), 24.0).clicked() { + delete(app, del, ui.input(|i| i.modifiers.alt)); } }); } @@ -415,23 +474,23 @@ fn new_swatch(app: &mut VectorcraftApp, spot: bool) { pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let view: View = pstate(ui.ctx(), "swatch-view"); - let sel = deletable(ui); + let selected = selection(app, ui); + let del = deletable(&selected); + let opts = editable(app, &selected); if menu_item(ui, "New Swatch…", true, false) { new_swatch(app, false); } if menu_item(ui, "New Color Group…", true, false) { - app.run("swatch.newGroup", json!({"swatches": sel.clone().into_iter().collect::>()})).ok(); + app.run("swatch.newGroup", json!({"swatches": del})).ok(); } - if menu_item(ui, "Duplicate Swatch", sel.is_some(), false) - && let Some(n) = &sel + if menu_item(ui, "Duplicate Swatch", opts.is_some(), false) + && let Some(n) = &opts { app.run("swatch.duplicate", json!({"name": n})).ok(); } menu_item(ui, "Merge Swatches", false, false); - if menu_item(ui, "Delete Swatch", sel.is_some(), false) - && let Some(n) = &sel - { - app.run("swatch.delete", json!({"name": n})).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); @@ -448,7 +507,6 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } ui.separator(); - let opts = editable(app, ui); if menu_item(ui, "Swatch Options…", opts.is_some(), false) && let Some(n) = opts { @@ -464,7 +522,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { mod tests { use super::*; use crate::dialogs::swatch_options::KIND; - use egui::{Event, PointerButton, Pos2}; + use egui::{Event, Modifiers, PointerButton, Pos2}; use vectorcraft_color::Color; use vectorcraft_engine::Session; @@ -476,8 +534,21 @@ mod tests { /// One frame of `draw` on a persistent context (clicks need the previous frame's layout). fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) { - let input = - egui::RawInput { events, time: Some(time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))), ..Default::default() }; + frame_with(app, ctx, events, Modifiers::NONE, time, draw); + } + + /// [`frame`] with `modifiers` held. + fn frame_with( + app: &mut VectorcraftApp, + ctx: &egui::Context, + events: Vec, + modifiers: Modifiers, + time: f64, + draw: fn(&mut VectorcraftApp, &mut Ui), + ) { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))); + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; let mut out = ctx.run_ui(input, |ui| draw(app, ui)); out.textures_delta.clear(); } @@ -500,6 +571,18 @@ mod tests { frame(app, ctx, double_click(tile.center()), 1.0, show); } + /// Click the thumbnail of swatch or group `name` with `modifiers` held (frames at `time`, `time + 0.5`). + fn click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { + frame(app, ctx, vec![], time, show); + let at = ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; + frame_with(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5, show); + } + + fn selected(ctx: &egui::Context) -> Vec { + pstate(ctx, "swatch-selected") + } + fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) { frame(app, ctx, vec![], 2.0, |app, ui| crate::dialogs::show(app, ui.ctx())); } @@ -550,6 +633,51 @@ mod tests { assert!(app.run("ui.swatchOptions", json!({})).is_err()); } + #[test] + fn modifier_clicks_select_several_swatches_and_groups() { + let mut app = app(); + let ctx = context(); + click_tile(&mut app, &ctx, "Red", Modifiers::NONE, 0.0); + assert_eq!(selected(&ctx), ["Red"]); + assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "a plain click applies"); + click_tile(&mut app, &ctx, "Amber", Modifiers::SHIFT, 2.0); + assert_eq!(selected(&ctx), ["Red", "Orange Red", "Orange", "Amber"], "Shift extends over the shown order"); + click_tile(&mut app, &ctx, "Orange", Modifiers::COMMAND, 4.0); + click_tile(&mut app, &ctx, "Brights", Modifiers::COMMAND, 6.0); + assert_eq!(selected(&ctx), ["Red", "Orange Red", "Amber", "Brights"], "Cmd toggles swatches and colour groups"); + assert_eq!(app.session.paint.fill.color().map(|c| c.to_hex()), Some("#ed1c24".into()), "modifier clicks only select"); + click_tile(&mut app, &ctx, "Grays", Modifiers::NONE, 8.0); + assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone"); + } + + #[test] + fn delete_asks_then_removes_the_selection_and_unlinks_art() { + let mut app = app(); + let ctx = context(); + app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Red"})).unwrap(); + delete(&mut app, vec!["Red".into(), "Brights".into()], false); + let d = app.ui.dialog.as_ref().expect("delete asks first"); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::KIND, "Delete these 2 swatches and groups?")); + dialog_frame(&mut app, &ctx); + assert!(app.session.doc().unwrap().doc.swatch("Red").is_some(), "nothing is deleted before OK"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let doc = &app.session.doc().unwrap().doc; + assert!(doc.swatch("Red").is_none() && doc.swatch("Bright Red").is_none() && doc.swatch_groups.iter().all(|g| g.name != "Brights")); + let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + assert_eq!(fill, Paint::solid(Color::from_hex("#ed1c24").unwrap()), "the art keeps its colour, unlinked"); + // Cancel keeps the swatch; Alt-click (now) deletes without asking. + delete(&mut app, vec!["Orange".into()], false); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("message"), "Delete “Orange”?"); + crate::dialogs::cancel(&mut app); + assert!(app.session.doc().unwrap().doc.swatch("Orange").is_some()); + delete(&mut app, vec!["Orange".into()], true); + assert!(app.ui.dialog.is_none() && app.session.doc().unwrap().doc.swatch("Orange").is_none()); + assert_eq!(deletable(&["[None]".into(), REGISTRATION.into(), "Grays".into()]), ["Grays"]); + } + #[test] fn kind_filter() { let solid = Paint::solid(Color::BLACK); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 23e45355..9dfda508 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -25,3 +25,6 @@ Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatc `preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo step and `ui.dialog.cancel` rolls the preview back. Gradient swatches open the Gradient panel and pattern swatches pattern editing instead. + +Confirmations: deleting swatches from the Swatches panel opens a `confirm` dialog (fields `message`, `detail`); +`ui.dialog.confirm` runs the command it asks about (here `swatch.delete`) and `ui.dialog.cancel` drops it. diff --git a/docs/mcp.md b/docs/mcp.md index a5919ada..84c4296a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -73,7 +73,8 @@ Errors (unknown tool, bad arguments, a disabled or failing command) come back as Swatches go through `run_command`: `swatch.list` lists every swatch with its colour group, kind and colour; `swatch.edit {name, newName?, color?, mode?, global?, spot?}` edits one (fills, strokes and text linked to a -global or spot swatch follow its colour and name, as one undo step). +global or spot swatch follow its colour and name, as one undo step); `swatch.delete {names, unlink?}` deletes +swatches and colour groups in one undo step (art using a deleted global swatch keeps its colour, unlinked). ## Resources