Merge pull request #943 from storytold/feat/926-library-groups
Fix #926: the Libraries panel's search, groups and library export/import
This commit is contained in:
commit
9449c92136
File diff suppressed because one or more lines are too long
|
|
@ -652,11 +652,13 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
|
|||
}
|
||||
|
||||
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
|
||||
/// libraries (which open in the library panel), flattener, PDF and print presets (imported).
|
||||
/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF and print
|
||||
/// presets (imported).
|
||||
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All readable files", OPEN_EXTS))
|
||||
.chain(format_filters())
|
||||
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
|
||||
.chain(std::iter::once(("Libraries", super::library::LIBRARY_EXTS)))
|
||||
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
|
||||
|
|
|
|||
|
|
@ -157,7 +157,11 @@ fn open_exts_cover_every_readable_format() {
|
|||
}
|
||||
let filters: Vec<_> = open_filters().collect();
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(filters.len(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins");
|
||||
assert_eq!(
|
||||
filters.len(),
|
||||
7 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, plug-ins"
|
||||
);
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
//! live for the session only. A graphic is kept as a native document with a PNG thumbnail: adding
|
||||
//! 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).
|
||||
//! 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).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
|
@ -16,10 +19,20 @@ use crate::Clipboard;
|
|||
|
||||
/// The extension of a library file.
|
||||
pub const LIBRARY_EXT: &str = "vclibrary";
|
||||
/// The extensions of library files (open dialogs).
|
||||
pub const LIBRARY_EXTS: &[&str] = &[LIBRARY_EXT];
|
||||
/// Bytes a library file may have when read (graphics with large images included).
|
||||
const MAX_FILE: u64 = 512 << 20;
|
||||
/// Items of one kind a library may hold.
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
/// Groups a library may hold.
|
||||
const MAX_GROUPS: usize = 1_000;
|
||||
/// Characters of a library, group or item name kept from a file.
|
||||
const MAX_NAME: usize = 256;
|
||||
/// Base64 bytes a graphic's thumbnail may have (a 256-pixel PNG is far smaller).
|
||||
const MAX_THUMBNAIL: usize = 4 << 20;
|
||||
/// Pixels a side a thumbnail may claim (they are made at most 256).
|
||||
const MAX_THUMBNAIL_SIDE: u32 = 1024;
|
||||
|
||||
/// A library: its name and its items, newest last.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -34,6 +47,38 @@ pub struct Library {
|
|||
pub para_styles: Vec<TextStyleDef>,
|
||||
#[serde(default)]
|
||||
pub graphics: Vec<LibraryGraphic>,
|
||||
/// User-named groups of its items, in the order they were made (#926); an item is in one
|
||||
/// group at most, the others are ungrouped. Files written before groups have none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub groups: Vec<LibraryGroup>,
|
||||
}
|
||||
|
||||
/// A user-named group of a library's items.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LibraryGroup {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub items: Vec<ItemRef>,
|
||||
}
|
||||
|
||||
/// A library item: its kind and its key (its name; a graphic's id).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ItemRef {
|
||||
pub kind: ItemKind,
|
||||
pub item: String,
|
||||
}
|
||||
|
||||
/// What a library item is, as groups name it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ItemKind {
|
||||
Color,
|
||||
CharStyle,
|
||||
ParaStyle,
|
||||
Graphic,
|
||||
/// A kind this version doesn't know (a newer one wrote it): dropped when the file is read.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -87,7 +132,7 @@ impl Libraries {
|
|||
})
|
||||
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
|
||||
if let Some(mut lib) = lib {
|
||||
lib.cap();
|
||||
lib.sanitize();
|
||||
self.libs.push((id, lib));
|
||||
}
|
||||
}
|
||||
|
|
@ -120,12 +165,15 @@ impl Libraries {
|
|||
|
||||
/// A new empty library called `name` → its id.
|
||||
fn create(&mut self, name: &str) -> Result<String> {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
let base = base.trim();
|
||||
let base = if base.is_empty() { "Library" } else { base };
|
||||
self.insert(Library { name: name.to_string(), ..Default::default() })
|
||||
}
|
||||
|
||||
/// `lib` as a new library, its file named after it, made the current one → its id.
|
||||
fn insert(&mut self, lib: Library) -> Result<String> {
|
||||
let base = file_stem_for(&lib.name);
|
||||
let taken = |id: &str| self.libs.iter().any(|(i, _)| i.eq_ignore_ascii_case(id)) || self.file_exists(id);
|
||||
let id = std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|id| !taken(id)).unwrap_or_default();
|
||||
self.libs.push((id.clone(), Library { name: name.to_string(), ..Default::default() }));
|
||||
let id = std::iter::once(base.clone()).chain((2..).map(|n| format!("{base} {n}"))).find(|id| !taken(id)).unwrap_or_default();
|
||||
self.libs.push((id.clone(), lib));
|
||||
if let Err(e) = self.save(&id) {
|
||||
self.libs.retain(|(i, _)| i != &id);
|
||||
return Err(e);
|
||||
|
|
@ -194,15 +242,123 @@ impl Libraries {
|
|||
}
|
||||
|
||||
impl Library {
|
||||
/// At most [`MAX_ITEMS`] of each kind (a hand-edited file may hold more).
|
||||
fn cap(&mut self) {
|
||||
/// Every item as (kind, key: its name, a graphic's id, name shown): colours, then character
|
||||
/// and paragraph styles, then graphics.
|
||||
pub fn items(&self) -> impl Iterator<Item = (ItemKind, &str, &str)> {
|
||||
fn styles(kind: ItemKind, list: &[TextStyleDef]) -> impl Iterator<Item = (ItemKind, &str, &str)> {
|
||||
list.iter().map(move |t| (kind, t.name.as_str(), t.name.as_str()))
|
||||
}
|
||||
self.colors
|
||||
.iter()
|
||||
.map(|c| (ItemKind::Color, c.name.as_str(), c.name.as_str()))
|
||||
.chain(styles(ItemKind::CharStyle, &self.char_styles))
|
||||
.chain(styles(ItemKind::ParaStyle, &self.para_styles))
|
||||
.chain(self.graphics.iter().map(|g| (ItemKind::Graphic, g.id.as_str(), g.name.as_str())))
|
||||
}
|
||||
|
||||
/// The group of each grouped item: (kind, key) → its index in [`Library::groups`].
|
||||
pub fn group_index(&self) -> HashMap<(ItemKind, &str), usize> {
|
||||
self.groups.iter().enumerate().flat_map(|(i, g)| g.items.iter().map(move |r| ((r.kind, r.item.as_str()), i))).collect()
|
||||
}
|
||||
|
||||
/// Group `name` (else the one of that name ignoring case): its index.
|
||||
fn group_position(&self, name: &str) -> Option<usize> {
|
||||
let lower = name.to_lowercase();
|
||||
self.groups.iter().position(|g| g.name == name).or_else(|| self.groups.iter().position(|g| g.name.to_lowercase() == lower))
|
||||
}
|
||||
|
||||
/// Item `item` of `kind` (a graphic by id, else by name) as a group names it.
|
||||
fn item_ref(&self, kind: ItemKind, item: &str) -> Option<ItemRef> {
|
||||
let found = self.items().find(|(k, key, _)| *k == kind && *key == item);
|
||||
let found = found.or_else(|| self.items().find(|(k, _, name)| *k == kind && kind == ItemKind::Graphic && *name == item));
|
||||
found.map(|(kind, key, _)| ItemRef { kind, item: key.to_string() })
|
||||
}
|
||||
|
||||
/// Take `r` out of its group.
|
||||
fn ungroup(&mut self, r: &ItemRef) {
|
||||
for g in &mut self.groups {
|
||||
g.items.retain(|x| x != r);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// thumbnail only when it is a small PNG, and groups holding only items there are, each in one
|
||||
/// group at most.
|
||||
fn sanitize(&mut self) {
|
||||
clip(&mut self.name);
|
||||
self.colors.truncate(MAX_ITEMS);
|
||||
self.char_styles.truncate(MAX_ITEMS);
|
||||
self.para_styles.truncate(MAX_ITEMS);
|
||||
self.graphics.truncate(MAX_ITEMS);
|
||||
self.groups.truncate(MAX_GROUPS);
|
||||
uniquify(self.colors.iter_mut().map(|c| &mut c.name), "Color", " ", false);
|
||||
uniquify(self.char_styles.iter_mut().map(|t| &mut t.name), "Character Style", " ", false);
|
||||
uniquify(self.para_styles.iter_mut().map(|t| &mut t.name), "Paragraph Style", " ", false);
|
||||
uniquify(self.graphics.iter_mut().map(|g| &mut g.name), "Graphic", " ", false);
|
||||
uniquify(self.graphics.iter_mut().map(|g| &mut g.id), "graphic", "-", false);
|
||||
uniquify(self.groups.iter_mut().map(|g| &mut g.name), "Group", " ", true);
|
||||
for g in &mut self.graphics {
|
||||
for v in [&mut g.width, &mut g.height] {
|
||||
if !(v.is_finite() && *v >= 0.0) {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
if !thumbnail_ok(&g.thumbnail) {
|
||||
g.thumbnail.clear();
|
||||
}
|
||||
}
|
||||
let mut groups = std::mem::take(&mut self.groups);
|
||||
let present: HashSet<(ItemKind, &str)> = self.items().map(|(k, key, _)| (k, key)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
for g in &mut groups {
|
||||
g.items.retain(|r| present.contains(&(r.kind, r.item.as_str())) && seen.insert(r.clone()));
|
||||
}
|
||||
self.groups = groups;
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` cut to [`MAX_NAME`] characters.
|
||||
fn clip(name: &mut String) {
|
||||
if let Some((at, _)) = name.char_indices().nth(MAX_NAME) {
|
||||
name.truncate(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip each of `names` ([`clip`]) and make it unique among them (ignoring case when `fold`): an
|
||||
/// empty one becomes `fallback`, a repeated one gets a number after `sep` (`Blue 2`).
|
||||
fn uniquify<'a>(names: impl Iterator<Item = &'a mut String>, fallback: &str, sep: &str, fold: bool) {
|
||||
let key = |n: &str| if fold { n.to_lowercase() } else { n.to_string() };
|
||||
let mut seen = HashSet::new();
|
||||
for name in names {
|
||||
clip(name);
|
||||
if name.trim().is_empty() {
|
||||
*name = fallback.to_string();
|
||||
}
|
||||
if seen.contains(&key(name)) {
|
||||
let base = name.clone();
|
||||
*name = (2..).map(|n| format!("{base}{sep}{n}")).find(|n| !seen.contains(&key(n))).unwrap_or_default();
|
||||
}
|
||||
seen.insert(key(name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `b64` is empty or a PNG of at most [`MAX_THUMBNAIL_SIDE`] pixels a side, judged by its
|
||||
/// header: the panel decodes it.
|
||||
fn thumbnail_ok(b64: &str) -> bool {
|
||||
if b64.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// 32 base64 characters: 24 bytes, the signature then the IHDR chunk's length, type, width and
|
||||
// height.
|
||||
let Some(head) = b64.get(..32).filter(|_| b64.len() <= MAX_THUMBNAIL).and_then(vectorcraft_format::base64_decode) else { return false };
|
||||
let side = |at: usize| {
|
||||
head.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes).is_some_and(|v| (1..=MAX_THUMBNAIL_SIDE).contains(&v))
|
||||
};
|
||||
head.starts_with(b"\x89PNG\r\n\x1a\n") && head.get(12..16) == Some(b"IHDR".as_slice()) && side(16) && side(20)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn library_paths(dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
|
|
@ -234,6 +390,14 @@ fn remove_file(_: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Library `name` as a file name without its extension: letters, digits, spaces, `-` and `_`
|
||||
/// kept, other characters as `-` ("Library" when nothing is left).
|
||||
pub fn file_stem_for(name: &str) -> String {
|
||||
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
let safe = safe.trim();
|
||||
if safe.is_empty() { "Library".to_string() } else { safe.to_string() }
|
||||
}
|
||||
|
||||
fn stem_of(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned())
|
||||
}
|
||||
|
|
@ -260,6 +424,16 @@ impl Kind {
|
|||
_ => return Err(bad(cmd, "kind must be graphic, fillColor, strokeColor, charStyle or paraStyle")),
|
||||
})
|
||||
}
|
||||
|
||||
/// The kind as a group names it (fill and stroke colours are one list).
|
||||
fn item_kind(self) -> ItemKind {
|
||||
match self {
|
||||
Kind::Graphic => ItemKind::Graphic,
|
||||
Kind::FillColor | Kind::StrokeColor => ItemKind::Color,
|
||||
Kind::CharStyle => ItemKind::CharStyle,
|
||||
Kind::ParaStyle => ItemKind::ParaStyle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
|
@ -269,7 +443,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Libraries",
|
||||
[],
|
||||
None,
|
||||
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
|
||||
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles, groups} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
|
|
@ -278,7 +452,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}]}",
|
||||
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}], groups: [{name, items: [{kind: color|charStyle|paraStyle|graphic, item: name (graphic: id)}]}] (an item is in one group at most; the others are ungrouped)}",
|
||||
always,
|
||||
get
|
||||
),
|
||||
|
|
@ -336,7 +510,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
use_item
|
||||
),
|
||||
cmd!("library.removeItem", "Delete Library Item", [], None, "{library?, kind, item: name (graphic: id or name)} → null", always, remove_item),
|
||||
cmd!(
|
||||
"library.removeItem",
|
||||
"Delete Library Item",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name)} delete an item (out of its group too) → null",
|
||||
always,
|
||||
remove_item
|
||||
),
|
||||
cmd!(
|
||||
"library.createGroup",
|
||||
"New Library Group",
|
||||
[],
|
||||
None,
|
||||
"{library?, name?: \"Group\" (a name the library's groups have, ignoring case, gets a number), items?: [{kind, item}] (moved into it out of their groups)} a new group of items, after the others → {library, group: its name}",
|
||||
always,
|
||||
create_group
|
||||
),
|
||||
cmd!(
|
||||
"library.renameGroup",
|
||||
"Rename Library Group",
|
||||
[],
|
||||
None,
|
||||
"{library?, group: name, name} rename a group (another group's name, ignoring case, is an error) → {library, group: the new name}",
|
||||
always,
|
||||
rename_group
|
||||
),
|
||||
cmd!(
|
||||
"library.deleteGroup",
|
||||
"Delete Library Group",
|
||||
[],
|
||||
None,
|
||||
"{library?, group: name} delete a group: its items stay in the library, ungrouped → {library, ungrouped: how many}",
|
||||
always,
|
||||
delete_group
|
||||
),
|
||||
cmd!(
|
||||
"library.moveItem",
|
||||
"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}",
|
||||
always,
|
||||
move_item
|
||||
),
|
||||
cmd!(
|
||||
query "library.export",
|
||||
"Export Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library), path?} write a library (its colours, styles, graphics with their art and thumbnails, and groups) as a .vclibrary file (JSON, as libraries are kept) → {path, name}; without path → {data: the file's text, name}",
|
||||
always,
|
||||
export
|
||||
),
|
||||
cmd!(
|
||||
query "library.import",
|
||||
"Import Library",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: file text | dataBase64?, name?: the library's name (default: the file's)} add the library of a .vclibrary file (as library.export writes) as a new library, made the current one; a name another library has gets a number (`Brand (2)`) → {id, name, graphics, colors, charStyles, paraStyles, groups (counts)}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -362,6 +598,7 @@ fn counts(lib: &Library) -> Value {
|
|||
"colors": lib.colors.len(),
|
||||
"charStyles": lib.char_styles.len(),
|
||||
"paraStyles": lib.para_styles.len(),
|
||||
"groups": lib.groups.len(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -391,6 +628,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"colors": lib.colors.iter().map(|c| json!({"name": c.name, "hex": c.color.to_hex(), "color": c.color})).collect::<Vec<_>>(),
|
||||
"charStyles": lib.char_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
"paraStyles": lib.para_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
"groups": lib.groups,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -457,7 +695,8 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
let base = given.clone().unwrap_or_else(|| g.name.clone());
|
||||
g.name = unique(&base, |n| lib.graphics.iter().any(|o| o.name == n));
|
||||
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n)).replace(' ', "-");
|
||||
// Ids are `graphic`, `graphic-2`…: taken ones are looked for as they are kept.
|
||||
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n.replace(' ', "-"))).replace(' ', "-");
|
||||
let v = json!({ "name": g.name, "id": g.id, "existing": false });
|
||||
lib.graphics.push(g);
|
||||
v
|
||||
|
|
@ -682,22 +921,171 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let item = item.to_string();
|
||||
s.libraries.change(&id, |lib| {
|
||||
let before = (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len());
|
||||
let r = lib.item_ref(kind.item_kind(), &item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
|
||||
match kind {
|
||||
Kind::Graphic => {
|
||||
let at = lib.graphics.iter().position(|g| g.id == item).or_else(|| lib.graphics.iter().position(|g| g.name == item));
|
||||
if let Some(i) = at {
|
||||
lib.graphics.remove(i);
|
||||
}
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != item),
|
||||
Kind::CharStyle => lib.char_styles.retain(|t| t.name != item),
|
||||
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != item),
|
||||
}
|
||||
if before == (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()) {
|
||||
return Err(bad(C, format!("the library has no item `{item}`")));
|
||||
Kind::Graphic => lib.graphics.retain(|g| g.id != r.item),
|
||||
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != r.item),
|
||||
Kind::CharStyle => lib.char_styles.retain(|t| t.name != r.item),
|
||||
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != r.item),
|
||||
}
|
||||
lib.ungroup(&r);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `p`'s `name`, trimmed and clipped, else `default`.
|
||||
fn name_param(p: &Value, default: &str) -> String {
|
||||
let mut name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(default).to_string();
|
||||
clip(&mut name);
|
||||
name
|
||||
}
|
||||
|
||||
/// `p`'s `group`.
|
||||
fn group_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||||
str_param(p, "group").ok_or_else(|| bad(cmd, "missing `group`"))
|
||||
}
|
||||
|
||||
/// Group `name` of `lib`: its index.
|
||||
fn group_at(lib: &Library, name: &str, cmd: &str) -> Result<usize> {
|
||||
lib.group_position(name).ok_or_else(|| bad(cmd, format!("the library has no group `{name}`")))
|
||||
}
|
||||
|
||||
fn create_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.createGroup";
|
||||
let id = library_id(s, p, C)?;
|
||||
let base = name_param(p, "Group");
|
||||
let wanted = match p.get("items") {
|
||||
None | Some(Value::Null) => vec![],
|
||||
Some(Value::Array(items)) if items.len() <= 4 * MAX_ITEMS => items
|
||||
.iter()
|
||||
.map(|v| Ok((Kind::of(v, C)?.item_kind(), str_param(v, "item").ok_or_else(|| bad(C, "each of `items` is {kind, item}"))?)))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
Some(_) => return Err(bad(C, "`items` is a list of {kind, item}")),
|
||||
};
|
||||
let name = s.libraries.change(&id, |lib| {
|
||||
if lib.groups.len() >= MAX_GROUPS {
|
||||
return Err(bad(C, format!("the library holds {MAX_GROUPS} groups already")));
|
||||
}
|
||||
let refs = wanted
|
||||
.iter()
|
||||
.map(|&(kind, item)| lib.item_ref(kind, item).ok_or_else(|| bad(C, format!("the library has no item `{item}`"))))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let name = unique(&base, |n| lib.group_position(n).is_some());
|
||||
let mut items = vec![];
|
||||
let mut seen = HashSet::new();
|
||||
for r in refs {
|
||||
lib.ungroup(&r);
|
||||
if seen.insert(r.clone()) {
|
||||
items.push(r);
|
||||
}
|
||||
}
|
||||
lib.groups.push(LibraryGroup { name: name.clone(), items });
|
||||
Ok(name)
|
||||
})?;
|
||||
Ok(json!({ "library": id, "group": name }))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.renameGroup";
|
||||
let id = library_id(s, p, C)?;
|
||||
let group = group_param(p, C)?;
|
||||
if str_param(p, "name").is_none_or(|n| n.trim().is_empty()) {
|
||||
return Err(bad(C, "missing `name`"));
|
||||
}
|
||||
let name = name_param(p, "");
|
||||
s.libraries.change(&id, |lib| {
|
||||
let i = group_at(lib, group, C)?;
|
||||
if lib.group_position(&name).is_some_and(|j| j != i) {
|
||||
return Err(bad(C, format!("the library has a group `{name}` already")));
|
||||
}
|
||||
if let Some(g) = lib.groups.get_mut(i) {
|
||||
g.name.clone_from(&name);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "library": id, "group": name }))
|
||||
}
|
||||
|
||||
fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.deleteGroup";
|
||||
let id = library_id(s, p, C)?;
|
||||
let group = group_param(p, C)?;
|
||||
let ungrouped = s.libraries.change(&id, |lib| {
|
||||
let i = group_at(lib, group, C)?;
|
||||
Ok(if i < lib.groups.len() { lib.groups.remove(i).items.len() } else { 0 })
|
||||
})?;
|
||||
Ok(json!({ "library": id, "ungrouped": ungrouped }))
|
||||
}
|
||||
|
||||
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 (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 at = group.map(|g| group_at(lib, g, C)).transpose()?;
|
||||
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);
|
||||
g.name.clone()
|
||||
});
|
||||
Ok((key, to))
|
||||
})?;
|
||||
Ok(json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "group": to }))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.export";
|
||||
let id = library_id(s, p, C)?;
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
|
||||
let text = serde_json::to_string(lib).map_err(|e| bad(C, e.to_string()))?;
|
||||
let name = lib.name.clone();
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
super::fileio::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({ "path": path, "name": name }))
|
||||
}
|
||||
None => Ok(json!({ "data": text, "name": name })),
|
||||
}
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.import";
|
||||
let too_big = || bad(C, format!("the file is larger than {} MB", MAX_FILE >> 20));
|
||||
let bytes: std::borrow::Cow<[u8]> = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
(Some(path), ..) => {
|
||||
if super::fileio::file_stamp(path).is_some_and(|(len, _)| len > MAX_FILE) {
|
||||
return Err(too_big());
|
||||
}
|
||||
super::fileio::read_file(path)?.into()
|
||||
}
|
||||
(None, Some(text), _) => text.as_bytes().into(),
|
||||
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
|
||||
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
|
||||
};
|
||||
if bytes.len() as u64 > MAX_FILE {
|
||||
return Err(too_big());
|
||||
}
|
||||
let not_library = |e: String| bad(C, format!("not a library file: {e}"));
|
||||
let v: Value = serde_json::from_slice(&bytes).map_err(|e| not_library(e.to_string()))?;
|
||||
drop(bytes);
|
||||
// A library names itself; another file of ours (swatches, styles) says what it is.
|
||||
if !v.get("name").is_some_and(Value::is_string) || v.get("format").is_some_and(|f| f.as_str() != Some(LIBRARY_EXT)) {
|
||||
return Err(not_library("it has no library name".into()));
|
||||
}
|
||||
let mut lib: Library = serde_json::from_value(v).map_err(|e| not_library(e.to_string()))?;
|
||||
lib.sanitize();
|
||||
let base = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(lib.name.trim()).to_string();
|
||||
let mut base = if base.is_empty() { "Library".to_string() } else { base };
|
||||
clip(&mut base);
|
||||
let taken = |n: &str| s.libraries.all().iter().any(|(_, l)| l.name.to_lowercase() == n.to_lowercase());
|
||||
lib.name = std::iter::once(base.clone()).chain((2..).map(|n| format!("{base} ({n})"))).find(|n| !taken(n)).unwrap_or(base);
|
||||
let mut r = json!({ "name": lib.name });
|
||||
if let (Some(o), Value::Object(c)) = (r.as_object_mut(), counts(&lib)) {
|
||||
o.extend(c);
|
||||
}
|
||||
r["id"] = json!(s.libraries.insert(lib)?);
|
||||
Ok(r)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,3 +161,181 @@ fn libraries_are_kept_in_files_across_sessions() {
|
|||
assert_eq!(run(&mut v, "library.list", json!({}))["libraries"], json!([]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A library "Brand" holding a graphic, a colour and a character style, each added from the
|
||||
/// selection → (session, the graphic's id, the colour's name, the style's name).
|
||||
fn brand() -> (Session, String, String, String) {
|
||||
let mut s = session();
|
||||
run(&mut s, "library.create", json!({"name": "Brand"}));
|
||||
let r = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#336699", "ids": [r]}));
|
||||
run(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let g = run(&mut s, "library.add", json!({"kind": "graphic", "name": "Logo"}))["id"].as_str().unwrap().to_string();
|
||||
let c = run(&mut s, "library.add", json!({"kind": "fillColor"}))["name"].as_str().unwrap().to_string();
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Type", "size": 18}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let st = run(&mut s, "library.add", json!({"kind": "charStyle"}))["name"].as_str().unwrap().to_string();
|
||||
(s, g, c, st)
|
||||
}
|
||||
|
||||
/// The current library's groups as `library.get` reports them: (name, [(kind, item)]).
|
||||
fn groups(s: &mut Session) -> Vec<(String, Vec<(String, String)>)> {
|
||||
let got = run(s, "library.get", json!({}));
|
||||
got["groups"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let items = g["items"].as_array().unwrap().iter().map(|r| (r["kind"].as_str().unwrap().into(), r["item"].as_str().unwrap().into()));
|
||||
(g["name"].as_str().unwrap().to_string(), items.collect())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refs(v: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
v.iter().map(|(k, i)| (k.to_string(), i.to_string())).collect()
|
||||
}
|
||||
|
||||
/// Graphics added one after another get ids of their own (the third used to repeat the second's).
|
||||
#[test]
|
||||
fn every_graphic_gets_an_id_of_its_own() {
|
||||
let mut s = session();
|
||||
run(&mut s, "library.create", json!({}));
|
||||
let mut ids = vec![];
|
||||
for x in [0, 30, 60, 90] {
|
||||
let r = run(&mut s, "shape.rectangle", json!({"x": x, "y": 0, "width": 10 + x, "height": 10}))["id"].clone();
|
||||
ids.push(run(&mut s, "library.add", json!({"kind": "graphic", "ids": [r]}))["id"].as_str().unwrap().to_string());
|
||||
}
|
||||
assert_eq!(ids, ["graphic", "graphic-2", "graphic-3", "graphic-4"]);
|
||||
}
|
||||
|
||||
/// Items go into user-named groups and back out (#926); deleting a group keeps its items.
|
||||
#[test]
|
||||
fn items_are_grouped_moved_and_ungrouped() {
|
||||
let (mut s, g, c, st) = brand();
|
||||
assert!(groups(&mut s).is_empty(), "no groups yet");
|
||||
// A group made with items; a graphic is named by its name or its id.
|
||||
let r = run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": "Logo"}]}));
|
||||
assert_eq!(r["group"], "Logos");
|
||||
assert_eq!(groups(&mut s), [("Logos".into(), refs(&[("graphic", &g)]))]);
|
||||
// A name taken (ignoring case) gets a number.
|
||||
assert_eq!(run(&mut s, "library.createGroup", json!({"name": "logos"}))["group"], "logos 2");
|
||||
run(&mut s, "library.renameGroup", json!({"group": "logos 2", "name": "Palette"}));
|
||||
assert!(s.execute("library.renameGroup", &json!({"group": "Palette", "name": "LOGOS"})).is_err(), "another group's name");
|
||||
assert!(s.execute("library.renameGroup", &json!({"group": "Nope", "name": "X"})).is_err());
|
||||
// Moving an item takes it out of the group it was in; a colour is named as fill or stroke.
|
||||
run(&mut s, "library.moveItem", json!({"kind": "strokeColor", "item": c, "group": "palette"}));
|
||||
run(&mut s, "library.moveItem", json!({"kind": "charStyle", "item": st, "group": "Palette"}));
|
||||
let moved = run(&mut s, "library.moveItem", json!({"kind": "graphic", "item": "Logo", "group": "Palette"}));
|
||||
assert_eq!((moved["item"].as_str(), moved["group"].as_str()), (Some(g.as_str()), Some("Palette")));
|
||||
assert_eq!(groups(&mut s), [("Logos".into(), vec![]), ("Palette".into(), refs(&[("color", &c), ("charStyle", &st), ("graphic", &g)]))]);
|
||||
// Without a group, out of its group.
|
||||
assert!(run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": c}))["group"].is_null());
|
||||
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": c, "group": "Nope"})).is_err());
|
||||
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": "#nope", "group": "Palette"})).is_err());
|
||||
assert!(s.execute("library.createGroup", &json!({"items": [{"kind": "graphic", "item": "nope"}]})).is_err(), "no such item");
|
||||
assert_eq!(groups(&mut s).len(), 2, "a failed command makes no group");
|
||||
// A deleted item leaves its group.
|
||||
run(&mut s, "library.removeItem", json!({"kind": "charStyle", "item": st}));
|
||||
assert_eq!(groups(&mut s)[1].1, refs(&[("graphic", &g)]));
|
||||
// A deleted group's items stay, ungrouped.
|
||||
assert_eq!(run(&mut s, "library.deleteGroup", json!({"group": "Palette"}))["ungrouped"], 1);
|
||||
assert_eq!(run(&mut s, "library.get", json!({}))["graphics"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(groups(&mut s), [("Logos".into(), vec![])]);
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"][0]["groups"], 1);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn a_library_is_exported_and_imported() {
|
||||
let (mut s, g, c, _) = brand();
|
||||
run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}, {"kind": "fillColor", "item": c}]}));
|
||||
let out = run(&mut s, "library.export", json!({}));
|
||||
assert_eq!(out["name"], "Brand");
|
||||
let data = out["data"].as_str().unwrap().to_string();
|
||||
let r = run(&mut s, "library.import", json!({"data": data}));
|
||||
let counts = ["graphics", "colors", "charStyles", "paraStyles", "groups"].map(|k| r[k].as_u64().unwrap());
|
||||
assert_eq!((r["name"].as_str(), counts), (Some("Brand (2)"), [1, 1, 1, 0, 1]));
|
||||
let id = r["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["current"], id, "the imported library is the current one");
|
||||
let a = run(&mut s, "library.get", json!({"library": "Brand"}));
|
||||
let b = run(&mut s, "library.get", json!({"library": id}));
|
||||
for k in ["graphics", "colors", "charStyles", "paraStyles", "groups"] {
|
||||
assert_eq!(a[k], b[k], "{k}");
|
||||
}
|
||||
// Again: the next number; given a name, that name.
|
||||
let b64 = vectorcraft_format::base64_encode(data.as_bytes());
|
||||
assert_eq!(run(&mut s, "library.import", json!({"dataBase64": b64}))["name"], "Brand (3)");
|
||||
assert_eq!(run(&mut s, "library.import", json!({"data": data, "name": "Client"}))["name"], "Client");
|
||||
// Its graphic places in another document.
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
let placed = run(&mut s, "library.use", json!({"library": id, "kind": "graphic", "item": "Logo", "center": [50, 50]}));
|
||||
assert_eq!(placed["ids"].as_array().unwrap().len(), 1);
|
||||
// Not a library file.
|
||||
let swatches = json!({"format": "vcswatches", "name": "S"}).to_string();
|
||||
for bad in [json!({"data": "{not json"}), json!({"data": "[1, 2]"}), json!({"data": swatches}), json!({"dataBase64": "%%"}), json!({})] {
|
||||
assert!(s.execute("library.import", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file written before groups loads; one with repeated names, groups naming items that aren't
|
||||
/// there or kinds this version doesn't know, and a thumbnail claiming a huge image is made safe.
|
||||
#[test]
|
||||
fn imported_libraries_are_made_consistent() {
|
||||
let mut s = session();
|
||||
let old = json!({"name": "Old", "colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}]});
|
||||
let r = run(&mut s, "library.import", json!({"data": old.to_string()}));
|
||||
assert_eq!((r["colors"].as_u64(), r["groups"].as_u64()), (Some(1), Some(0)));
|
||||
// A PNG header claiming 100000 × 100000 pixels.
|
||||
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
png.extend(100_000u32.to_be_bytes());
|
||||
png.extend(100_000u32.to_be_bytes());
|
||||
png.extend([8, 6, 0, 0, 0]);
|
||||
let messy = json!({
|
||||
"name": " ",
|
||||
"colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}, {"name": "Red", "color": {"model": "rgb", "r": 0, "g": 1, "b": 0}}, {"name": "", "color": {"model": "rgb", "r": 0, "g": 0, "b": 1}}],
|
||||
"graphics": [
|
||||
{"id": "g", "name": "A", "width": 1, "height": 1, "data": "", "thumbnail": vectorcraft_format::base64_encode(&png)},
|
||||
{"id": "g", "name": "B", "width": -5, "height": 1, "data": ""}
|
||||
],
|
||||
"groups": [
|
||||
{"name": "One", "items": [{"kind": "color", "item": "Red 2"}, {"kind": "graphic", "item": "nope"}, {"kind": "brush", "item": "Red"}]},
|
||||
{"name": "one", "items": [{"kind": "color", "item": "Red 2"}, {"kind": "graphic", "item": "g-2"}]}
|
||||
]
|
||||
});
|
||||
let r = run(&mut s, "library.import", json!({"data": messy.to_string()}));
|
||||
assert_eq!(r["name"], "Library", "a blank name");
|
||||
let got = run(&mut s, "library.get", json!({}));
|
||||
let names: Vec<&str> = got["colors"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Red", "Red 2", "Color"]);
|
||||
let graphics = got["graphics"].as_array().unwrap();
|
||||
assert_eq!((graphics[0]["id"].as_str(), graphics[1]["id"].as_str()), (Some("g"), Some("g-2")));
|
||||
assert_eq!((graphics[0]["thumbnail"].as_str(), graphics[1]["width"].as_f64()), (Some(""), Some(0.0)));
|
||||
assert_eq!(
|
||||
got["groups"],
|
||||
json!([{"name": "One", "items": [{"kind": "color", "item": "Red 2"}]}, {"name": "one 2", "items": [{"kind": "graphic", "item": "g-2"}]}])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_library_is_exported_to_a_file_and_imported_from_it() {
|
||||
let dir = vectorcraft_testkit::temp_dir("library-export");
|
||||
let path = dir.join("Brand.vclibrary").to_string_lossy().to_string();
|
||||
let (mut s, g, ..) = brand();
|
||||
run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}]}));
|
||||
assert_eq!(run(&mut s, "library.export", json!({"library": "Brand", "path": path}))["path"], path.as_str());
|
||||
let mut t = session();
|
||||
let r = run(&mut t, "library.import", json!({"path": path}));
|
||||
assert_eq!((r["name"].as_str(), r["graphics"].as_u64(), r["groups"].as_u64()), (Some("Brand"), Some(1), Some(1)));
|
||||
assert!(t.execute("library.import", &json!({"path": dir.join("missing.vclibrary").to_string_lossy()})).is_err());
|
||||
// Groups are kept in the library folder's files too.
|
||||
let folder = dir.join("folder").to_string_lossy().to_string();
|
||||
let mut u = session();
|
||||
u.libraries.set_dir(Some(folder.clone()));
|
||||
run(&mut u, "library.import", json!({"path": path}));
|
||||
let mut v = session();
|
||||
v.libraries.set_dir(Some(folder));
|
||||
assert_eq!(groups(&mut v), [("Logos".into(), refs(&[("graphic", &g)]))]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -804,6 +804,53 @@ 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).
|
||||
fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
|
||||
survive_library(what, "library.import", data, |s, r| {
|
||||
let lib = r["id"].clone();
|
||||
let Ok(got) = no_panic(s.execute("library.get", &json!({"library": lib}))) else { return };
|
||||
let _ = no_panic(s.execute("select.all", &json!({})));
|
||||
for (list, kind, key) in
|
||||
[("graphics", "graphic", "id"), ("colors", "fillColor", "name"), ("charStyles", "charStyle", "name"), ("paraStyles", "paraStyle", "name")]
|
||||
{
|
||||
if let Some(item) = got[list].get(0) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
if let Some(g) = got["groups"].get(0) {
|
||||
let _ = no_panic(s.execute("library.deleteGroup", &json!({"library": lib, "group": g["name"]})));
|
||||
}
|
||||
let _ = no_panic(s.execute("library.export", &json!({"library": lib})));
|
||||
})
|
||||
}
|
||||
|
||||
/// A Libraries panel library of the rich document's first object (a graphic), a colour, its
|
||||
/// type's character and paragraph styles and a group, exported, made once.
|
||||
fn saved_library() -> String {
|
||||
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
TEXT.get_or_init(|| {
|
||||
let mut s = rich_session();
|
||||
s.execute("library.create", &json!({"name": "Fuzz"})).unwrap();
|
||||
let first = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
let g = s.execute("library.add", &json!({"kind": "graphic", "ids": [first]})).unwrap()["id"].clone();
|
||||
s.execute("library.add", &json!({"kind": "fillColor", "color": "#336699"})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
for kind in ["charStyle", "paraStyle"] {
|
||||
s.execute("library.add", &json!({"kind": kind})).unwrap();
|
||||
}
|
||||
s.execute(
|
||||
"library.createGroup",
|
||||
&json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}, {"kind": "fillColor", "item": "#336699"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("library.export", &json!({})).unwrap()["data"].as_str().unwrap().to_string()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn flattener_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
|
||||
survive_library(what, "flattener.presets.import", data, |s, r| {
|
||||
let _ = no_panic(s.execute("select.all", &json!({})));
|
||||
|
|
@ -865,11 +912,12 @@ proptest! {
|
|||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", "])) {
|
||||
fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", ", "{\"name\": \"L\", "])) {
|
||||
let data = format!("{head}{s}");
|
||||
swatches("swatch library garbage", &data)?;
|
||||
styles("style library garbage", &data)?;
|
||||
flattener_presets("flattener preset garbage", &data)?;
|
||||
libraries("library garbage", &data)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -890,6 +938,14 @@ proptest! {
|
|||
flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_libraries_never_panic(keep in 0.0f64..1.2, edits in prop::collection::vec(arb_edit(), 0..10)) {
|
||||
// Cut anywhere in the file (whole most of the time), the edits anywhere in it.
|
||||
let text = saved_library();
|
||||
let cut = (text.chars().count() as f64 * keep) as usize;
|
||||
libraries("mutated library", &mutate_text(&text, cut, &edits))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_exchange_garbage_never_panics(blocks in prop::collection::vec(arb_ase_block(), 0..12), tail in prop::collection::vec(any::<u8>(), 0..16)) {
|
||||
let mut bytes = b"ASEF\0\x01\0\0".to_vec();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! The generic parameter dialog (`ui.paramDialog`): edits a command's parameters (`__command`,
|
||||
//! headed `__label`) and runs it on OK.
|
||||
//! headed `__label`) and runs it on OK, with the fixed ones in `__params` (not shown) added.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
|
@ -60,7 +60,10 @@ const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("
|
|||
/// Closes before running, so a dialog the command opens stays open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
let params = form::params(d);
|
||||
let mut params = form::params(d);
|
||||
if let (Some(o), Some(Value::Object(fixed))) = (params.as_object_mut(), d.fields.get("__params")) {
|
||||
o.extend(fixed.clone());
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
// A tool's click-to-size shape (Flare) goes on the active perspective plane while the grid shows.
|
||||
let at = |x: &str, y: &str| Some(vectorcraft_geom::Point::new(params.get(x)?.as_f64()?, params.get(y)?.as_f64()?));
|
||||
|
|
|
|||
|
|
@ -2760,6 +2760,23 @@
|
|||
Add to Library Añadir a la biblioteca
|
||||
Delete Library Eliminar biblioteca
|
||||
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.
|
||||
New Group Nuevo grupo
|
||||
New Group… Nuevo grupo…
|
||||
Move to Group Mover al grupo
|
||||
No Group Sin grupo
|
||||
Rename Group… Cambiar nombre del grupo…
|
||||
Delete Group Eliminar grupo
|
||||
Import Library… Importar biblioteca…
|
||||
Export Library… Exportar biblioteca…
|
||||
Import Library Importar biblioteca
|
||||
Export Library Exportar biblioteca
|
||||
New Library Group Nuevo grupo de biblioteca
|
||||
Rename Library Group Cambiar nombre del grupo de biblioteca
|
||||
Delete Library Group Eliminar grupo de biblioteca
|
||||
Move Library Item to Group Mover elemento de la biblioteca al grupo
|
||||
Library Biblioteca
|
||||
Rename Library Cambiar nombre de la biblioteca
|
||||
Show Library Mostrar biblioteca
|
||||
|
|
@ -3881,3 +3898,4 @@ axis Both Ambos
|
|||
Even Fields Campos pares
|
||||
Duplication Duplicación
|
||||
Interpolation Interpolación
|
||||
@msg Imported library {name} Biblioteca {name} importada
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2757,6 +2757,23 @@
|
|||
Add to Library Ajouter à la bibliothèque
|
||||
Delete Library Supprimer la bibliothèque
|
||||
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.
|
||||
New Group Nouveau groupe
|
||||
New Group… Nouveau groupe…
|
||||
Move to Group Déplacer vers le groupe
|
||||
No Group Aucun groupe
|
||||
Rename Group… Renommer le groupe…
|
||||
Delete Group Supprimer le groupe
|
||||
Import Library… Importer une bibliothèque…
|
||||
Export Library… Exporter la bibliothèque…
|
||||
Import Library Importer une bibliothèque
|
||||
Export Library Exporter la bibliothèque
|
||||
New Library Group Nouveau groupe de bibliothèque
|
||||
Rename Library Group Renommer le groupe de bibliothèque
|
||||
Delete Library Group Supprimer le groupe de bibliothèque
|
||||
Move Library Item to Group Déplacer l’élément de la bibliothèque vers un groupe
|
||||
Library Bibliothèque
|
||||
Rename Library Renommer la bibliothèque
|
||||
Show Library Afficher la bibliothèque
|
||||
|
|
@ -3892,3 +3909,4 @@ axis Both Les deux
|
|||
Even Fields Trames paires
|
||||
Duplication Duplication
|
||||
Interpolation Interpolation
|
||||
@msg Imported library {name} Bibliothèque {name} importée
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2760,6 +2760,23 @@
|
|||
Add to Library Aggiungi alla libreria
|
||||
Delete Library Elimina libreria
|
||||
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.
|
||||
New Group Nuovo gruppo
|
||||
New Group… Nuovo gruppo…
|
||||
Move to Group Sposta nel gruppo
|
||||
No Group Nessun gruppo
|
||||
Rename Group… Rinomina gruppo…
|
||||
Delete Group Elimina gruppo
|
||||
Import Library… Importa libreria…
|
||||
Export Library… Esporta libreria…
|
||||
Import Library Importa libreria
|
||||
Export Library Esporta libreria
|
||||
New Library Group Nuovo gruppo di libreria
|
||||
Rename Library Group Rinomina gruppo di libreria
|
||||
Delete Library Group Elimina gruppo di libreria
|
||||
Move Library Item to Group Sposta elemento della libreria nel gruppo
|
||||
Library Libreria
|
||||
Rename Library Rinomina libreria
|
||||
Show Library Mostra libreria
|
||||
|
|
@ -3881,3 +3898,4 @@ axis Both Entrambi
|
|||
Even Fields Campi pari
|
||||
Duplication Duplicazione
|
||||
Interpolation Interpolazione
|
||||
@msg Imported library {name} Libreria {name} importata
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2496,6 +2496,23 @@
|
|||
Add to Library ライブラリに追加
|
||||
Delete Library ライブラリを削除
|
||||
Delete Library Item ライブラリ項目を削除
|
||||
No results 結果はありません
|
||||
No items in this library match “{query}”. このライブラリには「{query}」に一致する項目がありません。
|
||||
Right-click an item to move it into this group. 項目を右クリックすると、このグループに移動できます。
|
||||
New Group 新規グループ
|
||||
New Group… 新規グループ…
|
||||
Move to Group グループに移動
|
||||
No Group グループなし
|
||||
Rename Group… グループ名を変更…
|
||||
Delete Group グループを削除
|
||||
Import Library… ライブラリを読み込み…
|
||||
Export Library… ライブラリを書き出し…
|
||||
Import Library ライブラリを読み込み
|
||||
Export Library ライブラリを書き出し
|
||||
New Library Group 新規ライブラリグループ
|
||||
Rename Library Group ライブラリグループ名を変更
|
||||
Delete Library Group ライブラリグループを削除
|
||||
Move Library Item to Group ライブラリ項目をグループに移動
|
||||
Library ライブラリ
|
||||
Rename Library ライブラリ名を変更
|
||||
Show Library ライブラリを表示
|
||||
|
|
@ -3873,3 +3890,4 @@ theme Appearance 外観
|
|||
Even Fields 偶数フィールド
|
||||
Duplication 複製
|
||||
Interpolation 補間
|
||||
@msg Imported library {name} ライブラリ {name} を読み込みました
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2756,6 +2756,23 @@
|
|||
Add to Library Добавить в библиотеку
|
||||
Delete Library Удалить библиотеку
|
||||
Delete Library Item Удалить элемент библиотеки
|
||||
No results Ничего не найдено
|
||||
No items in this library match “{query}”. В этой библиотеке нет элементов, соответствующих «{query}».
|
||||
Right-click an item to move it into this group. Щёлкните элемент правой кнопкой мыши, чтобы переместить его в эту группу.
|
||||
New Group Новая группа
|
||||
New Group… Новая группа…
|
||||
Move to Group Переместить в группу
|
||||
No Group Без группы
|
||||
Rename Group… Переименовать группу…
|
||||
Delete Group Удалить группу
|
||||
Import Library… Импортировать библиотеку…
|
||||
Export Library… Экспортировать библиотеку…
|
||||
Import Library Импортировать библиотеку
|
||||
Export Library Экспортировать библиотеку
|
||||
New Library Group Новая группа библиотеки
|
||||
Rename Library Group Переименовать группу библиотеки
|
||||
Delete Library Group Удалить группу библиотеки
|
||||
Move Library Item to Group Переместить элемент библиотеки в группу
|
||||
Library Библиотека
|
||||
Rename Library Переименовать библиотеку
|
||||
Show Library Показать библиотеку
|
||||
|
|
@ -3879,3 +3896,4 @@ axis Both Оба
|
|||
Even Fields Чётные поля
|
||||
Duplication Дублированием
|
||||
Interpolation Интерполяцией
|
||||
@msg Imported library {name} Импортирована библиотека {name}
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2752,6 +2752,23 @@
|
|||
Add to Library Додати до бібліотеки
|
||||
Delete Library Видалити бібліотеку
|
||||
Delete Library Item Видалити елемент бібліотеки
|
||||
No results Нічого не знайдено
|
||||
No items in this library match “{query}”. У цій бібліотеці немає елементів, що відповідають «{query}».
|
||||
Right-click an item to move it into this group. Клацніть елемент правою кнопкою миші, щоб перемістити його в цю групу.
|
||||
New Group Нова група
|
||||
New Group… Нова група…
|
||||
Move to Group Перемістити в групу
|
||||
No Group Без групи
|
||||
Rename Group… Перейменувати групу…
|
||||
Delete Group Видалити групу
|
||||
Import Library… Імпортувати бібліотеку…
|
||||
Export Library… Експортувати бібліотеку…
|
||||
Import Library Імпортувати бібліотеку
|
||||
Export Library Експортувати бібліотеку
|
||||
New Library Group Нова група бібліотеки
|
||||
Rename Library Group Перейменувати групу бібліотеки
|
||||
Delete Library Group Видалити групу бібліотеки
|
||||
Move Library Item to Group Перемістити елемент бібліотеки в групу
|
||||
Library Бібліотека
|
||||
Rename Library Перейменувати бібліотеку
|
||||
Show Library Показати бібліотеку
|
||||
|
|
@ -3864,3 +3881,4 @@ axis Both Обидві
|
|||
Even Fields Парні поля
|
||||
Duplication Дублюванням
|
||||
Interpolation Інтерполяцією
|
||||
@msg Imported library {name} Імпортовано бібліотеку {name}
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -2652,6 +2652,23 @@
|
|||
Add to Library 添加到库
|
||||
Delete Library 删除库
|
||||
Delete Library Item 删除库项目
|
||||
No results 无结果
|
||||
No items in this library match “{query}”. 此库中没有与“{query}”匹配的项目。
|
||||
Right-click an item to move it into this group. 右键单击项目可将其移到此组。
|
||||
New Group 新建组
|
||||
New Group… 新建组…
|
||||
Move to Group 移到组
|
||||
No Group 无组
|
||||
Rename Group… 重命名组…
|
||||
Delete Group 删除组
|
||||
Import Library… 导入库…
|
||||
Export Library… 导出库…
|
||||
Import Library 导入库
|
||||
Export Library 导出库
|
||||
New Library Group 新建库组
|
||||
Rename Library Group 重命名库组
|
||||
Delete Library Group 删除库组
|
||||
Move Library Item to Group 将库项目移到组
|
||||
Library 库
|
||||
Rename Library 重命名库
|
||||
Show Library 显示库
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2738,6 +2738,23 @@
|
|||
Add to Library 加入資料庫
|
||||
Delete Library 刪除資料庫
|
||||
Delete Library Item 刪除資料庫項目
|
||||
No results 沒有結果
|
||||
No items in this library match “{query}”. 此資料庫中沒有符合「{query}」的項目。
|
||||
Right-click an item to move it into this group. 在項目上按一下右鍵,即可將其移至此群組。
|
||||
New Group 新增群組
|
||||
New Group… 新增群組…
|
||||
Move to Group 移至群組
|
||||
No Group 無群組
|
||||
Rename Group… 重新命名群組…
|
||||
Delete Group 刪除群組
|
||||
Import Library… 匯入資料庫…
|
||||
Export Library… 匯出資料庫…
|
||||
Import Library 匯入資料庫
|
||||
Export Library 匯出資料庫
|
||||
New Library Group 新增資料庫群組
|
||||
Rename Library Group 重新命名資料庫群組
|
||||
Delete Library Group 刪除資料庫群組
|
||||
Move Library Item to Group 將資料庫項目移至群組
|
||||
Library 資料庫
|
||||
Rename Library 重新命名資料庫
|
||||
Show Library 顯示資料庫
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -15,8 +15,8 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs};
|
|||
const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"];
|
||||
|
||||
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
|
||||
/// graphic style library files open in the library panel and flattener, PDF, print and perspective
|
||||
/// grid presets files are imported. → `document.open`'s result for a document opened now (its
|
||||
/// graphic style library files open in the library panel, Libraries panel files are added to it,
|
||||
/// and flattener, PDF, print and perspective grid presets files are imported. → `document.open`'s result for a document opened now (its
|
||||
/// `warnings` say what didn't come in as it was), else null.
|
||||
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value, String> {
|
||||
let ext = fileio::extension(name);
|
||||
|
|
@ -39,6 +39,9 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
|
|||
app.status(format!("Imported {what}: {}", names.join(", ")));
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) {
|
||||
return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null);
|
||||
}
|
||||
let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str());
|
||||
if swatches || ext == vectorcraft_doc::style_libs::STYLES_EXT {
|
||||
let p = match path {
|
||||
|
|
|
|||
|
|
@ -4,16 +4,20 @@
|
|||
//! graphic, fill or stroke colour or text styles. A colour click paints the selection through the
|
||||
//! active Fill/Stroke proxy, a style click applies it to the selected type, and a graphic is
|
||||
//! dragged onto the canvas (or double-clicked) to place a copy. Libraries are kept on this machine.
|
||||
//!
|
||||
//! 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).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_engine::cmd::library::Library;
|
||||
use vectorcraft_engine::cmd::library::{ItemKind, LIBRARY_EXT, LIBRARY_EXTS, Library, LibraryGraphic};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, PanelDrag, menu_item};
|
||||
use crate::widgets::{self, PanelDrag, menu_item, menu_item_name};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
/// The size of a graphic's tile.
|
||||
|
|
@ -21,20 +25,28 @@ const TILE: f32 = 56.0;
|
|||
/// The size of a colour chip.
|
||||
const CHIP: f32 = 22.0;
|
||||
|
||||
/// The current library: its id and a copy of it (the panel acts on it after reading).
|
||||
/// 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)> {
|
||||
let libs = &app.session.libraries;
|
||||
let id = libs.current()?;
|
||||
Some((id.to_string(), libs.get(id)?.clone()))
|
||||
}
|
||||
|
||||
/// Run `cmd`, reporting a failure in the status bar.
|
||||
fn run(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
if let Err(e) = app.run(cmd, p) {
|
||||
/// Report a failure in the status bar (a file dialog closed or still open says nothing).
|
||||
fn report<T>(app: &mut VectorcraftApp, r: Result<T, String>) {
|
||||
if let Err(e) = r
|
||||
&& e != "cancelled"
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `cmd`, reporting a failure in the status bar.
|
||||
fn run(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
let r = app.run(cmd, p);
|
||||
report(app, r);
|
||||
}
|
||||
|
||||
/// The name dialog for a new library, or for renaming the current one.
|
||||
fn ask_name(app: &mut VectorcraftApp, rename: Option<&str>) {
|
||||
let (cmd, label, name) = match rename {
|
||||
|
|
@ -44,9 +56,46 @@ fn ask_name(app: &mut VectorcraftApp, rename: Option<&str>) {
|
|||
app.ui.dialog = Some(crate::state::Dialog::new("command", json!({ "__command": cmd, "__label": label, "name": name })));
|
||||
}
|
||||
|
||||
/// The name dialog for a new group of `library` (`items`: `[{kind, item}]` moved into it), or for
|
||||
/// renaming group `rename`.
|
||||
fn ask_group_name(app: &mut VectorcraftApp, library: &str, rename: Option<&str>, items: Value) {
|
||||
let (cmd, label, name, fixed) = match rename {
|
||||
Some(g) => ("library.renameGroup", "Rename Library Group", g, json!({ "library": library, "group": g })),
|
||||
None => ("library.createGroup", "New Library Group", "Group", json!({ "library": library, "items": items })),
|
||||
};
|
||||
let d = json!({ "__command": cmd, "__label": label, "name": name, "__params": fixed });
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("command", d));
|
||||
}
|
||||
|
||||
/// What a click in the panel asks for, done once the list is drawn.
|
||||
enum Act {
|
||||
Run(&'static str, Value),
|
||||
/// The New Group dialog; the items (`[{kind, item}]`) go into the new group.
|
||||
NewGroup(Value),
|
||||
RenameGroup(String),
|
||||
}
|
||||
|
||||
/// What the item list draws from.
|
||||
struct Items<'a> {
|
||||
library: &'a str,
|
||||
lib: &'a Library,
|
||||
/// The group of each grouped item ([`Library::group_index`]).
|
||||
groups: HashMap<(ItemKind, &'a str), usize>,
|
||||
fill_active: bool,
|
||||
}
|
||||
|
||||
impl Items<'_> {
|
||||
fn group_of(&self, kind: ItemKind, key: &str) -> Option<usize> {
|
||||
self.groups.get(&(kind, key)).copied()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some((lib_id, lib)) = current(app) else {
|
||||
let fill_active = app.session.fill_active;
|
||||
let has_doc = app.session.active().is_some();
|
||||
let libs = &app.session.libraries;
|
||||
let Some((lib_id, lib)) = libs.current().and_then(|id| Some((id.to_string(), libs.get(id)?))) else {
|
||||
ui.add_space(16.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
icons::icon(ui, "library", 40.0, t.text_dim);
|
||||
|
|
@ -60,18 +109,27 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
});
|
||||
return;
|
||||
};
|
||||
let mut act: Option<Act> = None;
|
||||
// The library picker.
|
||||
let libs: Vec<(String, String)> = app.session.libraries.all().iter().map(|(i, l)| (i.clone(), l.name.clone())).collect();
|
||||
let names: Vec<&str> = libs.iter().map(|(_, n)| n.as_str()).collect();
|
||||
if let Some(i) = widgets::dropdown_names(ui, "lib-picker", &lib.name, &names, ui.available_width())
|
||||
&& let Some((id, _)) = libs.get(i)
|
||||
{
|
||||
run(app, "library.setCurrent", json!({ "library": id }));
|
||||
let names: Vec<&str> = libs.all().iter().map(|(_, l)| l.name.as_str()).collect();
|
||||
if let Some((id, _)) = widgets::dropdown_names(ui, "lib-picker", &lib.name, &names, ui.available_width()).and_then(|i| libs.all().get(i)) {
|
||||
act = Some(Act::Run("library.setCurrent", json!({ "library": id })));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let empty = lib.items().next().is_none() && lib.groups.is_empty();
|
||||
let query = if empty { String::new() } else { widgets::search_field(ui, egui::Id::new("library-search"), tl!("Search")) };
|
||||
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 };
|
||||
// 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)) {
|
||||
if let Some(n) = counts.get_mut(items.group_of(kind, key).map_or(0, |i| i + 1)) {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let empty = lib.colors.is_empty() && lib.char_styles.is_empty() && lib.para_styles.is_empty() && lib.graphics.is_empty();
|
||||
let mut act: Option<(&'static str, Value)> = None;
|
||||
let fill_active = app.session.fill_active;
|
||||
let list_rect = widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(140.0);
|
||||
ui.set_width(ui.available_width());
|
||||
|
|
@ -84,101 +142,53 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
);
|
||||
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| {
|
||||
if !lib.colors.is_empty() {
|
||||
heading(ui, tl!("Colors"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for c in &lib.colors {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(CHIP, CHIP), Sense::click_and_drag());
|
||||
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,
|
||||
});
|
||||
let resp = resp.on_hover_text(&c.name);
|
||||
if resp.clicked() {
|
||||
act = Some((
|
||||
"library.use",
|
||||
json!({ "kind": "fillColor", "item": c.name, "to": if fill_active { "fill" } else { "stroke" } }),
|
||||
));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
act = Some(("library.removeItem", json!({ "kind": "fillColor", "item": c.name })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
for (para, list, title) in [(false, &lib.char_styles, tl!("Character Styles")), (true, &lib.para_styles, tl!("Paragraph Styles"))] {
|
||||
if list.is_empty() {
|
||||
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;
|
||||
}
|
||||
heading(ui, title);
|
||||
let kind = if para { "paraStyle" } else { "charStyle" };
|
||||
for st in list {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(
|
||||
ui,
|
||||
if para { "pilcrow" } else { "type" },
|
||||
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,
|
||||
);
|
||||
let resp = resp.on_hover_text(tl!("Click to apply to the selected type"));
|
||||
if resp.clicked() {
|
||||
act = Some(("library.use", json!({ "kind": kind, "item": st.name })));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
act = Some(("library.removeItem", json!({ "kind": kind, "item": st.name })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
// 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,
|
||||
);
|
||||
if header.clicked() && !searching {
|
||||
super::set_pstate(ui.ctx(), &key, !closed);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if !lib.graphics.is_empty() {
|
||||
heading(ui, tl!("Graphics"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for g in &lib.graphics {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag());
|
||||
tile(ui, r, &lib_id, g);
|
||||
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: lib_id.clone(), item: g.id.clone() });
|
||||
let resp = resp.on_hover_text(&g.name);
|
||||
if resp.double_clicked() {
|
||||
act = Some(("library.use", json!({ "kind": "graphic", "item": g.id })));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Place", true, false) {
|
||||
act = Some(("library.use", json!({ "kind": "graphic", "item": g.id })));
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
act = Some(("library.removeItem", json!({ "kind": "graphic", "item": g.id })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.min_rect()
|
||||
|
|
@ -186,7 +196,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
// Art dragged off the canvas, or a swatch, 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(("library.add", json!({ "kind": "graphic", "ids": ids })));
|
||||
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
|
||||
|
|
@ -194,21 +204,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
|
||||
let color = *color;
|
||||
if zone.dnd_release_payload::<PanelDrag>().is_some() {
|
||||
act = Some(("library.add", json!({ "kind": "fillColor", "color": color })));
|
||||
act = Some(Act::Run("library.add", json!({ "kind": "fillColor", "color": color })));
|
||||
}
|
||||
}
|
||||
if let Some((cmd, mut p)) = act {
|
||||
p["library"] = json!(lib_id);
|
||||
if cmd == "library.use" && p["kind"] == "graphic" {
|
||||
// Placed at the centre of the view.
|
||||
if let Some(v) = app.view() {
|
||||
p["center"] = json!([v.center.x, v.center.y]);
|
||||
}
|
||||
}
|
||||
run(app, cmd, p);
|
||||
}
|
||||
let has_doc = app.session.active().is_some();
|
||||
widgets::bottom_bar(ui, |ui| {
|
||||
if widgets::icon_button(ui, "group", tl!("New Group"), false, 24.0).clicked() {
|
||||
act = Some(Act::NewGroup(json!([])));
|
||||
}
|
||||
let add = widgets::icon_button_enabled(ui, "plus", tl!("Add Content"), false, has_doc, 24.0);
|
||||
egui::Popup::menu(&add).show(|ui| {
|
||||
for (label, kind) in [
|
||||
|
|
@ -219,12 +221,154 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
("Paragraph Style", "paraStyle"),
|
||||
] {
|
||||
if menu_item(ui, label, true, false) {
|
||||
run(app, "library.add", json!({ "library": lib_id, "kind": kind }));
|
||||
act = Some(Act::Run("library.add", json!({ "kind": kind })));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
match act {
|
||||
Some(Act::Run(cmd, mut p)) => {
|
||||
if cmd != "library.setCurrent" {
|
||||
p["library"] = json!(lib_id);
|
||||
}
|
||||
if cmd == "library.use" && p["kind"] == "graphic" {
|
||||
// Placed at the centre of the view.
|
||||
if let Some(v) = app.view() {
|
||||
p["center"] = json!([v.center.x, v.center.y]);
|
||||
}
|
||||
}
|
||||
run(app, cmd, p);
|
||||
}
|
||||
Some(Act::NewGroup(items)) => ask_group_name(app, &lib_id, None, items),
|
||||
Some(Act::RenameGroup(g)) => ask_group_name(app, &lib_id, Some(&g), Value::Null),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
if !colors.is_empty() {
|
||||
heading(ui, tl!("Colors"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for c in colors {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(CHIP, CHIP), Sense::click_and_drag());
|
||||
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 });
|
||||
let resp = resp.on_hover_text(&c.name);
|
||||
if resp.clicked() {
|
||||
let to = if self.fill_active { "fill" } else { "stroke" };
|
||||
*act = Some(Act::Run("library.use", json!({ "kind": "fillColor", "item": c.name, "to": to })));
|
||||
}
|
||||
resp.context_menu(|ui| self.item_menu(ui, act, ItemKind::Color, &c.name));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
for (kind, list, title) in
|
||||
[(ItemKind::CharStyle, &lib.char_styles, tl!("Character Styles")), (ItemKind::ParaStyle, &lib.para_styles, tl!("Paragraph Styles"))]
|
||||
{
|
||||
let list: Vec<_> = list.iter().filter(|s| keep(kind, &s.name, &s.name)).collect();
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
heading(ui, title);
|
||||
for st in list {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
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);
|
||||
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 })));
|
||||
}
|
||||
resp.context_menu(|ui| self.item_menu(ui, act, kind, &st.name));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
let graphics: Vec<_> = lib.graphics.iter().filter(|g| keep(ItemKind::Graphic, &g.id, &g.name)).collect();
|
||||
if !graphics.is_empty() {
|
||||
heading(ui, tl!("Graphics"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for g in graphics {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag());
|
||||
tile(ui, r, self.library, g);
|
||||
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() });
|
||||
let resp = resp.on_hover_text(&g.name);
|
||||
let place = || Act::Run("library.use", json!({ "kind": "graphic", "item": g.id }));
|
||||
if resp.double_clicked() {
|
||||
*act = Some(place());
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Place", true, false) {
|
||||
*act = Some(place());
|
||||
ui.close();
|
||||
}
|
||||
self.item_menu(ui, act, ItemKind::Graphic, &g.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let at = self.group_of(kind, key);
|
||||
let item = json!({ "kind": kind_param(kind), "item": key });
|
||||
ui.menu_button(format!(" {}", tl!("Move to Group")), |ui| {
|
||||
if menu_item(ui, "No Group", true, at.is_none()) {
|
||||
if at.is_some() {
|
||||
*act = Some(Act::Run("library.moveItem", item.clone()));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
for (i, g) in self.lib.groups.iter().enumerate() {
|
||||
if menu_item_name(ui, &g.name, true, at == Some(i)) {
|
||||
if at != Some(i) {
|
||||
let mut p = item.clone();
|
||||
p["group"] = json!(g.name);
|
||||
*act = Some(Act::Run("library.moveItem", p));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "New Group…", true, false) {
|
||||
*act = Some(Act::NewGroup(json!([item])));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
*act = Some(Act::Run("library.removeItem", item));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `kind` as the `library.*` commands' `kind` param (a colour as a fill colour).
|
||||
fn kind_param(kind: ItemKind) -> &'static str {
|
||||
match kind {
|
||||
ItemKind::Graphic => "graphic",
|
||||
ItemKind::CharStyle => "charStyle",
|
||||
ItemKind::ParaStyle => "paraStyle",
|
||||
ItemKind::Color | ItemKind::Unknown => "fillColor",
|
||||
}
|
||||
}
|
||||
|
||||
/// A section heading in the list.
|
||||
|
|
@ -235,7 +379,7 @@ fn heading(ui: &mut Ui, text: &str) {
|
|||
}
|
||||
|
||||
/// Graphic `g`'s thumbnail on white in `r`.
|
||||
fn tile(ui: &Ui, r: Rect, library: &str, g: &vectorcraft_engine::cmd::library::LibraryGraphic) {
|
||||
fn tile(ui: &Ui, r: Rect, library: &str, g: &LibraryGraphic) {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
|
||||
if let Some(tex) = thumbnail(ui.ctx(), library, g) {
|
||||
let size = tex.size_vec2();
|
||||
|
|
@ -246,7 +390,7 @@ fn tile(ui: &Ui, r: Rect, library: &str, g: &vectorcraft_engine::cmd::library::L
|
|||
}
|
||||
|
||||
/// The texture of graphic `g`'s PNG thumbnail, decoded once.
|
||||
fn thumbnail(ctx: &egui::Context, library: &str, g: &vectorcraft_engine::cmd::library::LibraryGraphic) -> Option<egui::TextureHandle> {
|
||||
fn thumbnail(ctx: &egui::Context, library: &str, g: &LibraryGraphic) -> Option<egui::TextureHandle> {
|
||||
thread_local! {
|
||||
static CACHE: crate::graphics::TexCache<HashMap<(String, String, usize), Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
|
|
@ -278,7 +422,37 @@ pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item:
|
|||
}
|
||||
}
|
||||
|
||||
/// The panel's ≡ menu: Create New Library, Rename Library…, Delete Library.
|
||||
/// Import a library file (`library.import`'s params), saying so in the status bar.
|
||||
pub(crate) fn import(app: &mut VectorcraftApp, p: Value) -> Result<Value, String> {
|
||||
let r = app.run("library.import", p)?;
|
||||
let name = r["name"].as_str().unwrap_or_default().to_string();
|
||||
app.status(format!("Imported library {name}"));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Import Library…: a picked `.vclibrary` file becomes a new library (on the web the picked file
|
||||
/// arrives later through [`crate::io::open_bytes`]).
|
||||
fn import_picked(app: &mut VectorcraftApp) {
|
||||
if let Some(f) = app.services.open_async.as_mut() {
|
||||
f();
|
||||
return;
|
||||
}
|
||||
let pick = crate::FilePick { filters: vec![("Libraries", LIBRARY_EXTS)], ..Default::default() };
|
||||
if let Some(path) = crate::picks::open(app, &pick) {
|
||||
let r = import(app, json!({ "path": path }));
|
||||
report(app, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Export Library…: library `id` written to a picked file named after it (the web downloads it).
|
||||
fn export_picked(app: &mut VectorcraftApp, id: &str, name: &str) {
|
||||
let file = format!("{}.{LIBRARY_EXT}", vectorcraft_engine::cmd::library::file_stem_for(name));
|
||||
let r = crate::io::save_command_output_named(app, "library.export", &file, json!({ "library": id }));
|
||||
report(app, r);
|
||||
}
|
||||
|
||||
/// The panel's ≡ menu: Create New Library, Rename Library…, Delete Library, New Group…, Import
|
||||
/// Library…, Export Library….
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let cur = current(app);
|
||||
if menu_item(ui, "Create New Library…", true, false) {
|
||||
|
|
@ -304,6 +478,24 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "New Group…", cur.is_some(), false)
|
||||
&& let Some((id, _)) = &cur
|
||||
{
|
||||
ask_group_name(app, id, None, json!([]));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "Import Library…", true, false) {
|
||||
crate::picks::button(app, import_picked);
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Export Library…", cur.is_some(), false)
|
||||
&& let Some((id, lib)) = cur
|
||||
{
|
||||
crate::picks::button(app, move |app| export_picked(app, &id, &lib.name));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -315,6 +507,7 @@ mod tests {
|
|||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
|
|
@ -345,4 +538,94 @@ mod tests {
|
|||
let ctx = egui::Context::default();
|
||||
assert!(thumbnail(&ctx, "Brand", &lib.graphics[0]).is_some());
|
||||
}
|
||||
|
||||
/// The panel's text: every label it shows in one frame (`query` typed in the search field).
|
||||
fn texts(app: &mut VectorcraftApp, query: &str) -> Vec<String> {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("library-search"), query.to_string()));
|
||||
let mut shapes = vec![];
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
shapes.append(&mut out.shapes);
|
||||
let mut texts = vec![];
|
||||
for s in shapes {
|
||||
if let egui::epaint::Shape::Text(t) = s.shape {
|
||||
texts.push(t.galley.text().to_string());
|
||||
}
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// The search field filters items by name, ignoring case; groups show after the ungrouped
|
||||
/// items, and a search nothing matches says so (#926).
|
||||
#[test]
|
||||
fn the_panel_searches_and_shows_groups() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("library.create", json!({"name": "Brand"})).unwrap();
|
||||
for (name, color) in [("Ocean Blue", "#0044aa"), ("Sun Yellow", "#ffcc00")] {
|
||||
app.run("library.add", json!({"kind": "fillColor", "color": color, "name": name})).unwrap();
|
||||
}
|
||||
let t = app.run("text.create", json!({"x": 10, "y": 80, "text": "Type"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [t]})).unwrap();
|
||||
app.run("library.add", json!({"kind": "charStyle", "name": "Headline"})).unwrap();
|
||||
app.run("library.createGroup", json!({"name": "Warm", "items": [{"kind": "fillColor", "item": "Sun Yellow"}]})).unwrap();
|
||||
let all = texts(&mut app, "");
|
||||
let at = |s: &str| all.iter().position(|t| t == s);
|
||||
assert!(at("Headline").is_some() && at("Warm").is_some(), "{all:?}");
|
||||
assert!(at("Headline") < at("Warm"), "groups come after the ungrouped items: {all:?}");
|
||||
// "BLUE" finds the blue swatch only: the style and the group without a match are hidden.
|
||||
let found = texts(&mut app, "BLUE");
|
||||
assert!(!found.iter().any(|t| t == "Headline" || t == "Warm"), "{found:?}");
|
||||
let found = texts(&mut app, "yellow");
|
||||
assert!(found.iter().any(|t| t == "Warm") && !found.iter().any(|t| t == "Headline"), "{found:?}");
|
||||
let none = texts(&mut app, "zzz");
|
||||
assert!(none.iter().any(|t| t == "No results"), "{none:?}");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn group_dialogs_run_with_their_library_and_group() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let lib = app.run("library.create", json!({"name": "Brand"})).unwrap()["id"].as_str().unwrap().to_string();
|
||||
app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap();
|
||||
ask_group_name(&mut app, &lib, None, json!([{"kind": "fillColor", "item": "Blue"}]));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cool"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
ask_group_name(&mut app, &lib, Some("Cool"), Value::Null);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cold"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let got = app.run("library.get", json!({})).unwrap();
|
||||
assert_eq!(got["groups"], json!([{"name": "Cold", "items": [{"kind": "color", "item": "Blue"}]}]));
|
||||
}
|
||||
|
||||
/// Export Library… writes the library's file and Import Library… adds it back as a new library.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn libraries_are_exported_and_imported_from_the_panel_menu() {
|
||||
let dir = vectorcraft_testkit::temp_dir("libraries-panel");
|
||||
let path = dir.join("Brand.vclibrary").to_string_lossy().to_string();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("library.create", json!({"name": "Brand"})).unwrap();
|
||||
app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap();
|
||||
let picked = path.clone();
|
||||
app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| {
|
||||
assert_eq!(pick.name, "Brand.vclibrary", "the library's name suggested");
|
||||
Some(picked.clone())
|
||||
}));
|
||||
app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| std::fs::write(p, bytes).map_err(|e| e.to_string())));
|
||||
export_picked(&mut app, "Brand", "Brand");
|
||||
assert!(std::path::Path::new(&path).exists());
|
||||
let picked = path.clone();
|
||||
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone())));
|
||||
import_picked(&mut app);
|
||||
let list = app.run("library.list", json!({})).unwrap();
|
||||
let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Brand", "Brand (2)"]);
|
||||
assert_eq!(app.ui.status, "Imported library Brand (2)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,12 @@ pub fn section_header(ui: &mut Ui, text: &str) {
|
|||
/// A collapsible section's header across the panel: a chevron (down while `open`) and `text`;
|
||||
/// a click anywhere on it toggles the section. Whether it was clicked.
|
||||
pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool {
|
||||
section_toggle_name(ui, tl!(text), open).clicked()
|
||||
}
|
||||
|
||||
/// [`section_toggle`] for a name that is user data (a library group), shown as it is → its
|
||||
/// response (clicked: toggle; it takes a context menu).
|
||||
pub fn section_toggle_name(ui: &mut Ui, text: &str, open: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
|
|
@ -131,9 +137,9 @@ pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool {
|
|||
}
|
||||
let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0));
|
||||
icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon });
|
||||
ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, tl!(text), theme::semibold(12.5), t.text);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, tl!(text)));
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
|
||||
ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, text, theme::semibold(12.5), t.text);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, text));
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// Full-width 1 px divider with vertical margins.
|
||||
|
|
|
|||
17
docs/mcp.md
17
docs/mcp.md
|
|
@ -953,9 +953,26 @@ gives another colour merges into the document's), a colour paints the selection'
|
|||
to the document (numbered when its name is taken by other attributes) and applied to the selected text, each as one
|
||||
undo step. `library.removeItem {library?, kind, item}` removes one.
|
||||
|
||||
Items can be put in user-named groups, which the panel shows as collapsible sections after the ungrouped items (its
|
||||
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).
|
||||
|
||||
`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).
|
||||
`library.import {path? | data? | dataBase64?, name?}` adds the library of such a file as a new library and makes it
|
||||
the current one; a name another library has gets a number (`Brand (2)`). Imported files are checked: at most 10,000
|
||||
items of each kind and 1,000 groups, names clipped and made unique, thumbnails kept only when they are small PNGs, and
|
||||
groups naming items that aren't there dropped. Files written before groups load as they are.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"library.add","params":{"kind":"graphic","name":"Logo"}}}
|
||||
{"name":"run_command","arguments":{"command":"library.use","params":{"kind":"graphic","item":"Logo","center":[300,200]}}}
|
||||
{"name":"run_command","arguments":{"command":"library.createGroup","params":{"name":"Logos","items":[{"kind":"graphic","item":"Logo"}]}}}
|
||||
{"name":"run_command","arguments":{"command":"library.export","params":{"path":"/tmp/Brand.vclibrary"}}}
|
||||
```
|
||||
|
||||
## Strokes on type
|
||||
|
|
|
|||
Loading…
Reference in New Issue