Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 d57134a308 Fix #938: Live Corner widgets on every selected path
The corner widgets only showed when the selection was exactly one path.
Now every selected path that has them shows its widgets: live rectangles
and polygons with the Selection tool, any path with Direct Selection,
each with its own corners (all of a path selected whole, the
Direct-Selected ones otherwise). Dragging any widget rounds every shown
corner of every path in one step, Alt-click cycles their kind, and the
red limit outline covers them all; a double-click opens Corners for the
path it was on. Each widget is drawn in its own layer's colour.

object.setLiveShape takes items: [{id, corners?}], several objects each
with its own corners, which the drag sends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:19:52 +07:00
BFlat 9449c92136 Merge pull request #943 from storytold/feat/926-library-groups
Fix #926: the Libraries panel's search, groups and library export/import
2026-10-10 17:18:20 +07:00
bflatasticandClaude Opus 5.5 d62ecfe33a Fix #926: the Libraries panel's search, groups and library export/import
Search: a search field at the top of the Libraries panel filters the
current library's colours, character and paragraph styles and graphics
by name, ignoring case; groups without a match are hidden, groups with
one open, and a search nothing matches says so ("No results").

Groups: items can be put in user-named groups, kept in the library file
(`groups: [{name, items: [{kind, item}]}]`, left out when there are
none, so files written before groups load as they are). New commands:
library.createGroup {library?, name?, items?}, library.renameGroup
{library?, group, name}, library.deleteGroup {library?, group} (its
items stay, ungrouped) and library.moveItem {library?, kind, item,
group?}. An item is in one group at most; deleting an item takes it out
of its group. The panel shows each group as a collapsible section with
its item count after the ungrouped items (rename and delete on its
context menu), each item's context menu has Move to Group (No Group,
the groups, New Group...), and a New Group button sits in the footer.
library.get lists the groups, library.list counts them. The generic
command dialog takes fixed params in `__params` for this.

Export / import: library.export {library?, path?} writes a library
(colours, styles, graphics with art and thumbnails, groups) as a
.vclibrary file, the JSON the library folder keeps (without a path:
{data}); library.import {path | data | dataBase64, name?} adds one as a
new library, made current, numbered on a name clash ("Brand (2)").
The panel menu has Import Library... and Export Library..., through the
app's file dialogs (the web downloads, and picks through File > Open,
which now lists Libraries files). Imported and folder files are made
safe: at most 10,000 items of a kind and 1,000 groups, names clipped and
made unique, sizes finite, thumbnails kept only when their PNG header
says at most 1024 px a side, group entries naming missing items or
unknown kinds dropped; a file over 512 MB or that isn't a library is an
error. Paths go through the engine's file helpers (automation roots).
The importer is fuzzed (import_fuzz: garbage and mutated libraries).

Also fixed: the third graphic added to a library got the second's id
(`graphic-2` twice).

Docs: the commands' params docs, docs/mcp.md, ROADMAP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:17:01 +07:00
BFlat c738400ebb Merge pull request #942 from storytold/feat/video-effects
Part of gap 2: Effect › Video (De-Interlace, NTSC Colors)
2026-10-10 17:06:09 +07:00
23 changed files with 1468 additions and 238 deletions

No files matched your search

+2 -2
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
@@ -237,7 +237,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 80% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles are done (#745); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
+3 -1
View File
@@ -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)))
+5 -1
View File
@@ -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");
}
+414 -26
View File
@@ -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)
}
+28 -3
View File
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
has_selection,
set_live_shape
),
@@ -1083,8 +1083,32 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let corners = corners_param(p)?;
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
let items = match p.get("items").filter(|v| !v.is_null()) {
None => None,
Some(Value::Array(a)) => Some(
a.iter()
.map(|it| {
let id = it
.get("id")
.and_then(Value::as_u64)
.map(NodeId)
.ok_or_else(|| bad("object.setLiveShape", format!("each of `items` is {{id, corners?}} with an object id, not {it}")))?;
Ok((id, corners_param(it)?))
})
.collect::<Result<Vec<_>>>()?,
),
Some(v) => return Err(bad("object.setLiveShape", format!("`items` must be an array of {{id, corners?}}, not {v}"))),
};
let ids = match &items {
Some(items) => items.iter().map(|(id, _)| *id).collect(),
None => targets(s, p)?,
};
let shared = corners_param(p)?;
let corners_of = |id: NodeId| match &items {
Some(items) => items.iter().find(|(i, _)| *i == id).and_then(|(_, c)| c.clone()),
None => shared.clone(),
};
let kind = p
.get("kind")
.filter(|v| !v.is_null())
@@ -1154,6 +1178,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
let partial = sel.anchors.get_mut(id);
// The corners to edit, the Direct-Selected ones and the anchor layout before the edit.
let Some(c) = LiveCorners::new(path, live.as_ref()) else { continue };
let corners = corners_of(*id);
let picked = match &corners {
Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => {
let n = c.base.anchor_count();
+178
View File
@@ -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)]))]);
}
+19
View File
@@ -58,6 +58,25 @@ fn a_whole_rectangle_rounds_all_corners_and_given_corners_alone() {
assert_eq!(corners(&s, id).1[3], CornerKind::Chamfer);
}
/// #938: `items` rounds several objects in one step, each its own corners (or all of them).
#[test]
fn items_round_several_objects_each_its_own_corners() {
let (mut s, a) = session();
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap());
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, json!({"items": [{"id": a.0, "corners": [2]}, {"id": b.0}], "radius": 6}));
assert_eq!(corners(&s, a).0, [0.0, 0.0, 6.0, 0.0]);
assert_eq!(corners(&s, b).0, [6.0; 4]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one step");
for p in [
json!({"items": 3, "radius": 1}),
json!({"items": [{"corners": [1]}], "radius": 1}),
json!({"items": [{"id": a.0, "corners": [9]}], "radius": 1}),
] {
assert!(s.execute("object.setLiveShape", &p).is_err(), "{p}");
}
}
#[test]
fn bad_corners_and_kinds_are_errors() {
let (mut s, id) = session();
+57 -1
View File
@@ -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();
+172 -69
View File
@@ -1,8 +1,8 @@
//! Live Corners: the widgets inside the corners of a selected path. The Selection tool shows them
//! on a live rectangle or polygon, Direct Selection on any path (a star, a pen path) at each corner
//! anchor: one without handles between two straight sides. Dragging one rounds (or sharpens) the
//! corners whose widgets show together: every corner when the whole path is selected, those with a
//! selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
//! Live Corners: the widgets inside the corners of the selected paths. The Selection tool shows
//! them on live rectangles and polygons, Direct Selection on any path (a star, a pen path) at each
//! corner anchor: one without handles between two straight sides. Dragging one rounds (or
//! sharpens) the corners whose widgets show together, on every selected path (#938): every corner
//! of a path selected whole, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
//! kind (round, inverted round, chamfer); double-clicking one opens the Corners dialog. The
//! Selection and Direct Selection tools share this, and the canvas draws the widgets from the same
//! geometry.
@@ -38,10 +38,10 @@ struct Widget {
at: Point,
}
/// The corner widgets of a selection that is exactly one editable path with corners.
/// The corner widgets of one selected path.
#[derive(Clone, Debug, PartialEq)]
pub struct CornerWidgets {
pub id: NodeId,
struct PathWidgets {
id: NodeId,
/// Maps the corners' space into the document (a live rectangle's own; else none).
xf: Affine,
/// The widgets that show (and which a drag edits), in anchor order.
@@ -50,11 +50,10 @@ pub struct CornerWidgets {
all: bool,
}
impl CornerWidgets {
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
let [id] = selection.objects[..] else { return None };
impl PathWidgets {
/// The widgets of path `id` at `zoom` (screen px per document point), if it has any: any path
/// with Direct Selection (`any_path`), live rectangles and polygons with Selection.
fn of(doc: &Document, selection: &Selection, id: NodeId, zoom: f64, any_path: bool) -> Option<Self> {
if !doc.is_editable(id) {
return None;
}
@@ -86,13 +85,36 @@ impl CornerWidgets {
(!widgets.is_empty()).then_some(Self { id, xf: lc.xf, widgets, all })
}
/// The corners whose widgets show: anchor indices of the path's uncut outline.
fn corners(&self) -> Vec<usize> {
self.widgets.iter().map(|w| w.corner.index).collect()
}
}
/// The corner widgets of the selected paths that have some.
#[derive(Clone, Debug, PartialEq)]
pub struct CornerWidgets {
paths: Vec<PathWidgets>,
}
impl CornerWidgets {
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
let paths: Vec<_> = selection.objects.iter().filter_map(|id| PathWidgets::of(doc, selection, *id, zoom, any_path)).collect();
(!paths.is_empty()).then_some(Self { paths })
}
/// Selection & Anchor Display → Hide Corner Widget for angles greater than: the widgets of
/// corners wider than `max` degrees (in the document) hide; none if all do.
pub fn within_angle(mut self, max: f64) -> Option<Self> {
let n = self.widgets.len();
self.widgets.retain(|w| w.angle <= max + 1e-9);
self.all &= self.widgets.len() == n;
(!self.widgets.is_empty()).then_some(self)
for p in &mut self.paths {
let n = p.widgets.len();
p.widgets.retain(|w| w.angle <= max + 1e-9);
p.all &= p.widgets.len() == n;
}
self.paths.retain(|p| !p.widgets.is_empty());
(!self.paths.is_empty()).then_some(self)
}
/// The widgets on show: [`Self::of`] with View → Show Corner Widget `on`, less those wider
@@ -111,30 +133,61 @@ impl CornerWidgets {
/// The centres of the widgets that show.
pub fn visible(&self) -> impl Iterator<Item = Point> + '_ {
self.widgets.iter().map(|w| w.at)
self.visible_on().map(|(_, at)| at)
}
/// The corners whose widgets show: anchor indices of the path's uncut outline.
/// The centres of the widgets that show, each with the object it belongs to.
pub fn visible_on(&self) -> impl Iterator<Item = (NodeId, Point)> + '_ {
self.paths.iter().flat_map(|p| p.widgets.iter().map(move |w| (p.id, w.at)))
}
/// The corners whose widgets show, path after path: anchor indices of each path's uncut
/// outline.
pub fn corners(&self) -> Vec<usize> {
self.widgets.iter().map(|w| w.corner.index).collect()
self.paths.iter().flat_map(PathWidgets::corners).collect()
}
/// The widget nearest to `p` within `tol` (document units).
fn hit(&self, p: Point, tol: f64) -> Option<usize> {
let d = |k: &usize| self.widgets.get(*k).map_or(f64::INFINITY, |w| w.at.distance(p));
(0..self.widgets.len()).filter(|k| d(k) <= tol).min_by(|a, b| d(a).total_cmp(&d(b)))
/// The widget nearest to `p` within `tol` (document units): its path's and its own index.
fn hit(&self, p: Point, tol: f64) -> Option<(usize, usize)> {
let d = |&(i, k): &(usize, usize)| self.widget(i, k).map_or(f64::INFINITY, |w| w.at.distance(p));
self.paths
.iter()
.enumerate()
.flat_map(|(i, path)| (0..path.widgets.len()).map(move |k| (i, k)))
.filter(|ik| d(ik) <= tol)
.min_by(|a, b| d(a).total_cmp(&d(b)))
}
/// `object.setLiveShape` params setting `key` on the shown corners (every corner unless some
/// are hidden, which `corners` then leaves out).
fn widget(&self, path: usize, k: usize) -> Option<&Widget> {
self.paths.get(path).and_then(|p| p.widgets.get(k))
}
/// `object.setLiveShape` params setting `key` on the shown corners (every corner of a path
/// unless some are hidden, which `corners` then leaves out): one path by its `id`, several as
/// `items`.
fn command(&self, key: &str, value: Value) -> Value {
let mut p = Map::new();
p.insert("id".into(), json!(self.id.0));
p.insert(key.into(), value);
if !self.all {
p.insert("corners".into(), json!(self.corners()));
let corners = |p: &PathWidgets| (!p.all).then(|| p.corners());
let mut out = Map::new();
match self.paths.as_slice() {
[p] => {
out.insert("id".into(), json!(p.id.0));
if let Some(c) = corners(p) {
out.insert("corners".into(), json!(c));
}
}
paths => {
let items: Vec<Value> = paths
.iter()
.map(|p| match corners(p) {
Some(c) => json!({ "id": p.id.0, "corners": c }),
None => json!({ "id": p.id.0 }),
})
.collect();
out.insert("items".into(), Value::Array(items));
}
}
Value::Object(p)
out.insert(key.into(), value);
Value::Object(out)
}
}
@@ -151,11 +204,22 @@ pub fn over_widget(cx: &ToolContext, p: Point, any_path: bool) -> bool {
CornerWidgets::for_tool(cx, any_path).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
}
/// A double-click on a corner widget opens the Corners dialog for the shown corners.
/// A double-click on a corner widget opens the Corners dialog for the shown corners of its path.
pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action> {
let w = CornerWidgets::for_tool(cx, any_path)?;
w.hit(p, cx.tol(5.0))?;
Some(Action::Dialog(DIALOG.into(), json!({ "id": w.id.0, "corners": w.corners() })))
let (i, _) = w.hit(p, cx.tol(5.0))?;
let path = w.paths.get(i)?;
Some(Action::Dialog(DIALOG.into(), json!({ "id": path.id.0, "corners": path.corners() })))
}
/// A path's uncut outline with its corners' radii and kinds, and the corners' space (to outline
/// the corners at their largest).
#[derive(Clone, Debug)]
struct Outline {
base: PathData,
radii: Vec<f64>,
kinds: Vec<CornerKind>,
xf: Affine,
}
/// Dragging a corner widget: the radius follows the pointer along the corner's bisector. An
@@ -163,13 +227,10 @@ pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action
#[derive(Clone, Debug)]
pub struct CornerDrag {
widgets: CornerWidgets,
/// The dragged widget (index into the shown ones).
widget: usize,
/// The path's uncut outline with its corners' radii and kinds (to outline the corners at
/// their largest).
base: PathData,
radii: Vec<f64>,
kinds: Vec<CornerKind>,
/// The dragged widget: its path's and its own index among the shown ones.
widget: (usize, usize),
/// Each shown path's outline, in the order of [`CornerWidgets`]' paths.
outlines: Vec<Outline>,
start: Point,
began: bool,
alt: bool,
@@ -185,20 +246,29 @@ impl CornerDrag {
pub fn hit(cx: &ToolContext, ev: &PointerEvent, any_path: bool) -> Option<Self> {
let (widgets, p) = (CornerWidgets::for_tool(cx, any_path)?, ev.pos);
let widget = widgets.hit(p, cx.tol(5.0))?;
let radius = widgets.widgets.get(widget)?.radius;
let lc = cx.doc.node(widgets.id).and_then(LiveCorners::of)?;
let n = lc.base.anchor_count();
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
let base = lc.base.into_owned();
Some(Self { widgets, widget, base, radii, kinds, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
let radius = widgets.widget(widget.0, widget.1)?.radius;
let outlines = widgets
.paths
.iter()
.map(|w| {
let lc = cx.doc.node(w.id).and_then(LiveCorners::of)?;
let n = lc.base.anchor_count();
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
Some(Outline { base: lc.base.into_owned(), radii, kinds, xf: w.xf })
})
.collect::<Option<Vec<_>>>()?;
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
/// corner's bisector (in the corners' own units), from square to fully round; and whether the
/// travel reached fully round.
fn radius_at(&self, p: Point) -> (f64, bool) {
let Some(w) = self.widgets.widgets.get(self.widget) else { return (self.radius, self.at_limit) };
let inv = self.widgets.xf.inverse();
let (path, k) = self.widget;
let (Some(w), Some(xf)) = (self.widgets.widget(path, k), self.widgets.paths.get(path).map(|p| p.xf)) else {
return (self.radius, self.at_limit);
};
let inv = xf.inverse();
let max = w.corner.max_radius();
let r = w.corner.fitted(w.radius) + w.corner.radius_change(inv * p - inv * self.start);
(r.min(max).max(0.0), max > 0.0 && r >= max)
@@ -206,25 +276,27 @@ impl CornerDrag {
/// The cuts of the edited corners that are at their largest radius, in document coordinates.
fn corners_at_limit(&self) -> BezPath {
let shown = &self.widgets.widgets;
let mut radii = self.radii.clone();
for w in shown {
if let Some(r) = radii.get_mut(w.corner.index) {
*r = self.radius;
}
}
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9);
let (path, sources) = cut_corners(&self.base, &radii, &self.kinds);
let mut out = BezPath::new();
for (sp, from) in path.transformed(self.widgets.xf).subpaths.iter().zip(&sources) {
// A corner's cut is the segment between its two anchors (the last one, for a closed
// subpath's first corner).
for i in 0..sp.segment_count() {
let (a, b) = (from.get(i), from.get(i + 1).or(from.first()));
if a == b && a.is_some_and(full) {
let c = sp.segment(i);
out.move_to(c.p0);
out.curve_to(c.p1, c.p2, c.p3);
for (shown, o) in self.widgets.paths.iter().zip(&self.outlines) {
let shown = &shown.widgets;
let mut radii = o.radii.clone();
for w in shown {
if let Some(r) = radii.get_mut(w.corner.index) {
*r = self.radius;
}
}
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9);
let (path, sources) = cut_corners(&o.base, &radii, &o.kinds);
for (sp, from) in path.transformed(o.xf).subpaths.iter().zip(&sources) {
// A corner's cut is the segment between its two anchors (the last one, for a
// closed subpath's first corner).
for i in 0..sp.segment_count() {
let (a, b) = (from.get(i), from.get(i + 1).or(from.first()));
if a == b && a.is_some_and(full) {
let c = sp.segment(i);
out.move_to(c.p0);
out.curve_to(c.p1, c.p2, c.p3);
}
}
}
}
@@ -249,7 +321,7 @@ impl CornerDrag {
pub fn finish(self) -> Vec<Action> {
if self.began {
vec![Action::Commit]
} else if let Some(w) = self.widgets.widgets.get(self.widget).filter(|_| self.alt) {
} else if let Some(w) = self.widgets.widget(self.widget.0, self.widget.1).filter(|_| self.alt) {
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(w.kind.next())))]
} else {
vec![]
@@ -359,6 +431,37 @@ mod tests {
}
}
/// #938: with two rectangles selected, both show their widgets, and a drag on either rounds
/// the corners of both in one step; with Direct Selection, each its shown corners.
#[test]
fn widgets_of_several_selected_paths_drag_together() {
let (mut d, a) = live_rect(0.0, Affine::translate((100.0, 100.0)));
let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((300.0, 100.0)) };
let b = d.alloc_id();
let mut n = Node::path(b, live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
}
let l = d.layers[0].id;
d.insert(Some(l), 1, n).unwrap();
let mut s = selected(a);
s.add(b);
assert_eq!(pts(&CornerWidgets::of(&d, &s, 1.0, false).unwrap()).len(), 8);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 390.0, 191.0));
let preview = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 375.0, 176.0));
assert!((radius(&preview[1]) - 15.0).abs() < 1e-9);
let Action::Preview(_, v) = &preview[1] else { panic!() };
assert_eq!(v["items"], json!([{"id": a.0}, {"id": b.0}]));
// Direct Selection with one anchor of the first: its corner, and the whole second.
s.anchors.insert(a, [(0, 2)].into());
let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap();
assert_eq!(w.corners(), [2, 0, 1, 2, 3]);
assert_eq!(w.command("radius", json!(5.0)), json!({"items": [{"id": a.0, "corners": [2]}, {"id": b.0}], "radius": 5.0}));
}
/// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also
/// with a radius past half the shorter side; a drag past fully round stops every corner there
/// and outlines them in red.
+2 -2
View File
@@ -1701,8 +1701,8 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
app.session.prefs.hide_corner_widget_above,
)
{
let color = c32(st.doc.layer_color(w.id));
for sp in w.visible().map(|q| xf.to_screen(q)) {
for (id, q) in w.visible_on() {
let (sp, color) = (xf.to_screen(q), c32(st.doc.layer_color(id)));
p.circle_filled(sp, 3.0, Color32::WHITE);
p.circle_stroke(sp, 3.0, Stroke::new(1.0, color));
p.circle_filled(sp, 1.0, color);
+5 -2
View File
@@ -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()?));
+18
View File
@@ -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.
+18
View File
@@ -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.
+18
View File
@@ -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.
+18
View File
@@ -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.
+18
View File
@@ -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.
+18
View File
@@ -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 contains an unexpected character in line 659 and column 40.
+17
View File
@@ -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.
+17
View File
@@ -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.
+5 -2
View File
@@ -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 {
+405 -122
View File
@@ -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)");
}
}
+9 -3
View File
@@ -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.
+22 -4
View File
@@ -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
@@ -1380,7 +1397,7 @@ size. The journal entry of a scaling command records the `strokes` and `corners`
## Live Corners
`object.setLiveShape {id?, ids?, radius?, kind?, corners?}` sets the corners of any path (one undo step): a live
`object.setLiveShape {id?, ids?, radius?, kind?, corners?, items?}` sets the corners of any path (one undo step): a live
rectangle's or polygon's, a star's, a pen path's. A corner is an anchor without handles between two straight sides
(not an open path's ends, not a smooth anchor, not one the sides run straight on through). `radius` (pt) and `kind`
(`round`, `invertedRound` or `chamfer`) go to the `corners` given, else to the corners holding a Direct-Selected
@@ -1396,9 +1413,10 @@ draws no larger than takes the cut halfway along the corner's shorter side (half
same limit for its four corners), the corners stay circular through uneven scales (the radius scales by the mean scale,
or keeps its size with Scale Corners off), and a rectangle from a file that kept an uneven scale in its transform
(elliptical corners) gets circular ones in document units when its corners are next set. Dragging a corner widget
rounds the corners whose widgets show (every corner, or the Direct-Selected ones), outlining in red those that reach
their limit; the Selection tool shows the widgets of live rectangles and polygons, the Direct Selection tool those of
any path. Alt-clicking one cycles their kind and double-clicking one opens Corners (`ui.corners {id?, corners?}`,
rounds the corners whose widgets show (every corner, or the Direct-Selected ones) on every selected path at once,
outlining in red those that reach their limit; the Selection tool shows the widgets of live rectangles and polygons,
the Direct Selection tool those of any path. Several objects each with their own corners are one call:
`items: [{id, corners?}]` in place of `ids` and `corners`. Alt-clicking one cycles their kind and double-clicking one opens Corners (`ui.corners {id?, corners?}`,
dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
```json