Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 7255ac394d Fix #745: a working Libraries panel of graphics, colours and text styles
The Libraries panel was a placeholder: it invited dragging art onto it,
but nothing happened. It now keeps local libraries (no account, no
cloud), as Illustrator's Libraries panel does:

- drag art off the canvas (or a swatch) onto the panel to add it, or use
  + to add the selection's graphic, fill or stroke colour, character
  style or paragraph style;
- drag a graphic onto the canvas (or double-click it) to place a copy,
  click a colour to paint the selection through the active Fill/Stroke
  proxy, click a style to apply it to the selected type;
- the library picker and the panel menu create, rename and delete
  libraries; each is a .vclibrary file in a Libraries folder next to the
  preferences (session-only in the web app, headless sessions and runs
  without preferences).

Engine: library.list/get/setCurrent/create/rename/delete/add/use/
removeItem. A graphic is stored as a native document with a thumbnail:
adding copies as Copy does and placing pastes as Paste does, resources
and swatch-name conflicts included. Using a style adds it to the
document and applies it in one undo step.

Clipboard::to_document gave its layer the id u64::MAX, which made a
saved copy overflow Document::fix_next_id when read back; the layer now
takes an id above the objects', and fix_next_id saturates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:12:59 +07:00
BFlat c2711df30b Merge pull request #751 from dmatviichuk/i18n-ukrainian
Add Ukrainian (uk) interface translation
2026-10-09 19:29:17 +07:00
bflatasticandClaude Opus 5.5 f86618777e i18n: Ukrainian for the tab bar's » tooltip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:19:03 +07:00
bflatastic 14d8efcabf Merge branch 'pr751' into land/751 2026-10-09 19:17:34 +07:00
Dmytro Matviichuk 8a19490ea0 i18n: add Ukrainian interface translation (#747) 2026-10-09 14:15:12 +02:00
BFlat 0e6ca8f88d Merge pull request #749 from storytold/fix/746-tab-overflow
Fix #746: document tabs that don't fit are reached from a » list
2026-10-09 19:10:56 +07:00
31 changed files with 5253 additions and 36 deletions

No files matched your search

+1 -1
View File
@@ -200,7 +200,7 @@ and masks, text and images, with what didn't come in (effects, adjustments, brus
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
doesn't write Affinity files. [Scope and limits](crates/affinity/README.md).
The interface
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian and Russian (and Czech and Brazilian Portuguese in the menus).
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus).
The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
+3 -3
View File
@@ -107,7 +107,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian and Russian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
@@ -146,7 +146,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian and Russian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (`object.cropImage {trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
@@ -231,7 +231,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 20 Preferences options stored but not yet acted on (#394: Spacing Guides, Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation, Appearance of Black, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); 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) | 5–8 |
| 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 |
| **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 |
+6
View File
@@ -489,6 +489,12 @@ fn main() -> std::process::ExitCode {
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
// So do the Libraries panel's libraries; runs without preferences (agents' test
// runs) keep theirs for the session only, never touching the user's.
if prefs_enabled() {
let libraries = prefs_path().and_then(|p| Some(p.parent()?.join("Libraries").to_string_lossy().to_string()));
app.session.libraries.set_dir(libraries);
}
// Data Recovery copies live next to the preferences too (none for runs without
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
+2 -1
View File
@@ -741,7 +741,8 @@ impl Document {
}
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
self.next_id = self.next_id.max(max + 1);
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
self.next_id = self.next_id.max(max.saturating_add(1));
}
/// Find a node anywhere in the tree.
+8 -1
View File
@@ -154,11 +154,18 @@ impl Clipboard {
}
vectorcraft_brush::store(&mut d, &lib);
}
let mut layer = Node::layer(NodeId(u64::MAX), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
// The layer's id is above every copied object's, so the document saves and reopens as
// a native file.
let mut top = 0;
for n in &self.nodes {
n.walk(&mut |c| top = top.max(c.id.0));
}
let mut layer = Node::layer(NodeId(top.saturating_add(1)), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = self.nodes.iter().cloned().map(Arc::new).collect();
}
d.layers = vec![Arc::new(layer)];
d.fix_next_id();
d
}
}
+12 -1
View File
@@ -287,6 +287,8 @@ enum PasteMode {
Back,
InPlace,
AllArtboards,
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
Library,
}
impl PasteMode {
@@ -297,6 +299,7 @@ impl PasteMode {
PasteMode::InPlace => "Paste in Place",
PasteMode::AllArtboards => "Paste on All Artboards",
PasteMode::Offset => "Paste",
PasteMode::Library => "Place from Library",
}
}
}
@@ -310,6 +313,14 @@ fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
r
}
/// Place the objects of `clip` (a library graphic) into the active document centred on `center`,
/// as one undo step: their resources come along as a paste's do, a swatch whose name the document
/// gives another colour merged into the document's.
pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_geom::Point) -> Result<Value> {
let choices = SwatchChoices::parse(PasteMode::Library.label(), &json!({}))?;
paste_clip(s, &json!({ "center": [center.x, center.y] }), PasteMode::Library, clip, &choices)
}
/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the
/// objects were on the artboard they were copied from, on that one; without it (or a copy from no
/// artboard), where they were.
@@ -348,7 +359,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
};
let placements: Vec<Affine> = match (mode, board) {
(_, Some((_, dv))) => vec![Affine::translate(dv)],
(PasteMode::Offset, None) => vec![match point_param(p, "center") {
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
}],
+1
View File
@@ -54,6 +54,7 @@ pub(crate) use load::native_file;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
pub use load::{losses_summary, overwrite_losses};
pub(crate) use native::preview_png;
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
+1 -1
View File
@@ -186,7 +186,7 @@ pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing
/// to show).
fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
pub(crate) fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
let Some(r) = doc.artboards.first().map(|a| a.rect).or_else(|| vectorcraft_render::encode::art_bounds(doc)) else { return Ok(None) };
let scale = f64::from(PREVIEW_MAX) / r.width().max(r.height());
if vectorcraft_render::raster_size(r, scale).is_err() {
+699
View File
@@ -0,0 +1,699 @@
//! Libraries (Window › Libraries): local libraries of graphics, colours and text styles that every
//! document can use. A library is one `.vclibrary` file (JSON) in the library folder next to the
//! preferences, which the desktop app sets; without a folder (the web, headless sessions) libraries
//! 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).
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::TextStyleDef;
use super::*;
use crate::Clipboard;
/// The extension of a library file.
pub const LIBRARY_EXT: &str = "vclibrary";
/// 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;
/// A library: its name and its items, newest last.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Library {
pub name: String,
#[serde(default)]
pub colors: Vec<LibraryColor>,
#[serde(default)]
pub char_styles: Vec<TextStyleDef>,
#[serde(default)]
pub para_styles: Vec<TextStyleDef>,
#[serde(default)]
pub graphics: Vec<LibraryGraphic>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LibraryColor {
pub name: String,
pub color: Color,
}
/// A graphic: the art as a native document (its own resources included) and its thumbnail.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LibraryGraphic {
pub id: String,
pub name: String,
/// Size of the art (points).
pub width: f64,
pub height: f64,
/// The native document, base64.
pub data: String,
/// A PNG of the art, at most 256 pixels a side, base64 (empty when it draws nothing).
#[serde(default)]
pub thumbnail: String,
}
/// The libraries of a session ([`Session::libraries`]).
#[derive(Clone, Debug, Default)]
pub struct Libraries {
/// The library folder: none in the web app and headless sessions.
dir: Option<String>,
/// (id: the file's stem, library), in name order.
libs: Vec<(String, Library)>,
/// The library the panel shows, which commands without a `library` act on.
current: Option<String>,
}
impl Libraries {
pub fn dir(&self) -> Option<&str> {
self.dir.as_deref()
}
/// Set the library folder and read its libraries (unreadable files are skipped).
pub fn set_dir(&mut self, dir: Option<String>) {
self.dir = dir;
self.libs.clear();
if let Some(dir) = self.dir.clone() {
for path in library_paths(&dir) {
let id = stem_of(&path);
let lib = super::fileio::file_stamp(&path)
.filter(|(len, _)| *len <= MAX_FILE)
.and_then(|_| super::fileio::read_file(&path).ok())
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
if let Some(mut lib) = lib {
lib.cap();
self.libs.push((id, lib));
}
}
}
self.sort();
}
fn sort(&mut self) {
self.libs.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()).then(a.0.cmp(&b.0)));
}
/// The libraries, as (id, library), in name order.
pub fn all(&self) -> &[(String, Library)] {
&self.libs
}
/// The library the panel shows: the one chosen last, else the first.
pub fn current(&self) -> Option<&str> {
self.current.as_deref().filter(|c| self.get(c).is_some()).or_else(|| self.libs.first().map(|(i, _)| i.as_str()))
}
pub fn get(&self, id: &str) -> Option<&Library> {
self.libs.iter().find(|(i, _)| i == id).map(|(_, l)| l)
}
/// Library `key` by id, else by name (ignoring case).
fn find(&self, key: &str) -> Option<String> {
self.libs.iter().find(|(i, _)| i == key).or_else(|| self.libs.iter().find(|(_, l)| l.name.eq_ignore_ascii_case(key))).map(|(i, _)| i.clone())
}
/// 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 };
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() }));
if let Err(e) = self.save(&id) {
self.libs.retain(|(i, _)| i != &id);
return Err(e);
}
self.sort();
self.current = Some(id.clone());
Ok(id)
}
#[cfg(not(target_arch = "wasm32"))]
fn file_exists(&self, id: &str) -> bool {
self.path(id).is_some_and(|p| std::path::Path::new(&p).exists())
}
#[cfg(target_arch = "wasm32")]
fn file_exists(&self, _: &str) -> bool {
false
}
fn path(&self, id: &str) -> Option<String> {
let dir = self.dir.as_deref()?;
Some(std::path::Path::new(dir).join(format!("{id}.{LIBRARY_EXT}")).to_string_lossy().into_owned())
}
/// Write library `id` to its file (nothing to do without a folder).
fn save(&self, id: &str) -> Result<()> {
let (Some(path), Some(lib)) = (self.path(id), self.get(id)) else { return Ok(()) };
if let Some(dir) = self.dir.as_deref() {
super::fileio::create_dir(dir)?;
}
let bytes = serde_json::to_vec(lib).map_err(|e| EngineError::Other(e.to_string()))?;
super::fileio::write_file(&path, &bytes)
}
/// Change library `id` with `f` and save it; on a failed save the change is undone.
fn change<T>(&mut self, id: &str, f: impl FnOnce(&mut Library) -> Result<T>) -> Result<T> {
let i = self.libs.iter().position(|(i, _)| i == id).ok_or_else(|| EngineError::Other(format!("no library `{id}`")))?;
let before = self.libs.get(i).map(|(_, l)| l.clone()).unwrap_or_default();
let r = match self.libs.get_mut(i) {
Some((_, lib)) => f(lib)?,
None => return Err(EngineError::Other(format!("no library `{id}`"))),
};
if let Err(e) = self.save(id) {
if let Some((_, lib)) = self.libs.get_mut(i) {
*lib = before;
}
return Err(e);
}
self.sort();
Ok(r)
}
fn delete(&mut self, id: &str) -> Result<()> {
if let Some(path) = self.path(id) {
remove_file(&path)?;
}
self.libs.retain(|(i, _)| i != id);
if self.current.as_deref() == Some(id) {
self.current = None;
}
Ok(())
}
}
impl Library {
/// At most [`MAX_ITEMS`] of each kind (a hand-edited file may hold more).
fn cap(&mut self) {
self.colors.truncate(MAX_ITEMS);
self.char_styles.truncate(MAX_ITEMS);
self.para_styles.truncate(MAX_ITEMS);
self.graphics.truncate(MAX_ITEMS);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn library_paths(dir: &str) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut v: Vec<String> = rd
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x.to_string_lossy().eq_ignore_ascii_case(LIBRARY_EXT)))
.map(|p| p.to_string_lossy().into_owned())
.collect();
v.sort();
v
}
#[cfg(target_arch = "wasm32")]
fn library_paths(_: &str) -> Vec<String> {
vec![]
}
#[cfg(not(target_arch = "wasm32"))]
fn remove_file(path: &str) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(EngineError::Other(format!("{path}: {e}"))),
}
}
#[cfg(target_arch = "wasm32")]
fn remove_file(_: &str) -> Result<()> {
Ok(())
}
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())
}
/// The kinds of library item.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Graphic,
FillColor,
StrokeColor,
CharStyle,
ParaStyle,
}
impl Kind {
fn of(p: &Value, cmd: &str) -> Result<Self> {
Ok(match str_param(p, "kind") {
Some("graphic") => Kind::Graphic,
Some("fillColor") => Kind::FillColor,
Some("strokeColor") => Kind::StrokeColor,
Some("color") => Kind::FillColor,
Some("charStyle") => Kind::CharStyle,
Some("paraStyle") => Kind::ParaStyle,
_ => return Err(bad(cmd, "kind must be graphic, fillColor, strokeColor, charStyle or paraStyle")),
})
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "library.list",
"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)}",
always,
list
),
cmd!(
query "library.get",
"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}]}",
always,
get
),
cmd!(
"library.setCurrent",
"Show Library",
[],
None,
"{library: id or name} the library the Libraries panel shows, which library commands without a `library` act on → {id}",
always,
set_current
),
cmd!(
"library.create",
"Create New Library",
[],
None,
"{name?: \"My Library\"} a new empty library (its file in the library folder), made the current one → {id, name}",
always,
create
),
cmd!(
"library.rename",
"Rename Library",
[],
None,
"{library?, name} rename a library (default: the current one) → {id, name}",
always,
rename
),
cmd!(
"library.delete",
"Delete Library",
[],
None,
"{library?} delete a library (default: the current one) and its file; not undoable → null",
always,
delete
),
cmd!(
"library.add",
"Add to Library",
[],
None,
"{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|charStyle|paraStyle, ids?: objects (default: the selection), name?} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). An item the library already has (same kind and contents) isn't added again → {library, kind, name, id?: graphic id, existing: bool}",
has_doc,
add
),
cmd!(
"library.use",
"Use Library Item",
[],
None,
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colours; default: the kind's)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colours: paint the selection (and the defaults) as paint.setFill/paint.setStroke; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}",
has_doc,
use_item
),
cmd!("library.removeItem", "Delete Library Item", [], None, "{library?, kind, item: name (graphic: id or name)} → null", always, remove_item),
]
}
/// The library `p` names (`library`: id or name), else the current one.
fn library_id(s: &Session, p: &Value, cmd: &str) -> Result<String> {
match str_param(p, "library") {
Some(key) => s.libraries.find(key).ok_or_else(|| bad(cmd, format!("no library `{key}`"))),
None => s.libraries.current().map(str::to_string).ok_or_else(|| bad(cmd, "there is no library: create one first")),
}
}
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.setCurrent";
let key = str_param(p, "library").ok_or_else(|| bad(C, "missing `library`"))?;
let id = s.libraries.find(key).ok_or_else(|| bad(C, format!("no library `{key}`")))?;
s.libraries.current = Some(id.clone());
Ok(json!({ "id": id }))
}
fn counts(lib: &Library) -> Value {
json!({
"graphics": lib.graphics.len(),
"colors": lib.colors.len(),
"charStyles": lib.char_styles.len(),
"paraStyles": lib.para_styles.len(),
})
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let libraries: Vec<Value> = s
.libraries
.all()
.iter()
.map(|(id, lib)| {
let mut v = json!({ "id": id, "name": lib.name });
if let (Some(o), Value::Object(c)) = (v.as_object_mut(), counts(lib)) {
o.extend(c);
}
v
})
.collect();
Ok(json!({ "libraries": libraries, "current": s.libraries.current(), "folder": s.libraries.dir() }))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let id = library_id(s, p, "library.get")?;
let lib = s.libraries.get(&id).ok_or_else(|| bad("library.get", format!("no library `{id}`")))?;
Ok(json!({
"id": id,
"name": lib.name,
"graphics": lib.graphics.iter().map(|g| json!({"id": g.id, "name": g.name, "width": g.width, "height": g.height, "thumbnail": g.thumbnail})).collect::<Vec<_>>(),
"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<_>>(),
}))
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("My Library").to_string();
let id = s.libraries.create(&name)?;
Ok(json!({ "id": id, "name": name }))
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.rename";
let id = library_id(s, p, C)?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
let n = name.clone();
s.libraries.change(&id, |l| {
l.name = n;
Ok(())
})?;
Ok(json!({ "id": id, "name": name }))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let id = library_id(s, p, "library.delete")?;
s.libraries.delete(&id)?;
ok()
}
/// The library `p` names, else the current one, else a new "My Library".
fn target_library(s: &mut Session, p: &Value, cmd: &str) -> Result<String> {
if p.get("library").is_some_and(|v| !v.is_null()) || s.libraries.current().is_some() {
return library_id(s, p, cmd);
}
s.libraries.create("My Library")
}
/// `base`, or `base 2`, `base 3`… the first that `taken` doesn't hold.
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.add";
let kind = Kind::of(p, C)?;
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
// Read what is added before a library is made for it, so a failure makes none.
let item = match kind {
Kind::Graphic => Item::Graphic(graphic_of(s, p, C)?),
Kind::FillColor | Kind::StrokeColor => Item::Color(color_of(s, p, kind == Kind::StrokeColor, C)?),
Kind::CharStyle | Kind::ParaStyle => {
let para = kind == Kind::ParaStyle;
let attrs = super::textstyles::selected_style_attrs(s, para)?.ok_or_else(|| bad(C, "select some type first"))?;
Item::Style(para, attrs)
}
};
let id = target_library(s, p, C)?;
let kind_name = str_param(p, "kind").unwrap_or_default().to_string();
let r = s.libraries.change(&id, |lib| {
let full = |n: usize| if n >= MAX_ITEMS { Err(bad(C, format!("the library holds {MAX_ITEMS} items of this kind already"))) } else { Ok(()) };
Ok(match item {
Item::Graphic(mut g) => {
full(lib.graphics.len())?;
if let Some(old) = lib.graphics.iter().find(|o| o.data == g.data) {
return Ok(json!({ "name": old.name, "id": old.id, "existing": true }));
}
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(' ', "-");
let v = json!({ "name": g.name, "id": g.id, "existing": false });
lib.graphics.push(g);
v
}
Item::Color((name, color)) => {
full(lib.colors.len())?;
if let Some(old) = lib.colors.iter().find(|o| o.color == color) {
return Ok(json!({ "name": old.name, "existing": true }));
}
let name = unique(&given.clone().unwrap_or(name), |n| lib.colors.iter().any(|o| o.name == n));
lib.colors.push(LibraryColor { name: name.clone(), color });
json!({ "name": name, "existing": false })
}
Item::Style(para, attrs) => {
let list = if para { &mut lib.para_styles } else { &mut lib.char_styles };
full(list.len())?;
if let Some(old) = list.iter().find(|o| o.attrs == attrs) {
return Ok(json!({ "name": old.name, "existing": true }));
}
let base = given.clone().unwrap_or_else(|| style_name(&attrs, para));
let name = unique(&base, |n| list.iter().any(|o| o.name == n));
list.push(TextStyleDef { name: name.clone(), attrs });
json!({ "name": name, "existing": false })
}
})
})?;
let mut r = r;
r["library"] = json!(id);
r["kind"] = json!(kind_name);
Ok(r)
}
enum Item {
Graphic(LibraryGraphic),
Color((String, Color)),
Style(bool, Map<String, Value>),
}
/// The selected objects (or `ids`) as a library graphic (its id and name set by the caller).
fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result<LibraryGraphic> {
let st = s.doc()?;
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => st.selection.in_paint_order(&st.doc),
};
let roots = super::edit::roots_of(&st.doc, ids);
if roots.is_empty() {
return Err(bad(cmd, "select the art to add first"));
}
let clip = Clipboard::copy(st, &roots);
let bounds = clip.bounds().ok_or_else(|| bad(cmd, "the selection draws nothing"))?;
let mut doc = clip.to_document();
let board = bounds.inflate(1.0, 1.0);
// One artboard round the art: what the thumbnail shows.
doc.artboards.truncate(1);
match doc.artboards.first_mut() {
Some(a) => a.rect = board,
None => return Err(bad(cmd, "the graphic's document has no artboard")),
}
let name = match roots.as_slice() {
[one] => st.doc.node(*one).map(|n| n.display_name()).unwrap_or_default(),
_ => String::new(),
};
let name = if name.trim().is_empty() || name.starts_with('<') { "Graphic".to_string() } else { name };
let thumbnail = super::fileio::preview_png(&doc)?.map(|b| vectorcraft_format::base64_encode(&b)).unwrap_or_default();
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&doc, false));
Ok(LibraryGraphic { id: String::new(), name, width: bounds.width(), height: bounds.height(), data, thumbnail })
}
/// The first selected object's solid fill (`stroke`: stroke) colour, else the default one, with a
/// name: its swatch's, else its hex.
fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<(String, Color)> {
// A colour given (a swatch dropped on the panel).
if let Some(c) = p.get("color").filter(|v| !v.is_null()) {
let color = color_value(c).ok_or_else(|| bad(cmd, "bad colour"))?;
return Ok((color.to_hex(), color));
}
let st = s.doc()?;
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => st.selection.objects.clone(),
};
let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| {
let paint = if stroke { n.appearance.stroke().map(|x| &x.paint) } else { n.appearance.fill().map(|f| &f.paint) };
paint.cloned()
});
let paint = match from_art {
Some(p) => p,
None => {
let d = &s.paint;
if stroke { d.stroke.clone() } else { d.fill.clone() }
}
};
match paint {
Paint::Solid { color, swatch, .. } => {
let name = swatch.filter(|n| !n.is_empty()).unwrap_or_else(|| color.to_hex());
Ok((name, color))
}
Paint::None => Err(bad(cmd, if stroke { "the selection has no stroke colour" } else { "the selection has no fill colour" })),
_ => Err(bad(cmd, "only solid colours can be added (not gradients or patterns)")),
}
}
/// A character style's name from its font, style and size ("Source Sans 3 Bold 24 pt"); a
/// paragraph style's from its alignment and size too, else "Paragraph Style".
fn style_name(attrs: &Map<String, Value>, para: bool) -> String {
let s = |k: &str| attrs.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
let size = attrs.get("size").and_then(Value::as_f64).map(|v| format!("{} pt", (v * 100.0).round() / 100.0));
let words: Vec<String> = [s("font_family"), s("font_style")].into_iter().chain(size).filter(|w| !w.is_empty()).collect();
match (para, words.is_empty()) {
(false, false) => words.join(" "),
(false, true) => "Character Style".into(),
(true, _) => "Paragraph Style".into(),
}
}
/// The library item `p` names: (library id, kind).
fn item_param<'a>(s: &Session, p: &'a Value, cmd: &str) -> Result<(String, Kind, &'a str)> {
let id = library_id(s, p, cmd)?;
let kind = Kind::of(p, cmd)?;
let item = str_param(p, "item").ok_or_else(|| bad(cmd, "missing `item`"))?;
Ok((id, kind, item))
}
fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.use";
// A replay (the journal) carries what the item was, so it doesn't depend on the library.
let recorded = p.get("recorded").cloned();
let (kind, data) = match recorded {
Some(r) => (Kind::of(p, C)?, r),
None => {
let (id, kind, item) = item_param(s, p, C)?;
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
let missing = || bad(C, format!("the library has no item `{item}`"));
let data = match kind {
Kind::Graphic => {
let g =
lib.graphics.iter().find(|g| g.id == item).or_else(|| lib.graphics.iter().find(|g| g.name == item)).ok_or_else(missing)?;
json!({ "data": g.data })
}
Kind::FillColor | Kind::StrokeColor => {
let c = lib.colors.iter().find(|c| c.name == item).ok_or_else(missing)?;
json!({ "color": c.color })
}
Kind::CharStyle | Kind::ParaStyle => {
let list = if kind == Kind::ParaStyle { &lib.para_styles } else { &lib.char_styles };
let t = list.iter().find(|t| t.name == item).ok_or_else(missing)?;
json!({ "name": t.name, "attrs": t.attrs })
}
};
s.note_journal("recorded", data.clone());
(kind, data)
}
};
match kind {
Kind::Graphic => {
let bytes = data
.get("data")
.and_then(Value::as_str)
.and_then(vectorcraft_format::base64_decode)
.ok_or_else(|| bad(C, "the graphic's data is damaged"))?;
let doc = vectorcraft_format::load(&bytes).map_err(|e| bad(C, format!("the graphic can't be read: {e}")))?;
let clip = Clipboard::from_document(&doc);
if clip.is_empty() {
return Err(bad(C, "the graphic is empty"));
}
let center = match point_param(p, "center") {
Some(c) => c,
None => s.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(),
};
let mut r = super::edit::place_clip(s, &clip, center)?;
r["kind"] = json!("graphic");
Ok(r)
}
Kind::FillColor | Kind::StrokeColor => {
let stroke = match str_param(p, "to") {
Some("fill") => false,
Some("stroke") => true,
Some(_) => return Err(bad(C, "to must be fill or stroke")),
None => kind == Kind::StrokeColor,
};
let color = data.get("color").cloned().ok_or_else(|| bad(C, "no colour"))?;
s.execute(if stroke { "paint.setStroke" } else { "paint.setFill" }, &json!({ "color": color }))?;
Ok(json!({ "kind": if stroke { "strokeColor" } else { "fillColor" } }))
}
Kind::CharStyle | Kind::ParaStyle => {
let para = kind == Kind::ParaStyle;
let name = data.get("name").and_then(Value::as_str).unwrap_or("Style").to_string();
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
let doc = &s.doc()?.doc;
// The document's style of that name when it is the same; else a new one.
let same = super::textstyles::style_attrs(doc, para, &name).is_some_and(|a| a == attrs);
let style = if same { name } else { unique(&name, |n| super::textstyles::style_attrs(doc, para, n).is_some()) };
let prefix = if para { "paraStyle" } else { "charStyle" };
in_one_step(s, |s| {
if !same {
s.execute(&format!("{prefix}.new"), &json!({ "name": style, "attrs": attrs }))?;
}
s.execute(&format!("{prefix}.apply"), &json!({ "name": style }))
})?;
Ok(json!({ "kind": if para { "paraStyle" } else { "charStyle" }, "style": style }))
}
}
}
/// Run `f`'s commands as one undo step (undone together when it fails), unless an undo group is
/// already open (its own step then holds them).
fn in_one_step<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
let open = s.active().is_some_and(|st| st.undo_group.is_some());
if !open {
s.begin_undo_group();
}
let r = f(s);
if !open {
s.end_undo_group(r.is_err());
}
r
}
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.removeItem";
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());
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}`")));
}
Ok(())
})?;
ok()
}
+2
View File
@@ -32,6 +32,7 @@ pub mod help;
pub(crate) mod inline;
mod layer;
mod layerpanel;
pub mod library;
pub mod links;
mod live;
pub(crate) mod maskedit;
@@ -245,6 +246,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(freeform::specs());
v.extend(flatten::specs());
v.extend(stylelib::specs());
v.extend(library::specs());
v.extend(expand::specs());
v.extend(attributes::specs());
v.extend(newart::specs());
+11
View File
@@ -241,6 +241,17 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
}))
}
/// The selected text's character (`para`: paragraph) attributes, every one of them: what a
/// library keeps of a text style. `None` without text selected.
pub(crate) fn selected_style_attrs(s: &Session, para: bool) -> Result<Option<Map<String, Value>>> {
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char })
}
/// Whether the document has a character (`para`: paragraph) style `name`, and its attributes.
pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option<Map<String, Value>> {
if para { Kind::Para } else { Kind::Char }.attrs(d, name)
}
fn name_param<'a>(p: &'a Value, kind: Kind, verb: &str) -> Result<&'a str> {
str_param(p, "name").ok_or_else(|| bad(&kind.cmd(verb), "missing `name`"))
}
+5
View File
@@ -829,6 +829,8 @@ pub struct Session {
pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
/// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved.
pub style_libraries: cmd::stylelib::Libraries,
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
pub libraries: cmd::library::Libraries,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
@@ -899,6 +901,7 @@ impl Session {
swatch_libraries: Default::default(),
freeform_point: None,
style_libraries: Default::default(),
libraries: Default::default(),
recent_urls: vec![],
ui_language: None,
journal_note: Default::default(),
@@ -1475,6 +1478,8 @@ mod tests_layerclip;
#[cfg(test)]
mod tests_layers;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_linked_stops;
#[cfg(test)]
mod tests_links;
+163
View File
@@ -0,0 +1,163 @@
//! The Libraries panel's libraries (#745): making, renaming and deleting them, adding graphics,
//! colours and text styles from the selection, using them in another document, and keeping them
//! in files across sessions.
use serde_json::{Value, json};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len())
}
fn hex_of(s: &Session, id: &Value, stroke: bool) -> String {
let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
let paint = if stroke { n.appearance.stroke().unwrap().paint.clone() } else { n.appearance.fill().unwrap().paint.clone() };
paint.color().unwrap().to_hex()
}
#[test]
fn libraries_are_made_renamed_and_deleted() {
let mut s = session();
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"], json!([]));
let id = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
let other = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
assert_ne!(id, other, "two libraries of one name are two files");
run(&mut s, "library.rename", json!({"library": other, "name": "Client"}));
let list = run(&mut s, "library.list", json!({}));
let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Brand", "Client"]);
assert!(list["folder"].is_null(), "no folder: libraries last for the session");
run(&mut s, "library.delete", json!({"library": "client"}));
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"].as_array().unwrap().len(), 1);
assert!(s.execute("library.get", &json!({"library": "Client"})).is_err());
}
#[test]
fn a_graphic_goes_into_a_library_and_comes_out_in_another_document() {
let mut s = session();
let id = run(&mut s, "shape.ellipse", json!({"x": 10, "y": 10, "width": 40, "height": 30}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#cc3300", "ids": [id]}));
run(&mut s, "select.set", json!({"ids": [id]}));
// With no library yet, adding makes "My Library".
let added = run(&mut s, "library.add", json!({"kind": "graphic"}));
assert_eq!(added["existing"], false);
let lib = added["library"].as_str().unwrap().to_string();
let item = added["id"].as_str().unwrap().to_string();
let got = run(&mut s, "library.get", json!({"library": lib}));
assert_eq!(got["name"], "My Library");
let g = &got["graphics"][0];
// Its size as it draws: the 1 pt stroke included.
assert!((g["width"].as_f64().unwrap() - 41.0).abs() < 1e-6 && (g["height"].as_f64().unwrap() - 31.0).abs() < 1e-6, "{g}");
assert!(!g["thumbnail"].as_str().unwrap().is_empty(), "a thumbnail of the art");
// The same art again is the item already there.
assert_eq!(run(&mut s, "library.add", json!({"kind": "graphic"}))["existing"], true);
// Placed in another document, centred where asked, selected, one undo step.
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
let placed = run(&mut s, "library.use", json!({"library": lib, "kind": "graphic", "item": item, "center": [150, 100]}));
let new_id = placed["ids"][0].clone();
let st = s.doc().unwrap();
let b = st.doc.node(NodeId(new_id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap();
assert!((b.center().x - 150.0).abs() < 1e-6 && (b.center().y - 100.0).abs() < 1e-6, "{b:?}");
assert_eq!(st.selection.objects, vec![NodeId(new_id.as_u64().unwrap())]);
assert_eq!(hex_of(&s, &new_id, false), "#cc3300");
assert_eq!(objects(&s), 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(objects(&s), 0);
// A graphic is removed by id.
run(&mut s, "library.removeItem", json!({"library": lib, "kind": "graphic", "item": item}));
assert!(s.execute("library.use", &json!({"library": lib, "kind": "graphic", "item": item})).is_err());
}
#[test]
fn colours_come_from_the_selection_and_paint_it() {
let mut s = session();
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#123456", "ids": [a]}));
run(&mut s, "paint.setStroke", json!({"color": "#abcdef", "ids": [a]}));
run(&mut s, "select.set", json!({"ids": [a]}));
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["name"], "#123456");
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "strokeColor"}))["name"], "#abcdef");
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["existing"], true);
let b = run(&mut s, "shape.rectangle", json!({"x": 50, "y": 0, "width": 20, "height": 20}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [b]}));
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456"}));
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456", "to": "stroke"}));
assert_eq!(hex_of(&s, &b, false), "#123456");
assert_eq!(hex_of(&s, &b, true), "#123456");
assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillColor", "item": "#nope"})).is_err());
}
#[test]
fn text_styles_come_from_the_selected_type_and_apply_in_one_step() {
let mut s = session();
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
assert!(s.execute("library.add", &json!({"library": lib, "kind": "charStyle"})).is_err(), "no type selected");
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Heading", "size": 31}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [t]}));
let name = run(&mut s, "library.add", json!({"library": lib, "kind": "charStyle"}))["name"].as_str().unwrap().to_string();
assert!(name.ends_with("31 pt"), "{name}");
run(&mut s, "library.add", json!({"library": lib, "kind": "paraStyle"}));
// Another document: the style is added and applied, and one undo takes both back.
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let u = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [u]}));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
assert_eq!(r["style"], name);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let size = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(u.as_u64().unwrap())).unwrap().kind {
vectorcraft_doc::NodeKind::Text(t) => t.runs[0].style.size,
_ => 0.0,
};
assert_eq!(size(&s), 31.0);
run(&mut s, "edit.undo", json!({}));
assert_eq!(size(&s), 9.0);
assert!(s.doc().unwrap().doc.char_styles.iter().all(|c| c.name != name), "the style went with the undo");
// A style of that name with other attributes in the document: the library's comes in numbered.
run(&mut s, "charStyle.new", json!({"name": name, "attrs": {"size": 5}}));
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
assert_eq!(r["style"], format!("{name} 2"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn libraries_are_kept_in_files_across_sessions() {
let dir = std::env::temp_dir().join(format!("vc-libraries-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let folder = dir.to_string_lossy().to_string();
let mut s = session();
s.libraries.set_dir(Some(folder.clone()));
let lib = run(&mut s, "library.create", json!({"name": "Kept"}))["id"].as_str().unwrap().to_string();
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [id]}));
run(&mut s, "library.add", json!({"library": lib, "kind": "graphic", "name": "Square"}));
run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}));
let mut t = session();
t.libraries.set_dir(Some(folder.clone()));
let got = run(&mut t, "library.get", json!({"library": "Kept"}));
assert_eq!(got["graphics"][0]["name"], "Square");
assert_eq!(got["colors"].as_array().unwrap().len(), 1);
run(&mut t, "library.delete", json!({"library": "Kept"}));
let mut u = session();
u.libraries.set_dir(Some(folder));
assert_eq!(run(&mut u, "library.list", json!({}))["libraries"], json!([]), "deleting removes the file");
// A damaged file is skipped.
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Bad.vclibrary"), b"{not json").unwrap();
let mut v = session();
v.libraries.set_dir(Some(dir.to_string_lossy().to_string()));
assert_eq!(run(&mut v, "library.list", json!({}))["libraries"], json!([]));
let _ = std::fs::remove_dir_all(&dir);
}
+9 -1
View File
@@ -1295,6 +1295,14 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
return;
}
let Some(d) = resp.dnd_release_payload::<widgets::PanelDrag>() else { return };
// A Libraries panel graphic: a copy centred where it is dropped.
if let widgets::PanelDrag::LibraryGraphic { library, item } = &*d {
let at = xf.to_doc(pos);
if let Err(e) = app.run("library.use", json!({"library": library, "kind": "graphic", "item": item, "center": [at.x, at.y]})) {
app.status(e);
}
return;
}
// A symbol from the Symbols panel: an instance centred where it is dropped, on art or not.
if let widgets::PanelDrag::Symbol(name) = &*d {
let at = xf.to_doc(pos);
@@ -1333,7 +1341,7 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
}
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
// above.)
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) => return,
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) | widgets::PanelDrag::LibraryGraphic { .. } => return,
};
if let Err(e) = app.run(cmd, params) {
app.status(e);
+19
View File
@@ -2648,6 +2648,24 @@
Swatch Tiles Mosaicos de muestras
Swatches Muestras
Switch workspace Cambiar espacio de trabajo
Add Content Añadir contenido
Click to apply to the selected type Haz clic para aplicarlo al texto seleccionado
Create New Library Crear nueva biblioteca
Create New Library… Crear nueva biblioteca…
Rename Library… Cambiar nombre de la biblioteca…
Delete the library “{name}”? ¿Eliminar la biblioteca “{name}”?
Drag art or swatches here, or use + to add the selection's colors and text styles. Arrastra aquí ilustraciones o muestras, o usa + para añadir los colores y estilos de texto de la selección.
Its graphics, colors and text styles are deleted from this machine. Sus gráficos, colores y estilos de texto se eliminan de este equipo.
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Guarda aquí gráficos, colores y estilos de texto para usarlos en cualquier documento. Las bibliotecas se guardan en este equipo; no hace falta una cuenta.
This library is empty Esta biblioteca está vacía
Add to Library Añadir a la biblioteca
Delete Library Eliminar biblioteca
Delete Library Item Eliminar elemento de la biblioteca
Library Biblioteca
Rename Library Cambiar nombre de la biblioteca
Show Library Mostrar biblioteca
Use Library Item Usar elemento de la biblioteca
Graphic Gráfico
Show all open documents Mostrar todos los documentos abiertos
Symbol Instance Instancia de símbolo
Symbol Libraries Bibliotecas de símbolos
@@ -3130,6 +3148,7 @@ axis Both Ambos
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
@msg no library `{id}` no hay ninguna biblioteca `{id}`
@msg select some type first selecciona primero algún texto
@msg {_1} has no {_2} style {_1} no tiene el estilo {_2}
@msg a layer couldn't be read no se pudo leer una capa
Can't render this file because it contains an unexpected character in line 2 and column 73.
+19
View File
@@ -2645,6 +2645,24 @@
Swatch Tiles Carreaux de nuances
Swatches Nuancier
Switch workspace Changer d’espace de travail
Add Content Ajouter du contenu
Click to apply to the selected type Cliquez pour l’appliquer au texte sélectionné
Create New Library Créer une bibliothèque
Create New Library… Créer une bibliothèque…
Rename Library… Renommer la bibliothèque…
Delete the library “{name}”? Supprimer la bibliothèque « {name} » ?
Drag art or swatches here, or use + to add the selection's colors and text styles. Faites glisser ici des illustrations ou des nuances, ou utilisez + pour ajouter les couleurs et les styles de texte de la sélection.
Its graphics, colors and text styles are deleted from this machine. Ses graphismes, couleurs et styles de texte sont supprimés de cet ordinateur.
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Conservez ici des graphismes, des couleurs et des styles de texte pour les utiliser dans n’importe quel document. Les bibliothèques sont stockées sur cet ordinateur, sans compte.
This library is empty Cette bibliothèque est vide
Add to Library Ajouter à la bibliothèque
Delete Library Supprimer la bibliothèque
Delete Library Item Supprimer l’élément de la bibliothèque
Library Bibliothèque
Rename Library Renommer la bibliothèque
Show Library Afficher la bibliothèque
Use Library Item Utiliser l’élément de la bibliothèque
Graphic Graphisme
Show all open documents Afficher tous les documents ouverts
Symbol Instance Occurrence de symbole
Symbol Libraries Bibliothèques de symboles
@@ -3127,6 +3145,7 @@ axis Both Les deux
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
@msg no library `{id}` aucune bibliothèque `{id}`
@msg select some type first sélectionnez d’abord du texte
@msg {_1} has no {_2} style {_1} n’a pas de style {_2}
@msg a layer couldn't be read un calque n’a pas pu être lu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+19
View File
@@ -2648,6 +2648,24 @@
Swatch Tiles Riquadri campioni
Swatches Campioni
Switch workspace Cambia area di lavoro
Add Content Aggiungi contenuto
Click to apply to the selected type Fai clic per applicarlo al testo selezionato
Create New Library Crea nuova libreria
Create New Library… Crea nuova libreria…
Rename Library… Rinomina libreria…
Delete the library “{name}”? Eliminare la libreria “{name}”?
Drag art or swatches here, or use + to add the selection's colors and text styles. Trascina qui grafica o campioni, oppure usa + per aggiungere i colori e gli stili di testo della selezione.
Its graphics, colors and text styles are deleted from this machine. Grafiche, colori e stili di testo vengono eliminati da questo computer.
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Conserva qui grafiche, colori e stili di testo per usarli in qualsiasi documento. Le librerie restano su questo computer: non serve un account.
This library is empty Questa libreria è vuota
Add to Library Aggiungi alla libreria
Delete Library Elimina libreria
Delete Library Item Elimina elemento della libreria
Library Libreria
Rename Library Rinomina libreria
Show Library Mostra libreria
Use Library Item Usa elemento della libreria
Graphic Grafica
Show all open documents Mostra tutti i documenti aperti
Symbol Instance Istanza di simbolo
Symbol Libraries Librerie simboli
@@ -3130,6 +3148,7 @@ axis Both Entrambi
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
@msg no library `{id}` nessuna libreria `{id}`
@msg select some type first seleziona prima del testo
@msg {_1} has no {_2} style {_1} non ha lo stile {_2}
@msg a layer couldn't be read non è stato possibile leggere un livello
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -2422,6 +2422,24 @@
Swatch Options… スウォッチオプション…
Swatch Tiles スウォッチタイル
Switch workspace ワークスペースを切り替え
Add Content コンテンツを追加
Click to apply to the selected type クリックして選択したテキストに適用
Create New Library 新規ライブラリを作成
Create New Library… 新規ライブラリを作成…
Rename Library… ライブラリ名を変更…
Delete the library “{name}”? ライブラリ「{name}」を削除しますか?
Drag art or swatches here, or use + to add the selection's colors and text styles. ここにアートやスウォッチをドラッグするか、+ で選択範囲のカラーやテキストスタイルを追加します。
Its graphics, colors and text styles are deleted from this machine. グラフィック、カラー、テキストスタイルはこのコンピューターから削除されます。
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. グラフィック、カラー、テキストスタイルをここに保存すると、どのドキュメントでも使えます。ライブラリはこのコンピューターに保存され、アカウントは不要です。
This library is empty このライブラリは空です
Add to Library ライブラリに追加
Delete Library ライブラリを削除
Delete Library Item ライブラリ項目を削除
Library ライブラリ
Rename Library ライブラリ名を変更
Show Library ライブラリを表示
Use Library Item ライブラリ項目を使用
Graphic グラフィック
Show all open documents 開いているすべてのドキュメントを表示
Symbol Libraries (on the roadmap) シンボルライブラリ (今後対応予定)
Symbol Screener Tool シンボルスクリーナーツール
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20 -4
View File
@@ -68,8 +68,9 @@ fn plural_french(n: u64) -> usize {
usize::from(n > 1)
}
/// Russian: 1 (but not 11) is `one`, 2–4 (but not 12–14) `few`, everything else `many`.
fn plural_russian(n: u64) -> usize {
/// Russian and Ukrainian integer counts: 1 (but not 11) is `one`, 2–4 (but not 12–14)
/// `few`, everything else `many`.
fn plural_east_slavic(n: u64) -> usize {
match n % 100 {
11..=14 => 2,
_ => match n % 10 {
@@ -81,7 +82,7 @@ fn plural_russian(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 10] = [
pub static LANGUAGES: [LangInfo; 11] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Japanese: the whole interface (every menu string and `tl!` literal), keeping the product,
// workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
@@ -114,7 +115,22 @@ pub static LANGUAGES: [LangInfo; 10] = [
// Russian: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` …) resolves here.
LangInfo {
code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new()
code: "ru",
name: "Русский",
source: include_str!("ru.tsv"),
plural: plural_east_slavic,
complete_menus: true,
catalog: OnceLock::new(),
},
// Ukrainian: the whole interface and the status and error messages, with one/few/many
// plural forms; every `uk-*` locale (including `uk-UA`) resolves here.
LangInfo {
code: "uk",
name: "Українська",
source: include_str!("uk.tsv"),
plural: plural_east_slavic,
complete_menus: true,
catalog: OnceLock::new(),
},
// Brazilian Portuguese: every menu label (`menu_catalogs_translate_every_menu_label`), every
// `tl!` literal and the plural messages; left English on purpose are the `MENU_KEEP_AS_IS`
+19
View File
@@ -2644,6 +2644,24 @@
Swatch Tiles Плитки образцов
Swatches Образцы
Switch workspace Переключить рабочую среду
Add Content Добавить содержимое
Click to apply to the selected type Щёлкните, чтобы применить к выделенному тексту
Create New Library Создать библиотеку
Create New Library… Создать библиотеку…
Rename Library… Переименовать библиотеку…
Delete the library “{name}”? Удалить библиотеку «{name}»?
Drag art or swatches here, or use + to add the selection's colors and text styles. Перетащите сюда графику или образцы или нажмите +, чтобы добавить цвета и стили текста выделенного.
Its graphics, colors and text styles are deleted from this machine. Её графика, цвета и стили текста будут удалены с этого компьютера.
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Храните здесь графику, цвета и стили текста, чтобы использовать их в любом документе. Библиотеки хранятся на этом компьютере — учётная запись не нужна.
This library is empty Эта библиотека пуста
Add to Library Добавить в библиотеку
Delete Library Удалить библиотеку
Delete Library Item Удалить элемент библиотеки
Library Библиотека
Rename Library Переименовать библиотеку
Show Library Показать библиотеку
Use Library Item Использовать элемент библиотеки
Graphic Графика
Show all open documents Показать все открытые документы
Symbol Instance Экземпляр символа
Symbol Libraries Библиотеки символов
@@ -3126,6 +3144,7 @@ axis Both Оба
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
@msg no library `{id}` нет библиотеки `{id}`
@msg select some type first сначала выделите текст
@msg {_1} has no {_2} style у {_1} нет начертания {_2}
@msg a layer couldn't be read не удалось прочитать слой
Can't render this file because it contains an unexpected character in line 2 and column 73.
+84 -6
View File
@@ -19,6 +19,9 @@ fn tags_map_to_languages() {
for tag in ["fr", "fr_FR.UTF-8", "fr-BE", "fr_CA", "fr-CH", "fr-LU"] {
assert_eq!(lang_from_tag(tag), Lang::from_code("fr"), "{tag}");
}
for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", "uk_UA.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(uk()), "{tag}");
}
// Traditional Chinese: by region, by script, and with a region after the script.
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
@@ -64,6 +67,7 @@ fn preferences_resolve_with_fallback() {
assert_eq!(Lang::from_pref("zh-hant"), ZH());
assert_eq!(Lang::from_pref("ZH-Hant"), ZH());
assert_eq!(Lang::from_pref("en"), Lang::EN);
assert_eq!(Lang::from_pref("UK"), uk());
// `auto` and unknown codes follow the system (English under test).
assert_eq!(Lang::from_pref("auto"), Lang::EN);
assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN);
@@ -419,7 +423,7 @@ fn cs() -> Lang {
}
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
const KEEPS_MENU_NAMES: [&str; 7] = ["cs", "es", "fr", "it", "ja", "pt-br", "ru"];
const KEEPS_MENU_NAMES: [&str; 8] = ["cs", "es", "fr", "it", "ja", "pt-br", "ru", "uk"];
/// Menu labels the menu-complete catalogs (Czech, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
@@ -568,6 +572,10 @@ fn ru() -> Lang {
Lang::from_code("ru").expect("ru registered")
}
fn uk() -> Lang {
Lang::from_code("uk").expect("uk registered")
}
/// Spanish uses the vector-illustration vocabulary its users know, has two plural forms like
/// English, and reads the same in the menus and in the panels.
#[test]
@@ -655,6 +663,76 @@ fn russian_reads_as_russian() {
assert_eq!(trn(ru(), 22, "{n} Layer", "{n} Layers"), "22 слоя");
}
#[test]
fn ukrainian_reads_as_ukrainian() {
for (en, want) in [
("Artboard Tool", "Інструмент «Монтажна область»"),
("Swatches", "Зразки"),
("Pathfinder", "Обробка контурів"),
("Stroke", "Обведення"),
("Fill", "Заливка"),
("Direct Selection Tool", "Інструмент «Пряме виділення»"),
("Save As…", "Зберегти як…"),
("Undo", "Скасувати"),
] {
assert_eq!(tr(uk(), en), want);
}
assert_eq!(tr_ctx(uk(), "axis", "Both"), "Обидві");
for (n, word) in [
(0, "шарів"),
(1, "шар"),
(2, "шари"),
(5, "шарів"),
(11, "шарів"),
(21, "шар"),
(22, "шари"),
(111, "шарів"),
(112, "шарів"),
(121, "шар"),
(124, "шари"),
] {
assert_eq!(trn(uk(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}"));
}
let nested = "command `object.group` is not available right now: nothing selected";
assert_eq!(message(uk(), nested), "команда `object.group` зараз недоступна: нічого не виділено");
}
/// Every Ukrainian plural has all three forms; exercise different endings through real lookups.
#[test]
fn ukrainian_plurals_have_three_forms() {
let (entries, _) = parse_entries(uk().0.source);
let mut count = 0;
for (ctx, src, translated) in entries {
if ctx != "@plural" {
continue;
}
count += 1;
let (one, other) = src.split_once('|').expect("one|other source");
let forms: Vec<_> = translated.split('|').collect();
assert_eq!(forms.len(), 3, "{src}");
for (n, form) in [
(1, 0),
(2, 1),
(4, 1),
(0, 2),
(5, 2),
(11, 2),
(12, 2),
(14, 2),
(21, 0),
(24, 1),
(111, 2),
(114, 2),
(121, 0),
(122, 1),
(u64::MAX, 2),
] {
assert_eq!(trn(uk(), n, one, other), forms[form].replace("{n}", &n.to_string()), "{src}: {n}");
}
}
assert!(count >= 13, "Ukrainian plural rows missing");
}
/// Catalogs written with spaces between words keep a fragment's leading and trailing spaces: the
/// hint bar and a few labels are joined from pieces (" to finish", "Press ").
#[test]
@@ -675,7 +753,7 @@ fn czech_plurals_have_three_forms() {
#[test]
fn russian_plurals_have_three_forms() {
let forms: Vec<usize> = [0, 1, 2, 4, 5, 11, 21, 22, 25, 111, 121].into_iter().map(plural_russian).collect();
let forms: Vec<usize> = [0, 1, 2, 4, 5, 11, 21, 22, 25, 111, 121].into_iter().map(plural_east_slavic).collect();
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 0, 1, 2, 2, 0]);
}
@@ -702,10 +780,10 @@ fn latin_script_glyphs_are_available_without_system_fonts() {
});
}
/// Russian letters (and the punctuation the catalog uses) come from each family's own first font, not
/// Russian and Ukrainian letters (and their punctuation) come from each family's own first font, not
/// from a fallback further down the stack.
#[test]
fn russian_glyphs_are_available_without_system_fonts() {
fn cyrillic_glyphs_are_available_without_system_fonts() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
@@ -716,7 +794,7 @@ fn russian_glyphs_are_available_without_system_fonts() {
let first = first.unwrap();
let mut font = fonts.fonts.font(&family);
let chars = font.characters();
for ch in "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ«»„“…–’".chars()
for ch in "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯґєіїҐЄІЇ«»„“…–’".chars()
{
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
}
@@ -794,7 +872,7 @@ fn complete_languages_translate_every_message() {
}
/// Languages whose catalogs cover every status and error message.
const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ru"];
const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ru", "uk"];
/// Crates whose error and status messages reach the status bar.
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
File diff suppressed because it is too large. Load diff
+18
View File
@@ -2565,6 +2565,24 @@
Swatch Tiles 色板拼贴
Swatches 色板
Switch workspace 切换工作区
Add Content 添加内容
Click to apply to the selected type 单击以应用到所选文字
Create New Library 创建新库
Create New Library… 创建新库…
Rename Library… 重命名库…
Delete the library “{name}”? 要删除库“{name}”吗?
Drag art or swatches here, or use + to add the selection's colors and text styles. 将图稿或色板拖到此处,或使用 + 添加所选对象的颜色和文字样式。
Its graphics, colors and text styles are deleted from this machine. 其图形、颜色和文字样式将从此计算机中删除。
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. 在此保存图形、颜色和文字样式,即可在任何文档中使用。库存储在此计算机上,无需帐户。
This library is empty 此库为空
Add to Library 添加到库
Delete Library 删除库
Delete Library Item 删除库项目
Library 库
Rename Library 重命名库
Show Library 显示库
Use Library Item 使用库项目
Graphic 图形
Show all open documents 显示所有打开的文档
Symbol Instance 符号实例
Symbol Libraries 符号库
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -2651,6 +2651,24 @@
Swatch Tiles 色票拼貼
Swatches 色票
Switch workspace 切換工作區
Add Content 新增內容
Click to apply to the selected type 按一下以套用到所選文字
Create New Library 建立新資料庫
Create New Library… 建立新資料庫…
Rename Library… 重新命名資料庫…
Delete the library “{name}”? 要刪除資料庫「{name}」嗎?
Drag art or swatches here, or use + to add the selection's colors and text styles. 將圖稿或色票拖到此處,或使用 + 加入所選物件的顏色和文字樣式。
Its graphics, colors and text styles are deleted from this machine. 其圖形、顏色和文字樣式將從此電腦中刪除。
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. 在此保存圖形、顏色和文字樣式,即可在任何文件中使用。資料庫儲存在此電腦上,無需帳號。
This library is empty 此資料庫是空的
Add to Library 加入資料庫
Delete Library 刪除資料庫
Delete Library Item 刪除資料庫項目
Library 資料庫
Rename Library 重新命名資料庫
Show Library 顯示資料庫
Use Library Item 使用資料庫項目
Graphic 圖形
Show all open documents 顯示所有開啟的文件
Symbol Instance 符號範例
Symbol Libraries 符號資料庫
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -351,7 +351,7 @@ fn zone_input(ui: &Ui, zone: &Response, d: &Document, ev: &mut Events) {
PanelDrag::GraphicStyle(n) => (Some(n), None),
PanelDrag::Art(ids) => (None, ids.first().copied()),
PanelDrag::Appearance(id) => (None, Some(*id)),
PanelDrag::Paint { .. } | PanelDrag::Symbol(_) | PanelDrag::Brush { .. } => (None, None),
PanelDrag::Paint { .. } | PanelDrag::Symbol(_) | PanelDrag::Brush { .. } | PanelDrag::LibraryGraphic { .. } => (None, None),
};
if style.is_none() && source.is_none() {
return;
+348
View File
@@ -0,0 +1,348 @@
//! Libraries panel (Window › Libraries, #745): the current library's colours, character and
//! paragraph styles and graphics, from the engine's `library.*` commands. Art dragged off the canvas
//! onto the panel, and swatches dragged in, are added to it; the + button adds the selection's
//! 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.
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 crate::theme::Tokens;
use crate::widgets::{self, PanelDrag, menu_item};
use crate::{VectorcraftApp, icons};
/// The size of a graphic's tile.
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).
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) {
app.status(e);
}
}
/// 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 {
Some(n) => ("library.rename", "Rename Library", n.to_string()),
None => ("library.create", "Create New Library", "My Library".to_string()),
};
app.ui.dialog = Some(crate::state::Dialog::new("command", json!({ "__command": cmd, "__label": label, "name": name })));
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some((lib_id, lib)) = current(app) else {
ui.add_space(16.0);
ui.vertical_centered(|ui| {
icons::icon(ui, "library", 40.0, t.text_dim);
ui.add_space(8.0);
ui.label(egui::RichText::new(tl!("Local Libraries")).size(14.0).color(t.text));
widgets::dim_label(ui, tl!("Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required."));
ui.add_space(8.0);
if ui.button(tl!("Create New Library")).clicked() {
ask_name(app, None);
}
});
return;
};
// 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 }));
}
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());
if empty {
super::empty_state(
ui,
"library",
tl!("This library is empty"),
tl!("Drag art or swatches here, or use + to add the selection's colors and text styles."),
);
return ui.min_rect();
}
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() {
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();
}
});
}
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();
}
});
}
});
}
});
ui.min_rect()
});
// 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 })));
}
if let Some(drag) = zone.dnd_hover_payload::<PanelDrag>()
&& let PanelDrag::Paint { paint: Paint::Solid { color, .. }, .. } = &*drag
{
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 })));
}
}
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| {
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 [
("Graphic", "graphic"),
("Fill Color", "fillColor"),
("Stroke Color", "strokeColor"),
("Character Style", "charStyle"),
("Paragraph Style", "paraStyle"),
] {
if menu_item(ui, label, true, false) {
run(app, "library.add", json!({ "library": lib_id, "kind": kind }));
ui.close();
}
}
});
});
}
/// A section heading in the list.
fn heading(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).size(11.5).color(t.text_dim));
ui.add_space(2.0);
}
/// Graphic `g`'s thumbnail on white in `r`.
fn tile(ui: &Ui, r: Rect, library: &str, g: &vectorcraft_engine::cmd::library::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();
let k = ((r.width() - 6.0) / size.x).min((r.height() - 6.0) / size.y).min(1.0);
let fit = Rect::from_center_size(r.center(), size * k);
ui.painter().image(tex.id(), fit, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
}
}
/// 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> {
thread_local! {
static CACHE: crate::graphics::TexCache<HashMap<(String, String, usize), Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
}
let key = (library.to_string(), g.id.clone(), g.thumbnail.len());
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
return t;
}
let tex = vectorcraft_format::base64_decode(&g.thumbnail)
.and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok())
.map(|img| {
let rgba = img.to_rgba8();
let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
ctx.load_texture(format!("library-{library}-{}", g.id), image, egui::TextureOptions::LINEAR)
});
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() > 512 {
c.clear();
}
c.insert(key, tex.clone());
});
tex
}
/// Graphic `item` of `library`'s thumbnail in `r` (the chip a dragged graphic shows at the pointer).
pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item: &str) {
if let Some(g) = app.session.libraries.get(library).and_then(|l| l.graphics.iter().find(|g| g.id == item)) {
tile(ui, r, library, g);
}
}
/// The panel's ≡ menu: Create New Library, Rename Library…, Delete Library.
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let cur = current(app);
if menu_item(ui, "Create New Library…", true, false) {
ask_name(app, None);
ui.close();
}
if menu_item(ui, "Rename Library…", cur.is_some(), false)
&& let Some((_, lib)) = &cur
{
ask_name(app, Some(&lib.name));
ui.close();
}
if menu_item(ui, "Delete Library", cur.is_some(), false)
&& let Some((id, lib)) = &cur
{
let message = crate::i18n::fmt(tl!("Delete the library “{name}”?"), &[("name", &lib.name)]);
crate::dialogs::confirm::ask(
app,
&message,
tl!("Its graphics, colors and text styles are deleted from this machine."),
"library.delete",
json!({ "library": id }),
);
ui.close();
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
show(app, ui);
menu(app, ui);
});
out.textures_delta.clear();
}
/// The panel draws with no library, an empty one and one holding every kind of item (#745).
#[test]
fn the_panel_draws_its_libraries() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
frame(&mut app);
app.run("library.create", json!({"name": "Brand"})).unwrap();
frame(&mut app);
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("library.add", json!({"kind": "graphic"})).unwrap();
app.run("library.add", json!({"kind": "fillColor"})).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"})).unwrap();
app.run("library.add", json!({"kind": "paraStyle"})).unwrap();
frame(&mut app);
let lib = app.session.libraries.get("Brand").unwrap();
assert_eq!((lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()), (1, 1, 1, 1));
// The thumbnail decodes.
let ctx = egui::Context::default();
assert!(thumbnail(&ctx, "Brand", &lib.graphics[0]).is_some());
}
}
+4 -12
View File
@@ -24,6 +24,7 @@ pub mod history;
pub mod image_trace;
pub mod info;
pub mod layers;
pub mod libraries;
pub mod library_panel;
pub mod links;
pub mod magic_wand;
@@ -169,6 +170,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool
"paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para),
"magicWand" => magic_wand::menu(app, ui),
"tabs" => tabs::menu(app, ui),
"libraries" => libraries::menu(app, ui),
flattener_preview::ID => flattener_preview::menu(app, ui),
"attributes" => attributes::menu(app, ui),
"colorThemes" => color_themes::menu(app, ui),
@@ -208,18 +210,8 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
});
}
pub fn libraries(_app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.add_space(20.0);
ui.vertical_centered(|ui| {
icons::icon(ui, "library", 40.0, t.text_dim);
ui.add_space(8.0);
ui.label(egui::RichText::new(tl!("Local Libraries")).size(14.0).color(t.text));
dim_label(
ui,
"Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required.",
);
});
pub fn libraries(app: &mut VectorcraftApp, ui: &mut Ui) {
libraries::show(app, ui);
}
// ---------- shared helpers ----------
+1
View File
@@ -581,6 +581,7 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
None => return,
},
PanelDrag::Brush { def, .. } => return pointer_chip(ctx, |ui, r| super::brushes::chip(ui, r, def)),
PanelDrag::LibraryGraphic { library, item } => return pointer_chip(ctx, |ui, r| super::libraries::chip(app, ui, r, library, item)),
};
pointer_chip(ctx, |ui, r| {
if registration {
+3
View File
@@ -1463,6 +1463,9 @@ pub enum PanelDrag {
/// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is
/// dropped on takes it (`brush.apply`).
Brush { name: String, def: serde_json::Value },
/// A Libraries panel graphic (`item`: its id in `library`): the canvas places a copy centred
/// where it is dropped (`library.use`).
LibraryGraphic { library: String, item: String },
}
/// A panel list `zone` that takes art dragged off the canvas: outlined while art is held over it;
+5 -4
View File
@@ -138,7 +138,8 @@ so the two scripts never mix), Japanese (`ja`, complete), Spanish (`es`, complet
international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), French (`fr`,
complete; `fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` and every other `fr-*` locale resolve to it), Italian (`it`,
complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Russian (`ru`, complete; every `ru-*`
locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Czech (`cs`, every menu label) and Brazilian Portuguese
locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Ukrainian (`uk`, complete; `uk-UA`,
`uk_UA.UTF-8` and every other `uk-*` locale resolve to it; integer plurals use one/few/many), Czech (`cs`, every menu label) and Brazilian Portuguese
(`pt-br`, every menu label and every `tl!` literal). Untranslated text falls back to English until its rows are added.
- `tl!("…")` translates a literal into the language the UI is drawn in; `i18n::t(s)` is the same for a
@@ -166,17 +167,17 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Czech (`cs`, every m
control channel, MCP and tests read them) and are translated only where the status bar draws them
(`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as
`Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, French, Italian and Russian cover every
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, French, Italian, Russian and Ukrainian cover every
message literal the test scan finds (`complete_languages_translate_every_message`, languages listed in
`COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs an `es.tsv`,
a `fr.tsv`, an `it.tsv` and a `ru.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
a `fr.tsv`, an `it.tsv`, a `ru.tsv` and a `uk.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
complete_languages_translate_every_message` lists them all). Other languages show messages in English
until they add `@msg` rows.
- Not translated on purpose: names that are user data (layers, swatches, fonts, documents), the tab
title's colour mode, command ids and parameter names inside messages. Not done yet: locale-aware number
and date formats, right-to-left layout. Chinese and Japanese UI text
is drawn with craft-fonts' BIZ UDPGothic when the build embeds it (see Fonts above), else with an installed
system font; the glyph test checks Latin-script catalogs always and the CJK ones only with craft-fonts.
system font; the glyph test checks Latin and Cyrillic catalogs always and the CJK ones only with craft-fonts.
A Traditional Chinese UI font is still to be added to craft-fonts for the web build.
## Vendor names gate
+25
View File
@@ -809,6 +809,31 @@ with); without `path` it returns `{data}`, and `user: true` saves into the user
(category `user`, User Defined). `graphicStyle.loadLibrary {path? | data? | dataBase64?, name?}` loads a `.vcstyles`
file, or another document's graphic styles, as a library to add from.
## Libraries
The Libraries panel's libraries hold graphics, colours and character and paragraph styles that any document can use.
They are kept on this machine: each is a `.vclibrary` file (JSON) in the `Libraries` folder next to the preferences
(none in the web app and headless sessions, where they last for the session). `library.list` lists them (`id`, `name`,
the count of each kind, `current`, `folder`); `library.get {library?}` returns one's items (graphics with `id`, `name`,
size and a PNG `thumbnail` in base64; colours with `name`, `hex` and `color`; styles with `name` and `attrs`).
`library.create {name?}`, `library.rename {library?, name}`, `library.delete {library?}` and
`library.setCurrent {library}` manage them; `library` is an id or a name, and without it the current library (the one
the panel shows) is meant.
`library.add {library?, kind, ids?, name?, color?}` adds from the selection: `graphic` (a copy of the objects with the
images, symbols, patterns, swatches, styles and brushes they use, as Copy takes them), `fillColor` / `strokeColor` (the
first selected object's solid colour, else the default one, or `color`), `charStyle` / `paraStyle` (the selected
text's attributes). An item the library already has is reported as `existing`. `library.use {library?, kind, item,
center?, to?}` uses one: a graphic is placed centred on `center` (pasted with its resources; a swatch name the document
gives another colour merges into the document's), a colour paints the selection's fill or stroke, and a style is added
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.
```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]}}}
```
## Strokes on type
`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit and the dash