Libraries panel: the list scrolls; items and groups reorder by dragging, items drag into groups

The list fills the panel down to its bottom bar and scrolls, also while a drag nears its top or
bottom edge (in the dock and in a flyout, as the Layers panel does).

Dragging in the list:
- an item (colour, text style or graphic) dropped on another of its kind lands just before or
  after it, by the pointer's half, an accent line showing where, and joins that item's group;
- dropped elsewhere on a group (its header too, even collapsed) it goes into that group, and on
  the ungrouped items out of its group, the section outlined; with every item grouped, a
  "Drop here to take it out of its group" strip shows while one is dragged;
- a group dragged by its header goes before or after another group.
Colours and graphics still drag onto art and the canvas as before.

Engine: library.moveItem takes `before` / `after` (an item of the same kind) to place the item
in its kind's order, which the panel shows; the new library.moveGroup {group, to} moves a group.
Text styles drag as PanelDrag::LibraryItem, groups as PanelDrag::LibraryGroup, and a library
colour's Paint drag names its library item, so the panel tells it from a swatch dragged in.

Tests: reordering and moving in the engine, the commands in the library fuzz test, and panel
tests that drop styles and group headers and check the list scrolls and scrolls while dragging.
Strings translated in the 23 catalogs; docs/mcp.md and the parity row updated.
This commit is contained in:
Dan Green
2026-10-20 05:28:17 +07:00
committed by bflatastic
parent 6f3c974984
commit 6bed87b5fb
35 changed files with 621 additions and 103 deletions
+66 -4
View File
@@ -5,7 +5,8 @@
//! one copies the selection as Copy does, and placing it pastes a copy as Paste does (its images,
//! symbols, patterns and swatches come along; a swatch name the document gives another colour is
//! merged into the document's). Items can be put in user-named groups, and a library can be
//! exported to a `.vclibrary` file and imported from one (#926).
//! exported to a `.vclibrary` file and imported from one (#926). Items and groups are kept in the
//! order the Libraries panel shows them, which dragging them there changes.
use std::collections::{HashMap, HashSet};
@@ -281,6 +282,25 @@ impl Library {
}
}
/// Move item `r` next to `to`, an item of its kind: just before it, or just after it when
/// `after`, in their kind's list (the order the panel shows them in).
fn place_next_to(&mut self, r: &ItemRef, to: &ItemRef, after: bool) {
fn shift<T>(list: &mut Vec<T>, key: impl Fn(&T) -> &str, from: &str, to: &str, after: bool) {
let Some(i) = list.iter().position(|x| key(x) == from) else { return };
let item = list.remove(i);
let j = list.iter().position(|x| key(x) == to).map_or(list.len(), |j| j + usize::from(after));
list.insert(j.min(list.len()), item);
}
let (from, to_key) = (r.item.as_str(), to.item.as_str());
match r.kind {
ItemKind::Color => shift(&mut self.colors, |c| c.name.as_str(), from, to_key, after),
ItemKind::CharStyle => shift(&mut self.char_styles, |c| c.name.as_str(), from, to_key, after),
ItemKind::ParaStyle => shift(&mut self.para_styles, |c| c.name.as_str(), from, to_key, after),
ItemKind::Graphic => shift(&mut self.graphics, |g| g.id.as_str(), from, to_key, after),
ItemKind::Unknown => {}
}
}
/// Make a library read from a file (hand-edited, imported) safe and consistent: at most
/// [`MAX_ITEMS`] items of each kind and [`MAX_GROUPS`] groups, names clipped to [`MAX_NAME`]
/// characters and unique within their kind (a repeated one gets a number), sizes finite, a
@@ -552,10 +572,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Library Item to Group",
[],
None,
"{library?, kind, item: name (graphic: id or name), group?: name (null or none: out of its group)} put an item in a group (out of the one it was in) → {library, kind, item, group: name or null}",
"{library?, kind, item: name (graphic: id or name), group?: name (null or none: out of its group), before?: item | after?: item (an item of the same kind, named as `item` is)} put an item in a group (out of the one it was in); with `before` or `after`, also move it just before or after that item in the order its kind is shown in, as dragging it in the Libraries panel does → {library, kind, item, group: name or null}",
always,
move_item
),
cmd!(
"library.moveGroup",
"Move Library Group",
[],
None,
"{library?, group: name, to: index} move a group to position `to` of the library's groups (0 = first; past the end: last), as dragging its header in the Libraries panel does → {library, group, to: where it went}",
always,
move_group
),
cmd!(
query "library.export",
"Export Library",
@@ -1023,13 +1052,30 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.moveItem";
let (id, kind, item) = item_param(s, p, C)?;
let group = str_param(p, "group");
let next_to = match (str_param(p, "before"), str_param(p, "after")) {
(Some(_), Some(_)) => return Err(bad(C, "give `before` or `after`, not both")),
(Some(b), None) => Some((b, false)),
(None, Some(a)) => Some((a, true)),
(None, None) => None,
};
let (key, to) = s.libraries.change(&id, |lib| {
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
let missing = |item: &str| bad(C, format!("the library has no item `{item}`"));
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| missing(item))?;
let at = group.map(|g| group_at(lib, g, C)).transpose()?;
let next_to = next_to
.map(|(other, after)| Ok::<_, EngineError>((lib.item_ref(kind.item_kind(), other).ok_or_else(|| missing(other))?, after)))
.transpose()?;
if let Some((other, after)) = &next_to
&& *other != r
{
lib.place_next_to(&r, other, *after);
}
lib.ungroup(&r);
let key = r.item.clone();
let to = at.and_then(|i| lib.groups.get_mut(i)).map(|g| {
g.items.push(r);
// Next to the other item when that is in the group too, else last.
let near = next_to.as_ref().and_then(|(other, after)| g.items.iter().position(|x| x == other).map(|j| j + usize::from(*after)));
g.items.insert(near.unwrap_or(g.items.len()).min(g.items.len()), r);
g.name.clone()
});
Ok((key, to))
@@ -1037,6 +1083,22 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "group": to }))
}
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.moveGroup";
let id = library_id(s, p, C)?;
let group = group_param(p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing `to` (an index)"))?;
let (name, at) = s.libraries.change(&id, |lib| {
let i = group_at(lib, group, C)?;
let g = lib.groups.remove(i);
let at = usize::try_from(to).unwrap_or(usize::MAX).min(lib.groups.len());
let name = g.name.clone();
lib.groups.insert(at, g);
Ok((name, at))
})?;
Ok(json!({ "library": id, "group": name, "to": at }))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.export";
let id = library_id(s, p, C)?;
+55
View File
@@ -276,6 +276,61 @@ fn items_are_grouped_moved_and_ungrouped() {
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"][0]["groups"], 1);
}
/// The names of the current library's `list` ("colors", "graphics"…), in their order.
fn order(s: &mut Session, list: &str, key: &str) -> Vec<String> {
let got = run(s, "library.get", json!({}));
got[list].as_array().unwrap().iter().map(|v| v[key].as_str().unwrap().to_string()).collect()
}
/// Dragging in the Libraries panel: an item moves just before or after another of its kind, into
/// that one's group or out of every group, and groups move to another place in the list.
#[test]
fn items_and_groups_are_reordered() {
let mut s = session();
run(&mut s, "library.create", json!({"name": "Brand"}));
for (name, color) in [("Red", "#ff0000"), ("Green", "#00ff00"), ("Blue", "#0000ff"), ("Gray", "#808080")] {
run(&mut s, "library.add", json!({"kind": "fillColor", "color": color, "name": name}));
}
// Before an item, and after one; on itself it stays.
run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Blue", "before": "Red"}));
assert_eq!(order(&mut s, "colors", "name"), ["Blue", "Red", "Green", "Gray"]);
run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Blue", "after": "Gray"}));
assert_eq!(order(&mut s, "colors", "name"), ["Red", "Green", "Gray", "Blue"]);
run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Red", "before": "Red"}));
assert_eq!(order(&mut s, "colors", "name"), ["Red", "Green", "Gray", "Blue"]);
// Into a group next to an item there: the group lists it next to that one too.
run(
&mut s,
"library.createGroup",
json!({"name": "Cool", "items": [{"kind": "fillColor", "item": "Green"}, {"kind": "fillColor", "item": "Blue"}]}),
);
let r = run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Gray", "group": "Cool", "before": "Blue"}));
assert_eq!(r["group"], "Cool");
assert_eq!(order(&mut s, "colors", "name"), ["Red", "Green", "Gray", "Blue"]);
assert_eq!(groups(&mut s), [("Cool".into(), refs(&[("color", "Green"), ("color", "Gray"), ("color", "Blue")]))]);
// Next to an ungrouped item, without a group: out of its group.
run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Blue", "before": "Red"}));
assert_eq!(order(&mut s, "colors", "name"), ["Blue", "Red", "Green", "Gray"]);
assert_eq!(groups(&mut s), [("Cool".into(), refs(&[("color", "Green"), ("color", "Gray")]))]);
// Wrong asks change nothing.
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": "Red", "before": "Nope"})).is_err());
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": "Red", "before": "Blue", "after": "Gray"})).is_err());
assert!(s.execute("library.moveItem", &json!({"kind": "charStyle", "item": "Red", "before": "Blue"})).is_err(), "not a style");
assert_eq!(order(&mut s, "colors", "name"), ["Blue", "Red", "Green", "Gray"]);
// Groups: to an index, past the end to the last place.
run(&mut s, "library.createGroup", json!({"name": "Warm"}));
run(&mut s, "library.createGroup", json!({"name": "Neutral"}));
let names = |s: &mut Session| groups(s).into_iter().map(|(n, _)| n).collect::<Vec<_>>();
assert_eq!(run(&mut s, "library.moveGroup", json!({"group": "neutral", "to": 0}))["to"], 0);
assert_eq!(names(&mut s), ["Neutral", "Cool", "Warm"]);
assert_eq!(run(&mut s, "library.moveGroup", json!({"group": "Neutral", "to": 99}))["to"], 2);
assert_eq!(names(&mut s), ["Cool", "Warm", "Neutral"]);
assert!(s.execute("library.moveGroup", &json!({"group": "Nope", "to": 0})).is_err());
assert!(s.execute("library.moveGroup", &json!({"group": "Cool", "to": -1})).is_err());
assert!(s.execute("library.moveGroup", &json!({"group": "Cool"})).is_err());
assert_eq!(groups(&mut s)[0].1, refs(&[("color", "Green"), ("color", "Gray")]), "a group keeps its items when it moves");
}
/// A library goes out to a file with its art and groups and comes back as a new library, numbered
/// when its name is taken; the graphic it brings places in a document (#926).
#[test]
+10 -1
View File
@@ -988,7 +988,8 @@ fn styles(what: &str, data: &str) -> Result<(), TestCaseError> {
}
/// Libraries panel library file `data` imported, then each kind's first item used on the rich
/// document and moved into its first group, the group deleted and the library exported (#926).
/// document, moved into its first group and next to its kind's last item, the group moved and
/// deleted and the library exported (#926).
fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "library.import", data, |s, r| {
let lib = r["id"].clone();
@@ -1001,9 +1002,17 @@ fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
let _ = no_panic(s.execute("library.use", &json!({"library": lib, "kind": kind, "item": item[key]})));
let to = json!({"library": lib, "kind": kind, "item": item[key], "group": got["groups"][0]["name"]});
let _ = no_panic(s.execute("library.moveItem", &to));
// Next to the last item of its kind, whatever group that is in.
let last = got[list].as_array().and_then(|a| a.last()).map(|v| v[key].clone()).unwrap_or_default();
for side in ["before", "after"] {
let _ = no_panic(s.execute("library.moveItem", &json!({"library": lib, "kind": kind, "item": item[key], side: last})));
}
}
}
if let Some(g) = got["groups"].get(0) {
for to in [0, 1, 1_000_000] {
let _ = no_panic(s.execute("library.moveGroup", &json!({"library": lib, "group": g["name"], "to": to})));
}
let _ = no_panic(s.execute("library.deleteGroup", &json!({"library": lib, "group": g["name"]})));
}
let _ = no_panic(s.execute("library.export", &json!({"library": lib})));
+3 -1
View File
@@ -2040,6 +2040,8 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
// above.)
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) | widgets::PanelDrag::LibraryGraphic { .. } => return,
// A Libraries panel style or group only moves within that panel.
widgets::PanelDrag::LibraryItem { .. } | widgets::PanelDrag::LibraryGroup { .. } => return,
};
if let Err(e) = app.run(cmd, params) {
app.status(e);
@@ -6958,7 +6960,7 @@ mod tests {
let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
let red = Paint::solid(Color::from_hex("#ed1c24").unwrap());
let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false };
let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows) };
let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows), library: None };
// Off the art nothing happens.
drop(&mut app, swatch.clone(), Point::new(300.0, 250.0));
assert_eq!(fill(&app), Paint::solid(Color::WHITE));
+5 -1
View File
@@ -467,7 +467,11 @@ pub(crate) fn panel_body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width:
ui.set_max_height(tall);
panels::layers::show(app, ui);
}
Some(DockTab::Libraries) => panels::libraries(app, ui),
Some(DockTab::Libraries) => {
// Its list scrolls inside `tall` (as in the dock) instead of growing the flyout.
ui.set_max_height(tall);
panels::libraries(app, ui);
}
None => panels::show_icon_panel(app, ui, id),
}
});
+3 -1
View File
@@ -3093,7 +3093,7 @@
Delete Library Item Изтриване на елемент от библиотека
No results Няма резултати
No items in this library match “{query}”. Няма елементи в тази библиотека, съвпадащи с „{query}“.
Right-click an item to move it into this group. Щракнете с десния бутон върху елемент, за да го преместите в тази група.
Drag an item here, or right-click one, to put it in this group. Плъзнете елемент тук или щракнете с десния бутон върху него, за да го поставите в тази група.
New Group Нова група
New Group… Нова група…
Move to Group Преместване в група
@@ -5204,5 +5204,7 @@ theme Appearance Външен вид
Plastic Wrap Найлоново фолио
Chrome… Хром…
Chrome Хром
Drop here to take it out of its group Пуснете тук, за да го извадите от групата му
Move Library Group Преместване на библиотечна група
Plaster… Гипс…
Plaster Гипс
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -1657,7 +1657,7 @@ lighting Light Světlo
This library is empty Tato knihovna je prázdná
No results Žádné výsledky
No items in this library match “{query}”. Žádná položka v této knihovně neodpovídá „{query}“.
Right-click an item to move it into this group. Kliknutím pravým tlačítkem na položku ji přesunete do této skupiny.
Drag an item here, or right-click one, to put it in this group. Přetáhněte sem položku nebo na ni klikněte pravým tlačítkem a vložte ji do této skupiny.
New Group Nová skupina
New Group… Nová skupina…
Move to Group Přesunout do skupiny
@@ -5243,5 +5243,7 @@ mark-style Japanese Japonské
Plastic Wrap Plastová fólie
Chrome… Chrom…
Chrome Chrom
Drop here to take it out of its group Pusťte sem, chcete-li ji vyjmout ze skupiny
Move Library Group Přesunout skupinu knihovny
Plaster… Sádra…
Plaster Sádra
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -1489,7 +1489,7 @@ lighting Light Lys
This library is empty Dette bibliotek er tomt
No results Ingen resultater
No items in this library match “{query}”. Ingen elementer i dette bibliotek matcher »{query}«.
Right-click an item to move it into this group. Højreklik på et element for at flytte det til denne gruppe.
Drag an item here, or right-click one, to put it in this group. Træk et element hertil, eller højreklik på et, for at lægge det i denne gruppe.
New Group Ny gruppe
New Group… Ny gruppe…
Move to Group Flyt til gruppe
@@ -5222,5 +5222,7 @@ swatch Bright Violet Kraftig violet
Plastic Wrap Plastfolie
Chrome… Krom…
Chrome Krom
Drop here to take it out of its group Slip her for at tage det ud af gruppen
Move Library Group Flyt biblioteksgruppe
Plaster… Gips…
Plaster Gips
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -4362,7 +4362,7 @@ theme Appearance Erscheinungsbild
Move to Group In Gruppe verschieben
No results Keine Ergebnisse
No items in this library match “{query}”. Keine Elemente in dieser Bibliothek entsprechen „{query}“.
Right-click an item to move it into this group. Klicken Sie mit der rechten Maustaste auf ein Element, um es in diese Gruppe zu verschieben.
Drag an item here, or right-click one, to put it in this group. Ziehen Sie ein Element hierher oder klicken Sie mit der rechten Maustaste darauf, um es in diese Gruppe zu legen.
@msg Imported library {name} Bibliothek {name} importiert
# Linked files changed on disk.
Update Changed Links Geänderte Verknüpfungen aktualisieren
@@ -4823,5 +4823,7 @@ effect Soft Weich
Plastic Wrap Kunststofffolie
Chrome… Chrom…
Chrome Chrom
Drop here to take it out of its group Hier ablegen, um es aus seiner Gruppe zu nehmen
Move Library Group Bibliotheksgruppe verschieben
Plaster… Gipsabdruck…
Plaster Gipsabdruck
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -3492,7 +3492,7 @@ theme Appearance Εμφάνιση
Delete Library Item Διαγραφή στοιχείου βιβλιοθήκης
No results Δεν βρέθηκαν αποτελέσματα
No items in this library match “{query}”. Κανένα στοιχείο αυτής της βιβλιοθήκης δεν αντιστοιχεί στο «{query}».
Right-click an item to move it into this group. Κάντε δεξί κλικ σε στοιχείο για να το μετακινήσετε σε αυτήν την ομάδα.
Drag an item here, or right-click one, to put it in this group. Σύρετε εδώ ένα στοιχείο ή κάντε δεξί κλικ σε ένα για να το βάλετε σε αυτήν την ομάδα.
Move to Group Μετακίνηση σε ομάδα
No Group Χωρίς ομάδα
Import Library… Εισαγωγή βιβλιοθήκης…
@@ -5195,5 +5195,7 @@ effect Points Σημεία
Plastic Wrap Πλαστική μεμβράνη
Chrome… Χρώμιο…
Chrome Χρώμιο
Drop here to take it out of its group Αφήστε εδώ για να το βγάλετε από την ομάδα του
Move Library Group Μετακίνηση ομάδας βιβλιοθήκης
Plaster… Γύψος…
Plaster Γύψος
Can't render this file because it contains an unexpected character in line 2016 and column 19.
+3 -1
View File
@@ -3152,7 +3152,7 @@
Delete Library Item Eliminar elemento de la biblioteca
No results Sin resultados
No items in this library match “{query}”. Ningún elemento de esta biblioteca coincide con “{query}”.
Right-click an item to move it into this group. Haz clic con el botón derecho en un elemento para moverlo a este grupo.
Drag an item here, or right-click one, to put it in this group. Arrastra aquí un elemento, o haz clic con el botón derecho en uno, para ponerlo en este grupo.
New Group Nuevo grupo
New Group… Nuevo grupo…
Move to Group Mover al grupo
@@ -5185,5 +5185,7 @@ mark-style Japanese Japonesas
Plastic Wrap Envoltura plástica
Chrome… Cromo…
Chrome Cromo
Drop here to take it out of its group Suelta aquí para sacarlo de su grupo
Move Library Group Mover grupo de biblioteca
Plaster… Yeso…
Plaster Yeso
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -1620,7 +1620,7 @@ lighting Light Valo
This library is empty Tämä kirjasto on tyhjä
No results Ei tuloksia
No items in this library match “{query}”. Mikään tämän kirjaston kohde ei vastaa hakua ”{query}”.
Right-click an item to move it into this group. Siirrä kohde tähän ryhmään napsauttamalla sitä hiiren kakkospainikkeella.
Drag an item here, or right-click one, to put it in this group. Vedä kohde tähän tai napsauta sitä hiiren kakkospainikkeella lisätäksesi sen tähän ryhmään.
New Group Uusi ryhmä
New Group… Uusi ryhmä…
Move to Group Siirrä ryhmään
@@ -5274,5 +5274,7 @@ swatch Bright Violet Kirkas violetti
Plastic Wrap Muovikalvo
Chrome… Kromi…
Chrome Kromi
Drop here to take it out of its group Pudota tähän poistaaksesi sen ryhmästään
Move Library Group Siirrä kirjastoryhmä
Plaster… Kipsi…
Plaster Kipsi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -3149,7 +3149,7 @@
Delete Library Item Supprimer l’élément de la bibliothèque
No results Aucun résultat
No items in this library match “{query}”. Aucun élément de cette bibliothèque ne correspond à « {query} ».
Right-click an item to move it into this group. Cliquez avec le bouton droit sur un élément pour le placer dans ce groupe.
Drag an item here, or right-click one, to put it in this group. Faites glisser un élément ici, ou cliquez dessus avec le bouton droit, pour le placer dans ce groupe.
New Group Nouveau groupe
New Group… Nouveau groupe…
Move to Group Déplacer vers le groupe
@@ -5242,5 +5242,7 @@ theme Appearance Apparence
Plastic Wrap Emballage plastique
Chrome… Chrome…
Chrome Chrome
Drop here to take it out of its group Déposez ici pour le sortir de son groupe
Move Library Group Déplacer le groupe de bibliothèque
Plaster… Plâtre…
Plaster Plâtre
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -3171,7 +3171,7 @@ mark-style Japanese Japán
Right Indent Jobb behúzás
Right Plane Jobb sík
Right to Left Jobbról balra
Right-click an item to move it into this group. Egy elemet jobb gombbal kattintva helyezhetsz át ebbe a csoportba.
Drag an item here, or right-click one, to put it in this group. Húzz ide egy elemet, vagy kattints rá jobb gombbal, hogy ebbe a csoportba kerüljön.
Right-Justified Tab Jobbra igazító tabulátor
Right-to-Left Paragraph Direction Jobbról balra haladó bekezdés
Right: Jobb:
@@ -5229,5 +5229,7 @@ imageTrace Outline Körvonal
Plastic Wrap Fóliázás
Chrome… Króm…
Chrome Króm
Drop here to take it out of its group Engedd el itt, hogy kikerüljön a csoportjából
Move Library Group Könyvtárcsoport áthelyezése
Plaster… Gipsz…
Plaster Gipsz
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -1489,7 +1489,7 @@ lighting Light Cahaya
This library is empty Pustaka ini kosong
No results Tidak ada hasil
No items in this library match “{query}”. Tidak ada item di pustaka ini yang cocok dengan “{query}”.
Right-click an item to move it into this group. Klik kanan item untuk memindahkannya ke grup ini.
Drag an item here, or right-click one, to put it in this group. Seret item ke sini, atau klik kanan item, untuk memasukkannya ke grup ini.
New Group Grup baru
New Group… Grup baru…
Move to Group Pindahkan ke grup
@@ -5222,5 +5222,7 @@ swatch Bright Violet Violet cerah
Plastic Wrap Pembungkus plastik
Chrome… Krom…
Chrome Krom
Drop here to take it out of its group Lepaskan di sini untuk mengeluarkannya dari grupnya
Move Library Group Pindahkan grup pustaka
Plaster… Plester…
Plaster Plester
Can't render this file because it contains an unexpected character in line 3772 and column 19.
+3 -1
View File
@@ -3185,7 +3185,7 @@ mark-style Japanese Giapponese
Delete Library Item Elimina elemento della libreria
No results Nessun risultato
No items in this library match “{query}”. Nessun elemento di questa libreria corrisponde a “{query}”.
Right-click an item to move it into this group. Fai clic con il pulsante destro su un elemento per spostarlo in questo gruppo.
Drag an item here, or right-click one, to put it in this group. Trascina qui un elemento, o fai clic con il pulsante destro su uno, per metterlo in questo gruppo.
New Group Nuovo gruppo
New Group… Nuovo gruppo…
Move to Group Sposta nel gruppo
@@ -4857,5 +4857,7 @@ lighting Light Luce
Plastic Wrap Pellicola plastica
Chrome… Cromo…
Chrome Cromo
Drop here to take it out of its group Rilascia qui per toglierlo dal suo gruppo
Move Library Group Sposta gruppo di libreria
Plaster… Gesso…
Plaster Gesso
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -2917,7 +2917,7 @@ mark-style Japanese 日本式
Delete Library Item ライブラリ項目を削除
No results 結果はありません
No items in this library match “{query}”. このライブラリには「{query}」に一致する項目がありません。
Right-click an item to move it into this group. 項目を右クリックすると、このグループに移動できます。
Drag an item here, or right-click one, to put it in this group. 項目をここにドラッグするか、項目を右クリックすると、このグループに入れられます。
New Group 新規グループ
New Group… 新規グループ…
Move to Group グループに移動
@@ -4897,5 +4897,7 @@ lighting Light ライト
Plastic Wrap ラップ
Chrome… クロム…
Chrome クロム
Drop here to take it out of its group ここにドロップするとグループから外れます
Move Library Group ライブラリグループを移動
Plaster… 石膏…
Plaster 石膏
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -1398,7 +1398,7 @@ lighting Light სინათლე
This library is empty ეს ბიბლიოთეკა ცარიელია
No results შედეგები არ არის
No items in this library match “{query}”. ამ ბიბლიოთეკაში „{query}“-ს შესაბამისი ელემენტი არ არის.
Right-click an item to move it into this group. ამ ჯგუფში გადასატანად დაწკაპეთ ელემენტზე მარჯვენა ღილაკით.
Drag an item here, or right-click one, to put it in this group. ამ ჯგუფში ჩასასმელად ჩაავლეთ ელემენტი აქ ან დაწკაპეთ მასზე მარჯვენა ღილაკით.
New Group ახალი ჯგუფი
New Group… ახალი ჯგუფი…
Move to Group ჯგუფში გადატანა
@@ -5150,5 +5150,7 @@ imageTrace Outline კონტური
Plastic Wrap პლასტიკური აფსკი
Chrome… ქრომი…
Chrome ქრომი
Drop here to take it out of its group ჯგუფიდან ამოსაღებად ჩააგდეთ აქ
Move Library Group ბიბლიოთეკის ჯგუფის გადატანა
Plaster… თაბაშირი…
Plaster თაბაშირი
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -2911,7 +2911,7 @@ mark-style Japanese 일본식
Delete Library Item 라이브러리 항목 삭제
No results 결과 없음
No items in this library match “{query}”. 이 라이브러리에 “{query}”와(과) 일치하는 항목이 없습니다.
Right-click an item to move it into this group. 항목을 마우스 오른쪽 버튼으로 클릭해 이 그룹으로 이동하세요.
Drag an item here, or right-click one, to put it in this group. 항목을 여기로 끌어 놓거나 마우스 오른쪽 버튼으로 클릭해 이 그룹에 넣으세요.
New Group 새 그룹
New Group… 새 그룹…
Move to Group 그룹으로 이동
@@ -4820,5 +4820,7 @@ lighting Light 광원
Plastic Wrap 비닐 랩
Chrome… 크롬…
Chrome 크롬
Drop here to take it out of its group 그룹에서 빼려면 여기에 놓으세요
Move Library Group 라이브러리 그룹 이동
Plaster… 석고…
Plaster 석고
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -1503,7 +1503,7 @@ lighting Light Licht
This library is empty Deze bibliotheek is leeg
No results Geen resultaten
No items in this library match “{query}”. Geen items in deze bibliotheek komen overeen met „{query}”.
Right-click an item to move it into this group. Klik met de rechtermuisknop op een item om het naar deze groep te verplaatsen.
Drag an item here, or right-click one, to put it in this group. Sleep een item hierheen, of klik er met de rechtermuisknop op, om het in deze groep te zetten.
New Group Nieuwe groep
New Group… Nieuwe groep…
Move to Group Verplaatsen naar groep
@@ -5264,5 +5264,7 @@ swatch Bright Violet Felviolet
Plastic Wrap Plasticfolie
Chrome… Chroom…
Chrome Chroom
Drop here to take it out of its group Zet het hier neer om het uit de groep te halen
Move Library Group Bibliotheekgroep verplaatsen
Plaster… Gips…
Plaster Gips
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -1577,7 +1577,7 @@ lighting Light Światło
This library is empty Ta biblioteka jest pusta
No results Brak wyników
No items in this library match “{query}”. Żaden element tej biblioteki nie pasuje do „{query}”.
Right-click an item to move it into this group. Kliknij element prawym przyciskiem, aby przenieść go do tej grupy.
Drag an item here, or right-click one, to put it in this group. Przeciągnij tu element albo kliknij go prawym przyciskiem, aby umieścić go w tej grupie.
New Group Nowa grupa
New Group… Nowa grupa…
Move to Group Przenieś do grupy
@@ -5095,5 +5095,7 @@ mark-style Japanese Japońskie
Plastic Wrap Folia plastikowa
Chrome… Chrom…
Chrome Chrom
Drop here to take it out of its group Upuść tutaj, aby wyjąć go z grupy
Move Library Group Przenieś grupę biblioteki
Plaster… Gips…
Plaster Gips
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -3242,7 +3242,7 @@ lighting Light Luz
Delete Library Item Excluir item da biblioteca
No results Nenhum resultado
No items in this library match “{query}”. Nenhum item desta biblioteca corresponde a “{query}”.
Right-click an item to move it into this group. Clique com o botão direito em um item para movê-lo para este grupo.
Drag an item here, or right-click one, to put it in this group. Arraste um item para cá, ou clique com o botão direito em um, para colocá-lo neste grupo.
New Group Novo grupo
Move to Group Mover para o grupo
Import Library Importar biblioteca
@@ -5160,5 +5160,7 @@ mark-style Japanese Japonesas
Plastic Wrap Envoltório plástico
Chrome… Cromo…
Chrome Cromo
Drop here to take it out of its group Solte aqui para tirá-lo do grupo
Move Library Group Mover grupo de biblioteca
Plaster… Gesso…
Plaster Gesso
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -1
View File
@@ -3181,7 +3181,7 @@ mark-style Japanese Японский
Delete Library Item Удалить элемент библиотеки
No results Ничего не найдено
No items in this library match “{query}”. В этой библиотеке нет элементов, соответствующих «{query}».
Right-click an item to move it into this group. Щёлкните элемент правой кнопкой мыши, чтобы переместить его в эту группу.
Drag an item here, or right-click one, to put it in this group. Перетащите сюда элемент или щёлкните его правой кнопкой мыши, чтобы поместить его в эту группу.
New Group Новая группа
New Group… Новая группа…
Move to Group Переместить в группу
@@ -4853,5 +4853,7 @@ lighting Light Свет
Plastic Wrap Целлофановая упаковка
Chrome… Хром…
Chrome Хром
Drop here to take it out of its group Отпустите здесь, чтобы вынуть его из группы
Move Library Group Переместить группу библиотеки
Plaster… Гипс…
Plaster Гипс
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -3093,7 +3093,7 @@
Delete Library Item Radera biblioteksobjekt
No results Inga resultat
No items in this library match “{query}”. Inga objekt i det här biblioteket matchar ”{query}”.
Right-click an item to move it into this group. Högerklicka på ett objekt för att flytta det till den här gruppen.
Drag an item here, or right-click one, to put it in this group. Dra hit ett objekt, eller högerklicka på ett, för att lägga det i den här gruppen.
New Group Ny grupp
New Group… Ny grupp…
Move to Group Flytta till grupp
@@ -5199,5 +5199,7 @@ theme Appearance Utseende
Plastic Wrap Plastfolie
Chrome… Krom…
Chrome Krom
Drop here to take it out of its group Släpp här för att ta ut det ur gruppen
Move Library Group Flytta biblioteksgrupp
Plaster… Gips…
Plaster Gips
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3 -1
View File
@@ -3178,7 +3178,7 @@ effect Points Точки
Delete Library Item Видалити елемент бібліотеки
No results Нічого не знайдено
No items in this library match “{query}”. У цій бібліотеці немає елементів, що відповідають «{query}».
Right-click an item to move it into this group. Клацніть елемент правою кнопкою миші, щоб перемістити його в цю групу.
Drag an item here, or right-click one, to put it in this group. Перетягніть сюди елемент або клацніть його правою кнопкою миші, щоб покласти його в цю групу.
New Group Нова група
New Group… Нова група…
Move to Group Перемістити в групу
@@ -4837,5 +4837,7 @@ lighting Light Світло
Plastic Wrap Целофанова плівка
Chrome… Хром…
Chrome Хром
Drop here to take it out of its group Відпустіть тут, щоб вийняти його з групи
Move Library Group Перемістити групу бібліотеки
Plaster… Гіпс…
Plaster Гіпс
Can't render this file because it contains an unexpected character in line 725 and column 40.
+3 -1
View File
@@ -2926,7 +2926,7 @@ mark-style Japanese Kiểu Nhật
Right Indent Thụt lề phải
Right Plane Mặt phẳng phải
Right to Left Phải sang trái
Right-click an item to move it into this group. Bấm chuột phải vào mục để di chuyển vào nhóm này.
Drag an item here, or right-click one, to put it in this group. Kéo một mục vào đây, hoặc bấm chuột phải vào mục, để đưa vào nhóm này.
Right-Justified Tab Tab căn phải
Right-to-Left Paragraph Direction Hướng đoạn văn từ phải sang trái
Right: Phải:
@@ -4837,5 +4837,7 @@ theme Appearance Giao diện
Plastic Wrap Màng bọc nhựa
Chrome… Crôm…
Chrome Crôm
Drop here to take it out of its group Thả vào đây để đưa ra khỏi nhóm
Move Library Group Di chuyển nhóm thư viện
Plaster… Thạch cao…
Plaster Thạch cao
Can't render this file because it contains an unexpected character in line 3962 and column 10.
+3 -1
View File
@@ -3036,7 +3036,7 @@ font-script Japanese 汉字
Delete Library Item 删除库项目
No results 无结果
No items in this library match “{query}”. 此库中没有与“{query}”匹配的项目。
Right-click an item to move it into this group. 右键单击项目可将其移到此组。
Drag an item here, or right-click one, to put it in this group. 将项目拖到此处,或右键单击项目,即可将其放入此组。
New Group 新建组
New Group… 新建组…
Move to Group 移到组
@@ -5080,5 +5080,7 @@ lighting Light 光源
Plastic Wrap 塑料包装
Chrome… 铬黄…
Chrome 铬黄
Drop here to take it out of its group 放到此处可将其移出所在组
Move Library Group 移动库组
Plaster… 塑料效果…
Plaster 塑料效果
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -3904,7 +3904,7 @@ font-script Japanese 漢字
Delete Library Item 刪除資料庫項目
No results 沒有結果
No items in this library match “{query}”. 此資料庫中沒有符合「{query}」的項目。
Right-click an item to move it into this group. 在項目上按一下右鍵,即可將其移至此群組。
Drag an item here, or right-click one, to put it in this group. 將項目拖到此處,或在項目上按一下右鍵,即可將其放入此群組。
New Group 新增群組
New Group… 新增群組…
Move to Group 移至群組
@@ -4875,5 +4875,7 @@ lighting Light 光源
Plastic Wrap 塑膠包裝
Chrome… 鉻黃…
Chrome 鉻黃
Drop here to take it out of its group 放到此處即可將其移出所屬群組
Move Library Group 移動資料庫群組
Plaster… 塑膠效果…
Plaster 塑膠效果
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -1044,7 +1044,7 @@ mod tests {
// A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop.
app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap();
let ink = app.session.doc().unwrap().doc.swatch("Ink").unwrap().paint.clone();
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::Paint { paint: ink, params: json!({"swatch": "Ink"}), rows: None });
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::Paint { paint: ink, params: json!({"swatch": "Ink"}), rows: None, library: None });
p.frame(&mut app, vec![Event::PointerMoved(a)], Modifiers::NONE);
p.frame(
&mut app,
+6 -1
View File
@@ -351,7 +351,12 @@ fn zone_input(ui: &Ui, zone: &Response, d: &Document, ev: &mut Events) {
PanelDrag::GraphicStyle(n) => (Some(n), None),
PanelDrag::Art(ids) => (None, ids.first().copied()),
PanelDrag::Appearance(id) => (None, Some(*id)),
PanelDrag::Paint { .. } | PanelDrag::Symbol(_) | PanelDrag::Brush { .. } | PanelDrag::LibraryGraphic { .. } => (None, None),
PanelDrag::Paint { .. }
| PanelDrag::Symbol(_)
| PanelDrag::Brush { .. }
| PanelDrag::LibraryGraphic { .. }
| PanelDrag::LibraryItem { .. }
| PanelDrag::LibraryGroup { .. } => (None, None),
};
if style.is_none() && source.is_none() {
return;
+384 -61
View File
@@ -8,6 +8,12 @@
//! The search field filters the items by name; items can be moved into user-named groups, shown as
//! collapsible sections after the ungrouped items; and the panel menu exports the library to a
//! `.vclibrary` file and imports one as a new library (#926).
//!
//! The list fills the panel down to its bottom bar and scrolls (also while a drag nears its top or
//! bottom edge). An item dragged within it lands before or after another of its kind, in that one's
//! group (`library.moveItem` with `before`/`after`), or, dropped elsewhere on a group or on the
//! ungrouped items, goes into that group or out of its own; a group dragged by its header goes
//! before or after another group (`library.moveGroup`).
use std::collections::HashMap;
@@ -24,6 +30,13 @@ use crate::{VectorcraftApp, icons};
const TILE: f32 = 56.0;
/// The size of a colour chip.
const CHIP: f32 = 22.0;
/// The height under the list the bottom bar takes (its divider and buttons) until it has been
/// measured.
const BAR: f32 = 42.0;
/// The least height the list takes.
const MIN_LIST: f32 = 140.0;
/// How near the list's top or bottom edge a drag scrolls it.
const EDGE: f32 = 28.0;
/// The current library: its id and a copy of it (the panel menu acts on it after reading).
fn current(app: &VectorcraftApp) -> Option<(String, Library)> {
@@ -75,6 +88,25 @@ enum Act {
RenameGroup(String),
}
/// What a drag in the panel holds of the library shown: one of its items, or one of its groups
/// (by the group's header).
#[derive(Clone, Debug, PartialEq)]
enum Grab {
Item(ItemKind, String),
Group(String),
}
/// The item or group of library `library` that `drag` holds, if it holds one.
fn grabbed(drag: &PanelDrag, library: &str) -> Option<Grab> {
match drag {
PanelDrag::Paint { library: Some((l, item)), .. } if l == library => Some(Grab::Item(ItemKind::Color, item.clone())),
PanelDrag::LibraryGraphic { library: l, item } if l == library => Some(Grab::Item(ItemKind::Graphic, item.clone())),
PanelDrag::LibraryItem { library: l, kind, item } if l == library => Some(Grab::Item(*kind, item.clone())),
PanelDrag::LibraryGroup { library: l, group } if l == library => Some(Grab::Group(group.clone())),
_ => None,
}
}
/// What the item list draws from.
struct Items<'a> {
library: &'a str,
@@ -82,6 +114,8 @@ struct Items<'a> {
/// The group of each grouped item ([`Library::group_index`]).
groups: HashMap<(ItemKind, &'a str), usize>,
fill_active: bool,
/// What a drag in progress holds of this library.
grab: Option<Grab>,
}
impl Items<'_> {
@@ -121,7 +155,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let query = query.trim().to_lowercase();
let searching = !query.is_empty();
let shown = |name: &str| !searching || name.to_lowercase().contains(&query);
let items = Items { library: &lib_id, lib, groups: lib.group_index(), fill_active };
let grab = egui::DragAndDrop::payload::<PanelDrag>(ui.ctx()).and_then(|d| grabbed(&d, &lib_id));
let items = Items { library: &lib_id, lib, groups: lib.group_index(), fill_active, grab };
// The items shown in each section: the ungrouped ones first, then each group's.
let mut counts = vec![0usize; lib.groups.len() + 1];
for (kind, key, _) in lib.items().filter(|(_, _, name)| shown(name)) {
@@ -130,76 +165,113 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
ui.add_space(4.0);
// The list takes the panel's height down to the bottom bar (as tall as it was last frame), and
// scrolls.
let bar_id = egui::Id::new("library-bottom-bar");
let bar: f32 = ui.data(|d| d.get_temp(bar_id)).unwrap_or(BAR);
let list_h = ui.available_height() - bar;
let list_h = if list_h.is_finite() { list_h.max(MIN_LIST) } else { MIN_LIST };
// Each section's rows: the ungrouped items (None), then each group (its index), header included.
let mut sections: Vec<(Option<usize>, Rect)> = vec![];
// Whether an item takes the drag in progress (dropped next to it), so its section doesn't.
let mut over_item = false;
let list_rect = widgets::list_box(ui, |ui| {
ui.set_min_height(140.0);
ui.set_width(ui.available_width());
if empty {
super::empty_state(
ui,
"library",
tl!("This library is empty"),
tl!("Drag art or swatches here, or use + to add the selection's colors and text styles."),
);
return ui.min_rect();
}
if searching && counts.iter().all(|n| *n == 0) {
let body = crate::i18n::fmt(tl!("No items in this library match “{query}”."), &[("query", &query)]);
super::empty_state(ui, "search", tl!("No results"), &body);
return ui.min_rect();
}
egui::Frame::NONE.inner_margin(egui::Margin::same(6)).show(ui, |ui| {
items.show(ui, &mut act, |kind, key, name| items.group_of(kind, key).is_none() && shown(name));
for (i, g) in lib.groups.iter().enumerate() {
let n = counts.get(i + 1).copied().unwrap_or_default();
if searching && n == 0 {
continue;
}
// Open unless closed; open while searching, to show what matched.
let key = format!("library-group-closed:{lib_id}:{}", g.name);
let closed: bool = super::pstate(ui.ctx(), &key);
let open = searching || !closed;
let header = widgets::section_toggle_name(ui, &g.name, open);
ui.painter().text(
header.rect.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
n.to_string(),
egui::FontId::proportional(11.5),
t.text_dim,
let out = egui::ScrollArea::vertical().id_salt("library-scroll").max_height(list_h).auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(ui.available_width());
if empty {
super::empty_state(
ui,
"library",
tl!("This library is empty"),
tl!("Drag art or swatches here, or use + to add the selection's colors and text styles."),
);
if header.clicked() && !searching {
super::set_pstate(ui.ctx(), &key, !closed);
return;
}
if searching && counts.iter().all(|n| *n == 0) {
let body = crate::i18n::fmt(tl!("No items in this library match “{query}”."), &[("query", &query)]);
super::empty_state(ui, "search", tl!("No results"), &body);
return;
}
egui::Frame::NONE.inner_margin(egui::Margin::same(6)).show(ui, |ui| {
let span = |ui: &Ui, top: f32| Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=ui.cursor().top());
let top = ui.cursor().top();
if counts.first().is_some_and(|n| *n > 0) {
items.show(ui, &mut act, &mut over_item, |kind, key, name| items.group_of(kind, key).is_none() && shown(name));
} else if let Some(Grab::Item(kind, item)) = &items.grab
&& items.group_of(*kind, item).is_some()
{
no_group_strip(ui);
}
header.context_menu(|ui| {
if menu_item(ui, "Rename Group…", true, false) {
act = Some(Act::RenameGroup(g.name.clone()));
ui.close();
sections.push((None, span(ui, top)));
for (i, g) in lib.groups.iter().enumerate() {
let n = counts.get(i + 1).copied().unwrap_or_default();
if searching && n == 0 {
continue;
}
if menu_item(ui, "Delete Group", true, false) {
act = Some(Act::Run("library.deleteGroup", json!({ "group": g.name })));
ui.close();
let top = ui.cursor().top();
// Open unless closed; open while searching, to show what matched.
let key = format!("library-group-closed:{lib_id}:{}", g.name);
let closed: bool = super::pstate(ui.ctx(), &key);
let open = searching || !closed;
// The header also drags the group to another place in the list.
let header = widgets::section_toggle_name(ui, &g.name, open).interact(Sense::drag());
widgets::drag_source(ui, &header, || PanelDrag::LibraryGroup { library: lib_id.clone(), group: g.name.clone() });
ui.painter().text(
header.rect.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
n.to_string(),
egui::FontId::proportional(11.5),
t.text_dim,
);
if header.clicked() && !searching {
super::set_pstate(ui.ctx(), &key, !closed);
}
});
if !open {
continue;
}
ui.add_space(2.0);
if n == 0 {
ui.label(egui::RichText::new(tl!("Right-click an item to move it into this group.")).size(11.5).color(t.text_dim));
ui.add_space(6.0);
} else {
items.show(ui, &mut act, |kind, key, name| items.group_of(kind, key) == Some(i) && shown(name));
header.context_menu(|ui| {
if menu_item(ui, "Rename Group…", true, false) {
act = Some(Act::RenameGroup(g.name.clone()));
ui.close();
}
if menu_item(ui, "Delete Group", true, false) {
act = Some(Act::Run("library.deleteGroup", json!({ "group": g.name })));
ui.close();
}
});
if open {
ui.add_space(2.0);
if n == 0 {
ui.label(
egui::RichText::new(tl!("Drag an item here, or right-click one, to put it in this group."))
.size(11.5)
.color(t.text_dim),
);
ui.add_space(6.0);
} else {
items.show(ui, &mut act, &mut over_item, |kind, key, name| items.group_of(kind, key) == Some(i) && shown(name));
}
}
sections.push((Some(i), span(ui, top)));
}
});
if items.grab.is_some() {
scroll_at_edges(ui);
}
});
ui.min_rect()
out.inner_rect
});
// Art dragged off the canvas, or a swatch, dropped on the list is added to the library.
if let Some(grab) = &items.grab
&& !over_item
{
section_drop(ui, &items, grab, &sections, list_rect, &mut act);
}
// Art dragged off the canvas, or a swatch (not one of this library's colours), dropped on the
// list is added to the library.
let zone = ui.interact(list_rect, ui.id().with("libraries-drop"), Sense::hover());
if let Some(ids) = widgets::art_drop(ui, &zone) {
act = Some(Act::Run("library.add", json!({ "kind": "graphic", "ids": ids })));
}
if let Some(drag) = zone.dnd_hover_payload::<PanelDrag>()
&& let PanelDrag::Paint { paint: Paint::Solid { color, .. }, .. } = &*drag
&& let PanelDrag::Paint { paint: Paint::Solid { color, .. }, library, .. } = &*drag
&& library.as_ref().is_none_or(|(l, _)| *l != lib_id)
{
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
let color = *color;
@@ -207,6 +279,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
act = Some(Act::Run("library.add", json!({ "kind": "fillColor", "color": color })));
}
}
let bar_top = ui.cursor().top();
widgets::bottom_bar(ui, |ui| {
if widgets::icon_button(ui, "group", tl!("New Group"), false, 24.0).clicked() {
act = Some(Act::NewGroup(json!([])));
@@ -227,6 +300,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
});
let bar_h = ui.min_rect().bottom() - bar_top;
if bar_h.is_finite() && bar_h > 0.0 {
ui.data_mut(|d| d.insert_temp(bar_id, bar_h));
}
match act {
Some(Act::Run(cmd, mut p)) => {
if cmd != "library.setCurrent" {
@@ -248,8 +325,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
impl Items<'_> {
/// The colours, character and paragraph styles and graphics `keep` keeps (given the kind, key
/// and name of each), each kind under its heading.
fn show(&self, ui: &mut Ui, act: &mut Option<Act>, keep: impl Fn(ItemKind, &str, &str) -> bool) {
/// and name of each), each kind under its heading. Each drags to another place in the list;
/// `over` is set while one of them takes the drag in progress.
fn show(&self, ui: &mut Ui, act: &mut Option<Act>, over: &mut bool, keep: impl Fn(ItemKind, &str, &str) -> bool) {
let t = Tokens::get(ui.ctx());
let lib = self.lib;
let colors: Vec<_> = lib.colors.iter().filter(|c| keep(ItemKind::Color, &c.name, &c.name)).collect();
@@ -262,7 +340,13 @@ impl Items<'_> {
let paint = Paint::solid(c.color);
widgets::swatch_tile(ui, r, &paint, false, false);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
widgets::drag_source(ui, &resp, || PanelDrag::Paint { paint: paint.clone(), params: json!({ "color": c.color }), rows: None });
widgets::drag_source(ui, &resp, || PanelDrag::Paint {
paint: paint.clone(),
params: json!({ "color": c.color }),
rows: None,
library: Some((self.library.to_string(), c.name.clone())),
});
self.drop_next_to(ui, &resp, ItemKind::Color, &c.name, false, act, over);
let resp = resp.on_hover_text(&c.name);
if resp.clicked() {
let to = if self.fill_active { "fill" } else { "stroke" };
@@ -282,13 +366,15 @@ impl Items<'_> {
}
heading(ui, title);
for st in list {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let icon = if kind == ItemKind::ParaStyle { "pilcrow" } else { "type" };
icons::paint(ui, icon, Rect::from_center_size(r.left_center() + vec2(10.0, 0.0), vec2(14.0, 14.0)), t.icon);
ui.painter().text(r.left_center() + vec2(24.0, 0.0), egui::Align2::LEFT_CENTER, &st.name, egui::FontId::proportional(12.5), t.text);
widgets::drag_source(ui, &resp, || PanelDrag::LibraryItem { library: self.library.to_string(), kind, item: st.name.clone() });
self.drop_next_to(ui, &resp, kind, &st.name, true, act, over);
let resp = resp.on_hover_text(tl!("Click to apply to the selected type"));
if resp.clicked() {
*act = Some(Act::Run("library.use", json!({ "kind": kind_param(kind), "item": st.name })));
@@ -308,6 +394,7 @@ impl Items<'_> {
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border }), StrokeKind::Inside);
// Dragged onto the canvas, a copy is placed where it is dropped.
widgets::drag_source(ui, &resp, || PanelDrag::LibraryGraphic { library: self.library.to_string(), item: g.id.clone() });
self.drop_next_to(ui, &resp, ItemKind::Graphic, &g.id, false, act, over);
let resp = resp.on_hover_text(&g.name);
let place = || Act::Run("library.use", json!({ "kind": "graphic", "item": g.id }));
if resp.double_clicked() {
@@ -326,6 +413,41 @@ impl Items<'_> {
}
}
/// An item of `kind` dragged over item `key`'s cell `resp` lands just before or after it, by
/// the pointer's half (`row`: rows stack downwards; else chips and tiles run across), in its
/// group: an accent line shows where, and `over` is set. Dropped on itself it stays.
#[allow(clippy::too_many_arguments)]
fn drop_next_to(&self, ui: &Ui, resp: &egui::Response, kind: ItemKind, key: &str, row: bool, act: &mut Option<Act>, over: &mut bool) {
let Some(Grab::Item(grabbed_kind, moving)) = &self.grab else { return };
if *grabbed_kind != kind || resp.dnd_hover_payload::<PanelDrag>().is_none() {
return;
}
*over = true;
if moving == key {
resp.dnd_release_payload::<PanelDrag>();
return;
}
let r = resp.rect;
let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center());
let after = if row { at.y > r.center().y } else { at.x > r.center().x };
let accent = Stroke::new(2.0, Tokens::get(ui.ctx()).accent);
if row {
let y = if after { r.bottom() } else { r.top() };
ui.painter().line_segment([egui::pos2(r.left(), y), egui::pos2(r.right(), y)], accent);
} else {
let x = if after { r.right() + 2.0 } else { r.left() - 2.0 };
ui.painter().line_segment([egui::pos2(x, r.top()), egui::pos2(x, r.bottom())], accent);
}
if resp.dnd_release_payload::<PanelDrag>().is_some() {
let mut p = json!({ "kind": kind_param(kind), "item": moving });
p[if after { "after" } else { "before" }] = json!(key);
if let Some(g) = self.group_of(kind, key).and_then(|i| self.lib.groups.get(i)) {
p["group"] = json!(g.name);
}
*act = Some(Act::Run("library.moveItem", p));
}
}
/// An item's context menu entries: Move to Group (No Group, the library's groups, New
/// Group…) and Delete.
fn item_menu(&self, ui: &mut Ui, act: &mut Option<Act>, kind: ItemKind, key: &str) {
@@ -371,6 +493,82 @@ fn kind_param(kind: ItemKind) -> &'static str {
}
}
/// Where an item dragged out of its group can be dropped when no ungrouped item is showing.
fn no_group_strip(ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
tl!("Drop here to take it out of its group"),
egui::FontId::proportional(11.5),
t.text_dim,
);
ui.add_space(6.0);
}
/// While something is dragged near the top or bottom edge of the scrolled list (`ui`: its
/// contents), scroll that way, faster nearer the edge.
fn scroll_at_edges(ui: &mut Ui) {
let Some(pos) = ui.ctx().pointer_hover_pos() else { return };
let view = ui.clip_rect();
if !view.x_range().contains(pos.x) {
return;
}
let speed = |depth: f32| if (0.0..EDGE).contains(&depth) { 2.0 + 10.0 * (1.0 - depth / EDGE) } else { 0.0 };
let delta = speed(pos.y - view.top()) - speed(view.bottom() - pos.y);
if delta != 0.0 {
ui.scroll_with_delta(vec2(0.0, delta));
ui.ctx().request_repaint();
}
}
/// The drag in progress held over a section of the list (`sections`, seen through `clip`), away
/// from the items that take one: an item goes into that section's group (the ungrouped section:
/// out of its group), the section outlined; a group goes before or after that group, by the
/// pointer's half, an accent line showing where.
fn section_drop(ui: &Ui, items: &Items, grab: &Grab, sections: &[(Option<usize>, Rect)], clip: Rect, act: &mut Option<Act>) {
// By the pointer, not by hover zones: the headers and items over the sections take the hover.
let Some(pos) = ui.ctx().pointer_latest_pos() else { return };
let released = ui.input(|i| i.pointer.any_released());
let take = || egui::DragAndDrop::take_payload::<PanelDrag>(ui.ctx()).is_some();
let t = Tokens::get(ui.ctx());
let Some((group, rect)) = sections.iter().find(|(_, rect)| rect.intersect(clip).contains(pos)) else { return };
let r = rect.intersect(clip);
let name = |i: &usize| items.lib.groups.get(*i).map(|g| g.name.clone());
match grab {
Grab::Item(kind, item) => {
if items.group_of(*kind, item) == *group {
return;
}
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
if released && take() {
let mut p = json!({ "kind": kind_param(*kind), "item": item });
if let Some(g) = group.as_ref().and_then(name) {
p["group"] = json!(g);
}
*act = Some(Act::Run("library.moveItem", p));
}
}
Grab::Group(moving) => {
// Groups only go among the groups; dropped on itself, one stays.
let (Some(to), Some(from)) = (*group, items.lib.groups.iter().position(|g| g.name == *moving)) else { return };
if from == to {
return;
}
let after = pos.y > rect.center().y;
let y = if after { rect.bottom() } else { rect.top() };
ui.painter().line_segment([egui::pos2(r.left(), y), egui::pos2(r.right(), y)], Stroke::new(2.0, t.accent));
if released && take() {
let to = to + usize::from(after);
let to = if from < to { to - 1 } else { to };
*act = Some(Act::Run("library.moveGroup", json!({ "group": moving, "to": to })));
}
}
}
}
/// A section heading in the list.
fn heading(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx());
@@ -584,6 +782,131 @@ mod tests {
assert!(none.iter().any(|t| t == "No results"), "{none:?}");
}
/// A library imported from `styles` (character style names) and `groups` ([(name, its styles)])
/// → its id.
fn styled(app: &mut VectorcraftApp, styles: &[&str], groups: &[(&str, &[&str])]) -> String {
let styles: Vec<Value> = styles.iter().enumerate().map(|(i, n)| json!({"name": n, "attrs": {"size": 8 + i}})).collect();
let groups: Vec<Value> = groups
.iter()
.map(|(g, items)| json!({"name": g, "items": items.iter().map(|i| json!({"kind": "charStyle", "item": i})).collect::<Vec<_>>()}))
.collect();
let data = json!({"name": "Order", "charStyles": styles, "groups": groups}).to_string();
app.run("library.import", json!({"data": data})).unwrap()["id"].as_str().unwrap().to_string()
}
/// One frame of the panel in a 260 × `tall` window with `events` → where each text it shows is
/// (its rect), and the panel's height.
fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, tall: f32, events: Vec<egui::Event>) -> (Vec<(String, Rect)>, f32) {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(260.0, tall))), events, ..Default::default() };
let mut height = 0.0;
let mut out = ctx.run_ui(raw, |ui| {
show(app, ui);
height = ui.min_rect().height();
});
out.textures_delta.clear();
let mut texts = vec![];
for s in out.shapes {
if let egui::epaint::Shape::Text(t) = s.shape {
texts.push((t.galley.text().to_string(), t.galley.rect.translate(t.pos.to_vec2())));
}
}
(texts, height)
}
/// Where text `name` is in `texts`.
fn at(texts: &[(String, Rect)], name: &str) -> Rect {
texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} isn't shown: {texts:?}"))
}
/// `drag` released at `pos`.
fn drop_at(app: &mut VectorcraftApp, ctx: &egui::Context, drag: PanelDrag, pos: egui::Pos2) {
egui::DragAndDrop::set_payload(ctx, drag);
let up = egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
frame_at(app, ctx, 600.0, vec![egui::Event::PointerMoved(pos), up]);
frame_at(app, ctx, 600.0, vec![]);
}
fn style_order(app: &mut VectorcraftApp) -> Vec<String> {
let got = app.run("library.get", json!({})).unwrap();
got["charStyles"].as_array().unwrap().iter().map(|s| s["name"].as_str().unwrap().to_string()).collect()
}
fn group_names(app: &mut VectorcraftApp) -> Vec<(String, Vec<String>)> {
let got = app.run("library.get", json!({})).unwrap();
let items = |g: &Value| g["items"].as_array().unwrap().iter().map(|i| i["item"].as_str().unwrap().to_string()).collect();
got["groups"].as_array().unwrap().iter().map(|g| (g["name"].as_str().unwrap().to_string(), items(g))).collect()
}
/// Dragging in the list: a style dropped on the top or bottom half of another lands before or
/// after it, in its group; one dropped on a group's header goes into that group; a group's
/// header dropped on another group moves the group; and with every item grouped, a strip
/// takes an item out of its group.
#[test]
fn items_and_groups_move_by_dragging() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let lib = styled(&mut app, &["A", "B", "C", "D"], &[("Warm", &["D"]), ("Cool", &[])]);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
let item = |name: &str| PanelDrag::LibraryItem { library: lib.clone(), kind: ItemKind::CharStyle, item: name.into() };
// C onto A's top half: first.
let a = at(&texts, "A");
drop_at(&mut app, &ctx, item("C"), egui::pos2(a.center().x, a.top() - 4.0));
assert_eq!(style_order(&mut app), ["C", "A", "B", "D"]);
// A onto D's bottom half (in Warm): after D, in Warm.
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
let d = at(&texts, "D");
drop_at(&mut app, &ctx, item("A"), egui::pos2(d.center().x, d.bottom() + 4.0));
assert_eq!(style_order(&mut app), ["C", "B", "D", "A"]);
assert_eq!(group_names(&mut app), [("Warm".into(), vec!["D".to_string(), "A".into()]), ("Cool".into(), vec![])]);
// B onto Cool's header: into Cool, the order kept.
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
drop_at(&mut app, &ctx, item("B"), at(&texts, "Cool").center());
assert_eq!(style_order(&mut app), ["C", "B", "D", "A"]);
assert_eq!(group_names(&mut app)[1], ("Cool".into(), vec!["B".to_string()]));
// Cool's header onto Warm's header (its top half): Cool first.
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
let warm = at(&texts, "Warm");
drop_at(&mut app, &ctx, PanelDrag::LibraryGroup { library: lib.clone(), group: "Cool".into() }, egui::pos2(warm.center().x, warm.top()));
assert_eq!(group_names(&mut app).iter().map(|(g, _)| g.as_str()).collect::<Vec<_>>(), ["Cool", "Warm"]);
// Every item grouped: while one is dragged a strip takes it out of its group.
app.run("library.moveItem", json!({"kind": "charStyle", "item": "C", "group": "Warm"})).unwrap();
egui::DragAndDrop::set_payload(&ctx, item("C"));
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
let strip = at(&texts, "Drop here to take it out of its group");
drop_at(&mut app, &ctx, item("C"), strip.center());
assert!(group_names(&mut app).iter().all(|(_, items)| !items.contains(&"C".to_string())), "{:?}", group_names(&mut app));
// Without a drag there is no strip.
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
assert!(!texts.iter().any(|(t, _)| t == "Drop here to take it out of its group"));
}
/// A long list scrolls inside the panel (its bottom bar stays in the window), and a drag held
/// near the list's bottom edge scrolls it.
#[test]
fn a_long_list_scrolls_also_while_dragging() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let names: Vec<String> = (0..60).map(|i| format!("Style {i}")).collect();
let lib = styled(&mut app, &names.iter().map(String::as_str).collect::<Vec<_>>(), &[]);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let (texts, height) = frame_at(&mut app, &ctx, 400.0, vec![]);
assert!(height <= 400.0, "the panel fits the window: {height}");
let first = at(&texts, "Style 0");
// The list's bottom edge: the last text drawn before the window's bottom bar area.
let bottom = texts.iter().filter(|(t, _)| t.starts_with("Style ")).map(|(_, r)| r.bottom()).fold(0.0, f32::max).min(400.0 - BAR - 4.0);
egui::DragAndDrop::set_payload(&ctx, PanelDrag::LibraryItem { library: lib, kind: ItemKind::CharStyle, item: "Style 0".into() });
let hold = egui::pos2(first.center().x, bottom - 4.0);
for _ in 0..5 {
frame_at(&mut app, &ctx, 400.0, vec![egui::Event::PointerMoved(hold)]);
}
let (texts, _) = frame_at(&mut app, &ctx, 400.0, vec![egui::Event::PointerMoved(hold)]);
let moved = texts.iter().find(|(t, _)| t == "Style 0").map_or(f32::NEG_INFINITY, |(_, r)| r.top());
assert!(moved < first.top(), "the list scrolled down: {} → {moved}", first.top());
}
/// New Group… and Rename Group… ask for a name and act on the library and group they came
/// from; the dialog shows only the name.
#[test]
+2 -1
View File
@@ -410,7 +410,7 @@ fn drag_of(e: &Entry, names: Vec<String>) -> PanelDrag {
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) }
PanelDrag::Paint { paint, params: swatch_params(e).unwrap_or_default(), rows: Some(rows), library: None }
}
/// What the tiles or rows saw this frame.
@@ -614,6 +614,7 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
},
PanelDrag::Brush { def, .. } => return pointer_chip(ctx, |ui, r| super::brushes::chip(ui, r, def)),
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 {
+10 -4
View File
@@ -2034,8 +2034,9 @@ pub enum PanelDrag {
/// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group,
/// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the
/// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes
/// a swatch of a paint dragged from elsewhere.
Paint { paint: Paint, params: serde_json::Value, rows: Option<SwatchRows> },
/// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is
/// (library id, colour name), which that panel moves to where it is dropped.
Paint { paint: Paint, params: serde_json::Value, rows: Option<SwatchRows>, library: Option<(String, String)> },
/// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance
/// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it.
Appearance(vectorcraft_doc::NodeId),
@@ -2054,8 +2055,13 @@ pub enum PanelDrag {
/// dropped on takes it (`brush.apply`).
Brush { name: String, def: serde_json::Value },
/// A Libraries panel graphic (`item`: its id in `library`): the canvas places a copy centred
/// where it is dropped (`library.use`).
/// where it is dropped (`library.use`); the panel moves it to where it is dropped.
LibraryGraphic { library: String, item: String },
/// A Libraries panel text style (`item`: its name in `library`), which the panel moves to where
/// it is dropped.
LibraryItem { library: String, kind: vectorcraft_engine::cmd::library::ItemKind, item: String },
/// A Libraries panel group, by its header, which the panel moves to where it is dropped.
LibraryGroup { library: String, group: String },
}
/// A panel list `zone` that takes art dragged off the canvas: outlined while art is held over it;
@@ -2088,7 +2094,7 @@ impl PanelDrag {
g.geom = None;
g.freeform = None;
}
Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None }
Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None, library: None }
}
/// The dragged colour (a solid paint's), which the Gradient panel's ramp takes.
pub fn color(&self) -> Option<vectorcraft_color::Color> {
+7 -3
View File
@@ -1564,9 +1564,13 @@ Items can be put in user-named groups, which the panel shows as collapsible sect
search field filters every item by name; `library.get` lists them all to filter). `library.createGroup {library?,
name?, items?: [{kind, item}]}` makes one (a name another group has, ignoring case, gets a number) with those items
moved into it; `library.renameGroup {library?, group, name}` renames one; `library.deleteGroup {library?, group}`
deletes one, its items staying in the library ungrouped; and `library.moveItem {library?, kind, item, group?}` moves
an item into a group (without `group`, out of its group). An item is in one group at most; `library.get` lists the
groups as `groups: [{name, items: [{kind: "color"|"charStyle"|"paraStyle"|"graphic", item}]}]` (a graphic by its id).
deletes one, its items staying in the library ungrouped; and `library.moveItem {library?, kind, item, group?, before?
| after?}` moves an item into a group (without `group`, out of its group) and, with `before` or `after` (another item
of its kind), just before or after that item in the order its kind is listed and shown in. `library.moveGroup
{library?, group, to}` moves a group to place `to` of the list (0: first; past the end: last). Dragging items and
group headers in the panel runs these. An item is in one group at most; `library.get` lists the groups as `groups:
[{name, items: [{kind: "color"|"charStyle"|"paraStyle"|"graphic", item}]}]` (a graphic by its id), and each kind's
items in the order the panel shows them.
`library.export {library?, path?}` writes a library (its colours, styles, graphics with their art and thumbnails, and
its groups) as a `.vclibrary` file, the JSON the library folder keeps (without `path`: `{data}`, the file's text).
File diff suppressed because one or more lines are too long