diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 66cbce4c5..34262e71c 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -22,6 +22,30 @@ use crate::theme::Tokens; use crate::widgets::{self, PanelDrag, SwatchRows, menu_item, swatch_tile}; use crate::{VectorcraftApp, icons}; +mod actions; +mod body; +mod dnd; +mod libraries; +mod tiles; + +pub use actions::menu; +#[cfg(test)] +use actions::{deletable, delete, mergeable, new_swatch, selected_group}; +use body::body; +#[cfg(test)] +use body::{active_swatch, find_id, focus_swatch, kept_swatch}; +use dnd::drag_of; +#[cfg(test)] +use dnd::highlighted; +pub(crate) use dnd::{click_selection, drag_preview}; +pub(crate) use libraries::{ + DOCUMENT_SWATCHES, SwatchLibraries, USER_SLOT, colour_libraries, limit_key, limit_name, load_library, open_library, other_library, user_library, + window_menu, +}; +#[cfg(test)] +pub(crate) use tiles::describe; +pub(crate) use tiles::{draw_folder, folder_tile, global_mark, list_row, list_row_name, tile_grid}; + /// View modes of the swatch list (and of the library panels: `ui.libraryPanel {view}`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -175,483 +199,6 @@ fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { super::apply_click(app, ui, params); } -/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). -fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Paint) { - use std::collections::HashMap; - type Key = (String, Vec, String, u32); - thread_local! { - static CACHE: crate::graphics::TexCache>> = crate::graphics::TexCache::default(); - } - let Paint::Pattern { pattern, .. } = paint else { return }; - let Some(st) = app.session.active() else { return }; - let Some(def) = st.doc.pattern(pattern) else { return }; - let px = (r.width() * ui.ctx().pixels_per_point()).round().max(4.0) as u32; - let key = ( - pattern.clone(), - def.art.iter().map(|a| std::sync::Arc::as_ptr(a) as usize).collect(), - format!("{:?}{:?}{:?}", def.tile, def.tile_type, def.overlap), - px, - ); - let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).unwrap_or_else(|| { - let tex = vectorcraft_render::render_pattern_swatch(&st.doc, pattern, px).map(|img| { - let color = crate::canvas::color_image(&img); - ui.ctx().load_texture(format!("pattern-swatch-{pattern}-{px}"), color, egui::TextureOptions::LINEAR) - }); - CACHE.with(|c| { - let mut c = c.borrow_mut(); - if c.len() > 256 { - c.clear(); - } - c.insert(key, tex.clone()); - }); - tex - }); - if let Some(tex) = tex { - ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); - } -} - -fn draw_registration(ui: &Ui, r: Rect) { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); - let c = r.center(); - let rad = r.width() * 0.28; - let s = Stroke::new(1.0, egui::Color32::BLACK); - ui.painter().circle_stroke(c, rad, s); - ui.painter().line_segment([pos2(r.left() + 2.0, c.y), pos2(r.right() - 2.0, c.y)], s); - ui.painter().line_segment([pos2(c.x, r.top() + 2.0), pos2(c.x, r.bottom() - 2.0)], s); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); -} - -pub(crate) fn draw_folder(ui: &Ui, r: Rect) { - let t = Tokens::get(ui.ctx()); - 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)]; -/// Lab's opponent axes: green | red (a) over blue | yellow (b). -const LAB_AXES: [Color32; 4] = - [Color32::from_rgb(0, 166, 81), Color32::from_rgb(230, 30, 60), Color32::from_rgb(0, 90, 220), Color32::from_rgb(250, 205, 0)]; - -/// A swatch's kind in words, for list tooltips ("Global Process Color, CMYK"). -pub(crate) fn describe(paint: &Paint, global: bool, spot: bool) -> String { - match paint { - Paint::Solid { color, .. } => { - let kind = if spot { - tl!("Spot Color") - } else if global { - tl!("Global Process Color") - } else { - tl!("Process Color") - }; - crate::i18n::fmt(tl!("{kind}, {model}"), &[("kind", kind), ("model", color.model_name())]) - } - Paint::Gradient(g) => crate::i18n::fmt( - tl!("{kind} Gradient"), - &[("kind", crate::i18n::tr_ctx(crate::i18n::current(), "gradient-kind", g.gradient.kind.label()))], - ), - Paint::Pattern { .. } => tl!("Pattern").into(), - Paint::None => tl!("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, Lab as its -/// two opponent axes). -pub(crate) 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)); - } - } - let quarters = |colors: [Color32; 4]| { - let q = mode.size() / 2.0; - for (i, c) in colors.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, c); - } - }; - match color { - Color::Cmyk { .. } => quarters(CMYK_INKS), - Color::Lab { .. } => quarters(LAB_AXES), - 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 { - let Some(st) = app.session.active() else { return vec![] }; - let mut names: Vec = pstate(ui.ctx(), "swatch-selected"); - names.retain(|n| n == REGISTRATION || st.doc.swatch_name_taken(n)); - names -} - -/// The 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 (kept in panel state `anchor_key`) over `order` (the names as displayed), a plain click -/// selects it alone. -pub(crate) fn click_selection(ui: &Ui, anchor_key: &str, mut sel: Vec, order: &[&str], name: &str, m: egui::Modifiers) -> Vec { - let anchor: String = pstate(ui.ctx(), anchor_key); - let pos = |n: &str| order.iter().position(|o| *o == n); - if m.shift - && let (Some(a), Some(b)) = (pos(&anchor), pos(name)) - { - for n in &order[a.min(b)..=a.max(b)] { - if !sel.iter().any(|s| s == n) { - sel.push(n.to_string()); - } - } - return sel; - } - set_pstate(ui.ctx(), anchor_key, name.to_string()); - if !m.command { - return vec![name.to_string()]; - } - match sel.iter().position(|s| s == name) { - Some(i) => { - sel.remove(i); - } - None => sel.push(name.to_string()), - } - sel -} - -/// 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`). With Alt held, `with` names the swatch grabbed: dropped on swatch - /// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it - /// gives that swatch its paint instead of moving. - Move { names: Vec, target: Option, after: bool, with: Option }, - /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row - /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch - /// `target` when that is of its kind and not a tint swatch. - New { paint: Paint, target: Option, replace: bool }, - /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). - Pattern { ids: Vec }, -} - -impl Drop { - /// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows - /// it carries (with Alt held, `replace`, a swatch grabbed may replace `target`), make a swatch of - /// a paint dragged from elsewhere, or a pattern swatch of art dragged off the canvas (appearances: - /// nothing). - fn of(d: &PanelDrag, target: Option, after: bool, replace: bool) -> Option { - match d { - PanelDrag::Paint { rows: Some(r), .. } => { - let with = (replace && !r.groups).then(|| r.grabbed.clone()); - Some(Drop::Move { names: r.names.clone(), target, after, with }) - } - PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target, replace }), - PanelDrag::Art(ids) => Some(Drop::Pattern { ids: ids.iter().map(|id| id.0).collect() }), - _ => None, - } - } -} - -/// The drag held over `resp`, if the panel takes it (paints and art dragged off the canvas, not the -/// Appearance panel's thumbnail). -fn held(resp: &Response) -> Option> { - resp.dnd_hover_payload::().filter(|d| matches!(**d, PanelDrag::Paint { .. } | PanelDrag::Art(_))) -} - -/// The drag a tile or row of `e` starts, moving rows `names`. Colour groups have no paint of their -/// own (the chip under the pointer draws Registration's mark for Registration). -fn drag_of(e: &Entry, names: Vec) -> PanelDrag { - let paint = match e { - Entry::Swatch { paint, .. } => paint.clone(), - Entry::Registration => Paint::registration(), - Entry::Folder(_) => Paint::None, - }; - let rows = SwatchRows { grabbed: e.name().to_string(), names, groups: e.is_folder() }; - PanelDrag::Paint { paint, params: swatch_params(e).unwrap_or_default(), rows: Some(rows), library: None } -} - -/// 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), - /// or Edit or Apply Color Group on a colour group. - 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 [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it. -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 { .. } | Entry::Folder(_)) && resp.double_clicked() { - ev.edit = Some(e.clone()); - } - 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(), drag_of(e, names)); - } - tile_drop(ui, &resp, e, list, ev); - // The default swatches' names show in the UI's language; the document keeps them as they are. - let shown = crate::i18n::t_name("swatch", name); - let resp = match e { - Entry::Folder(_) => resp.on_hover_text(crate::i18n::fmt(tl!("Color Group: {name}"), &[("name", shown)])), - Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| { - ui.label(format!("{shown} ({})", describe(paint, *global, *spot))); - }), - _ => resp.on_hover_text(shown), - }; - if resp.clicked() { - ev.clicked = Some((e.clone(), ui.input(|i| i.modifiers))); - } -} - -/// A drag held over the tile or row `resp` of entry `e` shows where it would land (before or after -/// it by the pointer's half, or into a folder); a release there sets the drop. -fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvents) { - let Some(d) = held(resp) else { return }; - let PanelDrag::Paint { rows, .. } = &*d else { return }; - let name = e.name(); - ev.over = true; - // Swatches dropped on one of themselves stay where they are. - if rows.as_ref().is_some_and(|r| r.names.iter().any(|n| n == name)) { - resp.dnd_release_payload::(); - return; - } - 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 paint from elsewhere, or swatches over a folder, go into it; otherwise a bar marks the slot. - if rows.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)); - } - if resp.dnd_release_payload::().is_some() { - ev.drop = Drop::of(&d, Some(name.to_string()), after, super::alt_held(ui)); - } -} - -/// 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 Some(d) = held(zone) else { return }; - if !ev.over { - ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - } - if zone.dnd_release_payload::().is_some() { - ev.drop = Drop::of(&d, None, false, false); - } -} - -/// 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})) -} - -/// Are both paints colours, gradients or patterns? -fn same_kind(a: &Paint, b: &Paint) -> bool { - std::mem::discriminant(a) == std::mem::discriminant(b) && !a.is_none() -} - -/// Can swatch `with` give its paint to swatch `target`? Both are swatches of one kind, neither is -/// None or Registration, and `target` is not a tint swatch (a tint swatch only takes a new name). -fn replaces(d: &Document, with: &str, target: &str) -> bool { - match (d.swatch(with), d.swatch(target)) { - (Some(w), Some(t)) => with != target && !w.is_reserved() && !t.is_reserved() && t.tint_of().is_none() && same_kind(&t.paint, &w.paint), - _ => false, - } -} - -/// Run a drop on the panel: move the swatches, give the paint of a swatch Alt-dropped on another of -/// its kind to that one, or make a swatch of the proxy's paint (a color 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 { - // Alt: the swatch grabbed replaces the color, gradient or pattern of the swatch of its kind - // it is dropped on, which keeps its name and place. - Drop::Move { with: Some(w), target: Some(t), .. } if replaces(d, &w, &t) => ("swatch.edit", json!({"name": t, "paint": {"swatch": w}})), - Drop::Move { names, target, after, .. } => match move_params(d, &names, target.as_deref(), after) { - Some(p) => ("swatch.move", p), - None => return, - }, - // Alt: an edited gradient (or colour) replaces the swatch it is dropped on. - Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => { - ("swatch.edit", json!({"name": t, "paint": super::swatch_paint_params(&paint)})) - } - Drop::New { paint, target, .. } => { - let mut p = super::swatch_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) - } - Drop::Pattern { ids } => ("object.pattern.make", json!({"ids": ids, "edit": false})), - }; - if let Err(e) = app.run(cmd, params) { - app.status(e); - } -} - -/// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, -/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance -/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the -/// thumbnail of a dragged graphic style. -pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { - let Some(d) = egui::DragAndDrop::payload::(ctx) else { return }; - let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint()); - let (paint, registration) = match &*d { - // Colour groups paint nothing. - PanelDrag::Paint { params, .. } if params.is_null() => return, - PanelDrag::Paint { paint, rows, .. } => (std::borrow::Cow::Borrowed(paint), rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION)), - PanelDrag::Appearance(id) => match fill_of(id) { - Some(p) => (std::borrow::Cow::Owned(p), false), - None => return, - }, - PanelDrag::Art(ids) => match ids.first().and_then(fill_of) { - Some(p) => (std::borrow::Cow::Owned(p), false), - None => return, - }, - PanelDrag::GraphicStyle(name) => return pointer_chip(ctx, |ui, r| super::graphic_styles::paint_style(app, ui, r, name)), - PanelDrag::Symbol(name) => match app.session.active() { - Some(st) => return pointer_chip(ctx, |ui, r| super::symbols::chip(ui, &st.doc, &super::symbols::document_symbols(st), r, name)), - None => return, - }, - PanelDrag::LibrarySymbol { library, name } => match vectorcraft_engine::cmd::symbollib::library(&app.session, library) { - Some((_, lib)) => return pointer_chip(ctx, |ui, r| super::symbols::chip(ui, &lib.doc, &super::symbols::library_symbols(&lib), r, name)), - None => return, - }, - PanelDrag::Brush { name, .. } => match app.session.active() { - Some(st) => return pointer_chip(ctx, |ui, r| super::brushes::document_chip(ui, r, st, name)), - None => return, - }, - PanelDrag::LibraryBrush { library, name } => match vectorcraft_engine::cmd::brushlib::library(&app.session, library) { - Some((_, lib)) => return pointer_chip(ctx, |ui, r| super::brushes::library_chip(ui, r, &lib, name)), - None => return, - }, - PanelDrag::LibraryGraphic { library, item } => return pointer_chip(ctx, |ui, r| super::libraries::chip(app, ui, r, library, item)), - PanelDrag::LibraryItem { .. } | PanelDrag::LibraryGroup { .. } => return, - }; - pointer_chip(ctx, |ui, r| { - if registration { - draw_registration(ui, r); - } else { - swatch_tile(ui, r, &paint, false, false); - pattern_thumb(app, ui, r.shrink(1.0), &paint); - } - }); -} - -/// A 16-point chip `draw` paints, following the pointer. -fn pointer_chip(ctx: &egui::Context, draw: impl FnOnce(&Ui, Rect)) { - let Some(at) = ctx.pointer_hover_pos() else { return }; - let area = egui::Area::new(egui::Id::unique("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()); - draw(ui, r); - }); -} - -/// The id of the find field's text (shown with the panel menu's Show Find Field). -fn find_id() -> egui::Id { - egui::Id::unique("swatch-find") -} - pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { body(app, ui, TILE); } @@ -666,629 +213,5 @@ pub(crate) fn popover(app: &mut VectorcraftApp, ui: &mut Ui) { const TILE: &str = "swatch-tile"; const POPOVER_TILE: &str = "swatch-pop-tile"; -/// The panel's body; `salt` keys its tiles (see [`tile_id`]). -fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { - if app.session.active().is_none() { - super::empty_state(ui, "swatch-book", tl!("No document"), tl!("Open a document to see its swatches.")); - return; - } - let view: View = pstate(ui.ctx(), "swatch-view"); - let kind: Kind = pstate(ui.ctx(), "swatch-kind"); - // Top row: proxy at left, list / grid view toggles at right. - ui.horizontal(|ui| { - super::proxy(app, ui, 34.0); - ui.add_space(ui.available_width() - 58.0); - if widgets::icon_button(ui, "dc-list-view", tl!("Show List View"), view.is_list(), 26.0).clicked() { - set_pstate(ui.ctx(), "swatch-view", View::SmallList); - } - if widgets::icon_button(ui, "dc-grid-view", tl!("Show Thumbnail View"), !view.is_list(), 26.0).clicked() { - set_pstate(ui.ctx(), "swatch-view", View::MediumThumb); - } - }); - ui.add_space(4.0); - // A recent colour paints the active proxy (Alt: the inactive one), as a swatch does. - if let Some(c) = super::recent_colors_row(app, ui) { - super::apply_click(app, ui, json!({"color": super::color_json(&c)})); - } - widgets::divider(ui); - widgets::subheader(ui, tl!("Swatch Tiles")); - let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), tl!("Find")) } else { String::new() }; - let items = entries(app, kind, &query); - let active_swatch = kept_swatch(app, ui, active_swatch(app)); - let focus = focus_swatch(app, ui, active_swatch.as_deref()); - let selected = selection(app, 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 lit = app.session.active().map(|st| highlighted(&st.doc, sel)).unwrap_or_default(); - let is_sel = |name: &str| lit.contains(&name); - let mut ev = TileEvents::default(); - widgets::list_box(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 name = e.name(); - let (r, resp, chip) = list_row(ui, tile_id(salt, e), view, is_sel(name)); - if focus.as_deref() == Some(name) { - ui.scroll_to_rect(r, Some(egui::Align::Center)); - } - match e { - Entry::Registration => draw_registration(ui, chip), - 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), - } - list_row_name(ui, r, chip, crate::i18n::t_name("swatch", name)); - tile_input(ui, resp, e, &items, sel, true, &mut ev); - } - } else { - for (e, cell) in tile_grid(ui, &items, view, Entry::is_folder) { - let resp = ui.interact(cell, tile_id(salt, e), Sense::click_and_drag()); - let name = e.name(); - if focus.as_deref() == Some(name) { - ui.scroll_to_rect(cell, Some(egui::Align::Center)); - } - match e { - Entry::Registration => draw_registration(ui, cell), - 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 *global { - global_mark(ui, cell, *spot); - } - } - Entry::Folder(_) => folder_tile(ui, cell, is_sel(name)), - } - tile_input(ui, resp, e, &items, sel, false, &mut ev); - } - } - }); - let zone = ui.interact(out.inner_rect, ui.scope_id().with("swatch-drop"), Sense::hover()); - zone_input(ui, &zone, &mut ev); - }); - match ev.edit { - Some(Entry::Folder(g)) => edit_group(app, &g), - Some(e) => { - app.run("ui.swatchOptions", json!({"name": e.name()})).ok(); - } - None => {} - } - 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, "swatch-anchor", selected, &order, e.name(), m); - set_pstate(ui.ctx(), "swatch-selected", sel.clone()); - // A plain click also applies the swatch; modifier clicks only select. - if !(m.shift || m.command) { - apply(app, ui, &e); - } - sel - } - None => selected, - }; - bottom(app, ui, &selected); -} - -/// The document and the objects selected in it. -type SelectedArt = (u64, Vec); - -/// The swatch highlighted for the active paint (`active`): with art selected, its swatch; once the -/// art is deselected, still that swatch, until another document shows or the paints for new art -/// change (a swatch or colour picked with nothing selected). Deselecting doesn't jump to the -/// swatch of the paint new art gets (White). -fn kept_swatch(app: &VectorcraftApp, ui: &Ui, active: Option) -> Option { - let Some(st) = app.session.active() else { return active }; - let p = &app.session.paint; - // The document, and the paints new art gets, which picking with nothing selected changes. - let defaults = format!("{}|{:?}|{:?}|{}", st.uid, p.fill, p.stroke, app.session.fill_active); - if !st.selection.objects.is_empty() { - set_pstate(ui.ctx(), "swatch-kept", Some((defaults, active.clone()))); - return active; - } - match pstate::)>>(ui.ctx(), "swatch-kept") { - Some((d, kept)) if d == defaults => kept, - Some(_) => { - set_pstate(ui.ctx(), "swatch-kept", None::<(String, Option)>); - active - } - None => active, - } -} - -/// The swatch the tiles scroll to this frame, once: the active paint's swatch when it changed, or -/// when other art was selected. Selecting other art also clears the panel's own selection, so its -/// swatch is the one highlighted. -fn focus_swatch(app: &VectorcraftApp, ui: &Ui, active: Option<&str>) -> Option { - let art: Option = app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())); - // (None the first time the panel shows.) - let (last_art, last_swatch): (Option>, Option) = pstate(ui.ctx(), "swatch-focus"); - let reselected = last_art.as_ref().is_some_and(|l| *l != art); - if reselected { - set_pstate(ui.ctx(), "swatch-selected", Vec::::new()); - } - let changed = reselected || last_art.is_none() || last_swatch.as_deref() != active; - set_pstate(ui.ctx(), "swatch-focus", (Some(art), active.map(str::to_string))); - active.filter(|_| changed).map(str::to_string) -} - -/// The swatch the active paint comes from: a linked colour's global swatch (a tint's tint swatch -/// when there is one), a process colour's swatch of that colour (a colour no swatch has: none, so -/// text in a colour of its own highlights nothing), a gradient's gradient swatch, or None; nothing -/// for selected images. -fn active_swatch(app: &VectorcraftApp) -> Option { - // Images and placed files have no fill or stroke: no swatch (not None). - let st = app.session.active()?; - let unpainted = |id: &vectorcraft_doc::NodeId| { - st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_) | vectorcraft_doc::NodeKind::PlacedDocument(_))) - }; - if !st.selection.objects.is_empty() && st.selection.objects.iter().all(unpainted) { - return None; - } - match active_paint(app) { - Paint::Solid { swatch: Some(n), tint, .. } => { - let d = &app.session.active()?.doc; - let tint_swatch = d.swatches_iter().find(|w| w.tint_of() == Some((n.as_str(), tint))); - Some(tint_swatch.map_or(n.clone(), |w| w.name.clone())) - } - // A process colour isn't linked: the swatch of that very colour. - Paint::Solid { color, swatch: None, .. } => { - let d = &app.session.active()?.doc; - d.swatches_iter().find(|w| matches!(w.paint, Paint::Solid { color: c, swatch: None, .. } if c == color)).map(|w| w.name.clone()) - } - Paint::Gradient(g) => g.swatch, - Paint::None => Some("[None]".to_string()), - _ => None, - } -} - -/// The thumbnail cells of `items` for `view` in the full available width, row by row; a folder -/// (`is_folder`) starts a new row. -pub(crate) fn tile_grid<'a, T>(ui: &mut Ui, items: &'a [T], view: View, is_folder: impl Fn(&T) -> bool) -> Vec<(&'a T, Rect)> { - let (tile, pitch) = view.tile(); - let w = ui.available_width(); - let per_row = ((w - 2.0) / pitch).floor().max(1.0) as usize; - let mut rows: Vec> = vec![vec![]]; - for e in items { - match rows.last_mut() { - Some(row) if row.len() < per_row && (row.is_empty() || !is_folder(e)) => row.push(e), - _ => rows.push(vec![e]), - } - } - let mut out = Vec::with_capacity(items.len()); - for row in rows { - let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); - let cell = |c: usize| Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); - out.extend(row.into_iter().enumerate().map(|(c, e)| (e, cell(c)))); - } - out -} - -/// A list-view row for `view` taking clicks and drags as `id`, shaded when `selected` or hovered -/// → (the row, its response, the chip at its left). -pub(crate) fn list_row(ui: &mut Ui, id: egui::Id, view: View, selected: bool) -> (Rect, Response, Rect) { - let t = Tokens::get(ui.ctx()); - let (tile, pitch) = view.tile(); - let (_, r) = ui.allocate_space(vec2(ui.available_width(), pitch)); - let resp = ui.interact(r, id, Sense::click_and_drag()); - if selected { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - (r, resp, Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile))) -} - -/// A list row's name, right of its `chip`. -pub(crate) fn list_row_name(ui: &Ui, r: Rect, chip: Rect, name: &str) { - let t = Tokens::get(ui.ctx()); - ui.painter().text(pos2(chip.right() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); -} - -/// The white corner of a global colour's tile; a spot colour has a dot in it. -pub(crate) fn global_mark(ui: &Ui, cell: Rect, spot: bool) { - 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); - } -} - -/// A colour group's folder in the thumbnail view, outlined when `selected`. -pub(crate) fn folder_tile(ui: &Ui, cell: Rect, selected: bool) { - draw_folder(ui, cell); - if selected { - ui.painter().rect_stroke(cell.expand(1.0), 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Outside); - } -} - -/// The interaction id of a tile or list row (stable per swatch or group name and host `salt`). -fn tile_id(salt: &'static str, e: &Entry) -> egui::Id { - egui::Id::unique((salt, e.name())) -} - -/// The selected swatches and groups that can be deleted (all but None and Registration). -fn deletable(sel: &[String]) -> Vec { - sel.iter().filter(|n| !n.starts_with('[')).cloned().collect() -} - -/// The selected swatch when exactly one swatch with an editor is selected (not None, Registration -/// or a group). -fn editable(app: &VectorcraftApp, sel: &[String]) -> Option { - match sel { - [n] if !n.starts_with('[') && app.session.active().is_some_and(|st| st.doc.swatch(n).is_some()) => Some(n.clone()), - _ => None, - } -} - -/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first). -fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec { - let Some(st) = app.session.active() else { return vec![] }; - sel.iter().filter(|n| st.doc.swatch(n).is_some_and(|w| w.paint.color().is_some())).cloned().collect() -} - -/// The first selected colour group (Ungroup Color Group). -fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option { - let d = &app.session.active()?.doc; - sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned() -} - -/// Edit or Apply Color Group: Recolor Artwork on colour group `group` (and the selected art). -fn edit_group(app: &mut VectorcraftApp, group: &str) { - app.run("ui.recolorDialog", json!({ "group": group })).ok(); -} - -/// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click). -fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { - let params = json!({"names": names}); - if now { - app.run("swatch.delete", params).ok(); - return; - } - let message = match names.as_slice() { - [n] => crate::i18n::fmt(tl!("Delete “{name}”?"), &[("name", n)]), - _ => crate::i18n::fmt(tl!("Delete these {count} swatches and groups?"), &[("count", &names.len().to_string())]), - }; - crate::dialogs::confirm::ask(app, &message, tl!("Art using a deleted global swatch keeps its colour."), "swatch.delete", params); -} - -fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { - let kind: Kind = pstate(ui.ctx(), "swatch-kind"); - widgets::bottom_bar(ui, |ui| { - let open = library_panel::any_open::(&app.ui); - let lr = widgets::icon_button(ui, "library", SwatchLibraries::MENU, open, 24.0); - egui::Popup::menu(&lr).show(|ui| { - ui.set_min_width(200.0); - library_panel::library_menu::(app, ui); - }); - let kr = widgets::icon_button(ui, "dc-swatch-kinds", tl!("Show Swatch Kinds"), kind != Kind::All, 24.0); - egui::Popup::menu(&kr).show(|ui| { - for (k, label) in Kind::ALL { - if menu_item(ui, label, true, k == kind) { - set_pstate(ui.ctx(), "swatch-kind", k); - } - } - }); - // With a colour group selected the options button edits or applies the group. - let opts = editable(app, sel); - if let Some(g) = selected_group(app, sel).filter(|_| opts.is_none()) { - if widgets::icon_button(ui, "palette", tl!("Edit or Apply Color Group"), false, 24.0).clicked() { - edit_group(app, &g); - } - } else if widgets::icon_button_enabled(ui, "dc-options", tl!("Swatch Options"), false, opts.is_some(), 24.0).clicked() - && let Some(n) = opts - { - app.run("ui.swatchOptions", json!({"name": n})).ok(); - } - ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); - if widgets::icon_button(ui, "dc-folder", tl!("New Color Group"), false, 24.0).clicked() { - app.run("ui.newColorGroup", json!({"swatches": deletable(sel)})).ok(); - } - // Alt-click skips the dialog; Ctrl/Cmd-click makes a spot color, and Ctrl/Cmd+Shift-click - // makes a global process color. - if widgets::icon_button(ui, "dc-new-item", tl!("New Swatch"), false, 24.0).clicked() { - new_swatch(app, sel, ui.input(|i| i.modifiers)); - } - // Alt-click deletes without asking. - let del = deletable(sel); - if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Swatch"), false, !del.is_empty(), 24.0).clicked() { - delete(app, del, ui.input(|i| i.modifiers.alt)); - } - }); -} - -/// 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 { - group_of(&app.session.active()?.doc, sel.last()?) -} - -/// New Swatch from the active paint (a color goes into the selected color group), for a click -/// with modifiers `m`: a plain click opens the dialog and an Alt-click saves at once. Cmd/Ctrl -/// makes a color a spot color, and Cmd/Ctrl+Shift makes it a global process color. -fn new_swatch(app: &mut VectorcraftApp, sel: &[String], m: egui::Modifiers) { - let paint = active_paint(app); - let group = paint.color().and_then(|_| target_group(app, sel)); - let (spot, global) = (m.command && !m.shift, m.command && m.shift); - if !m.alt { - app.run("ui.newSwatch", json!({"spot": spot, "global": global, "group": group})).ok(); - return; - } - let mut params = super::swatch_paint_params(&paint); - if paint.color().is_some() { - params["spot"] = json!(spot); - params["global"] = json!(global); - } - if let Some(g) = group { - params["group"] = json!(g); - } - app.run("swatch.new", params).ok(); -} - -pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - let view: View = pstate(ui.ctx(), "swatch-view"); - let selected = selection(app, ui); - let del = deletable(&selected); - let opts = editable(app, &selected); - if menu_item(ui, tl!("New Swatch…"), true, false) { - new_swatch(app, &selected, egui::Modifiers::NONE); - } - if menu_item(ui, tl!("New Color Group…"), true, false) { - app.run("ui.newColorGroup", json!({"swatches": del})).ok(); - } - if menu_item(ui, tl!("Duplicate Swatch"), opts.is_some(), false) - && let Some(n) = &opts - { - app.run("swatch.duplicate", json!({"name": n})).ok(); - } - // The first selected colour is kept. - let merge = mergeable(app, &selected); - if menu_item(ui, tl!("Merge Swatches"), merge.len() > 1, false) { - app.run("swatch.merge", json!({ "names": merge })).ok(); - } - if menu_item(ui, tl!("Delete Swatch"), !del.is_empty(), false) { - delete(app, del, false); - } - let group = selected_group(app, &selected); - if menu_item(ui, tl!("Edit or Apply Color Group…"), group.is_some(), false) - && let Some(g) = &group - { - edit_group(app, g); - } - if menu_item(ui, tl!("Ungroup Color Group"), group.is_some(), false) - && let Some(g) = group - { - app.run("swatch.ungroup", json!({ "name": g })).ok(); - } - if menu_item(ui, tl!("Select All Unused"), true, false) - && let Ok(r) = app.run("swatch.unused", json!({})) - { - let names: Vec = r["names"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); - set_pstate(ui.ctx(), "swatch-selected", names); - } - if menu_item(ui, tl!("Add Used Colors"), true, false) { - app.run("swatch.addUsedColors", json!({})).ok(); - } - ui.separator(); - if menu_item(ui, tl!("Sort by Name"), true, false) { - app.run("swatch.sortByName", json!({})).ok(); - } - if menu_item(ui, tl!("Sort by Kind"), true, false) { - app.run("swatch.sortByKind", json!({})).ok(); - } - ui.separator(); - for (v, label) in View::ALL { - if menu_item(ui, label, true, v == view) { - set_pstate(ui.ctx(), "swatch-view", v); - } - } - ui.separator(); - let find: bool = pstate(ui.ctx(), "swatch-show-find"); - if menu_item(ui, tl!("Show Find Field"), true, find) { - set_pstate(ui.ctx(), "swatch-show-find", !find); - } - ui.separator(); - if menu_item(ui, tl!("Swatch Options…"), opts.is_some(), false) - && let Some(n) = opts - { - app.run("ui.swatchOptions", json!({"name": n})).ok(); - } - if menu_item(ui, tl!("Spot Colors…"), app.session.active().is_some(), false) { - app.run("ui.spotColors", json!({})).ok(); - } - ui.separator(); - ui.menu_button(tl!("Open Swatch Library"), |ui| library_panel::library_menu::(app, ui)); - if menu_item(ui, tl!("Save Swatch Library…"), true, false) { - app.run("ui.saveSwatchLibrary", json!({ "names": deletable(&selected) })).ok(); - } -} - -fn library_refs(libs: Vec) -> Vec { - libs.into_iter().map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() -} - -/// The swatch libraries with solid colours (not the gradient-only ones), for Limit to Library. -pub(crate) fn colour_libraries(app: &VectorcraftApp) -> Vec { - let has_colours = |id: &str| swatchlib::library(&app.session, id).is_some_and(|(_, lib)| lib.iter().any(|w| w.paint.color().is_some())); - library_refs(swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients" && has_colours(&l.id)).collect()) -} - -/// What Limit to Library menus call the document's own swatches ([`swatchlib::DOCUMENT_SWATCHES`]). -pub(crate) const DOCUMENT_SWATCHES: &str = "Document Swatches"; - -/// The name of Limit to Library choice `key` (a library id or [`swatchlib::DOCUMENT_SWATCHES`]); -/// `None` for no limit or a library that is gone. -pub(crate) fn limit_name(app: &VectorcraftApp, key: &str) -> Option { - match key { - "" => None, - swatchlib::DOCUMENT_SWATCHES => Some(DOCUMENT_SWATCHES.into()), - _ => swatchlib::library(&app.session, key).map(|(info, _)| info.name), - } -} - -/// The Limit to Library key for `l`: a library's id (from its id or name), or -/// [`swatchlib::DOCUMENT_SWATCHES`] as it is. -pub(crate) fn limit_key(app: &VectorcraftApp, l: &str) -> Result { - if l == swatchlib::DOCUMENT_SWATCHES { - return Ok(l.into()); - } - swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}` (see swatch.library.list)")) -} - -/// Swatch libraries in library panels (engine: `swatch.library.*`). -pub(crate) struct SwatchLibraries; - -impl LibraryKind for SwatchLibraries { - const KIND: &'static str = "swatches"; - const OPEN: &'static str = "window.swatchLibrary"; - const LIST: &'static str = "swatch.library.list"; - const LOAD: &'static str = "swatch.library.load"; - const MENU: &'static str = "Swatch Libraries Menu"; - const ADD: &'static str = "Add to Swatches"; - type Lib = Arc; - type Item = Swatch; - - fn list(app: &VectorcraftApp) -> Vec { - library_refs(swatchlib::libraries(&app.session)) - } - fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { - swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) - } - fn find(app: &VectorcraftApp, key: &str) -> Option<(String, String, usize)> { - swatchlib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) - } - fn path(app: &VectorcraftApp, id: &str) -> Option { - app.session.swatch_libraries.path(id).map(str::to_string) - } - /// Ungrouped swatches, then each colour group's folder and swatches (a group whose name - /// matches shows all of them), as in the Swatches panel. - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { - let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); - let row = |w: &'a Swatch| Row { name: &w.name, item: Some(w) }; - let mut out: Vec> = lib.swatches.iter().filter(|w| found(&w.name)).map(row).collect(); - for g in &lib.groups { - let all = found(&g.name); - let mut items = g.swatches.iter().filter(|w| all || found(&w.name)).map(row).peekable(); - if items.peek().is_some() { - out.push(Row { name: &g.name, item: None }); - out.extend(items); - } - } - out - } - fn draw(_: &VectorcraftApp, ui: &Ui, r: Rect, w: &Swatch, selected: bool, hovered: bool) { - swatch_tile(ui, r, &w.paint, selected, hovered); - if w.global { - global_mark(ui, r, w.spot); - } - } - fn row_icons(ui: &Ui, r: Rect, w: &Swatch) { - list_icons(ui, r, &w.paint, w.global, w.spot); - } - fn describe(w: &Swatch) -> String { - describe(&w.paint, w.global, w.spot) - } - /// Adds the swatch and applies it to the active proxy (Alt: the inactive one), as one step. - fn click(app: &mut VectorcraftApp, ui: &Ui, id: &str, name: &str) { - let alt = super::alt_held(ui); - let apply = if super::proxy_cmd(app, alt) == "paint.setFill" { "fill" } else { "stroke" }; - add_from_library(app, json!({"library": id, "names": [name], "apply": apply, "focus": !alt})); - } - fn add(app: &mut VectorcraftApp, id: &str, names: Vec) { - add_from_library(app, json!({"library": id, "names": names})); - } - fn menu_head(app: &mut VectorcraftApp, ui: &mut Ui) { - if menu_item(ui, tl!("Default Swatches"), app.session.active().is_some(), false) { - app.run("swatch.resetDefaults", json!({})).ok(); - } - ui.separator(); - } - fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) { - ui.separator(); - // As a command, so its file dialog, shown off the UI thread, loads the library when it - // answers. - if menu_item(ui, tl!("Other Library…"), true, false) - && let Err(e) = app.run("window.swatchLibrary.other", json!({})) - { - app.status(e); - } - if menu_item(ui, tl!("Save Swatch Library…"), app.session.active().is_some(), false) { - let names = deletable(&selection(app, ui)); - app.run("ui.saveSwatchLibrary", json!({ "names": names })).ok(); - } - } -} - -fn add_from_library(app: &mut VectorcraftApp, params: Value) { - if let Err(e) = app.run("swatch.library.add", params) { - app.status(e); - } -} - -/// `window.swatchLibrary {library}`: open a swatch library in its own panel (`library` null closes -/// every swatch library panel). -pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result { - library_panel::open_command::(app, p) -} - -/// `swatch.library.load` params, then open the library in its panel. -pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result { - let r = app.run("swatch.library.load", params)?; - open_library(app, &json!({ "library": r["library"] })) -} - -/// Other Library…: load the library (or document) at `path`, else one picked in an open dialog -/// (on the web the picked file arrives later and opens through [`crate::io::open_bytes`]). -pub(crate) fn other_library(app: &mut VectorcraftApp, path: Option) -> Result { - match library_panel::pick_library_file(app, path, library_panel::library_pick_filters(SwatchLibraries::KIND))? { - Some(path) => load_library(app, json!({ "path": path })), - None => Ok(Value::Null), - } -} - -/// The id prefix of the Window → Swatch Libraries → User Defined slots. -pub(crate) const USER_SLOT: &str = "window.userSwatchLibrary"; - -/// The User Defined library slot `id` (`window.userSwatchLibrary3`) stands for. -pub(crate) fn user_library(app: &VectorcraftApp, id: &str) -> Option { - library_panel::user_slot(id, USER_SLOT, swatchlib::libraries(&app.session)) -} - -/// Window → Swatch Libraries. -pub(crate) fn window_menu() -> Vec { - const SLOTS: [&str; 10] = [ - "window.userSwatchLibrary1", - "window.userSwatchLibrary2", - "window.userSwatchLibrary3", - "window.userSwatchLibrary4", - "window.userSwatchLibrary5", - "window.userSwatchLibrary6", - "window.userSwatchLibrary7", - "window.userSwatchLibrary8", - "window.userSwatchLibrary9", - "window.userSwatchLibrary10", - ]; - let open = |b: &'static BuiltinLibrary| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id })); - let mut items = vec![Item::Cmd("Default Swatches", "swatch.resetDefaults", Value::Null), Item::Sep]; - items.extend(SWATCH_LIBRARIES.iter().map(open)); - items.extend([ - Item::Sub("Gradients", GRADIENT_LIBRARIES.iter().map(open).collect()), - Item::Sep, - Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()), - Item::Sep, - Item::Cmd("Other Library…", "window.swatchLibrary.other", Value::Null), - Item::Cmd("Save Swatch Library…", "ui.saveSwatchLibrary", Value::Null), - ]); - items -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/swatches/actions.rs b/crates/ui-egui/src/panels/swatches/actions.rs new file mode 100644 index 000000000..33a4078cd --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/actions.rs @@ -0,0 +1,199 @@ +use super::dnd::{group_of, selection}; +use super::libraries::SwatchLibraries; +use super::*; + +/// The selected swatches and groups that can be deleted (all but None and Registration). +pub(super) fn deletable(sel: &[String]) -> Vec { + sel.iter().filter(|n| !n.starts_with('[')).cloned().collect() +} + +/// The selected swatch when exactly one swatch with an editor is selected (not None, Registration +/// or a group). +fn editable(app: &VectorcraftApp, sel: &[String]) -> Option { + match sel { + [n] if !n.starts_with('[') && app.session.active().is_some_and(|st| st.doc.swatch(n).is_some()) => Some(n.clone()), + _ => None, + } +} + +/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first). +pub(super) fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + sel.iter().filter(|n| st.doc.swatch(n).is_some_and(|w| w.paint.color().is_some())).cloned().collect() +} + +/// The first selected colour group (Ungroup Color Group). +pub(super) fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option { + let d = &app.session.active()?.doc; + sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned() +} + +/// Edit or Apply Color Group: Recolor Artwork on colour group `group` (and the selected art). +pub(super) fn edit_group(app: &mut VectorcraftApp, group: &str) { + app.run("ui.recolorDialog", json!({ "group": group })).ok(); +} + +/// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click). +pub(super) fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { + let params = json!({"names": names}); + if now { + app.run("swatch.delete", params).ok(); + return; + } + let message = match names.as_slice() { + [n] => crate::i18n::fmt(tl!("Delete “{name}”?"), &[("name", n)]), + _ => crate::i18n::fmt(tl!("Delete these {count} swatches and groups?"), &[("count", &names.len().to_string())]), + }; + crate::dialogs::confirm::ask(app, &message, tl!("Art using a deleted global swatch keeps its colour."), "swatch.delete", params); +} + +pub(super) fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { + let kind: Kind = pstate(ui.ctx(), "swatch-kind"); + widgets::bottom_bar(ui, |ui| { + let open = library_panel::any_open::(&app.ui); + let lr = widgets::icon_button(ui, "library", SwatchLibraries::MENU, open, 24.0); + egui::Popup::menu(&lr).show(|ui| { + ui.set_min_width(200.0); + library_panel::library_menu::(app, ui); + }); + let kr = widgets::icon_button(ui, "dc-swatch-kinds", tl!("Show Swatch Kinds"), kind != Kind::All, 24.0); + egui::Popup::menu(&kr).show(|ui| { + for (k, label) in Kind::ALL { + if menu_item(ui, label, true, k == kind) { + set_pstate(ui.ctx(), "swatch-kind", k); + } + } + }); + // With a colour group selected the options button edits or applies the group. + let opts = editable(app, sel); + if let Some(g) = selected_group(app, sel).filter(|_| opts.is_none()) { + if widgets::icon_button(ui, "palette", tl!("Edit or Apply Color Group"), false, 24.0).clicked() { + edit_group(app, &g); + } + } else if widgets::icon_button_enabled(ui, "dc-options", tl!("Swatch Options"), false, opts.is_some(), 24.0).clicked() + && let Some(n) = opts + { + app.run("ui.swatchOptions", json!({"name": n})).ok(); + } + ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); + if widgets::icon_button(ui, "dc-folder", tl!("New Color Group"), false, 24.0).clicked() { + app.run("ui.newColorGroup", json!({"swatches": deletable(sel)})).ok(); + } + // Alt-click skips the dialog; Ctrl/Cmd-click makes a spot color, and Ctrl/Cmd+Shift-click + // makes a global process color. + if widgets::icon_button(ui, "dc-new-item", tl!("New Swatch"), false, 24.0).clicked() { + new_swatch(app, sel, ui.input(|i| i.modifiers)); + } + // Alt-click deletes without asking. + let del = deletable(sel); + if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Swatch"), false, !del.is_empty(), 24.0).clicked() { + delete(app, del, ui.input(|i| i.modifiers.alt)); + } + }); +} + +/// 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 { + group_of(&app.session.active()?.doc, sel.last()?) +} + +/// New Swatch from the active paint (a color goes into the selected color group), for a click +/// with modifiers `m`: a plain click opens the dialog and an Alt-click saves at once. Cmd/Ctrl +/// makes a color a spot color, and Cmd/Ctrl+Shift makes it a global process color. +pub(super) fn new_swatch(app: &mut VectorcraftApp, sel: &[String], m: egui::Modifiers) { + let paint = active_paint(app); + let group = paint.color().and_then(|_| target_group(app, sel)); + let (spot, global) = (m.command && !m.shift, m.command && m.shift); + if !m.alt { + app.run("ui.newSwatch", json!({"spot": spot, "global": global, "group": group})).ok(); + return; + } + let mut params = crate::panels::swatch_paint_params(&paint); + if paint.color().is_some() { + params["spot"] = json!(spot); + params["global"] = json!(global); + } + if let Some(g) = group { + params["group"] = json!(g); + } + app.run("swatch.new", params).ok(); +} + +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let view: View = pstate(ui.ctx(), "swatch-view"); + let selected = selection(app, ui); + let del = deletable(&selected); + let opts = editable(app, &selected); + if menu_item(ui, tl!("New Swatch…"), true, false) { + new_swatch(app, &selected, egui::Modifiers::NONE); + } + if menu_item(ui, tl!("New Color Group…"), true, false) { + app.run("ui.newColorGroup", json!({"swatches": del})).ok(); + } + if menu_item(ui, tl!("Duplicate Swatch"), opts.is_some(), false) + && let Some(n) = &opts + { + app.run("swatch.duplicate", json!({"name": n})).ok(); + } + // The first selected colour is kept. + let merge = mergeable(app, &selected); + if menu_item(ui, tl!("Merge Swatches"), merge.len() > 1, false) { + app.run("swatch.merge", json!({ "names": merge })).ok(); + } + if menu_item(ui, tl!("Delete Swatch"), !del.is_empty(), false) { + delete(app, del, false); + } + let group = selected_group(app, &selected); + if menu_item(ui, tl!("Edit or Apply Color Group…"), group.is_some(), false) + && let Some(g) = &group + { + edit_group(app, g); + } + if menu_item(ui, tl!("Ungroup Color Group"), group.is_some(), false) + && let Some(g) = group + { + app.run("swatch.ungroup", json!({ "name": g })).ok(); + } + if menu_item(ui, tl!("Select All Unused"), true, false) + && let Ok(r) = app.run("swatch.unused", json!({})) + { + let names: Vec = r["names"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + set_pstate(ui.ctx(), "swatch-selected", names); + } + if menu_item(ui, tl!("Add Used Colors"), true, false) { + app.run("swatch.addUsedColors", json!({})).ok(); + } + ui.separator(); + if menu_item(ui, tl!("Sort by Name"), true, false) { + app.run("swatch.sortByName", json!({})).ok(); + } + if menu_item(ui, tl!("Sort by Kind"), true, false) { + app.run("swatch.sortByKind", json!({})).ok(); + } + ui.separator(); + for (v, label) in View::ALL { + if menu_item(ui, label, true, v == view) { + set_pstate(ui.ctx(), "swatch-view", v); + } + } + ui.separator(); + let find: bool = pstate(ui.ctx(), "swatch-show-find"); + if menu_item(ui, tl!("Show Find Field"), true, find) { + set_pstate(ui.ctx(), "swatch-show-find", !find); + } + ui.separator(); + if menu_item(ui, tl!("Swatch Options…"), opts.is_some(), false) + && let Some(n) = opts + { + app.run("ui.swatchOptions", json!({"name": n})).ok(); + } + if menu_item(ui, tl!("Spot Colors…"), app.session.active().is_some(), false) { + app.run("ui.spotColors", json!({})).ok(); + } + ui.separator(); + ui.menu_button(tl!("Open Swatch Library"), |ui| library_panel::library_menu::(app, ui)); + if menu_item(ui, tl!("Save Swatch Library…"), true, false) { + app.run("ui.saveSwatchLibrary", json!({ "names": deletable(&selected) })).ok(); + } +} diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs new file mode 100644 index 000000000..259a48809 --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -0,0 +1,194 @@ +use super::actions::{bottom, edit_group}; +use super::dnd::{TileEvents, apply_drop, click_selection, highlighted, selection, tile_input, zone_input}; +use super::tiles::{ + draw_folder, draw_registration, folder_tile, global_mark, list_icons, list_row, list_row_name, pattern_thumb, tile_grid, tile_id, +}; +use super::*; + +/// The id of the find field's text (shown with the panel menu's Show Find Field). +pub(super) fn find_id() -> egui::Id { + egui::Id::unique("swatch-find") +} + +/// The panel's body; `salt` keys its tiles (see [`tile_id`]). +pub(super) fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { + if app.session.active().is_none() { + crate::panels::empty_state(ui, "swatch-book", tl!("No document"), tl!("Open a document to see its swatches.")); + return; + } + let view: View = pstate(ui.ctx(), "swatch-view"); + let kind: Kind = pstate(ui.ctx(), "swatch-kind"); + // Top row: proxy at left, list / grid view toggles at right. + ui.horizontal(|ui| { + crate::panels::proxy(app, ui, 34.0); + ui.add_space(ui.available_width() - 58.0); + if widgets::icon_button(ui, "dc-list-view", tl!("Show List View"), view.is_list(), 26.0).clicked() { + set_pstate(ui.ctx(), "swatch-view", View::SmallList); + } + if widgets::icon_button(ui, "dc-grid-view", tl!("Show Thumbnail View"), !view.is_list(), 26.0).clicked() { + set_pstate(ui.ctx(), "swatch-view", View::MediumThumb); + } + }); + ui.add_space(4.0); + // A recent colour paints the active proxy (Alt: the inactive one), as a swatch does. + if let Some(c) = crate::panels::recent_colors_row(app, ui) { + crate::panels::apply_click(app, ui, json!({"color": crate::panels::color_json(&c)})); + } + widgets::divider(ui); + widgets::subheader(ui, tl!("Swatch Tiles")); + let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), tl!("Find")) } else { String::new() }; + let items = entries(app, kind, &query); + let active_swatch = kept_swatch(app, ui, active_swatch(app)); + let focus = focus_swatch(app, ui, active_swatch.as_deref()); + let selected = selection(app, 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 lit = app.session.active().map(|st| highlighted(&st.doc, sel)).unwrap_or_default(); + let is_sel = |name: &str| lit.contains(&name); + let mut ev = TileEvents::default(); + widgets::list_box(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 name = e.name(); + let (r, resp, chip) = list_row(ui, tile_id(salt, e), view, is_sel(name)); + if focus.as_deref() == Some(name) { + ui.scroll_to_rect(r, Some(egui::Align::Center)); + } + match e { + Entry::Registration => draw_registration(ui, chip), + 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), + } + list_row_name(ui, r, chip, crate::i18n::t_name("swatch", name)); + tile_input(ui, resp, e, &items, sel, true, &mut ev); + } + } else { + for (e, cell) in tile_grid(ui, &items, view, Entry::is_folder) { + let resp = ui.interact(cell, tile_id(salt, e), Sense::click_and_drag()); + let name = e.name(); + if focus.as_deref() == Some(name) { + ui.scroll_to_rect(cell, Some(egui::Align::Center)); + } + match e { + Entry::Registration => draw_registration(ui, cell), + 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 *global { + global_mark(ui, cell, *spot); + } + } + Entry::Folder(_) => folder_tile(ui, cell, is_sel(name)), + } + tile_input(ui, resp, e, &items, sel, false, &mut ev); + } + } + }); + let zone = ui.interact(out.inner_rect, ui.scope_id().with("swatch-drop"), Sense::hover()); + zone_input(ui, &zone, &mut ev); + }); + match ev.edit { + Some(Entry::Folder(g)) => edit_group(app, &g), + Some(e) => { + app.run("ui.swatchOptions", json!({"name": e.name()})).ok(); + } + None => {} + } + 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, "swatch-anchor", selected, &order, e.name(), m); + set_pstate(ui.ctx(), "swatch-selected", sel.clone()); + // A plain click also applies the swatch; modifier clicks only select. + if !(m.shift || m.command) { + apply(app, ui, &e); + } + sel + } + None => selected, + }; + bottom(app, ui, &selected); +} + +/// The document and the objects selected in it. +type SelectedArt = (u64, Vec); + +/// The swatch highlighted for the active paint (`active`): with art selected, its swatch; once the +/// art is deselected, still that swatch, until another document shows or the paints for new art +/// change (a swatch or colour picked with nothing selected). Deselecting doesn't jump to the +/// swatch of the paint new art gets (White). +pub(super) fn kept_swatch(app: &VectorcraftApp, ui: &Ui, active: Option) -> Option { + let Some(st) = app.session.active() else { return active }; + let p = &app.session.paint; + // The document, and the paints new art gets, which picking with nothing selected changes. + let defaults = format!("{}|{:?}|{:?}|{}", st.uid, p.fill, p.stroke, app.session.fill_active); + if !st.selection.objects.is_empty() { + set_pstate(ui.ctx(), "swatch-kept", Some((defaults, active.clone()))); + return active; + } + match pstate::)>>(ui.ctx(), "swatch-kept") { + Some((d, kept)) if d == defaults => kept, + Some(_) => { + set_pstate(ui.ctx(), "swatch-kept", None::<(String, Option)>); + active + } + None => active, + } +} + +/// The swatch the tiles scroll to this frame, once: the active paint's swatch when it changed, or +/// when other art was selected. Selecting other art also clears the panel's own selection, so its +/// swatch is the one highlighted. +pub(super) fn focus_swatch(app: &VectorcraftApp, ui: &Ui, active: Option<&str>) -> Option { + let art: Option = app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())); + // (None the first time the panel shows.) + let (last_art, last_swatch): (Option>, Option) = pstate(ui.ctx(), "swatch-focus"); + let reselected = last_art.as_ref().is_some_and(|l| *l != art); + if reselected { + set_pstate(ui.ctx(), "swatch-selected", Vec::::new()); + } + let changed = reselected || last_art.is_none() || last_swatch.as_deref() != active; + set_pstate(ui.ctx(), "swatch-focus", (Some(art), active.map(str::to_string))); + active.filter(|_| changed).map(str::to_string) +} + +/// The swatch the active paint comes from: a linked colour's global swatch (a tint's tint swatch +/// when there is one), a process colour's swatch of that colour (a colour no swatch has: none, so +/// text in a colour of its own highlights nothing), a gradient's gradient swatch, or None; nothing +/// for selected images. +pub(super) fn active_swatch(app: &VectorcraftApp) -> Option { + // Images and placed files have no fill or stroke: no swatch (not None). + let st = app.session.active()?; + let unpainted = |id: &vectorcraft_doc::NodeId| { + st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_) | vectorcraft_doc::NodeKind::PlacedDocument(_))) + }; + if !st.selection.objects.is_empty() && st.selection.objects.iter().all(unpainted) { + return None; + } + match active_paint(app) { + Paint::Solid { swatch: Some(n), tint, .. } => { + let d = &app.session.active()?.doc; + let tint_swatch = d.swatches_iter().find(|w| w.tint_of() == Some((n.as_str(), tint))); + Some(tint_swatch.map_or(n.clone(), |w| w.name.clone())) + } + // A process colour isn't linked: the swatch of that very colour. + Paint::Solid { color, swatch: None, .. } => { + let d = &app.session.active()?.doc; + d.swatches_iter().find(|w| matches!(w.paint, Paint::Solid { color: c, swatch: None, .. } if c == color)).map(|w| w.name.clone()) + } + Paint::Gradient(g) => g.swatch, + Paint::None => Some("[None]".to_string()), + _ => None, + } +} diff --git a/crates/ui-egui/src/panels/swatches/dnd.rs b/crates/ui-egui/src/panels/swatches/dnd.rs new file mode 100644 index 000000000..e7916fe8b --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/dnd.rs @@ -0,0 +1,341 @@ +use super::tiles::{describe, draw_registration, pattern_thumb}; +use super::*; + +/// The swatches and colour groups selected in the panel, in click order, without names that no +/// longer exist (deleted, renamed or undone). +pub(super) fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let mut names: Vec = pstate(ui.ctx(), "swatch-selected"); + names.retain(|n| n == REGISTRATION || st.doc.swatch_name_taken(n)); + names +} + +/// The names the panel highlights: `sel` plus the swatches of selected colour groups (clicking a +/// folder selects the whole group). +pub(super) 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 (kept in panel state `anchor_key`) over `order` (the names as displayed), a plain click +/// selects it alone. +pub(crate) fn click_selection(ui: &Ui, anchor_key: &str, mut sel: Vec, order: &[&str], name: &str, m: egui::Modifiers) -> Vec { + let anchor: String = pstate(ui.ctx(), anchor_key); + let pos = |n: &str| order.iter().position(|o| *o == n); + if m.shift + && let (Some(a), Some(b)) = (pos(&anchor), pos(name)) + { + for n in &order[a.min(b)..=a.max(b)] { + if !sel.iter().any(|s| s == n) { + sel.push(n.to_string()); + } + } + return sel; + } + set_pstate(ui.ctx(), anchor_key, name.to_string()); + if !m.command { + return vec![name.to_string()]; + } + match sel.iter().position(|s| s == name) { + Some(i) => { + sel.remove(i); + } + None => sel.push(name.to_string()), + } + sel +} + +/// A drag released on the panel. +pub(super) enum Drop { + /// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or + /// at the end (`None`). With Alt held, `with` names the swatch grabbed: dropped on swatch + /// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it + /// gives that swatch its paint instead of moving. + Move { names: Vec, target: Option, after: bool, with: Option }, + /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row + /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch + /// `target` when that is of its kind and not a tint swatch. + New { paint: Paint, target: Option, replace: bool }, + /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). + Pattern { ids: Vec }, +} + +impl Drop { + /// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows + /// it carries (with Alt held, `replace`, a swatch grabbed may replace `target`), make a swatch of + /// a paint dragged from elsewhere, or a pattern swatch of art dragged off the canvas (appearances: + /// nothing). + fn of(d: &PanelDrag, target: Option, after: bool, replace: bool) -> Option { + match d { + PanelDrag::Paint { rows: Some(r), .. } => { + let with = (replace && !r.groups).then(|| r.grabbed.clone()); + Some(Drop::Move { names: r.names.clone(), target, after, with }) + } + PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target, replace }), + PanelDrag::Art(ids) => Some(Drop::Pattern { ids: ids.iter().map(|id| id.0).collect() }), + _ => None, + } + } +} + +/// The drag held over `resp`, if the panel takes it (paints and art dragged off the canvas, not the +/// Appearance panel's thumbnail). +fn held(resp: &Response) -> Option> { + resp.dnd_hover_payload::().filter(|d| matches!(**d, PanelDrag::Paint { .. } | PanelDrag::Art(_))) +} + +/// The drag a tile or row of `e` starts, moving rows `names`. Colour groups have no paint of their +/// own (the chip under the pointer draws Registration's mark for Registration). +pub(super) fn drag_of(e: &Entry, names: Vec) -> PanelDrag { + let paint = match e { + Entry::Swatch { paint, .. } => paint.clone(), + Entry::Registration => Paint::registration(), + Entry::Folder(_) => Paint::None, + }; + let rows = SwatchRows { grabbed: e.name().to_string(), names, groups: e.is_folder() }; + PanelDrag::Paint { paint, params: swatch_params(e).unwrap_or_default(), rows: Some(rows), library: None } +} + +/// What the tiles or rows saw this frame. +#[derive(Default)] +pub(super) struct TileEvents { + pub(super) clicked: Option<(Entry, egui::Modifiers)>, + /// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing), + /// or Edit or Apply Color Group on a colour group. + pub(super) edit: Option, + pub(super) 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 [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it. +pub(super) 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 { .. } | Entry::Folder(_)) && resp.double_clicked() { + ev.edit = Some(e.clone()); + } + 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(), drag_of(e, names)); + } + tile_drop(ui, &resp, e, list, ev); + // The default swatches' names show in the UI's language; the document keeps them as they are. + let shown = crate::i18n::t_name("swatch", name); + let resp = match e { + Entry::Folder(_) => resp.on_hover_text(crate::i18n::fmt(tl!("Color Group: {name}"), &[("name", shown)])), + Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| { + ui.label(format!("{shown} ({})", describe(paint, *global, *spot))); + }), + _ => resp.on_hover_text(shown), + }; + if resp.clicked() { + ev.clicked = Some((e.clone(), ui.input(|i| i.modifiers))); + } +} + +/// A drag held over the tile or row `resp` of entry `e` shows where it would land (before or after +/// it by the pointer's half, or into a folder); a release there sets the drop. +fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvents) { + let Some(d) = held(resp) else { return }; + let PanelDrag::Paint { rows, .. } = &*d else { return }; + let name = e.name(); + ev.over = true; + // Swatches dropped on one of themselves stay where they are. + if rows.as_ref().is_some_and(|r| r.names.iter().any(|n| n == name)) { + resp.dnd_release_payload::(); + return; + } + 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 paint from elsewhere, or swatches over a folder, go into it; otherwise a bar marks the slot. + if rows.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)); + } + if resp.dnd_release_payload::().is_some() { + ev.drop = Drop::of(&d, Some(name.to_string()), after, crate::panels::alt_held(ui)); + } +} + +/// 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. +pub(super) fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) { + let Some(d) = held(zone) else { return }; + if !ev.over { + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if zone.dnd_release_payload::().is_some() { + ev.drop = Drop::of(&d, None, false, false); + } +} + +/// The colour group `name` is, or the one swatch `name` belongs to. +pub(super) 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})) +} + +/// Are both paints colours, gradients or patterns? +fn same_kind(a: &Paint, b: &Paint) -> bool { + std::mem::discriminant(a) == std::mem::discriminant(b) && !a.is_none() +} + +/// Can swatch `with` give its paint to swatch `target`? Both are swatches of one kind, neither is +/// None or Registration, and `target` is not a tint swatch (a tint swatch only takes a new name). +fn replaces(d: &Document, with: &str, target: &str) -> bool { + match (d.swatch(with), d.swatch(target)) { + (Some(w), Some(t)) => with != target && !w.is_reserved() && !t.is_reserved() && t.tint_of().is_none() && same_kind(&t.paint, &w.paint), + _ => false, + } +} + +/// Run a drop on the panel: move the swatches, give the paint of a swatch Alt-dropped on another of +/// its kind to that one, or make a swatch of the proxy's paint (a color dropped on a folder or a +/// grouped swatch joins that group). +pub(super) 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 { + // Alt: the swatch grabbed replaces the color, gradient or pattern of the swatch of its kind + // it is dropped on, which keeps its name and place. + Drop::Move { with: Some(w), target: Some(t), .. } if replaces(d, &w, &t) => ("swatch.edit", json!({"name": t, "paint": {"swatch": w}})), + Drop::Move { names, target, after, .. } => match move_params(d, &names, target.as_deref(), after) { + Some(p) => ("swatch.move", p), + None => return, + }, + // Alt: an edited gradient (or colour) replaces the swatch it is dropped on. + Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => { + ("swatch.edit", json!({"name": t, "paint": crate::panels::swatch_paint_params(&paint)})) + } + Drop::New { paint, target, .. } => { + let mut p = crate::panels::swatch_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) + } + Drop::Pattern { ids } => ("object.pattern.make", json!({"ids": ids, "edit": false})), + }; + if let Err(e) = app.run(cmd, params) { + app.status(e); + } +} + +/// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, +/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance +/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the +/// thumbnail of a dragged graphic style. +pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { + let Some(d) = egui::DragAndDrop::payload::(ctx) else { return }; + let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint()); + let (paint, registration) = match &*d { + // Colour groups paint nothing. + PanelDrag::Paint { params, .. } if params.is_null() => return, + PanelDrag::Paint { paint, rows, .. } => (std::borrow::Cow::Borrowed(paint), rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION)), + PanelDrag::Appearance(id) => match fill_of(id) { + Some(p) => (std::borrow::Cow::Owned(p), false), + None => return, + }, + PanelDrag::Art(ids) => match ids.first().and_then(fill_of) { + Some(p) => (std::borrow::Cow::Owned(p), false), + None => return, + }, + PanelDrag::GraphicStyle(name) => return pointer_chip(ctx, |ui, r| crate::panels::graphic_styles::paint_style(app, ui, r, name)), + PanelDrag::Symbol(name) => match app.session.active() { + Some(st) => { + return pointer_chip(ctx, |ui, r| crate::panels::symbols::chip(ui, &st.doc, &crate::panels::symbols::document_symbols(st), r, name)); + } + None => return, + }, + PanelDrag::LibrarySymbol { library, name } => match vectorcraft_engine::cmd::symbollib::library(&app.session, library) { + Some((_, lib)) => { + return pointer_chip(ctx, |ui, r| { + crate::panels::symbols::chip(ui, &lib.doc, &crate::panels::symbols::library_symbols(&lib), r, name) + }); + } + None => return, + }, + PanelDrag::Brush { name, .. } => match app.session.active() { + Some(st) => return pointer_chip(ctx, |ui, r| crate::panels::brushes::document_chip(ui, r, st, name)), + None => return, + }, + PanelDrag::LibraryBrush { library, name } => match vectorcraft_engine::cmd::brushlib::library(&app.session, library) { + Some((_, lib)) => return pointer_chip(ctx, |ui, r| crate::panels::brushes::library_chip(ui, r, &lib, name)), + None => return, + }, + PanelDrag::LibraryGraphic { library, item } => return pointer_chip(ctx, |ui, r| crate::panels::libraries::chip(app, ui, r, library, item)), + PanelDrag::LibraryItem { .. } | PanelDrag::LibraryGroup { .. } => return, + }; + pointer_chip(ctx, |ui, r| { + if registration { + draw_registration(ui, r); + } else { + swatch_tile(ui, r, &paint, false, false); + pattern_thumb(app, ui, r.shrink(1.0), &paint); + } + }); +} + +/// A 16-point chip `draw` paints, following the pointer. +fn pointer_chip(ctx: &egui::Context, draw: impl FnOnce(&Ui, Rect)) { + let Some(at) = ctx.pointer_hover_pos() else { return }; + let area = egui::Area::new(egui::Id::unique("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()); + draw(ui, r); + }); +} diff --git a/crates/ui-egui/src/panels/swatches/libraries.rs b/crates/ui-egui/src/panels/swatches/libraries.rs new file mode 100644 index 000000000..3f0200398 --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/libraries.rs @@ -0,0 +1,183 @@ +use super::actions::deletable; +use super::dnd::selection; +use super::tiles::{describe, global_mark, list_icons}; +use super::*; + +fn library_refs(libs: Vec) -> Vec { + libs.into_iter().map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() +} + +/// The swatch libraries with solid colours (not the gradient-only ones), for Limit to Library. +pub(crate) fn colour_libraries(app: &VectorcraftApp) -> Vec { + let has_colours = |id: &str| swatchlib::library(&app.session, id).is_some_and(|(_, lib)| lib.iter().any(|w| w.paint.color().is_some())); + library_refs(swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients" && has_colours(&l.id)).collect()) +} + +/// What Limit to Library menus call the document's own swatches ([`swatchlib::DOCUMENT_SWATCHES`]). +pub(crate) const DOCUMENT_SWATCHES: &str = "Document Swatches"; + +/// The name of Limit to Library choice `key` (a library id or [`swatchlib::DOCUMENT_SWATCHES`]); +/// `None` for no limit or a library that is gone. +pub(crate) fn limit_name(app: &VectorcraftApp, key: &str) -> Option { + match key { + "" => None, + swatchlib::DOCUMENT_SWATCHES => Some(DOCUMENT_SWATCHES.into()), + _ => swatchlib::library(&app.session, key).map(|(info, _)| info.name), + } +} + +/// The Limit to Library key for `l`: a library's id (from its id or name), or +/// [`swatchlib::DOCUMENT_SWATCHES`] as it is. +pub(crate) fn limit_key(app: &VectorcraftApp, l: &str) -> Result { + if l == swatchlib::DOCUMENT_SWATCHES { + return Ok(l.into()); + } + swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}` (see swatch.library.list)")) +} + +/// Swatch libraries in library panels (engine: `swatch.library.*`). +pub(crate) struct SwatchLibraries; + +impl LibraryKind for SwatchLibraries { + const KIND: &'static str = "swatches"; + const OPEN: &'static str = "window.swatchLibrary"; + const LIST: &'static str = "swatch.library.list"; + const LOAD: &'static str = "swatch.library.load"; + const MENU: &'static str = "Swatch Libraries Menu"; + const ADD: &'static str = "Add to Swatches"; + type Lib = Arc; + type Item = Swatch; + + fn list(app: &VectorcraftApp) -> Vec { + library_refs(swatchlib::libraries(&app.session)) + } + fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { + swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) + } + fn find(app: &VectorcraftApp, key: &str) -> Option<(String, String, usize)> { + swatchlib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) + } + fn path(app: &VectorcraftApp, id: &str) -> Option { + app.session.swatch_libraries.path(id).map(str::to_string) + } + /// Ungrouped swatches, then each colour group's folder and swatches (a group whose name + /// matches shows all of them), as in the Swatches panel. + fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); + let row = |w: &'a Swatch| Row { name: &w.name, item: Some(w) }; + let mut out: Vec> = lib.swatches.iter().filter(|w| found(&w.name)).map(row).collect(); + for g in &lib.groups { + let all = found(&g.name); + let mut items = g.swatches.iter().filter(|w| all || found(&w.name)).map(row).peekable(); + if items.peek().is_some() { + out.push(Row { name: &g.name, item: None }); + out.extend(items); + } + } + out + } + fn draw(_: &VectorcraftApp, ui: &Ui, r: Rect, w: &Swatch, selected: bool, hovered: bool) { + swatch_tile(ui, r, &w.paint, selected, hovered); + if w.global { + global_mark(ui, r, w.spot); + } + } + fn row_icons(ui: &Ui, r: Rect, w: &Swatch) { + list_icons(ui, r, &w.paint, w.global, w.spot); + } + fn describe(w: &Swatch) -> String { + describe(&w.paint, w.global, w.spot) + } + /// Adds the swatch and applies it to the active proxy (Alt: the inactive one), as one step. + fn click(app: &mut VectorcraftApp, ui: &Ui, id: &str, name: &str) { + let alt = crate::panels::alt_held(ui); + let apply = if crate::panels::proxy_cmd(app, alt) == "paint.setFill" { "fill" } else { "stroke" }; + add_from_library(app, json!({"library": id, "names": [name], "apply": apply, "focus": !alt})); + } + fn add(app: &mut VectorcraftApp, id: &str, names: Vec) { + add_from_library(app, json!({"library": id, "names": names})); + } + fn menu_head(app: &mut VectorcraftApp, ui: &mut Ui) { + if menu_item(ui, tl!("Default Swatches"), app.session.active().is_some(), false) { + app.run("swatch.resetDefaults", json!({})).ok(); + } + ui.separator(); + } + fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.separator(); + // As a command, so its file dialog, shown off the UI thread, loads the library when it + // answers. + if menu_item(ui, tl!("Other Library…"), true, false) + && let Err(e) = app.run("window.swatchLibrary.other", json!({})) + { + app.status(e); + } + if menu_item(ui, tl!("Save Swatch Library…"), app.session.active().is_some(), false) { + let names = deletable(&selection(app, ui)); + app.run("ui.saveSwatchLibrary", json!({ "names": names })).ok(); + } + } +} + +fn add_from_library(app: &mut VectorcraftApp, params: Value) { + if let Err(e) = app.run("swatch.library.add", params) { + app.status(e); + } +} + +/// `window.swatchLibrary {library}`: open a swatch library in its own panel (`library` null closes +/// every swatch library panel). +pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result { + library_panel::open_command::(app, p) +} + +/// `swatch.library.load` params, then open the library in its panel. +pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result { + let r = app.run("swatch.library.load", params)?; + open_library(app, &json!({ "library": r["library"] })) +} + +/// Other Library…: load the library (or document) at `path`, else one picked in an open dialog +/// (on the web the picked file arrives later and opens through [`crate::io::open_bytes`]). +pub(crate) fn other_library(app: &mut VectorcraftApp, path: Option) -> Result { + match library_panel::pick_library_file(app, path, library_panel::library_pick_filters(SwatchLibraries::KIND))? { + Some(path) => load_library(app, json!({ "path": path })), + None => Ok(Value::Null), + } +} + +/// The id prefix of the Window → Swatch Libraries → User Defined slots. +pub(crate) const USER_SLOT: &str = "window.userSwatchLibrary"; + +/// The User Defined library slot `id` (`window.userSwatchLibrary3`) stands for. +pub(crate) fn user_library(app: &VectorcraftApp, id: &str) -> Option { + library_panel::user_slot(id, USER_SLOT, swatchlib::libraries(&app.session)) +} + +/// Window → Swatch Libraries. +pub(crate) fn window_menu() -> Vec { + const SLOTS: [&str; 10] = [ + "window.userSwatchLibrary1", + "window.userSwatchLibrary2", + "window.userSwatchLibrary3", + "window.userSwatchLibrary4", + "window.userSwatchLibrary5", + "window.userSwatchLibrary6", + "window.userSwatchLibrary7", + "window.userSwatchLibrary8", + "window.userSwatchLibrary9", + "window.userSwatchLibrary10", + ]; + let open = |b: &'static BuiltinLibrary| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id })); + let mut items = vec![Item::Cmd("Default Swatches", "swatch.resetDefaults", Value::Null), Item::Sep]; + items.extend(SWATCH_LIBRARIES.iter().map(open)); + items.extend([ + Item::Sub("Gradients", GRADIENT_LIBRARIES.iter().map(open).collect()), + Item::Sep, + Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()), + Item::Sep, + Item::Cmd("Other Library…", "window.swatchLibrary.other", Value::Null), + Item::Cmd("Save Swatch Library…", "ui.saveSwatchLibrary", Value::Null), + ]); + items +} diff --git a/crates/ui-egui/src/panels/swatches/tiles.rs b/crates/ui-egui/src/panels/swatches/tiles.rs new file mode 100644 index 000000000..d2d36efa9 --- /dev/null +++ b/crates/ui-egui/src/panels/swatches/tiles.rs @@ -0,0 +1,208 @@ +use super::*; + +/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). +pub(super) fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Paint) { + use std::collections::HashMap; + type Key = (String, Vec, String, u32); + thread_local! { + static CACHE: crate::graphics::TexCache>> = crate::graphics::TexCache::default(); + } + let Paint::Pattern { pattern, .. } = paint else { return }; + let Some(st) = app.session.active() else { return }; + let Some(def) = st.doc.pattern(pattern) else { return }; + let px = (r.width() * ui.ctx().pixels_per_point()).round().max(4.0) as u32; + let key = ( + pattern.clone(), + def.art.iter().map(|a| std::sync::Arc::as_ptr(a) as usize).collect(), + format!("{:?}{:?}{:?}", def.tile, def.tile_type, def.overlap), + px, + ); + let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).unwrap_or_else(|| { + let tex = vectorcraft_render::render_pattern_swatch(&st.doc, pattern, px).map(|img| { + let color = crate::canvas::color_image(&img); + ui.ctx().load_texture(format!("pattern-swatch-{pattern}-{px}"), color, egui::TextureOptions::LINEAR) + }); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 256 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + tex + }); + if let Some(tex) = tex { + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); + } +} + +pub(super) fn draw_registration(ui: &Ui, r: Rect) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + let c = r.center(); + let rad = r.width() * 0.28; + let s = Stroke::new(1.0, egui::Color32::BLACK); + ui.painter().circle_stroke(c, rad, s); + ui.painter().line_segment([pos2(r.left() + 2.0, c.y), pos2(r.right() - 2.0, c.y)], s); + ui.painter().line_segment([pos2(c.x, r.top() + 2.0), pos2(c.x, r.bottom() - 2.0)], s); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); +} + +pub(crate) fn draw_folder(ui: &Ui, r: Rect) { + let t = Tokens::get(ui.ctx()); + 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)]; + +/// Lab's opponent axes: green | red (a) over blue | yellow (b). +const LAB_AXES: [Color32; 4] = + [Color32::from_rgb(0, 166, 81), Color32::from_rgb(230, 30, 60), Color32::from_rgb(0, 90, 220), Color32::from_rgb(250, 205, 0)]; + +/// A swatch's kind in words, for list tooltips ("Global Process Color, CMYK"). +pub(crate) fn describe(paint: &Paint, global: bool, spot: bool) -> String { + match paint { + Paint::Solid { color, .. } => { + let kind = if spot { + tl!("Spot Color") + } else if global { + tl!("Global Process Color") + } else { + tl!("Process Color") + }; + crate::i18n::fmt(tl!("{kind}, {model}"), &[("kind", kind), ("model", color.model_name())]) + } + Paint::Gradient(g) => crate::i18n::fmt( + tl!("{kind} Gradient"), + &[("kind", crate::i18n::tr_ctx(crate::i18n::current(), "gradient-kind", g.gradient.kind.label()))], + ), + Paint::Pattern { .. } => tl!("Pattern").into(), + Paint::None => tl!("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, Lab as its +/// two opponent axes). +pub(crate) 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)); + } + } + let quarters = |colors: [Color32; 4]| { + let q = mode.size() / 2.0; + for (i, c) in colors.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, c); + } + }; + match color { + Color::Cmyk { .. } => quarters(CMYK_INKS), + Color::Lab { .. } => quarters(LAB_AXES), + 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 thumbnail cells of `items` for `view` in the full available width, row by row; a folder +/// (`is_folder`) starts a new row. +pub(crate) fn tile_grid<'a, T>(ui: &mut Ui, items: &'a [T], view: View, is_folder: impl Fn(&T) -> bool) -> Vec<(&'a T, Rect)> { + let (tile, pitch) = view.tile(); + let w = ui.available_width(); + let per_row = ((w - 2.0) / pitch).floor().max(1.0) as usize; + let mut rows: Vec> = vec![vec![]]; + for e in items { + match rows.last_mut() { + Some(row) if row.len() < per_row && (row.is_empty() || !is_folder(e)) => row.push(e), + _ => rows.push(vec![e]), + } + } + let mut out = Vec::with_capacity(items.len()); + for row in rows { + let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); + let cell = |c: usize| Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); + out.extend(row.into_iter().enumerate().map(|(c, e)| (e, cell(c)))); + } + out +} + +/// A list-view row for `view` taking clicks and drags as `id`, shaded when `selected` or hovered +/// → (the row, its response, the chip at its left). +pub(crate) fn list_row(ui: &mut Ui, id: egui::Id, view: View, selected: bool) -> (Rect, Response, Rect) { + let t = Tokens::get(ui.ctx()); + let (tile, pitch) = view.tile(); + let (_, r) = ui.allocate_space(vec2(ui.available_width(), pitch)); + let resp = ui.interact(r, id, Sense::click_and_drag()); + if selected { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + (r, resp, Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile))) +} + +/// A list row's name, right of its `chip`. +pub(crate) fn list_row_name(ui: &Ui, r: Rect, chip: Rect, name: &str) { + let t = Tokens::get(ui.ctx()); + ui.painter().text(pos2(chip.right() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); +} + +/// The white corner of a global colour's tile; a spot colour has a dot in it. +pub(crate) fn global_mark(ui: &Ui, cell: Rect, spot: bool) { + 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); + } +} + +/// A colour group's folder in the thumbnail view, outlined when `selected`. +pub(crate) fn folder_tile(ui: &Ui, cell: Rect, selected: bool) { + draw_folder(ui, cell); + if selected { + ui.painter().rect_stroke(cell.expand(1.0), 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Outside); + } +} + +/// The interaction id of a tile or list row (stable per swatch or group name and host `salt`). +pub(super) fn tile_id(salt: &'static str, e: &Entry) -> egui::Id { + egui::Id::unique((salt, e.name())) +}