M3.30: Swatches panel multi-selection, find field and drag and drop
Clicking a colour group's folder selects the whole group. Show Find Field filters the panel by name. Tiles and list rows drag to reorder swatches, move them into or out of colour groups or reorder groups (new command swatch.move, one undo step); swatches and Fill/Stroke proxies dropped on art paint the object under the pointer (paint.setFill/setStroke with its id), and a proxy dropped on the panel becomes a swatch. List rows show kind and colour-mode icons and take double-clicks; spot colours get a dot in the grid. The search field widget is shared with the Layers panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6bd27a05da
commit
fe258915e3
|
|
@ -59,6 +59,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<Value> {
|
|||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Vec<String>> {
|
||||
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) {
|
||||
|
|
@ -110,8 +119,14 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
}
|
||||
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<Value> {
|
||||
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<String> = vec![];
|
||||
|
|
@ -223,6 +238,67 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<T>(list: &[T], to: Option<usize>, moving: &[String], name: impl Fn(&T) -> &str) -> Option<String> {
|
||||
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<T>(list: &mut Vec<T>, items: Vec<T>, 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<Value> {
|
||||
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<usize>) -> &mut Vec<Swatch> {
|
||||
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<Swatch> = 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<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string();
|
||||
let new = s.edit("Duplicate Swatch", |d, _| {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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(_))));
|
||||
}
|
||||
|
|
@ -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<vectorcraft_doc::hit::Hit> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,22 +25,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let mut expanded: HashSet<u64> = 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| {
|
||||
|
|
|
|||
|
|
@ -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<Entry> {
|
||||
/// 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<Entry> {
|
||||
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<Entry> {
|
|||
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<Value> {
|
||||
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<String> {
|
||||
|
|
@ -196,6 +291,16 @@ fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec<String> {
|
|||
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<String>, order: &[&str], name: &str, m: egui::Modifiers) -> Vec<String> {
|
||||
|
|
@ -224,6 +329,223 @@ fn click_selection(ui: &Ui, mut sel: Vec<String>, 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<String>,
|
||||
/// 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<String>, target: Option<String>, after: bool },
|
||||
/// A Fill/Stroke proxy's paint dropped on row `target` (or between rows) becomes a swatch.
|
||||
New { paint: Paint, target: Option<String> },
|
||||
}
|
||||
|
||||
/// 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<T: std::any::Any + Send + Sync>(resp: &Response) -> Option<std::sync::Arc<T>> {
|
||||
resp.dnd_hover_payload::<T>()?;
|
||||
resp.dnd_release_payload::<T>()
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
drop: Option<Drop>,
|
||||
/// 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::<SwatchDrag>().filter(|d| !d.names.iter().any(|n| n == name));
|
||||
if moving.is_some() || resp.dnd_hover_payload::<Paint>().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::<SwatchDrag>(&resp)) {
|
||||
ev.drop = Some(Drop::Move { names: d.names.clone(), target, after });
|
||||
} else if let Some(p) = released::<Paint>(&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::<SwatchDrag>().is_some() || zone.dnd_hover_payload::<Paint>().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::<SwatchDrag>(zone) {
|
||||
ev.drop = Some(Drop::Move { names: d.names.clone(), target: None, after: false });
|
||||
} else if let Some(p) = released::<Paint>(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<String> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
if let Some(d) = released::<SwatchDrag>(resp) {
|
||||
return swatch_params(&app.session.active()?.doc, &d.grabbed);
|
||||
}
|
||||
let mut paint = (*released::<Paint>(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::<SwatchDrag>(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::<Paint>(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<String> = 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<String> = 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<&Entry>> = 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<String> {
|
||||
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<String> {
|
||||
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::<Paint>(&ctx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_filter() {
|
||||
let solid = Paint::solid(Color::BLACK);
|
||||
|
|
|
|||
|
|
@ -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<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue