M3.12: delete swatches and colour groups — Shift/Cmd-click selects several swatches and groups in the panel, Delete asks first (Alt-click skips), swatch.delete {names} removes them in one undo step and art using a deleted global swatch keeps its colour, unlinked

Adds a shared confirmation dialog (`confirm`) that runs a command on OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 17:15:46 +07:00
parent d272d32be3
commit 3082be6ecf
7 changed files with 349 additions and 94 deletions

View File

@ -20,7 +20,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
}
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
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<String> = 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<String> = 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<String>| 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<Value> {
let names: Vec<String> =
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<Color> = 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<Value> {
Ok(json!({"name": new}))
}
/// A list-of-strings parameter (missing or non-string entries are skipped).
fn str_list(p: &Value, key: &str) -> Vec<String> {
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<String> {
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<Color>,
/// 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<String>) -> 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);
}
}
}

View File

@ -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());
}

View File

@ -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<Value, String> {
let params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({}));
app.ui.dialog = None;
app.run(&d.str("__command"), params)
}

View File

@ -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).

View File

@ -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<Entry> {
@ -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<String> {
pstate::<Option<String>>(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<String> {
let Some(st) = app.session.active() else { return vec![] };
let mut names: Vec<String> = 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<String>, order: &[&str], name: &str, m: egui::Modifiers) -> Vec<String> {
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<String> = 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<Entry> = 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<String> = 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<String> {
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<String> {
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<String>, 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<String> {
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<String> {
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<String> = 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::<Option<String>>(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::<Vec<_>>()})).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<Event>, 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<Event>,
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<String> {
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);

View File

@ -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.

View File

@ -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