refactor(ui-egui): move panels/swatches.rs items to panels/swatches/{actions,body,dnd,libraries,tiles}.rs

This commit is contained in:
aiongg
2026-10-26 21:45:33 +00:00
parent d8a6f8f3d8
commit 556825b363
6 changed files with 1149 additions and 1101 deletions
File diff suppressed because it is too large Load Diff
@@ -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<String> {
sel.iter().filter(|n| !n.starts_with('[')).cloned().collect()
}
/// The selected swatch when exactly one swatch with an editor is selected (not None, Registration
/// or a group).
fn editable(app: &VectorcraftApp, sel: &[String]) -> Option<String> {
match sel {
[n] if !n.starts_with('[') && app.session.active().is_some_and(|st| st.doc.swatch(n).is_some()) => Some(n.clone()),
_ => None,
}
}
/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first).
pub(super) fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec<String> {
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<String> {
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<String>, 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::<SwatchLibraries>(&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::<SwatchLibraries>(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<String> {
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<String> = 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::<SwatchLibraries>(app, ui));
if menu_item(ui, tl!("Save Swatch Library…"), true, false) {
app.run("ui.saveSwatchLibrary", json!({ "names": deletable(&selected) })).ok();
}
}
+194
View File
@@ -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<String> = 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<u64>);
/// 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<String>) -> Option<String> {
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::<Option<(String, Option<String>)>>(ui.ctx(), "swatch-kept") {
Some((d, kept)) if d == defaults => kept,
Some(_) => {
set_pstate(ui.ctx(), "swatch-kept", None::<(String, Option<String>)>);
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<String> {
let art: Option<SelectedArt> = 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<SelectedArt>>, Option<String>) = 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::<String>::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<String> {
// 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,
}
}
+341
View File
@@ -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<String> {
let Some(st) = app.session.active() else { return vec![] };
let mut names: Vec<String> = pstate(ui.ctx(), "swatch-selected");
names.retain(|n| n == REGISTRATION || st.doc.swatch_name_taken(n));
names
}
/// The 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<String>, order: &[&str], name: &str, m: egui::Modifiers) -> Vec<String> {
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<String>, target: Option<String>, after: bool, with: Option<String> },
/// 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<String>, replace: bool },
/// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays).
Pattern { ids: Vec<u64> },
}
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<String>, after: bool, replace: bool) -> Option<Self> {
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<std::sync::Arc<PanelDrag>> {
resp.dnd_hover_payload::<PanelDrag>().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<String>) -> 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<Entry>,
pub(super) drop: Option<Drop>,
/// A drag is held over a tile or row.
over: bool,
}
/// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e`
/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it
/// starts a [`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::<PanelDrag>();
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::<PanelDrag>().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::<PanelDrag>().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<String> {
if d.swatch_groups.iter().any(|g| g.name == name) {
return Some(name.to_string());
}
d.swatch_group_of(name).map(|g| d.swatch_groups[g].name.clone())
}
/// `swatch.move` params for dropping `names` before or `after` row `target`, into folder `target`
/// (swatches), or at the end (`target` None); `None` when nothing would move. Only solid colours go
/// into colour groups; None and Registration stay put.
fn move_params(d: &Document, names: &[String], target: Option<&str>, after: bool) -> Option<Value> {
if target.is_some_and(|t| names.iter().any(|n| n == t)) {
return None;
}
let group_index = |n: &str| d.swatch_groups.iter().position(|g| g.name == n);
if names.iter().all(|n| group_index(n).is_some()) {
let to = match target {
None => d.swatch_groups.len(),
// Over a group's folder or one of its swatches: next to that group; else before the first.
Some(t) => group_index(t).or_else(|| d.swatch_group_of(t)).map_or(0, |i| i + usize::from(after)),
};
return Some(json!({"names": names, "to": to}));
}
let (group, to) = match target {
Some(t) if group_index(t).is_some() => (Some(t), None),
Some(REGISTRATION) => (None, Some(d.swatches.iter().position(|w| !w.paint.is_none()).unwrap_or(0))),
Some(t) => {
let (group, list) = match d.swatch_group_of(t) {
Some(g) => (Some(d.swatch_groups[g].name.as_str()), &d.swatch_groups[g].swatches),
None => (None, &*d.swatches),
};
(group, list.iter().position(|w| w.name == t).map(|i| i + usize::from(after)))
}
None => (None, None),
};
let movable: Vec<&String> =
names.iter().filter(|n| d.swatch(n).is_some_and(|w| !w.paint.is_none() && (group.is_none() || w.paint.color().is_some()))).collect();
(!movable.is_empty()).then(|| json!({"names": movable, "to": to, "group": group}))
}
/// 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::<PanelDrag>(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);
});
}
@@ -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<swatchlib::LibraryInfo>) -> Vec<LibraryRef> {
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<LibraryRef> {
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<String> {
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<String, String> {
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<SwatchLibrary>;
type Item = Swatch;
fn list(app: &VectorcraftApp) -> Vec<LibraryRef> {
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<String> {
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<Row<'a, Swatch>> {
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<Row<Swatch>> = 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<String>) {
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<Value, String> {
library_panel::open_command::<SwatchLibraries>(app, p)
}
/// `swatch.library.load` params, then open the library in its panel.
pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
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<String>) -> Result<Value, String> {
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<swatchlib::LibraryInfo> {
library_panel::user_slot(id, USER_SLOT, swatchlib::libraries(&app.session))
}
/// Window → Swatch Libraries.
pub(crate) fn window_menu() -> Vec<Item> {
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
}
+208
View File
@@ -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<usize>, String, u32);
thread_local! {
static CACHE: crate::graphics::TexCache<HashMap<Key, Option<egui::TextureHandle>>> = 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<&T>> = 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()))
}