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:
parent
d272d32be3
commit
3082be6ecf
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue