Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f46527e0bd | ||
|
|
1ce3a5c908 | ||
|
|
4254bf915e | ||
|
|
44c7e64e1b | ||
|
|
7c7ce7f4fb | ||
|
|
f44155e098 | ||
|
|
9533707893 | ||
|
|
64cbee2a96 | ||
|
|
e2a4173337 | ||
|
|
17f66003cf | ||
|
|
1b93336bdf | ||
|
|
9a4a632ea7 | ||
|
|
97906f2104 | ||
|
|
b08188e5a5 | ||
|
|
dcc8a24f66 | ||
|
|
e80b3543d3 | ||
|
|
a6fceb295e | ||
|
|
a32aed7e81 | ||
|
|
a216c36779 | ||
|
|
80f4de1a5e | ||
|
|
cae68a57a1 | ||
|
|
1706e84829 | ||
|
|
696d9f619b | ||
|
|
3966b0cce5 | ||
|
|
b86d19feed | ||
|
|
f155bc5514 | ||
|
|
e6914861f1 | ||
|
|
60439d88b9 | ||
|
|
2366000e19 | ||
|
|
b71d877a0a | ||
|
|
48cfb414ca | ||
|
|
e04385e950 | ||
|
|
4c9d27b4f5 | ||
|
|
45f0dcc397 | ||
|
|
ad28cf4793 | ||
|
|
693e928c9c |
No files matched your search
Generated
+12
@@ -4649,6 +4649,7 @@ dependencies = [
|
||||
"unicode-script",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"windows-registry",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
@@ -5421,6 +5422,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
+18
-14
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -44,6 +44,7 @@ since 2026-10-01.
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
|
||||
@@ -94,17 +95,20 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- 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.
|
||||
- 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 and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, 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 and Spanish 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.
|
||||
- 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.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 45 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, the smart guide display options, performance, devices…).
|
||||
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
|
||||
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
|
||||
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
@@ -115,7 +119,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442).
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on), Measure, Type, Hand, Zoom, Rotate View.
|
||||
@@ -123,22 +127,22 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles: the dragged edges align with the other objects; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (Shift snaps them to the ruler's ticks) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the art's edges, centres and anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it.
|
||||
- **Geometry and effects:**
|
||||
- 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.
|
||||
- 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), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. 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. 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. 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.
|
||||
- **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 and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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; 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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.
|
||||
- **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), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. 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. 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. 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.
|
||||
- **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 and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish also translates 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 and Spanish 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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; 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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.
|
||||
- **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; Alt deletes their pieces; filled open paths count as closed), Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **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), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units, shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups; Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
@@ -151,7 +155,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369).
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web).
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
## Milestones and estimates
|
||||
@@ -165,7 +169,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
@@ -193,7 +197,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
|
||||
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on plain paths and polygons (and Relative rounding) | 10–15 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection and options | 3–6 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
//! - Save / Export trigger a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
|
||||
//!
|
||||
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
|
||||
@@ -9,6 +9,7 @@ use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::i18n;
|
||||
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
|
||||
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
@@ -49,6 +50,7 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
detect_language();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Before the first frame, which looks for copies a crash left behind.
|
||||
let locks = WebLocks::start(RECOVERY_PREFIX).await;
|
||||
@@ -131,6 +133,27 @@ fn web_options() -> eframe::WebOptions {
|
||||
options
|
||||
}
|
||||
|
||||
/// Tell the UI the browser's languages (`?lang=es` in the address first, as `VECTORCRAFT_LOCALE`
|
||||
/// does on the desktop) before the first frame, so the Automatic interface language follows the
|
||||
/// browser; and show the loading text in that language.
|
||||
fn detect_language() {
|
||||
let mut tags: Vec<String> = query_param("lang").into_iter().collect();
|
||||
if let Some(navigator) = web_sys::window().map(|w| w.navigator()) {
|
||||
tags.extend(navigator.languages().iter().filter_map(|v| v.as_string()));
|
||||
tags.extend(navigator.language());
|
||||
}
|
||||
i18n::set_system_locales(tags.as_slice());
|
||||
if let Some(el) = element(LOADING_ID) {
|
||||
el.set_text_content(Some(i18n::message(i18n::system_lang(), "Loading VectorCraft…").as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `name=` in the address's query, if given.
|
||||
fn query_param(name: &str) -> Option<String> {
|
||||
let query = query();
|
||||
query.trim_start_matches('?').split('&').find_map(|kv| kv.strip_prefix(name)?.strip_prefix('=').map(str::to_string)).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
@@ -215,7 +238,7 @@ fn notice(lines: &[&str]) {
|
||||
el.set_text_content(None);
|
||||
for line in lines {
|
||||
if let Ok(p) = document.create_element("p") {
|
||||
p.set_text_content(Some(line));
|
||||
p.set_text_content(Some(i18n::message(i18n::system_lang(), line).as_str()));
|
||||
// Best effort: the log has the message too.
|
||||
el.append_child(&p).ok();
|
||||
}
|
||||
|
||||
@@ -358,6 +358,9 @@ fn main() -> eframe::Result {
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
// One frame queued, not two: the canvas is rasterized on the CPU and the GPU only
|
||||
// composites it, so the window answers the pointer a frame sooner (#444).
|
||||
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaOptions, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathEffect,
|
||||
AreaOptions, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect,
|
||||
ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
|
||||
};
|
||||
pub use vectorcraft_color as color;
|
||||
@@ -191,7 +191,10 @@ impl Unit {
|
||||
}
|
||||
/// [`Unit::format`] without the suffix (`12.5`), for narrow fields.
|
||||
pub fn number(self, pt: f64) -> String {
|
||||
let s = format!("{:.3}", self.from_pt(pt));
|
||||
// Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`,
|
||||
// `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`).
|
||||
let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 };
|
||||
let s = format!("{:.*}", decimals, self.from_pt(pt));
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
+63
-19
@@ -143,24 +143,11 @@ impl LiveShape {
|
||||
/// scale in `xf` keep their exact geometry until they're next transformed).
|
||||
pub fn transform(&mut self, a: Affine) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { w, h, radii, xf, .. } => {
|
||||
let m = a * *xf;
|
||||
LiveShape::Rectangle { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
|
||||
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
|
||||
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
|
||||
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
|
||||
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
|
||||
if moves_only || !orthogonal || !finite {
|
||||
*xf = m;
|
||||
return false;
|
||||
}
|
||||
let k = (sx * sy).sqrt();
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
*w *= sx;
|
||||
*h *= sy;
|
||||
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
|
||||
true
|
||||
!moves_only && self.fold_scale()
|
||||
}
|
||||
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
@@ -173,6 +160,35 @@ impl LiveShape {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Move the scale of a rectangle's `xf` into `w`/`h` (and its radii, by the mean scale), so
|
||||
/// `xf` keeps only a rotation or reflection plus a move and the corners are circular in the
|
||||
/// document. False (and nothing changes) when there is no scale to move, when `xf` shears or
|
||||
/// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an
|
||||
/// uneven scale in `xf`: their corners are elliptical until this runs.
|
||||
pub fn fold_scale(&mut self) -> bool {
|
||||
let LiveShape::Rectangle { w, h, radii, xf, .. } = self else { return false };
|
||||
let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs();
|
||||
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
|
||||
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
|
||||
let finite = xf.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
|
||||
let unscaled = (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12;
|
||||
if !orthogonal || !finite || unscaled {
|
||||
return false;
|
||||
}
|
||||
let k = (sx * sy).sqrt();
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
*w *= sx;
|
||||
*h *= sy;
|
||||
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
|
||||
true
|
||||
}
|
||||
/// This shape with [`LiveShape::fold_scale`] applied: a rectangle's size and radii in document
|
||||
/// units (what Live Corners show and edit).
|
||||
pub fn folded(&self) -> Self {
|
||||
let mut s = self.clone();
|
||||
s.fold_scale();
|
||||
s
|
||||
}
|
||||
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
|
||||
/// keep their size (Scale Corners off). False when nothing changed.
|
||||
pub fn keep_corners(&mut self, k: f64) -> bool {
|
||||
@@ -212,15 +228,16 @@ impl LiveShape {
|
||||
pub fn corner_anchors(&self, corners: [bool; 4]) -> BTreeSet<AnchorRef> {
|
||||
self.anchor_corners().into_iter().enumerate().filter(|(_, k)| corners.get(*k) == Some(&true)).map(|(ai, _)| (0, ai)).collect()
|
||||
}
|
||||
/// The radius and kind the `corners` of a rectangle share (each `None` when they differ).
|
||||
/// The radius (in document units, see [`LiveShape::folded`]) and kind the `corners` of a
|
||||
/// rectangle share (each `None` when they differ).
|
||||
pub fn corner_style(&self, corners: [bool; 4]) -> (Option<f64>, Option<CornerKind>) {
|
||||
let LiveShape::Rectangle { radii, kinds, .. } = self else { return (None, None) };
|
||||
let LiveShape::Rectangle { radii, kinds, .. } = self.folded() else { return (None, None) };
|
||||
fn shared<T: Copy>(v: [T; 4], corners: [bool; 4], same: impl Fn(T, T) -> bool) -> Option<T> {
|
||||
let mut it = v.into_iter().zip(corners).filter(|(_, on)| *on).map(|(x, _)| x);
|
||||
let first = it.next()?;
|
||||
it.all(|x| same(x, first)).then_some(first)
|
||||
}
|
||||
(shared(*radii, corners, |a, b| (a - b).abs() < 1e-9), shared(*kinds, corners, |a, b| a == b))
|
||||
(shared(radii, corners, |a, b| (a - b).abs() < 1e-9), shared(kinds, corners, |a, b| a == b))
|
||||
}
|
||||
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
@@ -1395,6 +1412,33 @@ mod tests {
|
||||
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
|
||||
}
|
||||
|
||||
/// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live
|
||||
/// Corners see and edit it in document units, with circular corners (#442).
|
||||
#[test]
|
||||
fn a_scale_left_in_the_transform_folds_into_the_size() {
|
||||
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0);
|
||||
let n = live_rect(50.0, 80.0, 20.0, legacy);
|
||||
let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") };
|
||||
let folded = live.folded();
|
||||
assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone");
|
||||
let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") };
|
||||
// Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius.
|
||||
assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}");
|
||||
assert!(near_xf(*xf, Affine::translate((5.0, 5.0))));
|
||||
let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap());
|
||||
assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}");
|
||||
assert_eq!(live.corner_style([true; 4]).0, Some(40.0), "the radius in document units");
|
||||
// Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are.
|
||||
let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0);
|
||||
let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]);
|
||||
for xf in [place, shear] {
|
||||
let mut l = live_rect(100.0, 160.0, 20.0, xf);
|
||||
let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") };
|
||||
assert!(!live.fold_scale());
|
||||
assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_transparency() {
|
||||
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
@@ -60,6 +60,9 @@ fn readouts_show_two_decimals() {
|
||||
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
|
||||
assert_eq!(Unit::Millimeters.readout(72.0), "25.40 mm");
|
||||
assert_eq!(Unit::Inches.readout(-36.0), "-0.50 in");
|
||||
assert_eq!(Unit::Millimeters.number(1.0), "0.353");
|
||||
assert_eq!(Unit::Points.number(-0.0001), "0");
|
||||
assert_eq!(Unit::Millimeters.number(1.0), "0.3528");
|
||||
// Four decimals for the large units, three for the small ones.
|
||||
assert_eq!(Unit::Inches.number(0.5616), "0.0078");
|
||||
assert_eq!((Unit::Inches.number(595.2756), Unit::Points.number(595.2756)), ("8.2677".into(), "595.276".into()));
|
||||
assert_eq!(Unit::Points.number(-0.00001), "0");
|
||||
}
|
||||
+105
-2
@@ -539,6 +539,36 @@ impl PathEffect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options › Align to Path: which height of the type runs along the path.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PathAlign {
|
||||
/// The font's top edge: the type hangs below the path.
|
||||
Ascender,
|
||||
/// The font's bottom edge: the type stands above the path.
|
||||
Descender,
|
||||
/// Halfway between the ascender and the descender.
|
||||
Center,
|
||||
/// The baseline (the default).
|
||||
#[default]
|
||||
Baseline,
|
||||
}
|
||||
|
||||
impl PathAlign {
|
||||
pub const ALL: [PathAlign; 4] = [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center, PathAlign::Baseline];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
PathAlign::Ascender => "ascender",
|
||||
PathAlign::Descender => "descender",
|
||||
PathAlign::Center => "center",
|
||||
PathAlign::Baseline => "baseline",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Area Type Options: rows and columns, gutters, inset and first baseline of area type.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -583,8 +613,48 @@ pub enum TextKind {
|
||||
Point,
|
||||
/// Area type flowed inside `frame` (document coordinates, untransformed by `xf`).
|
||||
Area { frame: PathData },
|
||||
/// Type on a path, starting at `start` (0..1 of the path length).
|
||||
OnPath { path: PathData, start: f64 },
|
||||
/// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the
|
||||
/// path length; no `end`: the end of the path, or once round a closed path). See
|
||||
/// [`TextKind::path_span`].
|
||||
OnPath {
|
||||
path: PathData,
|
||||
start: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
end: Option<f64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TextKind {
|
||||
/// Type on a path's span: from its start to its end bracket, as fractions of its path's length.
|
||||
/// Round a closed path the end may be past 1 (the span runs on past the path's start), a full
|
||||
/// turn without an end or with the end at the start. None for other type.
|
||||
pub fn path_span(&self) -> Option<(f64, f64)> {
|
||||
let TextKind::OnPath { path, start, end } = self else { return None };
|
||||
let s = fraction(*start);
|
||||
Some(if path.is_closed() {
|
||||
let run = end.map_or(0.0, |e| (fraction(e) - s).rem_euclid(1.0));
|
||||
(s, s + if run < 1e-9 { 1.0 } else { run })
|
||||
} else {
|
||||
(s, end.map_or(1.0, |e| fraction(e).max(s)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `x` as a fraction of a path's length (0..1; 0 when not finite).
|
||||
fn fraction(x: f64) -> f64 {
|
||||
if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// `path` run the other way, each point as far from its new start as it was from its old end
|
||||
/// (the subpaths in reverse order, a closed one keeping its first anchor first).
|
||||
fn reverse_from_end(path: &mut PathData) {
|
||||
path.subpaths.reverse();
|
||||
for sp in &mut path.subpaths {
|
||||
sp.reverse();
|
||||
if sp.closed && !sp.anchors.is_empty() {
|
||||
sp.anchors.rotate_right(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text object. `runs` split into paragraphs at `\n`.
|
||||
@@ -605,6 +675,14 @@ pub struct TextObject {
|
||||
/// Type on a Path effect (type on a path only).
|
||||
#[serde(default, rename = "pathEffect", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub path_effect: PathEffect,
|
||||
/// Type on a Path Options › Align to Path (type on a path only).
|
||||
#[serde(default, rename = "pathAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub path_align: PathAlign,
|
||||
/// Type on a Path Options › Spacing in points (type on a path only): glyphs are spaced as if
|
||||
/// set this far above the path, which closes them up round the outside of a curve and opens
|
||||
/// them up round the inside.
|
||||
#[serde(default, rename = "pathSpacing", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub path_spacing: f64,
|
||||
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wrap: Vec<WrapShape>,
|
||||
@@ -623,6 +701,8 @@ impl TextObject {
|
||||
para: ParaStyle::default(),
|
||||
area: AreaOptions::default(),
|
||||
path_effect: PathEffect::default(),
|
||||
path_align: PathAlign::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
}
|
||||
@@ -652,6 +732,29 @@ impl TextObject {
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.xf = a * self.xf;
|
||||
}
|
||||
/// Type on a path's path in document space; None for other type.
|
||||
pub fn type_path(&self) -> Option<PathData> {
|
||||
match &self.kind {
|
||||
TextKind::OnPath { path, .. } => Some(path.transformed(self.xf)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its
|
||||
/// centre bracket dragged across the path): the path runs the other way and the brackets swap
|
||||
/// ends, so the type keeps its stretch of the path. False, changing nothing, for other type.
|
||||
pub fn flip_on_path(&mut self) -> bool {
|
||||
let Some((s, e)) = self.kind.path_span() else { return false };
|
||||
let TextKind::OnPath { path, start, end } = &mut self.kind else { return false };
|
||||
reverse_from_end(path);
|
||||
if path.is_closed() {
|
||||
*start = (1.0 - e).rem_euclid(1.0);
|
||||
*end = (e - s < 1.0 - 1e-9).then(|| (1.0 - s).rem_euclid(1.0));
|
||||
} else {
|
||||
*start = 1.0 - e;
|
||||
*end = (s > 1e-9).then_some(1.0 - s);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Area type's frame (the type area) in document space; None for other type.
|
||||
pub fn area_frame(&self) -> Option<PathData> {
|
||||
match &self.kind {
|
||||
|
||||
@@ -16,7 +16,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sample_polyline,
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (Alt-drag deletes the regions, and the pieces of unfilled open paths within tolerance), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines}",
|
||||
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
|
||||
has_selection_or_ids,
|
||||
sb_merge
|
||||
),
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder Regions",
|
||||
[],
|
||||
None,
|
||||
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose) and the pieces of those open paths",
|
||||
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
|
||||
has_selection_or_ids,
|
||||
sb_regions
|
||||
),
|
||||
@@ -225,14 +225,14 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
|
||||
}
|
||||
let erase = bool_or(p, "erase", false);
|
||||
// Line pieces are only deleted (Alt), never merged.
|
||||
let line_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
||||
// Line pieces and edges are only deleted (erase mode), never merged.
|
||||
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
||||
let fill = fill_param(s, p, C)?;
|
||||
let current_fill = s.paint.fill.clone();
|
||||
let roots = root_ids(s, p)?;
|
||||
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
|
||||
let (ids, merged, regions, lines) = s.edit(label, |d, sel| {
|
||||
let (map, leaves) = BuilderMap::new(d, &roots);
|
||||
let (ids, merged, touched) = s.edit(label, |d, sel| {
|
||||
let (map, leaves) = BuilderMap::new(d, &roots, erase);
|
||||
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
|
||||
if map.shapes.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
|
||||
@@ -240,11 +240,15 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
|
||||
let mut touched = Touched::default();
|
||||
for q in sample_polyline(&pts, step) {
|
||||
touched.add(map.hit(q, line_tol));
|
||||
touched.add(map.hit(q, piece_tol));
|
||||
}
|
||||
touched.edges = map.erased_edges(&touched);
|
||||
if touched.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
|
||||
}
|
||||
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
|
||||
// Edges come back from the planar map within a few hundredths of the outlines they follow.
|
||||
let cut_tol = step * 0.2;
|
||||
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
||||
let union = if picked.is_empty() { PathData::default() } else { po::merge_regions(&picked) };
|
||||
// The merged shape takes the look of the front-most object over the first region, or (for an
|
||||
@@ -269,11 +273,26 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !picked.iter().any(|r| r.sources.contains(&i)) {
|
||||
new_nodes.push(d.reid(leaf));
|
||||
} else {
|
||||
let rest = po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference);
|
||||
if !rest.is_empty() {
|
||||
// A shape loses the regions deleted or merged, then the edges deleted (each one on
|
||||
// its outline, shared edges from every shape along them).
|
||||
let in_union = picked.iter().any(|r| r.sources.contains(&i));
|
||||
let mut rest = if in_union {
|
||||
po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference)
|
||||
} else {
|
||||
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
|
||||
};
|
||||
let fill_closes = !leaf.appearance.fill_paint().is_none();
|
||||
let mut cut = false;
|
||||
for piece in &cuts {
|
||||
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
|
||||
rest = r;
|
||||
cut = true;
|
||||
}
|
||||
}
|
||||
if !in_union && !cut {
|
||||
new_nodes.push(d.reid(leaf));
|
||||
} else if !rest.is_empty() {
|
||||
new_nodes.push(shape_node(d, rest, Some(leaf)));
|
||||
}
|
||||
}
|
||||
@@ -288,9 +307,15 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let new_ids = replace_roots(d, &roots, new_nodes)?;
|
||||
sel.set(new_ids.iter().copied());
|
||||
Ok((new_ids, merged_id, touched.regions.len(), touched.lines.len()))
|
||||
Ok((new_ids, merged_id, touched))
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids), "merged": merged.map(|m| m.0), "regions": regions, "lines": lines }))
|
||||
Ok(json!({
|
||||
"ids": ids_json(&ids),
|
||||
"merged": merged.map(|m| m.0),
|
||||
"regions": touched.regions.len(),
|
||||
"lines": touched.lines.len(),
|
||||
"edges": touched.edges.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
|
||||
@@ -315,7 +340,7 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
|
||||
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let (map, leaves) = BuilderMap::new(d, &roots);
|
||||
let (map, leaves) = BuilderMap::new(d, &roots, true);
|
||||
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
|
||||
let regions: Vec<Value> = map
|
||||
.regions
|
||||
@@ -328,9 +353,10 @@ fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let lines: Vec<Value> =
|
||||
map.lines.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect();
|
||||
Ok(json!({ "regions": regions, "lines": lines }))
|
||||
let pieces = |list: &[(po::Shape, Rect)]| -> Vec<Value> {
|
||||
list.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect()
|
||||
};
|
||||
Ok(json!({ "regions": regions, "lines": pieces(&map.lines), "edges": pieces(&map.edges) }))
|
||||
}
|
||||
|
||||
// ---------- Live Paint ----------
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
//! become active when assigned.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget};
|
||||
use vectorcraft_color::recolor::Neutral;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::ColorMode;
|
||||
use vectorcraft_render::proof;
|
||||
@@ -55,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Document Color Mode",
|
||||
[],
|
||||
None,
|
||||
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
|
||||
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?, grays?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
|
||||
has_doc,
|
||||
convert_mode
|
||||
),
|
||||
@@ -232,6 +234,28 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
|
||||
}
|
||||
|
||||
/// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Grays {
|
||||
/// Through the CMYK profile like any colour: four-colour greys and a rich black.
|
||||
#[default]
|
||||
Profile,
|
||||
/// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then
|
||||
/// to CMYK would give).
|
||||
Black,
|
||||
}
|
||||
|
||||
impl Grays {
|
||||
/// The `grays` param of `cmd`: `"profile"` (default) or `"black"`.
|
||||
pub(crate) fn param(cmd: &str, p: &Value) -> Result<Self> {
|
||||
match str_param(p, "grays").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("profile") => Ok(Grays::Profile),
|
||||
Some("black") => Ok(Grays::Black),
|
||||
Some(g) => Err(bad(cmd, format!("grays must be \"profile\" or \"black\", not `{g}`"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File → Document Color Mode (`file.documentColorMode` and its alias
|
||||
/// `object.convertDocumentColorMode`): set the mode and, unless `convert: false`, convert every
|
||||
/// colour through the colour settings.
|
||||
@@ -243,6 +267,7 @@ pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
|
||||
};
|
||||
let intent = intent_param(p, C)?;
|
||||
let grays = Grays::param(C, p)?;
|
||||
// Choosing the mode the document already has (the checked menu item) is not an edit.
|
||||
if s.doc()?.doc.color_mode == mode {
|
||||
return Ok(json!({ "changed": 0 }));
|
||||
@@ -250,18 +275,20 @@ pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let convert = bool_or(p, "convert", true);
|
||||
let mut changed = 0usize;
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
changed = set_color_mode(d, mode, convert, intent);
|
||||
changed = set_color_mode(d, mode, convert, intent, grays);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols and
|
||||
/// swatches through the colour settings (`intent`, default: theirs) → how many colours changed.
|
||||
/// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
|
||||
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols,
|
||||
/// pattern tiles and swatches through the colour settings (`intent`, default: theirs) → how many
|
||||
/// colours of the art, symbols and patterns changed. Gray colours stay Gray (they print on the
|
||||
/// black plate in either mode); RGB greys are colours like any other and separate through the
|
||||
/// CMYK profile, unless `grays` puts them on the black plate. Lab colours (spot colour
|
||||
/// definitions) are device independent and fit either mode. Document Color Mode and
|
||||
/// `document.open {colorMode}` both convert this way.
|
||||
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>) -> usize {
|
||||
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>, grays: Grays) -> usize {
|
||||
d.color_mode = mode;
|
||||
if !convert {
|
||||
return 0;
|
||||
@@ -269,15 +296,25 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
|
||||
let c = cms::active();
|
||||
let intent = intent.unwrap_or(c.settings().intent);
|
||||
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
|
||||
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
|
||||
let k_only = model == Model::Cmyk && grays == Grays::Black;
|
||||
let conv = |col: &Color| match col {
|
||||
Color::Gray { .. } | Color::Lab { .. } => *col,
|
||||
// Grey ink percentage, then K only (a Gray colour's CMYK).
|
||||
Color::Rgb { .. } if k_only && Neutral::of(col).is_some() => c.convert(&c.convert(col, Model::Gray, intent), Model::Cmyk, intent),
|
||||
_ => c.convert(col, model, intent),
|
||||
};
|
||||
let mut changed = 0usize;
|
||||
proof::map_document_colors(d, &mut |col, _| {
|
||||
let mut count = |col: &Color, _: proof::Link| {
|
||||
let n = conv(col);
|
||||
if n != *col {
|
||||
changed += 1;
|
||||
}
|
||||
n
|
||||
});
|
||||
};
|
||||
proof::map_document_colors(d, &mut count);
|
||||
for a in d.patterns.iter_mut().flat_map(|p| &mut p.art) {
|
||||
proof::map_node_colors(Arc::make_mut(a), &mut count);
|
||||
}
|
||||
for sw in d.swatches_iter_mut() {
|
||||
match &mut sw.paint {
|
||||
Paint::Solid { color, .. } => *color = conv(color),
|
||||
|
||||
@@ -189,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols and swatches through the colour settings; swatch links kept; greys stay greys), intent?} → {changed}",
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols, pattern tiles and swatches through the colour settings; swatch links kept; Gray colours stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB greys separate through the CMYK profile like any colour, into four-colour greys and a rich black) | \"black\" (to CMYK, RGB greys with R = G = B go on the black plate only, K = their grey value)} → {changed}",
|
||||
has_doc,
|
||||
super::colormgmt::convert_mode
|
||||
),
|
||||
|
||||
@@ -178,7 +178,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None);
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
|
||||
}
|
||||
// Imports are named after the file; a native document keeps its own title (the tab shows the
|
||||
// file name once it has a path).
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
|
||||
|
||||
use super::super::colormgmt::Grays;
|
||||
use super::super::*;
|
||||
use super::{err, source};
|
||||
|
||||
@@ -22,6 +23,8 @@ pub struct LoadOptions {
|
||||
/// The colour mode the document opens in, its colours converted as Document Color Mode does
|
||||
/// (default: the file's; a PDF painted mostly in CMYK opens in CMYK).
|
||||
pub color_mode: Option<ColorMode>,
|
||||
/// How that conversion separates RGB greys (`grays`).
|
||||
pub grays: Grays,
|
||||
/// What a PDF's text becomes.
|
||||
pub text_as: TextAs,
|
||||
/// A PDF's optional content groups become layers (else one layer per page, without the art
|
||||
@@ -38,6 +41,7 @@ impl Default for LoadOptions {
|
||||
crop: CropTo::default(),
|
||||
password: None,
|
||||
color_mode: None,
|
||||
grays: Grays::default(),
|
||||
text_as: TextAs::default(),
|
||||
layers: true,
|
||||
dxf: Default::default(),
|
||||
@@ -47,7 +51,7 @@ impl Default for LoadOptions {
|
||||
|
||||
impl LoadOptions {
|
||||
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
|
||||
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`),
|
||||
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
|
||||
/// `textAs` (`text` | `outlines`), `layers` (true | false) and `dxf` (the DXF import options).
|
||||
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
|
||||
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
|
||||
@@ -65,13 +69,14 @@ impl LoadOptions {
|
||||
Some("cmyk") => Some(ColorMode::Cmyk),
|
||||
Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
|
||||
};
|
||||
let grays = Grays::param(cmd, p)?;
|
||||
let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?;
|
||||
let layers = match p.get("layers").filter(|v| !v.is_null()) {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
|
||||
};
|
||||
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
|
||||
Ok(Self { pages, crop, password, color_mode, text_as, layers, dxf })
|
||||
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf })
|
||||
}
|
||||
|
||||
/// Does the document read only part of the file, or read it differently (a page range, another
|
||||
|
||||
@@ -1038,6 +1038,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Live Shape", |d, sel| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
// Radii are document lengths and corners circular, also on a rectangle from a file
|
||||
// that kept an uneven scale in its transform (#442).
|
||||
live.fold_scale();
|
||||
let partial = sel.anchors.get_mut(id);
|
||||
let picked = corners.unwrap_or_else(|| live.picked_corners(partial.as_deref()));
|
||||
let (layout, selected) = (live.anchor_corners(), partial.as_deref().map(|a| live.corners_of(a)));
|
||||
|
||||
@@ -130,6 +130,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("scaleCorners", "General", "Options", "Scale Corners", bool),
|
||||
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
|
||||
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
|
||||
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
|
||||
// Selection & Anchor Display
|
||||
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
|
||||
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
|
||||
|
||||
@@ -453,10 +453,10 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let start = match point_param(p, "at") {
|
||||
Some(at) if on_path => vectorcraft_text::path_fraction_at(&path.to_bezpath(), at).0,
|
||||
Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } };
|
||||
let kind = if on_path { TextKind::OnPath { path: path.clone(), start, end: None } } else { TextKind::Area { frame: path.clone() } };
|
||||
let mut t = TextObject {
|
||||
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
|
||||
kind,
|
||||
@@ -465,6 +465,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
para: super::create::new_type_para(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
};
|
||||
@@ -595,6 +597,8 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
para: Default::default(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type on a Path Options…",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{start?: 0..1 (fraction of the path length), flip?: bool (reverse the path), effect?: rainbow|skew|3dRibbon|stairStep|gravity, ids?}",
|
||||
"{ids?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
@@ -343,38 +343,95 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// The options `type.pathOptions` sets; with none of them given it queries.
|
||||
const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"];
|
||||
|
||||
/// Type on a path's text, if `n` is type on a path.
|
||||
fn path_text(n: Option<&vectorcraft_doc::Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::OnPath { .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options of `t`, as `type.pathOptions` takes them (`flip` is an action: false).
|
||||
fn path_options_of(t: &TextObject) -> Value {
|
||||
let (start, end) = match &t.kind {
|
||||
TextKind::OnPath { start, end, .. } => (*start, *end),
|
||||
_ => (0.0, None),
|
||||
};
|
||||
json!({
|
||||
"start": start,
|
||||
"end": end,
|
||||
"flip": false,
|
||||
"effect": t.path_effect.id(),
|
||||
"alignToPath": t.path_align.id(),
|
||||
"spacing": t.path_spacing,
|
||||
})
|
||||
}
|
||||
|
||||
fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.pathOptions";
|
||||
let ids = texts(s, p, C)?;
|
||||
let start = p.get("start").and_then(Value::as_f64);
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
texts(s, p, C)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect()
|
||||
};
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path"))?;
|
||||
let options = |s: &Session| -> Result<Value> {
|
||||
let t = path_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select type on a path"))?;
|
||||
Ok(path_options_of(t))
|
||||
};
|
||||
if !PATH_OPTIONS.iter().any(|k| p.get(k).is_some()) {
|
||||
return options(s);
|
||||
}
|
||||
// A bracket: a fraction of the path's length.
|
||||
let fraction = |k: &str| -> Result<Option<f64>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(0.0, 1.0))).ok_or_else(|| bad(C, format!("`{k}` is a number 0..1"))),
|
||||
}
|
||||
};
|
||||
let start = fraction("start")?;
|
||||
// `end: null` puts the end bracket back at the end of the path.
|
||||
let end = p.get("end").map(|_| fraction("end")).transpose()?;
|
||||
let flip = bool_or(p, "flip", false);
|
||||
let effect = match str_param(p, "effect") {
|
||||
Some(e) => Some(vectorcraft_doc::PathEffect::parse(e).ok_or_else(|| bad(C, format!("unknown effect `{e}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let n = s.edit("Type on a Path Options", |d, _| {
|
||||
let mut n = 0;
|
||||
let align = match str_param(p, "alignToPath") {
|
||||
Some(a) => Some(vectorcraft_doc::PathAlign::parse(a).ok_or_else(|| bad(C, format!("unknown alignToPath `{a}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(-1000.0, 1000.0));
|
||||
s.edit("Type on a Path Options", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let TextKind::OnPath { path, start: st } = &mut t.kind else { continue };
|
||||
let TextKind::OnPath { start: st, end: en, .. } = &mut t.kind else { continue };
|
||||
if let Some(v) = start {
|
||||
*st = v.clamp(0.0, 1.0);
|
||||
*st = v;
|
||||
}
|
||||
if let Some(v) = end {
|
||||
*en = v;
|
||||
}
|
||||
// After the brackets: they are given on the path as it runs before the flip.
|
||||
if flip {
|
||||
path.reverse();
|
||||
t.flip_on_path();
|
||||
}
|
||||
if let Some(e) = effect {
|
||||
t.path_effect = e;
|
||||
}
|
||||
n += 1;
|
||||
if let Some(a) = align {
|
||||
t.path_align = a;
|
||||
}
|
||||
if let Some(v) = spacing {
|
||||
t.path_spacing = v;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(bad(C, "select type on a path"));
|
||||
}
|
||||
Ok(n)
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "changed": n }))
|
||||
options(s)
|
||||
}
|
||||
|
||||
// ---------- placeholder / insert ----------
|
||||
|
||||
@@ -89,6 +89,15 @@ pub struct Interaction {
|
||||
pub perspective_again: Option<Value>,
|
||||
}
|
||||
|
||||
/// An open undo group ([`Session::begin_undo_group`]): the edits made in it are one undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UndoGroup {
|
||||
/// The document before the group's first edit, as the undo step that edit recorded keeps it.
|
||||
first: Option<Arc<Document>>,
|
||||
/// The journal's length when the group began: a cancelled group drops the entries after it.
|
||||
journal: usize,
|
||||
}
|
||||
|
||||
/// Per-document editing state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
@@ -112,6 +121,8 @@ pub struct DocState {
|
||||
/// Isolation mode container.
|
||||
pub isolation: Option<NodeId>,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// Edits made while this is open are one undo step (a scrubbed numeric field).
|
||||
pub undo_group: Option<UndoGroup>,
|
||||
/// For Object → Transform → Transform Again (⌘D).
|
||||
pub last_transform: Option<(Affine, bool)>,
|
||||
/// Selection saved by Select → Reselect.
|
||||
@@ -166,6 +177,7 @@ impl DocState {
|
||||
layer_rows: vec![],
|
||||
isolation: None,
|
||||
interaction: None,
|
||||
undo_group: None,
|
||||
last_transform: None,
|
||||
last_selection_cmd: None,
|
||||
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
@@ -197,11 +209,26 @@ impl DocState {
|
||||
/// Keep what interaction `it` (taken from this document) changed, as one undo step.
|
||||
pub(crate) fn keep_interaction(&mut self, it: Interaction) {
|
||||
if !Arc::ptr_eq(&it.doc, &self.doc) {
|
||||
self.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
|
||||
self.history.redo.clear();
|
||||
self.push_undo(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
|
||||
self.revision += 1;
|
||||
}
|
||||
}
|
||||
/// Record undo step `e` (the document before an edit). In an undo group only the group's first
|
||||
/// edit records one: the edits after it extend that step.
|
||||
fn push_undo(&mut self, e: HistoryEntry) {
|
||||
if let Some(g) = &mut self.undo_group {
|
||||
let recorded = |first: &Arc<Document>| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first));
|
||||
if g.first.as_ref().is_some_and(recorded) {
|
||||
return;
|
||||
}
|
||||
g.first = Some(e.doc.clone());
|
||||
}
|
||||
self.history.undo.push(e);
|
||||
if self.history.undo.len() > self.history.limit {
|
||||
self.history.undo.remove(0);
|
||||
}
|
||||
self.history.redo.clear();
|
||||
}
|
||||
/// End the interaction in progress, undoing what it changed.
|
||||
pub(crate) fn undo_interaction(&mut self) {
|
||||
if let Some(it) = self.interaction.take() {
|
||||
@@ -332,6 +359,8 @@ pub struct Prefs {
|
||||
/// Scale Strokes & Effects.
|
||||
pub scale_strokes: bool,
|
||||
pub zoom_with_mouse_wheel: bool,
|
||||
/// A horizontal drag on a numeric field or its label steps its value (#400).
|
||||
pub scrub_numeric_fields: bool,
|
||||
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
|
||||
pub paste_offset: f64,
|
||||
// Selection & Anchor Display
|
||||
@@ -552,6 +581,7 @@ impl Default for Prefs {
|
||||
scale_corners: false,
|
||||
scale_strokes: false,
|
||||
zoom_with_mouse_wheel: false,
|
||||
scrub_numeric_fields: true,
|
||||
paste_offset: 10.0,
|
||||
selection_tolerance: 3.0,
|
||||
object_selection_by_path_only: false,
|
||||
@@ -1066,11 +1096,7 @@ impl Session {
|
||||
st.selection.prune(&st.doc);
|
||||
st.revision += 1;
|
||||
if st.interaction.is_none() {
|
||||
st.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel });
|
||||
if st.history.undo.len() > st.history.limit {
|
||||
st.history.undo.remove(0);
|
||||
}
|
||||
st.history.redo.clear();
|
||||
st.push_undo(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel });
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
@@ -1184,6 +1210,44 @@ impl Session {
|
||||
self.active().is_some_and(|d| d.interaction.is_some())
|
||||
}
|
||||
|
||||
// ---------- undo groups (scrubbed numeric fields) ----------
|
||||
|
||||
/// Open an undo group in the active document: until [`Session::end_undo_group`], the edits
|
||||
/// made there (commands, committed interactions) are one undo step. A scrubbed numeric field
|
||||
/// applies each value it passes as its own command, as a typed value is applied; the drag is
|
||||
/// one step.
|
||||
pub fn begin_undo_group(&mut self) {
|
||||
let journal = self.journal.len();
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.undo_group.is_none()
|
||||
{
|
||||
st.undo_group = Some(UndoGroup { first: None, journal });
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are
|
||||
/// undone and dropped from the journal.
|
||||
pub fn end_undo_group(&mut self, cancel: bool) {
|
||||
let mut journal = None;
|
||||
for st in &mut self.docs {
|
||||
let Some(g) = st.undo_group.take() else { continue };
|
||||
// Only while the group's step is still the newest one.
|
||||
if cancel
|
||||
&& let Some(first) = g.first
|
||||
&& st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, &first))
|
||||
&& let Some(e) = st.history.undo.pop()
|
||||
{
|
||||
st.doc = e.doc;
|
||||
st.selection = e.selection;
|
||||
st.revision += 1;
|
||||
journal = Some(g.journal);
|
||||
}
|
||||
}
|
||||
if let Some(len) = journal {
|
||||
self.journal.truncate(len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands with enablement (for menus, palette, MCP `list_commands`).
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
@@ -1335,6 +1399,8 @@ mod tests_panelcmds;
|
||||
#[cfg(test)]
|
||||
mod tests_pathops;
|
||||
#[cfg(test)]
|
||||
mod tests_pathtype;
|
||||
#[cfg(test)]
|
||||
mod tests_pattern;
|
||||
#[cfg(test)]
|
||||
mod tests_pdffidelity;
|
||||
|
||||
@@ -320,6 +320,69 @@ fn shape_builder_tool_alt_deletes_line_pieces() {
|
||||
assert_eq!(layer_children(&s), 3, "square + the line's two loose ends");
|
||||
}
|
||||
|
||||
/// Red square A (0..100) and blue square B (50..150 both ways), overlapping at a corner, selected.
|
||||
fn corner(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(s, 50.0, 50.0, 100.0, 100.0);
|
||||
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
|
||||
set_fill(s, b, Color::rgb(0.0, 0.0, 1.0));
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// (closed, anchors, length) of each of `ids`.
|
||||
fn shapes_of(s: &Session, ids: &[NodeId]) -> Vec<(bool, usize, f64)> {
|
||||
ids.iter()
|
||||
.map(|i| {
|
||||
let (p, _) = outline(&node(s, *i));
|
||||
(p.is_closed(), p.anchor_count(), (p.length() * 1e6).round() / 1e6)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_erase_click_on_an_edge_opens_its_path() {
|
||||
let mut s = session();
|
||||
corner(&mut s);
|
||||
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
assert_eq!(r["edges"].as_array().unwrap().len(), 4, "each outline cut in two where the other crosses it: {r}");
|
||||
// A's side inside B (1 pt off it).
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap();
|
||||
assert_eq!((r["regions"].as_u64(), r["lines"].as_u64(), r["edges"].as_u64()), (Some(0), Some(0), Some(1)));
|
||||
let out = ids(&r);
|
||||
// A opens where B's outline crosses it and keeps its look; B is untouched.
|
||||
assert_eq!(shapes_of(&s, &out), vec![(false, 5, 300.0), (true, 4, 400.0)]);
|
||||
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let back: Vec<NodeId> = s.doc().unwrap().doc.children(s.doc().unwrap().doc.default_layer()).unwrap().iter().map(|n| n.id).collect();
|
||||
assert_eq!(shapes_of(&s, &back), vec![(true, 4, 400.0); 2], "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_erase_drag_along_edges_deletes_each() {
|
||||
let mut s = session();
|
||||
corner(&mut s);
|
||||
// Down A's side inside B, round the corner and along B's side inside A.
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 75], [100, 50], [75, 50]], "erase": true})).unwrap();
|
||||
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(0), Some(2)));
|
||||
assert_eq!(shapes_of(&s, &ids(&r)), vec![(false, 5, 300.0); 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_erase_drag_into_regions_deletes_them_not_the_edges_it_crosses() {
|
||||
let mut s = session();
|
||||
corner(&mut s);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 25], [75, 75]], "erase": true})).unwrap();
|
||||
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(2), Some(0)));
|
||||
// A is gone; what is left of B stays closed and blue.
|
||||
let out = ids(&r);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!((area(&s, out[0]) - 7500.0).abs() < 1e-6);
|
||||
assert!(outline(&node(&s, out[0])).0.is_closed());
|
||||
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
// ---------- Live Paint ----------
|
||||
|
||||
fn live(s: &mut Session) -> NodeId {
|
||||
|
||||
@@ -249,3 +249,50 @@ fn a_failed_batch_gives_back_the_highlighted_rows_and_the_current_layer() {
|
||||
let r = replayed(&s);
|
||||
assert_eq!(documents(&r), documents(&s));
|
||||
}
|
||||
|
||||
/// The edits made in an undo group (a scrubbed numeric field, #400) are one undo step, journaled
|
||||
/// one by one; a cancelled group undoes them and leaves the journal as it was.
|
||||
#[test]
|
||||
fn an_undo_group_is_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let width = |s: &Session| s.transform_box(&s.doc().unwrap().selection.objects).unwrap().rect.width();
|
||||
let undo_len = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let (undo, journal) = (undo_len(&s), s.journal.len());
|
||||
s.begin_undo_group();
|
||||
for w in [101, 102, 103] {
|
||||
s.execute("object.setBounds", &json!({"width": w})).unwrap();
|
||||
}
|
||||
// A live drag committed in the group (the Transparency panel's opacity field) joins it too.
|
||||
s.begin_interaction("Opacity").unwrap();
|
||||
s.preview("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
s.end_undo_group(false);
|
||||
assert!((width(&s) - 103.0).abs() < 1e-9);
|
||||
assert_eq!(undo_len(&s), undo + 1, "one undo step");
|
||||
assert_eq!(s.journal.len(), journal + 4, "each edit journaled");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((width(&s) - 100.0).abs() < 1e-9, "undo takes the whole drag back");
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((width(&s) - 103.0).abs() < 1e-9);
|
||||
|
||||
// Escape: back where it started, with no trace in the history or the journal.
|
||||
let (undo, journal) = (undo_len(&s), s.journal.len());
|
||||
s.begin_undo_group();
|
||||
s.execute("object.setBounds", &json!({"width": 150})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"width": 160})).unwrap();
|
||||
s.end_undo_group(true);
|
||||
assert!((width(&s) - 103.0).abs() < 1e-9);
|
||||
assert_eq!((undo_len(&s), s.journal.len()), (undo, journal));
|
||||
// Closed: the next edits are steps of their own again.
|
||||
s.execute("object.setBounds", &json!({"width": 110})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"width": 120})).unwrap();
|
||||
assert_eq!(undo_len(&s), undo + 2);
|
||||
// A group with no edits records nothing, and cancelling it undoes nothing.
|
||||
s.begin_undo_group();
|
||||
s.end_undo_group(true);
|
||||
assert!((width(&s) - 120.0).abs() < 1e-9);
|
||||
assert_eq!(undo_len(&s), undo + 2);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AnchorRef, LiveShape, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
|
||||
use super::*;
|
||||
@@ -105,3 +106,47 @@ fn a_drag_previews_one_corner_and_commits_one_step() {
|
||||
assert_eq!(selected_anchors(&s, id), Some(anchors(&[2, 3])));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
/// How far each corner's cut reaches along x and y (top-left, top-right, bottom-right,
|
||||
/// bottom-left) of a rectangle with four cut corners: equal for a circular arc.
|
||||
fn spans(s: &Session, id: NodeId) -> Vec<(f64, f64)> {
|
||||
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not a path") };
|
||||
let a: Vec<_> = path.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(a.len(), 8, "four cut corners");
|
||||
[(7, 0), (1, 2), (3, 4), (5, 6)].iter().map(|(i, j)| ((a[*j].x - a[*i].x).abs(), (a[*j].y - a[*i].y).abs())).collect()
|
||||
}
|
||||
|
||||
fn assert_spans(s: &Session, id: NodeId, want: [f64; 4]) {
|
||||
let got = spans(s, id);
|
||||
let ok = got.iter().zip(want).all(|((x, y), r)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9);
|
||||
assert!(ok, "corners span {got:?}, want circles of radii {want:?}");
|
||||
}
|
||||
|
||||
/// #442: on a rectangle that isn't square, every corner is the same circle, also past the limit
|
||||
/// and on a shape from a file that kept an uneven scale in its transform (before #291).
|
||||
#[test]
|
||||
fn corners_of_a_non_square_rectangle_are_alike_circles() {
|
||||
let (mut s, id) = session();
|
||||
run(&mut s, json!({"radius": 12}));
|
||||
assert_spans(&s, id, [12.0; 4]);
|
||||
// Past half the shorter side, every corner stops there.
|
||||
run(&mut s, json!({"radius": 45}));
|
||||
assert_spans(&s, id, [30.0; 4]);
|
||||
// A 50 × 60 rectangle with 10 pt corners, saved stretched to 100 × 60: 20 × 10 ellipses.
|
||||
{
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(id).unwrap().kind else { panic!("not live") };
|
||||
let xf = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(2.0, 1.0);
|
||||
*live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf };
|
||||
*path = live.to_path();
|
||||
}
|
||||
assert_eq!(spans(&s, id)[0], (20.0, 10.0));
|
||||
// Rounding one corner makes every corner a circle: the others of the mean radius.
|
||||
run(&mut s, json!({"corners": [1], "radius": 12}));
|
||||
let mean = 10.0 * 2f64.sqrt();
|
||||
assert_spans(&s, id, [mean, 12.0, mean, mean]);
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 10.0).abs() < 1e-9 && (b.x1 - 110.0).abs() < 1e-9 && (b.y1 - 70.0).abs() < 1e-9, "same bounds: {b:?}");
|
||||
run(&mut s, json!({"radius": 8}));
|
||||
assert_spans(&s, id, [8.0; 4]);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
@@ -859,6 +859,122 @@ fn document_color_mode_converts_colours() {
|
||||
assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err());
|
||||
}
|
||||
|
||||
/// Four RGB greys (#000000, #333333, #808080, #e6e6e6, as in issue #421) and a red, a 40% tint of a
|
||||
/// grey global swatch, a gradient to grey and a pattern of a grey tile → (session, the five
|
||||
/// rectangles, the tinted one, the gradient one).
|
||||
fn greys_document() -> (Session, Vec<NodeId>, NodeId, NodeId) {
|
||||
let mut s = session();
|
||||
let ids: Vec<NodeId> = ["#000000", "#333333", "#808080", "#e6e6e6", "#ff0000"]
|
||||
.iter()
|
||||
.map(|hex| {
|
||||
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
fill(&mut s, id, hex);
|
||||
id
|
||||
})
|
||||
.collect();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#808080", "global": true})).unwrap();
|
||||
let tint = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [tint.0], "swatch": "Ink", "tint": 40})).unwrap();
|
||||
let grad = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#333333"}]);
|
||||
s.execute("paint.setFill", &json!({"ids": [grad.0], "gradient": {"kind": "linear", "stops": stops}})).unwrap();
|
||||
let tile = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
fill(&mut s, tile, "#808080");
|
||||
s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Grey Tile", "edit": false})).unwrap();
|
||||
(s, ids, tint, grad)
|
||||
}
|
||||
|
||||
/// The colour of pattern `name`'s first tile object.
|
||||
fn pattern_fill(s: &Session, name: &str) -> Color {
|
||||
s.doc().unwrap().doc.pattern(name).unwrap().art[0].appearance.fill_paint().color().unwrap()
|
||||
}
|
||||
|
||||
/// K of a colour that prints on the black plate only.
|
||||
fn k_only(c: Color) -> f32 {
|
||||
match c {
|
||||
Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k } => k,
|
||||
other => panic!("not K only: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_color_mode_separates_rgb_greys_through_the_profile() {
|
||||
let (mut s, ids, _, _) = greys_document();
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
// As Illustrator converts them: four-colour greys and a rich black.
|
||||
let Color::Cmyk { c, m, y, k } = fill_of(&s, ids[2]) else { panic!("not CMYK") };
|
||||
assert!(c > 0.05 && m > 0.05 && y > 0.05 && k > 0.0, "a four-colour mid grey: {c} {m} {y} {k}");
|
||||
let Color::Cmyk { c, k, .. } = fill_of(&s, ids[0]) else { panic!("not CMYK") };
|
||||
assert!(k > 0.9 && c > 0.3, "rich black: {c} {k}");
|
||||
// Pattern tiles are swatches too: their art converts with the rest.
|
||||
assert!(matches!(pattern_fill(&s, "Grey Tile"), Color::Cmyk { .. }), "{:?}", pattern_fill(&s, "Grey Tile"));
|
||||
// Gray colours stay Gray.
|
||||
let mut g = session();
|
||||
let a = rect(&mut g, 0.0, 0.0, 10.0, 10.0);
|
||||
g.execute("paint.setFill", &json!({"ids": [a.0], "color": {"gray": 0.3}})).unwrap();
|
||||
g.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
assert_eq!(fill_of(&g, a), Color::gray(0.3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_color_mode_can_put_rgb_greys_on_the_black_plate() {
|
||||
let (mut profile, pids, ..) = greys_document();
|
||||
profile.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
let (mut s, ids, tint, grad) = greys_document();
|
||||
let e = s.execute("file.documentColorMode", &json!({"mode": "cmyk", "grays": "rich"})).unwrap_err().to_string();
|
||||
assert!(e.contains("grays"), "{e}");
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk", "grays": "black"})).unwrap();
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 0.005;
|
||||
for (id, want) in ids.iter().zip([1.0, 0.8, 0.498, 0.098]) {
|
||||
let k = k_only(fill_of(&s, *id));
|
||||
assert!(close(k, want), "K {k}, want {want}");
|
||||
}
|
||||
// Other colours convert through the profile as before.
|
||||
assert_eq!(fill_of(&s, ids[4]), fill_of(&profile, pids[4]));
|
||||
// Swatches, their tints, gradient stops and pattern tiles follow the same rule.
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(close(k_only(d.swatch("Ink").unwrap().paint.color().unwrap()), 0.498));
|
||||
let Paint::Solid { color, swatch, tint: t } = node(&s, tint).appearance.fill_paint() else { panic!("not solid") };
|
||||
assert!(close(k_only(color), 0.498 * 0.4) && swatch.as_deref() == Some("Ink") && t == 0.4, "{color:?} {swatch:?} {t}");
|
||||
let Paint::Gradient(g) = node(&s, grad).appearance.fill_paint() else { panic!("not a gradient") };
|
||||
let ks: Vec<f32> = g.gradient.stops.iter().map(|st| k_only(st.color)).collect();
|
||||
assert!(close(ks[0], 0.0) && close(ks[1], 0.8), "{ks:?}");
|
||||
assert!(close(k_only(pattern_fill(&s, "Grey Tile")), 0.498));
|
||||
// To RGB, `grays` changes nothing.
|
||||
s.execute("file.documentColorMode", &json!({"mode": "rgb", "grays": "black"})).unwrap();
|
||||
let rgb = s.doc().unwrap().doc.layers.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("file.documentColorMode", &json!({"mode": "rgb"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.layers, rgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_in_cmyk_can_put_rgb_greys_on_the_black_plate() {
|
||||
let mut s = session();
|
||||
for hex in ["#000000", "#333333", "#808080", "#e6e6e6", "#ff0000"] {
|
||||
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
fill(&mut s, id, hex);
|
||||
}
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
let data = vectorcraft_format::base64_encode(svg.as_bytes());
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": "greys.svg", "dataBase64": data, "colorMode": "cmyk", "grays": "black"})).unwrap();
|
||||
let d = &o.doc().unwrap().doc;
|
||||
assert_eq!(d.color_mode, ColorMode::Cmyk);
|
||||
let mut fills = vec![];
|
||||
for l in &d.layers {
|
||||
l.walk(&mut |n| fills.extend(n.children().is_none().then(|| n.appearance.fill_paint().color()).flatten()));
|
||||
}
|
||||
assert_eq!(fills.len(), 5, "{fills:?}");
|
||||
for (c, want) in fills.iter().zip([1.0, 0.8, 0.498, 0.098]) {
|
||||
let k = k_only(*c);
|
||||
assert!((k - want).abs() < 0.005, "K {k}, want {want}");
|
||||
}
|
||||
assert!(matches!(fills[4], Color::Cmyk { m, y, .. } if m > 0.8 && y > 0.8), "red separates as usual: {:?}", fills[4]);
|
||||
let e = Session::new().execute("document.open", &json!({"name": "greys.svg", "dataBase64": data, "grays": "k"})).unwrap_err().to_string();
|
||||
assert!(e.contains("grays"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_info_sets_title() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Type on a path (#429): Type on a Path Options keep its brackets, flip it, align it to the path
|
||||
//! and space it; the selection tools drag its brackets to move it along the path and across it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, PathAlign, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, Shape};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// 12 pt type on a path along `d` (SVG path data), starting where the path is nearest `at`.
|
||||
fn path_type(s: &mut Session, d: &str, at: [f64; 2], text: &str) -> NodeId {
|
||||
let pid = s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": text, "size": 12, "at": at})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Type on the 400 pt line from (100, 300) to (500, 300), from its middle on.
|
||||
fn on_line(s: &mut Session) -> NodeId {
|
||||
path_type(s, "M100 300 L500 300", [300.0, 310.0], "Slide")
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The type's start and end brackets as stored.
|
||||
fn brackets(s: &Session, id: NodeId) -> (f64, Option<f64>) {
|
||||
match text(s, id).kind {
|
||||
TextKind::OnPath { start, end, .. } => (start, end),
|
||||
k => panic!("not type on a path: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyphs' ink bounds in the document.
|
||||
fn ink(s: &Session, id: NodeId) -> Rect {
|
||||
let t = text(s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
lay.glyphs
|
||||
.iter()
|
||||
.filter(|g| !g.outline.elements().is_empty())
|
||||
.map(|g| t.xf.transform_rect_bbox(g.outline.bounding_box()))
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The glyph origins along the path, in reading order.
|
||||
fn origins(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
let t = text(s, id);
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).glyphs.iter().map(|g| t.xf * g.origin).collect()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> String {
|
||||
s.doc().unwrap().history.undo.last().unwrap().label.clone()
|
||||
}
|
||||
|
||||
/// Press, drag and release `tool` from `from` to `to`.
|
||||
fn drag(s: &mut Session, tool: &str, from: Point, to: Point, mods: Mods) {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (kind, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap();
|
||||
}
|
||||
assert!(!s.in_interaction());
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_keep_the_brackets() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
assert!(near(brackets(&s, id).0, 0.5));
|
||||
// The query (what the dialog shows) has the current options.
|
||||
let mut q = s.execute("type.pathOptions", &json!({})).unwrap();
|
||||
assert!(near(q["start"].as_f64().unwrap(), 0.5), "{q}");
|
||||
q.as_object_mut().unwrap().remove("start");
|
||||
assert_eq!(q, json!({"end": null, "flip": false, "effect": "rainbow", "alignToPath": "baseline", "spacing": 0.0}));
|
||||
let start = brackets(&s, id).0;
|
||||
// OK with the dialog's values (effect, flip, align, spacing) leaves the type where it is.
|
||||
let before = origins(&s, id);
|
||||
s.execute("type.pathOptions", &json!({"effect": "rainbow", "flip": false, "alignToPath": "baseline", "spacing": 0})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, None));
|
||||
assert_eq!(origins(&s, id), before);
|
||||
// So does a new effect.
|
||||
s.execute("type.pathOptions", &json!({"effect": "skew"})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, None));
|
||||
assert_eq!(text(&s, id).path_effect, vectorcraft_doc::PathEffect::Skew);
|
||||
// The end bracket is set, and put back at the end of the path.
|
||||
s.execute("type.pathOptions", &json!({"end": 0.9})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, Some(0.9)));
|
||||
s.execute("type.pathOptions", &json!({"end": null})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, None));
|
||||
for bad in [json!({"start": "x"}), json!({"effect": "nope"}), json!({"alignToPath": "top"})] {
|
||||
assert!(s.execute("type.pathOptions", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_turns_the_type_to_the_other_side_of_its_stretch() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
let up = ink(&s, id);
|
||||
assert!(up.y1 < 301.0 && up.x0 > 299.0, "above the path from its middle: {up:?}");
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// The path runs the other way and the brackets swap ends: the type keeps the right half.
|
||||
assert!(near(r["start"].as_f64().unwrap(), 0.0) && near(r["end"].as_f64().unwrap(), 0.5), "{r}");
|
||||
let down = ink(&s, id);
|
||||
assert!(down.y0 > 299.0 && down.x0 > 299.0, "below the path, on the same half: {down:?}");
|
||||
let o = origins(&s, id);
|
||||
assert!(o.first().unwrap().x > o.last().unwrap().x, "reading right to left: {o:?}");
|
||||
// Flipped back, it is as it was.
|
||||
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
|
||||
assert!(ink(&s, id).y1 < 301.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flip_round_a_closed_path_keeps_the_type_in_place() {
|
||||
let mut s = session();
|
||||
let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [200.0, 90.0], "Ring");
|
||||
let (start, _) = brackets(&s, id);
|
||||
assert!(near(start, 0.125), "{start}");
|
||||
let first = origins(&s, id)[0];
|
||||
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
// Once round from the same point, the other way.
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.875) && end.is_none(), "{start} {end:?}");
|
||||
assert!(origins(&s, id)[0].distance(first) < 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_to_path_and_spacing() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
s.execute("type.pathOptions", &json!({"alignToPath": "ascender"})).unwrap();
|
||||
assert_eq!(text(&s, id).path_align, PathAlign::Ascender);
|
||||
let hang = ink(&s, id);
|
||||
assert!(hang.y0 > 299.0, "hangs below the path: {hang:?}");
|
||||
s.execute("type.pathOptions", &json!({"alignToPath": "center"})).unwrap();
|
||||
let mid = ink(&s, id);
|
||||
assert!(mid.y0 < 300.0 && mid.y1 > 300.0, "the path runs through it: {mid:?}");
|
||||
// Spacing closes glyphs up round the outside of a curve.
|
||||
let id = path_type(&mut s, "M100 400 A60 60 0 0 1 220 400", [100.0, 400.0], "Round the bend");
|
||||
let spread = |s: &Session| {
|
||||
let o = origins(s, id);
|
||||
o.first().unwrap().distance(*o.last().unwrap())
|
||||
};
|
||||
let loose = spread(&s);
|
||||
s.execute("type.pathOptions", &json!({"spacing": 6})).unwrap();
|
||||
assert!(spread(&s) < loose - 1.0, "{} < {loose}", spread(&s));
|
||||
assert_eq!(s.execute("type.pathOptions", &json!({})).unwrap()["spacing"], json!(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_centre_bracket_slides_the_type_and_across_the_path_flips_it() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
let n = undo_len(&s);
|
||||
// The centre bracket stands midway between the start (300) and the end of the path (500).
|
||||
drag(&mut s, "selection", Point::new(400.0, 295.0), Point::new(440.0, 295.0), Mods::default());
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Move Type on a Path");
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.6) && end.is_none(), "{start} {end:?}");
|
||||
assert!(ink(&s, id).x0 > 339.0);
|
||||
// Direct Selection drags it too; Cmd/Ctrl held below the path only slides it.
|
||||
drag(&mut s, "directSelection", Point::new(420.0, 295.0), Point::new(380.0, 330.0), Mods { cmd: true, ..Mods::default() });
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
|
||||
assert!(ink(&s, id).y1 < 301.0, "still above the path");
|
||||
// Across the path: flipped, on the same stretch.
|
||||
drag(&mut s, "selection", Point::new(400.0, 295.0), Point::new(400.0, 330.0), Mods::default());
|
||||
assert_eq!(undo_len(&s), n + 3);
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.0) && end.is_some_and(|e| near(e, 0.5)), "{start} {end:?}");
|
||||
assert!(ink(&s, id).y0 > 299.0, "below the path");
|
||||
// The step undoes as one.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_start_and_end_brackets_sets_where_the_type_runs() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
// The start bracket from the middle (300) to 200.
|
||||
drag(&mut s, "selection", Point::new(300.0, 290.0), Point::new(200.0, 290.0), Mods::default());
|
||||
assert!(near(brackets(&s, id).0, 0.25));
|
||||
// The end bracket from the end of the path to 400.
|
||||
drag(&mut s, "directSelection", Point::new(500.0, 290.0), Point::new(400.0, 290.0), Mods::default());
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.25) && end.is_some_and(|e| near(e, 0.75)), "{start} {end:?}");
|
||||
let v: Value = s.execute("type.pathOptions", &json!({})).unwrap();
|
||||
assert!(near(v["end"].as_f64().unwrap(), 0.75));
|
||||
let r = ink(&s, id);
|
||||
assert!(r.x0 > 199.0 && r.x1 < 401.0, "between the brackets: {r:?}");
|
||||
}
|
||||
@@ -73,7 +73,7 @@ fn stroke_unit_follows_the_preference() {
|
||||
assert_eq!(s.stroke_unit(), Unit::Points);
|
||||
s.prefs.units_stroke = "millimeters".into();
|
||||
assert_eq!(s.stroke_unit(), Unit::Millimeters);
|
||||
assert_eq!(s.stroke_unit().format(1.0), "0.353 mm");
|
||||
assert_eq!(s.stroke_unit().format(1.0), "0.3528 mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Arc-length parametrization of a path: the point and direction at a distance along it, and the
|
||||
//! distance along it of the point nearest another (type on a path lays its glyphs out and the
|
||||
//! selection tools drag its brackets by distance along the path).
|
||||
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2};
|
||||
|
||||
use crate::PathData;
|
||||
|
||||
const ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// A path's segments with their lengths, measured once.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ArcPath {
|
||||
/// Segments of some length, each with the distance to its start and its length.
|
||||
segs: Vec<(PathSeg, f64, f64)>,
|
||||
len: f64,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl ArcPath {
|
||||
pub fn new(path: &PathData) -> Self {
|
||||
let mut segs = Vec::new();
|
||||
let mut cum = 0.0;
|
||||
for s in path.to_bezpath().segments() {
|
||||
let l = s.arclen(ACCURACY);
|
||||
if l.is_finite() && l > 1e-9 {
|
||||
segs.push((s, cum, l));
|
||||
cum += l;
|
||||
}
|
||||
}
|
||||
Self { segs, len: cum, closed: path.is_closed() }
|
||||
}
|
||||
|
||||
/// The path's length.
|
||||
pub fn len(&self) -> f64 {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// No segment has any length.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.segs.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed
|
||||
}
|
||||
|
||||
/// Point and unit tangent at distance `s` along the path: round and round a closed path, held
|
||||
/// at an open one's ends.
|
||||
pub fn at(&self, s: f64) -> (Point, Vec2) {
|
||||
let s = if self.closed && self.len > 0.0 { s.rem_euclid(self.len) } else { s.clamp(0.0, self.len) };
|
||||
let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1);
|
||||
let Some(&(seg, c, l)) = self.segs.get(i) else { return (Point::ZERO, Vec2::new(1.0, 0.0)) };
|
||||
let t = seg.inv_arclen((s - c).min(l), ACCURACY).clamp(0.0, 1.0);
|
||||
let p = seg.eval(t);
|
||||
let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0));
|
||||
let d = seg.eval(t1) - seg.eval(t0);
|
||||
let len = d.hypot();
|
||||
(p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) })
|
||||
}
|
||||
|
||||
/// The distance along the path of its point nearest `p`.
|
||||
pub fn nearest(&self, p: Point) -> Option<f64> {
|
||||
self.segs
|
||||
.iter()
|
||||
.map(|(seg, c, l)| {
|
||||
let n = seg.nearest(p, ACCURACY);
|
||||
(n.distance_sq, c + seg.subsegment(0.0..n.t).arclen(ACCURACY).min(*l))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, s)| s)
|
||||
}
|
||||
|
||||
/// [`Self::nearest`] as a fraction (0..1) of the path's length.
|
||||
pub fn fraction_at(&self, p: Point) -> Option<f64> {
|
||||
(self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes;
|
||||
use kurbo::{Rect, Shape};
|
||||
|
||||
#[test]
|
||||
fn points_and_nearest_distances_along_a_path() {
|
||||
let line = PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1));
|
||||
let a = ArcPath::new(&line);
|
||||
assert!((a.len() - 100.0).abs() < 1e-6 && !a.is_closed());
|
||||
assert!(a.at(25.0).0.distance(Point::new(25.0, 0.0)) < 1e-6);
|
||||
// Held at an open path's ends.
|
||||
assert!(a.at(150.0).0.distance(Point::new(100.0, 0.0)) < 1e-6);
|
||||
assert!((a.nearest(Point::new(40.0, 30.0)).unwrap() - 40.0).abs() < 1e-6);
|
||||
// Round a closed one.
|
||||
let sq = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
assert!(sq.is_closed() && (sq.len() - 40.0).abs() < 1e-6);
|
||||
assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6);
|
||||
assert!(ArcPath::new(&PathData::default()).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! Coordinates are document points (1/72 in), y pointing down.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod arc;
|
||||
pub mod bez;
|
||||
pub mod hit;
|
||||
pub mod path;
|
||||
@@ -15,6 +16,7 @@ pub mod recognize;
|
||||
pub mod shapes;
|
||||
pub mod snap;
|
||||
|
||||
pub use arc::ArcPath;
|
||||
pub use kurbo;
|
||||
pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2};
|
||||
pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath};
|
||||
|
||||
@@ -47,6 +47,18 @@ impl CornerKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest corner radius a `w` × `h` rectangle draws: half its shorter side.
|
||||
pub fn max_corner_radius(w: f64, h: f64) -> f64 {
|
||||
w.abs().min(h.abs()) / 2.0
|
||||
}
|
||||
|
||||
/// The radius a corner of a `w` × `h` rectangle is drawn with: `radius`, no larger than
|
||||
/// [`max_corner_radius`] (the same limit for every corner, so opposite corners match), 0 when
|
||||
/// negative or not a number.
|
||||
pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 {
|
||||
radius.max(0.0).min(max_corner_radius(w, h))
|
||||
}
|
||||
|
||||
/// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have
|
||||
/// their own radius and kind, every radius clamped to half the shorter side. A corner without a
|
||||
/// radius is one anchor, the others two (where the cut meets each side). Clockwise from the
|
||||
@@ -63,7 +75,6 @@ pub fn rectangle_anchor_corners(r: Rect, radii: [f64; 4]) -> Vec<usize> {
|
||||
/// The anchors of [`rectangle_with_corners`], each with its corner.
|
||||
fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usize, Anchor)> {
|
||||
let r = r.abs();
|
||||
let max = r.width().min(r.height()) / 2.0;
|
||||
// Each corner, with the directions of the side arriving at it and of the side leaving it.
|
||||
let corners = [
|
||||
(Point::new(r.x0, r.y0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0)),
|
||||
@@ -74,7 +85,7 @@ fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usiz
|
||||
let mut out = Vec::with_capacity(8);
|
||||
let mut top_left_start = None;
|
||||
for (k, ((c, din, dout), (rad, kind))) in corners.into_iter().zip(radii.into_iter().zip(kinds)).enumerate() {
|
||||
let rad = rad.max(0.0).min(max);
|
||||
let rad = fitted_corner_radius(r.width(), r.height(), rad);
|
||||
if rad <= 1e-9 {
|
||||
out.push((k, Anchor::corner(c)));
|
||||
continue;
|
||||
@@ -312,6 +323,24 @@ mod tests {
|
||||
assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]);
|
||||
}
|
||||
|
||||
/// #442: on a rectangle that isn't square, every corner is a circular arc of the same radius,
|
||||
/// and radii past the limit stop at half the shorter side, whichever side a corner is on.
|
||||
#[test]
|
||||
fn corners_of_a_non_square_rectangle_are_alike() {
|
||||
let r = Rect::new(0.0, 0.0, 200.0, 80.0);
|
||||
assert_eq!(max_corner_radius(-200.0, 80.0), 40.0);
|
||||
assert_eq!([30.0, 60.0, -1.0, f64::NAN].map(|x| fitted_corner_radius(200.0, 80.0, x)), [30.0, 40.0, 0.0, 0.0]);
|
||||
for (radius, drawn) in [(30.0, 30.0), (60.0, 40.0)] {
|
||||
for kinds in [[CornerKind::Round; 4], [CornerKind::Chamfer, CornerKind::InvertedRound, CornerKind::Round, CornerKind::Chamfer]] {
|
||||
let a: Vec<Point> = rectangle_with_corners(r, [radius; 4], kinds).subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
for (i, j) in [(7, 0), (1, 2), (3, 4), (5, 6)] {
|
||||
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
|
||||
assert!((x - drawn).abs() < 1e-9 && (y - drawn).abs() < 1e-9, "radius {radius}: a corner spans {x} × {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_first_vertex_up() {
|
||||
let p = polygon(Point::new(0.0, 0.0), 10.0, 6, 0.0);
|
||||
|
||||
@@ -145,3 +145,55 @@ fn pdf_pages(pdf: &[u8]) -> usize {
|
||||
.unwrap();
|
||||
h.session.doc().unwrap().doc.artboards.len()
|
||||
}
|
||||
|
||||
/// Issue #421 over MCP: RGB greys switched to CMYK with `grays: "black"` print on the black plate
|
||||
/// only, in the document and in the exported PDF.
|
||||
#[test]
|
||||
fn document_color_mode_puts_rgb_greys_on_the_black_plate() {
|
||||
let svg = tmp("greys.svg");
|
||||
std::fs::write(
|
||||
&svg,
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200pt" height="50pt" viewBox="0 0 200 50">
|
||||
<rect x="0" y="0" width="50" height="50" fill="#000000"/><rect x="50" y="0" width="50" height="50" fill="#333333"/>
|
||||
<rect x="100" y="0" width="50" height="50" fill="#808080"/><rect x="150" y="0" width="50" height="50" fill="#e6e6e6"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let want = [1.0, 0.8, 0.498, 0.098];
|
||||
let k_only = |h: &Headless| -> Vec<f32> {
|
||||
let mut ks = vec![];
|
||||
for l in &h.session.doc().unwrap().doc.layers {
|
||||
l.walk(&mut |n| {
|
||||
if n.children().is_none()
|
||||
&& let Some(c) = n.appearance.fill_paint().color()
|
||||
{
|
||||
let v = serde_json::to_value(c).unwrap();
|
||||
assert!(v["model"] == "cmyk" && v["c"] == 0.0 && v["m"] == 0.0 && v["y"] == 0.0, "not K only: {v}");
|
||||
ks.push(v["k"].as_f64().unwrap() as f32);
|
||||
}
|
||||
});
|
||||
}
|
||||
ks
|
||||
};
|
||||
let close = |ks: &[f32]| ks.len() == want.len() && ks.iter().zip(want).all(|(k, w)| (k - w).abs() < 0.005);
|
||||
let mut h = Headless::new();
|
||||
text(&call_tool(&mut h, "open_file", &json!({"path": svg})));
|
||||
let r = text(&call_tool(&mut h, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "cmyk", "grays": "black"}})));
|
||||
assert_eq!(r["changed"], 4);
|
||||
let ks = k_only(&h);
|
||||
assert!(close(&ks), "{ks:?}");
|
||||
// The PDF carries the same inks (DeviceCMYK opens as CMYK).
|
||||
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf"})));
|
||||
let mut back = Headless::new();
|
||||
back.call("engine.execute", json!({"command": "document.open", "params": {"name": "greys.pdf", "dataBase64": pdf["dataBase64"]}})).unwrap();
|
||||
let ks = k_only(&back);
|
||||
assert!(close(&ks), "{ks:?}");
|
||||
// document.open takes the same option.
|
||||
let mut o = Headless::new();
|
||||
let r = call_tool(&mut o, "run_command", &json!({"command": "document.open", "params": {"path": svg, "colorMode": "cmyk", "grays": "black"}}));
|
||||
text(&r);
|
||||
let ks = k_only(&o);
|
||||
assert!(close(&ks), "{ks:?}");
|
||||
let bad = call_tool(&mut o, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "rgb", "grays": "rich"}}));
|
||||
assert!(bad.is_error, "{bad:?}");
|
||||
}
|
||||
@@ -20,7 +20,7 @@ pub use edit::{
|
||||
};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
|
||||
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
|
||||
pub use planar::{SHAPE_BUILDER_MAX_SEGMENTS, interior_point, live_paint, shape_builder};
|
||||
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, interior_point, live_paint, shape_builder};
|
||||
|
||||
/// Errors from fallible operations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
|
||||
+253
-21
@@ -21,7 +21,7 @@ use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _};
|
||||
use linesweeper::topology::{Topology, WindingNumber};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_geom::{FillRule, PathData, SubPath};
|
||||
|
||||
use crate::boolean::{DEFAULT_PRECISION, Seg, Tidy, eps_for, is_sliver, segs_to_subpath, snap_horizontals, tidy_segments, to_seg};
|
||||
use crate::pathfinder::{Region, Shape};
|
||||
@@ -102,7 +102,12 @@ struct Outlines {
|
||||
|
||||
impl Outlines {
|
||||
fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self {
|
||||
let segs = inputs.iter().flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i))).collect();
|
||||
// A zero-length segment (a point) is the source of no edge, even one running through it.
|
||||
let segs = inputs
|
||||
.iter()
|
||||
.flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i)))
|
||||
.filter(|(r, ..)| r.width() > 0.0 || r.height() > 0.0)
|
||||
.collect();
|
||||
// The sweep moves points by a few `eps` at most.
|
||||
Self { segs, tol: 16.0 * eps }
|
||||
}
|
||||
@@ -292,26 +297,172 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
|
||||
/// than the filled areas' arrangement, so past this only the filled areas count.
|
||||
pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096;
|
||||
|
||||
/// Shape Builder regions of `shapes` (back → front), and the pieces of their open paths.
|
||||
/// The Shape Builder's arrangement: see [`shape_builder`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BuilderArrangement {
|
||||
pub regions: Vec<Region>,
|
||||
/// The pieces of the paths with no closed subpath, keyed by input index.
|
||||
pub lines: Vec<Shape>,
|
||||
/// The pieces of the other paths' outlines, keyed by input index (the front-most where inputs
|
||||
/// share one); only when asked for.
|
||||
pub edges: Vec<Shape>,
|
||||
}
|
||||
|
||||
/// Shape Builder regions of `shapes` (back → front), the pieces of their open paths and, with
|
||||
/// `edges`, the pieces of their outlines (what erasing an edge deletes).
|
||||
///
|
||||
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions). Open
|
||||
/// paths (the caller closes the ones whose fill should count) are cutting edges: a line across a
|
||||
/// shape splits it, lines around an area enclose a region nothing fills (empty `sources`), and each
|
||||
/// path with no closed subpath comes back cut wherever another path meets it, as pieces keyed by
|
||||
/// its index in `shapes`. With no open path, more than [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or
|
||||
/// a map that can't be built, only the filled areas count and there are no pieces.
|
||||
pub fn shape_builder(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
|
||||
/// its index in `shapes`. Outlines are cut the same way. With no open path, more than
|
||||
/// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas
|
||||
/// count and there are no pieces.
|
||||
pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
|
||||
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
|
||||
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
|
||||
if has_open && segments <= SHAPE_BUILDER_MAX_SEGMENTS {
|
||||
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
|
||||
let (faces, edges) = live_paint(&keyed);
|
||||
if !faces.is_empty() || !edges.is_empty() {
|
||||
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
|
||||
return (faces, edges.into_iter().filter(|e| is_line(e.key)).collect());
|
||||
let planar = ((has_open || edges) && segments <= SHAPE_BUILDER_MAX_SEGMENTS)
|
||||
.then(|| {
|
||||
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
|
||||
live_paint(&keyed)
|
||||
})
|
||||
.filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty());
|
||||
let Some((faces, pieces)) = planar else {
|
||||
return BuilderArrangement { regions: crate::regions(shapes), ..Default::default() };
|
||||
};
|
||||
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
|
||||
let (lines, outline): (Vec<Shape>, Vec<Shape>) = pieces.into_iter().partition(|e| is_line(e.key));
|
||||
BuilderArrangement {
|
||||
regions: if has_open { faces } else { crate::regions(shapes) },
|
||||
lines: if has_open { lines } else { Vec::new() },
|
||||
edges: if edges { outline } else { Vec::new() },
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the
|
||||
/// open subpaths of filled paths that do.
|
||||
pub fn encloses_area(sp: &SubPath) -> bool {
|
||||
let mut closed = sp.clone();
|
||||
closed.closed = true;
|
||||
let mut bp = BezPath::new();
|
||||
closed.to_bezpath_into(&mut bp);
|
||||
let size = bp.bounding_box().size();
|
||||
bp.area().abs() > 1e-9 * (size.width * size.width + size.height * size.height)
|
||||
}
|
||||
|
||||
/// `path` without `piece`, a run along one of its subpaths between two points (a Shape Builder
|
||||
/// edge): a closed subpath opens there, an open one splits in two (or loses an end), and a piece
|
||||
/// that runs all the way round takes its subpath. With `fill_closes`, open subpaths that enclose an
|
||||
/// area ([`encloses_area`]) run on round an invisible closing edge, as the Shape Builder sees a
|
||||
/// filled path: a piece may run over it, and it never comes back as a visible segment. None when
|
||||
/// the piece doesn't lie on `path` within `tol`.
|
||||
pub fn cut_out(path: &PathData, piece: &SubPath, fill_closes: bool, tol: f64) -> Option<PathData> {
|
||||
let n = piece.segment_count();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
// A point well inside the piece: the middle of its longest segment.
|
||||
let arclen = |k: usize| kurbo::ParamCurveArclen::arclen(&piece.segment(k), 1e-3);
|
||||
let mid = (0..n).map(|k| (arclen(k), k)).max_by(|x, y| x.0.total_cmp(&y.0)).map(|(_, k)| piece.segment(k).eval(0.5))?;
|
||||
let (a, b) = (piece.anchors.first()?.p, piece.anchors.last()?.p);
|
||||
let mut bp = BezPath::new();
|
||||
piece.to_bezpath_into(&mut bp);
|
||||
if bp.perimeter(1e-6) <= tol {
|
||||
// A speck (where paths touch): there's nothing to cut.
|
||||
return None;
|
||||
}
|
||||
// A piece that ends where it starts runs all the way round.
|
||||
let whole = piece.closed || a.distance(b) <= tol;
|
||||
// Each subpath as the piece may run along it: round its closing edge when it has one.
|
||||
let ring = |sp: &SubPath| SubPath { anchors: sp.anchors.clone(), closed: sp.closed || (fill_closes && encloses_area(sp)) };
|
||||
let (si, geom, um) = path
|
||||
.subpaths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, sp)| {
|
||||
let g = ring(sp);
|
||||
let (u, d) = nearest_u(&g, mid)?;
|
||||
(d <= tol).then_some((i, g, u, d))
|
||||
})
|
||||
.min_by(|x, y| x.3.total_cmp(&y.3))
|
||||
.map(|(i, g, u, _)| (i, g, u))?;
|
||||
let len = geom.segment_count() as f64;
|
||||
let real = path.subpaths.get(si)?.segment_count() as f64;
|
||||
let at = |p: Point| nearest_u(&geom, p).filter(|(_, d)| *d <= tol).map(|(u, _)| u);
|
||||
// The parameter spans of `geom` that stay (unrolled past the end of a ring).
|
||||
let keep: Vec<(f64, f64)> = if whole {
|
||||
Vec::new()
|
||||
} else if geom.closed {
|
||||
let (ua, ub) = (at(a)?, at(b)?);
|
||||
let fwd = |from: f64, to: f64| (to - from).rem_euclid(len);
|
||||
// The piece runs from one end through `mid` to the other; what stays runs on round.
|
||||
let (start, end) = if fwd(ua, um) <= fwd(ua, ub) { (ub, ua) } else { (ua, ub) };
|
||||
let rest = fwd(start, end);
|
||||
if rest <= 1e-9 || len - rest <= 1e-9 {
|
||||
Vec::new()
|
||||
} else {
|
||||
// Where the ring closes with an invisible edge (`real..len`), what stays stops at it.
|
||||
let parts: &[(f64, f64)] = if real >= len { &[(0.0, 2.0 * len)] } else { &[(0.0, real), (len, len + real)] };
|
||||
parts.iter().map(|&(lo, hi)| (start.max(lo), (start + rest).min(hi))).filter(|(x, y)| y - x > 1e-9).collect()
|
||||
}
|
||||
} else {
|
||||
let (ua, ub) = (at(a)?, at(b)?);
|
||||
let (lo, hi) = (ua.min(ub), ua.max(ub));
|
||||
if !(lo - 1e-9..=hi + 1e-9).contains(&um) {
|
||||
return None;
|
||||
}
|
||||
[(0.0, lo), (hi, len)].into_iter().filter(|(x, y)| y - x > 1e-9).collect()
|
||||
};
|
||||
let mut subpaths: Vec<SubPath> = path.subpaths.iter().take(si).cloned().collect();
|
||||
subpaths.extend(keep.into_iter().filter_map(|(u0, u1)| span(&geom, u0, u1)).map(|sp| snap_ends(sp, [a, b], tol)));
|
||||
subpaths.extend(path.subpaths.iter().skip(si + 1).cloned());
|
||||
Some(PathData::new(subpaths))
|
||||
}
|
||||
|
||||
/// The point of `sp` nearest `p`, as a parameter `segment + t`, with its distance.
|
||||
fn nearest_u(sp: &SubPath, p: Point) -> Option<(f64, f64)> {
|
||||
use kurbo::ParamCurveNearest;
|
||||
(0..sp.segment_count())
|
||||
.map(|k| {
|
||||
let n = sp.segment(k).nearest(p, 1e-9);
|
||||
(k as f64 + n.t, n.distance_sq.sqrt())
|
||||
})
|
||||
.min_by(|x, y| x.1.total_cmp(&y.1))
|
||||
}
|
||||
|
||||
/// `sp` with its end anchors within `tol` of one of `to` put exactly on it (where the planar map
|
||||
/// put the junction a piece ends at), handles moving with them.
|
||||
fn snap_ends(mut sp: SubPath, to: [Point; 2], tol: f64) -> SubPath {
|
||||
let last = sp.anchors.len().saturating_sub(1);
|
||||
for i in [0, last] {
|
||||
if let Some(an) = sp.anchors.get_mut(i)
|
||||
&& let Some(q) = to.iter().find(|q| q.distance(an.p) <= tol)
|
||||
{
|
||||
let d = *q - an.p;
|
||||
an.p = *q;
|
||||
an.h_in += d;
|
||||
an.h_out += d;
|
||||
}
|
||||
}
|
||||
(crate::regions(shapes), Vec::new())
|
||||
sp
|
||||
}
|
||||
|
||||
/// The run of `sp` from parameter `u0` to `u1` (`u0 < u1`, unrolled past the end of a closed one),
|
||||
/// open.
|
||||
fn span(sp: &SubPath, u0: f64, u1: f64) -> Option<SubPath> {
|
||||
let n = sp.segment_count();
|
||||
if n == 0 || !(u0.is_finite() && u1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let first = u0.floor().max(0.0) as usize;
|
||||
let last = (u1.ceil().max(0.0) as usize).min(first + 2 * n);
|
||||
let segs: Vec<Seg> = (first..last)
|
||||
.filter_map(|k| {
|
||||
let (t0, t1) = ((u0 - k as f64).clamp(0.0, 1.0), (u1 - k as f64).clamp(0.0, 1.0));
|
||||
(t1 - t0 > 1e-12).then(|| Seg { c: sp.segment(k % n).subsegment(t0..t1), line: sp.segment_is_line(k % n) })
|
||||
})
|
||||
.collect();
|
||||
segs_to_subpath(&segs, false)
|
||||
}
|
||||
|
||||
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
@@ -618,7 +769,7 @@ pub fn interior_point(path: &PathData) -> Option<Point> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
use vectorcraft_geom::{Rect, SubPath, shapes};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape {
|
||||
Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key)
|
||||
@@ -766,14 +917,14 @@ mod tests {
|
||||
fn shape_builder_lines_cut_regions() {
|
||||
// A line across a rectangle: two regions, the line in three pieces keyed by its index.
|
||||
let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)];
|
||||
let (regions, lines) = shape_builder(&s);
|
||||
let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false);
|
||||
assert_eq!(regions.len(), 2);
|
||||
assert!(regions.iter().all(|r| r.sources == [0]));
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines.iter().all(|l| l.key == 1 && !l.path.is_closed()));
|
||||
// Lines alone enclose a region nothing fills.
|
||||
let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 0), line((-10.0, 80.0), (110.0, 80.0), 0)];
|
||||
let (regions, lines) = shape_builder(&s);
|
||||
let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false);
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert!(regions[0].sources.is_empty() && regions[0].top().is_none());
|
||||
assert_eq!(lines.len(), 9);
|
||||
@@ -782,10 +933,10 @@ mod tests {
|
||||
#[test]
|
||||
fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() {
|
||||
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)];
|
||||
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
|
||||
assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() });
|
||||
// A broken line can't be swept: what the filled areas give (nothing here, without a panic).
|
||||
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((0.0, 0.0), (f64::NAN, 1.0), 1)];
|
||||
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
|
||||
assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() });
|
||||
// Zero-length and doubled-back lines don't break it either.
|
||||
let s = [
|
||||
rect(0.0, 0.0, 100.0, 100.0, 0),
|
||||
@@ -793,7 +944,7 @@ mod tests {
|
||||
line((0.0, 0.0), (100.0, 0.0), 2),
|
||||
line((50.0, 0.0), (50.0, 100.0), 3),
|
||||
];
|
||||
let (regions, _) = shape_builder(&s);
|
||||
let regions = shape_builder(&s, false).regions;
|
||||
assert_eq!(regions.len(), 2);
|
||||
assert!((regions.iter().map(area).sum::<f64>() - 10000.0).abs() < 1e-6);
|
||||
}
|
||||
@@ -815,14 +966,75 @@ mod tests {
|
||||
s.push(line((-150.0, y), (150.0, y + 7.0), 1));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let (regions, lines) = shape_builder(&s);
|
||||
let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false);
|
||||
assert!(regions.len() >= 5, "the ring cut in five bands at least (the waves make pockets)");
|
||||
assert!(lines.len() >= 12, "{}", lines.len());
|
||||
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
|
||||
// Past the cap, only the filled areas count.
|
||||
s.push(line((0.0, 0.0), (1.0, 1.0), 2));
|
||||
s[0].path.subpaths[0].anchors.extend((0..16).map(|k| vectorcraft_geom::Anchor::corner(Point::new(100.0, f64::from(k)))));
|
||||
assert!(shape_builder(&s).1.is_empty());
|
||||
assert!(shape_builder(&s, false).lines.is_empty());
|
||||
}
|
||||
|
||||
fn poly(pts: &[(f64, f64)], closed: bool) -> SubPath {
|
||||
SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), closed)
|
||||
}
|
||||
|
||||
fn ends(sp: &SubPath) -> (Point, Point) {
|
||||
(sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_edges_are_the_outlines_cut_where_paths_meet() {
|
||||
// Two overlapping squares: each outline in two pieces, cut where the other crosses it.
|
||||
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 50.0, 150.0, 150.0, 1)];
|
||||
let with = shape_builder(&s, true);
|
||||
assert_eq!(with.regions, shape_builder(&s, false).regions, "the regions don't change");
|
||||
assert!(with.lines.is_empty() && shape_builder(&s, false).edges.is_empty());
|
||||
assert_eq!(with.edges.len(), 4);
|
||||
for key in [0, 1] {
|
||||
assert_eq!(with.edges.iter().filter(|e| e.key == key && !e.path.is_closed()).count(), 2);
|
||||
}
|
||||
let inner = with.edges.iter().find(|e| e.key == 0 && e.path.bounds() == Some(Rect::new(50.0, 50.0, 100.0, 100.0))).expect("A inside B");
|
||||
// Erasing it opens A there.
|
||||
let cut = cut_out(&s[0].path, &inner.path.subpaths[0], false, 1e-3).expect("on A");
|
||||
assert_eq!(cut.subpaths.len(), 1);
|
||||
let sp = &cut.subpaths[0];
|
||||
assert!(!sp.closed && sp.anchors.len() == 5, "{sp:?}");
|
||||
let (a, b) = ends(sp);
|
||||
assert!([a, b].contains(&Point::new(100.0, 50.0)) && [a, b].contains(&Point::new(50.0, 100.0)), "{a:?} {b:?}");
|
||||
assert!((cut.length() - 300.0).abs() < 1e-6, "{}", cut.length());
|
||||
// Not on B.
|
||||
assert_eq!(cut_out(&s[1].path, &inner.path.subpaths[0], false, 1e-3), None);
|
||||
// A lone square's edge is its whole outline: erasing it leaves nothing.
|
||||
let lone = shape_builder(&s[..1], true);
|
||||
assert_eq!(lone.edges.len(), 1);
|
||||
assert!(cut_out(&s[0].path, &lone.edges[0].path.subpaths[0], false, 1e-3).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_out_splits_open_paths_and_keeps_closing_edges_invisible() {
|
||||
let open = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0)], false));
|
||||
let cut = cut_out(&open, &poly(&[(60.0, 0.0), (30.0, 0.0)], false), false, 1e-3).unwrap();
|
||||
let mut parts: Vec<(Point, Point)> = cut.subpaths.iter().map(ends).collect();
|
||||
parts.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
|
||||
assert_eq!(parts, vec![(Point::new(0.0, 0.0), Point::new(30.0, 0.0)), (Point::new(60.0, 0.0), Point::new(100.0, 0.0))]);
|
||||
// An end piece only shortens it.
|
||||
let cut = cut_out(&open, &poly(&[(70.0, 0.0), (100.0, 0.0)], false), false, 1e-3).unwrap();
|
||||
assert_eq!(cut.subpaths.iter().map(ends).collect::<Vec<_>>(), vec![(Point::new(0.0, 0.0), Point::new(70.0, 0.0))]);
|
||||
|
||||
// A filled open path runs on round its invisible closing edge (100,100) → (0,0).
|
||||
let corner = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false));
|
||||
let piece = poly(&[(100.0, 50.0), (100.0, 100.0), (50.0, 50.0)], false);
|
||||
assert_eq!(cut_out(&corner, &piece, false, 1e-3), None, "without the fill it isn't on the path");
|
||||
let cut = cut_out(&corner, &piece, true, 1e-3).unwrap();
|
||||
assert_eq!(cut.subpaths.len(), 1);
|
||||
assert_eq!(cut.subpaths[0].anchors.iter().map(|a| a.p).collect::<Vec<_>>(), [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)].map(Point::from));
|
||||
// Cut elsewhere, the closing edge stays invisible: two open runs, not one through it.
|
||||
let cut = cut_out(&corner, &poly(&[(20.0, 0.0), (60.0, 0.0)], false), true, 1e-3).unwrap();
|
||||
assert_eq!(cut.subpaths.len(), 2);
|
||||
assert!(cut.subpaths.iter().all(|sp| !sp.closed));
|
||||
assert!((cut.length() - 160.0).abs() < 1e-6, "{}", cut.length());
|
||||
}
|
||||
|
||||
fn arb_segment() -> impl Strategy<Value = Shape> {
|
||||
@@ -872,6 +1084,26 @@ mod tests {
|
||||
prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions);
|
||||
}
|
||||
|
||||
/// Every edge and line piece cuts out of the input it is keyed by, which loses its length
|
||||
/// and nothing else.
|
||||
#[test]
|
||||
fn pieces_cut_out_of_their_inputs(shapes in prop::collection::vec(arb_segment(), 1..6)) {
|
||||
let arr = shape_builder(&shapes, true);
|
||||
for e in arr.edges.iter().chain(&arr.lines) {
|
||||
let (Some(s), Some(piece)) = (shapes.get(e.key as usize), e.path.subpaths.first()) else { continue };
|
||||
let len = e.path.length();
|
||||
let cut = cut_out(&s.path, piece, false, 0.05);
|
||||
if len <= 0.05 {
|
||||
// A speck where two paths touch.
|
||||
prop_assert!(cut.is_none());
|
||||
continue;
|
||||
}
|
||||
prop_assert!(cut.is_some(), "{:?} not on {:?}", piece, s.path);
|
||||
let lost = s.path.length() - cut.map_or(0.0, |c| c.length());
|
||||
prop_assert!((lost - len).abs() <= 0.05 + 1e-3 * len, "lost {} for a piece {} long", lost, len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics.
|
||||
#[test]
|
||||
fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) {
|
||||
|
||||
@@ -269,7 +269,7 @@ impl TextLine {
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
if let Some(path) = path {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
|
||||
return Some((t, self.opacity));
|
||||
|
||||
@@ -96,7 +96,7 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
|
||||
TextRun { text: " ok".into(), style: CharStyle { rotation: 20.0, ..style(20.0) } },
|
||||
];
|
||||
let mut on_path = TextObject::point(Point::ZERO, "along the arc", style(18.0));
|
||||
on_path.kind = TextKind::OnPath { path: arc(), start: 0.0 };
|
||||
on_path.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None };
|
||||
on_path.xf = Affine::IDENTITY;
|
||||
for t in [plain, turned, mixed, on_path] {
|
||||
let what = t.plain_text();
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, LineCap, LiveShape, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind};
|
||||
use vectorcraft_effects::RasterFx;
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
use crate::export::sanitize_id;
|
||||
@@ -600,19 +600,21 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
|
||||
let [_, b, c, _, _, _] = xf.as_coeffs();
|
||||
b.abs() < 1e-9 && c.abs() < 1e-9
|
||||
};
|
||||
match live {
|
||||
// Sizes and radii in document units.
|
||||
match live.map(LiveShape::folded).as_ref() {
|
||||
// CSS rounds corners only: an inverted round or chamfered corner has no border radius.
|
||||
Some(LiveShape::Rectangle { radii, kinds, xf, .. })
|
||||
Some(LiveShape::Rectangle { w, h, radii, kinds, xf })
|
||||
if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) =>
|
||||
{
|
||||
let [a, _, _, d, _, _] = xf.as_coeffs();
|
||||
let s = (a * d).abs().sqrt();
|
||||
// The radii as drawn, none past half the shorter side (CSS draws one further when its
|
||||
// neighbours leave room).
|
||||
let radii = radii.map(|r| shapes::fitted_corner_radius(*w, *h, r));
|
||||
if radii.iter().all(|r| *r <= 0.0) {
|
||||
Some(Corners::Square)
|
||||
} else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) {
|
||||
Some(Corners::Round(vec![radii[0] * s]))
|
||||
Some(Corners::Round(vec![radii[0]]))
|
||||
} else {
|
||||
Some(Corners::Round(radii.iter().map(|r| r.max(0.0) * s).collect()))
|
||||
Some(Corners::Round(radii.to_vec()))
|
||||
}
|
||||
}
|
||||
Some(LiveShape::Ellipse { pie, xf, .. }) if upright(xf) => {
|
||||
|
||||
@@ -1665,7 +1665,13 @@ impl Writer<'_> {
|
||||
self.warn("bidirectional text is outlined to preserve shaping and visual order");
|
||||
return self.text_outlines(n, t);
|
||||
}
|
||||
if let TextKind::OnPath { path, start } = &t.kind {
|
||||
if let TextKind::OnPath { path, start, end } = &t.kind {
|
||||
// `<textPath>` has a start offset only: an end bracket, Align to Path and Spacing
|
||||
// keep their look as outlines.
|
||||
if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 {
|
||||
self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look");
|
||||
return self.text_outlines(n, t);
|
||||
}
|
||||
return self.text_on_path(n, t, path, *start);
|
||||
}
|
||||
self.note_fonts(t, &lay);
|
||||
|
||||
@@ -586,7 +586,7 @@ impl Ctx {
|
||||
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
|
||||
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
|
||||
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0));
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start };
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None };
|
||||
obj.xf = Affine::IDENTITY;
|
||||
} else if vertical {
|
||||
// Type on a vertical path: dy is extra advance, dx shifts across the column.
|
||||
@@ -606,7 +606,7 @@ impl Ctx {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((x0, y0));
|
||||
bp.line_to((x0, y0 + width + 1.0));
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start: 0.0 };
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start: 0.0, end: None };
|
||||
obj.xf = Affine::IDENTITY;
|
||||
} else {
|
||||
let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]);
|
||||
|
||||
@@ -84,6 +84,13 @@ fn live_corners_give_per_corner_radii_and_only_round_ones() {
|
||||
kinds[1] = CornerKind::InvertedRound;
|
||||
}
|
||||
assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in");
|
||||
// A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS
|
||||
// would draw it larger, its neighbours leaving room.
|
||||
if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
*radii = [60.0, 8.0, 0.0, 4.0];
|
||||
*kinds = Default::default();
|
||||
}
|
||||
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("25px 8px 0 4px"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -150,7 +150,7 @@ fn text_path_becomes_type_on_a_path() {
|
||||
);
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 3);
|
||||
let TextKind::OnPath { path, start } = &t[0].kind else { panic!("{:?}", t[0].kind) };
|
||||
let TextKind::OnPath { path, start, .. } = &t[0].kind else { panic!("{:?}", t[0].kind) };
|
||||
assert!(close(*start, 0.25, 1e-9));
|
||||
let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap());
|
||||
assert!(close(b.x0, 10.0, 1e-6) && close(b.x1, 210.0, 1e-6) && close(b.y0, 100.0, 1e-6), "{b:?}");
|
||||
@@ -310,7 +310,7 @@ fn pattern_fill_on_text_and_shapes() {
|
||||
fn vertical_writing_mode_runs_down_a_path() {
|
||||
let d = open(r#"<text x="100" y="20" writing-mode="tb" font-size="10">Down</text>"#);
|
||||
let t = only_text(&d);
|
||||
let TextKind::OnPath { path, start } = &t.kind else { panic!("{:?}", t.kind) };
|
||||
let TextKind::OnPath { path, start, .. } = &t.kind else { panic!("{:?}", t.kind) };
|
||||
assert_eq!(*start, 0.0);
|
||||
let b = path.bounds().unwrap();
|
||||
assert!(close(b.x0, b.x1, 1e-9) && close(b.y0, 20.0, 1e-9) && b.height() > 20.0, "{b:?}");
|
||||
|
||||
@@ -420,19 +420,42 @@ fn text_on_path_roundtrip() {
|
||||
curve.move_to((20.0, 150.0));
|
||||
curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0));
|
||||
let mut t = TextObject::point(Point::new(0.0, 0.0), "On a curve", CharStyle { size: 14.0, ..CharStyle::default() });
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.25 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.25, end: None };
|
||||
let id = d.alloc_id();
|
||||
let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]);
|
||||
let r = roundtrip(&d);
|
||||
let a = art(&r);
|
||||
let NodeKind::Text(t) = &a[0].kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "On a curve");
|
||||
let TextKind::OnPath { path, start } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
|
||||
let TextKind::OnPath { path, start, .. } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
|
||||
assert!((start - 0.25).abs() < 1e-6, "start {start}");
|
||||
let doc_path = path.bounds().map(|b| t.xf.transform_rect_bbox(b)).unwrap();
|
||||
assert!(close_rect(doc_path, PathData::from_bezpath(&curve).bounds().unwrap(), 0.01), "{doc_path:?}");
|
||||
}
|
||||
|
||||
/// An end bracket, Align to Path or Spacing, which `<textPath>` can't express, keep their look as
|
||||
/// outlines (#429).
|
||||
#[test]
|
||||
fn text_on_path_options_svg_cannot_express_are_outlined() {
|
||||
let mut curve = vectorcraft_geom::BezPath::new();
|
||||
curve.move_to((20.0, 150.0));
|
||||
curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0));
|
||||
for set in [0, 1, 2] {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let mut t = TextObject::point(Point::ZERO, "On a curve", CharStyle { size: 14.0, ..CharStyle::default() });
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: (set == 0).then_some(0.8) };
|
||||
if set == 1 {
|
||||
t.path_align = vectorcraft_doc::PathAlign::Center;
|
||||
}
|
||||
if set == 2 {
|
||||
t.path_spacing = 4.0;
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default());
|
||||
assert!(!svg.contains("<textPath"), "{set}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styling_modes_roundtrip() {
|
||||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
|
||||
|
||||
@@ -31,3 +31,8 @@ test-fonts = ["dep:write-fonts"]
|
||||
|
||||
[dev-dependencies]
|
||||
write-fonts = "0.48"
|
||||
|
||||
# Fonts registered with Windows outside its font folders ("install as shortcut", installers);
|
||||
# MIT OR Apache-2.0, a safe wrapper of the registry API from the windows-rs project.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-registry = "0.6"
|
||||
+149
-32
@@ -238,9 +238,9 @@ pub struct FontDb {
|
||||
catalog: RwLock<Catalog>,
|
||||
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
|
||||
postscript: RwLock<PostScriptNames>,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
/// Where the system font scan looks.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
font_dirs: Vec<PathBuf>,
|
||||
font_paths: FontPaths,
|
||||
/// The installed fonts' other names (localized, legacy, PostScript) by normalized name.
|
||||
aliases: RwLock<HashMap<String, Vec<Alias>>>,
|
||||
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
||||
@@ -254,6 +254,41 @@ pub struct FontDb {
|
||||
/// System fallback state: characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex<SysFallback>,
|
||||
/// What the last scan read, to tell when fonts were installed or removed since.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
stamp: Mutex<Option<ScanStamp>>,
|
||||
}
|
||||
|
||||
/// Where a database's system font scan looks: folders (read with their subfolders) and font files.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
enum FontPaths {
|
||||
Fixed(Vec<PathBuf>),
|
||||
/// The platform's ([`system_font_dirs`]), looked up again by each scan: fonts registered and
|
||||
/// font folders added since count too.
|
||||
System,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
impl FontPaths {
|
||||
fn get(&self) -> Vec<PathBuf> {
|
||||
match self {
|
||||
FontPaths::Fixed(paths) => paths.clone(),
|
||||
FontPaths::System => system_font_dirs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a system font scan read: the paths it started from, and when each folder it listed (and
|
||||
/// font file named by itself) last changed. Installing or removing a font changes its folder's.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct ScanStamp {
|
||||
paths: Vec<PathBuf>,
|
||||
modified: Vec<(PathBuf, Option<std::time::SystemTime>)>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn modified(path: &Path) -> Option<std::time::SystemTime> {
|
||||
std::fs::metadata(path).and_then(|m| m.modified()).ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -747,7 +782,8 @@ pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option<Vec<u8>> {
|
||||
Some(font)
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
|
||||
/// The platform's font folders (the system's and the user's) and, on Windows, the font files
|
||||
/// registered with it outside them, scanned by [`FontDb::global`].
|
||||
pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
@@ -755,7 +791,7 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts", "/Network/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
}
|
||||
@@ -769,19 +805,65 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
} else if cfg!(windows) {
|
||||
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
// Fonts installed for the current user only (what Install does without administrator rights).
|
||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
dirs.extend(registered_font_files(&dirs));
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
// The XDG data folders' `fonts` (fontconfig's user folder; Flatpak, Snap, NixOS and Guix
|
||||
// list their own data folders), relative paths ignored as the XDG spec says.
|
||||
let xdg = |var| std::env::var_os(var).map(|v| std::env::split_paths(&v).filter(|p| p.is_absolute()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
let data_home = xdg("XDG_DATA_HOME").into_iter().next().or_else(|| home.as_ref().map(|h| h.join(".local/share")));
|
||||
dirs.extend(data_home.into_iter().chain(xdg("XDG_DATA_DIRS")).map(|d| d.join("fonts")));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".fonts"));
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
}
|
||||
// In a Flatpak sandbox, the host's fonts (system, local and the user's).
|
||||
dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// The registry key listing the fonts installed on Windows (for all users under the local
|
||||
/// machine's key, for the current user under theirs): one value per face, holding its file's
|
||||
/// name in the Windows font folder or its full path.
|
||||
#[cfg(windows)]
|
||||
const REGISTERED_FONTS: &str = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts";
|
||||
|
||||
/// The font files registered with Windows outside `font_dirs` (fonts installed as shortcuts to
|
||||
/// where they are, or by programs into their own folders), which apps find by the registry.
|
||||
#[cfg(windows)]
|
||||
fn registered_font_files(font_dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
use windows_registry::{CURRENT_USER, LOCAL_MACHINE};
|
||||
let values = [LOCAL_MACHINE, CURRENT_USER].into_iter().filter_map(|root| root.open(REGISTERED_FONTS).ok()).flat_map(|key| {
|
||||
// A value that isn't a string isn't a font file.
|
||||
key.values().map(|vs| vs.filter_map(|(_, v)| String::try_from(v).ok()).collect::<Vec<_>>()).unwrap_or_default()
|
||||
});
|
||||
fonts_outside(values, font_dirs)
|
||||
}
|
||||
|
||||
/// The full paths among `registered` (font registrations' data) that aren't in `font_dirs`
|
||||
/// (ignoring case, as Windows paths do), sorted and deduplicated. File names alone are files in
|
||||
/// the Windows font folder, which is scanned anyway. The count is capped.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn fonts_outside(registered: impl IntoIterator<Item = String>, font_dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
const MAX_REGISTERED: usize = 1 << 16;
|
||||
let lower = |p: &Path| PathBuf::from(p.to_string_lossy().to_lowercase());
|
||||
let dirs: Vec<PathBuf> = font_dirs.iter().map(|d| lower(d)).collect();
|
||||
let mut files: Vec<PathBuf> = registered
|
||||
.into_iter()
|
||||
.take(MAX_REGISTERED)
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute() && !dirs.iter().any(|d| lower(p).starts_with(d)))
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::path::PathBuf>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
@@ -860,8 +942,13 @@ fn style_italic(style: &str) -> bool {
|
||||
type MenuFamilies = (Arc<[String]>, Arc<[String]>);
|
||||
|
||||
impl FontDb {
|
||||
/// A database holding the bundled fonts, whose system font scan reads `font_dirs`.
|
||||
/// A database holding the bundled fonts, whose system font scan reads `font_dirs` (folders,
|
||||
/// read with their subfolders, and font files).
|
||||
pub fn with_font_dirs(font_dirs: Vec<PathBuf>) -> Self {
|
||||
Self::new(FontPaths::Fixed(font_dirs))
|
||||
}
|
||||
|
||||
fn new(font_paths: FontPaths) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
// The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho
|
||||
// first: fallbacks for Japanese text after the requested and bundled fonts.
|
||||
@@ -878,7 +965,7 @@ impl FontDb {
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
catalog: RwLock::new(Catalog::new()),
|
||||
postscript: RwLock::new(PostScriptNames::new()),
|
||||
font_dirs,
|
||||
font_paths,
|
||||
aliases: RwLock::new(HashMap::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
@@ -887,6 +974,8 @@ impl FontDb {
|
||||
generation: AtomicU64::new(0),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
stamp: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,7 +995,7 @@ impl FontDb {
|
||||
/// [`FontDb::scan_in_background`]).
|
||||
pub fn global() -> &'static FontDb {
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
DB.get_or_init(|| FontDb::new(FontPaths::System))
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
@@ -1042,50 +1131,78 @@ impl FontDb {
|
||||
first.unwrap_or_else(|| self.scan_font_dirs())
|
||||
}
|
||||
|
||||
/// Whether fonts were installed or removed since the last scan (a font folder or font file it
|
||||
/// read changed, or a new one is to be read), for [`FontDb::load_system_fonts`] to catalog
|
||||
/// them. Costs a look at each folder the scan read, not a scan. `false` before the first scan
|
||||
/// and on wasm.
|
||||
pub fn installed_fonts_changed(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let paths = self.font_paths.get();
|
||||
let stamp = self.stamp.lock().unwrap_or_else(|e| e.into_inner());
|
||||
stamp.as_ref().is_some_and(|s| s.paths != paths || s.modified.iter().any(|(p, t)| modified(p) != *t))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
let mut catalog = Catalog::new();
|
||||
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
|
||||
let mut postscript = PostScriptNames::new();
|
||||
let mut n = 0;
|
||||
let mut stack = self.font_dirs.clone();
|
||||
let paths = self.font_paths.get();
|
||||
let mut modified_at = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut stack = paths.clone();
|
||||
// Each folder once, however links lead back to it.
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
while let Some(d) = stack.pop() {
|
||||
if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
// Before listing it: a font installed meanwhile changes it after this.
|
||||
modified_at.push((d.clone(), modified(&d)));
|
||||
// Not a folder: a font file named by itself (or nothing, yet).
|
||||
let Ok(rd) = std::fs::read_dir(&d) else {
|
||||
files.push(d);
|
||||
continue;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
|
||||
for (k, a) in Alias::of(&family, &style, &keys) {
|
||||
aliases.entry(k).or_default().push(a);
|
||||
}
|
||||
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
|
||||
for ps in &keys.postscript {
|
||||
postscript.insert(ps.clone(), (family.clone(), style.clone()));
|
||||
}
|
||||
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
||||
entry.traits.get_or_insert(traits);
|
||||
if entry.name.is_empty() {
|
||||
entry.name = family;
|
||||
}
|
||||
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
|
||||
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
|
||||
n += 1;
|
||||
} else {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in files {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
|
||||
for (k, a) in Alias::of(&family, &style, &keys) {
|
||||
aliases.entry(k).or_default().push(a);
|
||||
}
|
||||
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
|
||||
for ps in &keys.postscript {
|
||||
postscript.insert(ps.clone(), (family.clone(), style.clone()));
|
||||
}
|
||||
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
||||
entry.traits.get_or_insert(traits);
|
||||
if entry.name.is_empty() {
|
||||
entry.name = family;
|
||||
}
|
||||
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
|
||||
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.stamp.lock().unwrap_or_else(|e| e.into_inner()) = Some(ScanStamp { paths, modified: modified_at });
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
|
||||
*self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases;
|
||||
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
|
||||
|
||||
+60
-61
@@ -2,9 +2,10 @@
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2};
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2};
|
||||
use unicode_bidi::{BidiInfo, Level};
|
||||
use vectorcraft_doc::{CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathEffect, TextKind, TextObject};
|
||||
use vectorcraft_doc::{CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject};
|
||||
use vectorcraft_geom::{ArcPath, PathData};
|
||||
|
||||
use crate::composer::{Breakpoint, compose};
|
||||
use crate::fontdb::FontDb;
|
||||
@@ -184,7 +185,7 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
|
||||
flow(&mut cx, ¶s, &t.para, Some(®ions));
|
||||
}
|
||||
TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
|
||||
TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path),
|
||||
}
|
||||
if vertical && !is_on_path {
|
||||
// Glyph origins, outlines and transforms to text space (lines stay in line space).
|
||||
@@ -1168,43 +1169,31 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
}
|
||||
}
|
||||
|
||||
/// Arc-length parameterised path.
|
||||
struct ArcPath {
|
||||
segs: Vec<(PathSeg, f64, f64)>,
|
||||
len: f64,
|
||||
}
|
||||
|
||||
impl ArcPath {
|
||||
fn new(p: &BezPath) -> Self {
|
||||
let mut segs = Vec::new();
|
||||
let mut cum = 0.0;
|
||||
for s in p.segments() {
|
||||
let l = s.arclen(1e-4);
|
||||
if l > 1e-9 {
|
||||
segs.push((s, cum, l));
|
||||
cum += l;
|
||||
/// The distance along type on a path each glyph takes: its advance measured `spacing` points above
|
||||
/// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and
|
||||
/// open up round the inside; just the advances without spacing. Glyphs start `from` along the path.
|
||||
fn path_steps(ap: &ArcPath, glyphs: &[SGlyph], from: f64, spacing: f64) -> Vec<f64> {
|
||||
let mut s = from;
|
||||
glyphs
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let mut step = g.adv;
|
||||
if spacing != 0.0 && g.adv > 1e-9 {
|
||||
// How far the path turns across the glyph: positive bending away from its top.
|
||||
let ((_, a), (_, b)) = (ap.at(s), ap.at(s + g.adv));
|
||||
let curvature = a.cross(b).atan2(a.dot(b)) / g.adv;
|
||||
step = g.adv / (1.0 + curvature * spacing).clamp(0.25, 4.0);
|
||||
}
|
||||
}
|
||||
Self { segs, len: cum }
|
||||
}
|
||||
|
||||
/// Point and unit tangent at arc length `s`.
|
||||
fn at(&self, s: f64) -> (Point, Vec2) {
|
||||
let s = s.clamp(0.0, self.len);
|
||||
let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1);
|
||||
let (seg, c, l) = self.segs[i];
|
||||
let t = seg.inv_arclen((s - c).min(l), 1e-4).clamp(0.0, 1.0);
|
||||
let p = seg.eval(t);
|
||||
let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0));
|
||||
let d = seg.eval(t1) - seg.eval(t0);
|
||||
let len = d.hypot();
|
||||
(p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) })
|
||||
}
|
||||
s += step;
|
||||
step
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &BezPath, start: f64, closed: bool, effect: PathEffect) {
|
||||
/// Lay type on a path out along `path` (text space), between its brackets.
|
||||
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &PathData) {
|
||||
cx.out.on_path = true;
|
||||
let para = &t.para;
|
||||
let mut sg = Vec::new();
|
||||
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
|
||||
let mut rtl = None;
|
||||
@@ -1226,7 +1215,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
(s.0, s.1)
|
||||
});
|
||||
let text_len = cx.text.len();
|
||||
if ap.segs.is_empty() {
|
||||
if ap.is_empty() {
|
||||
cx.out.overflow = !sg.is_empty();
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl,
|
||||
@@ -1243,32 +1232,43 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
});
|
||||
return;
|
||||
}
|
||||
let centre = path.bounding_box().center();
|
||||
let s_start = start.clamp(0.0, 1.0) * ap.len;
|
||||
let avail = if closed { ap.len } else { ap.len - s_start };
|
||||
let w: f64 = sg.iter().map(|g| g.adv).sum();
|
||||
let centre = path.bounds().unwrap_or_default().center();
|
||||
let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0));
|
||||
let s_start = from * ap.len();
|
||||
let avail = (to - from) * ap.len();
|
||||
let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 };
|
||||
let mut steps = path_steps(&ap, &sg, s_start, spacing);
|
||||
let w: f64 = steps.iter().sum();
|
||||
let s0 = match para.justify {
|
||||
Justify::Auto if rtl => s_start + (avail - w).max(0.0),
|
||||
Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0),
|
||||
Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0),
|
||||
_ => s_start,
|
||||
};
|
||||
if spacing != 0.0 && s0 != s_start {
|
||||
// Spaced from where the alignment puts them, the curve under them is another.
|
||||
steps = path_steps(&ap, &sg, s0, spacing);
|
||||
}
|
||||
// Align to Path: how far down (glyph space) the type moves to run its ascender, centre or
|
||||
// descender along the path instead of its baseline.
|
||||
let rise = match t.path_align {
|
||||
PathAlign::Baseline => 0.0,
|
||||
PathAlign::Ascender => m.0,
|
||||
PathAlign::Descender => -m.1,
|
||||
PathAlign::Center => (m.0 - m.1) * 0.5,
|
||||
};
|
||||
let mut x = 0.0;
|
||||
for g in &sg {
|
||||
for (g, &step) in sg.iter().zip(&steps) {
|
||||
let s = s0 + x;
|
||||
if s + g.adv - s_start > avail + 1e-6 {
|
||||
if s + step - s_start > avail + 1e-6 {
|
||||
cx.out.overflow = true;
|
||||
break;
|
||||
}
|
||||
let mut mid = s + g.adv * 0.5;
|
||||
if closed {
|
||||
mid = mid.rem_euclid(ap.len);
|
||||
}
|
||||
let (p, dir) = ap.at(mid);
|
||||
let (p, dir) = ap.at(s + step * 0.5);
|
||||
let angle = dir.y.atan2(dir.x);
|
||||
let half = Affine::translate((-g.adv * 0.5, 0.0));
|
||||
let half = Affine::translate((-g.adv * 0.5, rise));
|
||||
// Glyph space: x along the advance, y down from the baseline; `pre` maps it onto the path.
|
||||
let pre = match effect {
|
||||
let pre = match t.path_effect {
|
||||
PathEffect::Rainbow => Affine::translate(p.to_vec2()) * Affine::rotate(angle) * half,
|
||||
// x axis along the tangent, y axis stays vertical.
|
||||
PathEffect::Skew => Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, 0.0, 1.0, 0.0, 0.0]) * half,
|
||||
@@ -1277,10 +1277,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
let sx = if dir.x < 0.0 { -1.0 } else { 1.0 };
|
||||
Affine::translate(p.to_vec2()) * Affine::new([sx, 0.0, -dir.y * sx, dir.x * sx, 0.0, 0.0]) * half
|
||||
}
|
||||
PathEffect::StairStep => {
|
||||
let s_left = if closed { s.rem_euclid(ap.len) } else { s };
|
||||
Affine::translate(ap.at(s_left).0.to_vec2())
|
||||
}
|
||||
PathEffect::StairStep => Affine::translate(ap.at(s).0.to_vec2()) * Affine::translate((0.0, rise)),
|
||||
// x axis along the tangent; vertical edges point at the path's centre (kept on the glyph's
|
||||
// up side, and never closer than ~17° to the baseline so glyphs stay legible).
|
||||
PathEffect::Gravity => {
|
||||
@@ -1291,17 +1288,19 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
up = -up;
|
||||
}
|
||||
if up.dot(n) < 0.3 {
|
||||
let t = up - n * up.dot(n);
|
||||
up = n * 0.3 + t / t.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt();
|
||||
let along = up - n * up.dot(n);
|
||||
up = n * 0.3 + along / along.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt();
|
||||
}
|
||||
Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, -up.x, -up.y, 0.0, 0.0]) * half
|
||||
}
|
||||
};
|
||||
cx.emit(g, pre, p - dir * (g.adv * 0.5), angle, g.adv, 0);
|
||||
x += g.adv;
|
||||
// The caret's baseline moves with the type.
|
||||
let origin = p - dir * (g.adv * 0.5) + Vec2::new(-dir.y, dir.x) * rise;
|
||||
cx.emit(g, pre, origin, angle, g.adv, 0);
|
||||
x += step;
|
||||
}
|
||||
let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 });
|
||||
let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x });
|
||||
let (ps, _) = ap.at(s0);
|
||||
let (pe, _) = ap.at(s0 + x);
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl,
|
||||
baseline: ps.y,
|
||||
@@ -1313,7 +1312,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
end: text_len,
|
||||
glyph_start: 0,
|
||||
glyph_end: cx.out.glyphs.len(),
|
||||
avail: (0.0, ap.len),
|
||||
avail: (0.0, ap.len()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -328,24 +328,6 @@ pub fn caret_vertical(layout: &TextLayout, byte: usize, delta: i32, goal_x: f64)
|
||||
byte_in_line(layout, li as usize, goal_x)
|
||||
}
|
||||
|
||||
/// Nearest point on `path` to `p`: (fraction of the path's arc length 0..1, distance). Used to
|
||||
/// start type on a path where the user clicked.
|
||||
pub fn path_fraction_at(path: &BezPath, p: Point) -> (f64, f64) {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let mut total = 0.0;
|
||||
let mut best = (0.0, f64::INFINITY);
|
||||
for seg in path.segments() {
|
||||
let len = seg.arclen(1e-3);
|
||||
let n = seg.nearest(p, 1e-4);
|
||||
let d = n.distance_sq.sqrt();
|
||||
if d < best.1 {
|
||||
best = (total + seg.subsegment(0.0..n.t).arclen(1e-3), d);
|
||||
}
|
||||
total += len;
|
||||
}
|
||||
if total <= 0.0 { (0.0, best.1) } else { ((best.0 / total).clamp(0.0, 1.0), best.1) }
|
||||
}
|
||||
|
||||
/// Highlight quads (text space, clockwise from top-left) covering the selected bytes `a..b`.
|
||||
pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4]> {
|
||||
let (a, b) = (a.min(b), a.max(b));
|
||||
|
||||
@@ -271,7 +271,7 @@ fn on_path_placement() {
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((500.0, 50.0));
|
||||
let mut t = point("Path", style(20.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.on_path && !l.overflow);
|
||||
assert_eq!(l.glyphs.len(), 4);
|
||||
@@ -283,7 +283,7 @@ fn on_path_placement() {
|
||||
let mut v = BezPath::new();
|
||||
v.move_to((0.0, 0.0));
|
||||
v.line_to((0.0, 300.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0, end: None };
|
||||
let l = layout(db(), &t);
|
||||
for g in &l.glyphs {
|
||||
assert!((g.angle - std::f64::consts::FRAC_PI_2).abs() < 1e-3);
|
||||
@@ -302,7 +302,7 @@ fn on_path_effects_orient_glyphs() {
|
||||
diag.move_to((0.0, 0.0));
|
||||
diag.line_to((400.0, 400.0));
|
||||
let mut t = point("H", style(40.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&diag), start: 0.1 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&diag), start: 0.1, end: None };
|
||||
let bbox = |t: &TextObject| layout(db(), t).glyphs[0].outline.bounding_box();
|
||||
let rainbow = bbox(&t);
|
||||
t.path_effect = PathEffect::StairStep;
|
||||
@@ -315,7 +315,7 @@ fn on_path_effects_orient_glyphs() {
|
||||
assert!(skew.height() > stair.height() && skew.width() < rainbow.width() + 1e-6, "{skew:?}");
|
||||
// Gravity on a circle: glyphs point away from the centre (same as Rainbow on a circle).
|
||||
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0, end: None };
|
||||
t.path_effect = PathEffect::Rainbow;
|
||||
let a = layout(db(), &t).glyphs[0].outline.bounding_box();
|
||||
t.path_effect = PathEffect::Gravity;
|
||||
@@ -329,7 +329,7 @@ fn on_path_effects_orient_glyphs() {
|
||||
fn on_path_circle_and_overflow() {
|
||||
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
|
||||
let mut t = point("Around the circle", style(14.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow);
|
||||
for g in &l.glyphs {
|
||||
@@ -339,7 +339,7 @@ fn on_path_circle_and_overflow() {
|
||||
let mut short = BezPath::new();
|
||||
short.move_to((0.0, 0.0));
|
||||
short.line_to((30.0, 0.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.overflow && l.glyphs.len() < t.plain_text().len());
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::fontdb::fonts_outside;
|
||||
|
||||
const FAMILY: &str = "Sysfont Sans3";
|
||||
|
||||
@@ -184,3 +185,55 @@ fn installed_faces_are_found_by_postscript_name() {
|
||||
assert_eq!(db.by_postscript_name("sourcesans3-regular"), Some((FAMILY.to_string(), "Regular".to_string())), "any case");
|
||||
assert_eq!(db.by_postscript_name("Rounded-X-Mplus-1c-black"), None);
|
||||
}
|
||||
|
||||
/// Windows lists fonts installed as shortcuts, or by programs into their own folders, by their
|
||||
/// files' full paths: those files are scanned too (#443).
|
||||
#[test]
|
||||
fn a_font_file_named_by_itself_is_scanned() {
|
||||
let dir = font_dir("file");
|
||||
let db = FontDb::with_font_dirs(vec![dir.join("Sub/Sysfont-Bold.TTF"), dir.join("No Such Font.ttf")]);
|
||||
assert_eq!(db.styles(FAMILY), ["Bold"]);
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_fonts_outside_the_font_folders_are_their_full_paths() {
|
||||
let dir = std::env::temp_dir().join("Fonts");
|
||||
let elsewhere = std::env::temp_dir().join("Downloads").join("Montserrat-Regular.ttf");
|
||||
let registered = [
|
||||
// A file in the Windows font folder, by name.
|
||||
"arial.ttf".into(),
|
||||
// A file in a font folder (ignoring case).
|
||||
dir.join("Lato-Regular.ttf").to_string_lossy().to_uppercase(),
|
||||
elsewhere.to_string_lossy().into_owned(),
|
||||
elsewhere.to_string_lossy().into_owned(),
|
||||
];
|
||||
assert_eq!(fonts_outside(registered, &[dir]), [elsewhere]);
|
||||
}
|
||||
|
||||
/// Fonts installed while the app runs are found when it comes back to the front, without
|
||||
/// scanning every time (#443).
|
||||
#[test]
|
||||
fn the_database_tells_when_fonts_were_installed_or_removed_since_its_scan() {
|
||||
let dir = font_dir("changed");
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone(), dir.join("Later")]);
|
||||
assert!(!db.installed_fonts_changed(), "nothing to compare with before the first scan");
|
||||
assert!(!db.has_family("Other Sans 33"));
|
||||
assert!(!db.installed_fonts_changed());
|
||||
// Past the file system's clock tick, so the folders' times differ from the scan's.
|
||||
let tick = || std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
tick();
|
||||
std::fs::write(dir.join("Sub/Other.ttf"), renamed_to("SourceSans3-Regular.ttf", "Other Sans 33")).unwrap();
|
||||
assert!(db.installed_fonts_changed(), "a font installed into a subfolder");
|
||||
assert_eq!(db.load_system_fonts(), 3);
|
||||
assert!(db.has_family("Other Sans 33") && !db.installed_fonts_changed());
|
||||
tick();
|
||||
std::fs::create_dir_all(dir.join("Later")).unwrap();
|
||||
assert!(db.installed_fonts_changed(), "a font folder that didn't exist");
|
||||
db.load_system_fonts();
|
||||
tick();
|
||||
std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap();
|
||||
assert!(db.installed_fonts_changed(), "a font removed");
|
||||
assert_eq!(db.load_system_fonts(), 2);
|
||||
assert_eq!(db.styles(FAMILY), ["Bold"]);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() {
|
||||
#[test]
|
||||
fn type_on_a_path_stays_horizontal() {
|
||||
let mut t = vertical("ab");
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0, end: None };
|
||||
assert!(!layout(FontDb::global(), &t).vertical);
|
||||
}
|
||||
|
||||
|
||||
+181
-56
@@ -162,12 +162,7 @@ fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Sha
|
||||
continue;
|
||||
}
|
||||
for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) {
|
||||
let mut closed = sp.clone();
|
||||
closed.closed = true;
|
||||
let mut bp = BezPath::new();
|
||||
closed.to_bezpath_into(&mut bp);
|
||||
let size = bp.bounding_box().size();
|
||||
sp.closed = bp.area().abs() > 1e-9 * (size.width * size.width + size.height * size.height);
|
||||
sp.closed = po::encloses_area(sp);
|
||||
}
|
||||
}
|
||||
(shapes, leaves)
|
||||
@@ -195,6 +190,9 @@ pub struct BuilderMap {
|
||||
pub regions: Vec<Face>,
|
||||
/// Pieces of the unfilled open paths, keyed by input index, with their bounds.
|
||||
pub lines: Vec<(po::Shape, Rect)>,
|
||||
/// Pieces of the other inputs' outlines (cut wherever paths meet), keyed by input index, with
|
||||
/// their bounds: the edges erasing deletes. Only in a map made `with_edges`.
|
||||
pub edges: Vec<(po::Shape, Rect)>,
|
||||
}
|
||||
|
||||
/// What a Shape Builder gesture touches at a point.
|
||||
@@ -202,18 +200,43 @@ pub struct BuilderMap {
|
||||
pub enum Hit {
|
||||
Region(usize),
|
||||
Line(usize),
|
||||
Edge(usize),
|
||||
}
|
||||
|
||||
/// The piece of `pieces` nearest `p` within `tol`, with its distance.
|
||||
fn piece_near(pieces: &[(po::Shape, Rect)], p: Point, tol: f64) -> Option<(f64, usize)> {
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for (i, (l, b)) in pieces.iter().enumerate() {
|
||||
if b.inflate(tol, tol).contains(p)
|
||||
&& let Some((.., d)) = l.path.nearest(p)
|
||||
&& d <= tol
|
||||
&& best.is_none_or(|b| d < b.0)
|
||||
{
|
||||
best = Some((d, i));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
impl BuilderMap {
|
||||
/// The map of `roots` (sorted back → front), with the leaves the inputs came from.
|
||||
pub fn new<'a>(doc: &'a Document, roots: &[NodeId]) -> (Self, Vec<&'a Node>) {
|
||||
/// The map of `roots` (sorted back → front), with the leaves the inputs came from; the outline
|
||||
/// edges only `with_edges` (they cost a planar map of their own when no open path is in).
|
||||
pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool) -> (Self, Vec<&'a Node>) {
|
||||
let (shapes, leaves) = shape_builder_shapes(doc, roots);
|
||||
if shapes.is_empty() {
|
||||
return (Self::default(), leaves);
|
||||
}
|
||||
let (regions, lines) = po::shape_builder(&shapes);
|
||||
let lines = lines.into_iter().filter_map(|l| Some((l.path.bounds()?, l))).map(|(b, l)| (l, b)).collect();
|
||||
(Self { shapes, regions: faces_of(regions), lines }, leaves)
|
||||
let arr = po::shape_builder(&shapes, with_edges);
|
||||
let bounded = |pieces: Vec<po::Shape>| {
|
||||
pieces
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let b = l.path.bounds()?;
|
||||
Some((l, b))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
(Self { shapes, regions: faces_of(arr.regions), lines: bounded(arr.lines), edges: bounded(arr.edges) }, leaves)
|
||||
}
|
||||
|
||||
/// The region containing `p`.
|
||||
@@ -221,53 +244,56 @@ impl BuilderMap {
|
||||
self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0)
|
||||
}
|
||||
|
||||
/// The line piece nearest `p`, within `tol`.
|
||||
pub fn line_near(&self, p: Point, tol: f64) -> Option<usize> {
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for (i, (l, b)) in self.lines.iter().enumerate() {
|
||||
if b.inflate(tol, tol).contains(p)
|
||||
&& let Some((.., d)) = l.path.nearest(p)
|
||||
&& d <= tol
|
||||
&& best.is_none_or(|b| d < b.0)
|
||||
{
|
||||
best = Some((d, i));
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
/// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that
|
||||
/// close, else the region under it.
|
||||
pub fn hit(&self, p: Point, piece_tol: Option<f64>) -> Option<Hit> {
|
||||
let piece = piece_tol.and_then(|t| match (piece_near(&self.lines, p, t), piece_near(&self.edges, p, t)) {
|
||||
(Some(l), Some(e)) if e.0 < l.0 => Some(Hit::Edge(e.1)),
|
||||
(Some(l), _) => Some(Hit::Line(l.1)),
|
||||
(None, e) => e.map(|e| Hit::Edge(e.1)),
|
||||
});
|
||||
piece.or_else(|| self.region_at(p).map(Hit::Region))
|
||||
}
|
||||
|
||||
/// What a gesture at `p` touches: with `line_tol` (erasing), a line piece that close, else the
|
||||
/// region under it.
|
||||
pub fn hit(&self, p: Point, line_tol: Option<f64>) -> Option<Hit> {
|
||||
line_tol.and_then(|t| self.line_near(p, t)).map(Hit::Line).or_else(|| self.region_at(p).map(Hit::Region))
|
||||
}
|
||||
|
||||
/// Does line piece `i` run between two of `regions` (an edge that merging or deleting them
|
||||
/// removes)?
|
||||
pub fn line_between(&self, i: usize, regions: &[usize]) -> bool {
|
||||
let Some(sp) = self.lines.get(i).and_then(|(l, _)| l.path.subpaths.first()) else { return false };
|
||||
/// The regions just either side of the middle of `piece` (None: outside every region).
|
||||
fn sides(&self, piece: &po::Shape) -> [Option<usize>; 2] {
|
||||
let Some(sp) = piece.path.subpaths.first() else { return [None; 2] };
|
||||
let n = sp.segment_count();
|
||||
if n == 0 {
|
||||
return false;
|
||||
return [None; 2];
|
||||
}
|
||||
let c = sp.segment(n / 2);
|
||||
use kurbo::{ParamCurve as _, ParamCurveDeriv as _};
|
||||
let (m, d) = (c.eval(0.5), c.deriv().eval(0.5).to_vec2());
|
||||
let len = d.hypot();
|
||||
if !(len.is_finite() && len > 0.0) {
|
||||
return false;
|
||||
return [None; 2];
|
||||
}
|
||||
// A little to each side: inside the faces next to it, which share it exactly.
|
||||
let off = d.turn_90() * (0.05f64.min(0.05 * c.p0.distance(c.p3)).max(1e-6) / len);
|
||||
[m + off, m - off].into_iter().all(|q| self.region_at(q).is_some_and(|r| regions.contains(&r)))
|
||||
[m + off, m - off].map(|q| self.region_at(q))
|
||||
}
|
||||
|
||||
/// Does line piece `i` run between two of `regions` (an edge that merging or deleting them
|
||||
/// removes)?
|
||||
pub fn line_between(&self, i: usize, regions: &[usize]) -> bool {
|
||||
self.lines.get(i).is_some_and(|(l, _)| self.sides(l).iter().all(|r| r.is_some_and(|r| regions.contains(&r))))
|
||||
}
|
||||
|
||||
/// The edges `touched` deletes: those that bound none of its regions (deleting a region takes
|
||||
/// its edges with it, so an erase drag that crosses an edge into a region deletes the region).
|
||||
pub fn erased_edges(&self, touched: &Touched) -> Vec<usize> {
|
||||
let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r)));
|
||||
touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The regions and line pieces a Shape Builder gesture has touched, in the order touched.
|
||||
/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Touched {
|
||||
pub regions: Vec<usize>,
|
||||
pub lines: Vec<usize>,
|
||||
pub edges: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Touched {
|
||||
@@ -275,6 +301,7 @@ impl Touched {
|
||||
let (list, i) = match hit {
|
||||
Some(Hit::Region(i)) => (&mut self.regions, i),
|
||||
Some(Hit::Line(i)) => (&mut self.lines, i),
|
||||
Some(Hit::Edge(i)) => (&mut self.edges, i),
|
||||
None => return,
|
||||
};
|
||||
if !list.contains(&i) {
|
||||
@@ -283,7 +310,7 @@ impl Touched {
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.regions.is_empty() && self.lines.is_empty()
|
||||
self.regions.is_empty() && self.lines.is_empty() && self.edges.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,36 +408,42 @@ struct RegionCache {
|
||||
map: BuilderMap,
|
||||
/// Live Paint faces (every path an edge, filled or not) rather than Shape Builder regions.
|
||||
live: bool,
|
||||
/// The map has the Shape Builder's outline edges.
|
||||
edges: bool,
|
||||
}
|
||||
|
||||
impl RegionCache {
|
||||
fn live_paint() -> Self {
|
||||
Self { live: true, ..Default::default() }
|
||||
}
|
||||
fn get(&mut self, cx: &ToolContext) -> &BuilderMap {
|
||||
/// The map, with the outline edges when `edges` (built the first time they're wanted).
|
||||
fn get(&mut self, cx: &ToolContext, edges: bool) -> &BuilderMap {
|
||||
let key = (cx.doc as *const Document as usize, cx.selection.objects.clone());
|
||||
if self.key.as_ref() != Some(&key) {
|
||||
if self.key.as_ref() != Some(&key) || (edges && !self.edges) {
|
||||
let roots = sorted_roots(cx.doc, &cx.selection.objects);
|
||||
self.map = if self.live {
|
||||
let (shapes, _) = shapes_for(cx.doc, &roots);
|
||||
let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) };
|
||||
BuilderMap { regions, ..Default::default() }
|
||||
} else {
|
||||
BuilderMap::new(cx.doc, &roots).0
|
||||
BuilderMap::new(cx.doc, &roots, edges).0
|
||||
};
|
||||
self.key = Some(key);
|
||||
self.edges = edges;
|
||||
}
|
||||
&self.map
|
||||
}
|
||||
fn at(&mut self, cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
self.get(cx).region_at(p)
|
||||
self.get(cx, false).region_at(p)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Shape Builder ----------
|
||||
|
||||
/// Shape Builder tool: drag across regions to merge them, Alt-drag to delete them (and the pieces
|
||||
/// of open paths it passes over).
|
||||
/// Shape Builder tool: drag across regions to merge them. Holding Alt (Option) switches to erase
|
||||
/// mode while it is held: the pointer shows a minus, what a click or drag would delete highlights in
|
||||
/// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges
|
||||
/// (outline pieces between the points where paths meet) it passes over.
|
||||
#[derive(Default)]
|
||||
pub struct ShapeBuilderTool {
|
||||
cache: RegionCache,
|
||||
@@ -418,10 +451,11 @@ pub struct ShapeBuilderTool {
|
||||
points: Vec<Point>,
|
||||
touched: Touched,
|
||||
dragging: bool,
|
||||
/// Erase mode: Alt held while hovering, or when the drag started.
|
||||
alt: bool,
|
||||
}
|
||||
|
||||
/// How close (screen px) the pointer must come to a line piece to delete it.
|
||||
/// How close (screen px) the pointer must come to a line piece or an edge to delete it.
|
||||
const LINE_TOL_PX: f64 = 4.0;
|
||||
|
||||
impl ShapeBuilderTool {
|
||||
@@ -435,9 +469,9 @@ impl ShapeBuilderTool {
|
||||
None => vec![to],
|
||||
};
|
||||
let tol = Self::line_tol(cx, self.alt);
|
||||
let map = self.cache.get(cx, self.alt);
|
||||
for p in pts {
|
||||
let hit = self.cache.get(cx).hit(p, tol);
|
||||
self.touched.add(hit);
|
||||
self.touched.add(map.hit(p, tol));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,7 +485,8 @@ impl Tool for ShapeBuilderTool {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.hover = self.cache.get(cx).hit(p, Self::line_tol(cx, ev.mods.alt));
|
||||
self.alt = ev.mods.alt;
|
||||
self.hover = self.cache.get(cx, self.alt).hit(p, Self::line_tol(cx, self.alt));
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
@@ -487,7 +522,8 @@ impl Tool for ShapeBuilderTool {
|
||||
self.cache.key = None;
|
||||
let roots = sorted_roots(cx.doc, &cx.selection.objects);
|
||||
let pts: Vec<Value> = points.iter().map(|q| json!([q.x, q.y])).collect();
|
||||
let erase = self.alt || ev.mods.alt;
|
||||
// The mode the drag started in, which its highlight showed.
|
||||
let erase = self.alt;
|
||||
let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase });
|
||||
if let Some(t) = Self::line_tol(cx, erase) {
|
||||
params["tolerance"] = json!(t);
|
||||
@@ -501,9 +537,11 @@ impl Tool for ShapeBuilderTool {
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
let map = &self.cache.map;
|
||||
let color = if self.dragging && self.alt { HIGHLIGHT_RED } else { HATCH };
|
||||
let color = if self.alt { HIGHLIGHT_RED } else { HATCH };
|
||||
let show: Vec<Hit> = if self.dragging {
|
||||
self.touched.regions.iter().map(|&i| Hit::Region(i)).chain(self.touched.lines.iter().map(|&i| Hit::Line(i))).collect()
|
||||
let t = &self.touched;
|
||||
let regions = t.regions.iter().map(|&i| Hit::Region(i));
|
||||
regions.chain(t.lines.iter().map(|&i| Hit::Line(i))).chain(map.erased_edges(t).into_iter().map(Hit::Edge)).collect()
|
||||
} else {
|
||||
self.hover.into_iter().collect()
|
||||
};
|
||||
@@ -515,8 +553,9 @@ impl Tool for ShapeBuilderTool {
|
||||
out.push(Overlay::Path { path: bp.clone(), color, width: 1.0, dashed: false });
|
||||
}
|
||||
}
|
||||
Hit::Line(i) => {
|
||||
if let Some((l, _)) = map.lines.get(i) {
|
||||
Hit::Line(i) | Hit::Edge(i) => {
|
||||
let pieces = if matches!(hit, Hit::Line(_)) { &map.lines } else { &map.edges };
|
||||
if let Some((l, _)) = pieces.get(i) {
|
||||
out.push(Overlay::Path { path: l.path.to_bezpath(), color: HIGHLIGHT_RED, width: 3.0, dashed: false });
|
||||
}
|
||||
}
|
||||
@@ -533,8 +572,10 @@ impl Tool for ShapeBuilderTool {
|
||||
out
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
|
||||
// A drag keeps the mode it started in; hovering follows the key.
|
||||
let erase = if self.dragging { self.alt } else { m.alt };
|
||||
if erase { Cursor::ShapeBuilderErase } else { Cursor::ShapeBuilder }
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
@@ -803,6 +844,90 @@ mod tests {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)).is_empty());
|
||||
}
|
||||
|
||||
/// Squares A (0..100) and B (50..150 both ways), overlapping at a corner, selected.
|
||||
fn corner_rects() -> (Document, Selection) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut s = Selection::default();
|
||||
for (k, r) in [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(50.0, 50.0, 150.0, 150.0)].into_iter().enumerate() {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), k, Node::path(id, shapes::rectangle(r), Appearance::default_art())).unwrap();
|
||||
s.add(id);
|
||||
}
|
||||
(d, s)
|
||||
}
|
||||
|
||||
fn colors(ov: &[Overlay]) -> Vec<[u8; 3]> {
|
||||
ov.iter().map(|o| if let Overlay::Path { color, .. } = o { *color } else { panic!("{o:?}") }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_alt_switches_to_erase_mode_while_held() {
|
||||
let (d, s) = corner_rects();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let at = Point::new(75.0, 75.0);
|
||||
// Merge mode: a grey mesh and the plus pointer.
|
||||
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y));
|
||||
assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]);
|
||||
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder);
|
||||
// Alt held: what would go highlights red, and the pointer shows a minus.
|
||||
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y).with_mods(alt));
|
||||
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 2]);
|
||||
assert_eq!(t.cursor(&cx, at, alt), Cursor::ShapeBuilderErase);
|
||||
// Released: merge mode again.
|
||||
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y));
|
||||
assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]);
|
||||
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder);
|
||||
// A drag keeps the mode it started in.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, at.x, at.y).with_mods(alt));
|
||||
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilderErase);
|
||||
let acts = t.pointer(&cx, &ev(PointerKind::Up, at.x, at.y));
|
||||
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
|
||||
assert_eq!(v["erase"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_alt_on_an_edge_highlights_and_deletes_it() {
|
||||
let (d, s) = corner_rects();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
// A's side inside B, between the points where B's outline crosses it.
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0).with_mods(alt));
|
||||
assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover);
|
||||
let ov = t.overlays(&cx);
|
||||
let [Overlay::Path { path, color: HIGHLIGHT_RED, width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") };
|
||||
assert_eq!(path.bounding_box(), Rect::new(50.0, 50.0, 100.0, 100.0));
|
||||
// Without Alt it's the region under the pointer.
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0));
|
||||
assert!(matches!(t.hover, Some(Hit::Region(_))));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 101.0, 75.0).with_mods(alt));
|
||||
assert_eq!(t.touched.edges.len(), 1);
|
||||
let acts = t.pointer(&cx, &ev(PointerKind::Up, 101.0, 75.0).with_mods(alt));
|
||||
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
|
||||
assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_erase_drag_into_regions_shows_the_regions_not_the_edges_it_crosses() {
|
||||
let (d, s) = corner_rects();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0).with_mods(alt));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(alt));
|
||||
assert_eq!(t.touched.regions.len(), 2);
|
||||
assert!(!t.touched.edges.is_empty(), "the drag crossed B's outline");
|
||||
assert!(t.cache.map.erased_edges(&t.touched).is_empty(), "which goes with the regions");
|
||||
// Two regions (mesh + outline each) and the drag's trail, all red.
|
||||
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_stays_inside_region() {
|
||||
let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath();
|
||||
|
||||
+100
-15
@@ -7,8 +7,8 @@
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2};
|
||||
|
||||
use crate::{Action, Overlay, PointerEvent, ToolContext};
|
||||
|
||||
@@ -47,18 +47,20 @@ impl CornerWidgets {
|
||||
return None;
|
||||
}
|
||||
let Some(NodeKind::Path { live: Some(live), .. }) = doc.node(id).map(|n| &n.kind) else { return None };
|
||||
let LiveShape::Rectangle { w, h, radii, kinds, xf } = live else { return None };
|
||||
let (w, h, radii, kinds, xf) = (*w, *h, *radii, *kinds, *xf);
|
||||
// In document units, as `object.setLiveShape` edits them.
|
||||
let LiveShape::Rectangle { w, h, radii, kinds, xf } = live.folded() else { return None };
|
||||
// Screen pixels per shape unit along each side (the shape may be scaled or skewed).
|
||||
let c = xf.as_coeffs();
|
||||
let (px, py) = (Vec2::new(c[0], c[1]).hypot() * zoom, Vec2::new(c[2], c[3]).hypot() * zoom);
|
||||
if xf.determinant().abs() < 1e-12 || (w * px).min(h * py) < MIN_SIDE_PX {
|
||||
return None;
|
||||
}
|
||||
// Each widget sits on its corner's diagonal, at the centre of the arc as drawn.
|
||||
let points = std::array::from_fn(|k| {
|
||||
let ((fx, fy), (sx, sy)) = CORNERS[k];
|
||||
let ix = radii[k].max(MIN_INSET_PX / px).min(w / 2.0);
|
||||
let iy = radii[k].max(MIN_INSET_PX / py).min(h / 2.0);
|
||||
let r = shapes::fitted_corner_radius(w, h, radii[k]);
|
||||
let ix = r.max(MIN_INSET_PX / px).min(w / 2.0);
|
||||
let iy = r.max(MIN_INSET_PX / py).min(h / 2.0);
|
||||
xf * Point::new(fx * w + sx * ix, fy * h + sy * iy)
|
||||
});
|
||||
Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) })
|
||||
@@ -138,6 +140,8 @@ pub struct CornerDrag {
|
||||
began: bool,
|
||||
alt: bool,
|
||||
radius: f64,
|
||||
/// The pointer went past fully round: the corners are at their largest radius.
|
||||
at_limit: bool,
|
||||
at: Point,
|
||||
}
|
||||
|
||||
@@ -146,18 +150,41 @@ impl CornerDrag {
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let (widgets, p) = (CornerWidgets::for_tool(cx)?, ev.pos);
|
||||
let corner = widgets.hit(p, cx.tol(5.0))?;
|
||||
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at: p })
|
||||
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at_limit: false, at: p })
|
||||
}
|
||||
|
||||
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
|
||||
/// corner's inward diagonal (in the shape's own units), from square to fully round.
|
||||
fn radius_at(&self, p: Point) -> f64 {
|
||||
/// corner's inward diagonal (in the shape's own units), from square to fully round; and
|
||||
/// whether the travel reached fully round (the limit every corner shares).
|
||||
fn radius_at(&self, p: Point) -> (f64, bool) {
|
||||
let w = &self.widgets;
|
||||
let inv = w.xf.inverse();
|
||||
let d = inv * p - inv * self.start;
|
||||
let (_, (sx, sy)) = CORNERS[self.corner];
|
||||
let max = (w.w.abs().min(w.h.abs()) / 2.0).max(0.0);
|
||||
(w.radii[self.corner].min(max) + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, max)
|
||||
let max = shapes::max_corner_radius(w.w, w.h);
|
||||
let r = shapes::fitted_corner_radius(w.w, w.h, w.radii[self.corner]) + (d.x * sx + d.y * sy) / 2.0;
|
||||
(r.min(max).max(0.0), max > 0.0 && r >= max)
|
||||
}
|
||||
|
||||
/// The edited corners' cuts at the dragged radius, in document coordinates.
|
||||
fn edited_corners(&self) -> BezPath {
|
||||
let w = &self.widgets;
|
||||
let rect = Rect::new(0.0, 0.0, w.w, w.h);
|
||||
let radii = std::array::from_fn(|k| if w.shown[k] { self.radius } else { w.radii[k] });
|
||||
let corner_of = shapes::rectangle_anchor_corners(rect, radii);
|
||||
let path = shapes::rectangle_with_corners(rect, radii, w.kinds).transformed(w.xf);
|
||||
let mut out = BezPath::new();
|
||||
let Some(sp) = path.subpaths.first() else { return out };
|
||||
// A corner's cut is the segment between its two anchors (the last one, for the top-left).
|
||||
for i in 0..sp.segment_count() {
|
||||
let (a, b) = (corner_of.get(i), corner_of.get(i + 1).or(corner_of.first()));
|
||||
if a == b && a.is_some_and(|k| w.shown.get(*k) == Some(&true)) {
|
||||
let c = sp.segment(i);
|
||||
out.move_to(c.p0);
|
||||
out.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
@@ -169,7 +196,7 @@ impl CornerDrag {
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Corner Radius".into()));
|
||||
}
|
||||
self.radius = self.radius_at(p);
|
||||
(self.radius, self.at_limit) = self.radius_at(p);
|
||||
self.at = p;
|
||||
out.push(Action::Preview("object.setLiveShape".into(), self.widgets.command("radius", json!(self.radius))));
|
||||
out
|
||||
@@ -186,12 +213,16 @@ impl CornerDrag {
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius readout next to the pointer.
|
||||
/// The radius readout next to the pointer; at the largest radius, the edited corners in red.
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if !self.began {
|
||||
return vec![];
|
||||
}
|
||||
vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }]
|
||||
let mut out = vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }];
|
||||
if self.at_limit {
|
||||
out.push(Overlay::Path { path: self.edited_corners(), color: crate::builder::HIGHLIGHT_RED, width: 2.0, dashed: false });
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,10 +235,15 @@ mod tests {
|
||||
|
||||
/// A live 100 × 100 rectangle at (100, 100) with corner radius `r`.
|
||||
fn live_rect(r: f64, xf: Affine) -> (Document, NodeId) {
|
||||
live_shape(100.0, 100.0, r, xf)
|
||||
}
|
||||
|
||||
/// A live `w` × `h` rectangle with corner radius `r`.
|
||||
fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [r; 4], kinds: Default::default(), xf };
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
|
||||
let mut n = Node::path(id, live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(live);
|
||||
@@ -264,6 +300,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also
|
||||
/// with a radius past half the shorter side; a drag past fully round stops every corner there
|
||||
/// and outlines them in red.
|
||||
#[test]
|
||||
fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() {
|
||||
let (d, id) = live_shape(200.0, 80.0, 60.0, Affine::translate((100.0, 100.0)));
|
||||
let s = selected(id);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
let centres = [Point::new(140.0, 140.0), Point::new(260.0, 140.0), Point::new(260.0, 140.0), Point::new(140.0, 140.0)];
|
||||
assert_eq!(w.points, centres);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let red = |t: &dyn crate::Tool| {
|
||||
t.overlays(&cx).into_iter().find_map(|o| match o {
|
||||
Overlay::Path { path, color, .. } if color == crate::builder::HIGHLIGHT_RED => Some(path),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 140.0, 140.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 200.0));
|
||||
assert_eq!(radius(&a[1]), 40.0);
|
||||
// The four arcs, each a quarter circle of radius 40 starting on a side.
|
||||
let arcs = red(t.as_ref()).expect("the limit shows");
|
||||
let starts: Vec<_> =
|
||||
arcs.elements().iter().filter_map(|e| if let vectorcraft_geom::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect();
|
||||
assert_eq!(starts, [Point::new(260.0, 100.0), Point::new(300.0, 140.0), Point::new(140.0, 180.0), Point::new(100.0, 140.0)]);
|
||||
// Back below the limit, the outline goes.
|
||||
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[0]), 30.0);
|
||||
assert!(red(t.as_ref()).is_none());
|
||||
}
|
||||
|
||||
/// #442: a rectangle saved stretched (an uneven scale in its transform, before #291) shows its
|
||||
/// widgets and drags its radius in document units, as `object.setLiveShape` sets it.
|
||||
#[test]
|
||||
fn a_stretched_rectangle_drags_its_radius_in_document_units() {
|
||||
// 50 × 80 with 10 pt corners stretched 4 × 1: drawn 200 × 80, a mean radius of 20.
|
||||
let (d, id) = live_shape(50.0, 80.0, 10.0, Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(4.0, 1.0));
|
||||
let s = selected(id);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
assert_eq!(w.points[0], Point::new(120.0, 120.0));
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = crate::create("directSelection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0, 120.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 110.0));
|
||||
assert!((radius(&a[1]) - 10.0).abs() < 1e-9, "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_follows_a_rotated_shape_and_a_click_does_nothing() {
|
||||
// Rotated 90°: the shape's top-left corner is at document (200, 100).
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]). Dragging a corner or an edge of area
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
|
||||
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
|
||||
//!
|
||||
//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors
|
||||
//! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else
|
||||
@@ -31,6 +32,7 @@ use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::{PointSnap, Targets};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
use crate::select::{MoveSnap, is_area_type, matrix_json};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
@@ -70,6 +72,8 @@ enum State {
|
||||
add: bool,
|
||||
},
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
/// Dragging a point of a blend's spine from `from`.
|
||||
SpinePoint {
|
||||
id: NodeId,
|
||||
@@ -310,6 +314,10 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||||
self.state = State::Bracket(b);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id))
|
||||
&& let Some(out) = hit_spine_handle(cx, sel, p, tol)
|
||||
{
|
||||
@@ -502,6 +510,15 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::Bracket(mut b)) => {
|
||||
let out = b.drag(cx, p, ev.mods.cmd);
|
||||
self.state = State::Bracket(b);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::Bracket(b)) => {
|
||||
self.state = State::Idle;
|
||||
b.finish()
|
||||
}
|
||||
(
|
||||
PointerKind::Up,
|
||||
State::MoveAnchors { began, .. }
|
||||
@@ -552,6 +569,7 @@ impl Tool for DirectSelectionTool {
|
||||
out.extend(self.mesh.overlays(cx));
|
||||
if !self.group {
|
||||
out.extend(frame_overlays(cx));
|
||||
out.extend(pathtype::overlays(cx));
|
||||
}
|
||||
if matches!(self.state, State::Handle { .. } | State::MoveAnchors { .. } | State::MoveObject { .. }) {
|
||||
out.extend(self.guides.iter().cloned());
|
||||
@@ -568,6 +586,9 @@ impl Tool for DirectSelectionTool {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p)) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
|
||||
return Cursor::PathBracket;
|
||||
}
|
||||
// A press on the highlighted anchor picks it, not the guide.
|
||||
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(Cursor::ArrowHollow)
|
||||
}
|
||||
|
||||
+25
-1
@@ -20,6 +20,7 @@ pub mod guides;
|
||||
pub mod meshblend;
|
||||
pub mod meshedit;
|
||||
pub mod params;
|
||||
pub mod pathtype;
|
||||
pub mod pen;
|
||||
pub mod place;
|
||||
pub mod printtiling;
|
||||
@@ -409,6 +410,12 @@ pub enum Cursor {
|
||||
BlendObject,
|
||||
/// The Blend tool over an anchor point (the blend starts there): a crosshair with a target.
|
||||
BlendAnchor,
|
||||
/// Over a bracket of selected type on a path (a drag moves it): the arrow with a bracket.
|
||||
PathBracket,
|
||||
/// The Shape Builder: a crosshair with a plus (merge mode)...
|
||||
ShapeBuilder,
|
||||
/// ...or, with Alt held, a minus (erase mode).
|
||||
ShapeBuilderErase,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
@@ -426,7 +433,9 @@ impl Cursor {
|
||||
| Cursor::Slice
|
||||
| Cursor::Blend
|
||||
| Cursor::BlendObject
|
||||
| Cursor::BlendAnchor => Cursor::Crosshair,
|
||||
| Cursor::BlendAnchor
|
||||
| Cursor::ShapeBuilder
|
||||
| Cursor::ShapeBuilderErase => Cursor::Crosshair,
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
@@ -569,6 +578,21 @@ pub(crate) mod testutil {
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// [`doc_with_rect`] plus type on a 300 pt path from (100, 300) to the right, from 20 % on.
|
||||
pub fn doc_with_path_type() -> (Document, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut t = vectorcraft_doc::TextObject::point(Point::ZERO, "Path type", Default::default());
|
||||
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
|
||||
vectorcraft_geom::PathEl::MoveTo(Point::new(100.0, 300.0)),
|
||||
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
|
||||
]));
|
||||
t.kind = vectorcraft_doc::TextKind::OnPath { path, start: 0.2, end: None };
|
||||
d.insert(Some(l), 1, Node::new(id, vectorcraft_doc::NodeKind::Text(Box::new(t)))).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
pub fn paint() -> PaintDefaults {
|
||||
PaintDefaults::default()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Type on a path's brackets: selected type on a path shows a start, a centre and an end bracket
|
||||
//! with the Selection and Direct Selection tools. Dragging the start or end bracket sets where the
|
||||
//! type begins or ends along its path; dragging the centre bracket slides the type along the path
|
||||
//! (its brackets with it) and, dragged across the path, flips the type to the other side
|
||||
//! (Cmd/Ctrl held: it only slides). Each drag is one undo step of `type.pathOptions`. The tools
|
||||
//! draw the brackets from the same geometry they hit-test.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_geom::kurbo::{Line, ParamCurveNearest};
|
||||
use vectorcraft_geom::{Affine, ArcPath, Point, Vec2};
|
||||
|
||||
use crate::{Action, Overlay, PointerEvent, ToolContext};
|
||||
|
||||
/// How far a bracket reaches (screen px): up from the path on the type's side, down across it,
|
||||
/// and the foot of the start and end brackets along the path, into the type's span.
|
||||
const UP_PX: f64 = 14.0;
|
||||
const DOWN_PX: f64 = 4.0;
|
||||
const FOOT_PX: f64 = 4.0;
|
||||
/// How far past the path (screen px) the centre bracket goes before the type flips.
|
||||
const FLIP_PX: f64 = 6.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Bracket {
|
||||
Start,
|
||||
Centre,
|
||||
End,
|
||||
}
|
||||
|
||||
impl Bracket {
|
||||
/// Hit-test order: the centre bracket first, then the start one (a closed path's span with
|
||||
/// no end of its own has both ends at the start).
|
||||
const ALL: [Bracket; 3] = [Bracket::Centre, Bracket::Start, Bracket::End];
|
||||
}
|
||||
|
||||
/// The brackets of a selected type on a path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Brackets {
|
||||
pub id: NodeId,
|
||||
/// Text space → document.
|
||||
xf: Affine,
|
||||
/// The path in text space.
|
||||
arc: ArcPath,
|
||||
/// [`TextKind::path_span`].
|
||||
span: (f64, f64),
|
||||
/// The end bracket was placed (not the end of the path, or once round it).
|
||||
end_set: bool,
|
||||
}
|
||||
|
||||
impl Brackets {
|
||||
/// The brackets of the selected type on a path the tools can edit (outside perspective).
|
||||
pub fn of(cx: &ToolContext) -> Vec<Self> {
|
||||
cx.selection.objects.iter().filter_map(|id| Self::of_node(cx, *id)).collect()
|
||||
}
|
||||
|
||||
fn of_node(cx: &ToolContext, id: NodeId) -> Option<Self> {
|
||||
let n = cx.doc.node(id).filter(|n| n.perspective.is_none() && cx.doc.is_editable(id))?;
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let TextKind::OnPath { path, end, .. } = &t.kind else { return None };
|
||||
let det = t.xf.determinant();
|
||||
let arc = ArcPath::new(path);
|
||||
if !det.is_finite() || det.abs() < 1e-12 || arc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self { id, xf: t.xf, arc, span: t.kind.path_span()?, end_set: end.is_some() })
|
||||
}
|
||||
|
||||
/// Where bracket `b` stands, as a fraction of the path's length (past 1 round a closed path).
|
||||
fn at(&self, b: Bracket) -> f64 {
|
||||
match b {
|
||||
Bracket::Start => self.span.0,
|
||||
Bracket::Centre => (self.span.0 + self.span.1) * 0.5,
|
||||
Bracket::End => self.span.1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The point at fraction `f` of the path, the path's direction there and the side the type
|
||||
/// stands on, in document space.
|
||||
fn frame(&self, f: f64) -> (Point, Vec2, Vec2) {
|
||||
let (p, dir) = self.arc.at(f * self.arc.len());
|
||||
let unit = |v: Vec2| {
|
||||
let w = self.xf * v.to_point() - self.xf * Point::ZERO;
|
||||
w / w.hypot().max(1e-12)
|
||||
};
|
||||
// Glyphs stand on the left of the path's direction (y down): their top faces (dir.y, -dir.x).
|
||||
(self.xf * p, unit(dir), unit(Vec2::new(dir.y, -dir.x)))
|
||||
}
|
||||
|
||||
/// The lines that draw bracket `b` at `zoom` (screen px per point).
|
||||
fn lines(&self, b: Bracket, zoom: f64) -> Vec<(Point, Point)> {
|
||||
let px = 1.0 / zoom.max(1e-9);
|
||||
let (p, dir, up) = self.frame(self.at(b));
|
||||
let top = p + up * UP_PX * px;
|
||||
let stem = (p - up * DOWN_PX * px, top);
|
||||
match b {
|
||||
Bracket::Centre => vec![stem],
|
||||
Bracket::Start => vec![stem, (top, top + dir * FOOT_PX * px)],
|
||||
Bracket::End => vec![stem, (top, top - dir * FOOT_PX * px)],
|
||||
}
|
||||
}
|
||||
|
||||
/// The bracket within `tol` (document units) of `p`.
|
||||
fn hit(&self, p: Point, zoom: f64, tol: f64) -> Option<Bracket> {
|
||||
Bracket::ALL.into_iter().find(|b| self.lines(*b, zoom).iter().any(|(a, c)| Line::new(*a, *c).nearest(p, 1e-9).distance_sq <= tol * tol))
|
||||
}
|
||||
|
||||
/// The fraction of the path's length nearest document point `p`.
|
||||
fn fraction_at(&self, p: Point) -> Option<f64> {
|
||||
self.arc.fraction_at(self.xf.inverse() * p)
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected type on a path's bracket under `p`, if any.
|
||||
fn bracket_at(cx: &ToolContext, p: Point) -> Option<(Brackets, Bracket)> {
|
||||
Brackets::of(cx).into_iter().find_map(|b| {
|
||||
let which = b.hit(p, cx.zoom, cx.tol(5.0))?;
|
||||
Some((b, which))
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `p` over a bracket of the selected type on a path?
|
||||
pub fn over_bracket(cx: &ToolContext, p: Point) -> bool {
|
||||
bracket_at(cx, p).is_some()
|
||||
}
|
||||
|
||||
/// The brackets of the selected type on a path, in their layer's colour.
|
||||
pub fn overlays(cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
for b in Brackets::of(cx) {
|
||||
let color = cx.doc.layer_color(b.id);
|
||||
for which in Bracket::ALL {
|
||||
out.extend(b.lines(which, cx.zoom).into_iter().map(|(a, b)| Overlay::Line { a, b, color, dashed: false }));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Dragging a bracket of type on a path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BracketDrag {
|
||||
brackets: Brackets,
|
||||
which: Bracket,
|
||||
start: Point,
|
||||
/// Where the press was along the path (a fraction of its length).
|
||||
grab: f64,
|
||||
began: bool,
|
||||
}
|
||||
|
||||
impl BracketDrag {
|
||||
/// Start a drag when the press `ev` is on a bracket of the selected type on a path.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let (brackets, which) = bracket_at(cx, ev.pos)?;
|
||||
let grab = brackets.fraction_at(ev.pos)?;
|
||||
Some(Self { brackets, which, start: ev.pos, grab, began: false })
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point, cmd: bool) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
if p.distance(self.start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Move Type on a Path".into()));
|
||||
}
|
||||
let Some(u) = self.brackets.fraction_at(p) else { return out };
|
||||
out.push(Action::Preview("type.pathOptions".into(), self.params(cx, p, u, cmd)));
|
||||
out
|
||||
}
|
||||
|
||||
/// `type.pathOptions` params for the pointer at `p`, `u` along the path.
|
||||
fn params(&self, cx: &ToolContext, p: Point, u: f64, cmd: bool) -> Value {
|
||||
let b = &self.brackets;
|
||||
let closed = b.arc.is_closed();
|
||||
let (s, e) = b.span;
|
||||
let mut m = Map::new();
|
||||
m.insert("ids".into(), json!([b.id.0]));
|
||||
match self.which {
|
||||
// Open paths keep the start before the end.
|
||||
Bracket::Start => {
|
||||
m.insert("start".into(), json!(if closed { u } else { u.min(e) }));
|
||||
}
|
||||
Bracket::End => {
|
||||
m.insert("end".into(), json!(if closed { u } else { u.max(s) }));
|
||||
}
|
||||
Bracket::Centre => {
|
||||
let mut d = u - self.grab;
|
||||
if closed {
|
||||
// The short way round.
|
||||
d -= d.round();
|
||||
}
|
||||
let place = |x: f64| if closed { x.rem_euclid(1.0) } else { x.clamp(0.0, 1.0) };
|
||||
let start = place(s + d);
|
||||
m.insert("start".into(), json!(start));
|
||||
if b.end_set {
|
||||
m.insert("end".into(), json!(if closed { place(e + d) } else { place(e + d).max(start) }));
|
||||
}
|
||||
// Across the path: the type flips to the other side.
|
||||
let (q, _, up) = b.frame(u);
|
||||
if !cmd && (p - q).dot(up) < -cx.tol(FLIP_PX) {
|
||||
m.insert("flip".into(), json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use crate::{Mods, PointerKind};
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
fn selected(id: NodeId) -> Selection {
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
s
|
||||
}
|
||||
|
||||
fn preview(a: &[Action]) -> &Value {
|
||||
let Some(Action::Preview(c, v)) = a.last() else { panic!("no preview: {a:?}") };
|
||||
assert_eq!(c, "type.pathOptions");
|
||||
v
|
||||
}
|
||||
|
||||
fn drag(cx: &ToolContext, from: Point, to: Point, mods: Mods) -> Vec<Action> {
|
||||
let mut d = BracketDrag::hit(cx, &PointerEvent::new(PointerKind::Down, from.x, from.y).with_mods(mods)).expect("a bracket");
|
||||
let a = d.drag(cx, to, mods.cmd);
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Move Type on a Path".into())));
|
||||
assert_eq!(d.finish(), vec![Action::Commit]);
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brackets_stand_at_the_start_the_middle_and_the_end_of_the_span() {
|
||||
// A 300 pt path from (100, 300) with the type from 20 % on.
|
||||
let (d, id) = doc_with_path_type();
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let b = Brackets::of(&cx).pop().unwrap();
|
||||
assert_eq!(b.hit(Point::new(160.0, 290.0), 1.0, 2.0), Some(Bracket::Start));
|
||||
assert_eq!(b.hit(Point::new(280.0, 290.0), 1.0, 2.0), Some(Bracket::Centre));
|
||||
assert_eq!(b.hit(Point::new(400.0, 290.0), 1.0, 2.0), Some(Bracket::End));
|
||||
assert_eq!(b.hit(Point::new(220.0, 290.0), 1.0, 2.0), None);
|
||||
// Drawn over the type's side of the path, with feet pointing into the span.
|
||||
let o = overlays(&cx);
|
||||
assert_eq!(o.len(), 5);
|
||||
assert!(o.contains(&Overlay::Line { a: Point::new(160.0, 286.0), b: Point::new(164.0, 286.0), color: d.layer_color(id), dashed: false }));
|
||||
// Nothing for unselected or other type.
|
||||
assert!(Brackets::of(&crate::testutil::cx(&d, &Selection::default(), &p)).is_empty());
|
||||
let (d2, area) = doc_with_area_type();
|
||||
let s2 = selected(area);
|
||||
assert!(Brackets::of(&crate::testutil::cx(&d2, &s2, &p)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_centre_bracket_slides_the_type_and_across_the_path_flips_it() {
|
||||
let (d, id) = doc_with_path_type();
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let grab = Point::new(280.0, 295.0);
|
||||
// 60 pt along: 20 % further, the end (the end of the path) left alone.
|
||||
let v = preview(&drag(&cx, grab, Point::new(340.0, 295.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() - 0.4).abs() < 1e-6, "{v}");
|
||||
assert!(v.get("end").is_none() && v.get("flip").is_none(), "{v}");
|
||||
assert_eq!(v["ids"], json!([id.0]));
|
||||
// Below the path: flipped as well.
|
||||
let v = preview(&drag(&cx, grab, Point::new(250.0, 320.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() - 0.1).abs() < 1e-6 && v["flip"] == true, "{v}");
|
||||
// Cmd/Ctrl held: it only slides.
|
||||
let v = preview(&drag(&cx, grab, Point::new(250.0, 320.0), Mods { cmd: true, ..Mods::default() })).clone();
|
||||
assert!(v.get("flip").is_none(), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_start_and_end_brackets_moves_them() {
|
||||
let (d, id) = doc_with_path_type();
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let v = preview(&drag(&cx, Point::new(160.0, 295.0), Point::new(130.0, 280.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() - 0.1).abs() < 1e-6 && v.get("end").is_none(), "{v}");
|
||||
let v = preview(&drag(&cx, Point::new(400.0, 295.0), Point::new(340.0, 310.0), Mods::default())).clone();
|
||||
assert!((v["end"].as_f64().unwrap() - 0.8).abs() < 1e-6 && v.get("start").is_none(), "{v}");
|
||||
// The end stops at the start.
|
||||
let v = preview(&drag(&cx, Point::new(400.0, 295.0), Point::new(110.0, 300.0), Mods::default())).clone();
|
||||
assert!((v["end"].as_f64().unwrap() - 0.2).abs() < 1e-6, "{v}");
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
//! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
|
||||
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]).
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
|
||||
//! ([`crate::pathtype`]).
|
||||
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
|
||||
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
|
||||
//! scaling its type: the text reflows at its size.
|
||||
@@ -17,6 +18,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::Targets;
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
|
||||
|
||||
@@ -49,6 +51,8 @@ enum State {
|
||||
},
|
||||
/// Dragging a Live Corners widget.
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -181,11 +185,16 @@ impl Tool for SelectionTool {
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Down, _) => {
|
||||
// 1. Live Corners widgets, then the bounding-box handles of the current selection.
|
||||
// 1. Live Corners widgets and type on a path's brackets, then the bounding-box
|
||||
// handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, ev) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||||
self.state = State::Bracket(b);
|
||||
return vec![];
|
||||
}
|
||||
if cx.show_bbox
|
||||
&& let Some(bx) = selection_box(cx)
|
||||
{
|
||||
@@ -296,6 +305,15 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::Bracket(mut b)) => {
|
||||
let out = b.drag(cx, p, m.cmd);
|
||||
self.state = State::Bracket(b);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::Bracket(b)) => {
|
||||
self.state = State::Idle;
|
||||
b.finish()
|
||||
}
|
||||
(PointerKind::Up, State::Moving { began, deselect, .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
@@ -332,7 +350,7 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = vec![];
|
||||
let mut o = pathtype::overlays(cx);
|
||||
match &self.state {
|
||||
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
State::Corner(c) => o.extend(c.overlays(cx)),
|
||||
@@ -352,6 +370,7 @@ impl Tool for SelectionTool {
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
|
||||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
State::Bracket(_) => return Cursor::PathBracket,
|
||||
_ => {}
|
||||
}
|
||||
if self.guide.busy() {
|
||||
@@ -360,6 +379,9 @@ impl Tool for SelectionTool {
|
||||
if over_widget(cx, p) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if over_bracket(cx, p) {
|
||||
return Cursor::PathBracket;
|
||||
}
|
||||
if cx.show_bbox
|
||||
&& let Some(bx) = selection_box(cx)
|
||||
{
|
||||
|
||||
@@ -388,6 +388,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors.
|
||||
let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none();
|
||||
match ui.input(|i| i.pointer.hover_pos()) {
|
||||
// An OS cursor: the system moves it at once, where a painted one trails the pointer
|
||||
// (#444). The system cursor stands in for cursors without a glyph.
|
||||
Some(_) if custom && crate::cursors::OS_CURSORS => {
|
||||
let ppp = ui.ctx().native_pixels_per_point().unwrap_or_else(|| ui.ctx().pixels_per_point());
|
||||
ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp));
|
||||
cursor_icon(c)
|
||||
}
|
||||
Some(hp) if custom && crate::cursors::paint(&painter, c, hp) => egui::CursorIcon::None,
|
||||
_ => cursor_icon(c),
|
||||
}
|
||||
@@ -401,7 +408,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
use egui::CursorIcon as C;
|
||||
match c {
|
||||
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius => C::Default,
|
||||
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius | Cursor::PathBracket => C::Default,
|
||||
Cursor::Move => C::Move,
|
||||
Cursor::Crosshair => C::Crosshair,
|
||||
Cursor::ResizeH => C::ResizeHorizontal,
|
||||
@@ -423,7 +430,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::SliceSelect => C::Default,
|
||||
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
|
||||
Cursor::WidthPoint => C::Move,
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => C::Crosshair,
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +445,14 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
app.hover_doc = hover.map(|p| xf.to_doc(p));
|
||||
let view = app.view_info();
|
||||
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
|
||||
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
|
||||
// without moving the mouse (Alt switches the Shape Builder to erase mode).
|
||||
let mods_changed = ui.data_mut(|d| {
|
||||
let id = egui::Id::new("canvas-mods");
|
||||
let prev = d.get_temp::<egui::Modifiers>(id);
|
||||
d.insert_temp(id, m);
|
||||
prev.is_some_and(|prev| prev != m)
|
||||
});
|
||||
|
||||
// Zoom around the pointer, or scroll ([`wheel`]).
|
||||
if resp.hovered() {
|
||||
@@ -576,7 +591,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
}
|
||||
}
|
||||
} else if let Some(p) = hover
|
||||
&& pointer.is_moving()
|
||||
&& (pointer.is_moving() || mods_changed)
|
||||
{
|
||||
let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
|
||||
dispatch(app, &ev, view);
|
||||
@@ -595,7 +610,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
}
|
||||
}
|
||||
}
|
||||
if drag.is_some() || pointer.is_moving() {
|
||||
if drag.is_some() || pointer.is_moving() || mods_changed {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
@@ -972,10 +987,11 @@ fn node_outline(n: &Node) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
walk_drawn(n, &mut |c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
|
||||
// Area type shows its frame: the type area Direct Selection reshapes.
|
||||
NodeKind::Text(t) if c.perspective.is_none() && matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => {
|
||||
if let Some(frame) = t.area_frame() {
|
||||
bp.extend(frame.to_bezpath());
|
||||
// Area type shows its frame: the type area Direct Selection reshapes; type on a path its
|
||||
// path, which the selection tools' brackets sit on.
|
||||
NodeKind::Text(t) if c.perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => {
|
||||
if let Some(path) = t.area_frame().or_else(|| t.type_path()) {
|
||||
bp.extend(path.to_bezpath());
|
||||
}
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||
@@ -1344,9 +1360,10 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false));
|
||||
}
|
||||
// Text: baseline marker (area type shows its frame instead, which may not be a rectangle).
|
||||
// Point type: baseline marker (area type shows its frame instead, which may not be a
|
||||
// rectangle, and type on a path its path).
|
||||
if let NodeKind::Text(tx) = &n.kind
|
||||
&& !matches!(tx.kind, vectorcraft_doc::TextKind::Area { .. })
|
||||
&& matches!(tx.kind, vectorcraft_doc::TextKind::Point)
|
||||
{
|
||||
let o = xf.to_screen(tx.xf * Point::ZERO);
|
||||
let b = n.geometric_bounds().unwrap_or_default();
|
||||
|
||||
@@ -217,7 +217,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if k == "height" {
|
||||
crate::panels::transform::constrain_link(app, ui);
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
|
||||
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) {
|
||||
app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok();
|
||||
}
|
||||
@@ -422,11 +422,13 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// Background saves and exports in progress, else the last message.
|
||||
if let Some(job) = app.background.jobs.first() {
|
||||
let more = app.background.jobs.len() - 1;
|
||||
let text = if more > 0 { format!("{}… (+{more})", job.label) } else { format!("{}…", job.label) };
|
||||
let label = crate::i18n::msg(&job.label);
|
||||
let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") };
|
||||
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
|
||||
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
|
||||
} else if !app.ui.status.is_empty() {
|
||||
ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text));
|
||||
// The message stays English in `ui.status` (agents and tests read it).
|
||||
ui.label(egui::RichText::new(crate::i18n::msg(&app.ui.status)).size(11.0).color(t.text));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+363
-48
@@ -1,21 +1,44 @@
|
||||
//! Tool cursors drawn as vector glyphs (egui only offers system cursors). Black shapes with a white
|
||||
//! halo, hotspot at `p`, Illustrator's visual grammar: solid arrow (Selection), hollow arrow (Direct
|
||||
//! Selection), pen nib with state badges, crosshair for drawing tools, curved arrows for rotate.
|
||||
//!
|
||||
//! The desktop app shows them as OS cursors ([`Images`]): the system moves those itself, at once,
|
||||
//! while a cursor painted into the window follows the pointer a few frames late (#444).
|
||||
|
||||
use egui::{Color32, Painter, Pos2, Shape, Stroke, pos2, vec2};
|
||||
use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex};
|
||||
use egui::{Color32, CustomCursorImage, Painter, Pos2, Shape, Stroke, Vec2, pos2, vec2};
|
||||
use vectorcraft_tools::Cursor;
|
||||
|
||||
const INK: Color32 = Color32::BLACK;
|
||||
const HALO: Color32 = Color32::WHITE;
|
||||
|
||||
fn poly(p: &Painter, pts: Vec<Pos2>, fill: Color32, stroke: Color32) {
|
||||
/// The shapes of one cursor glyph.
|
||||
#[derive(Default)]
|
||||
struct Ink(Vec<Shape>);
|
||||
|
||||
impl Ink {
|
||||
fn add(&mut self, s: Shape) {
|
||||
self.0.push(s);
|
||||
}
|
||||
fn line_segment(&mut self, pts: [Pos2; 2], stroke: Stroke) {
|
||||
self.add(Shape::line_segment(pts, stroke));
|
||||
}
|
||||
fn circle_stroke(&mut self, c: Pos2, r: f32, stroke: Stroke) {
|
||||
self.add(Shape::circle_stroke(c, r, stroke));
|
||||
}
|
||||
fn circle_filled(&mut self, c: Pos2, r: f32, fill: Color32) {
|
||||
self.add(Shape::circle_filled(c, r, fill));
|
||||
}
|
||||
}
|
||||
|
||||
fn poly(p: &mut Ink, pts: Vec<Pos2>, fill: Color32, stroke: Color32) {
|
||||
// Halo first (thicker white outline), then the glyph.
|
||||
p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO)));
|
||||
p.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE));
|
||||
p.add(Shape::closed_line(pts, Stroke::new(1.0, stroke)));
|
||||
}
|
||||
|
||||
fn line(p: &Painter, a: Pos2, b: Pos2) {
|
||||
fn line(p: &mut Ink, a: Pos2, b: Pos2) {
|
||||
p.line_segment([a, b], Stroke::new(3.0, HALO));
|
||||
p.line_segment([a, b], Stroke::new(1.2, INK));
|
||||
}
|
||||
@@ -25,7 +48,7 @@ fn arrow_points(o: Pos2) -> Vec<Pos2> {
|
||||
[(0.0, 0.0), (0.0, 15.0), (3.8, 11.4), (6.4, 17.0), (8.6, 16.0), (6.1, 10.6), (11.0, 10.6)].iter().map(|(x, y)| o + vec2(*x, *y)).collect()
|
||||
}
|
||||
|
||||
fn arrow(p: &Painter, o: Pos2, hollow: bool) {
|
||||
fn arrow(p: &mut Ink, o: Pos2, hollow: bool) {
|
||||
let pts = arrow_points(o);
|
||||
// The arrow is concave: draw as a filled mesh of two convex parts, then outline.
|
||||
p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO)));
|
||||
@@ -35,7 +58,7 @@ fn arrow(p: &Painter, o: Pos2, hollow: bool) {
|
||||
p.add(Shape::closed_line(pts, Stroke::new(1.0, INK)));
|
||||
}
|
||||
|
||||
fn crosshair(p: &Painter, o: Pos2) {
|
||||
fn crosshair(p: &mut Ink, o: Pos2) {
|
||||
for (a, b) in
|
||||
[(vec2(-9.0, 0.0), vec2(-2.0, 0.0)), (vec2(2.0, 0.0), vec2(9.0, 0.0)), (vec2(0.0, -9.0), vec2(0.0, -2.0)), (vec2(0.0, 2.0), vec2(0.0, 9.0))]
|
||||
{
|
||||
@@ -43,7 +66,7 @@ fn crosshair(p: &Painter, o: Pos2) {
|
||||
}
|
||||
}
|
||||
|
||||
fn pen(p: &Painter, o: Pos2, badge: &str) {
|
||||
fn pen(p: &mut Ink, o: Pos2, badge: &str) {
|
||||
// Nib pointing to the top-left hotspot.
|
||||
let pts = vec![o, o + vec2(4.0, 12.0), o + vec2(8.0, 16.0), o + vec2(16.0, 8.0), o + vec2(12.0, 4.0)];
|
||||
poly(p, pts, INK, INK);
|
||||
@@ -74,7 +97,7 @@ fn pen(p: &Painter, o: Pos2, badge: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn double_arrow(p: &Painter, o: Pos2, dir: egui::Vec2) {
|
||||
fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
let d = dir.normalized() * 8.0;
|
||||
let n = vec2(-d.y, d.x) * 0.45;
|
||||
line(p, o - d, o + d);
|
||||
@@ -83,7 +106,7 @@ fn double_arrow(p: &Painter, o: Pos2, dir: egui::Vec2) {
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate(p: &Painter, o: Pos2) {
|
||||
fn rotate(p: &mut Ink, o: Pos2) {
|
||||
let pts: Vec<Pos2> = (0..=10)
|
||||
.map(|i| {
|
||||
let a = std::f32::consts::PI * (0.15 + 0.7 * i as f32 / 10.0);
|
||||
@@ -98,7 +121,7 @@ fn rotate(p: &Painter, o: Pos2) {
|
||||
}
|
||||
|
||||
/// Live Corners: the hollow arrow with a rounded-corner badge.
|
||||
fn corner_radius(p: &Painter, o: Pos2) {
|
||||
fn corner_radius(p: &mut Ink, o: Pos2) {
|
||||
arrow(p, o, true);
|
||||
let b = o + vec2(12.0, 13.0);
|
||||
let mut pts = vec![b + vec2(0.0, 10.0)];
|
||||
@@ -111,9 +134,24 @@ fn corner_radius(p: &Painter, o: Pos2) {
|
||||
p.add(Shape::line(pts, Stroke::new(1.2, INK)));
|
||||
}
|
||||
|
||||
/// Over a bracket of type on a path: the arrow with a bracket (a stem standing on a baseline,
|
||||
/// with a foot) below right.
|
||||
fn path_bracket(p: &mut Ink, o: Pos2) {
|
||||
arrow(p, o, false);
|
||||
let b = o + vec2(13.0, 13.0);
|
||||
let stem = [b + vec2(3.0, 0.0), b + vec2(3.0, 10.0)];
|
||||
let foot = [b + vec2(3.0, 0.0), b + vec2(7.0, 0.0)];
|
||||
let base = [b + vec2(0.0, 8.0), b + vec2(10.0, 8.0)];
|
||||
for (w, c) in [(3.0, HALO), (1.2, INK)] {
|
||||
for l in [stem, foot, base] {
|
||||
p.line_segment(l, Stroke::new(w, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradient annotator's stop cursors: the arrow with a plus (add a stop) or minus (delete it)
|
||||
/// badge.
|
||||
fn stop_badge(p: &Painter, o: Pos2, add: bool) {
|
||||
fn stop_badge(p: &mut Ink, o: Pos2, add: bool) {
|
||||
arrow(p, o, false);
|
||||
let b = o + vec2(13.0, 13.0);
|
||||
line(p, b, b + vec2(6.0, 0.0));
|
||||
@@ -123,14 +161,14 @@ fn stop_badge(p: &Painter, o: Pos2, add: bool) {
|
||||
}
|
||||
|
||||
/// A slice badge: a small rectangle cut by a line, at `b` (its top left).
|
||||
fn slice_badge(p: &Painter, b: Pos2) {
|
||||
fn slice_badge(p: &mut Ink, b: Pos2) {
|
||||
let pts = vec![b, b + vec2(8.0, 0.0), b + vec2(8.0, 6.0), b + vec2(0.0, 6.0)];
|
||||
poly(p, pts, HALO, INK);
|
||||
line(p, b + vec2(4.0, 0.0), b + vec2(4.0, 6.0));
|
||||
}
|
||||
|
||||
/// The Slice tool: a crosshair with a blade below right of the hotspot.
|
||||
fn slice(p: &Painter, o: Pos2) {
|
||||
fn slice(p: &mut Ink, o: Pos2) {
|
||||
crosshair(p, o);
|
||||
let b = o + vec2(7.0, 7.0);
|
||||
poly(p, vec![b, b + vec2(9.0, 4.0), b + vec2(10.0, 7.0), b + vec2(3.0, 6.0)], HALO, INK);
|
||||
@@ -140,7 +178,7 @@ fn slice(p: &Painter, o: Pos2) {
|
||||
/// The Width tool: the hollow arrow with a stroke that swells in the middle (a width point),
|
||||
/// plus a badge: `+` over a stroke (a drag adds a point), a bar across the swell over a width point
|
||||
/// (a drag moves or widens it).
|
||||
fn width(p: &Painter, o: Pos2, badge: &str) {
|
||||
fn width(p: &mut Ink, o: Pos2, badge: &str) {
|
||||
arrow(p, o, true);
|
||||
let b = o + vec2(11.0, 18.0);
|
||||
let top: Vec<Pos2> = (0..=8)
|
||||
@@ -164,7 +202,7 @@ fn width(p: &Painter, o: Pos2, badge: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn ibeam(p: &Painter, o: Pos2) {
|
||||
fn ibeam(p: &mut Ink, o: Pos2) {
|
||||
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
|
||||
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
|
||||
line(p, o + vec2(-3.0, 8.0), o + vec2(3.0, 8.0));
|
||||
@@ -173,7 +211,7 @@ fn ibeam(p: &Painter, o: Pos2) {
|
||||
|
||||
/// The Blend tool: a crosshair with a square below right of the hotspot, hollow away from art,
|
||||
/// filled over an object; over an anchor point a ringed dot (the blend starts there).
|
||||
fn blend(p: &Painter, o: Pos2, badge: Cursor) {
|
||||
fn blend(p: &mut Ink, o: Pos2, badge: Cursor) {
|
||||
crosshair(p, o);
|
||||
let b = o + vec2(9.0, 9.0);
|
||||
if badge == Cursor::BlendAnchor {
|
||||
@@ -186,50 +224,327 @@ fn blend(p: &Painter, o: Pos2, badge: Cursor) {
|
||||
poly(p, vec![b, b + vec2(7.0, 0.0), b + vec2(7.0, 7.0), b + vec2(0.0, 7.0)], fill, INK);
|
||||
}
|
||||
|
||||
/// Paint cursor `c` at `p` on the given (foreground) painter. Returns false for cursors that should
|
||||
/// stay system cursors (hand, zoom, busy states).
|
||||
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
|
||||
/// The Shape Builder: a crosshair with a plus badge below right of the hotspot (merge mode), or a
|
||||
/// minus (erase mode).
|
||||
fn shape_builder(p: &mut Ink, o: Pos2, erase: bool) {
|
||||
crosshair(p, o);
|
||||
let c = o + vec2(12.0, 12.0);
|
||||
line(p, c - vec2(3.5, 0.0), c + vec2(3.5, 0.0));
|
||||
if !erase {
|
||||
line(p, c - vec2(0.0, 3.5), c + vec2(0.0, 3.5));
|
||||
}
|
||||
}
|
||||
|
||||
/// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors
|
||||
/// (hand, zoom, busy states).
|
||||
fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
let ink = &mut Ink::default();
|
||||
match c {
|
||||
Cursor::Arrow => arrow(painter, p, false),
|
||||
Cursor::ArrowHollow => arrow(painter, p, true),
|
||||
Cursor::Arrow => arrow(ink, p, false),
|
||||
Cursor::ArrowHollow => arrow(ink, p, true),
|
||||
Cursor::Move => {
|
||||
arrow(painter, p, false);
|
||||
double_arrow(painter, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
|
||||
arrow(ink, p, false);
|
||||
double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
|
||||
}
|
||||
Cursor::Crosshair | Cursor::Eyedropper => crosshair(painter, p),
|
||||
Cursor::ResizeH => double_arrow(painter, p, vec2(1.0, 0.0)),
|
||||
Cursor::ResizeV => double_arrow(painter, p, vec2(0.0, 1.0)),
|
||||
Cursor::ResizeNwSe => double_arrow(painter, p, vec2(1.0, 1.0)),
|
||||
Cursor::ResizeNeSw => double_arrow(painter, p, vec2(1.0, -1.0)),
|
||||
Cursor::Rotate => rotate(painter, p),
|
||||
Cursor::CornerRadius => corner_radius(painter, p),
|
||||
Cursor::Pen => pen(painter, p, "*"),
|
||||
Cursor::PenAdd => pen(painter, p, "+"),
|
||||
Cursor::PenDelete => pen(painter, p, "-"),
|
||||
Cursor::PenClose => pen(painter, p, "o"),
|
||||
Cursor::PenContinue => pen(painter, p, "/"),
|
||||
Cursor::PenConvert => pen(painter, p, "^"),
|
||||
Cursor::Text => ibeam(painter, p),
|
||||
Cursor::AddStop => stop_badge(painter, p, true),
|
||||
Cursor::RemoveStop => stop_badge(painter, p, false),
|
||||
Cursor::Slice => slice(painter, p),
|
||||
Cursor::Crosshair | Cursor::Eyedropper => crosshair(ink, p),
|
||||
Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)),
|
||||
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
|
||||
Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)),
|
||||
Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)),
|
||||
Cursor::Rotate => rotate(ink, p),
|
||||
Cursor::CornerRadius => corner_radius(ink, p),
|
||||
Cursor::Pen => pen(ink, p, "*"),
|
||||
Cursor::PenAdd => pen(ink, p, "+"),
|
||||
Cursor::PenDelete => pen(ink, p, "-"),
|
||||
Cursor::PenClose => pen(ink, p, "o"),
|
||||
Cursor::PenContinue => pen(ink, p, "/"),
|
||||
Cursor::PenConvert => pen(ink, p, "^"),
|
||||
Cursor::Text => ibeam(ink, p),
|
||||
Cursor::AddStop => stop_badge(ink, p, true),
|
||||
Cursor::RemoveStop => stop_badge(ink, p, false),
|
||||
Cursor::Slice => slice(ink, p),
|
||||
Cursor::SliceSelect => {
|
||||
arrow(painter, p, false);
|
||||
slice_badge(painter, p + vec2(11.0, 14.0));
|
||||
arrow(ink, p, false);
|
||||
slice_badge(ink, p + vec2(11.0, 14.0));
|
||||
}
|
||||
Cursor::Width => width(ink, p, ""),
|
||||
Cursor::WidthAdd => width(ink, p, "+"),
|
||||
Cursor::WidthPoint => width(ink, p, "point"),
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(ink, p, c),
|
||||
Cursor::PathBracket => path_bracket(ink, p),
|
||||
Cursor::ShapeBuilder => shape_builder(ink, p, false),
|
||||
Cursor::ShapeBuilderErase => shape_builder(ink, p, true),
|
||||
_ => return None,
|
||||
}
|
||||
Some(std::mem::take(&mut ink.0))
|
||||
}
|
||||
|
||||
/// Paint cursor `c` at `p` on the given (foreground) painter, where the window can't show it as an
|
||||
/// OS cursor ([`OS_CURSORS`]). Returns false for cursors that stay system cursors.
|
||||
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
|
||||
glyph(c, p).map(|shapes| painter.extend(shapes)).is_some()
|
||||
}
|
||||
|
||||
/// Does the window show the glyphs as OS cursors ([`Images`])? Not on macOS, which sizes a cursor
|
||||
/// bitmap in points (a Retina one would show twice as big, or blurred), nor on the web (eframe has no
|
||||
/// bitmap cursors there): those paint them into the window ([`paint`]).
|
||||
pub const OS_CURSORS: bool = !cfg!(any(target_os = "macos", target_arch = "wasm32"));
|
||||
|
||||
/// The cursor bitmaps handed to the OS, by (cursor, pixels per point): each glyph is rasterized
|
||||
/// once, and handing the same `Arc` every frame keeps the window's OS cursor instead of making a
|
||||
/// new one.
|
||||
#[derive(Default)]
|
||||
pub struct Images(Vec<(Cursor, f32, CustomCursorImage)>);
|
||||
|
||||
impl Images {
|
||||
/// Cursor `c` for a display of `ppp` physical pixels per point; `None` for cursors that stay
|
||||
/// system cursors.
|
||||
pub fn get(&mut self, c: Cursor, ppp: f32) -> Option<CustomCursorImage> {
|
||||
if let Some((.., image)) = self.0.iter().find(|(k, s, _)| *k == c && *s == ppp) {
|
||||
return Some(image.clone());
|
||||
}
|
||||
let image = rasterize(c, ppp)?;
|
||||
// A few dozen glyphs at the scales of the displays the window visited.
|
||||
if self.0.len() >= 128 {
|
||||
self.0.clear();
|
||||
}
|
||||
self.0.push((c, ppp, image.clone()));
|
||||
Some(image)
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest side of a cursor bitmap, in pixels (the glyphs span under 50 points).
|
||||
const MAX_SIDE: f32 = 256.0;
|
||||
|
||||
/// Cursor `c` as a straight-alpha RGBA bitmap at `ppp` pixels per point, tessellated (anti-aliased)
|
||||
/// as egui would paint it, the hotspot on a pixel corner.
|
||||
fn rasterize(c: Cursor, ppp: f32) -> Option<CustomCursorImage> {
|
||||
if !(ppp.is_finite() && ppp > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let shapes = glyph(c, Pos2::ZERO)?;
|
||||
let mut tessellator = Tessellator::new(ppp, TessellationOptions::default(), [1, 1], vec![]);
|
||||
let mut mesh = Mesh::default();
|
||||
for shape in shapes {
|
||||
tessellator.tessellate_shape(shape, &mut mesh);
|
||||
}
|
||||
let bounds = mesh.calc_bounds();
|
||||
// The hotspot must lie inside the bitmap.
|
||||
let min = (bounds.min.to_vec2() * ppp).floor().min(Vec2::ZERO);
|
||||
let max = (bounds.max.to_vec2() * ppp).ceil().max(Vec2::splat(1.0));
|
||||
let size = max - min;
|
||||
if !(size.x <= MAX_SIDE && size.y <= MAX_SIDE) {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (size.x as usize, size.y as usize);
|
||||
let mut px = vec![[0.0; 4]; w * h];
|
||||
let corner = |v: &Vertex| (v.pos.to_vec2() * ppp - min, v.color);
|
||||
for tri in mesh.indices.chunks_exact(3) {
|
||||
let &[i, j, k] = tri else { continue };
|
||||
if let (Some(a), Some(b), Some(c)) = (mesh.vertices.get(i as usize), mesh.vertices.get(j as usize), mesh.vertices.get(k as usize)) {
|
||||
fill_triangle(&mut px, w, [corner(a), corner(b), corner(c)]);
|
||||
}
|
||||
}
|
||||
let rgba: Vec<u8> = px
|
||||
.iter()
|
||||
.flat_map(|&[r, g, b, a]| {
|
||||
let k = if a > 0.0 { 1.0 / a } else { 0.0 };
|
||||
[r * k, g * k, b * k, a].map(|x| (x * 255.0).round().clamp(0.0, 255.0) as u8)
|
||||
})
|
||||
.collect();
|
||||
Some(CustomCursorImage { rgba: rgba.into(), size: [w as u16, h as u16], hotspot: [-min.x as u16, -min.y as u16] })
|
||||
}
|
||||
|
||||
/// Blend triangle `t` (pixel positions and premultiplied colours, interpolated across it) over `px`
|
||||
/// (premultiplied RGBA, `w` pixels a row) at the pixel centres it covers, as the GPU blends egui's
|
||||
/// meshes. A centre on an edge goes to one of the two triangles sharing it, so seams aren't doubled.
|
||||
fn fill_triangle(px: &mut [[f32; 4]], w: usize, t: [(Vec2, Color32); 3]) {
|
||||
let edge = |a: Vec2, b: Vec2, p: Vec2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||||
let [a, mut b, mut c] = t;
|
||||
let mut area = edge(a.0, b.0, c.0);
|
||||
if area < 0.0 {
|
||||
std::mem::swap(&mut b, &mut c);
|
||||
area = -area;
|
||||
}
|
||||
if area.is_nan() || area <= 1e-6 || w == 0 {
|
||||
return;
|
||||
}
|
||||
// Of the two triangles sharing an edge (walking it in opposite directions), one owns it.
|
||||
let owns = |u: Vec2, v: Vec2| v.y > u.y || (v.y == u.y && v.x > u.x);
|
||||
let h = px.len() / w;
|
||||
let lo = a.0.min(b.0).min(c.0).max(Vec2::ZERO);
|
||||
let hi = a.0.max(b.0).max(c.0).min(vec2(w as f32, h as f32));
|
||||
for y in lo.y.floor() as usize..hi.y.ceil() as usize {
|
||||
for x in lo.x.floor() as usize..hi.x.ceil() as usize {
|
||||
let p = vec2(x as f32 + 0.5, y as f32 + 0.5);
|
||||
let weights = [(b.0, c.0), (c.0, a.0), (a.0, b.0)].map(|(u, v)| (edge(u, v, p), owns(u, v)));
|
||||
if !weights.iter().all(|&(e, own)| e > 0.0 || (e == 0.0 && own)) {
|
||||
continue;
|
||||
}
|
||||
let Some(dst) = px.get_mut(y * w + x) else { continue };
|
||||
let [wa, wb, wc] = weights.map(|(e, _)| e / area);
|
||||
let src: [f32; 4] = std::array::from_fn(|i| {
|
||||
let channel = |col: Color32| f32::from(col.to_array()[i]);
|
||||
(wa * channel(a.1) + wb * channel(b.1) + wc * channel(c.1)) / 255.0
|
||||
});
|
||||
let keep = 1.0 - src[3];
|
||||
*dst = std::array::from_fn(|i| src[i] + dst[i] * keep);
|
||||
}
|
||||
Cursor::Width => width(painter, p, ""),
|
||||
Cursor::WidthAdd => width(painter, p, "+"),
|
||||
Cursor::WidthPoint => width(painter, p, "point"),
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
|
||||
_ => return false,
|
||||
}
|
||||
let _ = pos2;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Every cursor with a glyph.
|
||||
const GLYPHS: [Cursor; 31] = [
|
||||
Cursor::Arrow,
|
||||
Cursor::ArrowHollow,
|
||||
Cursor::Move,
|
||||
Cursor::Crosshair,
|
||||
Cursor::ResizeH,
|
||||
Cursor::ResizeV,
|
||||
Cursor::ResizeNwSe,
|
||||
Cursor::ResizeNeSw,
|
||||
Cursor::Rotate,
|
||||
Cursor::CornerRadius,
|
||||
Cursor::Pen,
|
||||
Cursor::PenAdd,
|
||||
Cursor::PenDelete,
|
||||
Cursor::PenClose,
|
||||
Cursor::PenContinue,
|
||||
Cursor::PenConvert,
|
||||
Cursor::Text,
|
||||
Cursor::Eyedropper,
|
||||
Cursor::AddStop,
|
||||
Cursor::RemoveStop,
|
||||
Cursor::Slice,
|
||||
Cursor::SliceSelect,
|
||||
Cursor::Width,
|
||||
Cursor::WidthAdd,
|
||||
Cursor::WidthPoint,
|
||||
Cursor::Blend,
|
||||
Cursor::BlendObject,
|
||||
Cursor::BlendAnchor,
|
||||
Cursor::PathBracket,
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
];
|
||||
|
||||
/// The straight RGBA of pixel (x, y).
|
||||
fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] {
|
||||
let i = (y * img.size[0] as usize + x) * 4;
|
||||
[img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() {
|
||||
for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] {
|
||||
for c in GLYPHS {
|
||||
let img = rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}"));
|
||||
let [w, h] = img.size.map(usize::from);
|
||||
assert_eq!(img.rgba.len(), w * h * 4, "{c:?}");
|
||||
assert!(img.hotspot[0] < img.size[0] && img.hotspot[1] < img.size[1], "{c:?} at {ppp}: {img:?}");
|
||||
assert!(w as f32 <= 50.0 * ppp && h as f32 <= 50.0 * ppp, "{c:?} at {ppp}: {img:?}");
|
||||
assert!(img.rgba.chunks_exact(4).any(|p| p[3] == 255), "{c:?}: nothing opaque");
|
||||
}
|
||||
}
|
||||
for c in [Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, Cursor::ZoomOut, Cursor::NotAllowed] {
|
||||
assert!(rasterize(c, 1.0).is_none(), "{c:?} stays a system cursor");
|
||||
}
|
||||
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
|
||||
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arrow_bitmap_is_black_in_a_white_halo_with_the_tip_on_the_hotspot() {
|
||||
let img = rasterize(Cursor::Arrow, 1.0).unwrap();
|
||||
let [hx, hy] = img.hotspot.map(usize::from);
|
||||
// The tip covers the hotspot's pixel; the body is solid black, the outline's halo white.
|
||||
assert!(pixel(&img, hx, hy)[3] > 0, "{:?}", pixel(&img, hx, hy));
|
||||
let body = pixel(&img, hx + 2, hy + 8);
|
||||
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
|
||||
assert!(img.rgba.chunks_exact(4).any(|p| p[3] == 255 && p[..3].iter().all(|&c| c > 215)), "no white halo");
|
||||
// Nothing left of or above the tip but the halo.
|
||||
assert!((0..img.size[1] as usize).all(|y| pixel(&img, 0, y)[..3].iter().all(|&c| c > 100) || pixel(&img, 0, y)[3] < 128));
|
||||
// At twice the pixels per point, twice the pixels.
|
||||
let big = rasterize(Cursor::Arrow, 2.0).unwrap();
|
||||
for i in 0..2 {
|
||||
let ratio = f32::from(big.size[i]) / f32::from(img.size[i]);
|
||||
assert!((1.8..=2.2).contains(&ratio), "{:?} vs {:?}", big.size, img.size);
|
||||
}
|
||||
let [bx, by] = big.hotspot.map(usize::from);
|
||||
let body = pixel(&big, bx + 4, by + 16);
|
||||
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() {
|
||||
let mut images = Images::default();
|
||||
let a = images.get(Cursor::Pen, 1.5).unwrap();
|
||||
let b = images.get(Cursor::Pen, 1.5).unwrap();
|
||||
assert!(Arc::ptr_eq(&a.rgba, &b.rgba));
|
||||
assert!(!Arc::ptr_eq(&a.rgba, &images.get(Cursor::Pen, 2.0).unwrap().rgba));
|
||||
assert!(images.get(Cursor::Hand, 1.5).is_none());
|
||||
assert!(images.get(Cursor::Arrow, f32::NAN).is_none());
|
||||
assert_eq!(images.0.len(), 2);
|
||||
}
|
||||
|
||||
/// One headless 800×600 frame of the whole window, a tenth of a second after the last.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> egui::FullOutput {
|
||||
let time = Some(ctx.input(|i| i.time) + 0.1);
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time, events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
}
|
||||
let canvas = app.canvas_rect.unwrap();
|
||||
let over = canvas.center();
|
||||
let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||||
if OS_CURSORS {
|
||||
let img = out.platform_output.cursor_image.as_ref().expect("the Selection tool's arrow as an OS cursor");
|
||||
assert_eq!(img, &app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap());
|
||||
// Where the OS can't show the bitmap, its system cursor.
|
||||
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default);
|
||||
} else {
|
||||
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None);
|
||||
}
|
||||
// The same bitmap while the pointer moves (the OS keeps its cursor), and an idle window
|
||||
// once it rests: no frame is asked for.
|
||||
let next = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over + vec2(5.0, 3.0))]);
|
||||
if OS_CURSORS {
|
||||
assert!(Arc::ptr_eq(&next.platform_output.cursor_image.unwrap().rgba, &out.platform_output.cursor_image.unwrap().rgba));
|
||||
}
|
||||
let mut idle = frame(&mut app, &ctx, vec![]);
|
||||
for _ in 0..3 {
|
||||
idle = frame(&mut app, &ctx, vec![]);
|
||||
}
|
||||
let delay = idle.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay).unwrap();
|
||||
assert!(delay > std::time::Duration::from_millis(100), "{delay:?} {:?}", ctx.repaint_causes());
|
||||
// Off the canvas, the panels' cursors.
|
||||
let off = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(canvas.left() - 20.0, canvas.center().y))]);
|
||||
assert!(off.platform_output.cursor_image.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_hotspot_is_tip() {
|
||||
|
||||
@@ -13,7 +13,8 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.open(&mut open)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18)))
|
||||
.show(ctx, |ui| {
|
||||
// Tabs: About · Contributors · Models (the credits are compiled in, see `credits`).
|
||||
|
||||
@@ -10,8 +10,9 @@ use crate::state::Dialog;
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |d| tl!(&d.str("__label")).to_string(),
|
||||
body: |app, ui, d| {
|
||||
let lengths = lengths(&d.str("__command"));
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), app.session.general_unit());
|
||||
let command = d.str("__command");
|
||||
let lengths = lengths(&command);
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), app.session.general_unit());
|
||||
false
|
||||
},
|
||||
confirm,
|
||||
@@ -27,10 +28,23 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
"perspective.grid.set" => &["cell", "distance"],
|
||||
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
|
||||
"text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"],
|
||||
"type.pathOptions" => &["spacing"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters of the commands this dialog edits that pick one of some values.
|
||||
fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
||||
match (command, key) {
|
||||
("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS),
|
||||
("type.pathOptions", "alignToPath") => Some(PATH_ALIGN),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options › Align to Path.
|
||||
const PATH_ALIGN: form::Choices = &[("Ascender", "ascender"), ("Descender", "descender"), ("Center", "center"), ("Baseline", "baseline")];
|
||||
|
||||
/// Closes before running, so a dialog the command opens stays open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
|
||||
@@ -180,7 +180,7 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
ui.end_row();
|
||||
for (key, label) in [("superscript", tl!("Superscript:")), ("subscript", tl!("Subscript:"))] {
|
||||
ui.label(egui::RichText::new(label).color(t.text));
|
||||
widgets::field_label(ui, egui::RichText::new(label).color(t.text));
|
||||
for part in ["size", "position"] {
|
||||
let v = d.fields.get(key).and_then(|m| m.get(part)).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
if let Some(n) = widgets::plain_field(ui, (key, part), v, "%", 1, 70.0) {
|
||||
@@ -191,7 +191,7 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Small Caps:")).color(t.text));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Small Caps:")).color(t.text));
|
||||
if let Some(n) = widgets::plain_field(ui, "smallCaps", d.f64("smallCapsSize", 70.0), "%", 1, 70.0) {
|
||||
d.fields.insert("smallCapsSize".into(), json!(n));
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let changed = match vectorcraft_plugins::effect::installed(&id) {
|
||||
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
|
||||
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
|
||||
None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), app.session.general_unit()),
|
||||
None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|_| None, app.session.general_unit()),
|
||||
};
|
||||
ui.add_space(6.0);
|
||||
let mut pv = d.bool("preview");
|
||||
|
||||
@@ -17,16 +17,20 @@ pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
|
||||
/// [`field`] `width` points wide.
|
||||
pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
|
||||
row_label(ui, label);
|
||||
text(ui, d, key, width);
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// A labelled [`length`] field (one grid row).
|
||||
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
|
||||
/// The label cell of a grid row.
|
||||
fn row_label(ui: &mut egui::Ui, label: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
|
||||
}
|
||||
|
||||
/// A labelled [`length`] field (one grid row).
|
||||
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
|
||||
row_label(ui, label);
|
||||
length(ui, d, key, unit, FIELD_W);
|
||||
ui.end_row();
|
||||
}
|
||||
@@ -57,6 +61,30 @@ fn lengths(kind: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of the form dialogs ([`grid`]) that come first, in this order (the others follow by
|
||||
/// name).
|
||||
fn order(kind: &str) -> &'static [&'static str] {
|
||||
match kind {
|
||||
"offsetPath" => &["offset", "joins", "miterLimit"],
|
||||
"splitIntoGrid" => &["rows", "columns", "gutter"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The choices of a form dialog's ([`grid`]) dropdown field, as (value, label in the UI
|
||||
/// language); empty for a field typed as text.
|
||||
fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> {
|
||||
// The axes in the "axis" context: some languages read the plain Horizontal and Vertical as
|
||||
// type orientations.
|
||||
let axis = |value, label| (value, crate::i18n::tr_ctx(crate::i18n::current(), "axis", label));
|
||||
match (kind, key) {
|
||||
("offsetPath", "joins") => vec![("miter", tl!("Miter")), ("round", tl!("Round")), ("bevel", tl!("Bevel"))],
|
||||
("average", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical"), axis("both", "Both")],
|
||||
("reflect" | "shear", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical")],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed.
|
||||
pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool {
|
||||
text_edit(ui, d, key, width).changed()
|
||||
@@ -111,30 +139,49 @@ pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
/// A dropdown row bound to the string `d.fields[key]`: `label` in a column `widths.0` wide, the
|
||||
/// dropdown `widths.1` wide; `options` are (value, label).
|
||||
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) {
|
||||
crate::widgets::label_row(ui, label, widths.0, |ui| dropdown(ui, d, key, widths.1, options, false));
|
||||
}
|
||||
|
||||
/// A dropdown `width` wide bound to the string `d.fields[key]`; `options` are (value, label), the
|
||||
/// labels shown as they are when `translated` (else the dropdown translates them). A value that
|
||||
/// isn't among them shows as it is.
|
||||
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: &[(&str, &str)], translated: bool) {
|
||||
let cur = d.str(key);
|
||||
let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l);
|
||||
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
|
||||
crate::widgets::label_row(ui, label, widths.0, |ui| {
|
||||
if let Some((value, _)) = crate::widgets::dropdown(ui, key, shown, &labels, widths.1).and_then(|i| options.get(i)) {
|
||||
d.fields.insert(key.into(), json!(value));
|
||||
}
|
||||
});
|
||||
let chosen = if translated {
|
||||
crate::widgets::dropdown_names(ui, key, shown, &labels, width)
|
||||
} else {
|
||||
crate::widgets::dropdown(ui, key, shown, &labels, width)
|
||||
};
|
||||
if let Some((value, _)) = chosen.and_then(|i| options.get(i)) {
|
||||
d.fields.insert(key.into(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
/// The generic dialog body: a field per non-boolean value (positions and indices hidden), the
|
||||
/// distances in `unit`.
|
||||
/// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys
|
||||
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns.
|
||||
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
let lengths = lengths(&d.kind);
|
||||
let (lengths, first) = (lengths(&d.kind), order(&d.kind));
|
||||
let mut keys: Vec<String> = d
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|(k, v)| !matches!(k.as_str(), "x" | "y" | "origin" | "index") && !k.starts_with("__") && !v.is_boolean())
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
// Stable: the fields `order` doesn't name keep their order after the ones it does.
|
||||
keys.sort_by_key(|k| first.iter().position(|f| f == k).unwrap_or(first.len()));
|
||||
egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
for (k, v) in keys {
|
||||
if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() {
|
||||
continue;
|
||||
}
|
||||
for k in keys {
|
||||
let options = choices(&d.kind, &k);
|
||||
if lengths.contains(&k.as_str()) {
|
||||
length_field(ui, d, &k, &humanized(&k), unit);
|
||||
} else {
|
||||
} else if options.is_empty() {
|
||||
field(ui, d, &k, &humanized(&k));
|
||||
} else {
|
||||
row_label(ui, &humanized(&k));
|
||||
dropdown(ui, d, &k, FIELD_W, &options, true);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -145,10 +192,19 @@ pub(super) fn params(d: &Dialog) -> Value {
|
||||
Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
}
|
||||
|
||||
/// The (label, value) choices of a parameter that picks one of some values.
|
||||
pub(super) type Choices = &'static [(&'static str, &'static str)];
|
||||
|
||||
/// Generic editor for command/effect parameters: numbers, booleans, strings and colours; the
|
||||
/// parameters `is_length` names are distances shown and typed in `unit`. Returns true when a
|
||||
/// value changed.
|
||||
pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn(&str) -> bool, unit: Unit) -> bool {
|
||||
/// parameters `is_length` names are distances shown and typed in `unit`, those `choices` gives
|
||||
/// choices for are dropdowns. Returns true when a value changed.
|
||||
pub(super) fn param_fields(
|
||||
ui: &mut egui::Ui,
|
||||
d: &mut Dialog,
|
||||
is_length: &dyn Fn(&str) -> bool,
|
||||
choices: &dyn Fn(&str) -> Option<Choices>,
|
||||
unit: Unit,
|
||||
) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut changed = false;
|
||||
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
@@ -169,6 +225,17 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
if let Some(options) = choices(&k) {
|
||||
let cur = v.as_str().unwrap_or_default();
|
||||
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| *l);
|
||||
let labels: Vec<&str> = options.iter().map(|(l, _)| *l).collect();
|
||||
if let Some((_, value)) = crate::widgets::dropdown(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
|
||||
d.fields.insert(k, json!(value));
|
||||
changed = true;
|
||||
}
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
let mut x = n.as_f64().unwrap_or(0.0);
|
||||
@@ -271,6 +338,7 @@ pub(super) fn humanize(k: &str) -> String {
|
||||
"Radius1" => "Radius 1".into(),
|
||||
"Radius2" => "Radius 2".into(),
|
||||
"Include Cmy Blacks" => "Include Blacks with CMY:".into(),
|
||||
"Align To Path" => "Align to Path:".into(),
|
||||
_ => format!("{s}:"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,31 @@ fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str, enabled: boo
|
||||
changed
|
||||
}
|
||||
|
||||
/// The size of the colour chip before each name in the Color list.
|
||||
const CHIP: egui::Vec2 = egui::vec2(16.0, 10.0);
|
||||
|
||||
/// The Color list: each preset's name after a chip of its colour, then Custom (no chip, its
|
||||
/// name aligned with the others). Returns the chosen row; Custom is `LAYER_COLORS.len()`.
|
||||
fn color_dropdown(ui: &mut egui::Ui, shown: &str) -> Option<usize> {
|
||||
let border = crate::theme::Tokens::get(ui.ctx()).input_border;
|
||||
let rows = LAYER_COLORS.iter().map(|(n, c)| (*n, Some(*c))).chain([("Custom", None)]);
|
||||
widgets::combo(ui, "layer-color", tl!(shown), 140.0, false, |ui| {
|
||||
let mut chosen = None;
|
||||
for (i, (name, rgb)) in rows.enumerate() {
|
||||
let chip = ui.id().with(("layer-color-chip", i));
|
||||
let r = egui::Button::selectable(name == shown, (egui::Atom::custom(chip, CHIP), tl!(name))).atom_ui(ui);
|
||||
if let (Some(rect), Some([cr, cg, cb])) = (r.rect(chip), rgb) {
|
||||
ui.painter().rect_filled(rect, 1.0, egui::Color32::from_rgb(cr, cg, cb));
|
||||
ui.painter().rect_stroke(rect, 1.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Inside);
|
||||
}
|
||||
if r.response.clicked() {
|
||||
chosen = Some(i);
|
||||
}
|
||||
}
|
||||
chosen
|
||||
})
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let layer = d.bool("layer");
|
||||
let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1);
|
||||
@@ -116,9 +141,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
label(ui, tl!("Color:"));
|
||||
ui.horizontal(|ui| {
|
||||
let cur = d.str("color");
|
||||
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
|
||||
let shown = if LAYER_COLORS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&cur)) { cur.clone() } else { "Custom".into() };
|
||||
if let Some((n, _)) = widgets::dropdown(ui, "layer-color", &shown, &names, 140.0).and_then(|i| LAYER_COLORS.get(i)) {
|
||||
let shown = LAYER_COLORS.iter().map(|(n, _)| *n).find(|n| n.eq_ignore_ascii_case(&cur)).unwrap_or("Custom");
|
||||
if let Some((n, _)) = color_dropdown(ui, shown).and_then(|i| LAYER_COLORS.get(i)) {
|
||||
d.fields.insert("color".into(), json!(n));
|
||||
}
|
||||
let [r, g, b] = LAYER_COLORS
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod layer_options;
|
||||
pub mod layers_panel_options;
|
||||
pub mod liquify;
|
||||
pub mod missing_links;
|
||||
pub(crate) mod modal;
|
||||
pub mod new_color_group;
|
||||
mod new_document;
|
||||
pub mod new_swatch;
|
||||
@@ -99,7 +100,6 @@ pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset};
|
||||
pub use tools::open_tool_dialog;
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
type DialogResult = Result<Value, String>;
|
||||
@@ -342,62 +342,48 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if let Some(window) = spec.window {
|
||||
return window(app, ctx);
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
let mut discard = false;
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
let heading = (spec.heading)(&d);
|
||||
egui::Window::new(heading.as_str())
|
||||
// One window per kind, so a dialog never inherits another dialog's size.
|
||||
.id(egui::Id::new(("dialog", d.kind.as_str())))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(MARGIN)))
|
||||
.show(ctx, |ui| {
|
||||
// Never wider than the window (a large UI scale in a small window): the text wraps.
|
||||
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
|
||||
ui.set_min_width(spec.min_width.min(room));
|
||||
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
|
||||
ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
// Just opened: its first field takes the keyboard focus, as in the reference app (type a
|
||||
// value, press Enter). Only the body's fields can take it.
|
||||
if focus_first {
|
||||
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
|
||||
modal::show(ctx, &heading, egui::Id::new(("dialog", d.kind.as_str())), -40.0, MARGIN, |ui| {
|
||||
// Never wider than the window (a large UI scale in a small window): the text wraps.
|
||||
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
|
||||
ui.set_min_width(spec.min_width.min(room));
|
||||
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
|
||||
modal::heading(ui, &heading);
|
||||
ui.add_space(12.0);
|
||||
// Just opened: its first field takes the keyboard focus, as in the reference app (type a
|
||||
// value, press Enter). Only the body's fields can take it.
|
||||
if focus_first {
|
||||
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
|
||||
}
|
||||
cancel = (spec.body)(app, ui, &mut d);
|
||||
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
|
||||
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
|
||||
ui.data_mut(|m| m.remove::<bool>(focus_id));
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
// The button row is as wide as the fields above it and as tall as the buttons: a
|
||||
// right-to-left layout would otherwise take all the room left in the window, so the
|
||||
// window could never shrink to its content.
|
||||
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
|
||||
&& widgets::primary_button(ui, label).clicked()
|
||||
{
|
||||
ok = true;
|
||||
}
|
||||
cancel = (spec.body)(app, ui, &mut d);
|
||||
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
|
||||
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
|
||||
ui.data_mut(|m| m.remove::<bool>(focus_id));
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
if let Some(label) = spec.discard {
|
||||
ui.add_space(28.0);
|
||||
discard = widgets::secondary_button(ui, label).clicked();
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
// The button row is as wide as the fields above it and as tall as the buttons: a
|
||||
// right-to-left layout would otherwise take all the room left in the window, so the
|
||||
// window could never shrink to its content.
|
||||
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
|
||||
&& widgets::primary_button(ui, label).clicked()
|
||||
{
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
if let Some(label) = spec.discard {
|
||||
ui.add_space(28.0);
|
||||
discard = widgets::secondary_button(ui, label).clicked();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if spec.ok.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
ok = true;
|
||||
}
|
||||
@@ -482,3 +468,6 @@ mod tests_scale;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_perspective;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_modal;
|
||||
@@ -0,0 +1,100 @@
|
||||
//! The modal dialog window: a backdrop that keeps the rest of the app from taking clicks, and a
|
||||
//! window with no title bar that opens centred and moves by dragging its heading (the band from its
|
||||
//! top edge down to the heading), as a title bar would. Where it was moved to stays, per window, for
|
||||
//! the session, and the window never leaves the screen.
|
||||
|
||||
use egui::{Id, Rect, Vec2};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// Show the modal dialog window `id` (`title` is its accessible name), `nudge` points below the
|
||||
/// screen's centre until it is moved, with `margin` inside its frame. `add` draws the contents and
|
||||
/// should start with [`heading`] (or [`drag_band`]) so the window can be moved.
|
||||
pub(crate) fn show<R>(ctx: &egui::Context, title: &str, id: Id, nudge: f32, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Area::new(Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::Pos2::ZERO).show(ctx, |ui| {
|
||||
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(title)
|
||||
.id(id)
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, nudge))
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
|
||||
.show(ctx, add)
|
||||
.and_then(|r| r.inner)
|
||||
}
|
||||
|
||||
/// The dialog's heading, which also moves the window ([`drag_band`]).
|
||||
pub(crate) fn heading(ui: &mut egui::Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Not selectable: a drag on it moves the window instead of selecting its text.
|
||||
let r = ui.add(egui::Label::new(egui::RichText::new(text).font(theme::semibold(16.0)).color(t.text)).selectable(false));
|
||||
drag_band(ui, r.rect.bottom());
|
||||
}
|
||||
|
||||
/// Make the band of the dialog window [`show`] draws `ui` in, from its top edge down to `bottom`, a
|
||||
/// handle that moves the window. Widgets added later on the band stay clickable (they sit above it).
|
||||
pub(crate) fn drag_band(ui: &mut egui::Ui, bottom: f32) {
|
||||
let id = ui.layer_id().id;
|
||||
// Last frame's rectangle: none while the window measures itself the first time it shows.
|
||||
let Some(outer) = ui.ctx().memory(|m| m.area_rect(id)) else { return };
|
||||
let band = Rect::from_x_y_ranges(outer.x_range(), outer.top()..=bottom.max(outer.top()));
|
||||
let r = ui.interact(band, handle(id), egui::Sense::drag());
|
||||
if r.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
} else if r.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
|
||||
/// The drag handle of window `id`.
|
||||
fn handle(id: Id) -> Id {
|
||||
id.with("modal-drag")
|
||||
}
|
||||
|
||||
/// Where window `id` sits, from the screen's centre: `nudge` down until it is first moved. While
|
||||
/// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved
|
||||
/// since the press, kept on screen (applied before the window places itself, so it follows the
|
||||
/// pointer without a frame's lag).
|
||||
fn offset(ctx: &egui::Context, id: Id, nudge: f32) -> Vec2 {
|
||||
let (key, start_key) = (id.with("modal-offset"), id.with("modal-drag-start"));
|
||||
let offset = ctx.data(|m| m.get_temp::<Vec2>(key)).unwrap_or(egui::vec2(0.0, nudge));
|
||||
let dragged = ctx.read_response(handle(id)).is_some_and(|r| r.dragged());
|
||||
let moved = ctx.input(|i| Some(i.pointer.latest_pos()? - i.pointer.press_origin()?));
|
||||
let (true, Some(moved), Some(rect)) = (dragged, moved, ctx.memory(|m| m.area_rect(id))) else {
|
||||
ctx.data_mut(|m| m.remove::<Vec2>(start_key));
|
||||
return offset;
|
||||
};
|
||||
let start = ctx.data_mut(|m| *m.get_temp_mut_or(start_key, offset));
|
||||
let screen = ctx.content_rect();
|
||||
let to = on_screen(Rect::from_center_size(screen.center() + start + moved, rect.size()), screen).center() - screen.center();
|
||||
ctx.data_mut(|m| m.insert_temp(key, to));
|
||||
to
|
||||
}
|
||||
|
||||
/// `r` moved the least to lie inside `screen` (its top left inside when it is larger).
|
||||
fn on_screen(r: Rect, screen: Rect) -> Rect {
|
||||
let x = r.left().min(screen.right() - r.width()).max(screen.left());
|
||||
let y = r.top().min(screen.bottom() - r.height()).max(screen.top());
|
||||
Rect::from_min_size(egui::pos2(x, y), r.size())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_moved_window_stays_on_screen() {
|
||||
let screen = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
let r = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, h));
|
||||
assert_eq!(on_screen(r(100.0, 100.0, 200.0, 100.0), screen), r(100.0, 100.0, 200.0, 100.0));
|
||||
assert_eq!(on_screen(r(-50.0, 550.0, 200.0, 100.0), screen), r(0.0, 500.0, 200.0, 100.0));
|
||||
assert_eq!(on_screen(r(700.0, -20.0, 200.0, 100.0), screen), r(600.0, 0.0, 200.0, 100.0));
|
||||
// Larger than the screen: its top left stays in view.
|
||||
assert_eq!(on_screen(r(-10.0, -10.0, 900.0, 700.0), screen), r(0.0, 0.0, 900.0, 700.0));
|
||||
}
|
||||
}
|
||||
@@ -151,21 +151,9 @@ fn set_layout(d: &mut Dialog, key: &str, v: Value) {
|
||||
d.fields.insert("artboardLayout".into(), l);
|
||||
}
|
||||
|
||||
/// The dimmed backdrop and a centred dialog window in the shared dialog style.
|
||||
/// The New Document dialog window `id` in the shared modal frame.
|
||||
fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(id)
|
||||
.id(egui::Id::new(("dialog", id)))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
|
||||
.show(ctx, add);
|
||||
super::modal::show(ctx, id, egui::Id::new(("dialog", id)), -20.0, margin, add);
|
||||
}
|
||||
|
||||
/// What the buttons of a window asked for.
|
||||
@@ -194,6 +182,9 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
let mut b = Buttons::default();
|
||||
window(ctx, KIND, 0, |ui| {
|
||||
// No heading: the top margin (14) and the empty part of the category tabs' row (30) move the
|
||||
// window.
|
||||
super::modal::drag_band(ui, ui.max_rect().top() + 44.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 22, right: 14, top: 14, bottom: 18 }).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
@@ -489,7 +480,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
const L: f32 = 150.0;
|
||||
window(ctx, MORE, 22, |ui| {
|
||||
ui.set_width(560.0);
|
||||
ui.label(egui::RichText::new(tl!("More Settings")).font(theme::semibold(16.0)).color(t.text));
|
||||
super::modal::heading(ui, tl!("More Settings"));
|
||||
ui.add_space(12.0);
|
||||
widgets::label_row(ui, tl!("Name:"), L, |ui| {
|
||||
form::text(ui, &mut d, "name", 300.0);
|
||||
@@ -533,7 +524,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
set_layout(&mut d, "spacing", json!(v.max(0.0)));
|
||||
}
|
||||
ui.add_space(20.0);
|
||||
ui.label(egui::RichText::new(tl!("Columns:")).color(t.text));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Columns:")).color(t.text));
|
||||
let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn);
|
||||
ui.add_enabled_ui(grid, |ui| {
|
||||
if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Path dialogs: Average, Offset Path, Simplify and Split Into Grid.
|
||||
//! Path dialogs: Average, Offset Path (with Preview), Simplify and Split Into Grid.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{DialogSpec, run_and_close};
|
||||
use super::{DialogSpec, form, run_and_close};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, preview: true, ..DialogSpec::FORM };
|
||||
|
||||
fn title(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
@@ -17,14 +17,28 @@ fn title(kind: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let (id, params) = match d.kind.as_str() {
|
||||
/// The command the dialog runs and its parameters.
|
||||
fn command(d: &Dialog) -> (&'static str, Value) {
|
||||
match d.kind.as_str() {
|
||||
"average" => ("path.average", json!({"axis": d.str("axis")})),
|
||||
"offsetPath" => {
|
||||
("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)}))
|
||||
}
|
||||
"simplify" => ("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
|
||||
_ => ("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
|
||||
};
|
||||
run_and_close(app, id, params)
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
form::grid(ui, d, app.session.general_unit());
|
||||
if d.kind == "offsetPath" {
|
||||
let (id, params) = command(d);
|
||||
form::preview(app, ui, d, "Offset Path", id, params);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let (id, params) = command(d);
|
||||
if d.kind == "offsetPath" { form::commit_preview(app, id, params) } else { run_and_close(app, id, params) }
|
||||
}
|
||||
@@ -813,7 +813,7 @@ fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option
|
||||
if let Some(v) = widgets::plain_field(ui, "sfw-w", f64::from(w), " px", 0, 80.0) {
|
||||
set(d, "width", json!(v.round().max(1.0)));
|
||||
}
|
||||
ui.label(tl!("H:"));
|
||||
widgets::field_label(ui, tl!("H:"));
|
||||
if let Some(v) = widgets::plain_field(ui, "sfw-h", f64::from(h), " px", 0, 80.0) {
|
||||
set(d, "height", json!(v.round().max(1.0)));
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) {
|
||||
choice(ui, d, "images", tl!("Images:"), &IMAGES, None);
|
||||
}
|
||||
choice(ui, d, "objectIds", tl!("Object IDs:"), &OBJECT_IDS, None);
|
||||
ui.label(egui::RichText::new(tl!("Decimal:")).color(t.text_dim));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Decimal:")).color(t.text_dim));
|
||||
let decimals = d.f64("decimals", 3.0);
|
||||
if let Some(v) = widgets::spin_plain(ui, "svg-decimals", decimals, "", 0, 70.0, 1.0, 1.0, &[]) {
|
||||
let (lo, hi) = (*vectorcraft_svg::DECIMALS.start() as f64, *vectorcraft_svg::DECIMALS.end() as f64);
|
||||
|
||||
@@ -146,7 +146,7 @@ pub(super) fn grid<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -
|
||||
|
||||
/// A dimmed field label in the swatch dialogs' grid.
|
||||
pub(super) fn label(ui: &mut egui::Ui, s: &str) {
|
||||
ui.label(egui::RichText::new(s).color(Tokens::get(ui.ctx()).text_dim));
|
||||
crate::widgets::field_label(ui, egui::RichText::new(s).color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
|
||||
/// The Swatch Name row of the grid. Returns true when it changed.
|
||||
|
||||
@@ -4,6 +4,7 @@ use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -9,6 +9,7 @@ use vectorcraft_engine::cmd::fileio;
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use crate::theme;
|
||||
|
||||
pub(super) type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
|
||||
use super::*;
|
||||
use crate::io;
|
||||
use crate::theme;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! #423: modal dialogs move by their heading (and stay on screen and where they were moved), and
|
||||
//! the form dialogs' choice fields are dropdowns (Offset Path's Joins).
|
||||
|
||||
use egui::{Event, Pos2, Rect, Vec2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
const SCREEN: Vec2 = vec2(1280.0, 900.0);
|
||||
|
||||
/// A context drawing the dialog layer in a 1280 × 900 window.
|
||||
fn ctx() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
ctx
|
||||
}
|
||||
|
||||
/// One frame of the dialog layer with `events`: every text drawn, with its screen rect.
|
||||
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<Event>) -> Vec<(String, Rect)> {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
|
||||
texts
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// Drag with the mouse from `from` by `by`, in steps, then let go.
|
||||
fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: Pos2, by: Vec2) {
|
||||
frame(ctx, app, vec![Event::PointerMoved(from), button(from, true)]);
|
||||
for i in 1..=6 {
|
||||
frame(ctx, app, vec![Event::PointerMoved(from + by * (i as f32 / 6.0))]);
|
||||
}
|
||||
frame(ctx, app, vec![button(from + by, false)]);
|
||||
frame(ctx, app, vec![]);
|
||||
}
|
||||
|
||||
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: Pos2) -> Vec<(String, Rect)> {
|
||||
frame(ctx, app, vec![Event::PointerMoved(pos), button(pos, true)]);
|
||||
frame(ctx, app, vec![button(pos, false)]);
|
||||
frame(ctx, app, vec![])
|
||||
}
|
||||
|
||||
/// Frames until a window that just opened has measured and placed itself.
|
||||
fn settle(ctx: &egui::Context, app: &mut VectorcraftApp) {
|
||||
for _ in 0..6 {
|
||||
frame(ctx, app, vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_of(ctx: &egui::Context, id: egui::Id) -> Rect {
|
||||
ctx.memory(|m| m.area_rect(id)).expect("the dialog window was shown")
|
||||
}
|
||||
|
||||
fn app_with_selection() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 60})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn close_to(a: Vec2, b: Vec2) -> bool {
|
||||
(a - b).length() < 1.5
|
||||
}
|
||||
|
||||
/// Opens a dialog.
|
||||
type Open = fn(&mut VectorcraftApp);
|
||||
|
||||
/// Every kind of modal window: the shared frame (Offset Path), and the ones drawing their own
|
||||
/// window (New Document without a heading, Preferences, Keyboard Shortcuts).
|
||||
#[test]
|
||||
fn dialogs_move_by_their_heading_stay_on_screen_and_where_they_were_moved() {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
|
||||
let opens: [(&str, Open, egui::Id); 4] = [
|
||||
("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::new(("dialog", "offsetPath"))),
|
||||
("New Document", open_new_document, egui::Id::new(("dialog", "newDocument"))),
|
||||
("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::new("dialog-preferences")),
|
||||
("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::new("dialog-shortcuts")),
|
||||
];
|
||||
for (name, open, id) in opens {
|
||||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||||
open(&mut app);
|
||||
settle(&ctx, &mut app);
|
||||
let start = rect_of(&ctx, id);
|
||||
// Dragging the heading (the band from the top edge down to it) moves the window.
|
||||
drag(&ctx, &mut app, start.left_top() + vec2(40.0, 10.0), vec2(-90.0, 50.0));
|
||||
let moved = rect_of(&ctx, id);
|
||||
assert!(close_to(moved.min - start.min, vec2(-90.0, 50.0)), "{name}: {start:?} → {moved:?}");
|
||||
// A drag that starts below the heading (in the bottom margin) doesn't.
|
||||
drag(&ctx, &mut app, moved.left_bottom() + vec2(6.0, -4.0), vec2(80.0, -80.0));
|
||||
assert_eq!(rect_of(&ctx, id), moved, "{name}: dragging the body moved it");
|
||||
// Dragged far past the window's edge, it stops at the edge.
|
||||
drag(&ctx, &mut app, moved.left_top() + vec2(40.0, 10.0), vec2(5000.0, 5000.0));
|
||||
let edge = rect_of(&ctx, id);
|
||||
assert!(screen.contains_rect(edge.shrink(0.5)), "{name}: off screen at {edge:?}");
|
||||
assert!((edge.right() - screen.right()).abs() < 1.5 && (edge.bottom() - screen.bottom()).abs() < 1.5, "{name}: {edge:?}");
|
||||
// Closed and opened again (this session): where it was left.
|
||||
app.ui.dialog = None;
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
open(&mut app);
|
||||
settle(&ctx, &mut app);
|
||||
assert!(close_to(rect_of(&ctx, id).min - edge.min, Vec2::ZERO), "{name}: reopened at {:?}, left at {edge:?}", rect_of(&ctx, id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset Path's Joins is a dropdown of Miter, Round and Bevel (it was a text field), listed after
|
||||
/// Offset; choosing Round runs the command with round joins.
|
||||
#[test]
|
||||
fn offset_path_joins_is_a_dropdown() {
|
||||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
let texts = frame(&ctx, &mut app, vec![]);
|
||||
let at = |texts: &[(String, Rect)], s: &str| texts.iter().find(|(t, _)| t == s).map(|(_, r)| *r);
|
||||
let (offset, joins, miter) = (at(&texts, "Offset:").unwrap(), at(&texts, "Joins:").unwrap(), at(&texts, "Miter Limit:").unwrap());
|
||||
assert!(offset.top() < joins.top() && joins.top() < miter.top(), "Offset, Joins, Miter Limit: {texts:?}");
|
||||
let current = at(&texts, "Miter").expect("Joins shows its choice");
|
||||
assert!(at(&texts, "miter").is_none(), "Joins is a text field: {texts:?}");
|
||||
// The list: Miter, Round, Bevel.
|
||||
let open = click(&ctx, &mut app, current.center());
|
||||
for choice in ["Round", "Bevel"] {
|
||||
assert!(at(&open, choice).is_some(), "{choice} isn't listed: {open:?}");
|
||||
}
|
||||
let round = at(&open, "Round").unwrap();
|
||||
click(&ctx, &mut app, round.center());
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["joins"], json!("round"));
|
||||
// OK: the same result as the command with round joins (not miter).
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let made = |app: &VectorcraftApp| {
|
||||
let st = app.session.doc().unwrap();
|
||||
format!("{:?}", st.doc.node(st.selection.objects[0]).unwrap().kind)
|
||||
};
|
||||
let direct = |joins: &str| {
|
||||
let mut app = app_with_selection();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("object.path.offsetPath", json!({"offset": 10, "joins": joins, "miterLimit": 4})).unwrap();
|
||||
made(&app)
|
||||
};
|
||||
assert_eq!(made(&app), direct("round"));
|
||||
assert_ne!(made(&app), direct("miter"));
|
||||
}
|
||||
|
||||
/// The other form dialogs' choices are dropdowns too: Average's and Shear's axis.
|
||||
#[test]
|
||||
fn form_dialog_axes_are_dropdowns() {
|
||||
for (id, shown) in [("path.average", "Both"), ("object.shear", "Horizontal"), ("object.reflect", "Vertical")] {
|
||||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, id, json!({}));
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
let texts = frame(&ctx, &mut app, vec![]);
|
||||
assert!(texts.iter().any(|(t, _)| t == shown), "{id}: {texts:?}");
|
||||
assert!(!texts.iter().any(|(t, _)| *t == shown.to_lowercase()), "{id} has a text field: {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The axis choices are translated in the "axis" context: in Japanese the plain Horizontal and
|
||||
/// Vertical are the type orientations (horizontal and vertical writing).
|
||||
#[test]
|
||||
fn axis_choices_read_as_axes_in_every_complete_language() {
|
||||
use crate::i18n::{LANGUAGES, Lang, tr, tr_ctx};
|
||||
let ja = Lang::from_code("ja").unwrap();
|
||||
assert_eq!(tr_ctx(ja, "axis", "Horizontal"), "水平方向");
|
||||
assert_ne!(tr_ctx(ja, "axis", "Horizontal"), tr(ja, "Horizontal"));
|
||||
for l in LANGUAGES.iter().filter(|l| l.complete_menus) {
|
||||
for s in ["Horizontal", "Vertical", "Both"] {
|
||||
assert!(l.source.lines().any(|line| line.starts_with(&format!("axis\t{s}\t"))), "{}: no axis row for {s}", l.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset Path previews on the canvas; Cancel rolls the preview back.
|
||||
#[test]
|
||||
fn offset_path_previews() {
|
||||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::<usize>();
|
||||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||||
let texts = frame(&ctx, &mut app, vec![]);
|
||||
let texts = if texts.is_empty() { frame(&ctx, &mut app, vec![]) } else { texts };
|
||||
let preview = texts.iter().find(|(t, _)| t == "Preview").map(|(_, r)| *r).expect("a Preview checkbox");
|
||||
click(&ctx, &mut app, preview.center());
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
assert_eq!(count(&app), 2, "previewed");
|
||||
cancel(&mut app);
|
||||
assert_eq!(count(&app), 1, "rolled back");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
/// The save prompt's rect after a few frames in a window of `physical` pixels at UI scale `zoom`.
|
||||
fn prompt(app: &mut VectorcraftApp, ctx: &egui::Context, physical: egui::Vec2, zoom: f32) -> (egui::Rect, egui::Rect) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
use crate::{VectorcraftApp, font_menu};
|
||||
|
||||
@@ -37,77 +37,66 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let sample = font_menu::sample_text(app);
|
||||
let mut close = false;
|
||||
let mut act: Option<&str> = None;
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(tl!("Find Font"))
|
||||
.id(egui::Id::new("dialog-find-font"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
|
||||
.show(ctx, |ui: &mut Ui| {
|
||||
ui.set_width(380.0);
|
||||
ui.label(egui::RichText::new(tl!("Find Font")).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, &crate::i18n::fmt(tl!("Fonts in Document: {count}"), &[("count", &list.len().to_string())]));
|
||||
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_min_height(120.0);
|
||||
ui.set_width(ui.available_width());
|
||||
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
|
||||
for (i, f) in list.iter().enumerate() {
|
||||
let (note, color) = font_note(f);
|
||||
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
|
||||
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
|
||||
if ui.selectable_label(i == sel, text).clicked() {
|
||||
d.fields.insert("selected".into(), json!(i));
|
||||
}
|
||||
crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::new("dialog-find-font"), -40.0, 22, |ui: &mut Ui| {
|
||||
ui.set_width(380.0);
|
||||
crate::dialogs::modal::heading(ui, tl!("Find Font"));
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, &crate::i18n::fmt(tl!("Fonts in Document: {count}"), &[("count", &list.len().to_string())]));
|
||||
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_min_height(120.0);
|
||||
ui.set_width(ui.available_width());
|
||||
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
|
||||
for (i, f) in list.iter().enumerate() {
|
||||
let (note, color) = font_note(f);
|
||||
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
|
||||
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
|
||||
if ui.selectable_label(i == sel, text).clicked() {
|
||||
d.fields.insert("selected".into(), json!(i));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, tl!("Replace With Font"));
|
||||
let fam = d.str("family");
|
||||
ui.horizontal(|ui| {
|
||||
// Picks the font only: nothing is previewed on the document.
|
||||
let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app));
|
||||
if let Some((f, style)) = font_menu::picked(app, pick) {
|
||||
d.fields.insert("family".into(), json!(f));
|
||||
d.fields.insert("style".into(), json!(style.unwrap_or_default()));
|
||||
}
|
||||
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
|
||||
// "(closest)" is ours; the font's style names are shown as they are.
|
||||
let closest = tl!("(closest)");
|
||||
let mut opts: Vec<&str> = vec![closest];
|
||||
opts.extend(styles.iter().map(String::as_str));
|
||||
let style = d.str("style");
|
||||
let cur = if style.is_empty() { closest } else { style.as_str() };
|
||||
if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) {
|
||||
d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() }));
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
let has = sel < list.len();
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Find"))).clicked() {
|
||||
act = Some("find");
|
||||
}
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Change"))).on_hover_text(tl!("In the selected objects")).clicked() {
|
||||
act = Some("change");
|
||||
}
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Change All"))).clicked() {
|
||||
act = Some("changeAll");
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("Done")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, tl!("Replace With Font"));
|
||||
let fam = d.str("family");
|
||||
ui.horizontal(|ui| {
|
||||
// Picks the font only: nothing is previewed on the document.
|
||||
let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app));
|
||||
if let Some((f, style)) = font_menu::picked(app, pick) {
|
||||
d.fields.insert("family".into(), json!(f));
|
||||
d.fields.insert("style".into(), json!(style.unwrap_or_default()));
|
||||
}
|
||||
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
|
||||
// "(closest)" is ours; the font's style names are shown as they are.
|
||||
let closest = tl!("(closest)");
|
||||
let mut opts: Vec<&str> = vec![closest];
|
||||
opts.extend(styles.iter().map(String::as_str));
|
||||
let style = d.str("style");
|
||||
let cur = if style.is_empty() { closest } else { style.as_str() };
|
||||
if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) {
|
||||
d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() }));
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
let has = sel < list.len();
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Find"))).clicked() {
|
||||
act = Some("find");
|
||||
}
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Change"))).on_hover_text(tl!("In the selected objects")).clicked() {
|
||||
act = Some("change");
|
||||
}
|
||||
if ui.add_enabled(has, egui::Button::new(tl!("Change All"))).clicked() {
|
||||
act = Some("changeAll");
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("Done")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
if let (Some(a), Some(from)) = (act, list.get(sel)) {
|
||||
let r = match a {
|
||||
@@ -157,7 +146,7 @@ mod tests {
|
||||
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap();
|
||||
open(&mut app);
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
|
||||
@@ -13,6 +13,92 @@ pub struct Catalog {
|
||||
ids: HashMap<String, String>,
|
||||
/// plural messages: `one|other` → forms
|
||||
plurals: HashMap<String, Vec<String>>,
|
||||
/// status and error messages without placeholders (`@msg`): English → translation
|
||||
messages: HashMap<String, String>,
|
||||
/// status and error messages with `{name}` placeholders (`@msg`), most specific first
|
||||
templates: Vec<Template>,
|
||||
}
|
||||
|
||||
/// A piece of a message template: literal text or a `{name}` placeholder.
|
||||
#[derive(Debug)]
|
||||
enum Piece {
|
||||
Text(String),
|
||||
Hole(String),
|
||||
}
|
||||
|
||||
/// A message template (`Couldn't open {name}: {e}`) and its translation.
|
||||
#[derive(Debug)]
|
||||
struct Template {
|
||||
pieces: Vec<Piece>,
|
||||
/// Bytes of literal text: a template with more of it is the more specific match.
|
||||
literal: usize,
|
||||
translation: String,
|
||||
}
|
||||
|
||||
/// Messages longer than this are shown as they are (template matching backtracks).
|
||||
const MAX_MESSAGE_LEN: usize = 2000;
|
||||
|
||||
impl Template {
|
||||
/// `None` for a template that can't be matched reliably: no literal text, or two placeholders
|
||||
/// in a row (where one ends and the next begins is unknowable).
|
||||
fn parse(source: &str, translation: String) -> Option<Template> {
|
||||
let mut pieces = Vec::new();
|
||||
let mut rest = source;
|
||||
while let Some(a) = rest.find('{') {
|
||||
let after = rest.get(a + 1..)?;
|
||||
let b = after.find('}')?;
|
||||
if a > 0 {
|
||||
pieces.push(Piece::Text(rest.get(..a)?.to_string()));
|
||||
} else if matches!(pieces.last(), Some(Piece::Hole(_))) {
|
||||
return None;
|
||||
}
|
||||
pieces.push(Piece::Hole(after.get(..b)?.to_string()));
|
||||
rest = after.get(b + 1..)?;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
pieces.push(Piece::Text(rest.to_string()));
|
||||
}
|
||||
let literal = pieces.iter().map(|p| if let Piece::Text(t) = p { t.len() } else { 0 }).sum();
|
||||
(literal > 0).then_some(Template { pieces, literal, translation })
|
||||
}
|
||||
|
||||
/// The placeholder values if `s` is an instance of this template.
|
||||
fn captures<'a>(&self, s: &'a str) -> Option<Vec<(&str, &'a str)>> {
|
||||
let mut out = Vec::new();
|
||||
match_pieces(&self.pieces, s, &mut out).then_some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Match `s` against `pieces`, a placeholder taking the shortest non-empty text that lets the
|
||||
/// rest match (the last one takes what remains).
|
||||
fn match_pieces<'p, 'a>(pieces: &'p [Piece], s: &'a str, out: &mut Vec<(&'p str, &'a str)>) -> bool {
|
||||
let Some((first, rest)) = pieces.split_first() else { return s.is_empty() };
|
||||
match first {
|
||||
Piece::Text(t) => s.strip_prefix(t.as_str()).is_some_and(|tail| match_pieces(rest, tail, out)),
|
||||
Piece::Hole(name) => {
|
||||
let Some((next, after)) = rest.split_first() else {
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
out.push((name.as_str(), s));
|
||||
return true;
|
||||
};
|
||||
let Piece::Text(next) = next else { return false };
|
||||
// Every place the next literal occurs, nearest first (the hole is never empty).
|
||||
let mut from = s.chars().next().map_or(1, char::len_utf8);
|
||||
while let Some(at) = s.get(from..).and_then(|tail| tail.find(next.as_str())).map(|i| i + from) {
|
||||
let (Some(value), Some(tail)) = (s.get(..at), s.get(at + next.len()..)) else { return false };
|
||||
let mark = out.len();
|
||||
out.push((name.as_str(), value));
|
||||
if match_pieces(after, tail, out) {
|
||||
return true;
|
||||
}
|
||||
out.truncate(mark);
|
||||
from = at + next.chars().next().map_or(1, char::len_utf8);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A catalog entry as read from the file: (context, source, translation).
|
||||
@@ -74,11 +160,20 @@ impl Catalog {
|
||||
"@plural" => {
|
||||
c.plurals.insert(src, tr.split('|').map(str::to_string).collect());
|
||||
}
|
||||
"@msg" => {
|
||||
if src.contains('{') {
|
||||
c.templates.extend(Template::parse(&src, tr));
|
||||
} else {
|
||||
c.messages.insert(src, tr);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
c.contextual.insert(format!("{ctx}\u{1}{src}"), tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`.
|
||||
c.templates.sort_by_key(|t| std::cmp::Reverse(t.literal));
|
||||
c
|
||||
}
|
||||
|
||||
@@ -94,6 +189,19 @@ impl Catalog {
|
||||
self.ids.get(id).map(String::as_str)
|
||||
}
|
||||
|
||||
/// A status or error message: an exact `@msg` entry, else the first template it is an instance
|
||||
/// of, with the placeholder values filled in. The values are returned for the caller to
|
||||
/// translate (a reason after `…: {e}` is often a message of its own).
|
||||
pub fn message<'a>(&self, s: &'a str) -> Option<(&str, Vec<(&str, &'a str)>)> {
|
||||
if let Some(tr) = self.messages.get(s) {
|
||||
return Some((tr.as_str(), Vec::new()));
|
||||
}
|
||||
if s.len() > MAX_MESSAGE_LEN {
|
||||
return None;
|
||||
}
|
||||
self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.translation.as_str(), caps)))
|
||||
}
|
||||
|
||||
/// The plural form `index` of the message whose English forms are `one|other`.
|
||||
pub fn plural(&self, one: &str, other: &str, index: usize) -> Option<&str> {
|
||||
let forms = self.plurals.get(&format!("{one}|{other}"))?;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# language names, OpenType, the built-in workspace names and the bracketed perspective grid preset
|
||||
# names.
|
||||
About VectorCraft O aplikaci VectorCraft
|
||||
Align to Path Zarovnat k cestě
|
||||
Join Our Discord Připojit se k našemu Discordu
|
||||
Settings… Nastavení…
|
||||
Language Jazyk
|
||||
@@ -603,3 +604,7 @@
|
||||
Version Verze
|
||||
% of all commits % všech commitů
|
||||
Lines +/− Řádky +/−
|
||||
Drag to move Přetažením přesunete
|
||||
Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů
|
||||
Put back in the toolbar Vrátit do panelu nástrojů
|
||||
Float Tool Group Plovoucí skupina nástrojů
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -11,6 +11,7 @@
|
||||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
|
||||
@@ -182,6 +183,7 @@
|
||||
Artwork Colors Colores de la ilustración
|
||||
As Multiple Assets Como varios recursos
|
||||
As Single Asset Como un solo recurso
|
||||
Ascender Ascendente
|
||||
Ask When Modified Preguntar al modificar
|
||||
Aspect Ratio Proporción
|
||||
Asset Export Exportación de recursos
|
||||
@@ -212,6 +214,7 @@
|
||||
Background: Fondo:
|
||||
Backspace Retroceso
|
||||
Bar Graph Tool Herramienta Gráfico de barras
|
||||
Baseline Línea de base
|
||||
Baseline Shift Desplazamiento de línea de base
|
||||
Basic Latin Latín básico
|
||||
Batch Lote
|
||||
@@ -283,6 +286,7 @@
|
||||
Cap Extremo
|
||||
Cap: Extremo:
|
||||
Cascade Cascada
|
||||
Center Centro
|
||||
Center Artwork Centrar ilustración
|
||||
Center on Artboard Centrar en la mesa de trabajo
|
||||
Center-Justified Tab Tabulación centrada
|
||||
@@ -472,6 +476,8 @@
|
||||
Corners Esquinas
|
||||
Chamfer Chaflán
|
||||
Corners… Esquinas…
|
||||
Descender Descendente
|
||||
Flip Voltear
|
||||
Inverted Round Redondeada invertida
|
||||
Radius: Radio:
|
||||
Round Redondeada
|
||||
@@ -924,6 +930,7 @@
|
||||
Fit Headline Ajustar titular
|
||||
Decrease Font Size Reducir tamaño de fuente
|
||||
Increase Font Size Aumentar tamaño de fuente
|
||||
Spacing Espaciado
|
||||
Step Type Ajustar texto por incrementos
|
||||
Fit in View Encajar en la vista
|
||||
Fit in Window Encajar en la ventana
|
||||
@@ -2708,6 +2715,7 @@
|
||||
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. El texto se convierte en contornos, y los trazos que una línea CAD no puede dibujar se convierten en formas rellenas.
|
||||
Type Object Selection by Path Only Selección de objetos de texto solo por trazado
|
||||
Type on a Path Texto en trazado
|
||||
Type on a Path Options Opciones de texto en trazado
|
||||
Type on a Path Options… Opciones de texto en trazado…
|
||||
Type on a Path Tool Herramienta Texto en trazado
|
||||
Type Orientation Orientación del texto
|
||||
@@ -2880,6 +2888,7 @@
|
||||
Zoom to Selection Zoom a la selección
|
||||
Zoom Tool Herramienta Zoom
|
||||
Zoom with Mouse Wheel Zoom con la rueda del mouse
|
||||
Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando
|
||||
{count} file(s) in {folder}{note}. Show the package folder? {count} archivo(s) en {folder}{note}. ¿Mostrar la carpeta del paquete?
|
||||
{count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s)
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas?
|
||||
@@ -3013,3 +3022,518 @@
|
||||
Version Versión
|
||||
% of all commits % de todos los commits
|
||||
Lines +/− Líneas +/−
|
||||
Joins Uniones
|
||||
Miter Inglete
|
||||
Bevel Bisel
|
||||
Offset Desplazamiento
|
||||
Axis Eje
|
||||
axis Horizontal Horizontal
|
||||
axis Vertical Vertical
|
||||
axis Both Ambos
|
||||
Drag to move Arrastrar para mover
|
||||
Drag to float as a toolbar Arrastrar para flotar como barra de herramientas
|
||||
Put back in the toolbar Devolver a la barra de herramientas
|
||||
Float Tool Group Hacer flotar el grupo de herramientas
|
||||
|
||||
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
|
||||
# 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 BMP files are RGB or grayscale: colorModel rgb or gray los archivos BMP son RGB o en escala de grises: colorModel rgb o gray
|
||||
@msg Blend colors: select at least three objects with filled colours Fusionar colores: selecciona al menos tres objetos con relleno de color
|
||||
@msg Combine Slices: the slices have no bounds Combinar sectores: los sectores no tienen límites
|
||||
@msg Convert to Artboards: selection has no area Convertir en mesas de trabajo: la selección no tiene área
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Convertir en forma: no hay rectángulos ni elipses seleccionados
|
||||
@msg Copied the CSS of {rules} object(s) Se copió el CSS de {rules} objeto(s)
|
||||
@msg Corners edits a live rectangle Esquinas edita un rectángulo interactivo
|
||||
@msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
|
||||
@msg Couldn't open {name}: {e} No se pudo abrir {name}: {e}
|
||||
@msg Couldn't place {_1}: the document it was dropped on was closed No se pudo colocar {_1}: se cerró el documento en el que se soltó
|
||||
@msg Couldn't read {name}: {e} No se pudo leer {name}: {e}
|
||||
@msg Couldn't save recovery data: {e} No se pudieron guardar los datos de recuperación: {e}
|
||||
@msg Create Outlines: select text objects Crear contornos: selecciona objetos de texto
|
||||
@msg Create from Guides: the document has no artboard Crear a partir de guías: el documento no tiene mesa de trabajo
|
||||
@msg Create from Guides: the guides make more than {MAX_PIECES} slices Crear a partir de guías: las guías forman más de {MAX_PIECES} sectores
|
||||
@msg Create from Selection: the selection has no area Crear a partir de la selección: la selección no tiene área
|
||||
@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Recuperación de datos no tiene dónde guardar copias: configura Preferencias → Manejo de archivos → Recuperación de datos → Carpeta (recoveryFolder)
|
||||
@msg Divide Objects Below: select a path Dividir objetos inferiores: selecciona un trazado
|
||||
@msg Divide Objects Below: select exactly one path Dividir objetos inferiores: selecciona exactamente un trazado
|
||||
@msg Downloaded {_1} file(s) Se descargaron {_1} archivo(s)
|
||||
@msg Downloaded {what} Se descargó {what}
|
||||
@msg Expand Appearance: the selection has a basic appearance Expandir apariencia: la selección tiene una apariencia básica
|
||||
@msg Expand: no brush strokes selected Expandir: no hay trazos de pincel seleccionados
|
||||
@msg Expand: no gradient fills Expandir: no hay rellenos de degradado
|
||||
@msg Expand: nothing to expand Expandir: no hay nada que expandir
|
||||
@msg Exported {count} file(s) to {folder} Se exportaron {count} archivo(s) a {folder}
|
||||
@msg Exported {path} Se exportó {path}
|
||||
@msg Flatten Transparency: select objects to flatten Acoplar transparencia: selecciona los objetos que quieres acoplar
|
||||
@msg Flatten Transparency: {e} Acoplar transparencia: {e}
|
||||
@msg Image Trace: the traced image is missing Calco de imagen: falta la imagen calcada
|
||||
@msg Imported {what}: {_1} Se importó {what}: {_1}
|
||||
@msg Installed plug-in {_1} Se instaló el plugin {_1}
|
||||
@msg Internal error: {msg} (please report this bug) Error interno: {msg} (por favor, informa de este error)
|
||||
@msg Live Paint Merge: select a Live Paint group and the paths to add Combinar pintura interactiva: selecciona un grupo de pintura interactiva y los trazados que quieres agregar
|
||||
@msg Live Paint: select paths or compound paths Pintura interactiva: selecciona trazados o trazados compuestos
|
||||
@msg Live Paint: these paths are too degenerate to paint Pintura interactiva: estos trazados están demasiado degenerados para pintarlos
|
||||
@msg Make Guides: select paths Crear guías: selecciona trazados
|
||||
@msg No saved selections (Select → Save Selection…) No hay selecciones guardadas (Selección → Guardar selección…)
|
||||
@msg No saved views (View → New View…) No hay vistas guardadas (Ver → Nueva vista…)
|
||||
@msg Offset Path: select paths Desplazar trazado: selecciona trazados
|
||||
@msg Opened with {_1} note(s): {_2} Se abrió con {_1} nota(s): {_2}
|
||||
@msg Opened {url} Se abrió {url}
|
||||
@msg Outline Stroke: no stroked paths selected Contornear trazo: no hay trazados con trazo seleccionados
|
||||
@msg PDF settings are not an object los ajustes de PDF no son un objeto
|
||||
@msg PDF writer error: {0} error del escritor de PDF: {0}
|
||||
@msg Packaged {n} file(s) in {folder}{note} Se empaquetaron {n} archivo(s) en {folder}{note}
|
||||
@msg Packaged {n} file(s){note} Se empaquetaron {n} archivo(s){note}
|
||||
@msg Preview in Browser needs the desktop app (the web downloads files instead) Previsualizar en el navegador requiere la aplicación de escritorio (la versión web descarga los archivos)
|
||||
@msg Release Guides: no guides Soltar guías: no hay guías
|
||||
@msg Release: no layer to put the rectangle in Soltar: no hay ninguna capa donde poner el rectángulo
|
||||
@msg Replace Spine: select a blend and a path Reemplazar eje: selecciona una fusión y un trazado
|
||||
@msg SVG parse error: {0} error al analizar el SVG: {0}
|
||||
@msg Save for Web: the image is empty Guardar para web: la imagen está vacía
|
||||
@msg Save writes {_1}, not {_2}: use document.export for other formats Guardar escribe {_1}, no {_2}: usa document.export para otros formatos
|
||||
@msg Saved recovery data for {title} Se guardaron los datos de recuperación de {title}
|
||||
@msg Saved to User Defined: {_1} Se guardó en Definido por el usuario: {_1}
|
||||
@msg Saved {path} Se guardó {path}
|
||||
@msg Select text or click into text with the Type tool to insert glyphs Selecciona texto o haz clic dentro del texto con la herramienta Texto para insertar glifos
|
||||
@msg Shape Builder: no region under the pointer Generador de formas: no hay ninguna región bajo el puntero
|
||||
@msg Shape Builder: select paths or compound paths Generador de formas: selecciona trazados o trazados compuestos
|
||||
@msg Show All Hidden Attributes: nothing is hidden Mostrar todos los atributos ocultos: no hay nada oculto
|
||||
@msg Split Into Grid: select paths large enough for the grid Dividir en cuadrícula: selecciona trazados lo bastante grandes para la cuadrícula
|
||||
@msg Targa files are RGB: colorModel rgb los archivos Targa son RGB: colorModel rgb
|
||||
@msg Text Wrap: select a wrap object Ceñir texto: selecciona un objeto para ceñir
|
||||
@msg `aspect` must be a number (%) `aspect` debe ser un número (%)
|
||||
@msg `at` must be [x, y] `at` debe ser [x, y]
|
||||
@msg `color` must be a colour `color` debe ser un color
|
||||
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` debe ser un comando cuyo elemento de menú abra un cuadro de diálogo (consulta ui.menuDialog)
|
||||
@msg `freeform.points` must be an array `freeform.points` debe ser una lista
|
||||
@msg `gradient` must be an object `gradient` debe ser un objeto
|
||||
@msg `library` is a library id or name (see {list}) `library` es el id o el nombre de una biblioteca (consulta {list})
|
||||
@msg `mode` applies to freeform gradients `mode` se aplica a los degradados de forma libre
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5
|
||||
@msg `stops` must be an array `stops` debe ser una lista
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` debe ser un objeto con opciones de SVG (consulta document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` no es un comando de guardado
|
||||
@msg `{area}` is no recovery area name `{area}` no es el nombre de un área de recuperación
|
||||
@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` no es un color (#rrggbb o el nombre de un preajuste)
|
||||
@msg `{id}` has no envelope dialog `{id}` no tiene cuadro de diálogo de envolvente
|
||||
@msg `{keep}` is not a side of this axis `{keep}` no es un lado de este eje
|
||||
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` debe ser `auto` o un código de idioma como `en` o `zh-hant`
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` debe ser un color #rrggbb
|
||||
@msg `{key}` must be a number `{key}` debe ser un número
|
||||
@msg `{key}` must be a string `{key}` debe ser un texto
|
||||
@msg `{key}` must be a whole number `{key}` debe ser un número entero
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` debe ser un número entero entre {min} y {max} (se recibió {x})
|
||||
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` debe estar entre {min} y {max} (se recibió {x})
|
||||
@msg `{key}` must be one of {_1} `{key}` debe ser uno de estos: {_1}
|
||||
@msg `{key}` must be true or false `{key}` debe ser true o false
|
||||
@msg `{k}` must be [x, y] `{k}` debe ser [x, y]
|
||||
@msg `{name}` has no options `{name}` no tiene opciones
|
||||
@msg `{name}` is a built-in workspace; choose another name `{name}` es un espacio de trabajo integrado; elige otro nombre
|
||||
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` es un color de cuatricromía: los matices se aplican a colores globales y planos
|
||||
@msg `{name}` is no recovery entry name `{name}` no es el nombre de una entrada de recuperación
|
||||
@msg `{name}` isn't a colour swatch `{name}` no es una muestra de color
|
||||
@msg `{path}` must be a bool or an object of bools `{path}` debe ser un booleano o un objeto de booleanos
|
||||
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` no es un color (#rrggbb o el nombre de un preajuste)
|
||||
@msg `{tool}` isn't a Liquify tool `{tool}` no es una herramienta de Licuar
|
||||
@msg `{tool}` isn't a freehand tool `{tool}` no es una herramienta de dibujo a mano alzada
|
||||
@msg `{to}` is not an available name `{to}` no es un nombre disponible
|
||||
@msg a bitmap is empty un mapa de bits está vacío
|
||||
@msg a bitmap is too large to read un mapa de bits es demasiado grande para leerlo
|
||||
@msg a colour index is 0 to {_1}, not {n} un índice de color va de 0 a {_1}, no {n}
|
||||
@msg a colour is an index, #rrggbb or a preset name, not {v} un color es un índice, #rrggbb o el nombre de un preajuste, no {v}
|
||||
@msg a document needs at least one artboard un documento necesita al menos una mesa de trabajo
|
||||
@msg a document needs at least one layer un documento necesita al menos una capa
|
||||
@msg a freeform gradient keeps at least one point un degradado de forma libre conserva al menos un punto
|
||||
@msg a gradient needs at least two stops un degradado necesita al menos dos paradas
|
||||
@msg a graphic style named `{to}` already exists ya existe un estilo gráfico llamado `{to}`
|
||||
@msg a page is missing falta una página
|
||||
@msg a page out of range una página fuera de rango
|
||||
@msg a page tree kid isn't a reference un hijo del árbol de páginas no es una referencia
|
||||
@msg a page tree node has no kids un nodo del árbol de páginas no tiene hijos
|
||||
@msg a page's placement is not finite la posición de una página no es finita
|
||||
@msg a preset named `{_1}` exists ya existe un preajuste llamado `{_1}`
|
||||
@msg a slice size must be a positive number of points el tamaño de un sector debe ser un número positivo de puntos
|
||||
@msg a stream runs past the end un flujo se extiende más allá del final
|
||||
@msg a stream's length is wrong la longitud de un flujo es incorrecta
|
||||
@msg a stream's length isn't a number la longitud de un flujo no es un número
|
||||
@msg add a format to export agrega un formato para exportar
|
||||
@msg already editing a pattern ya se está editando un motivo
|
||||
@msg an object can't be read no se puede leer un objeto
|
||||
@msg an object in perspective would leave the edited grid un objeto en perspectiva saldría de la cuadrícula editada
|
||||
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type
|
||||
@msg artboard {0} does not exist la mesa de trabajo {0} no existe
|
||||
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard a esta escala el dibujo mide {w} × {h} pt, más de lo que cabe en el lienzo: elige una escala menor o ajústalo a la mesa de trabajo
|
||||
@msg background {other}: transparent, white, black or a colour such as "#ff8800" fondo {other}: transparent, white, black o un color como "#ff8800"
|
||||
@msg bad color {v} color no válido {v}
|
||||
@msg bad colour `{hex}` (#rrggbb) color no válido `{hex}` (#rrggbb)
|
||||
@msg bad page tree árbol de páginas no válido
|
||||
@msg bad shortcut atajo no válido
|
||||
@msg bad stream length longitud de flujo no válida
|
||||
@msg bad trailer trailer no válido
|
||||
@msg balance must be between 0 and 100 el equilibrio debe estar entre 0 y 100
|
||||
@msg batch step {_1} (`{id}`) failed: {e} falló el paso {_1} del lote (`{id}`): {e}
|
||||
@msg brightness must be dark|mediumDark|mediumLight|light el brillo debe ser dark|mediumDark|mediumLight|light
|
||||
@msg can't encode the document: {0} no se puede codificar el documento: {0}
|
||||
@msg can't parse shortcut `{c}` no se puede interpretar el atajo `{c}`
|
||||
@msg can't parse shortcut `{s}` for `{k}` no se puede interpretar el atajo `{s}` de `{k}`
|
||||
@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} no se puede guardar la versión {0}: VectorCraft escribe las versiones {MIN_VERSION} a {VERSION}
|
||||
@msg can't write .{ext} images no se pueden escribir imágenes .{ext}
|
||||
@msg cannot distort a zero-size bounding box no se puede distorsionar un cuadro delimitador de tamaño cero
|
||||
@msg cannot read PDF: {0} no se puede leer el PDF: {0}
|
||||
@msg cannot split an open path at its end point no se puede dividir un trazado abierto en su punto final
|
||||
@msg check an asset to export marca un recurso para exportar
|
||||
@msg choice must be "merge" or "add", not `{choice}` choice debe ser "merge" o "add", no `{choice}`
|
||||
@msg choose a direction to divide in elige una dirección en la que dividir
|
||||
@msg choose a folder to export to elige una carpeta a la que exportar
|
||||
@msg choose a location for the package elige una ubicación para el paquete
|
||||
@msg clipboard is empty el portapapeles está vacío
|
||||
@msg collapsed must be true or false collapsed debe ser true o false
|
||||
@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` no es un color como "#ff8800"
|
||||
@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: como máximo {MAX_TABLE} colores
|
||||
@msg command `{0}` is not available right now: {1} el comando `{0}` no está disponible ahora: {1}
|
||||
@msg compound paths can only contain paths los trazados compuestos solo pueden contener trazados
|
||||
@msg compressed (RLE) bitmaps can't be read yet todavía no se pueden leer mapas de bits comprimidos (RLE)
|
||||
@msg damaged dañado
|
||||
@msg decimals must be {_1}–{_2} (got {_3}) los decimales deben ser {_1}–{_2} (se recibió {_3})
|
||||
@msg divide into 1 to {MAX_PIECES} slices divide en 1 a {MAX_PIECES} sectores
|
||||
@msg effect {k} is `{_1}`, not `{id}` el efecto {k} es `{_1}`, no `{id}`
|
||||
@msg empty brush stroke trazo de pincel vacío
|
||||
@msg empty cutter cortador vacío
|
||||
@msg empty path trazado vacío
|
||||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versión de archivo {0} es más reciente de lo que admite este VectorCraft ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido
|
||||
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
|
||||
@msg give corners 0–3 indica esquinas de 0 a 3
|
||||
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
|
||||
@msg guides are locked las guías están bloqueadas
|
||||
@msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió
|
||||
@msg image data `{_1}` is missing faltan los datos de imagen `{_1}`
|
||||
@msg image would be too large; lower the resolution la imagen sería demasiado grande; baja la resolución
|
||||
@msg index must be a number or first, previous, next or last index debe ser un número o first, previous, next o last
|
||||
@msg index must be a number or first, previous, next or last, not `{other}` index debe ser un número o first, previous, next o last, no `{other}`
|
||||
@msg installing from a path is not available on the web; pass the module as `dataBase64` en la web no se puede instalar desde una ruta; pasa el módulo como `dataBase64`
|
||||
@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) error interno en `{cmd}`: {msg} (el documento quedó como estaba; por favor, informa de este error)
|
||||
@msg internal error: {msg} (please report this bug) error interno: {msg} (por favor, informa de este error)
|
||||
@msg invalid PDF setting: {0} ajuste de PDF no válido: {0}
|
||||
@msg invalid image data for `{0}` datos de imagen no válidos para `{0}`
|
||||
@msg invalid parameters for `{cmd}`: {msg} parámetros no válidos para `{cmd}`: {msg}
|
||||
@msg is not an image VectorCraft reads no es una imagen que VectorCraft pueda leer
|
||||
@msg join needs open paths unir necesita trazados abiertos
|
||||
@msg lang must be one of {_1} lang debe ser uno de estos: {_1}
|
||||
@msg layers can't be wrap objects las capas no pueden ser objetos para ceñir
|
||||
@msg missing `folder` falta `folder`
|
||||
@msg missing `id` (see plugin.list) falta `id` (consulta plugin.list)
|
||||
@msg missing `name` falta `name`
|
||||
@msg missing `tool` falta `tool`
|
||||
@msg missing index falta index
|
||||
@msg multiple windows land with M11.5 las ventanas múltiples llegan con M11.5
|
||||
@msg name the file ponle nombre al archivo
|
||||
@msg no CSS to copy: select objects with CSS of their own no hay CSS que copiar: selecciona objetos con CSS propio
|
||||
@msg no Live Paint edge near that point no hay ningún borde de pintura interactiva cerca de ese punto
|
||||
@msg no Live Paint face at that point no hay ninguna cara de pintura interactiva en ese punto
|
||||
@msg no PNG encoder no hay codificador PNG
|
||||
@msg no active document no hay ningún documento activo
|
||||
@msg no active perspective plane no hay ningún plano de perspectiva activo
|
||||
@msg no anchor points selected no hay puntos de ancla seleccionados
|
||||
@msg no app to open files here aquí no hay ninguna aplicación para abrir archivos
|
||||
@msg no artboard no hay mesa de trabajo
|
||||
@msg no artboard {i} (0-based; the document has {_1}) no existe la mesa de trabajo {i} (contando desde 0; el documento tiene {_1})
|
||||
@msg no artboard {i} (0-based; the document has {count}) no existe la mesa de trabajo {i} (contando desde 0; el documento tiene {count})
|
||||
@msg no artboards named no se indicaron mesas de trabajo
|
||||
@msg no artwork no hay ilustración
|
||||
@msg no blend options no hay opciones de fusión
|
||||
@msg no bounds sin límites
|
||||
@msg no brush named `{name}` no hay ningún pincel llamado `{name}`
|
||||
@msg no catalog no hay catálogo
|
||||
@msg no changes since the last save no hay cambios desde la última vez que se guardó
|
||||
@msg no clock to keep a heartbeat by no hay reloj para mantener la señal de actividad
|
||||
@msg no colour group `{g}` no existe el grupo de colores `{g}`
|
||||
@msg no cross-reference table no hay tabla de referencias cruzadas
|
||||
@msg no current layer no hay capa actual
|
||||
@msg no data no hay datos
|
||||
@msg no dialog open no hay ningún cuadro de diálogo abierto
|
||||
@msg no document no hay documento
|
||||
@msg no document open no hay ningún documento abierto
|
||||
@msg no effect applied yet todavía no se aplicó ningún efecto
|
||||
@msg no effect at index {k} no hay ningún efecto en el índice {k}
|
||||
@msg no end record no hay registro final
|
||||
@msg no file manager here aquí no hay administrador de archivos
|
||||
@msg no file reader no hay lector de archivos
|
||||
@msg no file system here aquí no hay sistema de archivos
|
||||
@msg no file writer no hay escritor de archivos
|
||||
@msg no files to place no hay archivos para colocar
|
||||
@msg no format to export to no hay formato al que exportar
|
||||
@msg no free recovery area no hay ningún área de recuperación libre
|
||||
@msg no gradient stop or freeform point is selected no hay ninguna parada de degradado ni punto de forma libre seleccionado
|
||||
@msg no graphic style `{name}` no existe el estilo gráfico `{name}`
|
||||
@msg no graphic style `{n}` no existe el estilo gráfico `{n}`
|
||||
@msg no interaction in progress no hay ninguna interacción en curso
|
||||
@msg no key object no hay objeto clave
|
||||
@msg no library `{key}` (see {list}) no existe la biblioteca `{key}` (consulta {list})
|
||||
@msg no native format no hay formato nativo
|
||||
@msg no pages no hay páginas
|
||||
@msg no pattern `{name}` no existe el motivo `{name}`
|
||||
@msg no perspective grid preset named `{name}` no hay ningún preajuste de cuadrícula de perspectiva llamado `{name}`
|
||||
@msg no plug-in {0} is installed no hay ningún plugin {0} instalado
|
||||
@msg no plug-in {id} is installed no hay ningún plugin {id} instalado
|
||||
@msg no point {i} (the gradient has {_1}) no existe el punto {i} (el degradado tiene {_1})
|
||||
@msg no preset editor no hay editor de preajustes
|
||||
@msg no previous transform no hay transformación anterior
|
||||
@msg no print preset named `{base}` no hay ningún preajuste de impresión llamado `{base}`
|
||||
@msg no print preset named `{name}` no hay ningún preajuste de impresión llamado `{name}`
|
||||
@msg no print service no hay servicio de impresión
|
||||
@msg no revert command no hay comando para volver a la versión guardada
|
||||
@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) no hay ningún preajuste de PDF guardado llamado `{name}` (los preajustes integrados son de solo lectura: crea uno nuevo a partir de ellos)
|
||||
@msg no saved presets no hay preajustes guardados
|
||||
@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) no hay ningún preajuste de impresión guardado llamado `{name}` ({DEFAULT_PRESET} está protegido: crea uno nuevo a partir de él)
|
||||
@msg no saved profiles no hay perfiles guardados
|
||||
@msg no saved selections no hay selecciones guardadas
|
||||
@msg no saved themes no hay temas guardados
|
||||
@msg no segment {segment} on line {line} no existe el segmento {segment} en la línea {line}
|
||||
@msg no selected slice in the image: select slices, or save all of them no hay ningún sector seleccionado en la imagen: selecciona sectores o guárdalos todos
|
||||
@msg no selection bounds la selección no tiene límites
|
||||
@msg no slice selected no hay ningún sector seleccionado
|
||||
@msg no such action no existe esa acción
|
||||
@msg no such anchor no existe ese punto de ancla
|
||||
@msg no such artboard no existe esa mesa de trabajo
|
||||
@msg no such document no existe ese documento
|
||||
@msg no such layer or object no existe esa capa u objeto
|
||||
@msg no such object {0} no existe el objeto {0}
|
||||
@msg no such perspective grid preset no existe ese preajuste de cuadrícula de perspectiva
|
||||
@msg no such recent file no existe ese archivo reciente
|
||||
@msg no such recent font no existe esa fuente reciente
|
||||
@msg no such saved selection no existe esa selección guardada
|
||||
@msg no such segment no existe ese segmento
|
||||
@msg no such subpath no existe ese subtrazado
|
||||
@msg no such user library no existe esa biblioteca de usuario
|
||||
@msg no such view no existe esa vista
|
||||
@msg no such width point no existe ese punto de anchura
|
||||
@msg no swatch `{name}` no existe la muestra `{name}`
|
||||
@msg no swatch library `{l}` (see swatch.library.list) no existe la biblioteca de muestras `{l}` (consulta swatch.library.list)
|
||||
@msg no symbol instances selected no hay instancias de símbolo seleccionadas
|
||||
@msg no target object has an opacity mask: select one or give `id` ningún objeto de destino tiene máscara de opacidad: selecciona uno o indica `id`
|
||||
@msg no threaded text selected no hay texto enlazado seleccionado
|
||||
@msg no user workspace `{from}` no existe el espacio de trabajo de usuario `{from}`
|
||||
@msg node {_1} is not text el nodo {_1} no es texto
|
||||
@msg not a DXF drawing no es un dibujo DXF
|
||||
@msg not a VectorCraft file: {0} no es un archivo de VectorCraft: {0}
|
||||
@msg not a VectorCraft shortcut set (missing `overrides`) no es un conjunto de atajos de VectorCraft (falta `overrides`)
|
||||
@msg not a graphic style library (format `{_1}`) no es una biblioteca de estilos gráficos (formato `{_1}`)
|
||||
@msg not a stored central record no es un registro central almacenado
|
||||
@msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0}
|
||||
@msg not an EMF or WMF file no es un archivo EMF ni WMF
|
||||
@msg not found no se encontró
|
||||
@msg not in pattern editing mode no está en el modo de edición de motivos
|
||||
@msg nothing encoded no se codificó nada
|
||||
@msg nothing selected no hay nada seleccionado
|
||||
@msg nothing to align no hay nada que alinear
|
||||
@msg nothing to distort no hay nada que distorsionar
|
||||
@msg nothing to export: the document has no visible art (or turn on Use Artboards) no hay nada que exportar: el documento no tiene ilustración visible (o activa Usar mesas de trabajo)
|
||||
@msg nothing to preview no hay nada que previsualizar
|
||||
@msg nothing to print no hay nada que imprimir
|
||||
@msg nothing to redo no hay nada que rehacer
|
||||
@msg nothing to save: the document has no visible art no hay nada que guardar: el documento no tiene ilustración visible
|
||||
@msg nothing to undo no hay nada que deshacer
|
||||
@msg nothing to warp no hay nada que deformar
|
||||
@msg nothing to wrap no hay nada que envolver
|
||||
@msg object {0} cannot have children el objeto {0} no puede tener hijos
|
||||
@msg object {id} is not a path el objeto {id} no es un trazado
|
||||
@msg page size {w} × {h} tamaño de página {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) la página {0} no existe (el PDF tiene {1})
|
||||
@msg paste unavailable pegar no está disponible
|
||||
@msg path is already closed el trazado ya está cerrado
|
||||
@msg paths contain invalid coordinates los trazados contienen coordenadas no válidas
|
||||
@msg paths: give at least one file paths: indica al menos un archivo
|
||||
@msg pattern missing falta el motivo
|
||||
@msg percent must be above 0 and at most 6400, not {p} el porcentaje debe ser mayor que 0 y como máximo 6400, no {p}
|
||||
@msg pick a colour, gradient or pattern to save as a swatch elige un color, degradado o motivo para guardarlo como muestra
|
||||
@msg plane must be left, right or ground plane debe ser left, right o ground
|
||||
@msg plug-in ABI: {0} ABI del plugin: {0}
|
||||
@msg plug-in exceeded its {0} el plugin superó su {0}
|
||||
@msg plug-in failed: {0} el plugin falló: {0}
|
||||
@msg plug-in manifest: {0} manifiesto del plugin: {0}
|
||||
@msg plug-in output: {0} salida del plugin: {0}
|
||||
@msg plug-in parameters: {0} parámetros del plugin: {0}
|
||||
@msg plug-in trapped: {0} el plugin se detuvo con un error: {0}
|
||||
@msg print settings are not an object los ajustes de impresión no son un objeto
|
||||
@msg printing isn't available here aquí no se puede imprimir
|
||||
@msg range `{s}` is empty el rango `{s}` está vacío
|
||||
@msg range `{s}`: `{part}` names no number rango `{s}`: `{part}` no indica ningún número
|
||||
@msg range `{s}`: `{part}` runs backwards rango `{s}`: `{part}` va hacia atrás
|
||||
@msg range `{s}`: `{t}` is not a number from 1 to {count} rango `{s}`: `{t}` no es un número de 1 a {count}
|
||||
@msg recovery copy `{file}`: {e} copia de recuperación `{file}`: {e}
|
||||
@msg resolutions must be between 1 and 2400 ppi las resoluciones deben estar entre 1 y 2400 ppi
|
||||
@msg result would exceed the canvas (coordinates out of range) el resultado excedería el lienzo (coordenadas fuera de rango)
|
||||
@msg scale must be a number from {_1} to {_2}, not {_3} la escala debe ser un número de {_1} a {_2}, no {_3}
|
||||
@msg scale must be a number from {_1} to {_2}, not {s} la escala debe ser un número de {_1} a {_2}, no {s}
|
||||
@msg select (or target) objects whose appearance isn't basic selecciona (o apunta a) objetos cuya apariencia no sea básica
|
||||
@msg select a Live Paint group selecciona un grupo de pintura interactiva
|
||||
@msg select a blend selecciona una fusión
|
||||
@msg select a clip group selecciona un grupo de recorte
|
||||
@msg select a gradient mesh selecciona una malla de degradado
|
||||
@msg select a linked image: an embedded one has no file selecciona una imagen vinculada: una incrustada no tiene archivo
|
||||
@msg select a repeat selecciona una repetición
|
||||
@msg select a stroke with a variable width selecciona un trazo con anchura variable
|
||||
@msg select a symbol instance selecciona una instancia de símbolo
|
||||
@msg select an Image Trace object selecciona un objeto de calco de imagen
|
||||
@msg select an envelope selecciona una envolvente
|
||||
@msg select an image selecciona una imagen
|
||||
@msg select an object in perspective selecciona un objeto en perspectiva
|
||||
@msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear
|
||||
@msg select at least two objects selecciona al menos dos objetos
|
||||
@msg select objects, not layers selecciona objetos, no capas
|
||||
@msg select one live rectangle, or give its `id` selecciona un rectángulo interactivo o indica su `id`
|
||||
@msg select paths to join selecciona los trazados que quieres unir
|
||||
@msg select the art to repeat selecciona la ilustración que quieres repetir
|
||||
@msg select the artwork to warp selecciona la ilustración que quieres deformar
|
||||
@msg select the objects to transform in perspective selecciona los objetos que quieres transformar en perspectiva
|
||||
@msg select two or more graphic styles to merge selecciona dos o más estilos gráficos para combinarlos
|
||||
@msg select type in perspective selecciona texto en perspectiva
|
||||
@msg selection has no bounds la selección no tiene límites
|
||||
@msg shortcut for `{k}` must be a string el atajo de `{k}` debe ser un texto
|
||||
@msg stop {i} needs `offset` la parada {i} necesita `offset`
|
||||
@msg stop {i} needs a valid `color` la parada {i} necesita un `color` válido
|
||||
@msg stop {i}: `{k}` must be a number parada {i}: `{k}` debe ser un número
|
||||
@msg the EMF header is damaged el encabezado EMF está dañado
|
||||
@msg the PDF has no pages el PDF no tiene páginas
|
||||
@msg the PDF is no longer loaded el PDF ya no está cargado
|
||||
@msg the PDF is password-protected: give its password el PDF está protegido con contraseña: indica su contraseña
|
||||
@msg the PDF password is wrong la contraseña del PDF es incorrecta
|
||||
@msg the Save PDF dialog didn't open el cuadro de diálogo Guardar PDF no se abrió
|
||||
@msg the WMF header is damaged el encabezado WMF está dañado
|
||||
@msg the art could not be sampled no se pudo muestrear la ilustración
|
||||
@msg the art has no bounds la ilustración no tiene límites
|
||||
@msg the art is too light for any dots la ilustración es demasiado clara para generar puntos
|
||||
@msg the artboard to fit must be a positive size up to {_1} pt la mesa de trabajo a la que ajustar debe tener un tamaño positivo de hasta {_1} pt
|
||||
@msg the background worker stopped el proceso en segundo plano se detuvo
|
||||
@msg the compressed file unpacks to more than {_1} MB el archivo comprimido ocupa más de {_1} MB al descomprimirse
|
||||
@msg the document has never been saved el documento nunca se guardó
|
||||
@msg the document has no artboard el documento no tiene mesa de trabajo
|
||||
@msg the document has no artboards el documento no tiene mesas de trabajo
|
||||
@msg the document has no patterns el documento no tiene motivos
|
||||
@msg the document has no slices el documento no tiene sectores
|
||||
@msg the document has no symbols el documento no tiene símbolos
|
||||
@msg the drawing has no layout `{_1}` (it has {_2}) el dibujo no tiene la presentación `{_1}` (tiene {_2})
|
||||
@msg the drawing is no longer loaded el dibujo ya no está cargado
|
||||
@msg the drawing is too large to open el dibujo es demasiado grande para abrirlo
|
||||
@msg the export region is empty la región de exportación está vacía
|
||||
@msg the export region is not finite la región de exportación no es finita
|
||||
@msg the export returned unreadable data la exportación devolvió datos ilegibles
|
||||
@msg the export wrote no file la exportación no escribió ningún archivo
|
||||
@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG el archivo solo contiene una página provisional, no su ilustración (se guardó sin compatibilidad con PDF): vuelve a guardarlo con la compatibilidad con PDF activada, o como PDF o SVG
|
||||
@msg the file's preview header points outside the file el encabezado de previsualización del archivo apunta fuera del archivo
|
||||
@msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño
|
||||
@msg the layer has no objects to clip by la capa no tiene objetos con los que recortar
|
||||
@msg the layer or group is locked la capa o el grupo está bloqueado
|
||||
@msg the layer to flatten into is hidden la capa en la que acoplar está oculta
|
||||
@msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad
|
||||
@msg the object has no geometry el objeto no tiene geometría
|
||||
@msg the object has no opacity mask el objeto no tiene máscara de opacidad
|
||||
@msg the object has no stroke el objeto no tiene trazo
|
||||
@msg the object is beyond the plane's horizon el objeto está más allá del horizonte del plano
|
||||
@msg the object isn't in perspective el objeto no está en perspectiva
|
||||
@msg the object isn't on a perspective plane el objeto no está en un plano de perspectiva
|
||||
@msg the object would cross the horizon of the perspective plane el objeto cruzaría el horizonte del plano de perspectiva
|
||||
@msg the objects would leave the grid los objetos saldrían de la cuadrícula
|
||||
@msg the package came back damaged el paquete llegó dañado
|
||||
@msg the pointer is beyond the plane's horizon el puntero está más allá del horizonte del plano
|
||||
@msg the preview image is empty la imagen de previsualización está vacía
|
||||
@msg the print job has no data el trabajo de impresión no tiene datos
|
||||
@msg the scale is not a usable number la escala no es un número utilizable
|
||||
@msg the selected stroke's profile is already listed as {_1} el perfil del trazo seleccionado ya figura como {_1}
|
||||
@msg the selection has no URL (or they differ) la selección no tiene URL (o son distintas)
|
||||
@msg the selection isn't in perspective la selección no está en perspectiva
|
||||
@msg the stroke has no weight el trazo no tiene grosor
|
||||
@msg the thumbnail image is malformed la miniatura está mal formada
|
||||
@msg the top object must be a path, compound path or text object to use as a clipping mask el objeto superior debe ser un trazado, un trazado compuesto o un objeto de texto para usarlo como máscara de recorte
|
||||
@msg the {tool} tool has no options la herramienta {tool} no tiene opciones
|
||||
@msg there are no objects to release no hay objetos que soltar
|
||||
@msg there are no printer settings to open here aquí no hay ajustes de impresora que abrir
|
||||
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({_1}): guárdalo como PDF o SVG en la aplicación que lo creó
|
||||
@msg this format holds one artboard: export several as PDF or with document.exportForScreens este formato admite una sola mesa de trabajo: exporta varias como PDF o con document.exportForScreens
|
||||
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that este es un dibujo DWG, que no se puede leer: guárdalo desde la aplicación de CAD como DXF ASCII y abre ese archivo
|
||||
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG este es un archivo PostScript (un .ai guardado en un formato antiguo o sin compatibilidad con PDF, o un archivo EPS), que todavía no se puede abrir: guárdalo como PDF, .ai compatible con PDF o SVG
|
||||
@msg this is not a PostScript file este no es un archivo PostScript
|
||||
@msg tool shortcuts must be a single key (optionally with Shift) los atajos de herramientas deben ser una sola tecla (opcionalmente con Shift)
|
||||
@msg truncated truncado
|
||||
@msg unknown command `{0}` comando desconocido `{0}`
|
||||
@msg unknown effect `{id}` efecto desconocido `{id}`
|
||||
@msg unknown format formato desconocido
|
||||
@msg unknown format `{f}` (Save writes {_1}) formato desconocido `{f}` (Guardar escribe {_1})
|
||||
@msg unknown freeform mode `{m}` (points, lines) modo de forma libre desconocido `{m}` (points, lines)
|
||||
@msg unknown gradient kind `{k}` (linear, radial, freeform) tipo de degradado desconocido `{k}` (linear, radial, freeform)
|
||||
@msg unknown highlight `{h}` resaltado desconocido `{h}`
|
||||
@msg unknown option `{path}.{k}` opción desconocida `{path}.{k}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpresiones desconocidas `{o}` (preserve, simulate o discard)
|
||||
@msg unknown panel `{raw}` panel desconocido `{raw}`
|
||||
@msg unknown preference `{key}` preferencia desconocida `{key}`
|
||||
@msg unknown preset (one of {_1}) preajuste desconocido (uno de estos: {_1})
|
||||
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preajuste desconocido `{name}` (high, medium, low o uno guardado: consulta flattener.presets.list)
|
||||
@msg unknown preset `{name}` (see flattener.presets.list) preajuste desconocido `{name}` (consulta flattener.presets.list)
|
||||
@msg unknown preset `{name}` (see webExport.presets.list) preajuste desconocido `{name}` (consulta webExport.presets.list)
|
||||
@msg unknown side `{other}` (left|right|top|bottom) lado desconocido `{other}` (left|right|top|bottom)
|
||||
@msg unknown tool `{tool}` herramienta desconocida `{tool}`
|
||||
@msg unknown tool `{t}` herramienta desconocida `{t}`
|
||||
@msg unknown tool or command `{key}` herramienta o comando desconocido `{key}`
|
||||
@msg unknown workspace (one of {_1}) espacio de trabajo desconocido (uno de estos: {_1})
|
||||
@msg unsupported: {0} no compatible: {0}
|
||||
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files los archivos de la versión {0} no se pueden comprimir: solo la versión {COMPRESSED_SINCE} y posteriores abren archivos comprimidos
|
||||
@msg width and height are at least 1 pixel el ancho y el alto son de al menos 1 píxel
|
||||
@msg workspace name can't be empty el nombre del espacio de trabajo no puede estar vacío
|
||||
@msg {C}: no effect at index {from} {C}: no hay ningún efecto en el índice {from}
|
||||
@msg {_1} failed: {e} {_1} falló: {e}
|
||||
@msg {_1} files can be opened but not written (see document.formats) los archivos {_1} se pueden abrir pero no escribir (consulta document.formats)
|
||||
@msg {_1} font families available {_1} familias de fuentes disponibles
|
||||
@msg {_1} is a live effect (Effect › Plug-ins) {_1} es un efecto interactivo (Efecto › Plugins)
|
||||
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
|
||||
@msg {_1}: not saved, so not closed {_1}: no se guardó, así que no se cerró
|
||||
@msg {bpp}-bit bitmaps can't be read no se pueden leer mapas de bits de {bpp} bits
|
||||
@msg {command} didn't create an object {command} no creó ningún objeto
|
||||
@msg {label} produced an empty result {label} produjo un resultado vacío
|
||||
@msg {label}: lost track of a shape's style {label}: se perdió el estilo de una forma
|
||||
@msg {label}: select paths {label}: selecciona trazados
|
||||
@msg {label}: select paths, compound paths, groups or text {label}: selecciona trazados, trazados compuestos, grupos o texto
|
||||
@msg {label}… {label}…
|
||||
@msg {name}: CRC mismatch {name}: el CRC no coincide
|
||||
@msg {name}: no local header {name}: no hay encabezado local
|
||||
@msg {name}: no such recovery entry {name}: no existe esa entrada de recuperación
|
||||
@msg {path} can't be found: relink it first no se encuentra {path}: vuelve a vincularlo primero
|
||||
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: aquí no hay sistema de archivos (pasa dataBase64 para abrir; omite la ruta para obtener dataBase64)
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg Couldn't place {name}: {e} No se pudo colocar {name}: {e}
|
||||
@msg Couldn't paste from the clipboard: {e} No se pudo pegar desde el portapapeles: {e}
|
||||
@msg Saved {path} with {_1} note(s): {first} Se guardó {path} con {_1} nota(s): {first}
|
||||
@msg Exported {what} with {_1} note(s): {first} Se exportó {what} con {_1} nota(s): {first}
|
||||
@msg Exported {what} Se exportó {what}
|
||||
@msg Saved {n} files beside {path} Se guardaron {n} archivos junto a {path}
|
||||
@msg Saving Guardando
|
||||
@msg Saving {_1} Guardando {_1}
|
||||
@msg Exporting {_1} Exportando {_1}
|
||||
@msg Saving recovery data for {_1} Guardando datos de recuperación de {_1}
|
||||
@msg Discarded {_1} recovered document(s) Se descartaron {_1} documento(s) recuperado(s)
|
||||
@msg Restored {_1} recovered document(s): save them to keep them Se restauraron {_1} documento(s) recuperado(s): guárdalos para conservarlos
|
||||
@msg Embedded {n} image(s); {missing} missing: relink them first Se incrustaron {n} imagen(es); faltan {missing}: vuelve a vincularlas primero
|
||||
@msg Embedded {n} image(s) Se incrustaron {n} imagen(es)
|
||||
@msg Relinked {_1} image(s) Se volvieron a vincular {_1} imagen(es)
|
||||
@msg Relinked {n} image(s) Se volvieron a vincular {n} imagen(es)
|
||||
@msg Relinked {n} image(s); not in that folder: {_1} Se volvieron a vincular {n} imagen(es); no están en esa carpeta: {_1}
|
||||
@msg Updated {_1} image(s) Se actualizaron {_1} imagen(es)
|
||||
@msg Unembedded to {_1} Se desincrustó en {_1}
|
||||
@msg Reset {_1} Se restableció {_1}
|
||||
@msg Sent to {_1} Se envió a {_1}
|
||||
@msg {_1} files {_1} archivos
|
||||
@msg The graphics were lost and have been restarted Se perdieron los gráficos y se reiniciaron
|
||||
@msg Loading VectorCraft… Cargando VectorCraft…
|
||||
@msg VectorCraft failed to start: {e} VectorCraft no pudo iniciarse: {e}
|
||||
@msg A browser with WebGPU or WebGL2 is required. Se necesita un navegador con WebGPU o WebGL2.
|
||||
@msg The graphics were lost and couldn't be restarted. Se perdieron los gráficos y no se pudieron reiniciar.
|
||||
@msg Your unsaved changes are kept in this browser: reload the page to get them back. Tus cambios sin guardar se conservan en este navegador: vuelve a cargar la página para recuperarlos.
|
||||
@msg Reload the page to go on (changes not saved yet may be lost). Vuelve a cargar la página para continuar (los cambios que aún no se guardaron podrían perderse).
|
||||
@msg floating must be true or false floating debe ser true o false
|
||||
@msg tool (a tool id) is required se requiere tool (un id de herramienta)
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -11,13 +11,18 @@
|
||||
About VectorCraft VectorCraft について
|
||||
All Scripts すべての文字
|
||||
Any Kind すべての種類
|
||||
Ascender アセンダ
|
||||
Baseline ベースライン
|
||||
Bottom-to-Bottom Leading 欧文ベースライン基準の行送り
|
||||
Center 中央
|
||||
Character Alignment 文字揃え
|
||||
Decorative 装飾
|
||||
Descender ディセンダ
|
||||
Em Box Bottom/Left 仮想ボディの下 / 左
|
||||
Em Box Center 仮想ボディの中央
|
||||
Em Box Top/Right 仮想ボディの上 / 右
|
||||
Favorites お気に入り
|
||||
Flip 反転
|
||||
Japanese 日本語
|
||||
Join Our Discord Discord に参加
|
||||
Line-end Punctuation Half Width 行末約物半角
|
||||
@@ -31,7 +36,9 @@
|
||||
Serif / Mincho 明朝・セリフ
|
||||
Settings… 環境設定…
|
||||
Language 表示言語
|
||||
Spacing 間隔
|
||||
Top-to-Top Leading 仮想ボディの上基準の行送り
|
||||
Type on a Path Options パス上文字オプション
|
||||
UI Brightness UI の明るさ
|
||||
Dark 暗い
|
||||
Medium Dark やや暗い
|
||||
@@ -2553,6 +2560,7 @@
|
||||
Zoom Tool ズームツール
|
||||
Zoom to Selection 選択範囲にズーム
|
||||
Zoom with Mouse Wheel マウスホイールでズーム
|
||||
Scrub Numeric Fields by Dragging ドラッグで数値フィールドを調整
|
||||
[Default] is protected: New… starts an editable copy. [Default] は変更できません。「新規…」で編集できる複製を作ります。
|
||||
[Unnamed] [名称未設定]
|
||||
above (対象:次を超える画像)
|
||||
@@ -3002,3 +3010,15 @@
|
||||
Version バージョン
|
||||
% of all commits 全コミットに占める割合
|
||||
Lines +/− 行数 +/−
|
||||
Joins 角の形状
|
||||
Miter マイター
|
||||
Bevel ベベル
|
||||
Offset オフセット
|
||||
Axis 軸
|
||||
axis Horizontal 水平方向
|
||||
axis Vertical 垂直方向
|
||||
axis Both 両方
|
||||
Drag to move 移動するにはドラッグ
|
||||
Drag to float as a toolbar ドラッグしてツールバーとして浮かせる
|
||||
Put back in the toolbar ツールバーに戻す
|
||||
Float Tool Group ツールグループをフローティング
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -19,6 +19,10 @@
|
||||
//! translation survives rewording of the English text and can differ per command.
|
||||
//! - [`trn`]: plural-aware (`{n}` is filled in). [`fmt`]: fill `{name}` placeholders after [`tr`];
|
||||
//! translators may reorder placeholders freely.
|
||||
//! - [`message`] / [`msg`]: a status or error message, translated only where it is shown. The
|
||||
//! message itself stays English (the control channel, MCP and tests read it); `@msg` catalog
|
||||
//! rows hold whole messages or templates such as `Couldn't open {name}: {e}`, and the values in
|
||||
//! the placeholders are translated in turn (the reason after `: {e}` is often a message too).
|
||||
|
||||
mod catalog;
|
||||
|
||||
@@ -212,16 +216,28 @@ pub fn detect_system_lang_in_background() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The system language, worked out once.
|
||||
static SYSTEM: OnceLock<Lang> = OnceLock::new();
|
||||
|
||||
/// The system language (cached). English when it can't be determined.
|
||||
pub fn system_lang() -> Lang {
|
||||
// Tests drive the UI by its English labels whatever the developer's locale is.
|
||||
if cfg!(test) {
|
||||
return Lang::EN;
|
||||
}
|
||||
static SYSTEM: OnceLock<Lang> = OnceLock::new();
|
||||
*SYSTEM.get_or_init(detect_system_lang)
|
||||
}
|
||||
|
||||
/// Tell the UI the system's preferred languages, most preferred first, where it can't find them
|
||||
/// itself: the web shell passes the browser's (`navigator.languages`). The first supported one is
|
||||
/// the system language (English if none is); call it before the first frame. Has no effect once
|
||||
/// the system language is known.
|
||||
pub fn set_system_locales<S: AsRef<str>>(tags: &[S]) {
|
||||
let lang = tags.iter().find_map(|t| lang_from_tag(t.as_ref())).unwrap_or(Lang::EN);
|
||||
// Already set means already detected (or reported): the first answer stands.
|
||||
let _ = SYSTEM.set(lang);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn detect_system_lang() -> Lang {
|
||||
// `VECTORCRAFT_LOCALE` overrides everything (also handy for screenshots); then the POSIX
|
||||
@@ -277,6 +293,7 @@ fn registry_locale(text: &str) -> Option<String> {
|
||||
text.lines().find(|l| l.contains("LocaleName")).and_then(|l| l.split_whitespace().last()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// The browser's languages arrive through [`set_system_locales`]; without them, English.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn detect_system_lang() -> Lang {
|
||||
Lang::EN
|
||||
@@ -351,6 +368,40 @@ pub fn label_or_name(lang: Lang, s: &str, built_in: bool) -> &str {
|
||||
if built_in { tr(lang, s) } else { s }
|
||||
}
|
||||
|
||||
/// How deep [`message`] translates placeholder values that are messages themselves.
|
||||
const MESSAGE_DEPTH: usize = 3;
|
||||
|
||||
/// Translate a status or error message for display (see the module docs). Unknown messages, and
|
||||
/// parts of them (file names, numbers, a reason with no entry), stay as they are.
|
||||
pub fn message(lang: Lang, s: &str) -> String {
|
||||
message_at(lang, s, MESSAGE_DEPTH)
|
||||
}
|
||||
|
||||
fn message_at(lang: Lang, s: &str, depth: usize) -> String {
|
||||
let Some((template, caps)) = lang.catalog().message(s) else { return s.to_string() };
|
||||
let values: Vec<(&str, String)> =
|
||||
caps.into_iter().map(|(k, v)| (k, if depth > 1 { message_at(lang, v, depth - 1) } else { v.to_string() })).collect();
|
||||
let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
fmt(template, &args)
|
||||
}
|
||||
|
||||
/// [`message`] in the current language, remembering the last answer: the status bar asks for the
|
||||
/// same message every frame.
|
||||
pub fn msg(s: &str) -> String {
|
||||
static LAST: std::sync::Mutex<Option<(&'static str, String, String)>> = std::sync::Mutex::new(None);
|
||||
let lang = current();
|
||||
let mut last = LAST.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((code, src, out)) = last.as_ref()
|
||||
&& *code == lang.code()
|
||||
&& src == s
|
||||
{
|
||||
return out.clone();
|
||||
}
|
||||
let out = message(lang, s);
|
||||
*last = Some((lang.code(), s.to_string(), out.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// [`trn`] in the current language.
|
||||
pub fn tn(n: u64, one: &str, other: &str) -> String {
|
||||
trn(current(), n, one, other)
|
||||
|
||||
@@ -2536,6 +2536,7 @@
|
||||
Zoom Tool Ferramenta Zoom
|
||||
Zoom to Selection Ampliar para a Seleção
|
||||
Zoom with Mouse Wheel Zoom com a Roda do Mouse
|
||||
Scrub Numeric Fields by Dragging Ajustar Campos Numéricos Arrastando
|
||||
[Default] is protected: New… starts an editable copy. [Padrão] é protegido: Novo… inicia uma cópia editável.
|
||||
[Unnamed] [Sem Nome]
|
||||
above acima
|
||||
@@ -2620,3 +2621,7 @@
|
||||
Version Versão
|
||||
% of all commits % de todos os commits
|
||||
Lines +/− Linhas +/−
|
||||
Drag to move Arraste para mover
|
||||
Drag to float as a toolbar Arraste para flutuar como barra de ferramentas
|
||||
Put back in the toolbar Devolver à barra de ferramentas
|
||||
Float Tool Group Flutuar Grupo de Ferramentas
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -601,3 +601,150 @@ fn a_language_saved_by_an_older_version_carries_over() {
|
||||
crate::prefs_dialog::restore(&mut app);
|
||||
assert_eq!(app.session.prefs.interface_language, "cs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_translate_by_template_and_their_parts() {
|
||||
let c = Catalog::parse(concat!(
|
||||
"@msg\tnothing selected\tnada seleccionado\n",
|
||||
"@msg\t{path}: {e}\t{path}: {e}\n",
|
||||
"@msg\tCouldn't open {name}: {e}\tNo se pudo abrir {name}: {e}\n",
|
||||
"@msg\tcommand `{0}` is not available right now: {1}\tel comando `{0}` no está disponible ahora: {1}\n",
|
||||
"@msg\t{a}{b}\tnever\n",
|
||||
"@msg\t{n} of {total}\t{total} contiene {n}\n",
|
||||
));
|
||||
assert_eq!(c.message("nothing selected").map(|m| m.0), Some("nada seleccionado"));
|
||||
// The template with the most literal text wins over the generic `{path}: {e}`.
|
||||
let (tr, caps) = c.message("Couldn't open a: b.svg: damaged").unwrap();
|
||||
assert_eq!(tr, "No se pudo abrir {name}: {e}");
|
||||
assert_eq!(caps, [("name", "a"), ("e", "b.svg: damaged")], "a placeholder takes the shortest text that lets the rest match");
|
||||
assert_eq!(c.message("x of y").unwrap().1, [("n", "x"), ("total", "y")]);
|
||||
assert!(c.message("of y").is_none(), "a placeholder is never empty");
|
||||
assert!(c.message("something else").is_none());
|
||||
assert!(c.message(&"x".repeat(5000)).is_none());
|
||||
|
||||
let es = Lang::from_code("es").unwrap();
|
||||
assert_eq!(message(es, "no such message"), "no such message");
|
||||
// A reason inside a message is translated too, and parts without entries stay as they are.
|
||||
let nested = "command `object.group` is not available right now: nothing selected";
|
||||
let shown = message(es, nested);
|
||||
assert!(shown.contains("`object.group`") && !shown.contains("nothing selected") && !shown.contains("not available"), "{shown}");
|
||||
assert_eq!(message(Lang::EN, nested), nested);
|
||||
}
|
||||
|
||||
/// Each language that translates status and error messages ([`COMPLETE_MESSAGES`]) has an `@msg`
|
||||
/// row for every message in the sources that reach the status bar, so a new one can't ship
|
||||
/// untranslated by accident (`VECTORCRAFT_I18N_DUMP_MESSAGES=<file>` lists them).
|
||||
#[test]
|
||||
fn complete_languages_translate_every_message() {
|
||||
let messages = message_literals();
|
||||
assert!(messages.len() > 300, "scan found only {} messages", messages.len());
|
||||
if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP_MESSAGES") {
|
||||
std::fs::write(path, messages.iter().map(|m| format!("{m}\n")).collect::<String>()).expect("write dump");
|
||||
}
|
||||
for code in COMPLETE_MESSAGES {
|
||||
let lang = Lang::from_code(code).expect("registered");
|
||||
let (entries, _) = parse_entries(lang.0.source);
|
||||
let rows: std::collections::HashSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect();
|
||||
let missing: Vec<_> = messages.iter().filter(|m| !rows.contains(m.as_str())).collect();
|
||||
assert!(missing.is_empty(), "{code}: {} untranslated messages: {missing:#?}", missing.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Languages whose catalogs cover every status and error message.
|
||||
const COMPLETE_MESSAGES: &[&str] = &["es"];
|
||||
|
||||
/// 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"];
|
||||
|
||||
/// Where a message literal starts: a status, an error value, a `thiserror` message.
|
||||
const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Other(", "Err(", "ok_or(", "ok_or_else(|| ", "#[error("];
|
||||
|
||||
/// Messages that are signals, not text for people.
|
||||
const NOT_MESSAGES: &[&str] = &["quit", "cancelled"];
|
||||
|
||||
/// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog
|
||||
/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`).
|
||||
pub fn message_literals() -> std::collections::BTreeSet<String> {
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
let mut stack: Vec<_> = MESSAGE_CRATES.iter().map(|c| crates.join(c).join("src")).collect();
|
||||
while let Some(d) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if path.is_dir() {
|
||||
if name != "tests" {
|
||||
stack.push(path);
|
||||
}
|
||||
} else if name.ends_with(".rs") && !name.starts_with("tests") {
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let code = without_test_module(&text);
|
||||
for marker in MESSAGE_MARKERS {
|
||||
for (at, _) in code.match_indices(marker) {
|
||||
let rest = code[at + marker.len()..].trim_start();
|
||||
let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start);
|
||||
let Some(lit) = rest.strip_prefix('"').and_then(string_literal) else { continue };
|
||||
let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase));
|
||||
if has_word && !NOT_MESSAGES.contains(&lit.as_str()) {
|
||||
out.insert(as_template(&lit));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Source up to its inline test module (`#[cfg(test)] mod tests {`); declarations of test files
|
||||
/// (`#[cfg(test)] mod tests_x;`) don't end it.
|
||||
fn without_test_module(text: &str) -> &str {
|
||||
for (at, m) in text.match_indices("#[cfg(test)]") {
|
||||
let after = text[at + m.len()..].trim_start();
|
||||
if let Some(rest) = after.strip_prefix("mod ")
|
||||
&& rest.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_').trim_start().starts_with('{')
|
||||
{
|
||||
return &text[..at];
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// The text of a string literal whose opening quote was just read, escapes resolved.
|
||||
fn string_literal(s: &str) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
let mut it = s.chars();
|
||||
loop {
|
||||
match it.next()? {
|
||||
'"' => return Some(out),
|
||||
'\\' => match it.next()? {
|
||||
'n' => out.push('\n'),
|
||||
't' => out.push('\t'),
|
||||
c => out.push(c),
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A format string as a catalog template (see [`message_literals`]).
|
||||
fn as_template(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut unnamed = 0;
|
||||
let mut rest = s;
|
||||
while let Some(a) = rest.find('{') {
|
||||
out.push_str(&rest[..a]);
|
||||
let after = &rest[a + 1..];
|
||||
let Some(b) = after.find('}') else { break };
|
||||
let name = after[..b].split(':').next().unwrap_or("");
|
||||
if name.is_empty() {
|
||||
unnamed += 1;
|
||||
out.push_str(&format!("{{_{unnamed}}}"));
|
||||
} else {
|
||||
out.push_str(&format!("{{{name}}}"));
|
||||
}
|
||||
rest = &after[b + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
@@ -183,6 +183,7 @@
|
||||
Artwork Colors 图稿颜色
|
||||
As Multiple Assets 作为多个资源
|
||||
As Single Asset 作为单一资源
|
||||
Ascender 上缘
|
||||
Ask When Modified 修改时询问
|
||||
Aspect Ratio 长宽比
|
||||
Asset Export 资源导出
|
||||
@@ -214,6 +215,7 @@
|
||||
Background: 背景:
|
||||
Backspace Backspace
|
||||
Bar Graph Tool 条形图工具
|
||||
Baseline 基线
|
||||
Baseline Shift 基线偏移
|
||||
Basic Latin 基本拉丁文
|
||||
Batch 批处理
|
||||
@@ -471,6 +473,8 @@
|
||||
Corner Radius: 圆角半径:
|
||||
Corner: 转角:
|
||||
Corners 转角
|
||||
Descender 下缘
|
||||
Flip 翻转
|
||||
Round 圆角
|
||||
Inverted Round 反向圆角
|
||||
Corners… 转角…
|
||||
@@ -918,6 +922,7 @@
|
||||
Fit Headline 适合标题
|
||||
Decrease Font Size 减小字体大小
|
||||
Increase Font Size 增大字体大小
|
||||
Spacing 间距
|
||||
Step Type 按增量调整文字
|
||||
Fit in View 适合视图
|
||||
Fit in Window 适合窗口
|
||||
@@ -2647,6 +2652,7 @@
|
||||
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. 文字变为轮廓,CAD 无法绘制的描边变为填充形状。
|
||||
Type Object Selection by Path Only 仅按路径选择文字对象
|
||||
Type on a Path 路径文字
|
||||
Type on a Path Options 路径文字选项
|
||||
Type on a Path Options… 路径文字选项…
|
||||
Type on a Path Tool 路径文字工具
|
||||
Type Orientation 文字方向
|
||||
@@ -2821,6 +2827,7 @@
|
||||
Zoom to Selection 缩放到所选对象
|
||||
Zoom Tool 缩放工具
|
||||
Zoom with Mouse Wheel 使用鼠标滚轮缩放
|
||||
Scrub Numeric Fields by Dragging 拖动以调整数值字段
|
||||
{count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 个文件{note}。要显示打包文件夹吗?
|
||||
{count} linked file(s) not found 找不到 {count} 个链接文件
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? 自存储此文档后,有 {count} 个链接图像已更改。要显示新版本吗?
|
||||
@@ -3260,3 +3267,15 @@
|
||||
Version 版本
|
||||
% of all commits 占全部提交的百分比
|
||||
Lines +/− 行数 +/−
|
||||
Joins 连接
|
||||
Miter 斜接
|
||||
Bevel 斜角
|
||||
Offset 偏移
|
||||
Axis 轴
|
||||
axis Horizontal 水平
|
||||
axis Vertical 垂直
|
||||
axis Both 两者
|
||||
Drag to move 拖动以移动
|
||||
Drag to float as a toolbar 拖动使其浮动为工具栏
|
||||
Put back in the toolbar 放回工具栏
|
||||
Float Tool Group 浮动工具组
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -194,6 +194,7 @@
|
||||
Artwork Colors 圖稿顏色
|
||||
As Multiple Assets 作為多個資產
|
||||
As Single Asset 作為單一資產
|
||||
Ascender 上緣
|
||||
Ask When Modified 修改時詢問
|
||||
Aspect Ratio 長寬比
|
||||
Asset Export 資產匯出
|
||||
@@ -225,6 +226,7 @@
|
||||
Background: 背景:
|
||||
Backspace Backspace
|
||||
Bar Graph Tool 橫條圖工具
|
||||
Baseline 基線
|
||||
Baseline Shift 基線位移
|
||||
Basic Latin 基本拉丁文
|
||||
Batch 批次
|
||||
@@ -297,6 +299,7 @@
|
||||
Cap 端點
|
||||
Cap: 端點:
|
||||
Cascade 重疊顯示
|
||||
Center 置中
|
||||
Center Artwork 圖稿置中
|
||||
Center on Artboard 在工作區域置中
|
||||
Center-Justified Tab 置中對齊定位點
|
||||
@@ -484,6 +487,8 @@
|
||||
Corner Radius: 圓角半徑:
|
||||
Corner: 轉角:
|
||||
Corners 轉角
|
||||
Descender 下緣
|
||||
Flip 翻轉
|
||||
Round 圓角
|
||||
Radius: 半徑:
|
||||
Inverted Round 反向圓角
|
||||
@@ -940,6 +945,7 @@
|
||||
Fit Headline 符合標題
|
||||
Decrease Font Size 縮小字體大小
|
||||
Increase Font Size 放大字體大小
|
||||
Spacing 間距
|
||||
Step Type 依增量調整文字
|
||||
Fit in View 符合檢視
|
||||
Fit in Window 符合視窗
|
||||
@@ -2729,6 +2735,7 @@
|
||||
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. 文字會變成外框,CAD 線條無法繪製的筆畫會變成填色形狀。
|
||||
Type Object Selection by Path Only 僅以路徑選取文字物件
|
||||
Type on a Path 路徑文字
|
||||
Type on a Path Options 路徑文字選項
|
||||
Type on a Path Options… 路徑文字選項…
|
||||
Type on a Path Tool 路徑文字工具
|
||||
Type Orientation 文字方向
|
||||
@@ -2904,6 +2911,7 @@
|
||||
Zoom to Selection 縮放至選取範圍
|
||||
Zoom Tool 縮放工具
|
||||
Zoom with Mouse Wheel 使用滑鼠滾輪縮放
|
||||
Scrub Numeric Fields by Dragging 拖曳以調整數值欄位
|
||||
{count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 個檔案{note}。要顯示封裝資料夾嗎?
|
||||
{count} linked file(s) not found 找不到 {count} 個連結檔案
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? 自儲存此文件後,有 {count} 個連結影像已變更。要顯示新版本嗎?
|
||||
@@ -3033,3 +3041,15 @@
|
||||
Version 版本
|
||||
% of all commits 佔全部提交的比例
|
||||
Lines +/− 行數 +/−
|
||||
Joins 轉角
|
||||
Miter 尖角
|
||||
Bevel 斜角
|
||||
Offset 位移
|
||||
Axis 軸
|
||||
axis Horizontal 水平
|
||||
axis Vertical 垂直
|
||||
axis Both 兩者
|
||||
Drag to move 拖曳以移動
|
||||
Drag to float as a toolbar 拖曳使其浮動為工具列
|
||||
Put back in the toolbar 放回工具列
|
||||
Float Tool Group 浮動工具組
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -38,6 +38,7 @@ pub mod prefs_dialog;
|
||||
pub mod print;
|
||||
pub mod recovery;
|
||||
pub mod render_worker;
|
||||
mod scrub;
|
||||
pub mod shortcut_editor;
|
||||
pub mod shortcuts;
|
||||
pub mod state;
|
||||
@@ -85,6 +86,8 @@ mod tests_paintchips;
|
||||
#[cfg(test)]
|
||||
mod tests_pastechords;
|
||||
#[cfg(test)]
|
||||
mod tests_pathtype;
|
||||
#[cfg(test)]
|
||||
mod tests_pdfoutput;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
@@ -107,6 +110,8 @@ mod tests_save;
|
||||
#[cfg(test)]
|
||||
mod tests_saveext;
|
||||
#[cfg(test)]
|
||||
mod tests_scrub;
|
||||
#[cfg(test)]
|
||||
mod tests_selectall;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
@@ -229,6 +234,8 @@ pub struct CanvasCache {
|
||||
pub print_tiling: Option<PrintTilingCache>,
|
||||
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
|
||||
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
|
||||
/// The tools' cursors as OS cursor bitmaps.
|
||||
pub cursors: cursors::Images,
|
||||
}
|
||||
|
||||
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
|
||||
@@ -331,6 +338,9 @@ pub struct VectorcraftApp {
|
||||
pub(crate) ime_marked: Option<String>,
|
||||
/// The IME must drop its marked text (see [`Self::take_ime_discard`]).
|
||||
pub(crate) ime_discard: bool,
|
||||
/// A numeric field is being scrubbed: the document's edits meanwhile are one undo step
|
||||
/// ([`scrub::begin_frame`]).
|
||||
scrub_group: bool,
|
||||
}
|
||||
|
||||
/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`].
|
||||
@@ -359,6 +369,7 @@ impl VectorcraftApp {
|
||||
slices: None,
|
||||
print_tiling: None,
|
||||
selection_box: None,
|
||||
cursors: Default::default(),
|
||||
},
|
||||
perf: Perf::default(),
|
||||
integrated_titlebar: false,
|
||||
@@ -394,6 +405,7 @@ impl VectorcraftApp {
|
||||
synthetic_modifiers: false,
|
||||
ime_marked: None,
|
||||
ime_discard: false,
|
||||
scrub_group: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,6 +811,15 @@ impl VectorcraftApp {
|
||||
place::drop_files(self, files, target);
|
||||
}
|
||||
|
||||
/// Fonts installed or removed while the app was in the background are listed when it comes
|
||||
/// back (Refresh Font List by itself): a look at the font folders, a scan only when they changed.
|
||||
fn refresh_installed_fonts(&mut self) {
|
||||
if vectorcraft_text::FontDb::global().installed_fonts_changed() {
|
||||
// A failure shows in the status bar, as the menu item's does.
|
||||
let _ = self.run("text.rescanFonts", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic events (one press/release step or wheel turn per frame). Handlers read the
|
||||
/// modifiers egui holds (`i.modifiers`), so a synthetic key, button or wheel turn holds its own
|
||||
/// for the frames it spans (a drag's moves included); the keyboard's come back after.
|
||||
@@ -807,6 +828,7 @@ impl VectorcraftApp {
|
||||
match e {
|
||||
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
|
||||
egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE,
|
||||
egui::Event::WindowFocused(true) => self.refresh_installed_fonts(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -864,6 +886,7 @@ impl VectorcraftApp {
|
||||
return;
|
||||
}
|
||||
let t0 = now_ms();
|
||||
scrub::begin_frame(self, &ctx);
|
||||
font_menu::end_stale_preview(self, &ctx);
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
if self.ui.screen_mode < 2 {
|
||||
@@ -896,6 +919,7 @@ impl VectorcraftApp {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
self.ui_fonts.frame(&ctx);
|
||||
scrub::end_frame(self, &ctx);
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
let _ = json!(null);
|
||||
}
|
||||
|
||||
+50
-13
@@ -199,6 +199,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("window.toolbar", "Tools", "", "{}"),
|
||||
("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{}"),
|
||||
("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"),
|
||||
(
|
||||
"window.floatTools",
|
||||
"Float Tool Group",
|
||||
"",
|
||||
"{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state",
|
||||
),
|
||||
("window.taskBar", "Contextual Task Bar", "", "{}"),
|
||||
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
|
||||
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
|
||||
@@ -980,6 +986,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
"window.toolbar" => flag(&mut app.ui.toolbar),
|
||||
"window.toolbarColumns" => flag(&mut app.ui.toolbar_double),
|
||||
"window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced),
|
||||
"window.floatTools" => {
|
||||
let floating = match p.get("floating") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Bool(b)) => Some(*b),
|
||||
Some(_) => return Some(Err("floating must be true or false".into())),
|
||||
};
|
||||
let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) };
|
||||
crate::toolbar::float_group(app, &tool, floating).map(Value::Bool)
|
||||
}
|
||||
"window.taskBar" => flag(&mut app.ui.task_bar),
|
||||
"window.dock" => {
|
||||
let on = !(app.ui.dock && app.ui.toolbar);
|
||||
@@ -1302,6 +1317,10 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
"window.control" => app.ui.control_bar,
|
||||
"window.toolbar" => app.ui.toolbar,
|
||||
"window.toolbarAdvanced" => app.ui.toolbar_advanced,
|
||||
"window.floatTools" => {
|
||||
let tool = p.get("tool").and_then(Value::as_str).unwrap_or("");
|
||||
app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool))
|
||||
}
|
||||
"window.taskBar" => app.ui.task_bar,
|
||||
"window.panel" => {
|
||||
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
|
||||
@@ -2075,15 +2094,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
c("Area Type Options…", "text.areaOptions"),
|
||||
sub(
|
||||
"Type on a Path",
|
||||
vec![
|
||||
cp("Rainbow", "type.pathOptions", json!({"effect": "rainbow"})),
|
||||
cp("Skew", "type.pathOptions", json!({"effect": "skew"})),
|
||||
cp("3D Ribbon", "type.pathOptions", json!({"effect": "3dRibbon"})),
|
||||
cp("Stair Step", "type.pathOptions", json!({"effect": "stairStep"})),
|
||||
cp("Gravity", "type.pathOptions", json!({"effect": "gravity"})),
|
||||
Sep,
|
||||
cp("Type on a Path Options…", "type.pathOptions", json!({"start": 0})),
|
||||
],
|
||||
PATH_EFFECTS
|
||||
.iter()
|
||||
.map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect })))
|
||||
.chain([Sep, c("Type on a Path Options…", "type.pathOptions")])
|
||||
.collect(),
|
||||
),
|
||||
sub(
|
||||
"Threaded Text",
|
||||
@@ -2658,6 +2673,17 @@ pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) {
|
||||
(id.to_string(), if p.is_null() { json!({}) } else { p.clone() })
|
||||
}
|
||||
|
||||
/// The dialog of command `id` that shows the selection's current values (its query): its heading
|
||||
/// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they
|
||||
/// are.
|
||||
fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> {
|
||||
Some(match id {
|
||||
"text.areaOptions" => ("Area Type Options", &[]),
|
||||
"type.pathOptions" => ("Type on a Path Options", &["start", "end"]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does.
|
||||
fn menu_dialog(id: &str) -> Option<(&'static str, Value)> {
|
||||
Some(match id {
|
||||
@@ -2728,11 +2754,17 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields}));
|
||||
return;
|
||||
}
|
||||
// Area Type Options: a dialog with the selected area type's current values.
|
||||
if id == "text.areaOptions" && p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
// Area Type Options and Type on a Path Options: dialogs with the selected type's current
|
||||
// values (the command's query), less those the dialog leaves alone.
|
||||
if let Some((label, hidden)) = queried_dialog(id)
|
||||
&& p.as_object().is_none_or(|o| o.is_empty())
|
||||
{
|
||||
match app.session.execute(id, &json!({})) {
|
||||
Ok(fields) => {
|
||||
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Area Type Options", "params": fields}));
|
||||
Ok(mut fields) => {
|
||||
if let Some(o) = fields.as_object_mut() {
|
||||
o.retain(|k, _| !hidden.contains(&k.as_str()));
|
||||
}
|
||||
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
|
||||
}
|
||||
Err(e) => app.status(e.to_string()),
|
||||
}
|
||||
@@ -2937,6 +2969,11 @@ const TYPE_SIZES: [(&str, u32); 14] = [
|
||||
("72 pt", 72),
|
||||
];
|
||||
|
||||
/// Type → Type on a Path effects (label, `type.pathOptions` effect), also the choices of its
|
||||
/// Options dialog.
|
||||
pub(crate) const PATH_EFFECTS: &[(&str, &str)] =
|
||||
&[("Rainbow", "rainbow"), ("Skew", "skew"), ("3D Ribbon", "3dRibbon"), ("Stair Step", "stairStep"), ("Gravity", "gravity")];
|
||||
|
||||
const INSERT_SPECIAL: &[(&str, &str)] = &[
|
||||
("Bullet", "bullet"),
|
||||
("Copyright Symbol", "copyright"),
|
||||
|
||||
@@ -668,16 +668,13 @@ fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opaci
|
||||
let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 };
|
||||
let clicked = link(ui, pos2(lx, r.center().y), ("ap-op-link", item), tl!("Opacity:")) || resp.clicked();
|
||||
text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(opacity, blend), false);
|
||||
egui::Popup::menu(&resp)
|
||||
.open_memory(clicked.then_some(egui::SetOpenCommand::Toggle))
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) {
|
||||
Some(TransparencyEdit::Blend(b)) => {
|
||||
app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok();
|
||||
}
|
||||
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
|
||||
None => {}
|
||||
});
|
||||
widgets::popover(&resp, clicked, |ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) {
|
||||
Some(TransparencyEdit::Blend(b)) => {
|
||||
app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok();
|
||||
}
|
||||
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
|
||||
None => {}
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
@@ -227,7 +227,9 @@ pub(crate) fn route_type_input(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tl!(tip));
|
||||
let l =
|
||||
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tl!(tip));
|
||||
crate::scrub::note_label(ui, l.rect);
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//! shows every layer when they are hidden (`layer.showAll`); Alt-click a lock does the same with
|
||||
//! `layer.lockOthers` and `layer.unlockAll`.
|
||||
//! - Dragging rows moves them above, below or into a layer or group (`layer.move`, a drop line or
|
||||
//! a box shows where); Alt copies them; dropped on the trash they are deleted.
|
||||
//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer. Dropped on
|
||||
//! the trash they are deleted, on New Layer duplicated.
|
||||
//! - The triangle opens or closes a row; Alt-click does the same to everything inside it.
|
||||
//! - Double-clicking a name renames it; double-clicking elsewhere on a row opens its options.
|
||||
//! - A target circle targets its layer, group or object (`layer.target`); dragging it onto another
|
||||
@@ -109,6 +110,57 @@ fn rename_id() -> egui::Id {
|
||||
egui::Id::new("layers-rename")
|
||||
}
|
||||
|
||||
/// Ghost rows are drawn at this opacity.
|
||||
const GHOST_OPACITY: f32 = 0.5;
|
||||
|
||||
/// The colour of the layer holding `id`.
|
||||
fn layer_colour(doc: &Document, id: NodeId) -> Color32 {
|
||||
let [r, g, b] = doc.layer_color(id);
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// The layer colour bar of row `n` (rect `r`) at `x`: wider on layers.
|
||||
fn colour_bar(ui: &Ui, n: &Node, r: egui::Rect, x: f32, colour: Color32) {
|
||||
let w = if n.is_layer() { 4.0 } else { 2.0 };
|
||||
ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(w, r.height())), 0.0, colour);
|
||||
}
|
||||
|
||||
/// The thumbnail of row `n` (rect `r`) at `x`; returns where it went.
|
||||
fn row_thumb(ui: &Ui, view: &View, n: &Node, r: egui::Rect, x: f32) -> egui::Rect {
|
||||
let s = (view.h - 4.0).min(40.0);
|
||||
let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s));
|
||||
ui.painter().rect_filled(th, 0.0, Color32::WHITE);
|
||||
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, view.t.border), StrokeKind::Outside);
|
||||
if !real_thumb(ui, view.doc, n, th) {
|
||||
thumb(ui, n, th);
|
||||
}
|
||||
th
|
||||
}
|
||||
|
||||
/// The dragged row `src`, dimmed, following the pointer at `pos` above everything else: its
|
||||
/// colour bar, thumbnail and name.
|
||||
fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) {
|
||||
let (doc, t, h) = (view.doc, &view.t, view.h);
|
||||
let Some(n) = doc.node(src) else { return };
|
||||
let (grab, width, depth) = ui.data(|d| d.get_temp::<(egui::Vec2, f32, usize)>(key("grab"))).unwrap_or((vec2(h, h / 2.0), 240.0, 0));
|
||||
// A plain Ui on the tooltip layer, not an Area: a new Area is invisible for a frame and fades in.
|
||||
let r = egui::Rect::from_min_size(pos - grab, vec2(width, h));
|
||||
let id = key("ghost");
|
||||
let mut ui = Ui::new(ui.ctx().clone(), id, egui::UiBuilder::new().layer_id(egui::LayerId::new(egui::Order::Tooltip, id)).max_rect(r));
|
||||
ui.set_opacity(GHOST_OPACITY);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, t.row_selected);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
let mut x = r.left() + 2.0 * COLUMN + 2.0;
|
||||
colour_bar(&ui, n, r, x, layer_colour(doc, src));
|
||||
x += 22.0 + depth as f32 * INDENT;
|
||||
if view.opts.thumb(n) {
|
||||
x = row_thumb(&ui, view, n, r, x).right() + 4.0;
|
||||
}
|
||||
let name = painted_name(n, &n.display_name(), crate::i18n::current());
|
||||
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text);
|
||||
}
|
||||
|
||||
/// Where a dragged row goes relative to the row it is dropped on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Place {
|
||||
@@ -268,6 +320,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if released {
|
||||
ui.data_mut(|d| d.remove::<u64>(egui::Id::new("layers-drag")));
|
||||
}
|
||||
// Dragged rows show as a dimmed row under the pointer.
|
||||
if !released
|
||||
&& let Some(drag) = egui::DragAndDrop::payload::<LayersDrag>(ui.ctx())
|
||||
&& let LayersDrag::Rows(ids) = &*drag
|
||||
&& let Some(src) = ids.first()
|
||||
&& let Some(pos) = ui.input(|i| i.pointer.latest_pos())
|
||||
{
|
||||
drag_ghost(ui, &view, *src, pos);
|
||||
}
|
||||
// Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was
|
||||
// clicked last, not the canvas).
|
||||
let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p)));
|
||||
@@ -304,15 +365,13 @@ fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let trash = widgets::icon_button(&mut child, "trash-2", tl!("Delete Selection"), false, 24.0);
|
||||
// A target circle dropped on the trash clears that appearance; dragged rows are deleted.
|
||||
if let Some(d) = trash.dnd_release_payload::<PanelDrag>()
|
||||
// A target circle dropped on the trash clears that appearance (dragged rows: below). Peek at
|
||||
// the payload's type before taking it: egui's take drops a payload of another type.
|
||||
if trash.dnd_hover_payload::<PanelDrag>().is_some()
|
||||
&& let Some(d) = trash.dnd_release_payload::<PanelDrag>()
|
||||
&& let PanelDrag::Appearance(id) = *d
|
||||
{
|
||||
actions.push(("appearance.clear".into(), json!({"ids": [id.0]})));
|
||||
} else if let Some(d) = trash.dnd_release_payload::<LayersDrag>()
|
||||
&& let LayersDrag::Rows(ids) = &*d
|
||||
{
|
||||
actions.push(("layer.delete".into(), json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})));
|
||||
} else if trash.clicked() {
|
||||
if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
|
||||
actions.push(("edit.clear".into(), json!({})));
|
||||
@@ -321,13 +380,28 @@ fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document
|
||||
}
|
||||
}
|
||||
// Create New Layer: Alt asks for its options first, Ctrl/Cmd puts it on top of every layer.
|
||||
if widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0).clicked() {
|
||||
let new_layer = widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0);
|
||||
if new_layer.clicked() {
|
||||
if m.alt {
|
||||
actions.push(("ui.newLayer".into(), json!({})));
|
||||
} else {
|
||||
actions.push(("layer.new".into(), json!({"top": m.command})));
|
||||
}
|
||||
}
|
||||
// Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are
|
||||
// outlined while rows (or, on the trash, a target circle) hang over them.
|
||||
for (button, cmd) in [(&trash, "layer.delete"), (&new_layer, "layer.duplicate")] {
|
||||
if let Some(d) = button.dnd_release_payload::<LayersDrag>()
|
||||
&& let LayersDrag::Rows(ids) = &*d
|
||||
{
|
||||
actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})));
|
||||
}
|
||||
let over = button.dnd_hover_payload::<LayersDrag>().is_some_and(|d| matches!(*d, LayersDrag::Rows(_)))
|
||||
|| (cmd == "layer.delete" && button.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_))));
|
||||
if over {
|
||||
child.painter().rect_stroke(button.rect, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
if widgets::icon_button(&mut child, "plus", tl!("Create New Sublayer"), false, 24.0).clicked() {
|
||||
actions.push(if m.alt { ("ui.newLayer".into(), json!({"sublayer": true})) } else { ("layer.newSublayer".into(), json!({})) });
|
||||
}
|
||||
@@ -396,10 +470,7 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
|
||||
n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id));
|
||||
any
|
||||
});
|
||||
let color = {
|
||||
let c = doc.layer_color(n.id);
|
||||
Color32::from_rgb(c[0], c[1], c[2])
|
||||
};
|
||||
let color = layer_colour(doc, n.id);
|
||||
// Highlighted rows (else the current layer's).
|
||||
let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) };
|
||||
if highlighted {
|
||||
@@ -416,7 +487,7 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
|
||||
eye_and_lock(ui, view, n, r, out);
|
||||
// Layer colour bar (every row shows the colour of its layer).
|
||||
let mut x = r.left() + 2.0 * COLUMN + 2.0;
|
||||
ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(if n.is_layer() { 4.0 } else { 2.0 }, h)), 0.0, color);
|
||||
colour_bar(ui, n, r, x, color);
|
||||
x += 6.0 + depth as f32 * INDENT;
|
||||
// Disclosure triangle; Alt opens or closes everything inside too.
|
||||
let searching = !view.query.is_empty();
|
||||
@@ -440,14 +511,8 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
|
||||
x += 16.0;
|
||||
// Thumbnail.
|
||||
let thumb_rect = view.opts.thumb(n).then(|| {
|
||||
let s = (h - 4.0).min(40.0);
|
||||
let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s));
|
||||
ui.painter().rect_filled(th, 0.0, Color32::WHITE);
|
||||
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||||
if !real_thumb(ui, doc, n, th) {
|
||||
thumb(ui, n, th);
|
||||
}
|
||||
x += s + 4.0;
|
||||
let th = row_thumb(ui, view, n, r, x);
|
||||
x = th.right() + 4.0;
|
||||
th
|
||||
});
|
||||
// Name.
|
||||
@@ -533,6 +598,8 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
|
||||
if resp.drag_started() && view.column.is_none() {
|
||||
let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] };
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Rows(ids));
|
||||
let grab = resp.interact_pointer_pos().map_or(vec2(h, h / 2.0), |p| p - r.min);
|
||||
ui.data_mut(|d| d.insert_temp(key("grab"), (grab, r.width(), depth)));
|
||||
}
|
||||
if resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
@@ -1116,23 +1183,34 @@ mod tests {
|
||||
// Dropped on the trash, the layer's appearance is cleared (and nothing is deleted).
|
||||
let st = app.session.active().unwrap();
|
||||
let count = st.doc.node_count();
|
||||
let trash = {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
// The bottom bar's rightmost button, under the right end of its top divider.
|
||||
let divider = Tokens::get(&ctx).divider;
|
||||
let corner = out.shapes.iter().rev().find_map(|c| match &c.shape {
|
||||
egui::Shape::LineSegment { points, stroke } if stroke.color == divider => Some(points[1]),
|
||||
_ => None,
|
||||
});
|
||||
corner.unwrap() + vec2(-12.0, 15.0)
|
||||
};
|
||||
let trash = trash_pos(&mut app, &ctx);
|
||||
drag(&mut app, &ctx, c[0], trash, false);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.node(layer).unwrap().opacity, 1.0);
|
||||
assert_eq!(st.doc.node_count(), count);
|
||||
}
|
||||
|
||||
/// The trash button: the bottom bar's rightmost button.
|
||||
fn trash_pos(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 {
|
||||
bar_buttons(app, ctx)[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_rows_onto_the_trash_deletes_them() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
app.session.execute("layer.new", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
// Rows top first: Layer 2, Layer 1. Grab Layer 2 by its name, left of its target circle.
|
||||
let (c, _) = frame(&mut app, &ctx, vec![], false);
|
||||
let top = app.session.active().unwrap().doc.layers[1].id;
|
||||
let trash = trash_pos(&mut app, &ctx);
|
||||
drag(&mut app, &ctx, c[0] - vec2(100.0, 0.0), trash, false);
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
assert!(doc.node(top).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_layer_commits_on_enter_and_escape_cancels() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
@@ -1396,6 +1474,70 @@ mod tests {
|
||||
let _ = (layer, c);
|
||||
}
|
||||
|
||||
/// The centres of the bottom bar's buttons, right to left from under the right end of its top
|
||||
/// divider: Delete, New Layer, New Sublayer, Make/Release Clipping Mask, Locate Object.
|
||||
fn bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<egui::Pos2> {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let divider = Tokens::get(ctx).divider;
|
||||
let corner = out
|
||||
.shapes
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|c| match &c.shape {
|
||||
egui::Shape::LineSegment { points, stroke } if stroke.color == divider => Some(points[1]),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let step = 24.0 + ctx.global_style().spacing.item_spacing.x;
|
||||
(0..5).map(|k| corner + vec2(-12.0 - k as f32 * step, 15.0)).collect()
|
||||
}
|
||||
|
||||
/// How many texts reading `s` the frame after `events` paints.
|
||||
fn texts(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, s: &str) -> usize {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == s)).count()
|
||||
}
|
||||
|
||||
/// Drag the row at `a` to `b` and drop it there. Returns how many times its `name` was painted
|
||||
/// while it hung over `b`.
|
||||
fn drag_row(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, name: &str) -> usize {
|
||||
frame(app, ctx, vec![egui::Event::PointerMoved(a)], false);
|
||||
frame(app, ctx, vec![button(a, true)], false);
|
||||
frame(app, ctx, vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], false);
|
||||
let shown = texts(app, ctx, vec![egui::Event::PointerMoved(b)], name);
|
||||
frame(app, ctx, vec![button(b, false)], false);
|
||||
frame(app, ctx, vec![], false);
|
||||
shown
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_rows_on_new_layer_duplicates_them() {
|
||||
let (mut app, first, _) = two_rects();
|
||||
let second = app.session.execute("layer.new", &json!({"name": "Top"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let layers = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>();
|
||||
// Rows top down: Top, Layer 1 (expanded: its two rectangles).
|
||||
let (c, _) = frame(&mut app, &ctx, vec![], false);
|
||||
let buttons = bar_buttons(&mut app, &ctx);
|
||||
// Layer 1 dropped on New Layer: a copy of it right above it.
|
||||
let shown = drag_row(&mut app, &ctx, egui::pos2(150.0, c[1].y), buttons[1], "Layer 1");
|
||||
assert_eq!(shown, 2, "the row and its ghost under the pointer");
|
||||
let after = layers(&app);
|
||||
assert_eq!(after.len(), 3);
|
||||
assert_eq!((after[0], after[2]), (first, second));
|
||||
assert_eq!(app.session.active().unwrap().doc.node(after[1]).unwrap().children().map(Vec::len), Some(2));
|
||||
assert_eq!(texts(&mut app, &ctx, vec![], "Layer 1"), 1, "the ghost is gone after the drop");
|
||||
// One undo step takes the copy away.
|
||||
app.session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(layers(&app), vec![first, second]);
|
||||
// The other bottom-bar buttons are no drop targets.
|
||||
drag_row(&mut app, &ctx, egui::pos2(150.0, c[0].y), buttons[2], "Top");
|
||||
assert_eq!(layers(&app), vec![first, second]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selected_art_square_drags_the_selection_to_another_layer() {
|
||||
let (mut app, [_, sub, g, _, a, c]) = nested();
|
||||
|
||||
@@ -89,7 +89,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// Indents and paragraph spacing are distances (General); type sizes follow Units ▸ Type.
|
||||
let unit = app.session.general_unit();
|
||||
let label = |ui: &mut Ui, s: &str, tip: &str| {
|
||||
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(s).size(11.5).strong().color(t.text))).on_hover_text(tip);
|
||||
let l = ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(s).size(11.5).strong().color(t.text))).on_hover_text(tip);
|
||||
crate::scrub::note_label(ui, l.rect);
|
||||
};
|
||||
egui::Grid::new("para-grid").num_columns(4).spacing([4.0, 4.0]).show(ui, |ui| {
|
||||
label(ui, "→|", tl!("Left Indent"));
|
||||
|
||||
@@ -299,7 +299,8 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
super::transform::constrain_link(app, ui);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
|
||||
let icon = icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
|
||||
crate::scrub::note_label(ui, icon.rect);
|
||||
// The bounding box's angle: a new value turns the selection to it.
|
||||
if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, 70.0) {
|
||||
app.run("object.rotate", json!({"angle": a, "absolute": true})).ok();
|
||||
@@ -345,7 +346,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
for (label, is_fill) in [(tl!("Fill"), true), (tl!("Stroke"), false)] {
|
||||
ui.horizontal(|ui| {
|
||||
super::paint_chip(app, ui, !is_fill, 22.0, false);
|
||||
ui.label(egui::RichText::new(label).size(12.0));
|
||||
widgets::field_label(ui, egui::RichText::new(label).size(12.0));
|
||||
if !is_fill {
|
||||
ui.add_space(8.0);
|
||||
if let Some(w) = widgets::num_field(ui, "ap-w", weight, app.session.stroke_unit(), 70.0) {
|
||||
@@ -357,7 +358,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Opacity")).size(12.0));
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Opacity")).size(12.0));
|
||||
ui.add_space(8.0);
|
||||
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, 64.0) {
|
||||
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_engine::inspect::StrokeMixed;
|
||||
|
||||
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
|
||||
@@ -12,8 +12,34 @@ use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
pub const WEIGHT_PRESETS: [f64; 22] =
|
||||
/// Stroke weight dropdown presets in points, from the ladder of `unit` (Units > Stroke): each
|
||||
/// unit has its own ladder of round values in that unit, so the dropdown reads `0.25 mm`, not the
|
||||
/// `0.088 mm` a converted pt ladder gives. Feet, yards and meters, which no stroke is measured
|
||||
/// in, keep the pt ladder.
|
||||
pub fn weight_presets(unit: Unit) -> [f64; 22] {
|
||||
let native = match unit {
|
||||
Unit::Millimeters => MM_PRESETS,
|
||||
Unit::Centimeters => CM_PRESETS,
|
||||
Unit::Inches => IN_PRESETS,
|
||||
Unit::Pixels => PX_PRESETS,
|
||||
Unit::Picas => return PC_PRESETS,
|
||||
Unit::Points | Unit::FeetInches | Unit::Feet | Unit::Meters | Unit::Yards => return PT_PRESETS,
|
||||
};
|
||||
native.map(|v| unit.to_pt(v))
|
||||
}
|
||||
|
||||
const PT_PRESETS: [f64; 22] =
|
||||
[0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
|
||||
const MM_PRESETS: [f64; 22] = [0.1, 0.25, 0.35, 0.5, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 15.0, 20.0, 25.0, 30.0];
|
||||
const CM_PRESETS: [f64; 22] = [0.01, 0.02, 0.03, 0.05, 0.06, 0.07, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0];
|
||||
const IN_PRESETS: [f64; 22] =
|
||||
[0.0078, 0.0156, 0.0313, 0.0625, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0];
|
||||
const PX_PRESETS: [f64; 22] =
|
||||
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 30.0, 40.0];
|
||||
/// The pica ladder (0p1 … 5p) in points, so every entry is an exact pt weight. The dropdown shows
|
||||
/// them as decimal picas (`0.083 p`) until [`Unit::number`] writes `0p1` notation.
|
||||
const PC_PRESETS: [f64; 22] =
|
||||
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 15.0, 18.0, 21.0, 24.0, 27.0, 30.0, 33.0, 36.0, 48.0, 60.0];
|
||||
|
||||
/// A profile's points as its silhouette: none (the plain bar) for the uniform stroke.
|
||||
fn silhouette(points: &[(f64, f64, f64)]) -> Option<&[(f64, f64, f64)]> {
|
||||
@@ -87,7 +113,8 @@ pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed
|
||||
|
||||
/// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed.
|
||||
pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option<f64>, width: f32) {
|
||||
if let Some(w) = widgets::spin_field(ui, id, weight, app.session.stroke_unit(), width, 1.0, 0.0, &WEIGHT_PRESETS) {
|
||||
let unit = app.session.stroke_unit();
|
||||
if let Some(w) = widgets::spin_field(ui, id, weight, unit, width, 1.0, 0.0, &weight_presets(unit)) {
|
||||
set(app, json!({"weight": w}));
|
||||
}
|
||||
}
|
||||
@@ -95,7 +122,7 @@ pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weig
|
||||
/// The Stroke panel in a popover anchored to `resp` (the Control bar's and the Properties
|
||||
/// panel's Stroke links), which a click on it toggles.
|
||||
pub(crate) fn popover(app: &mut VectorcraftApp, resp: &egui::Response) {
|
||||
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
widgets::popover(resp, resp.clicked(), |ui| {
|
||||
ui.set_width(260.0);
|
||||
show(app, ui);
|
||||
});
|
||||
@@ -110,7 +137,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let hidden: bool = pstate(ui.ctx(), "stroke-hide-options");
|
||||
let label_w = 64.0;
|
||||
let row_label = |ui: &mut Ui, s: &str| {
|
||||
ui.add_sized(vec2(label_w, 24.0), egui::Label::new(egui::RichText::new(s).size(12.5).color(t.text)).halign(egui::Align::RIGHT));
|
||||
let l = ui.add_sized(vec2(label_w, 24.0), egui::Label::new(egui::RichText::new(s).size(12.5).color(t.text)).halign(egui::Align::RIGHT));
|
||||
crate::scrub::note_label(ui, l.rect);
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
row_label(ui, tl!("Weight:"));
|
||||
|
||||
@@ -168,6 +168,29 @@ fn item_opacity_popup_sets_the_items_opacity() {
|
||||
assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_popup_blend_dropdown_sets_the_blend_mode() {
|
||||
let mut app = app_with_rect();
|
||||
let ctx = egui::Context::default();
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
// The object's own Opacity row (the last one).
|
||||
let link = texts.iter().rev().find(|(t, _)| t == "Opacity:").expect("object opacity row").1;
|
||||
click(&ctx, &mut app, link.center(), appearance::show);
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
// Opening the dropdown inside the popup keeps the popup open: the list shows.
|
||||
click(&ctx, &mut app, text_rect(&texts, "Normal").center(), appearance::show);
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
click(&ctx, &mut app, text_rect(&texts, "Multiply").center(), appearance::show);
|
||||
assert_eq!(first_selected(&app).unwrap().blend, vectorcraft_color::BlendMode::Multiply);
|
||||
// The popup is still open after choosing.
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Multiply"), "{texts:?}");
|
||||
// A frame without the panel closes it, as egui closes the popups it remembers.
|
||||
frame_events(&ctx, &mut app, vec![], |_, _| {});
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
assert!(!texts.iter().any(|(t, _)| t == "Multiply"), "{texts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_row_names_the_selection_like_the_control_bar() {
|
||||
let mut app = app_with_rect();
|
||||
|
||||
@@ -32,10 +32,10 @@ fn weights_and_dashes_show_in_the_stroke_unit_everywhere() {
|
||||
app.session.prefs.units_stroke = "millimeters".into();
|
||||
run(&mut app, "stroke.set", json!({"dash": [12, 6]}));
|
||||
let mm = vectorcraft_doc::Unit::Millimeters.format(1.0);
|
||||
assert_eq!(mm, "0.353 mm");
|
||||
assert_eq!(mm, "0.3528 mm");
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
assert!(shows(&t, &mm), "Stroke panel: {t:?}");
|
||||
assert!(shows(&t, "4.233") && shows(&t, "2.117"), "dash and gap in mm: {t:?}");
|
||||
assert!(shows(&t, "4.2333") && shows(&t, "2.1167"), "dash and gap in mm: {t:?}");
|
||||
assert!(shows(&texts(&ctx, &mut app, properties::show), &mm), "Properties panel");
|
||||
assert!(shows(&texts(&ctx, &mut app, crate::chrome::control_bar), &mm), "Control bar");
|
||||
}
|
||||
@@ -75,7 +75,8 @@ fn inside_and_outside_are_off_for_open_paths_and_type() {
|
||||
|
||||
#[test]
|
||||
fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
|
||||
assert!(stroke::WEIGHT_PRESETS.contains(&0.25) && stroke::WEIGHT_PRESETS.contains(&100.0));
|
||||
let pt = stroke::weight_presets(vectorcraft_doc::Unit::Points);
|
||||
assert!(pt.contains(&0.25) && pt.contains(&100.0));
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_rect();
|
||||
// The chevron at the right end of the 120 px weight spinner opens them.
|
||||
@@ -86,3 +87,28 @@ fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
assert!(shows(&t, "0.25 pt") && shows(&t, "100 pt"), "{t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weight_presets_display_cleanly_in_their_native_unit() {
|
||||
use vectorcraft_doc::Unit;
|
||||
// Round-tripping a native-unit value through to_pt → from_pt → number() must give the same
|
||||
// string the ladder was authored with: no "0.353 mm" surprises in the dropdown.
|
||||
for (unit, want) in [
|
||||
(Unit::Millimeters, ["0.1", "0.25", "0.35", "0.5", "0.75", "1", "30"].as_slice()),
|
||||
(Unit::Centimeters, ["0.01", "0.05", "0.1", "0.5", "1", "5"].as_slice()),
|
||||
(Unit::Pixels, ["1", "10", "20", "40"].as_slice()),
|
||||
(Unit::Inches, ["0.0078", "0.0313", "0.375", "1", "5"].as_slice()),
|
||||
(Unit::Points, ["0.25", "0.5", "1", "50", "100"].as_slice()),
|
||||
] {
|
||||
let presets = stroke::weight_presets(unit);
|
||||
let shown: Vec<String> = presets.iter().map(|pt| unit.number(*pt)).collect();
|
||||
for w in want {
|
||||
assert!(shown.iter().any(|s| s == w), "{unit:?} preset {w:?} missing from {shown:?}");
|
||||
}
|
||||
}
|
||||
// Picas are exact pt weights (0p1 = 1 pt, 1p = 12 pt, 5p = 60 pt).
|
||||
let pc = stroke::weight_presets(Unit::Picas);
|
||||
assert!([1.0, 12.0, 60.0].iter().all(|w| pc.contains(w)), "{pc:?}");
|
||||
// Units no stroke is measured in keep the pt ladder, in points.
|
||||
assert_eq!(stroke::weight_presets(Unit::Meters), stroke::weight_presets(Unit::Points));
|
||||
}
|
||||
@@ -37,7 +37,7 @@ fn general_mm(app: &mut VectorcraftApp) {
|
||||
#[test]
|
||||
fn panels_show_millimetres_after_the_preference_changes() {
|
||||
let mut app = app_with_rect();
|
||||
assert_eq!(side_mm(), "35.278 mm");
|
||||
assert_eq!(side_mm(), "35.2778 mm");
|
||||
assert!(shows(&texts(&mut app, transform::show), "100 pt"), "points first");
|
||||
general_mm(&mut app);
|
||||
// The next frame: no restart, nothing cached.
|
||||
|
||||
@@ -139,7 +139,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let origin = rp.map(|p| json!([p.x, p.y]));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text(tl!("Rotate"));
|
||||
let icon = icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text(tl!("Rotate"));
|
||||
crate::scrub::note_label(ui, icon.rect);
|
||||
// The bounding box's angle: a new value turns the selection to it.
|
||||
let angle = bx.map_or(0.0, |b| b.angle);
|
||||
if let Some(a) = widgets::spin_plain(ui, "xfp-rot", angle, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS)
|
||||
@@ -148,7 +149,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.run("object.rotate", json!({"angle": a, "absolute": true, "origin": origin})).ok();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear"));
|
||||
let icon = icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear"));
|
||||
crate::scrub::note_label(ui, icon.rect);
|
||||
if let Some(a) = widgets::plain_field(ui, "xfp-shear", 0.0, "°", 1, 56.0)
|
||||
&& a != 0.0
|
||||
{
|
||||
|
||||
@@ -325,7 +325,7 @@ pub fn drop_files(app: &mut VectorcraftApp, files: Vec<(String, Option<String>,
|
||||
}),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(format!("Couldn't {} {name}: {e}", if target == DropTarget::Open { "open" } else { "place" }));
|
||||
app.status(if target == DropTarget::Open { format!("Couldn't open {name}: {e}") } else { format!("Couldn't place {name}: {e}") });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ struct Applied {
|
||||
white_canvas: bool,
|
||||
threads: i32,
|
||||
tool_tips: bool,
|
||||
scrub: bool,
|
||||
}
|
||||
|
||||
/// Per frame: push UI-side preferences into egui / the renderer when they change.
|
||||
@@ -88,8 +89,13 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
{
|
||||
app.ui.brightness = b;
|
||||
}
|
||||
let want =
|
||||
Applied { brightness: app.ui.brightness, white_canvas: p.canvas_color == "white", threads: p.render_threads, tool_tips: p.show_tool_tips };
|
||||
let want = Applied {
|
||||
brightness: app.ui.brightness,
|
||||
white_canvas: p.canvas_color == "white",
|
||||
threads: p.render_threads,
|
||||
tool_tips: p.show_tool_tips,
|
||||
scrub: p.scrub_numeric_fields,
|
||||
};
|
||||
let id = egui::Id::new("dc-applied-prefs");
|
||||
let prev: Option<Applied> = ctx.data(|d| d.get_temp::<Option<Applied>>(id)).flatten();
|
||||
if prev != Some(want) {
|
||||
@@ -98,6 +104,7 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// due), whichever widget asks for one.
|
||||
let delay = if want.tool_tips { egui::style::Interaction::default().tooltip_delay } else { f32::INFINITY };
|
||||
ctx.global_style_mut(|s| s.interaction.tooltip_delay = delay);
|
||||
crate::scrub::set_enabled(ctx, want.scrub);
|
||||
if want.white_canvas {
|
||||
let mut t = Tokens::get(ctx);
|
||||
t.pasteboard = egui::Color32::WHITE;
|
||||
@@ -122,81 +129,67 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let (mut ok, mut cancel, mut reset) = (false, false, false);
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
let cat = d.str("__category");
|
||||
let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]);
|
||||
egui::Window::new(tl!("Preferences"))
|
||||
.id(egui::Id::new("dialog-preferences"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(760.0);
|
||||
ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
// Category list.
|
||||
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.set_width(196.0);
|
||||
ui.set_min_height(430.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
for c in PREF_CATEGORIES {
|
||||
let sel = *c == cat;
|
||||
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
|
||||
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
|
||||
if ui.add(b).clicked() {
|
||||
d.fields.insert("__category".into(), json!(c));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
// Fields.
|
||||
crate::dialogs::modal::show(ctx, tl!("Preferences"), egui::Id::new("dialog-preferences"), -20.0, 20, |ui| {
|
||||
ui.set_width(760.0);
|
||||
crate::dialogs::modal::heading(ui, tl!("Preferences"));
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
// Category list.
|
||||
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||||
ui.set_width(196.0);
|
||||
ui.set_min_height(430.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(530.0);
|
||||
ui.label(egui::RichText::new(tl!(cat)).font(theme::semibold(14.0)).color(t.text_strong));
|
||||
ui.add_space(8.0);
|
||||
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
category_fields(ui, &mut d, cat);
|
||||
});
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
for c in PREF_CATEGORIES {
|
||||
let sel = *c == cat;
|
||||
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
|
||||
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
|
||||
if ui.add(b).clicked() {
|
||||
d.fields.insert("__category".into(), json!(c));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::secondary_button(ui, tl!("Reset Preferences"))
|
||||
.on_hover_text(tl!("Restore every preference to its default (applied on OK)"))
|
||||
.clicked()
|
||||
{
|
||||
reset = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
|
||||
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new(tl!("Next"))).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
|
||||
}
|
||||
if ui.add_enabled(i > 0, egui::Button::new(tl!("Previous"))).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
|
||||
}
|
||||
// Fields.
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(530.0);
|
||||
ui.label(egui::RichText::new(tl!(cat)).font(theme::semibold(14.0)).color(t.text_strong));
|
||||
ui.add_space(8.0);
|
||||
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
category_fields(ui, &mut d, cat);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::secondary_button(ui, tl!("Reset Preferences"))
|
||||
.on_hover_text(tl!("Restore every preference to its default (applied on OK)"))
|
||||
.clicked()
|
||||
{
|
||||
reset = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
|
||||
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new(tl!("Next"))).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
|
||||
}
|
||||
if ui.add_enabled(i > 0, egui::Button::new(tl!("Previous"))).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if reset {
|
||||
for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() {
|
||||
d.fields.insert(k, v);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Scrubbing numeric fields (#400, beyond Illustrator; Photoshop's scrubby labels): a horizontal
|
||||
//! drag on a numeric field's label (`W:`, `Opacity:`, the Character panel's size icon) steps the
|
||||
//! field's value by one unit every [`PX_PER_STEP`] points, ten with Shift and a tenth with
|
||||
//! Ctrl/Cmd, as the arrow keys step a focused field; a click on the label focuses the field. The
|
||||
//! text box itself never scrubs, and a field without a label doesn't.
|
||||
//!
|
||||
//! The panels apply each value the drag passes as they apply a typed one, so the art follows the
|
||||
//! drag live; the app holds an undo group open meanwhile ([`begin_frame`], [`end_frame`]), so the
|
||||
//! drag is one undo step and Escape takes it back. Preferences › General › Scrub Numeric Fields by
|
||||
//! Dragging turns it off.
|
||||
|
||||
use egui::{Context, CursorIcon, Id, Key, Rect, Response, Sense, Ui};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Points of horizontal drag per step.
|
||||
pub const PX_PER_STEP: f32 = 2.0;
|
||||
/// The widest gap between a label and the field it names.
|
||||
const LABEL_GAP: f32 = 64.0;
|
||||
|
||||
/// Where the scrub in progress is kept (egui memory).
|
||||
fn state_id() -> Id {
|
||||
Id::new("numeric-field-scrub")
|
||||
}
|
||||
|
||||
/// Where the last label drawn waits for its field.
|
||||
fn label_id() -> Id {
|
||||
Id::new("numeric-field-label")
|
||||
}
|
||||
|
||||
fn enabled_id() -> Id {
|
||||
Id::new("numeric-field-scrub-enabled")
|
||||
}
|
||||
|
||||
/// Turn scrubbing on or off (General › Scrub Numeric Fields by Dragging; on until set).
|
||||
pub(crate) fn set_enabled(ctx: &Context, on: bool) {
|
||||
ctx.data_mut(|d| d.insert_temp(enabled_id(), on));
|
||||
}
|
||||
|
||||
fn enabled(ctx: &Context) -> bool {
|
||||
ctx.data(|d| d.get_temp(enabled_id())).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The scrub in progress.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Scrub {
|
||||
/// The zone dragged: a field or its label.
|
||||
zone: Id,
|
||||
/// The field's value when the drag started (in its units), which Escape puts back.
|
||||
start: f64,
|
||||
/// Drag not applied yet, in the field's units.
|
||||
pending: f64,
|
||||
/// The step `pending` counts in (it changes with Shift and Ctrl/Cmd).
|
||||
step: f64,
|
||||
/// A value was applied.
|
||||
moved: bool,
|
||||
/// Escape ended the drag.
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// Where the scrub of a numeric field stands this frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Phase {
|
||||
Idle,
|
||||
Dragging,
|
||||
/// Released this frame.
|
||||
Released,
|
||||
/// Escape ended it this frame.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub(crate) fn phase(ctx: &Context) -> Phase {
|
||||
let Some(s) = ctx.data(|d| d.get_temp::<Scrub>(state_id())) else { return Phase::Idle };
|
||||
if ctx.drag_stopped_id() == Some(s.zone) {
|
||||
if s.cancelled { Phase::Cancelled } else { Phase::Released }
|
||||
} else if ctx.is_being_dragged(s.zone) {
|
||||
Phase::Dragging
|
||||
} else {
|
||||
Phase::Idle
|
||||
}
|
||||
}
|
||||
|
||||
/// Before the panels draw: while a field is scrubbed, the edits its values make are one undo step.
|
||||
/// (The drag's first frame applies nothing, so the group is open before its first value.)
|
||||
pub(crate) fn begin_frame(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
if !app.scrub_group && matches!(phase(ctx), Phase::Dragging | Phase::Released) {
|
||||
app.session.begin_undo_group();
|
||||
app.scrub_group = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// After the panels: a finished drag closes its undo group; Escape undoes it.
|
||||
pub(crate) fn end_frame(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
let phase = phase(ctx);
|
||||
if !app.scrub_group || phase == Phase::Dragging {
|
||||
return;
|
||||
}
|
||||
app.scrub_group = false;
|
||||
app.session.end_undo_group(phase == Phase::Cancelled);
|
||||
app.sync_views();
|
||||
}
|
||||
|
||||
/// A field's label drawn this pass, waiting for the field after it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Label {
|
||||
rect: Rect,
|
||||
pass: u64,
|
||||
}
|
||||
|
||||
/// Note the label (text or icon) drawn at `rect`: a numeric field drawn right after it on the
|
||||
/// same line takes it as its label, which scrubs the field.
|
||||
pub(crate) fn note_label(ui: &Ui, rect: Rect) {
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.data_mut(|d| d.insert_temp(label_id(), Label { rect, pass }));
|
||||
}
|
||||
|
||||
/// The label noted just before the field at `field`, on its line.
|
||||
fn take_label(ui: &Ui, field: Rect) -> Option<Rect> {
|
||||
let l = ui.data_mut(|d| {
|
||||
let l = d.get_temp::<Label>(label_id());
|
||||
d.remove::<Label>(label_id());
|
||||
l
|
||||
})?;
|
||||
let gap = field.left() - l.rect.right();
|
||||
(l.pass == ui.ctx().cumulative_pass_nr() && (-1.0..=LABEL_GAP).contains(&gap) && field.y_range().contains(l.rect.center().y)).then_some(l.rect)
|
||||
}
|
||||
|
||||
/// Scrub numeric field `field`, its box at `rect`, showing `text`: `value` in its units, rounded
|
||||
/// to `decimals` places. → The value a drag on the field's label steps it to, or the value it
|
||||
/// started at when Escape cancels the drag. A click on the label (or a press held without
|
||||
/// stepping) focuses the field with all of `text` selected. Nothing for a field without a label,
|
||||
/// disabled or showing no value (objects that differ).
|
||||
pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option<f64>, decimals: i32) -> Option<f64> {
|
||||
let label = take_label(ui, rect)?;
|
||||
let value = value.filter(|v| v.is_finite())?;
|
||||
if !enabled(ui.ctx()) || !ui.is_enabled() {
|
||||
return None;
|
||||
}
|
||||
// On top of the label, which may sense clicks itself (selectable text).
|
||||
let resp = ui.interact(label, field.with("scrub-label"), Sense::click_and_drag());
|
||||
drag(ui, &resp, field, text, value, decimals)
|
||||
}
|
||||
|
||||
/// What a drag on `resp`, the label of field `field`, does this frame.
|
||||
fn drag(ui: &Ui, resp: &Response, field: Id, text: &str, value: f64, decimals: i32) -> Option<f64> {
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
if resp.clicked() {
|
||||
crate::widgets::focus_field(ui, field, text);
|
||||
return None;
|
||||
}
|
||||
if resp.drag_started() {
|
||||
// Typing elsewhere ends, as a click elsewhere ends it.
|
||||
ui.memory_mut(|m| m.stop_text_input());
|
||||
let s = Scrub { zone: resp.id, start: value, pending: 0.0, step: 0.0, moved: false, cancelled: false };
|
||||
ui.data_mut(|d| d.insert_temp(state_id(), s));
|
||||
}
|
||||
if !(resp.dragged() || resp.drag_stopped()) {
|
||||
return None;
|
||||
}
|
||||
let mut s = ui.data(|d| d.get_temp::<Scrub>(state_id())).filter(|s| s.zone == resp.id && !s.cancelled)?;
|
||||
// egui ends the drag on Escape: the field goes back to where it started.
|
||||
let out = if ui.input_mut(|i| i.consume_key(i.modifiers, Key::Escape)) {
|
||||
s.cancelled = true;
|
||||
s.moved.then_some(s.start)
|
||||
} else if resp.drag_stopped() {
|
||||
// Held without stepping: a slow click.
|
||||
if !s.moved {
|
||||
crate::widgets::focus_field(ui, field, text);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
step(ui, resp, &mut s, value, decimals)
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(state_id(), s));
|
||||
out
|
||||
}
|
||||
|
||||
/// The value this frame's drag steps `value` to, if it makes a whole step.
|
||||
fn step(ui: &Ui, resp: &Response, s: &mut Scrub, value: f64, decimals: i32) -> Option<f64> {
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let size = if mods.command {
|
||||
0.1
|
||||
} else if mods.shift {
|
||||
10.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// A count steps by whole numbers: Ctrl/Cmd makes it slower instead.
|
||||
let step = f64::max(size, 10f64.powi(-decimals));
|
||||
if step != s.step {
|
||||
(s.step, s.pending) = (step, 0.0);
|
||||
}
|
||||
s.pending += f64::from(resp.drag_delta().x / PX_PER_STEP) * size;
|
||||
// (Nudged off the float error of summed tenths.)
|
||||
let n = (s.pending / step + s.pending.signum() * 1e-9).trunc();
|
||||
// The drag's first frame only starts it: the app opens its undo group on the next.
|
||||
if n == 0.0 || resp.drag_started() {
|
||||
return None;
|
||||
}
|
||||
s.pending -= n * step;
|
||||
s.moved = true;
|
||||
Some(crate::widgets::round_to(value + n * step, decimals))
|
||||
}
|
||||
@@ -521,178 +521,167 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(tl!("Keyboard Shortcuts"))
|
||||
.id(egui::Id::new("dialog-shortcuts"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(640.0);
|
||||
ui.label(egui::RichText::new(tl!("Keyboard Shortcuts")).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
// Set row.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Set:")).color(t.text_dim));
|
||||
let cur = d.str("set");
|
||||
let mut opts: Vec<&str> = PRESETS.to_vec();
|
||||
if !PRESETS.contains(&cur.as_str()) {
|
||||
opts.push(CUSTOM);
|
||||
}
|
||||
// The sets listed are ours (translated); a set read from an imported file shows
|
||||
// its name as it is.
|
||||
if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i))
|
||||
&& let Some(p) = preset(name)
|
||||
{
|
||||
ov = p;
|
||||
d.fields.insert("set".into(), json!(name));
|
||||
d.fields.insert("__message".into(), json!(""));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
if ui.button(tl!("Import…")).clicked() {
|
||||
d.fields.insert("__import".into(), json!(true));
|
||||
}
|
||||
if ui.button(tl!("Export…")).clicked() {
|
||||
d.fields.insert("__export".into(), json!(true));
|
||||
}
|
||||
if ui.button(tl!("Reset to Defaults")).clicked() {
|
||||
ov.clear();
|
||||
d.fields.insert("set".into(), json!(PRESETS[0]));
|
||||
d.fields.insert("__message".into(), json!(tl!("All shortcuts reset to defaults.")));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
// Tabs + search.
|
||||
ui.horizontal(|ui| {
|
||||
let tab = d.str("tab");
|
||||
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
|
||||
if ui.selectable_label(tab == id, egui::RichText::new(tl!(label)).font(theme::semibold(12.5))).clicked() {
|
||||
d.fields.insert("tab".into(), json!(id));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut q = d.str("query");
|
||||
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(200.0)).changed() {
|
||||
d.fields.insert("query".into(), json!(q));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let tools = d.str("tab") != "menu";
|
||||
let q = d.str("query").to_lowercase();
|
||||
let selected = d.str("__selected");
|
||||
let rec = d.str("__recording");
|
||||
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(360.0, 16.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(360.0);
|
||||
ui.label(egui::RichText::new(if tools { tl!("Tool") } else { tl!("Command") }).color(t.text_dim).size(11.0));
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
for e in entries().iter().filter(|e| e.is_tool == tools) {
|
||||
let sc = effective_in(&ov, &e.key);
|
||||
let matches = e.label.to_lowercase().contains(&q)
|
||||
|| tl!(&e.label).to_lowercase().contains(&q)
|
||||
|| e.group.to_lowercase().contains(&q)
|
||||
|| tl!(&e.group).to_lowercase().contains(&q)
|
||||
|| sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q));
|
||||
if !q.is_empty() && !matches {
|
||||
continue;
|
||||
}
|
||||
let row = ui.horizontal(|ui| {
|
||||
let name = if e.is_tool || e.group.is_empty() {
|
||||
tl!(&e.label).to_string()
|
||||
} else {
|
||||
format!("{} › {}", tl!(&e.group), tl!(&e.label))
|
||||
};
|
||||
let r = ui
|
||||
.allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(360.0);
|
||||
ui.add(egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate())
|
||||
})
|
||||
.inner;
|
||||
if r.clicked() {
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
let label = if rec == e.key {
|
||||
tl!("Press keys…").to_string()
|
||||
} else {
|
||||
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
|
||||
};
|
||||
let changed = ov.contains_key(&e.key);
|
||||
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
|
||||
let b = ui
|
||||
.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key))
|
||||
.on_hover_text(tl!("Click, then press the new shortcut (Esc cancels)"));
|
||||
if b.clicked() {
|
||||
d.fields.insert("__recording".into(), json!(e.key));
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
});
|
||||
if scroll_to.as_deref() == Some(e.key.as_str()) {
|
||||
row.response.scroll_to_me(Some(egui::Align::Center));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Selected-row actions + message.
|
||||
ui.horizontal(|ui| {
|
||||
let sel = d.str("__selected");
|
||||
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
||||
if ui.button(tl!("Clear")).on_hover_text(tl!("Remove the shortcut")).clicked() {
|
||||
let _ = assign(&mut ov, &sel, None, true);
|
||||
d.fields.insert("set".into(), json!(CUSTOM));
|
||||
}
|
||||
if ui.button(tl!("Use Default")).clicked() {
|
||||
reset_one(&mut ov, &sel);
|
||||
}
|
||||
});
|
||||
let conflict = d.str("__conflict");
|
||||
if !conflict.is_empty() && ui.button(tl!("Go to Conflict")).clicked() {
|
||||
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
|
||||
d.fields.insert("tab".into(), json!(tab));
|
||||
d.fields.insert("query".into(), json!(""));
|
||||
d.fields.insert("__selected".into(), json!(conflict));
|
||||
d.fields.insert("__scrollTo".into(), json!(conflict));
|
||||
d.fields.insert("__conflict".into(), json!(""));
|
||||
}
|
||||
});
|
||||
let msg = d.str("__message");
|
||||
if !msg.is_empty() {
|
||||
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
|
||||
crate::dialogs::modal::show(ctx, tl!("Keyboard Shortcuts"), egui::Id::new("dialog-shortcuts"), -20.0, 20, |ui| {
|
||||
ui.set_width(640.0);
|
||||
crate::dialogs::modal::heading(ui, tl!("Keyboard Shortcuts"));
|
||||
ui.add_space(10.0);
|
||||
// Set row.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Set:")).color(t.text_dim));
|
||||
let cur = d.str("set");
|
||||
let mut opts: Vec<&str> = PRESETS.to_vec();
|
||||
if !PRESETS.contains(&cur.as_str()) {
|
||||
opts.push(CUSTOM);
|
||||
}
|
||||
let n = all_conflicts(&ov).len();
|
||||
if n > 0 {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tn(
|
||||
n as u64,
|
||||
"{n} shortcut is assigned more than once",
|
||||
"{n} shortcuts are assigned more than once",
|
||||
))
|
||||
.color(t.text_dim)
|
||||
.size(11.0),
|
||||
);
|
||||
// The sets listed are ours (translated); a set read from an imported file shows
|
||||
// its name as it is.
|
||||
if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i))
|
||||
&& let Some(p) = preset(name)
|
||||
{
|
||||
ov = p;
|
||||
d.fields.insert("set".into(), json!(name));
|
||||
d.fields.insert("__message".into(), json!(""));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
if ui.button(tl!("Import…")).clicked() {
|
||||
d.fields.insert("__import".into(), json!(true));
|
||||
}
|
||||
if ui.button(tl!("Export…")).clicked() {
|
||||
d.fields.insert("__export".into(), json!(true));
|
||||
}
|
||||
if ui.button(tl!("Reset to Defaults")).clicked() {
|
||||
ov.clear();
|
||||
d.fields.insert("set".into(), json!(PRESETS[0]));
|
||||
d.fields.insert("__message".into(), json!(tl!("All shortcuts reset to defaults.")));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
// Tabs + search.
|
||||
ui.horizontal(|ui| {
|
||||
let tab = d.str("tab");
|
||||
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
|
||||
if ui.selectable_label(tab == id, egui::RichText::new(tl!(label)).font(theme::semibold(12.5))).clicked() {
|
||||
d.fields.insert("tab".into(), json!(id));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut q = d.str("query");
|
||||
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(200.0)).changed() {
|
||||
d.fields.insert("query".into(), json!(q));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let tools = d.str("tab") != "menu";
|
||||
let q = d.str("query").to_lowercase();
|
||||
let selected = d.str("__selected");
|
||||
let rec = d.str("__recording");
|
||||
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(360.0, 16.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(360.0);
|
||||
ui.label(egui::RichText::new(if tools { tl!("Tool") } else { tl!("Command") }).color(t.text_dim).size(11.0));
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
for e in entries().iter().filter(|e| e.is_tool == tools) {
|
||||
let sc = effective_in(&ov, &e.key);
|
||||
let matches = e.label.to_lowercase().contains(&q)
|
||||
|| tl!(&e.label).to_lowercase().contains(&q)
|
||||
|| e.group.to_lowercase().contains(&q)
|
||||
|| tl!(&e.group).to_lowercase().contains(&q)
|
||||
|| sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q));
|
||||
if !q.is_empty() && !matches {
|
||||
continue;
|
||||
}
|
||||
let row = ui.horizontal(|ui| {
|
||||
let name = if e.is_tool || e.group.is_empty() {
|
||||
tl!(&e.label).to_string()
|
||||
} else {
|
||||
format!("{} › {}", tl!(&e.group), tl!(&e.label))
|
||||
};
|
||||
let r = ui
|
||||
.allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(360.0);
|
||||
ui.add(egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate())
|
||||
})
|
||||
.inner;
|
||||
if r.clicked() {
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
let label = if rec == e.key {
|
||||
tl!("Press keys…").to_string()
|
||||
} else {
|
||||
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
|
||||
};
|
||||
let changed = ov.contains_key(&e.key);
|
||||
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
|
||||
let b = ui
|
||||
.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key))
|
||||
.on_hover_text(tl!("Click, then press the new shortcut (Esc cancels)"));
|
||||
if b.clicked() {
|
||||
d.fields.insert("__recording".into(), json!(e.key));
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
});
|
||||
if scroll_to.as_deref() == Some(e.key.as_str()) {
|
||||
row.response.scroll_to_me(Some(egui::Align::Center));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Selected-row actions + message.
|
||||
ui.horizontal(|ui| {
|
||||
let sel = d.str("__selected");
|
||||
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
||||
if ui.button(tl!("Clear")).on_hover_text(tl!("Remove the shortcut")).clicked() {
|
||||
let _ = assign(&mut ov, &sel, None, true);
|
||||
d.fields.insert("set".into(), json!(CUSTOM));
|
||||
}
|
||||
if ui.button(tl!("Use Default")).clicked() {
|
||||
reset_one(&mut ov, &sel);
|
||||
}
|
||||
});
|
||||
let conflict = d.str("__conflict");
|
||||
if !conflict.is_empty() && ui.button(tl!("Go to Conflict")).clicked() {
|
||||
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
|
||||
d.fields.insert("tab".into(), json!(tab));
|
||||
d.fields.insert("query".into(), json!(""));
|
||||
d.fields.insert("__selected".into(), json!(conflict));
|
||||
d.fields.insert("__scrollTo".into(), json!(conflict));
|
||||
d.fields.insert("__conflict".into(), json!(""));
|
||||
}
|
||||
});
|
||||
let msg = d.str("__message");
|
||||
if !msg.is_empty() {
|
||||
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
|
||||
}
|
||||
let n = all_conflicts(&ov).len();
|
||||
if n > 0 {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tn(
|
||||
n as u64,
|
||||
"{n} shortcut is assigned more than once",
|
||||
"{n} shortcuts are assigned more than once",
|
||||
))
|
||||
.color(t.text_dim)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("OK")).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
d.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or(json!({})));
|
||||
let import = d.fields.remove("__import").is_some();
|
||||
let export = d.fields.remove("__export").is_some();
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user