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