Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 8c69173bb7 #1089: an image copied in a browser pastes as the picture, also from its markup
A browser's Copy Image puts the picture on the clipboard with text next
to it. Paste already took the picture when that text was only the
image's address (#597), but some browsers (seen on Fedora with KDE) put
the image's HTML there instead, `<img src="…" class="…" style="…">`,
which isn't an address, so the markup was pasted as type.

names_a_picture also takes markup whose only content is <img> elements
(no text outside its tags) as naming the picture, and Paste then reads
the bitmap. Markup with words in it, and an address alone, still paste
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:51:41 +07:00
BFlat c7d29e5b92 Merge pull request #1134 from storytold/fix/1129-toolbar-proxy-size
#1129: the toolbar's paint buttons fit its width
2026-10-11 13:51:27 +07:00
bflatasticandClaude Opus 5.5 b9aca50c3c #1129: the toolbar's paint buttons fit its width
The Fill/Stroke proxy stayed 36 pt and the Color, Gradient and None
buttons 7.5 pt in both toolbar layouts, so in two columns they sat small
in a wide strip and were hard to hit (in one column too). The proxy is
now 54 pt in two columns, the three buttons 11 pt in one column and
17 pt in two, and in two columns Draw Normal, Draw Behind and Draw
Inside sit side by side, each setting its mode (view.drawMode {mode}),
as Illustrator's two-column toolbar shows them; one column keeps the
button that cycles them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:26:24 +07:00
BFlat a2314cf803 Merge pull request #1104 from bobtherobot/fix/direct-selection-drag-copy
Direct Selection: drag paths selected whole by an anchor; Alt-copy at any time, snapping by that anchor
2026-10-11 12:26:09 +07:00
BFlat 6c04fb56c8 Merge pull request #1101 from p0lyg0n/fix/pdf-japanese-spacing
PDF/.ai import: glyphs spaced each their own way keep their place
2026-10-11 12:25:51 +07:00
BFlat 0f162176be Merge pull request #1091 from webdevtodayjason/fix/plugin-int-precision
Plug-ins: whole-number parameters keep their exact value and stay in range
2026-10-11 12:25:38 +07:00
BFlat 91eeb5610c Merge pull request #1082 from imachus/pr/ja-vpal
Type: Proportional Metrics in vertical type — upright glyphs on the font's proportional heights, `vpal` (#966)
2026-10-11 12:25:23 +07:00
BFlat 632cff0068 Merge pull request #1081 from NoJuanNobody/fix/893-shape-builder-seams
#893: Shape Builder merges leave no seams on grids of lines
2026-10-11 12:25:13 +07:00
bflatasticandClaude Opus 5.5 1992239325 Review: Direct Selection drags in the UI checklist; ROADMAP (#1104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:47 +07:00
bflatasticandClaude Opus 5.5 ded39b51cc ROADMAP: PDF import keeps glyphs spaced each their own way in the progress log (#1101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
bflatasticandClaude Opus 5.5 5e9b7d09a2 Review: a whole number past i64::MAX is past any maximum; ROADMAP (#1091)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 a75b465419 Selection tools: Alt-drag copies snap to the original they leave behind
The snap targets of a copy were gathered when Alt went down, from the document
as previewed, where the original had already moved with the drag, so the
original was never a target where it stays. Now both target sets (for a move,
and for a copy, which includes the original) are gathered when the drag
begins, and Alt picks between them each frame: for Direct Selection drags by
an anchor and for whole-object moves (the Selection tool's, and Direct
Selection's from a segment or fill).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Hiroshi InukaiandClaude Opus 5.5 aa9f0153a4 PDF/.ai import: glyphs spaced each their own way keep their place
Character tightening and optical kerning space each glyph differently, as
Japanese titles often are. The importer evened a line out with one
tracking value (type on a path took none), leaving glyphs up to an eighth
of an em out of place. When the file's font is installed and one value
can't place every glyph, each character now keeps its own tracking,
measured along the baseline (along the path for type on a path).

Glyphs closed up by up to half an em (the proportional widths of CJK
punctuation) also continue their line instead of starting another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 af98309c01 Direct Selection: Alt during a drag of whole paths turns the copy on and off
Pressing an anchor of paths that are all selected whole now moves them as
objects held by that anchor, so Alt pressed or released during the drag turns
the copy on and off (the original shows or not), as with the Selection tool.
The anchor keeps snapping all along, onto the original only while a copy
leaves it behind. With only some anchors selected the drag still moves just
those.

A committed move drag is now named for what it did in the end (Copy or Move),
for the Selection tool too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 5dcc64e757 Direct Selection: Alt-drag copies snap by the anchor pressed
Alt-dragging a path by an anchor moved the copy with whole-object snapping
(bounds only, from the press point), so the anchor couldn't land exactly on
another anchor. Now the pressed anchor (its own coordinates, not the pointer's)
snaps as a dragged anchor does: onto other paths' anchors and segments and
Smart Guides, and onto the original the copy leaves behind. The history says
Copy for an Alt-drag.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 abfcd0800e Direct Selection: Alt-drag by an anchor copies the whole path
Alt-pressing a segment already picked the whole path and Alt-drag copied it,
but a press on an anchor ignored Alt and only moved the selected anchors. Now
Alt on an anchor picks the whole path too, so Alt-drag duplicates it as the
Selection tool's does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 fe5955e521 Direct Selection: pressing an anchor of a path selected whole keeps all its anchors
A path with all its anchors selected is stored whole (no partial entry), so
pressing one of its anchors looked unselected and replaced the selection with
that anchor alone; the drag then moved just it. Now an anchor of a path
selected whole counts as selected: the press keeps the selection and the drag
moves every anchor. Shift-press still deselects that one anchor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
bflatasticandClaude Opus 5.5 fe9838f296 Review: progress docs for proportional vertical metrics; ROADMAP (#1082)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:45 +07:00
bflatasticandClaude Opus 5.5 9896eda208 Review: progress docs dated the day #893 landed; ROADMAP (#1081)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:45 +07:00
webdevtodayjason aecf88c1c1 Plug-ins: whole-number parameters keep their exact value and stay in range 2026-10-11 12:24:45 +07:00
imachusandClaude Opus 5.5 2c8b8b0cc1 Type: Proportional Metrics in vertical type — upright glyphs on the font's proportional heights, vpal (#966)
The second half of #966. With Proportional Metrics on, a vertical segment is
also shaped top to bottom, with and without `vpal`: the difference gives
each glyph's change of advance down the column and its x and y placement.
An upright glyph (not tate-chu-yoko) in a font with vertical metrics takes
them on top of its cell, so it moves with its shorter cell; a font without
vertical metrics keeps `upright_cell`'s cell, and with the switch off
nothing is shaped twice and the column is as before.

As in horizontal type, Line-end Punctuation Half Width takes nothing more
off the punctuation `vpal` re-spaced and trims the punctuation it leaves
full height as before. Docs: mcp.md, and Proportional Metrics leaves the
missing lists in gaps.md, type-parity.md and target-app-parity.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:45 +07:00
webdevtodayjason f79858c12c Contributors: add webdevtodayjason (Jason Brashear)
# Conflicts:
#	contributors/people.toml
2026-10-11 12:24:45 +07:00
Alejandro LondonoandClaude Opus 5.5 8957545c33 #893: merge Shape Builder faces in the planar map
After linesweeper 0.5 (#1028 on main), uniting the faces of a grid of
lines could split the merged strip again even with their corners shared
exactly: a sweep over seven or more faces moved shared vertices by about
1e-6 (within its eps) and the edges between faces no longer cancelled.

Faces cut by open paths now merge like closed shapes' faces already do
(FaceMerger::exact): in the planar map itself. build() is split into
map() (the graph and its faces) and build(); Map::merge traces the
boundary of the picked faces along the map's own edges, dropping the
edges between two picked faces and the loose ends inside them, so no
sweep runs on the result. FaceMerger finds each face again by a point
inside it, checks the area, and keeps the boolean union as a fallback.

Tests: the regression test merges through FaceMerger; a grid test
covers a ring with its hole, cells touching at a corner, a loose end,
and that the map path (not the fallback) produced the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:44 +07:00
Alejandro LondonoandClaude Opus 5.5 bc762eee99 Contributors: add my names (NoJuanNobody)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:44 +07:00
Alejandro LondonoandClaude Opus 5.5 cfa36fc55f #893: Shape Builder merges leave no seams on grids of lines
The planar map merged sweep endpoints within eps into one vertex id but
kept each segment's own copy of the coordinates, so faces meeting at a
vertex could disagree on that corner by ~1e-13. Uniting them then failed
to cancel their shared edge, and a merged strip came back as several
touching subpaths whose stroke drew the inner edges as leftover lines.

Pin every edge's ends to its vertex's canonical position, so faces share
their corners bit for bit. Regression test from the reporter's grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:44 +07:00
BFlat a3b6c3d969 Merge pull request #1099 from Emlembow/fix/867-macos-file-sheets
Fix #867: macOS file dialogs are sheets, and no dialog runs inside the event handler
2026-10-11 12:14:39 +07:00
EmlembowandClaude Opus 5.5 7ff0269a1d Fix #867: macOS file dialogs are sheets, and no dialog runs inside the event handler
The crash: rfd's blocking dialog ran -[NSSavePanel runModal] inside
winit's event handler; when a resize of the panel ends, AppKit runs the
closures winit queued meanwhile, and winit's re-entrancy panic fires
inside an Objective-C block, where it can't unwind, so the app aborts.
As PhotoCraft (#673) and PdfCraft (#145) ship it:

- macOS: picks::ask shows an rfd::AsyncFileDialog as a sheet on the
  window (beginSheetModalForWindow returns at once); a waiter thread
  sends the paths and wakes the window. A pick asked outside an entry is
  refused and logged instead of running a modal. Linux keeps its thread
  (#592) and now also wakes the window; Windows is unchanged.
- Nine paths that asked outside an entry now run as entries: Load
  Profile, Swatches and Graphic Styles Other Library, the shortcut
  editor's Import and Export, and the presets dialogs' Export.
- While a dialog is open: the macOS menu bar is inert except Hide, Hide
  Others and Minimize; Quit, Close, Close All and the close button are
  refused; Finder/Dock opens and the IME discard wait; another dialog
  is refused with a status message. The answer re-activates the
  document that asked (by uid) and Save Changes finds its document by
  uid; a frame guard clears a stale picks entry after a recovered panic.
- ui.inspect reports fileDialog.

Squashed onto current main by the maintainer (with main's automation
sandbox wrappers and font-name menus kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:14:17 +07:00
BFlat cd1fac5393 Merge pull request #1127 from storytold/fix/1111-paragraph-align-shortcuts
#1111: Ctrl+Shift+L, C, R, J and F align paragraphs
2026-10-11 12:11:21 +07:00
bflatasticandClaude Opus 5.5 525b79a51a #1111: Ctrl+Shift+L, C, R, J and F align paragraphs
The paragraph alignment shortcuts were missing. Cmd/Ctrl+Shift+L, C and
R align the selected type's paragraphs left, centred and right, J
justifies them with the last line aligned left and F justifies all
lines; while the Type tool edits text they act on the paragraphs its
selection touches, through the Paragraph panel's own path. They are UI
commands (type.alignLeft, …), listed in Keyboard Shortcuts, and enabled
while type is selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:58:51 +07:00
BFlat 6ca54579cd Merge pull request #1122 from storytold/fix/1095-artboard-label-rotated
#1095: an artboard's name stays at its corner in a rotated view
2026-10-11 11:43:30 +07:00
bflatasticandClaude Opus 5.5 558b8b621c #1095: an artboard's name stays at its corner in a rotated view
The name was placed at the top-left corner of the artboard's bounding box
on screen, which a rotated view (Rotate View) turns away from the
artboard's own corner, so the name floated off beside it. It now sits
above the artboard's top-left corner as the view maps it, no wider than
the artboard's on-screen width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:11 +07:00
BFlat 012fe675a2 Merge pull request #1110 from dotCooCoo/feat/ase-save-count
Swatches: swatch.library.save counts only the swatches the file holds
2026-10-11 11:35:23 +07:00
BFlat 12ad39e7cc Merge pull request #1109 from dotCooCoo/feat/lock-hide-above-deselect
Object: Lock and Hide › All Artwork Above deselect the objects they lock or hide
2026-10-11 11:35:13 +07:00
BFlat f657110510 Merge pull request #1108 from dotCooCoo/feat/align-moves-pattern-tiles
Part of #394: Align and Distribute move pattern tiles when Transform Pattern Tiles is on
2026-10-11 11:35:02 +07:00
BFlat b10d6c63ee Merge pull request #1107 from dotCooCoo/feat/setprops-hidden-leave-selection
MCP: object.setProps removes the objects it hides or locks from the selection
2026-10-11 11:34:46 +07:00
BFlat 3eeb22179d Merge pull request #1106 from dotCooCoo/feat/align-refuses-unknown-values
Align, Reflect and Shear: object.align needs a direction; unknown choices are errors
2026-10-11 11:34:30 +07:00
BFlat b0ba64a940 Merge pull request #1105 from dotCooCoo/feat/align-active-artboard
Align: Align to Artboard uses the active artboard
2026-10-11 11:34:20 +07:00
BFlat 050e355e6c Merge pull request #1088 from dotCooCoo/feat/key-object-needs-two
Align: a key object needs two or more selected objects
2026-10-11 11:34:04 +07:00
BFlat b1e4c4e522 Merge pull request #1083 from dotCooCoo/feat/drops-open-libraries
Drops: libraries, presets and plug-ins open on a drop, as File › Open opens them
2026-10-11 11:33:50 +07:00
bflatasticandClaude Opus 5.5 c7b46dbb3e Review: palette writing keeps the swatches PaletteFormat::holds names; ROADMAP (#1110)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:07 +07:00
bflatasticandClaude Opus 5.5 4d52b043cf Review: Lock and Hide › All Artwork Above use Selection::deselect_uneditable; ROADMAP (#1109)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:07 +07:00
dotCooCoo 2b93e8a930 Swatches: swatch.library.save counts only the swatches the file holds
swatch.library.save replied with the number of solid colors and
gradients it was asked to save, including the gradients the format
leaves out. For a new document saved as .ase, count was 50 and the file
held 46 colors. The .gpl and .ase writers keep solid colors only, and
the CSS writer leaves freeform gradients out.

count is now the number of swatches written. The new
PaletteFormat::holds returns whether a file of a format keeps a swatch
with a given paint, and swatch.library.save counts the swatches for
which it returns true. The reply has the same fields as before.

Tests: after a freeform gradient is added to a new document's swatches,
the test checks that count has these values: 51 for .vcswatches, 46 for
.gpl and .ase, and 50 for CSS. Each file holds that many swatches.

Docs: docs/mcp.md.
2026-10-11 11:33:07 +07:00
bflatasticandClaude Opus 5.5 cc6fd8a5dc Review: Align and Distribute share one apply_moves; ROADMAP (#1108)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:06 +07:00
dotCooCoo cd34e4097a Object: Lock and Hide › All Artwork Above deselect the objects they lock or hide
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above
lock or hide the objects that are above the selection in its layer and
overlap it. Such an object can be a group or a sublayer that holds
another selected object. The commands left that object selected, the
key object included. Align then moved the locked object, or aligned
the other selected objects to the hidden key object. object.lock and
object.hide clear the selection, and layer.setProps, which the Layers
panel's eye and lock columns run, deselects what it hides or locks.

Both commands now deselect every selected object that is locked or
hidden after the command, as layer.setProps does, and clear the key
object when it is one of them. Undo selects the objects again and
restores the key object.

Docs: the object.lock.above and object.hide.above params text,
docs/target-app-parity.md.
2026-10-11 11:33:06 +07:00
bflatasticandClaude Opus 5.5 d10962ac45 Review: one Selection::deselect_uneditable for the hide and lock commands; ROADMAP (#1107)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:05 +07:00
dotCooCoo a31cdab606 Part of #394: Align and Distribute move pattern tiles when Transform Pattern Tiles is on
object.align, object.distribute and object.distributeSpacing moved
objects without their pattern tiles, whatever General › Transform
Pattern Tiles was set to. They now follow the preference as Move,
Nudge and the Selection tool do: with it on, pattern fills and strokes
move with the art. Each also takes a `patterns` param for one call, as
the transforms do. When the art uses a pattern, the journal records the
choice, and a recorded action plays back with it.

Docs: docs/mcp.md (transformPatternTiles), docs/target-app-parity.md.
2026-10-11 11:33:05 +07:00
dotCooCoo 975dea056c MCP: object.setProps removes the objects it hides or locks from the selection
object.setProps with visible: false or locked: true left the objects
it hid or locked selected, the key object included, and kept selected
objects inside a group or layer it hid or locked. Align then moved a
locked object, and aligned the other objects to a hidden key object.
object.lock and object.hide clear the selection, and layer.setProps,
which the Layers panel's eye and lock columns run, deselects what it
hides or locks.

After a call with visible: false or locked: true, object.setProps now
deselects every selected object that is then hidden or locked, as
layer.setProps does: the selected objects it hides or locks, selected
objects inside them, and selected objects that were hidden or locked
before the call. It clears the key object when the key is one of
them. Undo selects them again and restores the key object. A call
with visible: false or locked: true that changes nothing records no
undo step and still deselects them, as object.lock and object.hide
do. A call that sets neither, such as an opacity change from the
Control bar or the Transparency panel, leaves the selection as it is.

Docs: the object.setProps params text, docs/mcp.md, docs/target-app-parity.md.
2026-10-11 11:33:05 +07:00
bflatasticandClaude Opus 5.5 0a8d334510 Review: Align and Shear choice errors reuse the catalog's one-of message; ROADMAP (#1106)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:04 +07:00
dotCooCoo a2abe63e8d Align, Reflect and Shear: object.align needs a direction; unknown choices are errors
object.align with neither horizontal nor vertical returned success
and moved nothing. The command palette, and a key bound to it in
Keyboard Shortcuts, run it with no params. It now returns
"give horizontal or vertical", as object.distribute already did.
An action recorded while Align ran from the palette or a bound key
contains an object.align step with no params. Playing that action now
fails at that step, and command.batch rolls back the whole action.

Values outside a parameter's choices were read as another choice or
ignored. Align ignored an unknown horizontal or vertical and aligned
to the selection for an unknown to value. Distribute read an unknown
direction as center. Distribute Spacing and Shear read any axis other
than "vertical" as horizontal, and Reflect read any axis other than
"horizontal" or a number as vertical. A bounds or to value that was
not a string fell back to the default, and a spacing that was not a
number gave automatic spacing. The commands now return an error that
names such a value, before they move anything or record an undo
step. When a to, bounds, axis or spacing value is missing or null,
the command uses the parameter's default: Distribute Spacing without
an axis spaces the objects horizontally.

The run_command description and docs/mcp.md showed object.align with
an align param it does not have; they now use horizontal. The new
status-bar messages have rows in the de, es, fr, it, ja, ru and uk
catalogs.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-11 11:33:03 +07:00
bflatasticandClaude Opus 5.5 0ff9e35374 Review: lone-object Align wording after #1088; ROADMAP (#1105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:02 +07:00
bflatasticandClaude Opus 5.5 e02c2dc899 ROADMAP: a key object needs two or more selected objects in the progress log (#1088)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:01 +07:00
dotCooCoo 8e4158b364 Align: Align to Artboard uses the active artboard
Align to Artboard aligned the selection to the artboard under its
center, or to the first artboard when no artboard was under the
center. A press with a selection tool inside an artboard makes that
artboard the active one (#693). With the Selection tool, a
Shift-click on an empty spot inside an artboard also makes it active
and keeps the selection. Align to Artboard did not use the active
artboard.

object.align takes artboard, a 0-based artboard index, and uses it
when its to param is "artboard". The app passes its active artboard,
the one the Artboards panel highlights, to each object.align it runs
that names none, as it does for Paste in Place, Paste in Front, Paste
in Back (#693) and Select › All on Active Artboard (#1006). MCP calls
in remote mode also get it. Without artboard (headless MCP and the
CLI), or with an index the document doesn't have, object.align aligns
to the artboard under the center of the selection, or to the first
artboard when no artboard is under the center.

command.batch takes artboard too, and the app passes it the active
artboard. The batch passes it on to each step of these five commands
that names none, when the step runs. When the document a step runs in
has no artboard at that index (for example, after an earlier step
deleted the active artboard when it was the last one), the step gets
that document's last artboard. A step in a document that an earlier
step opened, created or switched to gets the same index, not that
document's own active artboard.

With Align to Selection, one selected object aligned from the Align
panel, the Control bar or the Properties panel is aligned to the
active artboard too, unless it is the key object.

Recorded actions leave out the artboard that the app passes to these
commands and to the batch. The Actions panel plays an action as a
batch. Its Align to Artboard, All on Active Artboard and in-place
paste steps that name no artboard use the artboard that is active
when it plays, and the default Center on Artboard action now centers
on the active artboard. In-place paste steps recorded since #693 and
before this change still paste onto the artboard that was active when
they were recorded.

Tests cover object.align with and without artboard on three artboards;
a Shift-click that makes an artboard active, after which Align to
Artboard aligns to that artboard but keeps an artboard the call names;
a batch that passes its artboard to each step when the step runs,
after a step deletes the last artboard and in a new document; recorded
actions that leave the artboard out; and recorded Align to Artboard
and Paste in Place steps and the default Center on Artboard action,
which play on the artboard that is active when they play.

Docs: docs/mcp.md (Align to Artboard), docs/target-app-parity.md.
2026-10-11 11:33:01 +07:00
bflatasticandClaude Opus 5.5 008644ea2c ROADMAP: dropped libraries, presets and plug-ins open in the progress log (#1083)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:32:59 +07:00
dotCooCoo a8eb9024da Align: a key object needs two or more selected objects
Selecting two objects, clicking one to make it the key object and then
Shift-clicking the other left the remaining object as the key. The
canvas still drew that object with the thick key-object outline. The
Align panel aligned the object to itself, so the object did not move.

The key is now cleared after any change that leaves no other selected
object outside it, such as a Shift-click, or hiding or deleting the
other object's row in the Layers panel. On a single selected object,
or on a group selected with only its own members, select.key sets no
key.

With one object selected and Align to Key Object chosen, the Align
panel, the Control bar and the Properties panel ran object.align with
to: "key", which fails with "no key object" when the selection has no
key. All three now align a single selected object to the artboard
whatever Align To is set to.

Docs: docs/target-app-parity.md.
2026-10-11 11:32:59 +07:00
dotCooCoo 05bc89827b Drops: libraries, presets and plug-ins open on a drop, as File › Open opens them
With a document open, the desktop app ran File › Place on a swatch or
graphic style library, a Libraries panel file, a presets file or a
plug-in dropped on it. A desktop drop has no position, and without one
the app opened only documents and ran File › Place on every other file.
On the web, the app also ran File › Place on these files when they were
dropped on the canvas. File › Place can't read them, and the drop
failed with "Couldn't place X: ... not a format VectorCraft reads".

VectorcraftApp::drop_target now returns Open for these files, with or
without a drop position, and they open as File › Open opens them:
plug-ins are installed, presets are imported, swatch and graphic style
libraries open in the library panel, and a Libraries panel file is
added to the Libraries panel as a new library. A dropped library,
presets file or plug-in that has a path is added to Open Recent Files
when it opens, as File › Open adds it. io::opens_as returns the kind of
each of these files from its extension, and drop_target and
io::open_bytes both call it. The rules from #472 still apply to
documents, pictures and text. On the web the browser gives no file name
before the drop, so the app still outlines the canvas while such a file
is dragged over it.

Tests: drop_target returns Open for each of the eleven extensions, with
no position and with a position on the canvas. A .gpl dropped through
the desktop drop path loads in the library panel and is added to Open
Recent Files, and a .vclibrary dropped the same way becomes a new
library in the Libraries panel.

Docs: docs/control-protocol.md (file.place), docs/development.md
(Drag-and-drop), docs/target-app-parity.md (Evidence, UI).
2026-10-11 11:32:58 +07:00
BFlat e3978d158a Merge pull request #1121 from storytold/fix/1085-dmg-strict
#1085: the DMG's app passes codesign --strict
2026-10-11 11:25:47 +07:00
bflatasticandClaude Opus 5.5 83ad194819 #1085: the DMG's app passes codesign --strict
`hdiutil makehybrid -hfs` gave every file in the image a
com.apple.FinderInfo, which `codesign --verify --strict` rejects as
"Finder information, or similar detritus" on the app in the DMG and on
copies installed from it, so compliance and MDM checks reported the
notarized app as damaged. As PdfCraft did (storytold/pdfcraft#823,
shipped in PdfCraft 0.6.0), the DMG is now made with
`hdiutil create -srcfolder -fs HFS+ -format UDZO`, retried when a CI
runner's device is busy, and the app mounted from the finished DMG must
pass `codesign --verify --strict --deep` before the release goes on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:24:36 +07:00
BFlat 61c333b8ed Merge pull request #1120 from storytold/fix/1102-eps-layers-page
#1102: an EPS keeps its page and stacked text strokes through its layers
2026-10-11 11:21:41 +07:00
bflatasticandClaude Opus 5.5 0cfdefa853 #1102: an EPS keeps its page and stacked text strokes through its layers
Since #1025 reads global colours, more Illustrator EPS files open through
their layers, and for one reported file that gave a worse result than its
page did in 0.8.0:
- the page became the editing data's artboard (344 pt tall) instead of the
  file's own bounding box (358 pt), cutting off art reaching above it. An
  EPS whose page is its art's box now keeps that page on the layers path
  too, as it does on the page path.
- prune_unseen took out text objects the page does draw: a red stroke
  stacked over a dark red one differs from it by less than the lightness
  margin, so the page looked no different without it. Pruning now weighs
  each text object by colour (any of red, green, blue more than 64 apart);
  the page check itself still compares by lightness, so colours written
  as their CMYK equivalents still match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:03:11 +07:00
BFlat 6c3f9f60aa Merge pull request #1113 from storytold/fix/width-point-index
Width points: set and copy refuse a malformed index
2026-10-11 10:50:05 +07:00
bflatasticandClaude Opus 5.5 6fd342563d Width points: set and copy refuse a malformed index
stroke.widthPoint.set and .copy read `index` with `as_u64() as usize`:
a malformed index ("bad", -1, 1.5) was taken as no index, so set added a
new point instead of failing, and a huge one wrapped round on wasm32.
They now read it through point_indices, as remove does since #1079:
anything but an index of an existing point is "no such width point".
A null index is still no index (set adds a point).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:49:42 +07:00
BFlat 8a31fda88f Merge pull request #1112 from storytold/fix/checkbox-outlines
#885: every checkbox keeps a visible box at rest
2026-10-11 10:49:35 +07:00
bflatasticandClaude Opus 5.5 c4e139af19 #885: every checkbox keeps a visible box at rest
The theme leaves egui's own checkbox without an outline while inactive,
so an unchecked box was invisible and a checked one a bare tick. #1080
moved Preferences to widgets::check; the remaining ui.checkbox uses
follow: the Properties panel's Snap to Point, Snap to Grid and Scale
Strokes & Effects, Revolve's Keep visible surfaces and Shading, Save
options, Export for Screens, the Variables dialog and plug-in forms.

widgets::check_name shows a document's name (an artboard's, an asset's)
as it is, never translated; the check and radio rows translate their
labels in the public functions, so choice_row draws text as given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:27:46 +07:00
skylar taylor-barrick 389f75fa7b Merge pull request #1090 from skylartaylor/codex/ui-suite-integration-20261010
Adopt shared docking with panel menus and saved return locations
2026-10-10 20:17:53 -06:00
skylar taylor-barrick 34eb19a5ab UI.6: qualify shared UI integration on current main 2026-10-10 19:39:47 -06:00
skylar taylor-barrick 0c57b1d5d2 UI.6: reconcile shared docking with native panel sets 2026-10-10 19:28:31 -06:00
BFlat 5d847ffc88 Merge pull request #1080 from Ni-zav/fix/885-checkbox-visibility-rebased-20261011
fix(ui): show Preferences checkbox outlines in every theme
2026-10-11 07:44:34 +07:00
BFlat 2a52b2901f Merge pull request #1079 from Ni-zav/fix/widthpoint-remove-indices-rebased-20261011
fix(width): validate every explicit width-point removal index
2026-10-11 07:44:20 +07:00
BFlat 7f4f7bd8d2 Merge pull request #1078 from Ni-zav/fix/shaper-target-validation-rebased-20261011
fix(shaper): reject invalid explicit scribble targets
2026-10-11 07:44:05 +07:00
BFlat 2729483851 Merge pull request #1077 from Ni-zav/fix/layer-options-target-validation-rebased-20261011
fix(layers): validate explicit Layer Options targets
2026-10-11 07:43:51 +07:00
bflatasticandClaude Opus 5.5 5226d5a728 Review: Preferences checkboxes pass their label untranslated to widgets::check; ROADMAP (#1080)
widgets::check translates the label itself, so tl!(label) translated it twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:15 +07:00
bflatasticandClaude Opus 5.5 dec085e1e5 Review: width-point removal reuses point_indices; ROADMAP (#1079)
point_indices moves from freeform.rs to the cmd param helpers so
stroke.widthPoint.remove parses `index` and `indices` with it, and a
malformed entry reports the translated "no such width point".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:14 +07:00
bflatasticandClaude Opus 5.5 eaf96c60ae Review: Shaper reads its explicit ids in one line; ROADMAP (#1078)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:14 +07:00
Nigel Ihza Firdausy 85bbc35176 fix(ui): show Preferences checkbox outlines in every theme
Reapply on current upstream main.
2026-10-11 07:43:14 +07:00
Nigel Ihza Firdausy 3ce51a3487 fix(width): validate explicit width-point removal indices
Reapply on current upstream main.
2026-10-11 07:43:14 +07:00
bflatasticandClaude Opus 5.5 a031ff6c7b Review: Layer Options reuses the translated id messages; ROADMAP (#1077)
The new id errors reuse messages the catalogs already translate
(`ids` must be an array of object ids, object ids are non-negative
integers, no such layer or object), so complete_languages_translate_every_message
keeps passing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:13 +07:00
Nigel Ihza Firdausy 0d748500d6 fix(shaper): reject invalid explicit scribble targets
Reapply on current upstream main.
2026-10-11 07:43:13 +07:00
Nigel Ihza Firdausy d47857a20f fix(layers): validate explicit Layer Options targets
Reapply on current upstream main.
2026-10-11 07:43:13 +07:00
BFlat c9236ed3f7 Merge pull request #1084 from storytold/fix/1068-ai-artboard-layout
#1068: a .ai read as its PDF part keeps its artboard layout
2026-10-11 07:35:59 +07:00
skylar taylor-barrick ed14640756 UI.9: integrate shared docking and preserve panel workflows 2026-10-10 14:49:35 -06:00
131 changed files with 6509 additions and 644 deletions

No files matched your search

Generated
+116
View File
@@ -200,6 +200,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.6.1"
@@ -713,6 +719,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -790,6 +806,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -861,6 +886,19 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dify"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1066,6 +1104,27 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"dify",
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"open",
"pollster 1.0.1",
"serde",
"tempfile",
"toml",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -1438,6 +1497,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1940,6 +2008,25 @@ dependencies = [
"generic-array",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2067,6 +2154,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "krilla"
version = "0.8.2"
@@ -2119,6 +2216,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2740,6 +2843,16 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -4335,6 +4448,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"pollster 1.0.1",
"raw-window-handle",
"rfd",
"serde_json",
@@ -4744,8 +4858,10 @@ dependencies = [
name = "vectorcraft-ui-egui"
version = "0.9.0"
dependencies = [
"craft-ui",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
"serde",
+1
View File
@@ -11,6 +11,7 @@ rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
+5 -1
View File
@@ -1,5 +1,9 @@
# VectorCraft Roadmap
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
@@ -123,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
+2 -1
View File
@@ -308,7 +308,8 @@ fn notice(lines: &[&str]) {
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
/// (libraries, presets and plug-ins open wherever they are dropped).
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
/// `None` once handed on.
+3
View File
@@ -63,6 +63,9 @@ raw-window-handle = "0.6"
# objc2-foundation.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
# already in the build through eframe.
pollster = "1"
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
+236 -33
View File
@@ -80,8 +80,9 @@ impl eframe::App for App {
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
if self.app.take_ime_discard() {
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
discard_marked_text();
}
}
@@ -114,8 +115,16 @@ impl App {
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
#[cfg(target_os = "macos")]
open_files(&mut self.app, open_documents::take());
if !self.app.file_dialog_open() {
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
if !self.app.file_dialog_open() {
open_files(&mut self.app, open_documents::take());
}
}
self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window);
if self.app.ui.status == "quit" {
@@ -189,7 +198,42 @@ fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
return None;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
serde_json::from_slice(&bytes).ok()
decode_saved_prefs(&bytes)
}
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
use serde_json::{Map, Value, json};
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
if let Some(layout) = object.get("docking")
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
{
object.remove("docking");
}
if let Some(hidden) = object.get_mut(hidden_key) {
if let Value::Object(entries) = hidden {
entries.retain(|panel, location| {
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
});
} else {
object.remove(hidden_key);
}
}
}
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
}
}
}
serde_json::from_value(value).ok()
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
@@ -220,48 +264,99 @@ fn save_prefs(app: &VectorcraftApp) {
/// The window file dialogs belong to.
type Parent = Option<std::sync::Arc<winit::window::Window>>;
/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then
/// shows it over the window and keeps it in front; macOS shows them as before).
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
let d = match parent {
Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w),
_ => rfd::FileDialog::new(),
};
/// What rfd's blocking and asynchronous file dialogs are both set up with.
trait DialogSettings: Sized {
fn parent(self, window: &winit::window::Window) -> Self;
fn title(self, title: &str) -> Self;
fn filter(self, name: &str, exts: &[&str]) -> Self;
fn folder(self, folder: &str) -> Self;
fn file_name(self, name: &str) -> Self;
}
macro_rules! dialog_settings {
($($dialog:ty),*) => {$(
impl DialogSettings for $dialog {
fn parent(self, window: &winit::window::Window) -> Self {
self.set_parent(window)
}
fn title(self, title: &str) -> Self {
self.set_title(title)
}
fn filter(self, name: &str, exts: &[&str]) -> Self {
self.add_filter(name, exts)
}
fn folder(self, folder: &str) -> Self {
self.set_directory(folder)
}
fn file_name(self, name: &str) -> Self {
self.set_file_name(name)
}
}
)*};
}
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
/// `d` set up for `request`: its title, file types, folder and suggested name.
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)),
PickRequest::OpenMany => return fileio::place_filters().fold(d.title("Place"), |d, (name, exts)| d.filter(name, exts)),
PickRequest::Folder => return d,
};
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts));
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.filter(name, exts));
let d = match &pick.folder {
Some(folder) => d.set_directory(folder),
Some(folder) => d.folder(folder),
None => d,
};
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
if pick.name.is_empty() { d } else { d.file_name(&pick.name) }
}
/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the
/// window and keeps it in front).
#[cfg(not(target_os = "macos"))]
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
match parent {
Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request),
None => dialog_for(rfd::FileDialog::new(), request),
}
}
/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet.
fn make_folder(request: &PickRequest) {
if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request {
let _ = std::fs::create_dir_all(folder);
}
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
#[cfg(not(target_os = "macos"))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
make_folder(request);
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
PickRequest::Save(pick) => {
// The Templates folder may not exist yet.
if let Some(folder) = &pick.folder {
let _ = std::fs::create_dir_all(folder);
}
dialog.save_file().into_iter().collect()
}
PickRequest::Save(_) => dialog.save_file().into_iter().collect(),
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
};
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}
/// Show the dialog for `request` over `parent` now → the paths picked.
#[cfg(not(target_os = "macos"))]
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request)
}
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]);
/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing.
#[cfg(target_os = "macos")]
fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only");
Vec::new()
}
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
@@ -297,34 +392,97 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
/// Linux and macOS: show the dialog for `request` over `parent` on a thread of its own, answering
/// on the receiver. Shown on the UI thread, nothing would answer the compositor meanwhile on Linux,
/// which then offers to kill the window as not responding (#592); on macOS the panel ran modally
/// inside the window's event handler, and resizing it delivered window events to that handler
/// again, which crashed the app (#867). From another thread, rfd runs the panel on the main thread
/// from the run loop, outside the handler.
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
#[cfg(not(target_os = "macos"))]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request));
ui.request_repaint();
})
.ok()?;
Some(rx)
}
fn services(parent: Parent) -> Services {
/// The paths a dialog shown as a sheet picks, once it closes.
#[cfg(target_os = "macos")]
type SheetAnswer = std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send>>;
/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver
/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an
/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered
/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet
/// returns at once and the window keeps running; a thread of its own waits for the answer.
#[cfg(target_os = "macos")]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
use winit::raw_window_handle::HasWindowHandle as _;
// rfd runs the panel modally after all unless it has the running app's window to hold the
// sheet (and from another thread it can't reach the window): no dialog then (`pick_now`).
let mtm = objc2::MainThreadMarker::new();
let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning());
let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else {
log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some());
return None;
};
// The thread first, so the answer of a sheet once shown always has somewhere to go.
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<SheetAnswer>();
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
if let Ok(answer) = sheet_rx.recv() {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(pollster::block_on(answer));
ui.request_repaint();
}
})
.ok()?;
make_folder(&request);
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
PickRequest::Open(_) => {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::OpenMany => {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(_) => {
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
};
// The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled.
let _ = sheet_tx.send(answer);
Some(rx)
}
/// The host's services; `ui` is woken when a file dialog answers.
fn services(parent: Parent, ui: egui::Context) -> Services {
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Windows dialogs run the window's events while they are open; Linux's don't, and macOS
// ones re-enter the window's event handler (#867).
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
// Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows
// dialogs run the window's events while they are open.
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
@@ -549,7 +707,7 @@ fn main() -> std::process::ExitCode {
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned()));
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
@@ -649,6 +807,51 @@ mod tests {
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
}
#[test]
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
use serde_json::json;
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
app.ui.toolbar = false;
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
app.ui.custom_workspaces =
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
let original = serde_json::to_value(&app.ui).unwrap();
let valid_location = original["docking_hidden"]["layers"].clone();
assert!(valid_location.is_object());
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
let mut raw = original.clone();
raw["docking"] = invalid_layout.clone();
raw["docking_hidden"] = invalid_hidden.clone();
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
assert!(!loaded.toolbar);
assert!(loaded.docking.is_none());
assert!(!loaded.docking_hidden.contains_key("layers"));
if invalid_hidden.is_object() {
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
} else {
assert!(loaded.docking_hidden.is_empty());
}
assert_eq!(loaded.custom_workspaces.len(), 2);
assert_eq!(loaded.custom_workspaces[0].name, "Future");
assert!(loaded.custom_workspaces[0].docking.is_none());
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
}
}
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
assert!(
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
"unrelated fields retain normal strict parsing"
);
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// Preferences › User Interface › System Title Bar gives the window back its system
+5 -11
View File
@@ -4,21 +4,15 @@
use std::path::PathBuf;
#[path = "profile.rs"]
mod profile;
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
/// BSD).
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
let base = if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join(lower))
};
base.map(|b| b.join("ui.json"))
let legacy = name == "DrawCraft" || lower == "drawcraft";
profile::prefs_path(legacy)
}
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which
+66
View File
@@ -0,0 +1,66 @@
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
use std::ffi::OsString;
use std::path::PathBuf;
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
}
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
}
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
let base = match os {
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
};
base.map(|p| p.join("ui.json"))
}
#[cfg(test)]
mod tests {
use super::*;
fn environment(key: &str) -> Option<OsString> {
match key {
"HOME" => Some("/user".into()),
"APPDATA" => Some("/roaming".into()),
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
_ => None,
}
}
#[test]
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
for os in ["macos", "windows", "linux"] {
let env = |key: &str| {
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
};
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with(os, true, env), None);
}
}
#[test]
fn empty_override_does_not_fall_back_to_user_files() {
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
assert_eq!(prefs_path_with("macos", false, env), None);
assert_eq!(prefs_path_with("macos", true, env), None);
}
#[test]
fn unset_override_keeps_platform_paths_and_legacy_migration() {
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
assert_eq!(
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
Some("/user/.config/drawcraft/ui.json".into())
);
}
}
+8
View File
@@ -65,3 +65,11 @@ sync_github_name = false
[people.KattKnight]
display_name = "KattKnight (ayla.dsc)"
sync_github_name = false
[people.NoJuanNobody]
real_name = "Alejandro Londono"
display_name = "alex"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"
+41 -1
View File
@@ -57,6 +57,19 @@ impl PaletteFormat {
pub fn binary(self) -> bool {
self == PaletteFormat::Ase
}
/// Does [`write`] keep a swatch with `paint` in a file of this format? None and patterns are
/// never written; `.gpl` and `.ase` hold solid colors only, and CSS holds no freeform gradients.
pub fn holds(self, paint: &Paint) -> bool {
match paint {
Paint::Solid { .. } => true,
Paint::Gradient(g) => match self {
PaletteFormat::Native => true,
PaletteFormat::Css => g.gradient.kind != GradientKind::Freeform,
PaletteFormat::Gpl | PaletteFormat::Ase => false,
},
Paint::None | Paint::Pattern { .. } => false,
}
}
}
/// The native file: a header around the library.
@@ -88,7 +101,7 @@ const ACB_CUT_SHORT: &str = "the color book is cut short";
/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than
/// [`read_bytes`] reads from one file.
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result<Vec<u8>, String> {
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
let keep = |w: &&Swatch| format.holds(&w.paint);
let text = match format {
PaletteFormat::Native => {
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
@@ -792,6 +805,23 @@ mod tests {
assert!(read(&text, "x").is_err(), "CSS is written only");
}
#[test]
fn holds_counts_the_swatches_each_format_writes() {
// `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group.
let mut lib = sample();
let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") };
mesh.gradient.kind = GradientKind::Freeform;
lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false });
lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false });
let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count();
assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css");
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}");
}
let css = written(&lib, PaletteFormat::Css);
assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}");
}
/// A swatch exchange file of `blocks` (type, body).
fn ase(blocks: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut out = b"ASEF\0\x01\0\0".to_vec();
@@ -1088,5 +1118,15 @@ mod tests {
prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches);
}
}
/// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as
/// [`PaletteFormat::holds`] counts.
#[test]
fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) {
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap();
prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f);
}
}
}
}
+64 -2
View File
@@ -16,7 +16,9 @@ pub struct Selection {
/// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
/// Key object for Align.
/// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears
/// it when every selected object is the key or lies inside it, as when one object is left
/// selected or when the key is a group selected with only its own members.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<NodeId>,
/// The object, group or layer targeted through its target circle in the Layers panel, which
@@ -118,7 +120,15 @@ impl Selection {
pub fn prune(&mut self, doc: &Document) {
self.objects.retain(|id| doc.node(*id).is_some());
self.anchors.retain(|id, _| doc.node(*id).is_some());
if self.key.is_some_and(|k| doc.node(k).is_none()) {
// The key object stays while another selected object lies outside it.
let keep = self.key.and_then(|k| doc.node(k)).is_some_and(|n| {
let mut inside = BTreeSet::new();
n.walk(&mut |c| {
inside.insert(c.id);
});
self.objects.iter().any(|o| !inside.contains(o))
});
if !keep {
self.key = None;
}
if self.target.is_some_and(|t| doc.node(t).is_none()) {
@@ -128,6 +138,15 @@ impl Selection {
// Guides on a hidden or locked layer can't stay selected.
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
}
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
/// around them (and so the key object when it is one): hidden or locked art can't stay
/// selected.
pub fn deselect_uneditable(&mut self, doc: &Document) {
let out: Vec<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
for o in out {
self.remove(o);
}
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
/// (the art of its sublayers too), anything else is selected itself.
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
@@ -253,4 +272,47 @@ mod tests {
s.set([NodeId(3)]);
assert_eq!(s.target, None);
}
#[test]
fn a_key_object_needs_two_or_more_selected_objects() {
let mut s = Selection::default();
s.set([NodeId(1), NodeId(2)]);
s.key = Some(NodeId(2));
s.remove(NodeId(1));
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
s.remove(NodeId(2));
assert_eq!(s.key, None, "the key object itself left");
// Pruning drops objects the document no longer has and clears the key once no other
// selected object lies outside it.
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = vec![];
for _ in 0..4 {
let id = d.alloc_id();
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
}
s.set(ids.iter().copied());
s.key = Some(ids[3]);
d.remove(ids[0]).unwrap();
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
s.toggle(ids[1]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
s.toggle(ids[2]);
s.prune(&d);
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
// A group key selected with only its own member is cleared too; another selected object
// outside the group keeps it.
let member = d.alloc_id();
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
s.set([member, ids[3]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, None, "a group and its own member");
s.set([member, ids[3], ids[2]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
}
}
+2 -2
View File
@@ -96,8 +96,8 @@ pub struct CharStyle {
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
pub char_align: CharAlign,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966). Vertical type sets them as before
/// for now.
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
/// its proportional heights, `vpal`.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
}
+19 -1
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
use vectorcraft_geom::Rect;
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address};
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub(crate) use resources::SwatchChoices;
use super::*;
@@ -248,6 +248,24 @@ mod tests {
}
}
/// The image markup some browsers put as text next to a copied picture names it too (#1089);
/// markup with words in it is text.
#[test]
fn markup_of_only_images_names_a_picture() {
for m in [
r#"<img src="https://static.example.com/eagle.png" jsaction="" class="sFlh5c" style="max-width: 351px" alt="">"#,
r#"<meta http-equiv="content-type" content="text/html; charset=utf-8"><img src="a.png">"#,
"<html><body><!--StartFragment--><IMG SRC=\"a.png\"/><!--EndFragment--></body></html>",
r#"<a href="https://x.org"><img src="a.png"></a>"#,
"https://example.com/cat.png",
] {
assert!(names_a_picture(m), "{m}");
}
for t in ["<p>Hello</p>", r#"<img src="a.png"> and a caption"#, "<b>bold</b>", "<imgx>", "<img", "plain words", "<br>", ""] {
assert!(!names_a_picture(t), "{t}");
}
}
#[test]
fn copy_exports_svg_that_round_trips_through_paste() {
let mut s = session();
@@ -39,6 +39,30 @@ pub fn is_address(text: &str) -> bool {
!t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len())
}
/// Is `text` only a reference to a picture: an address ([`is_address`]), or markup that shows
/// nothing but images (`<img …>`)? A browser's Copy Image puts one of them next to the picture
/// (some put the image's HTML there as text, #1089): then the picture is what Paste takes.
pub fn names_a_picture(text: &str) -> bool {
is_address(text) || only_images(text)
}
/// Is `html` markup whose only content is `<img>` elements: no text outside its tags (whitespace
/// between them aside), and at least one image?
fn only_images(html: &str) -> bool {
let mut images = 0;
let mut rest = html.trim();
while !rest.is_empty() {
let Some(tag) = rest.strip_prefix('<') else { return false };
let Some(end) = tag.find('>') else { return false };
let name = tag.get(..end).unwrap_or_default().split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default();
if name.eq_ignore_ascii_case("img") {
images += 1;
}
rest = tag.get(end + 1..).unwrap_or_default().trim_start();
}
images > 0
}
/// How much of a copied file [`file_flavour`] needs to tell what it is.
pub const FILE_HEAD: usize = 4096;
+11 -7
View File
@@ -219,11 +219,13 @@ fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc:
Ok(st)
}
/// The `index` param, checked against the points of `prof`.
/// The `index` param (none when absent or null), checked against the points of `prof`: a malformed
/// one is refused like one out of range, never wrapped round or taken as missing.
fn point_index(p: &Value, prof: &WidthProfile, cmd: &str) -> Result<Option<usize>> {
match p.get("index").and_then(Value::as_u64).map(|v| v as usize) {
Some(i) if i >= prof.points.len() => Err(bad(cmd, "no such width point")),
i => Ok(i),
let Some(v) = p.get("index").filter(|v| !v.is_null()) else { return Ok(None) };
match point_indices(&json!([v])).and_then(|i| i.first().copied()) {
Some(i) if i < prof.points.len() => Ok(Some(i)),
_ => Err(bad(cmd, "no such width point")),
}
}
@@ -323,11 +325,13 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.remove";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let mut indices: Vec<usize> = match (p.get("indices").and_then(Value::as_array), p.get("index").and_then(Value::as_u64)) {
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect(),
(None, Some(i)) => vec![i as usize],
let indices = match (p.get("indices"), p.get("index")) {
(Some(a), _) => point_indices(a),
(None, Some(i)) => point_indices(&json!([i])),
(None, None) => return Err(bad(C, "missing index")),
};
// A malformed index fails the whole delete instead of being dropped while the others go.
let mut indices = indices.ok_or_else(|| bad(C, "no such width point"))?;
indices.sort_unstable();
indices.dedup();
s.edit("Delete Width Point", |d, _| {
+16 -3
View File
@@ -8,12 +8,14 @@ use crate::{DocState, EngineError};
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let mut cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let label = str_param(p, "label").unwrap_or("Batch").to_string();
// The active artboard the app passes, for the steps that act on it.
let artboard = p.get("artboard").and_then(Value::as_u64);
// What an error rolls back besides the documents: session-level state a step may change
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
s.batch_stash = Some(vec![]);
let r = run_steps(s, &label, &mut cmds);
let r = run_steps(s, &label, artboard, &mut cmds);
let stash = s.batch_stash.take().unwrap_or_default();
let results = match r {
Ok(results) => results,
@@ -43,7 +45,10 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
/// Run the steps → their results. Each runs inside an interaction of the document active when it
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Value>> {
/// A step that acts on the active artboard ([`crate::ON_ACTIVE_ARTBOARD`]) and names none gets
/// `artboard`, as the Artboards panel shows it in the document the step runs in: past that
/// document's last artboard, the last one.
fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Value]) -> Result<Vec<Value>> {
let mut results = Vec::with_capacity(cmds.len());
for c in cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string();
@@ -53,7 +58,15 @@ fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Val
if s.active().is_some() {
s.begin_interaction(label)?;
}
let params = c.get("params").cloned().unwrap_or(json!({}));
let mut params = c.get("params").cloned().unwrap_or(json!({}));
if let Some(a) = artboard
&& crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str())
&& params.get("artboard").is_none()
&& let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1))
&& let Some(o) = params.as_object_mut()
{
o.insert("artboard".into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
}
let (v, noted) =
s.execute_step(&id, &params).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
// The step's journal entry records what it took from the preferences or the clock (a
+9 -1
View File
@@ -123,7 +123,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
screens::export_for_screens
),
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch),
cmd!(
query "command.batch",
"Batch",
[],
None,
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align or select.allOnArtboard step that names none gets it, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
has_doc,
batch::batch
),
cmd!(
query "document.formats",
"File Formats",
-6
View File
@@ -161,12 +161,6 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String>
Ok(f)
}
/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no
/// malformed index is dropped or wrapped round.
fn point_indices(v: &Value) -> Option<Vec<usize>> {
v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect()
}
pub(crate) fn parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
FreeformMode::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)"))
}
+1 -5
View File
@@ -551,11 +551,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p);
}
// Hidden or locked content can't stay selected.
let hidden: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
for h in hidden {
sel.remove(h);
}
sel.deselect_uneditable(d);
Ok(())
})?;
ok()
+1 -5
View File
@@ -188,11 +188,7 @@ fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str)
flag.set(n, v);
}
}
// Hidden or locked art can't stay selected.
let out: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
for o in out {
sel.remove(o);
}
sel.deselect_uneditable(d);
Ok(())
})?;
}
+4 -3
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"All Artwork Above",
["Object", "Lock"],
None,
"{} lock objects in the same layer that are above and overlap the selection → {count}",
"{} lock objects in the same layer that are above and overlap the selection, deselecting every selected object left locked or hidden (the key object too) → {count}",
has_selection,
|s, _| above(s, true)
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"All Artwork Above",
["Object", "Hide"],
None,
"{} hide objects in the same layer that are above and overlap the selection → {count}",
"{} hide objects in the same layer that are above and overlap the selection, deselecting every selected object left hidden or locked (the key object too) → {count}",
has_selection,
|s, _| above(s, false)
),
@@ -228,7 +228,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
}
let label = if lock { "Lock" } else { "Hide" };
let n = hits.len();
s.edit(label, |d, _| {
s.edit(label, |d, sel| {
for id in &hits {
if let Some(n) = d.node_mut(*id) {
if lock {
@@ -238,6 +238,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
}
}
}
sel.deselect_uneditable(d);
Ok(())
})?;
Ok(json!({ "count": n }))
+6
View File
@@ -340,6 +340,12 @@ pub(crate) fn finite_numbers<const N: usize>(v: &Value) -> Option<[f64; N]> {
Some(out)
}
/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no
/// malformed index is dropped or wrapped round.
pub(crate) fn point_indices(v: &Value) -> Option<Vec<usize>> {
v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect()
}
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
let a = p.get(key)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
+76 -60
View File
@@ -150,7 +150,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
has_doc,
set_props
),
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_selection,
align
),
@@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_multi,
distribute
),
@@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_multi,
distribute_spacing
),
@@ -363,7 +363,12 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
let t = -n.as_f64().unwrap_or(90.0).to_radians();
Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t)
}
_ => Affine::scale_non_uniform(-1.0, 1.0),
None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0),
Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0),
Some(v) => {
let given = v.as_str().map_or_else(|| v.to_string(), str::to_string);
return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`")));
}
};
apply_transform(s, "Reflect", ids, about(o, m), p)
}
@@ -372,8 +377,8 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
let m =
if str_param(p, "axis") == Some("vertical") { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical");
let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
apply_transform(s, "Shear", ids, about(o, m), p)
}
@@ -849,6 +854,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
),
None => None,
};
let hides = p.get("visible").and_then(Value::as_bool) == Some(false) || p.get("locked").and_then(Value::as_bool) == Some(true);
// When every object already has every value given, the document and its undo history stay as
// they are.
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
@@ -868,9 +874,13 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
};
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
// Hidden or locked objects can't stay selected.
if hides {
s.select(|d, sel| sel.deselect_uneditable(d))?;
}
return ok();
}
s.edit("Object Properties", |d, _| {
s.edit("Object Properties", |d, sel| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
if let Some(v) = str_param(p, "name") {
@@ -916,6 +926,10 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
});
}
}
// Hidden or locked objects can't stay selected.
if hides {
sel.deselect_uneditable(d);
}
Ok(())
})?;
ok()
@@ -936,22 +950,49 @@ impl Session {
/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds.
fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
match str_param(p, "bounds") {
match choice(p, cmd, "bounds", &["preview", "geometric"])? {
None => Ok(s.prefs.use_preview_bounds),
Some("preview") => Ok(true),
Some("geometric") => Ok(false),
Some(b) => Err(bad(cmd, format!("bounds must be \"preview\" or \"geometric\", not `{b}`"))),
Some(b) => Ok(b == "preview"),
}
}
/// Param `key` of the Align commands and Shear: one of `values`, or `None` when it is missing or
/// null. Any other value is an error that lists `values`.
fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result<Option<&'a str>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
match v.as_str() {
Some(s) if values.contains(&s) => Ok(Some(s)),
_ => Err(bad(cmd, format!("`{key}` must be one of {}", values.join(", ")))),
}
}
/// `horizontal` and `vertical` of `object.align` and `object.distribute`, each checked by [`choice`].
fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a str>)> {
Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?))
}
/// `ids` with their bounds (`preview`: visual bounds).
fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> {
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
}
/// Is every move in `moves` a rounding error (1e-9 pt or less on both axes)?
fn moves_nothing(moves: &[(NodeId, Vec2)]) -> bool {
moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
/// Move each object of `moves` by its offset, as undo step `label`; pattern tiles move too when
/// [`transform_patterns`] says so for `ids`. When every move is a rounding error (1e-9 pt or less
/// on both axes), the document and its undo history stay as they are.
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)]) -> Result<Value> {
let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() };
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) {
return ok();
}
s.edit(label, |d, _| {
for (id, dv) in moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), sc);
}
}
Ok(())
})?;
ok()
}
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
@@ -965,8 +1006,12 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
match align_to(s, p) {
Some("artboard") => {
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
// it, or with an index the document doesn't have, it uses the artboard under the center
// of the selection, or the first one when no artboard is there.
let named = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| st.doc.artboards.get(i));
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b))
Ok(named.or(st.doc.artboards.get(i)).map(|a| a.rect).unwrap_or(b))
}
Some("key") => {
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
@@ -988,9 +1033,12 @@ fn key_root(s: &Session, ids: &[NodeId]) -> Option<NodeId> {
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.align")?;
let (h, v) = direction(p, "object.align")?;
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
if h.is_none() && v.is_none() {
return Err(bad("object.align", "give horizontal or vertical"));
}
let r = reference_rect(s, p, &ids, preview)?;
let h = str_param(p, "horizontal");
let v = str_param(p, "vertical");
let key = key_root(s, &ids);
let moves: Vec<(NodeId, Vec2)> = {
let d = &s.doc()?.doc;
@@ -1014,26 +1062,14 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect()
};
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
}
s.edit("Align", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
apply_moves(s, "Align", p, &ids, &moves)
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.distribute")?;
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) {
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
(Some(h), _) => (
true,
match h {
@@ -1068,24 +1104,12 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
.collect();
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
}
s.edit("Distribute", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
apply_moves(s, "Distribute", p, &ids, &moves)
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let horiz = str_param(p, "axis") != Some("vertical");
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
@@ -1095,7 +1119,11 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
};
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite());
let spacing = p
.get("spacing")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad("object.distributeSpacing", "`spacing` must be a number")))
.transpose()?;
let gap = match spacing {
Some(g) => g,
None => {
@@ -1115,19 +1143,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
}
s.edit("Distribute Spacing", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
apply_moves(s, "Distribute Spacing", p, &ids, &moves)
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
+10 -2
View File
@@ -19,7 +19,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
all
),
cmd!("select.allOnArtboard", "All on Active Artboard", ["Select"], Some("Cmd+Alt+A"), "{artboard?: index}", has_doc, all_on_artboard),
cmd!(
"select.allOnArtboard",
"All on Active Artboard",
["Select"],
Some("Cmd+Alt+A"),
"{artboard?: index (0-based; the app passes the active one; default: the first)}",
has_doc,
all_on_artboard
),
cmd!("select.none", "Deselect", ["Select"], Some("Cmd+Shift+A"), "{}", has_doc, none),
cmd!("select.reselect", "Reselect", ["Select"], Some("Cmd+6"), "{}", has_doc, reselect),
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
@@ -57,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Key Object",
[],
None,
"{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it",
"{id?: a selected object, one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members)",
has_doc,
key
),
+1 -1
View File
@@ -500,7 +500,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?;
let expand = supplied.is_none() && st.selection.objects.len() < 2;
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
let eligible: Vec<NodeId> = ids
+2 -2
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Swatch Library…",
["Window", "Swatches"],
None,
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count: the swatches written (gpl and ase hold solid colors only, and css leaves freeform gradients out), library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
has_doc,
save
),
@@ -454,7 +454,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?;
let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?;
let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) };
let mut out = json!({"format": format.id(), "count": lib.len()});
let mut out = json!({"format": format.id(), "count": lib.iter().filter(|w| format.holds(&w.paint)).count()});
s.swatch_libraries.write(p, &lib.name, format.id(), data, &mut out, C)?;
Ok(out)
}
+1 -1
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
+27 -3
View File
@@ -369,10 +369,27 @@ impl DocState {
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
pub const MAX_COORD: f64 = 4.0e6;
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, and All on
/// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their
/// steps that names none.
pub const ON_ACTIVE_ARTBOARD: [&str; 5] = ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack", "object.align", "select.allOnArtboard"];
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
/// (dates from the clock: `cmd::clock_date`); an action leaves them out
/// (dates from the clock: `cmd::clock_date`; the active artboard the app passes to the
/// [`ON_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves them out
/// ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 3] = [("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified")];
const REPLAY_ONLY: [(&str, &str); 9] = [
("file.new", "created"),
("document.save", "modified"),
("file.saveAs", "modified"),
("edit.pasteInPlace", "artboard"),
("edit.pasteInFront", "artboard"),
("edit.pasteInBack", "artboard"),
("object.align", "artboard"),
("select.allOnArtboard", "artboard"),
("command.batch", "artboard"),
];
/// `p`, the params of command `id`, without its [`REPLAY_ONLY`] values (a batch: its steps').
fn strip_replay_only(id: &str, p: &mut Value) {
@@ -1349,10 +1366,17 @@ impl Session {
let Some(mut it) = st.interaction.take() else { return Ok(()) };
let Some(preview) = it.preview.take() else { return Ok(()) };
let perspective_again = it.perspective_again.take();
let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
// Alt pressed or released during a move drag turns it into a copy or back: name it for
// what it did in the end.
match (it.label.as_str(), copy) {
("Move", true) => it.label = "Copy".into(),
("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(),
_ => {}
}
st.keep_interaction(it);
if preview.0 == "object.transform" {
let m = cmd::matrix_param(&preview.1, "matrix");
let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
if let Some(m) = m {
st.last_transform = Some((m, copy));
st.last_perspective = None;
+141
View File
@@ -311,6 +311,57 @@ fn layers_and_lock_hide() {
assert!(s.doc().unwrap().doc.node(b).is_none());
}
/// A key object is one object of a selection of several: a Shift-click (`select.toggle`) or a
/// hidden or deleted row that leaves one object selected clears the key, and `select.key` sets
/// none on a single selected object. A Shift-drag marquee that leaves two or more objects selected
/// keeps the key in either order of its ids. `select.key` also sets none on a group selected with
/// only its own member, and sets it once another object is selected.
#[test]
fn a_lone_selected_object_is_not_the_key_object() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0);
let c = rect(&mut s, 200.0, 60.0, 10.0, 10.0);
let key = |s: &Session| s.doc().unwrap().selection.key;
let objects = |s: &Session| s.doc().unwrap().selection.objects.clone();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("select.key", &json!({"id": b.0})).unwrap();
assert_eq!(key(&s), Some(b));
s.execute("select.toggle", &json!({"id": a.0})).unwrap();
assert_eq!(objects(&s), vec![b]);
assert_eq!(key(&s), None, "the Shift-click left one object");
s.execute("select.key", &json!({"id": b.0})).unwrap();
assert_eq!(key(&s), None, "one object selected");
for ids in [[a.0, c.0], [c.0, a.0]] {
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("select.key", &json!({"id": b.0})).unwrap();
s.execute("select.toggle", &json!({"ids": ids})).unwrap();
assert_eq!((objects(&s), key(&s)), (vec![b, c], Some(b)), "the marquee toggled {ids:?}");
}
// The Group Selection tool's second click on a member adds its group.
s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
s.execute("select.add", &json!({"ids": [g.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), None, "a group and its own member");
s.execute("select.add", &json!({"ids": [a.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), Some(g), "another object outside the group");
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), Some(g));
s.execute("layer.setProps", &json!({"id": a.0, "visible": false})).unwrap();
assert_eq!((objects(&s), key(&s)), (vec![g], None), "hiding the other object's row left one object");
s.execute("layer.setProps", &json!({"id": a.0, "visible": true})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), Some(g));
s.execute("layer.delete", &json!({"id": a.0})).unwrap();
assert_eq!(objects(&s), vec![g]);
assert_eq!(key(&s), None, "deleting the other object's row left one object");
}
#[test]
fn clipping_and_compound() {
let mut s = session();
@@ -330,6 +381,65 @@ fn clipping_and_compound() {
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
}
/// `object.align` without `horizontal` or `vertical` (an unknown key such as `align` counts as
/// neither) returns an error: nothing moves, and the undo and redo stacks stay as they were.
#[test]
fn align_without_a_direction_is_refused() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let stacks = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len());
let before = stacks(&s);
for p in [json!({}), json!({"align": "left"}), json!({"to": "artboard"}), json!({"horizontal": null, "vertical": null})] {
let e = s.execute("object.align", &p).expect_err(&p.to_string());
assert_eq!(e.to_string(), "invalid parameters for `object.align`: give horizontal or vertical", "{p}");
}
assert_eq!(stacks(&s), before, "no undo step, and the redo step is kept");
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 50.0, "nothing moved");
}
/// Align, Distribute and Distribute Spacing return an error that lists the choices of a param
/// given a value outside them: nothing moves and no undo step is recorded. A null value counts as
/// not given, and Distribute Spacing without an `axis` spaces the objects horizontally.
#[test]
fn align_and_distribute_reject_bad_params_without_changing_state() {
let mut s = session();
let ids: Vec<NodeId> = [0.0, 50.0, 200.0].into_iter().map(|x| rect(&mut s, x, x / 2.0, 10.0, 10.0)).collect();
s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()})).unwrap();
let bounds = |s: &Session| ids.iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().geometric_bounds().unwrap()).collect::<Vec<_>>();
let before = bounds(&s);
let undo = s.doc().unwrap().history.undo.len();
let h = "`horizontal` must be one of left, center, right";
let v = "`vertical` must be one of top, center, bottom";
let to = "`to` must be one of selection, artboard, key";
for (cmd, p, msg) in [
("object.align", json!({"horizontal": "middle"}), h),
("object.align", json!({"vertical": "centre"}), v),
("object.align", json!({"horizontal": true}), h),
("object.align", json!({"horizontal": "left", "to": "page"}), to),
("object.align", json!({"horizontal": "left", "to": 0}), to),
("object.align", json!({"horizontal": "left", "bounds": false}), "`bounds` must be one of preview, geometric"),
("object.align", json!({"horizontal": "left", "vertical": "middle"}), v),
("object.distribute", json!({"horizontal": "middle"}), h),
("object.distribute", json!({"vertical": "centre"}), v),
("object.distribute", json!({"horizontal": "left", "vertical": "middle"}), v),
("object.distributeSpacing", json!({"axis": "diagonal"}), "`axis` must be one of horizontal, vertical"),
("object.distributeSpacing", json!({"axis": "vertical", "spacing": "5"}), "`spacing` must be a number"),
] {
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}"));
assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}"));
}
assert_eq!(bounds(&s), before, "nothing moved");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
s.execute("object.align", &json!({"horizontal": null, "vertical": "top", "to": null, "bounds": null})).unwrap();
assert!(bounds(&s).iter().all(|b| b.y0 == 0.0), "aligned to the top");
s.execute("object.distributeSpacing", &json!({"axis": null, "spacing": null})).unwrap();
assert_eq!(bounds(&s).iter().map(|b| b.x0).collect::<Vec<_>>(), [0.0, 100.0, 200.0], "equal horizontal gaps");
}
#[test]
fn align_left() {
let mut s = session();
@@ -340,6 +450,37 @@ fn align_left() {
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0);
}
/// Align to Artboard aligns to artboard `artboard` (the app passes the active one). Without it, or
/// with one the document doesn't have, it aligns to the artboard under the center of the
/// selection, or to the first artboard when no artboard is there. Align to Selection does not use
/// `artboard`.
#[test]
fn align_to_artboard_uses_the_artboard_named() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let a = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 10.0, 20.0, 20.0);
let b = rect(&mut s, boards[1].x0 + 50.0, boards[1].y0 + 40.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard", "artboard": 2})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b)), (boards[2].x0, boards[2].x0), "the left edges are on artboard 2's");
for (p, left) in [
(json!({"horizontal": "left", "to": "artboard"}), boards[1].x0),
(json!({"horizontal": "left", "to": "artboard", "artboard": 9}), boards[1].x0),
(json!({"horizontal": "left", "to": "selection", "artboard": 2}), boards[1].x0 + 10.0),
] {
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.align", &p).unwrap();
assert_eq!((x0(&s, a), x0(&s, b)), (left, left), "{p}");
}
// A selection whose center is on no artboard aligns to the first.
let c = rect(&mut s, boards[1].x0 + 10.0, boards[1].y1 + 50.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
assert_eq!(x0(&s, c), boards[0].x0, "off every artboard: the first");
}
/// #541: with the Selection tool, a click on one object of the selection makes it the key object:
/// aligning to the key moves the others to it, Distribute Spacing spaces them from it, and a click
/// on the key again lets it go.
+75
View File
@@ -66,6 +66,65 @@ fn recorded_actions_leave_out_replay_only_values() {
assert_eq!(s.journal_for_action(1), s.journal[1..].to_vec());
}
/// The artboard the app passes to Paste in Place, in Front and in Back, to Align to Artboard, to
/// All on Active Artboard and to a batch (its active one) is journaled and left out of recorded
/// actions, so a played action uses the artboard active when it plays.
#[test]
fn recorded_actions_leave_out_the_artboard_the_app_passes() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2, "created": null})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let start = s.journal.len();
let steps = [
("edit.pasteInPlace", json!({"artboard": 1})),
("edit.pasteInFront", json!({"artboard": 1})),
("edit.pasteInBack", json!({"artboard": 1})),
("select.allOnArtboard", json!({"artboard": 1})),
("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})),
("command.batch", json!({"artboard": 1, "commands": [{"command": "select.allOnArtboard", "params": {}}]})),
];
for (id, p) in &steps {
s.execute(id, p).unwrap();
}
assert!(s.journal[start..].iter().all(|(_, p)| p["artboard"] == json!(1)), "the journal keeps it: {:?}", &s.journal[start..]);
let action = s.journal_for_action(start);
assert_eq!(action.len(), steps.len());
for ((id, mut p), (step, recorded)) in steps.into_iter().zip(action) {
p.as_object_mut().unwrap().remove("artboard");
assert_eq!((step.as_str(), recorded), (id, p));
}
}
/// A batch passes its `artboard` (the app's active one) to each step that acts on the active
/// artboard and names none, when the step runs: once a step deletes the last artboard, the new
/// last one, and in a new document with fewer artboards, its last one. A step that names an
/// artboard keeps it.
#[test]
fn a_batch_passes_its_artboard_to_each_step_when_it_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let ids: Vec<u64> = boards
.iter()
.map(|b| {
s.execute("shape.rectangle", &json!({"x": b.x0 + 10.0, "y": b.y0 + 10.0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()
})
.collect();
let picked = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::<Vec<_>>();
let step = |command: &str, params: Value| json!({"command": command, "params": params});
let all_on_artboard = step("select.allOnArtboard", json!({}));
s.execute("command.batch", &json!({"artboard": 2, "commands": [all_on_artboard]})).unwrap();
assert_eq!(picked(&s), [ids[2]], "the batch's artboard 3");
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("select.allOnArtboard", json!({"artboard": 0}))]})).unwrap();
assert_eq!(picked(&s), [ids[0]], "an artboard the step names is kept");
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("artboard.delete", json!({"index": 2})), all_on_artboard]})).unwrap();
assert_eq!(picked(&s), [ids[1]], "artboard 2, the last one once artboard 3 is deleted");
let new = step("file.new", json!({"width": 100, "height": 100}));
s.execute("command.batch", &json!({"artboard": 2, "commands": [new, all_on_artboard]})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "in the new document");
}
#[test]
fn a_save_records_its_date_and_replays_it() {
let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned();
@@ -296,3 +355,19 @@ fn an_undo_group_is_one_undo_step() {
assert!((width(&s) - 120.0).abs() < 1e-9);
assert_eq!(undo_len(&s), undo + 2);
}
/// A move drag is named for what its last preview did: Alt pressed or released during the drag
/// turns it into a copy or back (the Selection and Direct Selection tools).
#[test]
fn a_move_drag_is_named_for_whether_it_copied() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let last = |s: &Session| s.doc().unwrap().history.undo.last().map(|e| e.label.clone());
for (begin, copy, label) in [("Move", true, "Copy"), ("Copy", false, "Move"), ("Move", false, "Move")] {
s.begin_interaction(begin).unwrap();
s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": copy})).unwrap();
s.commit_interaction().unwrap();
assert_eq!(last(&s).as_deref(), Some(label), "begun as {begin}, copy: {copy}");
}
}
+83
View File
@@ -206,6 +206,89 @@ fn visibility_and_lock_per_row_are_undoable() {
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
}
#[test]
fn object_set_props_deselects_the_objects_it_hides_or_locks() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
for (prop, value) in [("visible", false), ("locked", true)] {
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "select.key", json!({"id": b.0}));
let mut p = json!({"ids": [b.0]});
p[prop] = json!(value);
run(&mut s, "object.setProps", p);
assert!(!s.doc().unwrap().doc.is_editable(b));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a], None), "{prop}: {value} deselects the key object");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.is_editable(b));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a, b], Some(b)), "undoing {prop}: {value} selects it again as the key");
}
}
#[test]
fn object_set_props_deselects_the_objects_inside_a_group_or_layer_it_hides_or_locks() {
let mut s = session();
let (_, sub, a, g, b, c) = tree(&mut s);
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "object.setProps", json!({"ids": [g.0], "locked": true}));
assert_eq!(s.doc().unwrap().selection.objects, vec![a, c], "b is in the locked group");
run(&mut s, "select.key", json!({"id": a.0}));
assert_eq!(s.doc().unwrap().selection.key, Some(a));
run(&mut s, "object.setProps", json!({"ids": [sub.0], "visible": false}));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![c], None), "a, the key object, is in the hidden sublayer");
}
#[test]
fn object_set_props_that_hides_or_locks_nothing_keeps_the_selection() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let x = rect(&mut s, 0.0);
run(&mut s, "select.set", json!({"ids": [x.0]}));
run(&mut s, "object.lock", json!({}));
run(&mut s, "layer.setProps", json!({"id": layer.0, "locked": true}));
run(&mut s, "object.unlockAll", json!({}));
let st = s.doc().unwrap();
assert!(st.selection.objects == vec![x] && !st.doc.is_editable(x), "Unlock All selects x, and its layer still locks it");
for p in [json!({"opacity": 50}), json!({"visible": true, "locked": false})] {
run(&mut s, "object.setProps", p.clone());
assert_eq!(s.doc().unwrap().selection.objects, vec![x], "{p} keeps x selected");
}
assert_eq!(node(&s, x).opacity, 0.5);
}
#[test]
fn object_set_props_that_hides_or_locks_deselects_objects_hidden_or_locked_before() {
let mut s = session();
let (x, y, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0), rect(&mut s, 200.0));
run(&mut s, "object.setProps", json!({"ids": [x.0], "visible": false}));
run(&mut s, "select.set", json!({"ids": [x.0, y.0, z.0]}));
assert_eq!(s.doc().unwrap().selection.objects, vec![x, y, z], "select.set selects the hidden x");
run(&mut s, "object.setProps", json!({"ids": [y.0], "locked": true}));
assert_eq!(s.doc().unwrap().selection.objects, vec![z], "locking y deselects x, hidden before the call, too");
}
#[test]
fn object_set_props_that_changes_nothing_deselects_what_it_leaves_hidden_or_locked() {
let mut s = session();
let (x, y) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
for (prop, value) in [("visible", false), ("locked", true)] {
let mut p = json!({"ids": [x.0]});
p[prop] = json!(value);
run(&mut s, "object.setProps", p.clone());
run(&mut s, "select.set", json!({"ids": [x.0, y.0]}));
run(&mut s, "select.key", json!({"id": x.0}));
let before = undos(&s);
run(&mut s, "object.setProps", p);
assert_eq!(undos(&s), before, "{prop}: {value} again records no undo step");
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![y], None), "{prop}: {value} again deselects x, the key object");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.is_editable(x));
}
}
#[test]
fn layer_options_validate_and_template_locks_and_dims() {
let mut s = session();
+43
View File
@@ -94,6 +94,49 @@ fn hide_all_artwork_above() {
assert!(node(&s, over).visible);
}
fn key(s: &Session) -> Option<NodeId> {
s.doc().unwrap().selection.key
}
/// Lock › All Artwork Above deselects the selected objects it locks: here the key object, in a
/// group above the other selected object. Undo selects it again as the key object.
#[test]
fn lock_all_artwork_above_deselects_the_objects_it_locks() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
sel(&mut s, &[b]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
sel(&mut s, &[a, b]);
s.execute("select.key", &json!({"id": b.0})).unwrap();
s.execute("object.lock.above", &json!({})).unwrap();
assert!(node(&s, g).locked);
assert_eq!((selected(&s), key(&s)), (vec![a], None));
s.execute("edit.undo", &json!({})).unwrap();
assert!(!node(&s, g).locked);
assert_eq!((selected(&s), key(&s)), (vec![a, b], Some(b)));
}
/// Hide › All Artwork Above deselects the selected objects it hides (here one in a sublayer above)
/// and selected objects that were hidden before. The one object left can't be the key object, so
/// the key goes too; undo brings both back.
#[test]
fn hide_all_artwork_above_deselects_the_objects_it_hides() {
let mut s = session();
let hidden = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let sub = id_of(&s.execute("layer.newSublayer", &json!({})).unwrap());
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
sel(&mut s, &[hidden, a, b]);
s.execute("select.key", &json!({"id": a.0})).unwrap();
s.execute("object.hide.above", &json!({})).unwrap();
assert!(!node(&s, sub).visible);
assert_eq!((selected(&s), key(&s)), (vec![a], None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((selected(&s), key(&s)), (vec![hidden, a, b], Some(a)));
}
#[test]
fn lock_and_hide_other_layers() {
let mut s = session();
+44
View File
@@ -452,6 +452,50 @@ fn transform_pattern_tiles_is_the_transforms_default() {
assert_eq!(tiles(&s), [0.5, 0.0, 0.0, 0.5, 5.0, 0.0]);
}
/// General › Transform Pattern Tiles (#394) applies to Align and Distribute as it does to the
/// transforms: off, the tiles of a pattern-filled square stay in place when the square moves;
/// on, they move with it, and `patterns: false` keeps them in place for one call.
#[test]
fn align_and_distribute_follow_transform_pattern_tiles() {
use vectorcraft_color::Paint;
let mut s = new_doc();
let plain = square(&mut s, 0.0, 0.0);
s.execute("select.set", &json!({"ids": [plain.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let far = square(&mut s, 300.0, 0.0);
let filled = square(&mut s, 100.0, 0.0);
s.execute("paint.setFill", &json!({"ids": [filled.0], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [plain.0, filled.0, far.0]})).unwrap();
let tiles = |s: &Session| match s.doc().unwrap().doc.node(filled).unwrap().appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf.as_coeffs(),
p => panic!("not a pattern: {p:?}"),
};
// Align left moves the square 100 pt left, to the left edge of `plain`. Both Distribute
// commands move it 50 pt right, halfway between the other two.
for (cmd, p, dx) in [
("object.align", json!({"horizontal": "left"}), -100.0),
("object.distribute", json!({"horizontal": "left"}), 50.0),
("object.distributeSpacing", json!({"axis": "horizontal"}), 50.0),
] {
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(true));
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, dx, 0.0], "{cmd}: on, they move with the square");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
let mut p = p;
p["patterns"] = json!(false);
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(false));
}
}
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
/// swatch; on, only the same tint.
#[test]
+16
View File
@@ -42,6 +42,22 @@ fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
})
}
/// An invalid explicit Shaper target must never silently change which shapes get edited.
#[test]
fn shaper_scribble_rejects_malformed_explicit_targets() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let untouched = s.doc().unwrap().doc.layers.clone();
let undo = s.doc().unwrap().history.undo.len();
for ids in [json!([a.0, "bad"]), json!([a.0, 999999]), json!({"id": a.0}), json!("bad")] {
let err = s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": ids}));
assert!(err.is_err(), "malformed targets were accepted");
assert_eq!(s.doc().unwrap().doc.layers, untouched);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": [a.0]})).unwrap();
}
#[test]
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
for reverse in [false, true] {
+20
View File
@@ -283,6 +283,26 @@ fn swatch_exchange_files_save_with_models_kinds_and_groups() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn save_counts_the_swatches_the_file_holds() {
let mut s = session();
// A new document has 46 solid colors and 4 linear or radial gradients besides None. Mesh is a freeform gradient.
run(&mut s, "swatch.new", json!({"name": "Mesh", "gradient": {"kind": "freeform"}}));
let mut counts = vec![];
for format in ["vcswatches", "gpl", "ase", "css"] {
let r = run(&mut s, "swatch.library.save", json!({ "format": format }));
// The libraries load back with that many swatches, and the CSS file has one property for each.
let held = if format == "css" {
r["data"].as_str().unwrap().lines().filter(|l| l.starts_with(" --")).count() as u64
} else {
let load = json!({"data": r["data"], "dataBase64": r["dataBase64"], "name": format!("Saved.{format}")});
run(&mut s, "swatch.library.load", load)["count"].as_u64().unwrap()
};
counts.push((format, r["count"].as_u64().unwrap(), held));
}
assert_eq!(counts, [("vcswatches", 51, 51), ("gpl", 46, 46), ("ase", 46, 46), ("css", 50, 50)]);
}
#[test]
fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() {
use vectorcraft_testkit::ase::{Block, ase, sample};
+45
View File
@@ -30,6 +30,51 @@ fn dark(s: &Session, x: u32, y: u32) -> bool {
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y)[0] < 128
}
/// Invalid entries in an explicit multi-point delete must not delete the valid entries.
#[test]
fn invalid_width_point_indices_leave_the_entire_profile_unchanged() {
let (mut s, id) = line_session();
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
let before = points(&s, id);
let history = s.doc().unwrap().history.undo.len();
for bad in [
json!({"indices": [1, "bad"]}),
json!({"indices": [-1, 1]}),
json!({"indices": [1.5, 1]}),
json!({"indices": {"point": 1}}),
json!({"index": "bad"}),
] {
let mut params = bad.as_object().unwrap().clone();
params.insert("id".into(), json!(id.0));
let err = s.execute("stroke.widthPoint.remove", &serde_json::Value::Object(params));
assert!(err.is_err(), "accepted malformed width point indices");
assert_eq!(points(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "indices": [1]})).unwrap();
assert_eq!(points(&s, id).len(), before.len() - 1);
}
/// Setting or copying a width point with a malformed `index` is refused like one out of range: it
/// never adds a point instead, or wraps a huge index round. A null `index` is no index (a new point).
#[test]
fn a_malformed_width_point_index_is_refused_by_set_and_copy() {
let (mut s, id) = line_session();
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
let before = points(&s, id);
let history = s.doc().unwrap().history.undo.len();
for index in [json!("bad"), json!(-1), json!(1.5), json!([1]), json!(u64::MAX), json!(before.len())] {
for cmd in ["stroke.widthPoint.set", "stroke.widthPoint.copy"] {
let r = s.execute(cmd, &json!({"id": id.0, "index": index, "t": 0.3, "left": 4, "right": 4}));
assert!(r.is_err(), "{cmd} took index {index}");
assert_eq!(points(&s, id), before, "{cmd} with index {index}");
}
}
assert_eq!(s.doc().unwrap().history.undo.len(), history);
set(&mut s, json!({"id": id.0, "index": null, "t": 0.3, "left": 4, "right": 4}));
assert_eq!(points(&s, id).len(), before.len() + 1, "a null index adds a point");
}
#[test]
fn a_point_moved_onto_another_makes_a_discontinuous_point_that_renders_a_step() {
let (mut s, id) = line_session();
+31
View File
@@ -104,6 +104,37 @@ fn reflect_and_shear_tools() {
assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}");
}
/// Reflect and Shear return an error for an `axis` they don't take (Reflect's names it and also
/// takes an angle): nothing moves and no undo step is recorded. A null `axis` counts as not given: Reflect
/// then reflects across the vertical axis and Shear shears along the horizontal one.
#[test]
fn reflect_and_shear_reject_unknown_axes_without_changing_state() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 30.0, 10.0);
let n = undo_len(&s);
let reflect = "axis must be \"vertical\", \"horizontal\" or an angle in degrees";
let shear = "`axis` must be one of horizontal, vertical";
for (cmd, p, msg) in [
("object.reflect", json!({"axis": "diagonal", "origin": [0, 0]}), format!("{reflect}, not `diagonal`")),
("object.reflect", json!({"axis": "45", "origin": [0, 0]}), format!("{reflect}, not `45`")),
("object.reflect", json!({"axis": true, "origin": [0, 0]}), format!("{reflect}, not `true`")),
("object.shear", json!({"angle": 30, "axis": "diagonal"}), shear.to_string()),
("object.shear", json!({"angle": 30, "axis": 90}), shear.to_string()),
] {
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}"));
assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}"));
}
assert!(close(bounds(&s, a), Rect::new(10.0, 20.0, 40.0, 30.0)), "nothing moved: {:?}", bounds(&s, a));
assert_eq!(undo_len(&s), n, "no undo step");
s.execute("object.reflect", &json!({"axis": null, "origin": [0, 0]})).unwrap();
assert!(close(bounds(&s, a), Rect::new(-40.0, 20.0, -10.0, 30.0)), "across the vertical axis: {:?}", bounds(&s, a));
s.execute("object.reflect", &json!({"axis": 0, "origin": [0, 0]})).unwrap();
assert!(close(bounds(&s, a), Rect::new(-40.0, -30.0, -10.0, -20.0)), "an angle still reflects: {:?}", bounds(&s, a));
s.execute("object.shear", &json!({"angle": 45, "axis": null, "origin": [0, 0]})).unwrap();
let b = bounds(&s, a);
assert!((b.y0 + 30.0).abs() < 1e-6 && (b.y1 + 20.0).abs() < 1e-6 && b.width() > 30.0, "along the horizontal axis: {b:?}");
}
#[test]
fn distort_command_maps_corners() {
let mut s = session();
+3
View File
@@ -321,6 +321,9 @@ fn structured_junk() {
("object.setBounds", json!({"width": 0, "height": 0})),
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
("object.distributeSpacing", json!({"axis": "vertical", "spacing": 0})),
("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard", "bounds": "preview"})),
("object.distribute", json!({"vertical": "center", "bounds": "geometric"})),
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})),
("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})),
+79 -2
View File
@@ -624,6 +624,17 @@ fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool {
lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL)
}
/// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour
/// and its CMYK equivalent stay within it; a red and a dark red don't).
const SAME_COLOUR: u32 = 64;
/// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same
/// lightness to be alike)?
fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool {
let (p, q) = (on_white(p), on_white(q));
p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR)
}
/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, and of
/// those whose neighbours all differ too, away from type (where one draws an object that the other
/// doesn't, not the edges of one drawn a little differently, or type laid out a little
@@ -672,6 +683,20 @@ impl PageView {
type_areas(&b.layers, rb, self.scale, &mut areas);
compared(&self.page, &y, &areas)
}
/// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page
/// ([`differ_in_colour`]), type included: what taking an object out of the layers changes
/// there, when it is covered by one of about the same lightness (#1102).
fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 {
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let y = self.layers.render_region(b, rb, self.scale, false);
if (y.width, y.height) != (self.page.width, self.page.height) {
return 1.0;
}
let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count();
differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0)
}
}
/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]),
@@ -823,6 +848,14 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
let artboard_page =
artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0));
let whole = matches!(page, Page::Art) && !artboard_page;
if whole {
// Its page is the file's own (its bounding box), as when it comes in as its page: art
// reaching past the editing data's artboard stays on it (#1102).
doc.artboards.truncate(1);
if let Some(a) = doc.artboards.first_mut() {
a.rect = rect;
}
}
frame = Some(Frame { page: visible, page_rect, rect, whole });
}
let mut infos = vec![];
@@ -940,7 +973,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
if found.is_empty() || found.len() > MAX_WEIGHED {
return 0;
}
let mut with = view.difference(doc, rect).any;
let with = view.difference(doc, rect).any;
// Taking a text object out changes only what is drawn in its box. When the art would still
// differ from the page by more than `compare` allows with every candidate gone, the layers
// aren't used whatever is pruned: don't draw them again for each one (#758).
@@ -949,6 +982,9 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE {
return 0;
}
// Weighed by colour, not only lightness: a text object's red stroke over a dark red one is no
// less there for being about as light (#1102).
let mut with = view.colour_difference(doc, rect);
let mut removed = 0;
for (id, bounds) in found {
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
@@ -958,7 +994,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
if !take_out(&mut without.layers, id, true) {
continue;
}
let d = view.difference(&without, rect).any;
let d = view.colour_difference(&without, rect);
if d - with <= UNSEEN_MARGIN {
take_out(&mut doc.layers, id, false);
with = d;
@@ -1121,6 +1157,47 @@ mod tests {
use super::*;
use vectorcraft_doc::{CharStyle, TextObject};
/// `doc` with a square ring round (20, 20)–(80, 80) (or at `at`), stroked `color` `width` wide,
/// named `name`.
fn ring(doc: &mut Document, name: Option<&str>, color: vectorcraft_color::Color, width: f64, at: Rect) {
let stroke = vectorcraft_color::Paint::solid(color);
let mut n = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(at),
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, stroke, width),
);
n.name = name.map(str::to_string);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
/// A text object's red stroke drawn over a dark red one of about the same lightness is drawn
/// by the page: it isn't taken out as unseen (#1102). An object the page doesn't draw still is.
#[test]
fn a_stroke_over_one_as_light_is_not_taken_for_unseen() {
let (dark, red) = (vectorcraft_color::Color::rgb(0.55, 0.0, 0.0), vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
let square = Rect::new(20.0, 20.0, 80.0, 80.0);
let mut page = Document::new(100.0, 100.0);
ring(&mut page, None, dark, 12.0, square);
ring(&mut page, None, red, 6.0, square);
let mut layers = Document::new(100.0, 100.0);
ring(&mut layers, Some(MADE), dark, 12.0, square);
ring(&mut layers, Some(MADE), red, 6.0, square);
ring(&mut layers, Some(MADE), red, 2.0, Rect::new(40.0, 40.0, 60.0, 60.0));
let rect = page.artboards[0].rect;
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
let mut view = PageView::new(&frame);
assert_eq!(prune_unseen(&frame, &mut view, &mut layers), 1, "only the ring the page doesn't draw");
let widths: Vec<f64> = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect();
assert_eq!(widths, [12.0, 6.0]);
// Taken by lightness alone, the red and the dark red are alike.
let px = |c: vectorcraft_color::Color| {
let [r, g, b] = c.to_rgb().map(|v| (v * 255.0).round() as u8);
[r, g, b, 255]
};
assert!(!differ(&px(dark), &px(red)) && differ_in_colour(&px(dark), &px(red)));
}
fn piece(text: &str, x: f64, y: f64) -> Arc<Node> {
Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(x, y), text, CharStyle::default())))))
}
+14
View File
@@ -654,6 +654,20 @@ fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_p
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// An EPS whose page is its art's box keeps that page when it comes in through its layers, as it
/// does as its page: not the editing data's artboard (#1102).
#[test]
fn an_eps_page_of_its_art_stays_its_page_through_the_layers() {
let file = eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + &layer("Far", false, &square(80, 80))));
let r = import(&file).unwrap();
assert_eq!(names(&r.document), ["Art", "Far"], "{:?}", r.warnings);
let boards: Vec<_> = r.document.artboards.iter().map(|a| a.rect).collect();
let art = r.document.layers[0].children().unwrap()[0].visual_bounds().unwrap();
assert_eq!(boards.len(), 1);
assert!((boards[0].width() - 4.0).abs() < 1e-6 && (boards[0].height() - 4.0).abs() < 1e-6, "{boards:?}");
assert!(boards[0].inflate(1e-6, 1e-6).contains_rect(art), "{boards:?} round {art:?}");
}
#[test]
fn an_eps_saved_as_its_artboard_keeps_the_art_outside_it() {
// The page is the artboard (0 0 100 100): the art beyond it is the pasteboard's.
+1 -1
View File
@@ -138,7 +138,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"run_command",
"Run command",
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"align\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"horizontal\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
obj(
json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}),
&["command"],
+23 -4
View File
@@ -268,23 +268,42 @@ pub fn merge_regions(regions: &[&Region]) -> PathData {
/// path, from the arrangement itself, made once for every merge of the set: the faces' contours
/// share their edges exactly, so the edges between merged faces vanish. Uniting the faces' tidied
/// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut)
/// fall back to [`merge_regions`].
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut, as
/// [`shape_builder`](crate::shape_builder) gives them) merge in their planar map the same way
/// (#893). Either falls back to [`merge_regions`] when it can't place a face.
pub struct FaceMerger {
/// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes.
arr: Option<Arrangement>,
shapes: usize,
/// With open paths, the shapes, and their planar map once a merge has needed it.
lines: Option<(Vec<Shape>, std::cell::OnceCell<Option<crate::planar::Map>>)>,
}
impl FaceMerger {
pub fn new(shapes: &[Shape]) -> Self {
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len() }
// The Shape Builder's faces come from the planar map only up to its cap.
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let lines = (has_open && segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines }
}
/// The union of `faces`.
pub fn merge(&self, faces: &[&Region]) -> PathData {
self.exact(faces).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
}
/// `faces` merged in the planar map of shapes with open paths: each face found again by a
/// point inside it, and the result checked to cover what they cover.
fn planar(&self, faces: &[&Region]) -> Option<PathData> {
let (shapes, map) = self.lines.as_ref()?;
let map = map.get_or_init(|| crate::planar::map(shapes)).as_ref()?;
let inside: Vec<Point> = faces.iter().map(|r| crate::planar::interior_point(&r.path)).collect::<Option<_>>()?;
let merged = map.merge(&inside)?;
// A face not found as itself (two points in one face of the map) shows in the area.
let want: f64 = faces.iter().map(|r| crate::area(&r.path, FillRule::NonZero)).sum();
let outlines: f64 = faces.iter().map(|r| r.path.to_bezpath().perimeter(1e-6)).sum();
((crate::area(&merged, FillRule::NonZero) - want).abs() <= 1e-9 * want.max(1.0) + crate::DEFAULT_PRECISION * outlines).then_some(merged)
}
fn exact(&self, faces: &[&Region]) -> Option<PathData> {
+206 -17
View File
@@ -149,6 +149,20 @@ struct Edge {
source: usize,
}
/// `segs` (a chain) moved to start at `a` and end at `b`, the handles next to each moving with it.
fn pin_ends(segs: &mut [Seg], a: Point, b: Point) {
if let Some(s) = segs.first_mut() {
let d = a - s.c.p0;
s.c.p0 = a;
s.c.p1 += d;
}
if let Some(s) = segs.last_mut() {
let d = b - s.c.p3;
s.c.p3 = b;
s.c.p2 += d;
}
}
/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it.
fn twin(h: usize) -> usize {
h ^ 1
@@ -465,7 +479,24 @@ fn span(sp: &SubPath, u0: f64, u1: f64) -> Option<SubPath> {
segs_to_subpath(&segs, false)
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
/// The planar graph of `shapes` (in the sweep's frame) with its faces walked: what [`build`] makes
/// Live Paint faces and edges from, and what [`Map::merge`] merges faces in.
pub(crate) struct Map {
edges: Vec<Edge>,
around: Vec<Vec<usize>>,
slot_of: Vec<usize>,
/// Bounded faces: their cycle of half-edges, path and area.
faces: Vec<(Vec<usize>, BezPath, f64)>,
/// Outer boundaries of connected pieces, and the face each one is a hole in.
holes: Vec<(Vec<usize>, BezPath)>,
face_holes: Vec<Vec<usize>>,
tidy: Tidy,
/// The inputs' anchors (sorted by x) and how close results snap back onto them.
anchors: Vec<Point>,
snap: f64,
}
pub(crate) fn map(shapes: &[Shape]) -> Option<Map> {
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
for (i, s) in shapes.iter().enumerate() {
let mut bp = BezPath::new();
@@ -508,11 +539,11 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
.filter_map(|k| grid.get(&k))
.flatten()
.copied()
.find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps));
.find_map(|v| points.get(v).filter(|q| q.distance(p) <= eps).map(|&q| (v, q)));
near.unwrap_or_else(|| {
points.push(p);
grid.entry((cx, cy)).or_default().push(points.len() - 1);
points.len() - 1
(points.len() - 1, p)
})
};
let mut edges: Vec<Edge> = Vec::new();
@@ -520,11 +551,16 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let outlines = Outlines::new(&inputs, eps);
for idx in top.segment_indices() {
let half = idx.first_half();
let segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
let ends = [vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())];
if segs.is_empty() || ends[0] == ends[1] {
let mut segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
let ((v0, p0), (v1, p1)) = (vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo()));
let ends = [v0, v1];
if segs.is_empty() || v0 == v1 {
continue;
}
// Every edge at a vertex starts or ends exactly on it, so faces meeting there share its
// coordinates: merging them (a union) then cancels their common edge, which it can't when
// each face carries its own rounding of the corner (#893).
pin_ends(&mut segs, p0, p1);
let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue };
// Coincident pieces the sweep left apart are one edge (the front-most input's).
let key = (ends[0].min(ends[1]), ends[0].max(ends[1]));
@@ -637,22 +673,89 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
}
}
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
// The inputs' anchors, which results land on again up to the sweep's tolerance.
let snap = 2.0 * eps;
let mut anchors: Vec<Point> = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
Some(Map { edges, around, slot_of, faces, holes, face_holes, tidy, anchors, snap })
}
impl Map {
/// The union of the faces that hold `inside` (document points, one per face), traced along the
/// map's own edges: those between two merged faces drop out exactly, where a boolean union of
/// the faces' outlines can leave them in when its sweep rounds their shared corners apart
/// (#893). Outer outlines come out positive, holes negative. None when a point is in no face.
pub(crate) fn merge(&self, inside: &[Point]) -> Option<PathData> {
let halves = 2 * self.edges.len();
// The face on each half-edge's side; holes bound the face around them.
let mut face_of: Vec<Option<usize>> = vec![None; halves];
for (i, (cycle, ..)) in self.faces.iter().enumerate() {
let hole_cycles = self.face_holes.get(i)?.iter().filter_map(|&k| self.holes.get(k)).map(|(c, _)| c);
for &h in std::iter::once(cycle).chain(hole_cycles).flatten() {
*face_of.get_mut(h)? = Some(i);
}
}
let mut picked = vec![false; self.faces.len()];
for &p in inside {
let q = SHEAR * p;
// The smallest face around it: faces of a piece inside a hole lie within the face around.
let (i, _) = self
.faces
.iter()
.enumerate()
.filter(|(_, (_, bp, _))| bp.bounding_box().contains(q) && bp.winding(q) != 0)
.min_by(|a, b| a.1.2.total_cmp(&b.1.2))?;
*picked.get_mut(i)? = true;
}
let merged = |h: usize| face_of.get(h).copied().flatten().is_some_and(|f| picked.get(f) == Some(&true));
let boundary = |h: usize| merged(h) && !merged(twin(h));
let mut used = vec![false; halves];
let mut subpaths = Vec::new();
for start in 0..halves {
if !boundary(start) || used.get(start).copied().unwrap_or(true) {
continue;
}
let mut cycle = vec![];
let mut h = start;
while !used.get(h).copied().unwrap_or(true) {
if let Some(u) = used.get_mut(h) {
*u = true;
}
cycle.push(h);
// Turn from the way back as a face walk does, past the edges inside the merged
// faces (between two of them, or a line's loose end), to the next boundary edge.
let back = twin(h);
let list = self.around.get(origin(&self.edges, back)?)?;
let k = *self.slot_of.get(back)?;
let n = list.len();
h = (1..=n).filter_map(|j| list.get((k + n * 2 - j) % n).copied()).find(|&g| boundary(g))?;
}
if h != start {
return None;
}
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&self.edges, h)).collect();
subpaths.push(segs_to_subpath(&tidy_segments(segs, true, &self.tidy), true)?);
}
(!subpaths.is_empty()).then(|| unshear(PathData::new(subpaths), &self.anchors, self.snap))
}
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let m = map(shapes)?;
let Map { edges, around, faces, holes, face_holes, tidy, anchors, snap, .. } = &m;
let snap = *snap;
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
let subpath = |cycle: &[usize]| {
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&edges, h)).collect();
segs_to_subpath(&tidy_segments(segs, true, &tidy), true)
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(edges, h)).collect();
segs_to_subpath(&tidy_segments(segs, true, tidy), true)
};
let regions = faces
.iter()
.zip(&face_holes)
.zip(face_holes)
.filter_map(|((cycle, _, _), hs)| {
let mut subpaths = vec![subpath(cycle)?];
subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c)));
let path = unshear(PathData::new(subpaths), &anchors, snap);
let path = unshear(PathData::new(subpaths), anchors, snap);
let sources =
interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default();
Some(Region { path, sources })
@@ -680,13 +783,13 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
return Some((segs, true, source));
}
*u = true;
segs.extend(half_segs(&edges, h));
let v = origin(&edges, twin(h))?;
let Some((a, b)) = passes(v) else { return Some((segs, origin(&edges, h0) == Some(v), source)) };
segs.extend(half_segs(edges, h));
let v = origin(edges, twin(h))?;
let Some((a, b)) = passes(v) else { return Some((segs, origin(edges, h0) == Some(v), source)) };
h = if a == twin(h) { b } else { a };
}
};
for v in 0..nv {
for v in 0..around.len() {
if passes(v).is_some() {
continue;
}
@@ -709,8 +812,8 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let edge_shapes = chains
.into_iter()
.filter_map(|(segs, closed, source)| {
let sp = segs_to_subpath(&tidy_segments(segs, closed, &tidy), closed)?;
Some(Shape::new(unshear(PathData::new(vec![sp]), &anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
let sp = segs_to_subpath(&tidy_segments(segs, closed, tidy), closed)?;
Some(Shape::new(unshear(PathData::new(vec![sp]), anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
})
.collect();
Some((regions, edge_shapes))
@@ -913,6 +1016,92 @@ mod tests {
assert_eq!(edges.len(), 1);
}
/// A strip merged across an axonometric grid of lines (#893, the reporter's file): faces meeting
/// at a vertex share its exact coordinates, so the merge is one outline with no edge left inside.
#[test]
fn faces_share_their_corners_exactly_so_merging_them_leaves_no_seam() {
const LINES: &[[f64; 4]] = &[
[416.57797027866155, 268.45362144397683, 416.5779702786616, 811.5463785560231],
[243.12292300414873, 511.1134238473054, 713.4550472745127, 782.6598024033286],
[836.8770769958512, 511.1134238473054, 366.5449527254871, 782.6598024033286],
[457.718646852441, 268.45362144397683, 457.718646852441, 811.5463785560231],
[263.6932612910385, 475.4845528055331, 734.0253855614023, 747.0309313615563],
[816.3067387089613, 475.4845528055331, 345.9746144385975, 747.0309313615563],
[498.85932342622044, 268.45362144397683, 498.85932342622044, 811.5463785560231],
[284.26359957792823, 439.8556817637607, 754.5957238482922, 711.4020603197839],
[795.7364004220717, 439.8556817637607, 325.40427615170773, 711.4020603197839],
[539.9999999999998, 268.45362144397683, 539.9999999999998, 811.5463785560231],
[304.83393786481787, 404.22681072198844, 775.1660621351818, 675.7731892780117],
[775.1660621351821, 404.22681072198844, 304.833937864818, 675.7731892780117],
[581.1406765737793, 268.45362144397683, 581.1406765737793, 811.5463785560231],
[325.4042761517076, 368.59793968021614, 795.7364004220715, 640.1443182362394],
[754.5957238482922, 368.59793968021614, 284.26359957792835, 640.1443182362394],
[622.2813531475588, 268.45362144397683, 622.2813531475588, 811.5463785560231],
[345.97461443859737, 332.9690686384438, 816.3067387089613, 604.515447194467],
[734.0253855614026, 332.9690686384438, 263.6932612910385, 604.515447194467],
[663.422029721338, 268.45362144397683, 663.422029721338, 811.5463785560231],
[366.54495272548706, 297.3401975966715, 836.877076995851, 568.8865761526947],
[713.4550472745128, 297.3401975966715, 243.12292300414884, 568.8865761526947],
];
let s: Vec<Shape> = LINES.iter().enumerate().map(|(i, &[x0, y0, x1, y1])| line((x0, y0), (x1, y1), i as u64)).collect();
let (faces, _) = live_paint(&s);
let corners: Vec<Point> = faces.iter().flat_map(|f| f.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
for p in &corners {
for q in &corners {
assert!(p == q || p.distance(*q) > 1e-6, "one corner, two positions: {p:?} {q:?}");
}
}
// Down the strip between the lines x ≈ 498.9 and x = 540: 11 triangles, one outline.
let mut strip: Vec<&Region> = vec![];
for k in 0..=240 {
if let Some(f) = faces.iter().find(|f| f.contains(Point::new(519.43, 420.0 + f64::from(k))))
&& !strip.iter().any(|g| std::ptr::eq(*g, f))
{
strip.push(f);
}
}
assert_eq!(strip.len(), 11);
// As the Shape Builder merges them: in the map, so no sweep can round the corners apart.
let merged = crate::FaceMerger::new(&s).merge(&strip);
assert_eq!(merged.subpaths.len(), 1, "{merged:?}");
let want: f64 = strip.iter().map(|f| area(f)).sum();
assert!((crate::area(&merged, FillRule::NonZero) - want).abs() < 1e-6 * want);
}
/// Merging faces of a grid of lines in the map: a ring keeps its hole, cells that only touch at
/// a corner stay two outlines, a line's loose end inside the merge leaves no spur.
#[test]
fn faces_merge_in_the_map_with_holes_corners_and_loose_ends() {
let mut s = vec![];
for k in 0..4 {
let v = f64::from(k) * 10.0;
s.push(line((v, -5.0), (v, 35.0), 0));
s.push(line((-5.0, v), (35.0, v), 1));
}
// A loose end poking into the middle cell.
s.push(line((15.0, 10.0), (15.0, 15.0), 2));
let (faces, _) = live_paint(&s);
let cell = |x: f64, y: f64| at(&faces, x, y)[0];
let merger = crate::FaceMerger::new(&s);
let m = map(&s).unwrap();
let check = |picked: &[&Region], subpaths: usize, area_want: f64| {
// Merged in the map itself, not by the fallback union.
let inside: Vec<Point> = picked.iter().map(|f| interior_point(&f.path).unwrap()).collect();
assert_eq!(m.merge(&inside).as_ref(), Some(&merger.merge(picked)));
let m = merger.merge(picked);
assert_eq!(m.subpaths.len(), subpaths, "{m:?}");
assert!((crate::area(&m, FillRule::NonZero) - area_want).abs() < 1e-6, "{}", crate::area(&m, FillRule::NonZero));
m
};
let all: Vec<&Region> = faces.iter().collect();
let whole = check(&all, 1, 900.0);
assert_eq!(whole.subpaths[0].anchors.len(), 4, "one square, no anchors left from the inner lines or the loose end");
let ring: Vec<&Region> =
[(5., 5.), (15., 5.), (25., 5.), (5., 15.), (25., 15.), (5., 25.), (15., 25.), (25., 25.)].iter().map(|&(x, y)| cell(x, y)).collect();
check(&ring, 2, 800.0);
check(&[cell(5.0, 5.0), cell(15.0, 15.0)], 2, 200.0);
}
#[test]
fn shape_builder_lines_cut_regions() {
// A line across a rectangle: two regions, the line in three pieces keyed by its index.
+68 -2
View File
@@ -136,6 +136,8 @@ pub(crate) struct TextLine {
/// The spaces read from gaps between glyphs: their byte offset in the text, and the gap from
/// the end of the glyph before to the start of the next (points).
gaps: Vec<(usize, f64)>,
/// Each glyph's advance in the file's font (points), with [`Self::baseline`].
advances: Vec<f64>,
}
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
@@ -168,6 +170,7 @@ impl TextLine {
column: false,
ink: vec![],
gaps: vec![],
advances: vec![],
}
}
@@ -228,7 +231,8 @@ impl TextLine {
&& at.dir.dot(self.at.dir) > 0.9995
&& (at.slant - self.at.slant).abs() < 1e-3
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
&& gap > -size * 0.3
// Proportional widths close CJK marks up by up to half an em.
&& gap > -size * 0.6
&& gap < size * 3.0;
// Or on a curve: turned a little from the glyph before it, and starting about where
// that one ends.
@@ -263,6 +267,7 @@ impl TextLine {
self.last = at.origin;
self.last_dir = at.dir;
self.baseline.push(at.origin);
self.advances.push(advance);
true
}
@@ -329,6 +334,14 @@ impl TextLine {
// The glyphs' tops lean along the baseline (+x), up being -y in the type's own space.
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle) * Affine::skew(-self.at.slant, 0.0);
let db = vectorcraft_text::FontDb::global();
// Glyphs spaced each their own way (character tightening, optical kerning) keep their
// place: each character its own tracking.
let own = (self.upright.is_none() && t.para.direction.is_none())
.then(|| own_spacing(&t, &self.baseline, &self.advances, path.as_ref(), self.at.dir, self.at.size))
.flatten();
if let Some(own) = &own {
t.runs = tracked_each(std::mem::take(&mut t.runs), own, self.at.size);
}
if let Some(path) = path {
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
@@ -358,7 +371,7 @@ impl TextLine {
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
let size = self.at.size;
let mut bounds = laid.bounds;
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
if own.is_none() && chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
// The spaces read from gaps keep their own place only in text left in the file's order.
let total: usize = t.runs.iter().map(|r| r.text.len()).sum();
let gaps: Vec<(usize, f64)> =
@@ -373,6 +386,59 @@ impl TextLine {
}
}
/// The space each glyph of `t` (laid out untracked) leaves after its advance to put the next one
/// where the file did (points; the last glyph's is the one before it), when that varies by more
/// than a fiftieth of an em: one tracking value can't place them. Glyph origins are `baseline`,
/// measured along `path` (type on a path: one curve per glyph) or along `dir`. None when the
/// glyphs can't be matched to the characters one to one (spaces, ligatures), or when the font
/// laid out isn't the file's (its advances differ from `advances`): spacing measured against a
/// stand-in's widths would keep the stand-in's differences once the font is installed.
fn own_spacing(t: &TextObject, baseline: &[Point], advances: &[f64], path: Option<&BezPath>, dir: Vec2, size: f64) -> Option<Vec<f64>> {
let text: String = t.runs.iter().map(|r| r.text.as_str()).collect();
let chars = text.chars().count();
if chars < 3 || text.contains(' ') || baseline.len() != chars || advances.len() != chars {
return None;
}
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let advs: Vec<f64> = laid.glyphs.iter().map(|g| g.advance).collect();
if advs.len() != chars || advs.iter().zip(advances).any(|(a, b)| (a - b).abs() > size * 0.01) {
return None;
}
let dist: Vec<f64> = match path {
Some(bp) => bp.segments().take(chars - 1).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).collect(),
None => baseline.windows(2).map(|w| (w[1] - w[0]).dot(dir)).collect(),
};
if dist.len() != chars - 1 {
return None;
}
let mut extra: Vec<f64> = dist.iter().zip(&advs).map(|(d, a)| d - a).collect();
let mean = extra.iter().sum::<f64>() / extra.len() as f64;
if extra.iter().all(|e| (e - mean).abs() <= size * 0.02) {
return None;
}
extra.push(*extra.last()?);
Some(extra)
}
/// `runs` with each character's tracking from `own` (points, one per character), characters with
/// the same tracking kept in one run.
fn tracked_each(runs: Vec<TextRun>, own: &[f64], size: f64) -> Vec<TextRun> {
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round();
let mut out: Vec<TextRun> = vec![];
let mut k = 0;
for r in runs {
for c in r.text.chars() {
let style = CharStyle { tracking: own.get(k).map_or(0.0, |e| em(*e)), ..r.style.clone() };
k += 1;
match out.last_mut() {
Some(last) if last.style == style && last.inline == r.inline => last.text.push(c),
_ => out.push(TextRun { text: c.to_string(), style, inline: r.inline.clone() }),
}
}
}
out
}
/// The tracking (thousandths of an em) that sets a line of `chars` characters, laid out untracked
/// as `laid`, `extra` points longer, as the file set it: the same after every character, except
/// the spaces read from gaps (`gaps`: byte offset, gap in points) where that would put the glyphs
+9
View File
@@ -464,3 +464,12 @@ fn art_past_the_page_clip_keeps_the_clip() {
assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })));
assert!(!r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings);
}
/// Glyphs closed up by up to half an em (the proportional widths of CJK punctuation) still
/// continue their line.
#[test]
fn glyphs_closed_up_by_half_an_em_stay_one_line() {
let t = texts(&open(&one_page("BT /F1 20 Tf 10 40 Td [(T) 400 (i) 450 (g) 0 (h) 0 (t)] TJ ET", HELVETICA, &[])));
assert_eq!(t.len(), 1, "one line: {:?}", t.iter().map(|t| t.plain_text()).collect::<Vec<_>>());
assert_eq!(t[0].plain_text(), "Tight");
}
+43
View File
@@ -329,3 +329,46 @@ fn type_in_another_installed_version_reopens_in_that_version() {
assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}");
}
}
/// The glyph origins of the type objects of `d`, in document space, in order.
fn origins(d: &Document) -> Vec<Point> {
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
out.extend(vectorcraft_text::layout(FontDb::global(), t).glyphs.iter().map(|g| t.xf * g.origin));
}
});
out
}
/// Type spaced glyph by glyph (character tightening, optical kerning) comes back with each glyph
/// in its place, not evened out by one tracking value; evenly spaced type keeps one run.
#[test]
fn glyphs_spaced_each_their_own_way_keep_their_place() {
let runs = [("T", 0.0), ("i", -120.0), ("g", 160.0), ("h", -60.0), ("tl", 0.0), ("y", 0.0)];
let mut t = TextObject::point(Point::new(20.0, 100.0), "", style(40.0));
t.runs = runs.iter().map(|(s, tr)| TextRun { text: s.to_string(), style: CharStyle { tracking: *tr, ..style(40.0) }, inline: None }).collect();
let d = doc(vec![t]);
let want = origins(&d);
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
let got = origins(&back);
assert_eq!(got.len(), want.len(), "{:?}", texts(&back));
for (g, w) in got.iter().zip(&want) {
assert!((g.x - w.x).abs() < 0.1 && (g.y - w.y).abs() < 0.1, "{got:?}\nvs {want:?}");
}
let mut runs_back = 0;
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
runs_back += t.runs.len();
}
});
assert!(runs_back > 1, "one tracking can't place them");
// Evenly tracked: one run.
let even = doc(vec![TextObject::point(Point::new(20.0, 100.0), "Tightly", CharStyle { tracking: -50.0, ..style(40.0) })]);
let back = import_as(&pdf(&even, false).bytes, TextAs::default());
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
assert_eq!(t.runs.len(), 1, "{:?}", t.runs.iter().map(|r| r.style.tracking).collect::<Vec<_>>());
}
});
}
+34 -2
View File
@@ -203,8 +203,17 @@ impl Manifest {
let resolved = match spec {
ParamSpec::Number { min, max, .. } => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)),
ParamSpec::Int { min, max, .. } => {
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
json!((f.round().clamp(*min as f64, *max as f64)) as i64)
// Keep JSON integers exact: f64 cannot represent every i64 or its bounds.
let value = if let Some(i) = v.as_i64() {
i.clamp(*min, *max)
} else if v.is_u64() {
// Past i64::MAX, so past any maximum.
*max
} else {
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
(f.round() as i64).clamp(*min, *max)
};
json!(value)
}
ParamSpec::Bool { .. } => json!(v.as_bool().ok_or_else(|| wrong("true or false"))?),
ParamSpec::Choice { options, .. } => {
@@ -303,6 +312,29 @@ mod tests {
assert!(m.resolve_params(&Value::Null).is_ok());
}
#[test]
fn integer_parameters_preserve_precision_and_exact_bounds() {
let m = Manifest::parse(
br#"{"id":"org.example.generative","name":"Generative Pattern","kind":"filter",
"params":{"seed":{"type":"int","min":-9007199254740995,"max":9007199254740995}}}"#,
)
.unwrap();
for (given, expected) in [
(json!(9007199254740992_i64), 9007199254740992_i64),
(json!(9007199254740993_i64), 9007199254740993_i64),
(json!(-9007199254740993_i64), -9007199254740993_i64),
(json!(i64::MAX), 9007199254740995_i64),
(json!(i64::MIN), -9007199254740995_i64),
(json!(u64::MAX), 9007199254740995_i64),
(json!(1e30), 9007199254740995_i64),
(json!(-1e30), -9007199254740995_i64),
(json!(3.6), 4_i64),
] {
let resolved = m.resolve_params(&json!({"seed": given})).unwrap();
assert_eq!(resolved["seed"], json!(expected), "input: {given}");
}
}
#[test]
fn rejects_bad_manifests() {
for bad in [
+1 -1
View File
@@ -977,7 +977,7 @@ impl Renderer {
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
let doc = std::ptr::from_ref(f.doc) as usize;
if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) {
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
}
let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?;
+18 -1
View File
@@ -167,11 +167,28 @@ impl OtFeatures {
}
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
/// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before.
/// horizontal type; vertical type takes proportional heights instead
/// ([`Self::proportional_vertical`]).
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
st.proportional_metrics && !self.vertical
}
/// Does text in style `st` take proportional heights (`vpal`)? Proportional Metrics in
/// vertical type.
pub(crate) fn proportional_vertical(&self, st: &CharStyle) -> bool {
st.proportional_metrics && self.vertical
}
/// The harfrust features for vertical text in style `st` shaped top to bottom: [`Self::resolve`],
/// with `vpal` when `proportional`.
pub(crate) fn resolve_vertical(&self, st: &CharStyle, proportional: bool) -> Vec<Feature> {
let mut v = self.resolve(st);
if proportional {
v.push(f(b"vpal", true));
}
v
}
/// The harfrust features for text in style `st` with full-width glyphs on their full widths:
/// [`Self::resolve`] without `palt`.
pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec<Feature> {
+9
View File
@@ -55,6 +55,14 @@ impl Ctx<'_> {
let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx;
g.dx += extra * 0.5;
g.adv += extra;
// Proportional Metrics: `vpal` moves it and changes its length down the column,
// in fonts with vertical metrics (else the cell stays as it is, #966).
if let Some(a) = g.vpal.filter(|_| g.tcy.is_none() && g.face.vertical_glyph(g.gid).is_some()) {
g.dx += a.dx;
g.dy += a.dy;
g.adv += a.advance;
g.proportional = a.advance != 0.0;
}
}
}
v
@@ -1178,6 +1186,7 @@ mod wrapping_tests {
inline: None,
lead: 0.0,
proportional: false,
vpal: None,
level: unicode_bidi::Level::ltr(),
})
.collect()
+46 -7
View File
@@ -52,12 +52,25 @@ pub(crate) struct SGlyph {
/// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width,
/// and Japanese composition takes no more space off it (#966).
pub proportional: bool,
/// Vertical type with Proportional Metrics: what `vpal` does to the glyph when it stands
/// upright ([`crate::layout`] applies it).
pub vpal: Option<VAdjust>,
/// Resolved Unicode bidi embedding level (logical source order).
pub level: Level,
/// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph.
pub inline: Option<InlineBox>,
}
/// How `vpal` moves an upright glyph in vertical type and changes its length down the column, in
/// points: `advance` down the column, `dx` and `dy` in glyph space (y down; `dy` is down the column
/// once the glyph stands upright).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct VAdjust {
pub advance: f64,
pub dx: f64,
pub dy: f64,
}
/// Placement of an inline graphic's art, glyph space being points with the pen at the origin on
/// the baseline (y down).
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -121,6 +134,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
h.ch = '-';
h.inline = None;
h.proportional = false;
h.vpal = None;
h
}
@@ -427,6 +441,7 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
tcy: None,
lead: 0.0,
proportional: false,
vpal: None,
// U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in
// RTL text like any other glyph once the line is reordered.
level,
@@ -475,11 +490,14 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
// Proportional Metrics: which glyphs `palt` gave another advance than their full width.
// Proportional Metrics: which glyphs `palt` gave another advance than their full width, and
// what `vpal` does to each in vertical type.
let mut proportional = vec![];
let mut vpal = vec![];
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let shape = |feats: &[Feature]| {
// gid, cluster, x and y advance, x and y offset.
let shape = |feats: &[Feature], direction: Direction| {
let mut buf = UnicodeBuffer::new();
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
@@ -491,7 +509,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
buf.add(c, cl);
}
}
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
buf.set_direction(direction);
buf.guess_segment_properties();
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
// glyphs for it. This retains context at real style/font segmentation boundaries without
@@ -506,18 +524,38 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
gb.glyph_infos()
.iter()
.zip(gb.glyph_positions())
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset))
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.y_advance, pos.x_offset, pos.y_offset))
.collect::<Vec<_>>()
};
raw = shape(&feats.resolve(st));
let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight };
raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(&raw).all(|(a, b)| a.0 == b.0);
if feats.proportional(st) {
// `palt` is positioning only: the same glyphs set on their full widths tell which
// advances it changed (kerning and the rest change both alike).
let full = shape(&feats.resolve_fixed_width(st));
if full.len() == raw.len() && full.iter().zip(&raw).all(|(a, b)| a.0 == b.0) {
let full = shape(&feats.resolve_fixed_width(st), along);
if same_glyphs(&full) {
proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect();
}
}
if feats.proportional_vertical(st) {
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
// top to bottom with and without it tell how it moves each and changes its advance down
// the column (y up, advances negative).
let (with, without) =
(shape(&feats.resolve_vertical(st, true), Direction::TopToBottom), shape(&feats.resolve_vertical(st, false), Direction::TopToBottom));
if same_glyphs(&with) && same_glyphs(&without) {
vpal = with
.iter()
.zip(&without)
.map(|(a, b)| {
let adj =
VAdjust { advance: f64::from(b.3 - a.3) * k * vs, dx: f64::from(a.4 - b.4) * k * hs, dy: -f64::from(a.5 - b.5) * k * vs };
(adj != VAdjust::default()).then_some(adj)
})
.collect();
}
}
});
if shaped.is_none() {
// Fallback: nominal glyphs and hmtx advances, no shaping.
@@ -573,6 +611,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
tcy: None,
lead: 0.0,
proportional: proportional.get(gi).copied().unwrap_or(false),
vpal: vpal.get(gi).copied().flatten(),
level: *level,
inline: None,
});
+7 -10
View File
@@ -753,8 +753,8 @@ fn layout_10k_area_text_is_fast() {
/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional
/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt`
/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a
/// line's end); punctuation it leaves full width is trimmed as before. Vertical type sets as
/// before. Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
/// line's end); punctuation it leaves full width is trimmed as before (vertical type: see
/// `tests_vertical`). Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
/// 〘〙 (skipped without it).
#[test]
fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
@@ -765,32 +765,29 @@ fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
}
let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() };
// Right-aligned in a frame ten ems wide, so a line's end trim moves the line.
let lay = |text: &str, on: bool, m: Mojikumi, vertical: bool| {
let lay = |text: &str, on: bool, m: Mojikumi| {
let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right);
t.vertical = vertical;
t.para.mojikumi = m;
layout(db(), &t)
};
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::<Vec<_>>();
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
let off = adv(&lay("「あ漢」", false, Mojikumi::None, false));
let on = adv(&lay("「あ漢」", true, Mojikumi::None, false));
let off = adv(&lay("「あ漢」", false, Mojikumi::None));
let on = adv(&lay("「あ漢」", true, Mojikumi::None));
assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}");
assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}");
assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}");
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's
// start (「), between punctuation (」、) or at the line's end (」).
for text in ["「あ」、漢", "漢「あ」"] {
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf, false), lay(text, true, Mojikumi::None, false));
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf), lay(text, true, Mojikumi::None));
assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none));
assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none));
}
// Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end.
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf, false), lay("〘あ〙", true, Mojikumi::None, false));
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf), lay("〘あ〙", true, Mojikumi::None));
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
let moved = x0(&half)[1] - x0(&none)[1];
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
// Vertical type sets as before.
assert!(near(&adv(&lay("「あ漢」", true, Mojikumi::None, true)), &adv(&lay("「あ漢」", false, Mojikumi::None, true))));
}
+43
View File
@@ -539,3 +539,46 @@ fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() {
assert_eq!(stop(Kinsoku::Soft), [0, 1, 1], "vertical {vertical_type}");
}
}
/// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional
/// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in
/// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal`
/// re-spaced, and trims the punctuation it leaves full height (〘〙 in Shippori Mincho) as before.
/// Off, the column is as before, and a font without vertical metrics (the bundled Source Sans 3)
/// keeps today's cells. Needs craft-fonts' Shippori Mincho (skipped without it).
#[test]
fn proportional_metrics_set_upright_glyphs_on_their_proportional_heights() {
use vectorcraft_doc::Mojikumi;
let lay = |text: &str, font: &str, on: bool, m: Mojikumi| {
let mut t = vertical_styled(text, CharStyle { size: 20.0, font_family: font.into(), proportional_metrics: on, ..CharStyle::default() });
t.para.mojikumi = m;
layout(FontDb::global(), &t)
};
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
let y0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().y0).collect::<Vec<_>>();
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
// Without vertical metrics the cells stay as they are.
let (on, off) = (lay("§§", "Source Sans 3", true, Mojikumi::None), lay("§§", "Source Sans 3", false, Mojikumi::None));
assert!(near(&adv(&on), &adv(&off)) && near(&y0(&on), &y0(&off)), "{:?} {:?}", adv(&on), adv(&off));
if FontDb::global().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let text = "「あ」、ッ漢。";
let off = lay(text, "Shippori Mincho", false, Mojikumi::None);
assert!(off.glyphs.iter().all(|g| (g.advance - 20.0).abs() < 0.01), "off: an em each, as before: {:?}", adv(&off));
let on = lay(text, "Shippori Mincho", true, Mojikumi::None);
assert!(on.glyphs[0].advance < 12.0 && on.glyphs[4].advance < 16.0, "「 and ッ shorter: {:?}", adv(&on));
assert!((on.glyphs[5].advance - 20.0).abs() < 0.01, "the kanji keeps its em: {:?}", adv(&on));
// Each glyph's ink stays in its own cell, so none runs into the next.
for g in &on.glyphs {
let b = g.outline.bounding_box();
assert!(b.y0 > g.origin.y - 0.5 && b.y1 < g.origin.y + g.advance + 0.5, "byte {}: ink {b:?} in [{}, +{}]", g.byte, g.origin.y, g.advance);
}
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing.
let half = lay(text, "Shippori Mincho", true, Mojikumi::LineEndHalf);
assert!(near(&adv(&half), &adv(&on)) && near(&y0(&half), &y0(&on)), "{:?} {:?}", adv(&half), adv(&on));
// Punctuation `vpal` leaves full height: set flush at the paragraph's start, as before.
let (half, none) = (lay("〘あ〙", "Shippori Mincho", true, Mojikumi::LineEndHalf), lay("〘あ〙", "Shippori Mincho", true, Mojikumi::None));
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
}
+153 -13
View File
@@ -3,7 +3,8 @@
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
//! path's anchors on that segment, drag to move selected anchors (the one pressed on snaps to
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
//! steps), drag a direction handle to
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a
//! direction handle to
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners
@@ -28,7 +29,7 @@ use std::borrow::Cow;
use serde_json::{Value, json};
use vectorcraft_doc::hit::{HitKind, hit_test};
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
use crate::bbox::move_delta;
@@ -50,8 +51,11 @@ enum State {
grab: Point,
began: bool,
},
/// Moving (Alt: copying) the selected objects; `grab`: the anchor an Alt-press picked the
/// path by, which snaps as a dragged anchor does.
MoveObject {
start: Point,
grab: Option<Point>,
began: bool,
},
/// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`).
@@ -113,6 +117,9 @@ pub struct DirectSelectionTool {
guides: Vec<Overlay>,
/// What the dragged anchors snap to, gathered when the drag begins.
anchor_snap: Option<PointSnap>,
/// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left
/// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original.
copy_snap: Option<PointSnap>,
/// What the dragged handle snaps to.
handle_snap: HandleSnap,
/// What the objects being moved snap to, gathered when the move begins.
@@ -131,6 +138,7 @@ impl DirectSelectionTool {
mesh: MeshEdit::default(),
guides: vec![],
anchor_snap: None,
copy_snap: None,
handle_snap: HandleSnap::default(),
move_snap: None,
hover: None,
@@ -319,7 +327,7 @@ impl Tool for DirectSelectionTool {
} else {
h.leaf
};
self.state = State::MoveObject { start: p, began: false };
self.state = State::MoveObject { start: p, grab: None, began: false };
if cx.selection.contains(target) {
vec![]
} else if ev.mods.shift || cx.selection.contains(h.leaf) {
@@ -368,8 +376,21 @@ impl Tool for DirectSelectionTool {
if cx.doc.node(id).is_some_and(is_area_type) {
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
}
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
self.state = State::MoveAnchors { start: p, grab, began: false };
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
if ev.mods.alt {
self.state = State::MoveObject { start: p, grab: Some(grab), began: false };
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
// A path with all its anchors selected is stored whole (no partial entry).
let already = cx.selection.contains(id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai)));
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
// the copy on and off during the drag as with the Selection tool.
self.state = if already && cx.selection.anchors.is_empty() {
State::MoveObject { start: p, grab: Some(grab), began: false }
} else {
State::MoveAnchors { start: p, grab, began: false }
};
if ev.mods.shift {
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
}
@@ -409,7 +430,7 @@ impl Tool for DirectSelectionTool {
return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))];
}
// Clicking the fill selects the whole leaf path (all anchors).
self.state = State::MoveObject { start: p, began: false };
self.state = State::MoveObject { start: p, grab: None, began: false };
if ev.mods.shift {
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
}
@@ -459,19 +480,31 @@ impl Tool for DirectSelectionTool {
));
out
}
(PointerKind::Drag, State::MoveObject { start, began }) => {
(PointerKind::Drag, State::MoveObject { start, grab, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveObject { start, began: true };
self.move_snap = Some(MoveSnap::new(cx));
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
self.state = State::MoveObject { start, grab, began: true };
if grab.is_some() {
// Alt can go down or up at any time: gather both while nothing has moved.
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default())));
} else {
self.move_snap = Some(MoveSnap::new(cx));
}
}
let mut d = move_delta(start, p, ev.mods.shift);
if let Some(snap) = &self.move_snap {
(d, self.guides) = snap.snap(cx, start, d);
self.guides.clear();
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
if let (Some(grab), Some(snap)) = (grab, snap) {
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
(d, self.guides) = (q - grab, guides);
} else if let Some(snap) = &self.move_snap {
(d, self.guides) = snap.snap(cx, start, d, ev.mods.alt);
}
out.push(Action::Preview(
"object.transform".into(),
@@ -554,7 +587,7 @@ impl Tool for DirectSelectionTool {
) => {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.move_snap) = (None, None);
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
if began { vec![Action::Commit] } else { vec![] }
}
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
@@ -838,6 +871,45 @@ mod tests {
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
}
/// Pressing an anchor of a path whose anchors are all selected (stored as the whole path)
/// keeps them all selected, so the drag moves them all; Shift still deselects that anchor.
#[test]
fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0));
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0));
let shift = Mods { shift: true, ..Default::default() };
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]);
}
/// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies
/// it as the Selection tool's Alt-drag does.
#[test]
fn alt_dragging_an_anchor_copies_the_whole_path() {
let (d, id) = doc_with_rect();
let alt = Mods { alt: true, ..Default::default() };
let mut whole = Selection::default();
whole.set([id]);
let p = paint();
for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] {
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt));
let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
assert_eq!(a, if pick { set } else { vec![] }, "{s:?}");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt));
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
}
}
#[test]
fn marquee_selects_anchors() {
let (d, id) = doc_with_rect();
@@ -1037,6 +1109,74 @@ mod tests {
(v.clone(), labels)
}
/// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does:
/// onto another path's anchor, or onto an anchor of the original the copy leaves behind.
#[test]
fn alt_dragged_copies_snap_by_the_anchor_pressed() {
let (d, _, b) = two_squares();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let mut whole = Selection::default();
whole.set([b]);
let c = cx(&d, &whole, &p);
let mut t = DirectSelectionTool::new(false);
let copy =
|dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true});
// B's top-left corner, pressed 1 pt off, onto A's bottom-right one.
let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt);
assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()]));
// B's top-left corner onto the original B's top-right one: the copy sits beside it.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt);
assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()]));
// The history says what the drag did.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt));
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt));
assert_eq!(out.first(), Some(&Action::Begin("Copy".into())));
}
/// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released
/// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed
/// snaps all along, onto the original only while it is left behind.
#[test]
fn alt_during_a_drag_by_an_anchor_toggles_the_copy() {
let (d, _, b) = two_squares();
let p = paint();
let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() });
let mut whole = Selection::default();
whole.set([b]);
let c = cx(&d, &whole, &p);
let mut t = DirectSelectionTool::new(false);
let preview = |dx: f64, dy: f64, copy: bool| {
Action::Preview(
"object.transform".into(),
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}),
)
};
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty());
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)),
vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)]
);
// The tool sees the document as previewed: B moved along, not where the copy leaves it.
let (mut moved, _, b2) = two_squares();
let l = moved.layers[0].id;
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0));
moved.remove(b2).unwrap();
moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
let c = cx(&moved, &whole, &p);
// Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one.
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]);
// Alt up: a move again, which leaves nothing behind to snap to.
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]);
// With only some anchors selected, the drag still moves just those.
let one = anchors_of(b, &[(0, 0)]);
let c = cx(&d, &one, &p);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt));
assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}");
}
/// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors
/// of its own path, and with several selected the one pressed on snaps, the others following.
#[test]
+8 -7
View File
@@ -179,10 +179,11 @@ impl Targets {
Self::collect_inner(doc, exclude, None, visible, Gather::Bounds)
}
/// Targets for moving the selection of `cx`: [`Self::collect`]'s, styled as `cx` asks, and with
/// Smart Guides › Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
pub fn for_move(cx: &ToolContext) -> Self {
let mut t = Self::collect(cx.doc, &cx.selection.objects, None).styled(cx);
/// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection,
/// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides ›
/// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self {
let mut t = Self::collect(cx.doc, exclude, None).styled(cx);
if cx.spacing_guides {
t.boxes = object_boxes(cx.doc, &cx.selection.objects);
}
@@ -1229,7 +1230,7 @@ mod tests {
}
let (s, p) = (Selection::default(), paint());
let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count();
let t = Targets::for_move(&cx(&d, &s, &p));
let t = Targets::for_move(&cx(&d, &s, &p), &s.objects);
// 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each).
let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
assert_eq!(dv, Vec2::new(-1.0, 0.0));
@@ -1241,7 +1242,7 @@ mod tests {
let id = wide.alloc_id();
wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default()))
.unwrap();
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p)).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
assert_eq!(dv, Vec2::new(2.0, 0.0));
assert!(lines(&ov) >= 6, "{ov:?}");
// A column: 28 under D, as C and D are 30 apart.
@@ -1253,7 +1254,7 @@ mod tests {
assert_eq!(dv, Vec2::new(0.0, 2.0));
// Off: nowhere to go, and no marks.
let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) };
let (dv, ov) = Targets::for_move(&off).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0));
}
+51 -8
View File
@@ -79,36 +79,47 @@ pub struct SelectionTool {
/// Snap to Grid lands its bounds on the grid (#740), taking over from the rest; otherwise Smart
/// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the
/// point it was grabbed by on an anchor or a ruler guide. Snap to Pixel puts its top-left on whole
/// pixels.
/// pixels. A copy (Alt, which can go down or up at any time during the drag) leaves the original
/// behind, so it snaps to the original too.
pub(crate) struct MoveSnap {
bounds: Option<Rect>,
targets: Option<Targets>,
points: Option<Targets>,
/// The targets and points for a copy, gathered with the others before the previews move the
/// original.
copy_targets: Option<Targets>,
copy_points: Option<Targets>,
}
impl MoveSnap {
pub(crate) fn new(cx: &ToolContext) -> Self {
let guides = cx.smart_guides && !cx.snap_to_grid;
let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) };
Self {
bounds: selection_bounds(cx),
targets: (cx.smart_guides && !cx.snap_to_grid).then(|| Targets::for_move(cx)),
points: if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, &cx.selection.objects) },
targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)),
points: points(&cx.selection.objects),
copy_targets: guides.then(|| Targets::for_move(cx, &[])),
copy_points: points(&[]),
}
}
/// The move by `d` of the selection grabbed at `start`, snapped, and its guides.
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2) -> (Vec2, Vec<Overlay>) {
/// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its
/// guides.
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec<Overlay>) {
let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) };
let mut guides = vec![];
if cx.snap_to_grid
&& let Some(b) = self.bounds
{
d += grid_pull(b + d, cx.grid_step());
}
if let Some(t) = &self.points {
if let Some(t) = points {
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
d = q - start;
guides = ov;
}
if let (Some(t), Some(b)) = (&self.targets, self.bounds) {
if let (Some(t), Some(b)) = (targets, self.bounds) {
let (adj, ov) = t.snap_rect(b + d, cx.snap_tol());
d += adj;
guides = ov;
@@ -331,7 +342,7 @@ impl Tool for SelectionTool {
}
let mut d = move_delta(start, p, m.shift);
if let Some(snap) = &self.moving {
(d, self.guides) = snap.snap(cx, start, d);
(d, self.guides) = snap.snap(cx, start, d, m.alt);
}
self.state = State::Moving { start, began: true, deselect: None, key: None };
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
@@ -622,6 +633,38 @@ mod tests {
assert_eq!(a, vec![Action::Commit]);
}
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
/// gathered before the previews moved it); released, the move doesn't.
#[test]
fn an_alt_copy_snaps_to_the_original_left_behind() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, id) = doc_with_rect();
// An artboard whose edges and centre are out of reach.
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
let mut s = Selection::default();
s.add(id);
let p = paint();
let mut t = SelectionTool::default();
let dx = |a: &[Action]| {
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
v["matrix"][4].as_f64().unwrap()
};
t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 150.0, 150.0));
assert_eq!(dx(&t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Drag, 200.0, 150.0))), 50.0);
// The previews moved the rectangle: the tool sees it 50 pt along.
let mut moved = d.clone();
let l = moved.layers[0].id;
moved.remove(id).unwrap();
let r = vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0));
moved.insert(Some(l), 0, Node::path(id, r, Appearance::default_art())).unwrap();
let c = cx(&moved, &s, &p);
let alt = Mods { alt: true, ..Mods::default() };
// The copy's left edge, 3 pt past the original's right edge (x = 200), lands on it.
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0).with_mods(alt))), 100.0);
// Without Alt nothing stays behind there.
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0);
}
/// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by
/// whichever of its edges or its centre is nearest a gridline, on each axis.
#[test]
+2
View File
@@ -9,6 +9,7 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap
workspace = true
[dependencies]
craft-ui = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
@@ -33,6 +34,7 @@ serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
vectorcraft-testkit = { workspace = true }
wat = { workspace = true }
+49 -12
View File
@@ -362,7 +362,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let artboard_tool = app.session.tool_id() == "artboard";
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
for (i, ab) in doc.artboards.iter().enumerate() {
let r = xf.rect_to_screen(ab.rect);
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
painter.add(Shape::closed_line(xf.quad(ab.rect), Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c)));
// The bleed (Document Setup) as a red outline around the artboard.
@@ -370,7 +369,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
}
if (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
artboard_label(&painter, r, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
// At the artboard's own top-left corner, which a rotated view turns away from its
// bounding box's (#1095).
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
artboard_label(&painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
}
}
if app.ui.view.guides {
@@ -905,7 +907,8 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
v.artboard = i;
}
// A press with a selection tool inside an artboard makes it the active one (#693), as the
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it.
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to
// Artboard aligns to it, and All on Active Artboard selects on it.
if ev.kind == PointerKind::Down
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
@@ -1752,16 +1755,16 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
/// out rather than spilling over its neighbours.
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
/// Artboard `name` above the top-left corner of its on-screen rect `r`, no wider than the
/// artboard: a longer name ends in "…", and an artboard too narrow shows none (#949).
fn artboard_label(painter: &egui::Painter, r: egui::Rect, name: &str, color: Color32) {
if r.width() < MIN_LABELLED_ARTBOARD {
/// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on
/// screen): a longer name ends in "…", and an artboard too narrow shows none (#949).
fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) {
if !width.is_finite() || width < MIN_LABELLED_ARTBOARD {
return;
}
let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color);
job.wrap = egui::text::TextWrapping { max_width: r.width(), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
let galley = painter.layout_job(job);
painter.galley(r.left_top() - vec2(0.0, 4.0 + galley.size().y), galley, color);
painter.galley(corner - vec2(0.0, 4.0 + galley.size().y), galley, color);
}
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
@@ -2192,6 +2195,37 @@ mod tests {
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0));
}
/// A Shift-click with the Selection tool on an empty spot inside an artboard makes it the active
/// one and keeps the selection, and Align to Artboard then aligns the objects to it unless the
/// call names another artboard.
#[test]
fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let mut rect = |x: f64, y: f64| {
let r = app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + x, "y": boards[0].y0 + y, "width": 20, "height": 20}));
vectorcraft_doc::NodeId(r.unwrap()["id"].as_u64().unwrap())
};
let ids = [rect(10.0, 15.0), rect(50.0, 40.0)];
app.session.execute("select.set", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.select_tool("selection");
let view = app.view_info();
let shift = Mods { shift: true, ..Default::default() };
for kind in [PointerKind::Down, PointerKind::Up] {
dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view);
}
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays");
let lefts = |app: &VectorcraftApp| ids.map(|id| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0);
app.run("object.align", json!({"horizontal": "left", "to": "artboard"})).unwrap();
assert_eq!(lefts(&app), [boards[2].x0; 2], "the left edges are on the active artboard's");
app.run("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap();
assert_eq!(lefts(&app), [boards[1].x0; 2], "an artboard the caller names is kept");
}
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
#[test]
@@ -2240,8 +2274,9 @@ mod tests {
fn artboard_labels_fit_their_artboard() {
let ctx = egui::Context::default();
let drawn = |width: f32| {
let r = egui::Rect::from_min_size(pos2(50.0, 50.0), vec2(width, 40.0));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| artboard_label(ui.painter(), r, "01 - A long artboard name", Color32::WHITE));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
artboard_label(ui.painter(), pos2(50.0, 50.0), width, "01 - A long artboard name", Color32::WHITE)
});
out.textures_delta.clear();
out.shapes
.into_iter()
@@ -2473,8 +2508,10 @@ mod tests {
used
};
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only.
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only (once
// the rectangle isn't selected whole, which would move all its anchors).
app.select_tool("directSelection");
app.session.execute("select.none", &json!({})).unwrap();
let corner = xf.to_screen(Point::new(200.0, 150.0));
assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection");
let b = bounds(&app);
+179 -12
View File
@@ -47,13 +47,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.add_space(2.0);
// With the macOS menu bar the menus are at the top of the screen instead.
let menus_end = if app.services.native_menu.is_some() {
let full = ui.max_rect();
ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text);
ui.cursor().min.x
} else {
menus::menu_bar(app, ui)
};
let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) };
// The right-side group fills the space after the menus from the right; when it runs
// short, Discord goes first (it is also under Help), then the search icon (the palette
// stays under its shortcut), then the workspace switcher narrows.
@@ -97,6 +91,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(10.0);
crate::community::discord_button(app, ui, false);
}
if app.services.native_menu.is_some() {
let title = ui.painter().layout_no_wrap("VectorCraft".into(), egui::FontId::proportional(13.5), t.text);
let title_rect = egui::Rect::from_center_size(full.center(), title.size());
// Keep the title centered only while the interactive groups leave room for it.
if title_rect.left() >= menus_end + 8.0 && title_rect.right() <= ui.min_rect().left() - 8.0 {
ui.painter().galley(title_rect.min, title, t.text);
}
}
});
});
if custom {
@@ -153,7 +155,9 @@ pub enum AnchorControls {
/// Which anchor controls show for the selection and the active tool.
pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls {
let Some(st) = app.session.active() else { return AnchorControls::None };
let Some(st) = app.session.active() else {
return AnchorControls::None;
};
if !st.selection.anchors.is_empty() {
return AnchorControls::All;
}
@@ -229,7 +233,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchors = anchor_controls(app);
let label = match &first {
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => tl!("Anchor Point"),
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => {
tl!("Anchor Point")
}
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
if im.link.is_some() {
tl!("Linked File")
@@ -237,7 +243,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
tl!("Embedded")
}
}
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => tl!("Image Tracing"),
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => {
tl!("Image Tracing")
}
_ => tl!(crate::panels::appearance::object_label(app)),
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
@@ -571,11 +579,12 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let more = app.background.jobs.len() - 1;
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));
ui.add(egui::Label::new(egui::RichText::new(text).size(11.0).color(t.text)).truncate());
} else if !app.ui.status.is_empty() {
// 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));
let message = crate::i18n::msg(&app.ui.status);
ui.add(egui::Label::new(egui::RichText::new(message).size(11.0).color(t.text)).truncate());
}
});
});
@@ -1074,3 +1083,161 @@ mod tests {
assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft");
}
}
#[cfg(test)]
mod visual_regressions {
use super::*;
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
input
}
struct NativeMenus;
impl crate::native_menu::Backend for NativeMenus {
fn sync(&mut self, _: &crate::native_menu::MenuBar) {}
fn drain(&mut self) -> Vec<crate::native_menu::Event> {
Vec::new()
}
}
#[test]
fn capture_chrome_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else {
return;
};
std::fs::create_dir_all(&directory).unwrap();
for brightness in theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
let mut ready = false;
let mut harness =
egui_kittest::Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
theme::install_fonts(ui.ctx());
theme::apply(ui.ctx(), brightness);
ready = true;
return;
}
app_bar(app, ui);
status_bar(app, ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(4);
let stem = format!("vectorcraft-chrome-{brightness:?}-{width}-{scale}x");
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
let mut regions = serde_json::Map::new();
for id in ["app_bar", "status_bar"] {
let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect;
regions.insert(id.into(), json!([rect.left(), rect.top(), rect.width(), rect.height()]));
}
let dynamic: Vec<_> = harness
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.job.text.contains(" fps") => {
let r = text.visual_bounding_rect();
Some(json!([r.left(), r.top(), r.width(), r.height()]))
}
_ => None,
})
.collect();
std::fs::write(
directory.join(format!("{stem}.json")),
serde_json::to_vec_pretty(&json!({
"screen": [width, 200.0], "scale": scale, "regions": regions, "native_dynamic_text_regions": dynamic
}))
.unwrap(),
)
.unwrap();
}
}
}
}
#[test]
fn native_title_does_not_overlap_header_controls() {
for brightness in theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, brightness);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| app_bar(&mut app, ui));
output.textures_delta.clear();
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
assert_eq!(ctx.pixels_per_point(), scale);
let texts: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) => Some(text),
_ => None,
})
.collect();
let title = texts.iter().find(|text| text.galley.job.text == "VectorCraft");
if width >= 1280.0 {
assert!(title.is_some(), "the title remains visible when the header has room");
}
if let Some(title) = title {
for text in texts.iter().filter(|text| text.galley.job.text != "VectorCraft") {
assert!(
!title.visual_bounding_rect().intersects(text.visual_bounding_rect()),
"{width}: title overlaps {:?}",
text.galley.job.text
);
}
}
}
}
}
}
#[test]
fn long_status_stays_between_navigation_and_performance_readouts() {
for brightness in theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, brightness);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| status_bar(&mut app, ui));
output.textures_delta.clear();
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
assert_eq!(ctx.pixels_per_point(), scale);
let texts: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) => Some(text),
_ => None,
})
.collect();
let status = texts.iter().find(|text| text.galley.job.text.starts_with("Saved ")).expect("status stays visible");
assert_eq!(status.galley.rows.len(), 1, "status is one line");
assert!(status.galley.elided, "long status retains the full text for the label tooltip");
let bounds = status.visual_bounding_rect();
assert!(bounds.left() >= 0.0 && bounds.right() <= width, "{width}: {bounds:?}");
for text in texts.iter().filter(|text| !text.galley.job.text.starts_with("Saved ")) {
assert!(!bounds.intersects(text.visual_bounding_rect()), "{width}: status overlaps {:?}", text.galley.job.text);
}
}
}
}
}
}
+3
View File
@@ -104,6 +104,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"nativeMenuBar": app.services.native_menu.is_some(),
// Saves and exports still being written in the background (Background Save / Export).
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
// A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting
// for the person at the computer: what asked for it runs again when it answers.
"fileDialog": app.picks.waiting_kind(),
})
}
@@ -353,8 +353,10 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
}
}
if let Some(b) = boards.get_mut(i) {
ui.checkbox(b, name);
if let Some(b) = boards.get_mut(i)
&& crate::widgets::check_name(ui, name, *b)
{
*b ^= true;
}
});
}
@@ -399,10 +401,10 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
crate::panels::asset_export::paint_thumb(ui, st, a, r);
let mut checked = on.contains(&a.id);
if ui.checkbox(&mut checked, a.name.as_str()).changed() {
let checked = on.contains(&a.id);
if crate::widgets::check_name(ui, &a.name, checked) {
on.retain(|id| *id != a.id);
if checked {
if !checked {
on.push(a.id);
}
}
@@ -146,12 +146,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
}
if let Some(a) = act {
let r = if matches!(a, Action::Import) {
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
let current = preset.name.clone();
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, &current))
} else {
run(app, d, a, &preset.name)
let r = match a {
// Their file dialogs, shown off the UI thread, import into or export from the dialog
// as it is then.
Action::Import | Action::Export => {
let (current, export) = (preset.name.clone(), matches!(a, Action::Export));
crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, &current))
}
a => run(app, d, a, &preset.name),
};
if let Err(e) = r {
app.status(e);
+2 -2
View File
@@ -320,8 +320,8 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String,
crate::widgets::range_field(ui, ("plugin-int", k), x, *min as f64..=*max as f64, "", 0, 100.0).map(|x| json!(x as i64))
}
ParamSpec::Bool { .. } => {
let mut b = cur.as_bool().unwrap_or(false);
ui.checkbox(&mut b, "").changed().then(|| json!(b))
let b = cur.as_bool().unwrap_or(false);
crate::widgets::check(ui, "", b, true).then(|| json!(!b))
}
ParamSpec::Choice { options, .. } => {
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
+25 -3
View File
@@ -54,9 +54,16 @@ fn fields_of(n: &Node) -> serde_json::Map<String, Value> {
/// the current layer), filled in from the first.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let ids: Vec<NodeId> = match (p.get("ids").and_then(Value::as_array), p.get("id").and_then(Value::as_u64)) {
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(NodeId).collect(),
(None, Some(id)) => vec![NodeId(id)],
// Rows given are used as given: a malformed or unknown id fails rather than being skipped or
// the dialog opening on the highlighted rows instead.
let row = |v: &Value| -> Result<NodeId, String> {
let id = NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?);
st.doc.node(id).map(|_| id).ok_or_else(|| "no such layer or object".to_string())
};
let ids: Vec<NodeId> = match (p.get("ids"), p.get("id")) {
(Some(Value::Array(a)), _) => a.iter().map(row).collect::<Result<_, _>>()?,
(Some(v), _) => return Err(format!("`ids` must be an array of object ids, not {v}")),
(None, Some(v)) => vec![row(v)?],
(None, None) => {
let rows = st.highlighted_rows();
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
@@ -252,6 +259,21 @@ mod tests {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
}
/// Explicit object ids must not silently disappear or fall back to the highlighted row.
#[test]
fn invalid_layer_options_targets_do_not_open_a_partial_dialog() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
for params in
[json!({"ids": [id, "invalid"]}), json!({"ids": [id, 999999]}), json!({"ids": "invalid"}), json!({"id": "invalid"}), json!({"id": null})]
{
assert!(app.run("ui.layerOptions", params.clone()).is_err(), "accepted {params}");
assert!(app.ui.dialog.is_none(), "opened partial options for {params}");
}
app.run("ui.layerOptions", json!({"ids": [id]})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["ids"], json!([id]));
}
#[test]
fn layer_options_edit_the_layer_and_ok_is_one_undo_step() {
let mut app = app();
+8 -6
View File
@@ -103,12 +103,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
});
if let Some(a) = act {
let r = if matches!(a, Action::Import) {
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
let current = preset.name.clone();
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, &current))
} else {
run(app, d, a, &preset.name)
let r = match a {
// Their file dialogs, shown off the UI thread, import into or export from the dialog
// as it is then.
Action::Import | Action::Export => {
let (current, export) = (preset.name.clone(), matches!(a, Action::Export));
crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, &current))
}
a => run(app, d, a, &preset.name),
};
if let Err(e) = r {
app.status(e);
@@ -151,12 +151,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
let name = preset.name.clone();
if let Some(a) = act {
let r = if matches!(a, Action::Import) {
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
let current = name.clone();
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, &current))
} else {
run(app, d, a, &name)
let r = match a {
// Their file dialogs, shown off the UI thread, import into or export from the dialog
// as it is then.
Action::Import | Action::Export => {
let (current, export) = (name.clone(), matches!(a, Action::Export));
crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, &current))
}
a => run(app, d, a, &name),
};
if let Err(e) = r {
app.status(e);
+8 -6
View File
@@ -112,12 +112,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
});
if let Some(a) = act {
let r = if matches!(a, Action::Import) {
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
let current = name.clone();
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, &current))
} else {
run(app, d, a, &name)
let r = match a {
// Their file dialogs, shown off the UI thread, import into or export from the dialog
// as it is then.
Action::Import | Action::Export => {
let (current, export) = (name.clone(), matches!(a, Action::Export));
crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, &current))
}
a => run(app, d, a, &name),
};
if let Err(e) = r {
app.status(e);
+9 -8
View File
@@ -93,14 +93,15 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
super::form::check(ui, d, "__showAxis", tl!("Show axis"));
ui.add_space(6.0);
ui.horizontal(|ui| {
let mut visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
let response = ui.allocate_ui_with_layout(
let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
let tip = tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.");
let toggled = ui.allocate_ui_with_layout(
vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.checkbox(&mut visible, egui::RichText::new(tl!("Keep visible surfaces only when expanding")).size(12.0))
widgets::check_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip)
},
).inner;
if response.on_hover_text(tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.")).changed() {
d.fields.insert("expandVisibleOnly".into(), json!(visible));
if toggled {
d.fields.insert("expandVisibleOnly".into(), json!(!visible));
}
if reset_dot(ui, "expandVisibleOnly", !visible, "true") {
d.fields.insert("expandVisibleOnly".into(), json!(true));
@@ -111,9 +112,9 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
let mut shade = d.bool("shade");
if ui.checkbox(&mut shade, tl!("Shading")).changed() {
d.fields.insert("shade".into(), json!(shade));
let shade = d.bool("shade");
if widgets::check(ui, tl!("Shading"), shade, true) {
d.fields.insert("shade".into(), json!(!shade));
}
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "shade", !shade, "true") {
+3 -3
View File
@@ -145,9 +145,9 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma
let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null);
if o.ty == "boolean" {
ui.label("");
let mut on = value.as_bool().unwrap_or(false);
if ui.checkbox(&mut on, label.trim_end_matches(':')).on_hover_text(o.description).changed() {
d.fields.insert(o.name.into(), json!(on));
let on = value.as_bool().unwrap_or(false);
if widgets::check_tip(ui, label.trim_end_matches(':'), on, true, o.description) {
d.fields.insert(o.name.into(), json!(!on));
}
ui.end_row();
if o.name == "separateArtboards" {
+8 -8
View File
@@ -202,9 +202,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, &v.name);
let key = value_key(&v.name);
match v.kind {
VariableKind::Text => text(ui, d, &key, 220.0),
VariableKind::Text => {
text(ui, d, &key, 220.0);
}
VariableKind::Visibility => checkbox(ui, d, &key),
};
}
ui.end_row();
}
}
@@ -224,13 +226,11 @@ fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Respo
}
/// A checkbox for `key`, written back as it is toggled.
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response {
let mut b = d.bool(key);
let r = ui.checkbox(&mut b, "");
if r.changed() {
d.fields.insert(key.into(), json!(b));
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
let b = d.bool(key);
if widgets::check(ui, "", b, true) {
d.fields.insert(key.into(), json!(!b));
}
r
}
/// A dropdown over the document's names `options` (shown as they are, never translated) for
+208 -28
View File
@@ -26,7 +26,7 @@ fn column_left_id() -> egui::Id {
/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's
/// (egui temp memory, one frame old). Popped-out panels open left of it.
fn dock_left_id() -> egui::Id {
pub(crate) fn dock_left_id() -> egui::Id {
egui::Id::new("dock-left")
}
@@ -43,7 +43,7 @@ pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
/// The strip along the top of a dock column with its double arrow: » collapses the dock to icons,
/// « expands it. The whole strip is the click target (as on the toolbar). True when clicked.
fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
pub(crate) fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
@@ -66,20 +66,22 @@ fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
/// One icon of the column: a click pops its panel out (or puts it away); dragged out of the
/// column (`column`), the panel floats.
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, column: Rect) {
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, _column: Rect) {
let open = app.ui.open_panel.as_deref() == Some(id);
// Its own id: the icons below it move up when it floats, and mustn't take over its drag.
let (_, rect) = ui.allocate_space(vec2(30.0, 30.0));
let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label)));
if resp.clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
if resp.dragged() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !column.contains(p)) {
floating::tear(app, ui.ctx(), &[id], id, floating::strip_grab(ui.ctx(), resp.rect.left()));
}
crate::panel_docking::legacy_tab(app, ui, id, &resp);
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if crate::panel_docking::show(app, ui) {
return;
}
let t = Tokens::get(ui.ctx());
let shown = floating::shown_tab(&app.ui);
// Main tabbed group, unless it is collapsed to icons or all its panels float.
@@ -173,36 +175,36 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let out = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
let outside = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
// What a drag out of the strip floats: the panels, the one shown, where its tab began.
let mut tear: Option<(Vec<&'static str>, &'static str, f32)> = None;
let mut x = strip.left();
for tab in DockTab::ALL {
let (id, label, _) = tab.info();
if floating::group_of(&app.ui, id).is_some() {
continue;
}
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if tab == active { t.text_strong } else { t.text_dim });
let r = Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click_and_drag());
if tab == active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
let tabs: Vec<_> = floating::docked_tabs(&app.ui).collect();
let names: Vec<_> = tabs
.iter()
.map(|tab| {
let (id, label, _) = tab.info();
(id, label)
})
.collect();
let selected = tabs.iter().position(|&tab| tab == active).unwrap_or(0);
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let area = Rect::from_min_max(strip.min, egui::pos2((menu.left() - 4.0).max(strip.left()), strip.bottom() - 1.0));
let out = panel_tabs(ui, "docktab", area, &names, selected, theme::semibold(12.5), true);
for (i, resp) in out.responses {
let Some(&tab) = tabs.get(i) else { continue };
let id = tab.info().0;
if resp.clicked() {
app.ui.dock_tab = tab;
} else if resp.dragged() && out {
tear = Some((vec![id], id, r.left()));
}
x = r.right();
crate::panel_docking::legacy_tab(app, ui, id, &resp);
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(egui::pos2(x, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() {
app.ui.dock_tab = tab;
}
let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag());
if resp.dragged() && out {
if resp.dragged() && outside {
tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left()));
}
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
@@ -215,6 +217,92 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
floating::group_of(&app.ui, active.info().0).is_some()
}
/// App styling and overflow menus around the shared tab fitting/interaction components. Docking
/// and floating groups consume the responses themselves, preserving their different drag rules.
pub(crate) struct PanelTabs {
pub responses: Vec<(usize, egui::Response)>,
pub picked: Option<usize>,
pub right: f32,
}
pub(crate) fn panel_tabs(
ui: &mut Ui,
id: &str,
area: Rect,
tabs: &[(&str, &str)],
selected: usize,
font: egui::FontId,
separators: bool,
) -> PanelTabs {
let t = Tokens::get(ui.ctx());
let labels: Vec<_> = tabs.iter().map(|&(_, label)| tl!(label)).collect();
let galleys: Vec<_> = labels
.iter()
.enumerate()
.map(|(i, label)| ui.painter().layout_no_wrap((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim }))
.collect();
let natural: Vec<_> = galleys.iter().map(|g| g.size().x + 24.0).collect();
let fit = craft_ui::tabs::fit(&natural, selected, area.width(), 40.0, 18.0);
let mut out = PanelTabs { responses: Vec::with_capacity(fit.shown.len()), picked: None, right: area.left() };
for (i, width) in fit.shown {
let Some((key, _)) = tabs.get(i) else { continue };
let Some(label) = labels.get(i) else { continue };
let Some(galley) = galleys.get(i) else { continue };
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(width, area.height()));
let shrunk = natural.get(i).is_some_and(|natural| *natural > width + 0.5);
let galley = if shrunk {
let mut job =
egui::text::LayoutJob::simple_singleline((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim });
job.wrap =
egui::text::TextWrapping { max_width: (width - 8.0).max(1.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
ui.painter().layout_job(job)
} else {
galley.clone()
};
let resp = craft_ui::tabs::Tab::new(ui.id().with((id, key)), label, i == selected)
.sense(Sense::click_and_drag())
.focus_stroke(Stroke::new(1.0, t.accent))
.show_at(ui, rect, |ui, _| {
if i == selected {
ui.painter().rect_filled(rect, 0.0, t.panel);
}
ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).galley(rect.center() - galley.size() / 2.0, galley, t.text);
if separators {
ui.painter().line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0, t.border));
}
});
out.responses.push((i, if shrunk { resp.on_hover_text(*label) } else { resp }));
out.right = rect.right();
}
if !fit.overflow.is_empty() && fit.overflow_width > 0.0 {
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(fit.overflow_width, area.height()));
let resp = ui.interact(rect, ui.id().with((id, "overflow")), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(rect, 0.0, t.hover);
}
if resp.has_focus() && rect.width() > 4.0 {
ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
icons::paint(ui, "chevrons-right", Rect::from_center_size(rect.center(), vec2(12.0, 12.0)).intersect(rect), t.text);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panels")));
let resp = resp.on_hover_text(tl!("Panels"));
egui::Popup::menu(&resp).show(|ui| {
widgets::menu_scroll(ui, |ui| {
for i in fit.overflow {
if let Some(label) = labels.get(i)
&& ui.button(*label).clicked()
{
out.picked = Some(i);
ui.close();
}
}
});
});
out.right = rect.right();
}
out
}
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
/// while the dock is collapsed. Dragging its title floats it.
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
@@ -337,6 +425,98 @@ mod tests {
use super::*;
use crate::toolbar::tests::{wheel, widget_rects};
#[test]
fn tab_headers_fit_narrow_widths_keep_the_selected_tab_and_clip_labels() {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, theme::Brightness::MediumDark);
let names = [("one", "Long panel name"), ("two", "Another long panel name"), ("three", "Layers")];
for width in [0.0, 1.0, 5.0, 18.0, 19.0, 80.0, 180.0, 300.0] {
for selected in 0..names.len() {
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(width, 32.0));
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let out = panel_tabs(ui, "fit-test", area, &names, selected, theme::semibold(12.5), true);
assert!(out.responses.iter().any(|(i, _)| *i == selected), "selected index {selected} stays on the strip");
assert!(out.right <= area.right() + 0.001, "{width}: strip ends at {}", out.right);
for (_, response) in &out.responses {
if response.rect.is_positive() {
assert!(area.contains_rect(response.rect), "{width}: {:?}", response.rect);
}
}
});
output.textures_delta.clear();
for shape in output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Text(_))) {
assert!(shape.clip_rect.right() <= area.right() + 0.001, "{width}: a label is clipped to its tab");
}
if width > 0.0 && width < 18.0 {
assert!(
output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(rect) if rect.brush.is_some())),
"the actual overflow SVG was painted"
);
}
}
}
}
#[test]
fn overflow_menu_can_activate_a_hidden_panel() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let ctx = egui::Context::default();
ctx.enable_accesskit();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, theme::Brightness::MediumDark);
let names = [("properties", "Properties"), ("layers", "Layers"), ("libraries", "Libraries")];
let mut selected = 0;
let mut time = 0.0;
let mut frame = |events| {
time += 0.1;
let input = egui::RawInput {
events,
time: Some(time),
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 300.0))),
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| {
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(90.0, 32.0));
let out = panel_tabs(ui, "overflow-test", area, &names, selected, theme::semibold(12.5), true);
if let Some(picked) = out.picked {
selected = picked;
}
});
output.textures_delta.clear();
output
};
let button = |output: &egui::FullOutput, label: &str| {
let bounds = output
.platform_output
.accesskit_update
.as_ref()
.unwrap()
.nodes
.iter()
.find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button)
.unwrap()
.1
.bounds()
.unwrap();
egui::pos2((bounds.x0 + bounds.x1) as f32 * 0.5, (bounds.y0 + bounds.y1) as f32 * 0.5)
};
let press =
|pos, pressed| egui::Event::PointerButton { pos, pressed, button: egui::PointerButton::Primary, modifiers: egui::Modifiers::NONE };
frame(vec![]);
let output = frame(vec![]);
let overflow = button(&output, "Panels");
frame(vec![egui::Event::PointerMoved(overflow), press(overflow, true)]);
frame(vec![press(overflow, false)]);
let output = frame(vec![]);
let libraries = button(&output, "Libraries");
frame(vec![egui::Event::PointerMoved(libraries), press(libraries, true)]);
frame(vec![press(libraries, false)]);
assert_eq!(selected, 2);
}
#[test]
fn every_flyout_stays_left_of_the_icon_column() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+283 -71
View File
@@ -50,7 +50,7 @@ const TRAVEL: f32 = 4.0;
/// What a move carries.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Moving {
pub(crate) enum Moving {
/// The floating group holding this panel.
Panel(&'static str),
/// The floating Tools panel.
@@ -59,16 +59,17 @@ enum Moving {
/// A move in progress: what moves, where the pointer holds it from its top-left corner, and where
/// the press began.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
struct Move {
what: Moving,
grab: Vec2,
from: Pos2,
original: UiState,
}
/// Where a moved group or Tools panel lands.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Drop {
pub(crate) enum Drop {
/// Back in the dock (the Tools panel: at the window's left edge).
Dock,
/// Stacked with the floating group holding this panel.
@@ -85,6 +86,15 @@ fn move_id() -> Id {
Id::new("floating-move")
}
fn suppressed_id() -> Id {
move_id().with("suppressed-until-primary-press")
}
fn gesture_allowed(ctx: &egui::Context) -> bool {
ctx.input(|input| input.focused && !input.key_pressed(egui::Key::Escape))
&& !ctx.data(|data| data.get_temp::<bool>(suppressed_id()).unwrap_or(false))
}
/// The dock (tabbed group and icon column) as last drawn: a group dropped there docks.
pub(crate) fn dock_rect_id() -> Id {
Id::new("floating-dock-rect")
@@ -266,9 +276,12 @@ pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 {
}
/// Start moving `what` with the pointer, held `grab` from its top-left corner.
fn start(ctx: &egui::Context, what: Moving, grab: Vec2) {
fn start(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, grab: Vec2) {
if !gesture_allowed(ctx) {
return;
}
let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return };
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from }));
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from, original: app.ui.clone() }));
}
/// Where the pointer holds a group torn off by a press `left` points right of where the group's
@@ -281,30 +294,49 @@ pub(crate) fn strip_grab(ctx: &egui::Context, left: f32) -> Vec2 {
/// Float `ids` under the pointer, held `grab` from the new group's top-left corner, and carry on
/// moving them with the pointer (a tab or group dragged out of the dock or out of its group).
pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'static str], active: &'static str, grab: Vec2) {
// A restored popout may retain its drag response in the cancellation frame. Do not
// immediately tear it out again after track restored the gesture's original layout.
if !gesture_allowed(ctx) {
return;
}
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
float(&mut app.ui, ids, active, at - grab);
start(ctx, Moving::Panel(active), grab);
let original = app.ui.clone();
let pos = at - grab;
if let Err(error) = app.run("window.panel.float", json!({"panel":active,"group":ids.len() > 1,"x":pos.x,"y":pos.y})) {
app.ui.status = error;
return;
}
start(app, ctx, Moving::Panel(active), grab);
ctx.data_mut(|data| {
if let Some(mut drag) = data.get_temp::<Move>(move_id()) {
drag.original = original;
data.insert_temp(move_id(), drag);
}
});
}
/// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging
/// it out of the panel floats the panel under the pointer; floating at `floating`, a drag moves it.
pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: &egui::Response, bounds: Rect, floating: Option<Pos2>) {
if !gesture_allowed(ctx) {
return;
}
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
match floating {
Some(pos) if title.drag_started() => start(ctx, Moving::Tools, at - pos),
Some(pos) if title.drag_started() => start(app, ctx, Moving::Tools, at - pos),
None if title.dragged() && !bounds.contains(at) => {
// Held where the press grabbed the title bar.
let grab = ctx.input(|i| i.pointer.press_origin()).map_or(vec2(10.0, 7.0), |p| p - bounds.min);
let p = at - grab;
start(app, ctx, Moving::Tools, grab);
app.ui.toolbar_pos = Some([p.x, p.y]);
start(ctx, Moving::Tools, grab);
}
_ => {}
}
}
/// Where the pointer at `at` would drop `what`, and the zone that lights up for it.
fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
let zone = |id: Id| ctx.data(|d| d.get_temp::<Rect>(id)).filter(|r| r.contains(at));
let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) };
let own = group_of(&app.ui, id);
@@ -327,7 +359,39 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
let columns = ctx.data(|d| d.get_temp::<Vec<Rect>>(columns_id())).unwrap_or_default();
for (i, g) in app.ui.floating_panels.iter().enumerate() {
let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue };
let Some(mut r) = ctx.data(|d| d.get_temp::<Rect>(group_rect_id(first))) else { continue };
let Some(mut r) = ctx
.data(|d| d.get_temp::<Rect>(group_rect_id(first)))
.or_else(|| ctx.memory(|m| m.area_rect(Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())))))
else {
continue;
};
// A visible window owns hits inside its body. Old rectangles and lower windows
// must not capture a drop through another panel or popup; edge insertion remains
// available just outside the window. Dock columns reserve their own panel space.
if g.docked.is_none() && r.contains(at) {
let area = if app.ui.docking.is_some() && !crate::panel_docking::decorated(g) {
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
} else {
let lead = set_of(&app.ui, i).first().and_then(|&index| app.ui.floating_panels.get(index)).and_then(|group| group.panels.first());
area_id(lead.map_or(first, String::as_str))
};
let moving_areas: Vec<Id> = moving
.iter()
.filter_map(|&index| app.ui.floating_panels.get(index).and_then(|group| group.panels.first()))
.flat_map(|panel| [area_id(panel), Id::new("vectorcraft-panel-docking").with(("floating", panel))])
.collect();
let owner = ctx.memory(|memory| {
let layers: Vec<_> = memory.layer_ids().collect();
layers.into_iter().rev().find(|layer| {
!moving_areas.contains(&layer.id)
&& memory.areas().is_visible(layer)
&& memory.area_rect(layer.id).is_some_and(|rect| rect.contains(at))
})
});
if owner.is_some_and(|layer| layer.id != area) {
continue;
}
}
// In a dock column that scrolls, only the part in view: a group scrolled away takes nothing.
if let Some(col) = g.docked.and_then(|d| columns.get(usize::try_from(d).ok()?.checked_sub(1)?)) {
r = r.intersect(*col);
@@ -368,7 +432,7 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
}
/// Light up a drop zone: over the docked panels, under the floating ones (the moved one too).
fn highlight(ctx: &egui::Context, zone: Rect) {
pub(crate) fn highlight(ctx: &egui::Context, zone: Rect) {
let t = Tokens::get(ctx);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::new("floating-drop-zone")));
painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
@@ -388,10 +452,43 @@ fn insertion_line(ctx: &egui::Context, line: Rect) {
painter.rect_filled(line, CornerRadius::same(2), t.accent);
}
pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) {
match target {
Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect),
Drop::Stack(_) => tab_zone(ctx, rect),
Drop::Dock => highlight(ctx, rect),
}
}
/// Carry a move on (at the start of a frame, before the panels are drawn): the group or Tools panel
/// follows the pointer and the zone it would drop on lights up; the release drops it there.
pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
if ctx.input(|input| input.pointer.primary_pressed()) {
ctx.data_mut(|data| data.remove::<bool>(suppressed_id()));
}
let before = app.ui.clone();
let Some(m) = ctx.data(|d| d.get_temp::<Move>(move_id())) else { return };
if m.original.docking_generation != app.ui.docking_generation {
ctx.data_mut(|data| {
data.remove::<Move>(move_id());
data.insert_temp(suppressed_id(), true);
});
return;
}
if ctx.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
app.ui.docking = m.original.docking;
app.ui.docking_hidden = m.original.docking_hidden;
app.ui.docking_icons = m.original.docking_icons;
app.ui.floating_panels = m.original.floating_panels;
app.ui.open_panel = m.original.open_panel;
app.ui.dock_tab = m.original.dock_tab;
app.ui.toolbar_pos = m.original.toolbar_pos;
ctx.data_mut(|data| {
data.remove::<Move>(move_id());
data.insert_temp(suppressed_id(), true);
});
return;
}
let at = ctx.input(|i| i.pointer.interact_pos());
let target = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_target(app, ctx, m.what, at));
if ctx.input(|i| i.pointer.primary_down()) {
@@ -409,6 +506,9 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
Moving::Tools => {}
}
}
if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
app.ui.status = error;
}
match target {
Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line),
Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs),
@@ -441,6 +541,9 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
Drop::Above(onto) => attach(&mut app.ui, &set, onto, false),
Drop::Column(slot) => dock_column(&mut app.ui, &set, slot),
}
if let Err(error) = crate::panel_docking::reconcile_native(app, before) {
app.ui.status = error;
}
}
/// What a press on a floating or docked group did.
@@ -490,11 +593,12 @@ fn docked(ui: &UiState, set: &[usize]) -> bool {
/// the dock (Tab, Presentation Mode). Sets docked as dock columns are drawn by [`docked_columns`].
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 {
crate::panel_docking::finish_native_drop(app, ctx);
return;
}
let mut action = None;
for gi in 0..app.ui.floating_panels.len() {
if leads(&app.ui, gi) {
if leads(&app.ui, gi) && (app.ui.docking.is_none() || app.ui.floating_panels.get(gi).is_some_and(crate::panel_docking::decorated)) {
let set = set_of(&app.ui, gi);
if !docked(&app.ui, &set) {
show_set(app, ctx, &set, &mut action);
@@ -502,10 +606,18 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
apply(app, ctx, action);
crate::panel_docking::finish_native_drop(app, ctx);
}
/// Carry out what a press on a floating or docked group did.
fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>) {
if action.is_none() {
return;
}
if matches!(action, Some(Action::Move(..) | Action::Detach(..) | Action::Tear(..) | Action::Undock(..))) && !gesture_allowed(ctx) {
return;
}
let before = app.ui.clone();
match action {
Some(Action::Activate(gi, k)) => {
if let Some(g) = app.ui.floating_panels.get_mut(gi)
@@ -531,7 +643,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>)
set_pos(&mut app.ui, gi, [pos.x, pos.y]);
}
if let Some(at) = ctx.input(|i| i.pointer.interact_pos()) {
start(ctx, Moving::Panel(id), at - pos);
start(app, ctx, Moving::Panel(id), at - pos);
}
}
Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left),
@@ -547,10 +659,19 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>)
}
}
FloatingPanels::tidy(&mut app.ui.floating_panels);
start(ctx, Moving::Panel(id), grab);
start(app, ctx, Moving::Panel(id), grab);
}
None => {}
}
if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
app.ui.status = error;
}
ctx.data_mut(|data| {
if let Some(mut drag) = data.get_temp::<Move>(move_id()) {
drag.original = before;
data.insert_temp(move_id(), drag);
}
});
}
/// Take group `gi` out of its set (or its dock column) under the pointer, its tab strip having
@@ -566,7 +687,7 @@ fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) {
g.pos = [p.x, p.y];
}
FloatingPanels::tidy(&mut app.ui.floating_panels);
start(ctx, Moving::Panel(id), grab);
start(app, ctx, Moving::Panel(id), grab);
}
/// Put the groups `set` in the dock as a new column at `slot` (1: next to the icon column, counting
@@ -655,7 +776,13 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec
});
rects.push(panel.response.rect);
}
ui.ctx().data_mut(|d| d.insert_temp(columns_id(), rects.clone()));
ui.ctx().data_mut(|d| {
let icons = d.get_temp::<Rect>(icons_rect_id());
if let Some(left) = icons.map(|r| rects.iter().map(|r| r.left()).fold(r.left(), f32::min)) {
d.insert_temp(dock::dock_left_id(), left);
}
d.insert_temp(columns_id(), rects.clone());
});
let ctx = ui.ctx().clone();
apply(app, &ctx, action);
rects
@@ -753,18 +880,13 @@ fn draw_groups(
let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
let lone = g.ids.len() == 1;
let mut left = strip.left();
for (j, &id) in g.ids.iter().enumerate() {
let label = panel(id).map_or(id, |(_, label)| label);
let color = if id == g.active { t.text_strong } else { t.text_dim };
let galley = ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color);
let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP));
left = r.right();
let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag());
if id == g.active && !g.collapsed {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom()));
let names: Vec<_> = g.ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect();
let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0);
let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false);
for (j, resp) in out.responses {
let Some(&id) = g.ids.get(j) else { continue };
if resp.double_clicked() {
*action = Some(Action::Collapse(g.gi));
} else if resp.clicked() {
@@ -775,11 +897,13 @@ fn draw_groups(
None => Action::Detach(g.gi, strip.left()),
});
} else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) {
*action = Some(Action::Tear(id, r.left()));
*action = Some(Action::Tear(id, resp.rect.left()));
}
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
if let Some(j) = out.picked {
*action = Some(Action::Activate(g.gi, j));
}
let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag());
if resp.drag_started() {
@@ -1003,7 +1127,9 @@ mod tests {
fn frame(&mut self, events: Vec<Event>) {
self.time += 0.05;
let screen = Rect::from_min_size(Pos2::ZERO, self.screen);
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() };
let focused =
events.iter().rev().find_map(|event| if let Event::WindowFocused(focused) = event { Some(*focused) } else { None }).unwrap_or(true);
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), focused, events, ..Default::default() };
let app = &mut self.app;
self.ctx
.run_ui(raw, |ui| {
@@ -1050,11 +1176,22 @@ mod tests {
/// The floating group whose first panel is `first`, as drawn.
fn group(&self, first: &str) -> Rect {
self.ctx.memory(|m| m.area_rect(area_id(first))).unwrap()
let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated);
let id = if self.app.ui.docking.is_some() && !native {
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
} else {
area_id(first)
};
self.ctx.memory(|m| m.area_rect(id)).unwrap()
}
/// A point on a floating group's title bar (left of its ×).
fn title(&self, first: &str) -> Pos2 {
let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated);
if self.app.ui.docking.is_some() && !native {
let rect = self.group(first);
return rect.left_top() + vec2(6.0, 16.0);
}
let r = self.group(first);
pos2(r.left() + 30.0, r.top() + 1.0 + TITLE / 2.0)
}
@@ -1068,15 +1205,29 @@ mod tests {
r
}
fn shared_tab(&self, panel: &str) -> Rect {
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect
}
/// The dock's tabs, left to right.
fn dock_tabs(&self) -> Vec<Rect> {
if let Some(layout) = &self.app.ui.docking {
let root = craft_ui::docking::Layout { root: layout.root.clone(), floating: Vec::new() };
return root
.panels()
.into_iter()
.filter_map(|panel| {
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect)
})
.collect();
}
let dock = self.temp_rect(dock_rect_id());
self.widgets(|r| r.height() == 32.0 && dock.contains_rect(r))
}
/// The icon column's panel icons, top to bottom.
fn icons(&self) -> Vec<Rect> {
let dock = self.temp_rect(dock_rect_id());
let dock = self.temp_rect(if self.app.ui.docking.is_some() { icons_rect_id() } else { dock_rect_id() });
self.widgets(|r| r.size() == vec2(30.0, 30.0) && dock.contains_rect(r))
}
@@ -1089,30 +1240,19 @@ mod tests {
fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() {
let mut h = Harness::new();
let tabs = h.dock_tabs();
assert_eq!(tabs.len(), 3, "Properties, Layers, Libraries: {tabs:?}");
assert_eq!(tabs.len(), 3);
let to = pos2(600.0, 400.0);
h.drag(tabs[1].center(), to);
assert_eq!(h.groups(), [["layers"]], "Layers floats");
assert!(h.group("layers").contains(to), "under the pointer: {:?}", h.group("layers"));
assert_eq!(h.dock_tabs().len(), 2, "its tab left the dock");
assert_eq!(h.groups(), [["layers"]]);
assert!(h.group("layers").contains(to));
assert_eq!(h.dock_tabs().len(), 2);
assert_eq!(h.app.ui.dock_tab, DockTab::Properties);
// A click on its title bar moves nothing, even over the dock.
let dock = h.temp_rect(dock_rect_id());
h.app.run("window.panel.float", json!({"panel": "layers", "x": dock.left() + 10.0, "y": 300})).unwrap();
h.settle();
h.click(h.title("layers"));
assert_eq!(h.groups(), [["layers"]], "a click doesn't dock it");
// Dragged by its title bar onto the dock, the dock lights up, and the release docks it.
h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 300})).unwrap();
h.settle();
let over = dock.center();
h.hold(h.title("layers"), over);
assert!(h.group("layers").contains(over), "the group follows the pointer");
let target = drop_target(&h.app, &h.ctx, Moving::Panel("layers"), over);
assert_eq!(target, Some((Drop::Dock, dock)), "the dock is the drop zone, lit whole: its tab comes back");
h.release(over);
assert!(h.groups().is_empty(), "docked: {:?}", h.groups());
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers), "back in the dock, shown");
assert_eq!(h.groups(), [["layers"]], "a click only activates the floating tab");
let over = h.shared_tab("properties").center();
h.drag(h.title("layers"), over);
assert!(h.groups().is_empty());
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers));
}
#[test]
@@ -1127,7 +1267,9 @@ mod tests {
assert!(column.width() < 40.0, "only the icon column is left: {column:?}");
// Its × puts them back.
let g = h.group("properties");
h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0));
let close =
if h.app.ui.docking.is_some() { pos2(g.right() - 36.0, g.top() + 16.0) } else { pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0) };
h.click(close);
assert!(h.groups().is_empty());
assert_eq!(h.dock_tabs().len(), 3);
}
@@ -1143,32 +1285,40 @@ mod tests {
assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::<usize>(), "every group's icons are drawn");
h.drag(icons[2].center(), pos2(500.0, 250.0));
assert_eq!(h.groups(), [["swatches"]]);
assert_eq!(h.icons().len(), count - 1, "its icon left the column");
h.drag(h.icons()[0].center(), pos2(500.0, 650.0));
assert_eq!(h.icons().len(), count - 1);
let separate = pos2(900.0, 650.0);
assert!(!h.group("swatches").contains(separate));
h.drag(h.icons()[0].center(), separate);
assert_eq!(h.groups(), [["swatches"], ["color"]]);
// Color's title bar dropped on the Swatches group's tabs stacks them.
let onto = h.group("swatches").left_top() + vec2(150.0, 1.0 + TITLE + STRIP / 2.0);
h.hold(h.title("color"), onto);
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), onto).map(|t| t.0), Some(Drop::Stack("swatches")));
h.release(onto);
let onto = h.shared_tab("swatches").right_center() - vec2(3.0, 0.0);
h.drag(h.title("color"), onto);
assert_eq!(h.groups(), [["swatches", "color"]]);
assert_eq!(h.app.ui.floating_panels[0].active, 1, "showing the panel dropped on it");
// A click shows a tab; a tab dragged out of the strip floats on its own again.
let g = h.group("swatches");
let tabs = h.widgets(|r| r.height() == STRIP && g.contains_rect(r));
assert_eq!(tabs.len(), 3, "two tabs and the strip right of them: {tabs:?}");
h.click(tabs[0].center());
assert_eq!(h.app.ui.floating_panels[0].active, 1);
h.click(h.shared_tab("swatches").center());
assert_eq!(h.app.ui.floating_panels[0].active, 0);
let to = pos2(900.0, 500.0);
h.drag(tabs[1].center(), to);
h.drag(h.shared_tab("color").center(), to);
assert_eq!(h.groups(), [["swatches"], ["color"]]);
assert!(h.group("color").contains(to));
// Window › Color shows it where it floats; Dock Panel puts it back.
assert_eq!(h.app.run("window.panel", json!({"panel": "Color"})).unwrap(), json!({"floating": ["color"]}));
assert_eq!(crate::menus::checked(&h.app, "window.panel", &json!({"panel": "color"})), Some(true));
h.app.run("window.panel.dock", json!({"panel": "color"})).unwrap();
h.settle();
assert!(h.groups().iter().all(|group| !group.contains(&"color")));
assert_eq!(h.icons().len(), count - 1, "Color is back in the column");
assert_eq!(h.app.ui.open_panel.as_deref(), Some("color"));
}
#[test]
fn an_icon_panel_can_be_dropped_into_another_docked_tab_group() {
let mut h = Harness::new();
h.drag(h.icons()[0].center(), pos2(500.0, 400.0));
assert_eq!(h.groups(), [["color"]]);
h.drag(h.shared_tab("color").center(), h.shared_tab("layers").center());
assert!(h.groups().is_empty());
assert!(h.app.ui.docking.as_ref().unwrap().contains(&"color".into()));
assert!(h.app.ui.open_panel.is_none(), "the actual shared group owns Color");
assert_eq!(h.dock_tabs().len(), 4);
}
#[test]
@@ -1204,6 +1354,68 @@ mod tests {
assert!(h.groups().is_empty());
}
#[test]
fn popped_out_title_drag_cancels_on_escape_focus_loss_and_workspace_change() {
for reason in ["escape", "focus", "workspace"] {
let mut h = Harness::new();
h.app.run("window.panel", json!({"panel":"swatches"})).unwrap();
h.settle();
let original = h.app.ui.clone();
let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap();
let to = pos2(500.0, 400.0);
h.hold(flyout.left_top() + vec2(20.0, 13.0), to);
assert!(h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"swatches".into())));
match reason {
"escape" => h.frame(vec![Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Default::default(),
}]),
"focus" => h.frame(vec![Event::WindowFocused(false)]),
_ => {
crate::workspaces::apply(&mut h.app.ui, &crate::workspaces::Workspace::default());
h.frame(vec![Event::PointerMoved(pos2(650.0, 450.0))]);
}
}
assert!(h.ctx.data(|d| d.get_temp::<Move>(move_id())).is_none(), "{reason}: stale gesture removed");
assert!(h.app.ui.docking.is_none(), "{reason}: original/selected workspace retained");
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: no stale floating group");
if reason != "workspace" {
assert_eq!(h.app.ui.open_panel, original.open_panel);
}
if reason == "focus" {
h.frame(vec![Event::WindowFocused(true)]);
}
for step in 1..=3 {
h.frame(vec![Event::PointerMoved(to + vec2(15.0 * step as f32, 8.0))]);
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: canceled drag stays canceled on held frame {step}");
}
h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: true, modifiers: Default::default() }]);
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: secondary press does not restart primary gesture");
h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: false, modifiers: Default::default() }]);
h.release(to);
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: release does not restart canceled gesture");
}
}
#[test]
fn docked_tools_drag_cancellation_restores_the_docked_origin() {
let mut h = Harness::new();
let zone = h.temp_rect(tools_zone_id());
let title = zone.left_top() + vec2(30.0, 7.0);
let to = pos2(400.0, 300.0);
h.hold(title, to);
assert!(h.app.ui.toolbar_pos.is_some());
h.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
assert_eq!(h.app.ui.toolbar_pos, None, "cancel restores docked Tools origin");
h.frame(vec![Event::PointerMoved(to + vec2(30.0, 20.0))]);
assert_eq!(h.app.ui.toolbar_pos, None);
h.release(to);
assert_eq!(h.app.ui.toolbar_pos, None);
}
#[test]
fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() {
let mut h = Harness::new();
+10
View File
@@ -656,6 +656,8 @@
Appearance of Black Vzhled černé
Devices Zařízení
A file dialog is open: choose there first Je otevřené dialogové okno souboru: nejprve vyberte v něm
The document was closed while its file dialog was open Dokument byl zavřen, zatímco bylo otevřené jeho dialogové okno souboru
The dialog was closed before its file dialog answered Dialogové okno se zavřelo dřív, než odpovědělo jeho dialogové okno souboru
Shaper Shaper
Expand Shaper Group Rozšířit skupinu Shaper
Release Shaper Group Uvolnit skupinu Shaper
@@ -695,6 +697,14 @@
Ocean Ripple… Vlnky na hladině…
Rename Data Set Přejmenovat sadu dat
Rename Variable Přejmenovat proměnnou
# Shared panel docking
Activate Panel Aktivovat panel
Change Panel Layout Změnit rozložení panelů
Close Panel Zavřít panel
Move Panel Přesunout panel
Group with Seskupit s
Resize panels Změnit velikost panelů
Resize panel window Změnit velikost okna panelu
# Interactive Revolve controls.
Lighting Osvětlení
Can't render this file because it contains an unexpected character in line 2 and column 73.
+32
View File
@@ -3384,6 +3384,7 @@ theme Appearance Erscheinungsbild
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art oder type
@msg artboard {0} does not exist Zeichenfläche {0} ist nicht vorhanden
@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 in diesem Maßstab misst die Zeichnung {w} × {h} pt, mehr als die Arbeitsfläche fasst: Wählen Sie einen kleineren Maßstab oder passen Sie sie an die Zeichenfläche an
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis muss "vertical", "horizontal" oder ein Winkel in Grad sein, nicht `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black oder eine Farbe wie "#ff8800"
@msg bad color {v} ungültige Farbe {v}
@msg bad colour `{hex}` (#rrggbb) ungültige Farbe `{hex}` (#rrggbb)
@@ -3426,6 +3427,7 @@ theme Appearance Erscheinungsbild
@msg freeform line {i} must be an array of point indices Freiformlinie {i} muss ein Array von Punktindizes sein
@msg give both `start` and `end` [x, y] (or neither) geben Sie sowohl `start` als auch `end` [x, y] an (oder keines von beiden)
@msg give corners of the path: anchors between two straight sides geben Sie Ecken des Pfads an: Ankerpunkte zwischen zwei geraden Seiten
@msg give horizontal or vertical geben Sie horizontal oder vertical an
@msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an
@msg guides are locked die Hilfslinien sind gesperrt
@msg image '{key}' has no pixels and was left out das Bild '{key}' hat keine Pixel und wurde ausgelassen
@@ -3831,6 +3833,8 @@ theme Appearance Erscheinungsbild
Images: link to the file instead of only embedding it. Bilder: mit der Datei verknüpfen, statt sie nur einzubetten.
VectorCraft documents: one locked object, updated when the file changes. VectorCraft-Dokumente: ein gesperrtes Objekt, das aktualisiert wird, wenn sich die Datei ändert.
A file dialog is open: choose there first Ein Dateidialog ist geöffnet: Treffen Sie zuerst dort Ihre Auswahl
The document was closed while its file dialog was open Das Dokument wurde geschlossen, während sein Dateidialog geöffnet war
The dialog was closed before its file dialog answered Das Dialogfeld wurde geschlossen, bevor sein Dateidialog geantwortet hat
Shaper Shaper
Expand Shaper Group Shaper-Gruppe umwandeln
Release Shaper Group Shaper-Gruppe zurückwandeln
@@ -4007,6 +4011,31 @@ theme Appearance Erscheinungsbild
Define a variable first Definieren Sie zuerst eine Variable
Text Text
Visibility Sichtbarkeit
Activate Panel Bedienfeld aktivieren
Change Panel Layout Bedienfeldanordnung ändern
Close Panel Bedienfeld schließen
Move Panel Bedienfeld verschieben
Group with Gruppieren mit
Resize panel window Bedienfeldfenster skalieren
Resize panels Bedienfelder skalieren
@msg Invalid panel layout; restored the default dock Ungültige Bedienfeldanordnung; Standarddock wiederhergestellt
@msg action is required Eine Aktion ist erforderlich
@msg anchor must name a registered panel Der Anker muss ein registriertes Bedienfeld benennen
@msg before must name a panel before muss ein Bedienfeld benennen
@msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad
@msg group must be true or false group muss true oder false sein
@msg move requires a destination panel Das Verschieben erfordert ein Zielbedienfeld
@msg move requires an existing destination panel Das Verschieben erfordert ein vorhandenes Zielbedienfeld
@msg onto must name a floating panel onto muss ein schwebendes Bedienfeld benennen
@msg panel group is empty Die Bedienfeldgruppe ist leer
@msg panel must name a registered panel panel muss ein registriertes Bedienfeld benennen
@msg saved panel layout is invalid; reset the workspace first Die gespeicherte Bedienfeldanordnung ist ungültig; zuerst den Arbeitsbereich zurücksetzen
@msg x and y must be provided together x und y müssen zusammen angegeben werden
@msg zone must be a string zone muss eine Zeichenfolge sein
@msg zone must be tab, center, left, right, top or bottom zone muss tab, center, left, right, top oder bottom sein
@msg {key} must be a finite coordinate {key} muss eine endliche Koordinate sein
@msg invalid restored panel group Ungültige wiederhergestellte Bedienfeldgruppe
# 3D and Materials › Revolve: its options dialog and gizmos.
Ambient Umgebungslicht
Axis side Achsenseite
@@ -4060,3 +4089,6 @@ theme Appearance Erscheinungsbild
Stroke Pressure Strichdruck
Right Diagonal Diagonal nach rechts
Left Diagonal Diagonal nach links
@msg invalid native panel set Ungültiger nativer Bedienfeldsatz
@msg missing panel layout Bedienfeldanordnung fehlt
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
Can't render this file because it contains an unexpected character in line 2 and column 73.
+32
View File
@@ -3485,6 +3485,7 @@ axis Both Ambos
@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 axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis debe ser "vertical", "horizontal" o un ángulo en grados, no `{v}`
@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)
@@ -3527,6 +3528,7 @@ axis Both Ambos
@msg freeform line {i} must be an array of point indices la línea de forma libre {i} debe ser una lista de índices de puntos
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
@msg give corners of the path: anchors between two straight sides indica esquinas del trazado: puntos de ancla entre dos lados rectos
@msg give horizontal or vertical indica horizontal o vertical
@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ó
@@ -3932,6 +3934,8 @@ axis Both Ambos
Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo.
VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo.
A file dialog is open: choose there first Hay un cuadro de diálogo de archivos abierto: elija primero en él
The document was closed while its file dialog was open Se cerró el documento mientras su cuadro de diálogo de archivos estaba abierto
The dialog was closed before its file dialog answered El cuadro de diálogo se cerró antes de que respondiera su cuadro de diálogo de archivos
Shaper Shaper
Expand Shaper Group Expandir grupo Shaper
Release Shaper Group Soltar grupo Shaper
@@ -4047,6 +4051,31 @@ axis Both Ambos
Define a variable first Defina primero una variable
Text Texto
Visibility Visibilidad
# Shared panel docking
Activate Panel Activar panel
Change Panel Layout Cambiar distribución de paneles
Close Panel Cerrar panel
Move Panel Mover panel
Group with Agrupar con
Resize panels Cambiar tamaño de los paneles
Resize panel window Cambiar tamaño de la ventana del panel
@msg Invalid panel layout; restored the default dock La distribución de paneles no es válida; se restauró el anclaje predeterminado
@msg action is required Se requiere action
@msg anchor must name a registered panel anchor debe indicar un panel registrado
@msg before must name a panel before debe indicar un panel
@msg docking action contains an unknown panel or an excessive split path La acción de anclaje contiene un panel desconocido o una ruta de divisiones demasiado larga
@msg group must be true or false group debe ser true o false
@msg move requires a destination panel Para mover se requiere un panel de destino
@msg move requires an existing destination panel Para mover se requiere un panel de destino existente
@msg onto must name a floating panel onto debe indicar un panel flotante
@msg panel group is empty El grupo de paneles está vacío
@msg panel must name a registered panel panel debe indicar un panel registrado
@msg saved panel layout is invalid; reset the workspace first La distribución guardada de paneles no es válida; primero restablece el espacio de trabajo
@msg zone must be a string zone debe ser una cadena de texto
@msg zone must be tab, center, left, right, top or bottom zone debe ser tab, center, left, right, top o bottom
@msg {key} must be a finite coordinate {key} debe ser una coordenada finita
@msg x and y must be provided together x e y deben proporcionarse juntos
@msg invalid restored panel group El grupo de paneles restaurado no es válido
# Revolve profile guidance.
@@ -4105,3 +4134,6 @@ lighting Light Luz
Vertical Guide Guía vertical
Horizontal Guide Guía horizontal
Move Guide to Layer Mover guía a capa
@msg invalid native panel set Conjunto nativo de paneles no válido
@msg missing panel layout Falta la distribución de paneles
@msg unsupported native docking operation Operación nativa de anclaje no admitida
Can't render this file because it contains an unexpected character in line 2 and column 73.
+32
View File
@@ -3482,6 +3482,7 @@ axis Both Les deux
@msg antiAlias `{id}`: none, art or type antiAlias `{id}` : none, art ou type
@msg artboard {0} does not exist le plan de travail {0} n’existe pas
@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 à cette échelle, le dessin mesure {w} × {h} pt, plus que ne peut contenir la zone de travail : choisissez une échelle plus petite ou ajustez-le au plan de travail
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis doit être "vertical", "horizontal" ou un angle en degrés, pas `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" arrière-plan {other} : transparent, white, black ou une couleur comme "#ff8800"
@msg bad color {v} couleur non valide {v}
@msg bad colour `{hex}` (#rrggbb) couleur non valide `{hex}` (#rrggbb)
@@ -3524,6 +3525,7 @@ axis Both Les deux
@msg freeform line {i} must be an array of point indices la ligne de forme libre {i} doit être un tableau d’indices de points
@msg give both `start` and `end` [x, y] (or neither) indiquez à la fois `start` et `end` [x, y] (ou aucun des deux)
@msg give corners of the path: anchors between two straight sides indiquez des angles du tracé : des points d’ancrage entre deux côtés droits
@msg give horizontal or vertical indiquez horizontal ou vertical
@msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur
@msg guides are locked les repères sont verrouillés
@msg image '{key}' has no pixels and was left out l’image « {key} » n’a aucun pixel et a été ignorée
@@ -3929,6 +3931,8 @@ axis Both Les deux
Images: link to the file instead of only embedding it. Images : lier le fichier au lieu de seulement l’incorporer.
VectorCraft documents: one locked object, updated when the file changes. Documents VectorCraft : un objet verrouillé, mis à jour quand le fichier change.
A file dialog is open: choose there first Une boîte de dialogue de fichiers est ouverte : faites d’abord votre choix dans celle-ci
The document was closed while its file dialog was open Le document a été fermé pendant que sa boîte de dialogue de fichiers était ouverte
The dialog was closed before its file dialog answered La boîte de dialogue a été fermée avant la réponse de sa boîte de dialogue de fichiers
Shaper Shaper
Expand Shaper Group Décomposer le groupe Shaper
Release Shaper Group Libérer le groupe Shaper
@@ -4059,6 +4063,31 @@ effect Soft Doux
Define a variable first Définissez d’abord une variable
Text Texte
Visibility Visibilité
# Shared panel docking
Activate Panel Activer le panneau
Change Panel Layout Modifier la disposition des panneaux
Close Panel Fermer le panneau
Move Panel Déplacer le panneau
Group with Regrouper avec
Resize panels Redimensionner les panneaux
Resize panel window Redimensionner la fenêtre du panneau
@msg Invalid panel layout; restored the default dock La disposition des panneaux est invalide ; la disposition ancrée par défaut a été restaurée
@msg action is required action est requis
@msg anchor must name a registered panel anchor doit désigner un panneau enregistré
@msg before must name a panel before doit désigner un panneau
@msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long
@msg group must be true or false group doit être true ou false
@msg move requires a destination panel Le déplacement nécessite un panneau de destination
@msg move requires an existing destination panel Le déplacement nécessite un panneau de destination existant
@msg onto must name a floating panel onto doit désigner un panneau flottant
@msg panel group is empty Le groupe de panneaux est vide
@msg panel must name a registered panel panel doit désigner un panneau enregistré
@msg saved panel layout is invalid; reset the workspace first La disposition enregistrée des panneaux est invalide ; réinitialisez d’abord l’espace de travail
@msg zone must be a string zone doit être une chaîne de caractères
@msg zone must be tab, center, left, right, top or bottom zone doit être tab, center, left, right, top ou bottom
@msg {key} must be a finite coordinate {key} doit être une coordonnée finie
@msg x and y must be provided together x et y doivent être fournis ensemble
@msg invalid restored panel group Le groupe de panneaux restauré est invalide
# Interactive Revolve controls.
Lighting Éclairage
@@ -4115,3 +4144,6 @@ lighting Light Lumière
Vertical Guide Repère vertical
Horizontal Guide Repère horizontal
Move Guide to Layer Déplacer le repère vers le calque
@msg invalid native panel set Ensemble natif de panneaux invalide
@msg missing panel layout Disposition des panneaux manquante
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
Can't render this file because it contains an unexpected character in line 2 and column 73.
+32
View File
@@ -3485,6 +3485,7 @@ axis Both Entrambi
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type
@msg artboard {0} does not exist la tavola da disegno {0} non esiste
@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 questa scala il disegno misura {w} × {h} pt, più di quanto possa contenere l'area di disegno: scegli una scala più piccola o adattalo alla tavola da disegno
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis deve essere "vertical", "horizontal" o un angolo in gradi, non `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" sfondo {other}: transparent, white, black o un colore come "#ff8800"
@msg bad color {v} colore non valido {v}
@msg bad colour `{hex}` (#rrggbb) colore non valido `{hex}` (#rrggbb)
@@ -3527,6 +3528,7 @@ axis Both Entrambi
@msg freeform line {i} must be an array of point indices la linea a forma libera {i} deve essere un array di indici di punti
@msg give both `start` and `end` [x, y] (or neither) specifica sia `start` sia `end` [x, y] (o nessuno dei due)
@msg give corners of the path: anchors between two straight sides specifica angoli del tracciato: punti di ancoraggio tra due lati diritti
@msg give horizontal or vertical specifica horizontal o vertical
@msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza
@msg guides are locked le guide sono bloccate
@msg image '{key}' has no pixels and was left out l'immagine '{key}' non ha pixel ed è stata omessa
@@ -3932,6 +3934,8 @@ axis Both Entrambi
Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto.
VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia.
A file dialog is open: choose there first È aperta una finestra di dialogo dei file: scegli prima lì
The document was closed while its file dialog was open Il documento è stato chiuso mentre la sua finestra di dialogo dei file era aperta
The dialog was closed before its file dialog answered La finestra di dialogo è stata chiusa prima che rispondesse la sua finestra di dialogo dei file
Shaper Shaper
Expand Shaper Group Espandi gruppo Shaper
Release Shaper Group Rilascia gruppo Shaper
@@ -4049,6 +4053,31 @@ effect Regular Normale
Define a variable first Definisci prima una variabile
Text Testo
Visibility Visibilità
# Shared panel docking
Activate Panel Attiva pannello
Change Panel Layout Modifica disposizione dei pannelli
Close Panel Chiudi pannello
Move Panel Sposta pannello
Group with Raggruppa con
Resize panels Ridimensiona pannelli
Resize panel window Ridimensiona finestra del pannello
@msg Invalid panel layout; restored the default dock La disposizione dei pannelli non è valida; è stato ripristinato l’ancoraggio predefinito
@msg action is required action è obbligatorio
@msg anchor must name a registered panel anchor deve indicare un pannello registrato
@msg before must name a panel before deve indicare un pannello
@msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo
@msg group must be true or false group deve essere true o false
@msg move requires a destination panel Lo spostamento richiede un pannello di destinazione
@msg move requires an existing destination panel Lo spostamento richiede un pannello di destinazione esistente
@msg onto must name a floating panel onto deve indicare un pannello mobile
@msg panel group is empty Il gruppo di pannelli è vuoto
@msg panel must name a registered panel panel deve indicare un pannello registrato
@msg saved panel layout is invalid; reset the workspace first La disposizione salvata dei pannelli non è valida; reimposta prima l’area di lavoro
@msg zone must be a string zone deve essere una stringa
@msg zone must be tab, center, left, right, top or bottom zone deve essere tab, center, left, right, top o bottom
@msg {key} must be a finite coordinate {key} deve essere una coordinata finita
@msg x and y must be provided together x e y devono essere forniti insieme
@msg invalid restored panel group Il gruppo di pannelli ripristinato non è valido
# Revolve profile guidance.
@@ -4107,3 +4136,6 @@ lighting Light Luce
Vertical Guide Guida verticale
Horizontal Guide Guida orizzontale
Move Guide to Layer Sposta guida nel livello
@msg invalid native panel set Gruppo nativo di pannelli non valido
@msg missing panel layout Disposizione dei pannelli mancante
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -3290,6 +3290,8 @@ theme Appearance 外観
Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。
A file dialog is open: choose there first ファイルダイアログが開いています。先にそちらで選択してください
The document was closed while its file dialog was open ファイルダイアログが開いている間にドキュメントが閉じられました
The dialog was closed before its file dialog answered ファイルダイアログが応答する前にダイアログが閉じられました
Shaper Shaper
Expand Shaper Group Shaper グループを拡張
Release Shaper Group Shaper グループを解除
@@ -3528,6 +3530,8 @@ theme Appearance 外観
@msg nothing to align 整列するものがありません
@msg select a clip group クリップグループを選択してください
@msg select two or more objects to distribute 分布させるオブジェクトを 2 つ以上選択してください
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis には "vertical"、"horizontal"、度単位の角度のいずれかを指定してください (`{v}` は指定できません)
@msg give horizontal or vertical horizontal または vertical を指定してください
@msg selection has no bounds 選択範囲に境界がありません
@msg the top object must be a path, compound path or text object to use as a clipping mask クリッピングマスクとして使うには、最前面のオブジェクトがパス、複合パス、テキストオブジェクトのいずれかである必要があります
@msg no target object has an opacity mask: select one or give `id` 不透明マスクのあるターゲットオブジェクトがありません: 該当するオブジェクトを選択するか、`id` を指定してください
@@ -4042,6 +4046,32 @@ effect Vertical 垂直
Define a variable first 先に変数を定義してください
Text テキスト
Visibility 表示状態
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
# Shared panel docking
Activate Panel パネルをアクティブにする
Change Panel Layout パネルの配置を変更
Close Panel パネルを閉じる
Move Panel パネルを移動
Group with グループ化先
Resize panels パネルのサイズを変更
Resize panel window パネルウィンドウのサイズを変更
@msg invalid restored panel group 復元されたパネルグループが無効です
@msg Invalid panel layout; restored the default dock パネル配置が無効です。既定のドックを復元しました
@msg action is required action を指定してください
@msg anchor must name a registered panel anchor に登録済みパネルを指定してください
@msg before must name a panel before にパネルを指定してください
@msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています
@msg group must be true or false group は true または false にしてください
@msg move requires a destination panel 移動先のパネルを指定してください
@msg move requires an existing destination panel 移動先には既存のパネルを指定してください
@msg onto must name a floating panel onto にフローティングパネルを指定してください
@msg panel group is empty パネルグループが空です
@msg panel must name a registered panel panel に登録済みパネルを指定してください
@msg saved panel layout is invalid; reset the workspace first 保存されたパネル配置が無効です。先にワークスペースをリセットしてください
@msg x and y must be provided together x と y は両方指定してください
@msg zone must be a string zone は文字列にしてください
@msg zone must be tab, center, left, right, top or bottom zone は tab、center、left、right、top、bottom のいずれかにしてください
@msg {key} must be a finite coordinate {key} は有限の座標にしてください
# Revolve profile guidance.
@@ -4100,3 +4130,6 @@ lighting Light ライト
Vertical Guide 垂直ガイド
Horizontal Guide 水平ガイド
Move Guide to Layer ガイドをレイヤーへ移動
@msg invalid native panel set ネイティブパネルセットが無効です
@msg missing panel layout パネル配置がありません
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -2718,6 +2718,8 @@
Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo.
VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda.
A file dialog is open: choose there first Há uma caixa de diálogo de arquivos aberta: escolha nela primeiro
The document was closed while its file dialog was open O documento foi fechado enquanto sua caixa de diálogo de arquivos estava aberta
The dialog was closed before its file dialog answered O diálogo foi fechado antes que sua caixa de diálogo de arquivos respondesse
Shaper Formador
Expand Shaper Group Expandir Grupo Formador
Release Shaper Group Liberar Grupo Formador
@@ -2756,6 +2758,14 @@
Ocean Ripple… Ondulação oceânica…
Rename Data Set Renomear conjunto de dados
Rename Variable Renomear variável
# Shared panel docking
Activate Panel Ativar Painel
Change Panel Layout Alterar Layout dos Painéis
Close Panel Fechar Painel
Move Panel Mover Painel
Group with Agrupar com
Resize panels Redimensionar painéis
Resize panel window Redimensionar janela do painel
# Interactive Revolve controls.
Lighting Iluminação
Can't render this file because it contains an unexpected character in line 6 and column 47.
+32
View File
@@ -3481,6 +3481,7 @@ axis Both Оба
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art или type
@msg artboard {0} does not exist монтажная область {0} не существует
@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 при этом масштабе рисунок имеет размер {w} × {h} пт, что больше холста: выберите меньший масштаб или впишите его в монтажную область
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis должно быть "vertical", "horizontal" или углом в градусах, а не `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black или цвет, например "#ff8800"
@msg bad color {v} недопустимый цвет {v}
@msg bad colour `{hex}` (#rrggbb) недопустимый цвет `{hex}` (#rrggbb)
@@ -3523,6 +3524,7 @@ axis Both Оба
@msg freeform line {i} must be an array of point indices произвольная линия {i} должна быть массивом индексов точек
@msg give both `start` and `end` [x, y] (or neither) укажите и `start`, и `end` [x, y] (или ни одного)
@msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами
@msg give horizontal or vertical укажите horizontal или vertical
@msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины
@msg guides are locked направляющие заблокированы
@msg image '{key}' has no pixels and was left out изображение '{key}' не имеет пикселей и было пропущено
@@ -3928,6 +3930,8 @@ axis Both Оба
Images: link to the file instead of only embedding it. Изображения: связывать с файлом вместо простого внедрения.
VectorCraft documents: one locked object, updated when the file changes. Документы VectorCraft: один заблокированный объект, обновляемый при изменении файла.
A file dialog is open: choose there first Открыто окно выбора файла: сначала сделайте выбор в нём
The document was closed while its file dialog was open Документ был закрыт, пока было открыто его окно выбора файла
The dialog was closed before its file dialog answered Диалоговое окно было закрыто до того, как ответило его окно выбора файла
Shaper Shaper
Expand Shaper Group Разобрать группу Shaper
Release Shaper Group Освободить группу Shaper
@@ -4045,6 +4049,31 @@ axis Both Оба
Define a variable first Сначала определите переменную
Text Текст
Visibility Видимость
# Shared panel docking
Activate Panel Активировать панель
Change Panel Layout Изменить расположение панелей
Close Panel Закрыть панель
Move Panel Переместить панель
Group with Сгруппировать с
Resize panels Изменить размер панелей
Resize panel window Изменить размер окна панели
@msg Invalid panel layout; restored the default dock Недопустимое расположение панелей; восстановлено расположение по умолчанию
@msg action is required Необходимо указать action
@msg anchor must name a registered panel anchor должен указывать зарегистрированную панель
@msg before must name a panel before должен указывать панель
@msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений
@msg group must be true or false group должен иметь значение true или false
@msg move requires a destination panel Для перемещения требуется целевая панель
@msg move requires an existing destination panel Для перемещения требуется существующая целевая панель
@msg onto must name a floating panel onto должен указывать плавающую панель
@msg panel group is empty Группа панелей пуста
@msg panel must name a registered panel panel должен указывать зарегистрированную панель
@msg saved panel layout is invalid; reset the workspace first Сохранённое расположение панелей недопустимо; сначала сбросьте рабочую среду
@msg zone must be a string zone должен быть строкой
@msg zone must be tab, center, left, right, top or bottom zone должен иметь значение tab, center, left, right, top или bottom
@msg {key} must be a finite coordinate {key} должен быть конечной координатой
@msg x and y must be provided together x и y необходимо указывать вместе
@msg invalid restored panel group Восстановленная группа панелей недопустима
# Revolve profile guidance.
@@ -4103,3 +4132,6 @@ lighting Light Свет
Vertical Guide Вертикальная направляющая
Horizontal Guide Горизонтальная направляющая
Move Guide to Layer Переместить направляющую на слой
@msg invalid native panel set Недопустимый собственный набор панелей
@msg missing panel layout Отсутствует расположение панелей
@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления
Can't render this file because it contains an unexpected character in line 2 and column 73.
+31
View File
@@ -923,6 +923,37 @@ fn complete_languages_translate_every_message() {
}
}
/// The parameter errors of Align, Distribute Spacing, Reflect and Shear, whose text the scan above
/// doesn't read, are translated whole in each language of [`COMPLETE_MESSAGES`].
#[test]
fn align_reflect_and_shear_param_errors_are_translated() {
use serde_json::json;
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({})).unwrap();
for x in [0, 50] {
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap();
}
s.execute("select.all", &json!({})).unwrap();
for (cmd, p) in [
("object.align", json!({})),
("object.align", json!({"horizontal": "middle"})),
("object.align", json!({"vertical": "centre"})),
("object.align", json!({"horizontal": "left", "to": "page"})),
("object.align", json!({"horizontal": "left", "bounds": "visual"})),
("object.distributeSpacing", json!({"axis": "diagonal"})),
("object.distributeSpacing", json!({"spacing": "5"})),
("object.reflect", json!({"axis": "diagonal"})),
("object.shear", json!({"axis": "diagonal"})),
] {
let e = s.execute(cmd, &p).unwrap_err();
let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{cmd} {p}: {e}") };
for code in COMPLETE_MESSAGES {
let shown = message(Lang::from_code(code).unwrap(), &e.to_string());
assert!(!shown.contains(msg.as_str()), "{code}: {shown}");
}
}
}
/// Languages whose catalogs cover every status and error message.
const COMPLETE_MESSAGES: &[&str] = &["de", "es", "fr", "it", "ja", "ru", "uk"];
+32
View File
@@ -3468,6 +3468,7 @@ axis Both Обидві
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art або type
@msg artboard {0} does not exist монтажна область {0} не існує
@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 при цьому масштабі малюнок має розмір {w} × {h} пт, що більше полотна: виберіть менший масштаб або впишіть його в монтажну область
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis має бути "vertical", "horizontal" або кутом у градусах, а не `{v}`
@msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black або колір, наприклад "#ff8800"
@msg bad color {v} неприпустимий колір {v}
@msg bad colour `{hex}` (#rrggbb) неприпустимий колір `{hex}` (#rrggbb)
@@ -3510,6 +3511,7 @@ axis Both Обидві
@msg freeform line {i} must be an array of point indices довільна лінія {i} має бути масивом індексів точок
@msg give both `start` and `end` [x, y] (or neither) вкажіть `start`, `end` [x, y] (або жодного)
@msg give corners of the path: anchors between two straight sides вкажіть кути контуру: опорні точки між двома прямими сторонами
@msg give horizontal or vertical вкажіть horizontal або vertical
@msg give the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини
@msg guides are locked напрямні заблоковані
@msg image '{key}' has no pixels and was left out зображення '{key}' не має пікселів і було пропущено
@@ -3913,6 +3915,8 @@ axis Both Обидві
Images: link to the file instead of only embedding it. Зображення: пов’язувати із файлом замість простого впровадження.
VectorCraft documents: one locked object, updated when the file changes. Документи VectorCraft: один заблокований об’єкт, який оновлюється під час зміни файлу.
A file dialog is open: choose there first Відкрито вікно вибору файлу: спочатку зробіть вибір у ньому
The document was closed while its file dialog was open Документ було закрито, поки було відкрито його вікно вибору файлу
The dialog was closed before its file dialog answered Діалогове вікно було закрито раніше, ніж відповіло його вікно вибору файлу
Shaper Формоутворення
Expand Shaper Group Розібрати групу формоутворення
Release Shaper Group Звільнити групу формоутворення
@@ -4030,6 +4034,31 @@ axis Both Обидві
Define a variable first Спочатку визначте змінну
Text Текст
Visibility Видимість
# Shared panel docking
Activate Panel Активувати панель
Change Panel Layout Змінити розташування панелей
Close Panel Закрити панель
Move Panel Перемістити панель
Group with Згрупувати з
Resize panels Змінити розмір панелей
Resize panel window Змінити розмір вікна панелі
@msg Invalid panel layout; restored the default dock Неприпустиме розташування панелей; відновлено типове розташування
@msg action is required Потрібно вказати action
@msg anchor must name a registered panel anchor має вказувати зареєстровану панель
@msg before must name a panel before має вказувати панель
@msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів
@msg group must be true or false group має мати значення true або false
@msg move requires a destination panel Для переміщення потрібна цільова панель
@msg move requires an existing destination panel Для переміщення потрібна наявна цільова панель
@msg onto must name a floating panel onto має вказувати плаваючу панель
@msg panel group is empty Група панелей порожня
@msg panel must name a registered panel panel має вказувати зареєстровану панель
@msg saved panel layout is invalid; reset the workspace first Збережене розташування панелей неприпустиме; спочатку скиньте робоче середовище
@msg zone must be a string zone має бути рядком
@msg zone must be tab, center, left, right, top or bottom zone має мати значення tab, center, left, right, top або bottom
@msg {key} must be a finite coordinate {key} має бути скінченною координатою
@msg x and y must be provided together x та y потрібно вказувати разом
@msg invalid restored panel group Відновлена група панелей неприпустима
# Interactive Revolve controls.
Lighting Освітлення
@@ -4086,3 +4115,6 @@ lighting Light Світло
Vertical Guide Вертикальна напрямна
Horizontal Guide Горизонтальна напрямна
Move Guide to Layer Перемістити напрямну на шар
@msg invalid native panel set Неприпустимий власний набір панелей
@msg missing panel layout Відсутнє розташування панелей
@msg unsupported native docking operation Непідтримувана власна операція прикріплення
Can't render this file because it contains an unexpected character in line 659 and column 40.
+10
View File
@@ -3540,6 +3540,8 @@ axis Both 两者
Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。
A file dialog is open: choose there first 文件对话框已打开:请先在其中选择
The document was closed while its file dialog was open 文件对话框打开期间,文档已关闭
The dialog was closed before its file dialog answered 文件对话框给出结果之前,对话框已关闭
Expand Shaper Group 扩展 Shaper 组
Release Shaper Group 释放 Shaper 组
Select Shaper Face 选择 Shaper 区域
@@ -3670,6 +3672,14 @@ effect Vertical 垂直
Define a variable first 请先定义一个变量
Text 文本
Visibility 可见性
# Shared panel docking
Activate Panel 激活面板
Change Panel Layout 更改面板布局
Close Panel 关闭面板
Move Panel 移动面板
Group with 组合到
Resize panels 调整面板大小
Resize panel window 调整面板窗口大小
# Revolve profile guidance.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -3319,6 +3319,8 @@ axis Both 兩者
Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。
A file dialog is open: choose there first 檔案對話方塊已開啟:請先在其中選擇
The document was closed while its file dialog was open 檔案對話方塊開啟期間,文件已關閉
The dialog was closed before its file dialog answered 檔案對話方塊回應之前,對話方塊已關閉
Shaper Shaper
Expand Shaper Group 展開 Shaper 群組
Release Shaper Group 釋放 Shaper 群組
@@ -3451,6 +3453,14 @@ effect Soft 柔和
Define a variable first 請先定義一個變數
Text 文字
Visibility 可見性
# Shared panel docking
Activate Panel 啟用面板
Change Panel Layout 變更面板配置
Close Panel 關閉面板
Move Panel 移動面板
Group with 群組至
Resize panels 調整面板大小
Resize panel window 調整面板視窗大小
# Revolve profile guidance.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+37 -17
View File
@@ -1,28 +1,19 @@
//! Icon lookup and drawing (SVGs rasterized by egui_extras, tinted with theme colours).
use std::collections::HashMap;
use std::sync::OnceLock;
use craft_ui::icons::{SvgIconSet, SvgStyle};
use egui::{Color32, ImageSource, Rect, Ui, Vec2};
fn table() -> &'static HashMap<&'static str, &'static [u8]> {
static T: OnceLock<HashMap<&'static str, &'static [u8]>> = OnceLock::new();
fn table() -> &'static SvgIconSet {
static T: OnceLock<SvgIconSet> = OnceLock::new();
T.get_or_init(|| {
crate::icon_data::ICONS
.iter()
.map(|(n, b)| {
// Icons use currentColor; render white and tint at draw time. Slightly thinner strokes
// than Lucide's default read closer to Illustrator's glyph weight.
let s = String::from_utf8_lossy(b).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\"");
let leaked: &'static [u8] = Box::leak(s.into_bytes().into_boxed_slice());
(*n, leaked)
})
.collect()
SvgIconSet::new(crate::icon_data::ICONS, SvgStyle { current_color: "white", stroke_width: Some("1.6") }, "bytes://icon/", "square-dashed")
})
}
pub fn exists(name: &str) -> bool {
table().contains_key(name)
table().contains(name)
}
/// Map tool-catalogue icon names to SVG files.
@@ -102,7 +93,7 @@ pub fn tool_icon(name: &str) -> &'static str {
"tool-zoom" => "zoom-in",
other => {
if exists(other) {
table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed")
table().canonical_name(other).unwrap_or("square-dashed")
} else {
"square-dashed"
}
@@ -111,8 +102,7 @@ pub fn tool_icon(name: &str) -> &'static str {
}
pub fn source(name: &str) -> ImageSource<'static> {
let bytes = table().get(name).or_else(|| table().get("square-dashed")).copied().unwrap_or(&[]);
ImageSource::Bytes { uri: format!("bytes://icon/{name}.svg").into(), bytes: egui::load::Bytes::Static(bytes) }
table().source(name)
}
/// Paint icon `name` into `rect` tinted with `tint`.
@@ -126,3 +116,33 @@ pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response
paint(ui, name, rect, tint);
resp
}
#[cfg(test)]
mod tests {
use super::*;
/// Characterize the pre-extraction loader against every embedded asset, including custom
/// icons. A shared-crate update must not silently change our stroke weight or cache keys.
#[test]
fn shared_catalog_preserves_existing_icons() {
for (name, original) in crate::icon_data::ICONS {
let expected = String::from_utf8_lossy(original).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\"");
let ImageSource::Bytes { uri, bytes } = source(name) else { panic!("expected SVG bytes") };
assert_eq!(uri.as_ref(), format!("bytes://icon/{name}.svg"));
assert_eq!(bytes.as_ref(), expected.as_bytes(), "{name}");
assert!(exists(name));
assert_eq!(tool_icon(name), *name);
}
}
#[test]
fn shared_catalog_preserves_fallback_and_tool_aliases() {
let ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") };
let ImageSource::Bytes { bytes: fallback, .. } = source("square-dashed") else { panic!("expected SVG bytes") };
assert_eq!(uri.as_ref(), "bytes://icon/missing-icon.svg");
assert_eq!(bytes.as_ref(), fallback.as_ref());
assert!(!exists("missing-icon"));
assert_eq!(tool_icon("missing-icon"), "square-dashed");
assert_eq!(tool_icon("tool-selection"), "dc-selection");
}
}
+49 -12
View File
@@ -19,31 +19,25 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"
/// and flattener, PDF, print and perspective grid presets files are imported. → `document.open`'s result for a document opened now (its
/// `warnings` say what didn't come in as it was), else null.
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value, String> {
let ext = fileio::extension(name);
let opens = opens_as(name);
// A WebAssembly plug-in is installed.
if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) {
if opens == Some(OpensAs::Plugin) {
let r =
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}))?;
app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name)));
return Ok(Value::Null);
}
let presets = [
(vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"),
(vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"),
(vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"),
(vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"),
];
if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) {
if let Some(OpensAs::Presets { import, what }) = opens {
let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?;
let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
app.status(format!("Imported {what}: {}", names.join(", ")));
return Ok(Value::Null);
}
if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) {
if opens == Some(OpensAs::Library) {
return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null);
}
let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str());
if swatches || ext == vectorcraft_doc::style_libs::STYLES_EXT {
let swatches = opens == Some(OpensAs::SwatchLibrary);
if swatches || opens == Some(OpensAs::StyleLibrary) {
let p = match path {
Some(path) => serde_json::json!({ "path": path }),
// `.ase` swatch libraries are binary.
@@ -63,6 +57,49 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
open_document(app, name, bytes, path, &Value::Null)
}
/// The kinds of file File → Open installs, imports or shows in the library panel or the Libraries
/// panel ([`opens_as`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OpensAs {
/// A WebAssembly plug-in: installed.
Plugin,
/// Flattener, PDF, print or perspective grid presets: imported with command `import`, and
/// `what` names them in the status bar.
Presets { import: &'static str, what: &'static str },
/// A Libraries panel file: added to the Libraries panel as a new library.
Library,
/// A swatch library: opened in the library panel.
SwatchLibrary,
/// A graphic style library: opened in the library panel.
StyleLibrary,
}
/// The kind of file named `name` (by its extension) that File → Open installs, imports or shows in
/// the library panel or the Libraries panel ([`open_bytes`]), else `None`. A drop opens these files
/// the same way, wherever they are dropped ([`VectorcraftApp::drop_target`]).
pub fn opens_as(name: &str) -> Option<OpensAs> {
let ext = fileio::extension(name);
let presets = [
(vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"),
(vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"),
(vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"),
(vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"),
];
if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) {
Some(OpensAs::Plugin)
} else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) {
Some(OpensAs::Presets { import, what })
} else if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) {
Some(OpensAs::Library)
} else if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()) {
Some(OpensAs::SwatchLibrary)
} else if ext == vectorcraft_doc::style_libs::STYLES_EXT {
Some(OpensAs::StyleLibrary)
} else {
None
}
}
/// Open a document through the engine loader with the `document.open` options in `p` →
/// `document.open`'s result.
pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value, String> {
+42 -12
View File
@@ -36,6 +36,7 @@ pub mod io;
pub mod menus;
pub mod native_menu;
pub mod palette;
mod panel_docking;
pub mod panels;
pub mod picks;
pub mod place;
@@ -242,9 +243,13 @@ pub struct Services {
/// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then.
pub native_menu: Option<native_menu::NativeMenu>,
/// Show file dialogs off the UI thread (desktop Linux, where a dialog in line holds the window
/// and the compositor finds it not answering): what asked runs again with the answer
/// ([`picks`]). Without it they are shown in line.
/// and the compositor finds it not answering, #592) or as sheets on the window (macOS, where a
/// dialog in line aborts the app, #867): what asked runs again with the answer ([`picks`]).
/// Without it they are shown in line.
pub start_pick: Option<picks::StartPick>,
/// [`Self::start_pick`]'s dialogs are sheets on the window (macOS): while one is open the app
/// neither quits nor closes documents, as under the modal dialog before.
pub dialogs_are_sheets: bool,
}
/// Cached canvas raster.
@@ -579,9 +584,9 @@ impl VectorcraftApp {
return r;
}
let mut params = params;
// Paste in place, in front, in back (#693) and Select › All on Active Artboard (#1006): onto
// or on the active artboard, the view's.
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack" | "select.allOnArtboard")
// Paste in place, in front, in back (#693), Select › All on Active Artboard (#1006) and Align to
// Artboard: onto, on or to the active artboard, the view's. A batch passes it on to those steps.
if (vectorcraft_engine::ON_ACTIVE_ARTBOARD.contains(&id) || id == "command.batch")
&& params.get("artboard").is_none()
&& self.view().is_some()
&& let Some(n) = self.session.active().map(|d| d.doc.artboards.len())
@@ -678,8 +683,13 @@ impl VectorcraftApp {
let reply = req.reply.clone();
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
let roots = self.automation_roots.clone();
let outcome = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req)))
.unwrap_or_else(|msg| control::err(format!("internal error: {msg} (please report this bug)")));
let outcome = match file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) {
Ok(outcome) => outcome,
Err(msg) => {
self.picks.recover();
control::err(format!("internal error: {msg} (please report this bug)"))
}
};
match outcome {
control::Outcome::Done(v) => {
let _ = reply.send(v);
@@ -815,6 +825,18 @@ impl VectorcraftApp {
}
}
/// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs
/// again when it answers.
pub fn file_dialog_open(&self) -> bool {
self.picks.is_waiting()
}
/// A file dialog is open as a sheet on the window ([`Services::dialogs_are_sheets`]): nothing
/// closes or quits until it answers.
pub fn file_sheet_open(&self) -> bool {
self.services.dialogs_are_sheets && self.file_dialog_open()
}
/// Show a transient status message.
pub fn status(&mut self, s: impl Into<String>) {
self.ui.status = s.into();
@@ -903,6 +925,7 @@ impl VectorcraftApp {
pub fn logic(&mut self, ctx: &egui::Context) {
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.logic_frame(ctx))) {
self.picks.recover();
self.status(format!("Internal error: {msg} (please report this bug)"));
}
}
@@ -956,10 +979,14 @@ impl VectorcraftApp {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// The window's close button (or the system quitting the app) asks about unsaved documents,
// once the saves running in the background are done.
// once the saves running in the background are done. Not while a file dialog's sheet is on
// the window: what asked for it runs again when it answers.
if ctx.input(|i| i.viewport().close_requested()) {
background::wait_all(self);
if unsaved::any_dirty(self) {
if self.file_sheet_open() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.status(picks::busy());
} else if unsaved::any_dirty(self) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
if let Err(e) = unsaved::close_all(self, "quit") {
self.status(e);
@@ -1000,8 +1027,8 @@ impl VectorcraftApp {
self.take_dropped_files(ctx);
}
/// Files dropped on the window: documents opened, pictures and text placed on the canvas
/// ([`Self::drop_target`]).
/// Files dropped on the window: documents, libraries, presets and plug-ins opened, pictures
/// and text placed on the canvas ([`Self::drop_target`]).
#[cfg(not(target_arch = "wasm32"))]
fn take_dropped_files(&mut self, ctx: &egui::Context) {
let (dropped, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.modifiers.shift));
@@ -1077,7 +1104,9 @@ impl VectorcraftApp {
// The native menu's key equivalents become the input their keys make, ahead of what came
// after them (see `native_menu`).
let keys = self.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys).unwrap_or_default();
if !keys.is_empty() {
// The menu bar does nothing while a file dialog is open (`native_menu`): a key that raced it
// going inert is dropped.
if !keys.is_empty() && !self.file_dialog_open() {
let events: Vec<egui::Event> = keys.into_iter().flat_map(|k| native_menu::key_events(k, || self.system_clipboard_text())).collect();
raw.events.splice(0..0, events);
}
@@ -1134,6 +1163,7 @@ impl VectorcraftApp {
pub fn ui(&mut self, ui: &mut egui::Ui) {
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.ui_frame(ui))) {
self.picks.recover();
self.status(format!("Internal error: {msg} (please report this bug)"));
}
}
+34
View File
@@ -189,6 +189,16 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"),
("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"),
("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"),
("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"),
(
"type.alignCenter",
"Align center",
"Cmd+Shift+C",
"{} align the selected type's paragraphs (or those the Type tool's selection touches) centred",
),
("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"),
("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"),
("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"),
(
"type.bold",
"Bold",
@@ -256,6 +266,20 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"),
("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)"),
(
"window.panel.layout",
"Change Panel Layout",
"",
"{action} serialized docking action, including floating geometry, divider sizes and accordion state",
),
(
"window.panel.move",
"Move Panel",
"",
"{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} group, split or reorder a panel",
),
("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"),
("window.panel.close", "Close Panel", "", "{panel} hide a panel"),
(
"window.panel.float",
"Float Panel",
@@ -843,6 +867,9 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
/// Handle a UI command. `None` = not a UI command (the engine handles it).
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if let Some(result) = crate::panel_docking::command(app, id, p) {
return Some(result);
}
if id == "app.language" {
let lang = p.get("lang").and_then(Value::as_str).unwrap_or("");
let value = if lang.eq_ignore_ascii_case("auto") {
@@ -977,6 +1004,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars),
"type.bold" => crate::panels::character::toggle_face(app, Face::Bold),
"type.italic" => crate::panels::character::toggle_face(app, Face::Italic),
id if let Some((_, justify)) = crate::panels::paragraph::ALIGN_COMMANDS.iter().find(|c| c.0 == id) => {
crate::panels::paragraph::align(app, justify)
}
"view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator),
"effect.last" => match app.last_effect.clone() {
Some((e, params)) => {
@@ -1449,6 +1479,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
let canonical = normalize_panel(panel);
match canonical.and_then(DockTab::from_id) {
_ if app.ui.docking.is_some() => canonical.is_some_and(|id| {
app.ui.open_panel.as_deref() == Some(id) || app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string()))
}),
_ if canonical.is_some_and(|id| crate::floating::group_of(&app.ui, id).is_some()) => true,
Some(tab) if !app.ui.dock_collapsed => crate::floating::shown_tab(&app.ui) == Some(tab),
_ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)),
@@ -1856,6 +1889,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
// Save is off for a clean document that already has its own file.
"file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()),
"type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(),
id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(),
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
"file.place"
| "file.export.svg"
+33 -3
View File
@@ -458,7 +458,31 @@ fn take_submenu(bar: &mut MenuBar, command: &str) -> Option<Node> {
/// VectorCraft's menus as a Mac menu bar, as they show now.
pub fn layout(app: &VectorcraftApp) -> Layout {
mac_layout(app, &from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current())
let mut layout = mac_layout(app, &from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current());
if app.file_dialog_open() {
for menu in &mut layout.bar.menus {
inert(&mut menu.children);
}
}
layout
}
/// While a file dialog is open (a sheet on the window, [`crate::picks`]) the menu bar does
/// nothing, as under the modal dialog before: what asked for the dialog runs again when it
/// answers, on the document as it was. Hide and Minimize still work.
fn modal_allows(item: &Item) -> bool {
matches!(item.command, Some(HIDE | HIDE_OTHERS | MINIMIZE))
}
/// Disable every item of `nodes` (and their submenus) [`modal_allows`] doesn't keep.
fn inert(nodes: &mut [Node]) {
for node in nodes {
match node {
Node::Item(item) => item.enabled &= modal_allows(item),
Node::Submenu { children, .. } => inert(children),
Node::Separator | Node::Header(_) | Node::Standard(_) => {}
}
}
}
// ----------------------------------------------------------------------------- the app's side
@@ -516,8 +540,13 @@ impl NativeMenu {
/// Run the items clicked in the native menu since the last frame, like in-window menu clicks.
pub fn run(app: &mut VectorcraftApp, ctx: &egui::Context) {
let picking = app.file_dialog_open();
let Some(menu) = app.services.native_menu.as_mut() else { return };
for it in std::mem::take(&mut menu.clicks) {
// A click that raced the menu going inert under a file dialog.
if picking && !modal_allows(&it) {
continue;
}
match it.command {
Some(MINIMIZE) => ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)),
Some(command) => {
@@ -555,8 +584,8 @@ pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) {
/// What the menus' rows depend on, cheaply (no allocation): commands run, the active document,
/// its revision (edits, selection and view changes), history and clipboard, the language,
/// shortcuts and plug-ins. `input` forces a change on frames with a click or a key press, which
/// covers what the hash doesn't list (panels shown, a tool picked).
/// shortcuts, plug-ins and an open file dialog. `input` forces a change on frames with a click or
/// a key press, which covers what the hash doesn't list (panels shown, a tool picked).
fn state_hash(app: &VectorcraftApp, input: Option<u64>) -> u64 {
let mut h = DefaultHasher::new();
input.hash(&mut h);
@@ -567,6 +596,7 @@ fn state_hash(app: &VectorcraftApp, input: Option<u64>) -> u64 {
(d.uid, d.revision, d.history.undo.len(), d.history.redo.len(), d.transparency_grid, d.print_tiling).hash(&mut h);
}
(app.system_paste, app.last_effect.is_some(), app.ui.recent_files.len(), app.ui.recent_files.first(), app.recent_fonts().len()).hash(&mut h);
app.file_dialog_open().hash(&mut h);
(crate::i18n::current().code(), menus::plugin_revision()).hash(&mut h);
crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed).hash(&mut h);
h.finish()
+991
View File
@@ -0,0 +1,991 @@
//! Application-owned panel IDs and commands around the shared docking layout.
use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone};
use serde_json::{Value, json};
use crate::VectorcraftApp;
#[derive(Clone, Default)]
struct LegacyDrag {
layout: Option<Layout<String>>,
open_panel: Option<String>,
floating: Vec<crate::state::FloatingPanels>,
icons: std::collections::BTreeSet<String>,
}
#[derive(Clone)]
struct RootGroupDrag {
panel: String,
layout: Layout<String>,
hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
generation: u64,
}
fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
let key = egui::Id::new("vectorcraft-panel-docking").with("legacy-origin");
let previous = ctx.data_mut(|data| data.remove_temp::<LegacyDrag>(key));
if let Some(previous) = previous {
app.ui.open_panel = previous.open_panel;
app.ui.floating_panels = previous.floating;
app.ui.docking = previous.layout;
app.ui.docking_icons = previous.icons;
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("vectorcraft-panel-docking"));
true
} else {
false
}
}
fn normalize(raw: &str) -> Option<&'static str> {
crate::menus::normalize_panel(raw)
}
pub(crate) fn valid(layout: &Layout<String>) -> bool {
layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str()))
}
fn legacy(app: &VectorcraftApp) -> Layout<String> {
let panels: Vec<String> = crate::floating::docked_tabs(&app.ui).map(|tab| tab.info().0.to_string()).collect();
let active = panels.iter().position(|id| id == app.ui.dock_tab.info().0).unwrap_or(0);
let root = (!panels.is_empty()).then_some(Node::Tabs { panels, active });
let mut layout = Layout { root, floating: Vec::new() };
for group in &app.ui.floating_panels {
let panels: Vec<String> = group.panels.iter().filter(|id| normalize(id).is_some() && !layout.contains(id)).cloned().collect();
if !panels.is_empty() && group.pos.iter().all(|v| v.is_finite()) {
let active = group.active.min(panels.len().saturating_sub(1));
layout.floating.push(Floating {
panels,
active,
rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0).clamp(200.0, 720.0), 400.0],
});
}
}
layout
}
fn icon_origins(app: &VectorcraftApp) -> std::collections::BTreeSet<String> {
let mut icons = app.ui.docking_icons.clone();
if app.ui.docking.is_none() {
// Legacy workspaces have no custom groups in the dock. Icon-panel floats therefore
// retain their native rail destination when first converted to a shared layout.
icons.extend(
app.ui
.floating_panels
.iter()
.flat_map(|group| group.panels.iter())
.filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str()))
.cloned(),
);
}
icons
}
fn first_root(layout: &Layout<String>, except: &str) -> Option<String> {
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
root.panels().into_iter().find(|p| p.as_str() != except).cloned()
}
// Preserve the native dock's selected tab when another tab leaves. If the selected tab
// itself leaves, the remaining group's first tab is the native fallback.
fn float_fallback(layout: &Layout<String>, panel: &str) -> Option<String> {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, active } if panels.iter().any(|id| id == panel) => {
return panels.get(*active).filter(|id| id.as_str() != panel).or_else(|| panels.iter().find(|id| id.as_str() != panel)).cloned();
}
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
_ => {}
}
}
None
}
fn restore_float_fallback(layout: &mut Layout<String>, panel: Option<String>) -> Result<(), String> {
if let Some(panel) = panel.filter(|panel| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(panel)) {
layout.apply(Action::Activate { panel }).map_err(|error| error.to_string())?;
}
Ok(())
}
fn ensure(layout: &mut Layout<String>, panel: &str) -> Result<(), String> {
let anchor = first_root(layout, panel);
layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string())
}
fn group_members(layout: &Layout<String>, panel: &str) -> Vec<String> {
if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|id| id == panel)) {
return group.panels.clone();
}
let mut visit: Vec<_> = layout.root.iter().collect();
while let Some(node) = visit.pop() {
match node {
Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return panels.clone(),
Node::Split { first, second, .. } => {
visit.push(second);
visit.push(first);
}
_ => {}
}
}
vec![panel.into()]
}
fn number(params: &Value, key: &str, default: f32) -> Result<f32, String> {
match params.get(key) {
None => Ok(default),
Some(v) => v
.as_f64()
.filter(|v| v.is_finite() && v.abs() <= 1_000_000.0)
.map(|v| v as f32)
.ok_or_else(|| format!("{key} must be a finite coordinate")),
}
}
fn placement(params: &Value) -> Result<Placement<String>, String> {
if let Some(before) = params.get("before") {
let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?;
return Ok(Placement::Tab { before: Some(id.into()) });
}
let zone = match params.get("zone") {
None => "tab",
Some(value) => value.as_str().ok_or("zone must be a string")?,
};
Ok(match zone {
"tab" => Placement::Tab { before: None },
"center" => Placement::Split(Zone::Center),
"left" => Placement::Split(Zone::Left),
"right" => Placement::Split(Zone::Right),
"top" => Placement::Split(Zone::Top),
"bottom" => Placement::Split(Zone::Bottom),
_ => return Err("zone must be tab, center, left, right, top or bottom".into()),
})
}
pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if matches!(id, "window.panel.float" | "window.panel.dock")
&& (params.get("above").is_some()
|| params.get("below").is_some()
|| params.get("column").is_some()
|| params.get("collapsed").is_some()
|| params
.get("onto")
.and_then(Value::as_str)
.and_then(normalize)
.and_then(|id| crate::floating::group_of(&app.ui, id))
.and_then(|i| app.ui.floating_panels.get(i))
.is_some_and(decorated)
|| params
.get("panel")
.and_then(Value::as_str)
.and_then(normalize)
.and_then(|id| crate::floating::group_of(&app.ui, id))
.and_then(|i| app.ui.floating_panels.get(i))
.is_some_and(decorated))
{
return Some(native_change(app, id, params));
}
if id == "window.panel.layout" {
return Some(
params
.get("action")
.ok_or_else(|| "action is required".to_string())
.and_then(|value| serde_json::from_value::<Action<String>>(value.clone()).map_err(|error| error.to_string()))
.and_then(|action| apply_action(app, action)),
);
}
if params.get("panel").and_then(Value::as_str).is_some_and(|s| s.eq_ignore_ascii_case("tools") || s.eq_ignore_ascii_case("toolbar")) {
return None;
}
let operation = match id {
"window.panel.move" => "move",
"window.panel.float" => "float",
"window.panel.dock" => "dock",
"window.panel.activate" => "activate",
"window.panel.close" => "close",
"window.panel" if app.ui.docking.is_some() => "activate",
_ => return None,
};
Some(change(app, operation, params).map(|value| {
if id == "window.panel"
&& let Some(panel) = params.get("panel").and_then(Value::as_str).and_then(normalize)
&& let Some(group) = app.ui.floating_panels.iter().find(|group| group.panels.iter().any(|id| id == panel))
{
return json!({"floating":group.panels});
}
value
}))
}
// UI output and automation share this atomic dispatcher. The application owns the registry
// and saved return locations; craft-ui owns tree validation and structural changes.
fn apply_action(app: &mut VectorcraftApp, action: Action<String>) -> Result<Value, String> {
let registered = |id: &String| normalize(id) == Some(id.as_str());
let known = match &action {
Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => {
registered(panel)
&& registered(anchor)
&& match placement {
Placement::Tab { before: Some(id) } => registered(id),
_ => true,
}
}
Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered),
Action::Float { panel, .. }
| Action::Close { panel }
| Action::Activate { panel }
| Action::MoveFloating { panel, .. }
| Action::SetStackOpen { panel, .. }
| Action::ResizeStack { panel, .. } => registered(panel),
Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH,
};
if !known {
return Err("docking action contains an unknown panel or an excessive split path".into());
}
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
return Err("saved panel layout is invalid; reset the workspace first".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let mut icons = icon_origins(app);
let explicit_dock = match &action {
Action::Move { panel, anchor, .. } | Action::OpenAt { panel, anchor, .. }
if (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(anchor) =>
{
Some(panel.clone())
}
_ => None,
};
let returning = match &action {
Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)),
Action::Float { panel, .. } => {
layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location))
}
_ => None,
};
let fallback = match &action {
Action::Float { panel, .. } => float_fallback(&layout, panel),
_ => None,
};
let activated = match &action {
Action::Activate { panel } => Some(panel.clone()),
_ => None,
};
layout.apply(action).map_err(|error| error.to_string())?;
restore_float_fallback(&mut layout, fallback)?;
if let Some((panel, location)) = returning {
app.ui.docking_hidden.insert(panel, location);
}
if let Some(panel) = explicit_dock {
icons.remove(&panel);
}
app.ui.docking_icons = icons;
app.ui.docking = Some(layout);
if let Some(panel) = activated {
for group in &mut app.ui.floating_panels {
if group.panels.contains(&panel) {
group.collapsed = false;
}
}
}
sync_legacy(app);
app.ui.open_panel = None;
Ok(json!({"layout": app.ui.docking}))
}
fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result<Value, String> {
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
return Err("saved panel layout is invalid; reset the workspace first".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
if params.get("x").is_some() != params.get("y").is_some() {
return Err("x and y must be provided together".into());
}
let whole_group = match params.get("group") {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => return Err("group must be true or false".into()),
};
let members = if whole_group { group_members(&layout, panel) } else { vec![panel.to_string()] };
let mut hidden: std::collections::BTreeMap<_, _> =
app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect();
let mut icons = icon_origins(app);
let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel);
if icon_panel && !layout.contains(&panel.to_string()) {
icons.insert(panel.to_string());
}
if operation == "activate" && icon_panel && !layout.contains(&panel.to_string()) {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel) { None } else { Some(panel.into()) };
return Ok(json!({"panel": panel, "open": app.ui.open_panel}));
}
if operation == "dock" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
let mut candidate = layout.clone();
let mut open = None;
for member in &members {
if candidate.contains(member) {
candidate.apply(Action::Close { panel: member.clone() }).map_err(|error| error.to_string())?;
}
}
// Remove the complete group before restoring any saved neighbor. Otherwise a
// still-floating group member can accidentally receive the first restored tab.
for member in &members {
if icons.contains(member) {
open = Some(member.clone());
} else if let Some(location) = hidden.get(member).filter(|location| {
location.floating.is_none()
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: candidate.root.clone(), floating: Vec::new() }.contains(anchor))
}) {
if candidate.restore(member.clone(), location).is_err() {
// A removed neighboring group uses the normal dock as its documented fallback.
ensure(&mut candidate, member)?;
}
} else {
ensure(&mut candidate, member)?;
}
}
if candidate.contains(&panel.to_string()) {
candidate.apply(Action::Activate { panel: panel.to_string() }).map_err(|error| error.to_string())?;
} else if icons.contains(panel) {
open = Some(panel.to_string());
}
if !valid(&candidate) {
return Err("invalid restored panel group".into());
}
app.ui.docking = Some(candidate);
app.ui.docking_icons = icons;
for member in &members {
hidden.remove(member);
}
app.ui.docking_hidden = hidden;
sync_legacy(app);
app.ui.open_panel = open;
return Ok(json!({"panel": panel, "floating": false, "layout": app.ui.docking}));
}
let mut restored = false;
if operation == "dock"
&& params.get("anchor").or_else(|| params.get("onto")).is_none()
&& let Some(location) = hidden.get(panel).filter(|location| {
location.floating.is_none()
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor))
})
{
let mut candidate = layout.clone();
if candidate.contains(&panel.to_string()) {
candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
if candidate.restore(panel.into(), location).is_ok() {
layout = candidate;
restored = true;
hidden.remove(panel);
}
}
match operation {
"dock" if restored => {}
"float" => {
let old = layout
.floating
.iter()
.find(|group| group.panels.iter().any(|id| id == panel) && group.panels.len() == members.len())
.map(|group| group.rect)
.unwrap_or([400.0 + 24.0 * layout.floating.len().min(12) as f32, 140.0 + 24.0 * layout.floating.len().min(12) as f32, 300.0, 400.0]);
let rect =
[number(params, "x", old[0])?, number(params, "y", old[1])?, number(params, "width", old[2])?, number(params, "height", old[3])?];
let fallback = float_fallback(&layout, panel).or_else(|| first_root(&layout, panel));
for member in &members {
if let Ok(location) = layout.location(member)
&& location.floating.is_none()
{
hidden.insert(member.clone(), location);
}
}
ensure(&mut layout, panel)?;
if let Ok(location) = layout.location(&panel.to_string())
&& location.floating.is_none()
{
hidden.insert(panel.into(), location);
}
if let Some(onto) = params.get("onto") {
let onto = onto.as_str().and_then(normalize).ok_or("onto must name a floating panel")?;
if members.iter().any(|id| id == onto)
|| layout.floating.iter().any(|group| group.panels.iter().any(|id| id == panel) && group.panels.iter().any(|id| id == onto))
|| !layout.floating.iter().any(|group| group.panels.iter().any(|id| id == onto))
{
return Err("onto must name a panel floating in another group".into());
}
for member in &members {
layout
.apply(Action::Move { panel: member.clone(), anchor: onto.into(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
} else {
let lead = members.first().ok_or("panel group is empty")?;
layout.apply(Action::Float { panel: lead.clone(), rect }).map_err(|e| e.to_string())?;
for member in members.iter().filter(|id| *id != lead) {
layout
.apply(Action::Move { panel: member.clone(), anchor: lead.clone(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
}
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
restore_float_fallback(&mut layout, fallback)?;
}
"move" | "dock" => {
if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
return Err("move requires a destination panel".into());
}
let anchor = match params.get("anchor").or_else(|| params.get("onto")) {
Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()),
None => first_root(&layout, panel),
};
let placement = placement(params)?;
if let Some(anchor) = anchor {
ensure(&mut layout, panel)?;
layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?;
} else if operation == "dock" {
if layout.contains(&panel.to_string()) {
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?;
} else {
return Err("move requires an existing destination panel".into());
}
}
"close" => {
hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?);
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
_ => {
if !layout.contains(&panel.to_string())
&& let Some(location) = hidden.get(panel)
{
// A missing neighbor is allowed: the normal visible dock becomes the fallback.
let _ = layout.restore(panel.into(), location);
}
ensure(&mut layout, panel)?;
hidden.remove(panel);
}
}
if operation == "dock" && whole_group {
for member in members.iter().filter(|id| id.as_str() != panel) {
layout
.apply(Action::Move { panel: member.clone(), anchor: panel.into(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
}
let group = layout.floating.iter().find(|group| group.panels.iter().any(|id| id == panel));
let result = json!({"panel": panel, "floating": group.is_some(), "group": group.map(|g| &g.panels), "pos": group.map(|g| [g.rect[0], g.rect[1]]), "layout": layout});
if matches!(operation, "move" | "dock") && (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(&panel.to_string()) {
icons.remove(panel);
}
app.ui.docking = Some(layout);
app.ui.docking_hidden = hidden;
app.ui.docking_icons = icons;
if operation == "activate" {
for group in &mut app.ui.floating_panels {
if group.panels.iter().any(|id| id == panel) {
group.collapsed = false;
}
}
}
sync_legacy(app);
app.ui.open_panel = None;
app.ui.dock = true;
if matches!(operation, "activate" | "move" | "dock") {
app.ui.dock_collapsed = false;
}
Ok(result)
}
struct PanelContent<'a> {
app: &'a mut VectorcraftApp,
commands: Vec<(&'static str, Value)>,
}
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, panel: &String) {
let width = ui.available_width();
let height = (ui.available_height() - 20.0).max(0.0);
if crate::state::DockTab::from_id(panel).is_some() {
crate::dock::panel_body(self.app, ui, panel, width, height);
} else {
egui::ScrollArea::vertical()
.id_salt(("shared-panel-body", panel))
.auto_shrink([false, false])
.show(ui, |ui| crate::dock::panel_body(self.app, ui, panel, width, height));
}
}
fn panel_menu(&mut self, ui: &mut egui::Ui, panel: &String) {
crate::panels::panel_menu_items(self.app, ui, panel);
}
fn group_header_width(&self, _panels: &[String], _active: &String, floating: bool) -> f32 {
if floating { 48.0 } else { 80.0 }
}
fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[String], active: &String, floating: bool) {
let rect = ui.max_rect();
let menu = egui::Rect::from_center_size(rect.right_center() - egui::vec2(12.0, 0.0), egui::vec2(16.0, 16.0));
crate::panels::panel_menu(self.app, ui, active, menu);
let grip = egui::Rect::from_min_max(rect.min, egui::pos2(menu.left() - 4.0, rect.bottom()));
if floating {
let response = ui.put(grip, egui::Button::new("×")).on_hover_text(tl!("Dock Panel"));
if response.clicked() {
self.commands.push(("window.panel.dock", json!({"panel": active, "group": true})));
}
} else {
let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::drag());
if response.drag_started()
&& let Some(pos) = response.interact_pointer_pos()
{
ui.ctx().data_mut(|data| {
data.insert_temp(
egui::Id::new("vectorcraft-root-group-drag"),
RootGroupDrag {
panel: active.clone(),
layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)),
hidden: self.app.ui.docking_hidden.clone(),
generation: self.app.ui.docking_generation,
},
)
});
self.commands.push(("window.panel.float", json!({"panel": active, "group": true, "x": pos.x, "y": pos.y})));
}
response.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
}
}
}
pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
let root_key = egui::Id::new("vectorcraft-root-group-drag");
if let Some(drag) = ui.ctx().data(|data| data.get_temp::<RootGroupDrag>(root_key)) {
if drag.generation != app.ui.docking_generation {
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
} else if ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
app.ui.docking = Some(drag.layout);
app.ui.docking_hidden = drag.hidden;
sync_legacy(app);
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
} else if let Some(pos) = ui.input(|input| input.pointer.interact_pos()) {
if ui.input(|input| input.pointer.primary_down()) {
if let Err(error) = app.run("window.panel.float", json!({"panel":drag.panel, "group":true, "x":pos.x, "y":pos.y})) {
app.ui.status = error;
}
if let Some(panel) = normalize(&drag.panel)
&& let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
crate::floating::preview(ui.ctx(), target, rect);
}
} else {
if let Some(panel) = normalize(&drag.panel)
&& let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
let (id, params) = match target {
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})),
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})),
crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"below":onto})),
crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"above":onto})),
crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":true,"column":slot})),
};
if let Err(error) = app.run(id, params) {
app.ui.status = error;
}
}
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
}
}
}
let area = egui::Id::new("vectorcraft-panel-docking");
let changed_workspace = ui.ctx().data_mut(|data| {
let previous = data.get_temp::<u64>(area.with("generation"));
data.insert_temp(area.with("generation"), app.ui.docking_generation);
previous.is_some_and(|generation| generation != app.ui.docking_generation)
});
if changed_workspace {
craft_ui::docking::cancel_drag::<String>(ui.ctx(), area);
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(area.with("legacy-origin")));
}
let cancelled =
ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released()));
if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() {
return false;
}
let Some(layout) = app.ui.docking.as_ref() else { return false };
if !valid(layout) {
app.ui.docking = None;
app.ui.status = "Invalid panel layout; restored the default dock".into();
return false;
}
let layout = layout.clone();
let t = crate::theme::Tokens::get(ui.ctx());
let collapsed = app.ui.dock_collapsed;
let mut display = if collapsed { Layout { root: None, floating: layout.floating.clone() } } else { layout.clone() };
display
.floating
.retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id))));
let mut draw = |ui: &mut egui::Ui| {
if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) {
let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed}));
}
if collapsed {
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
for panel in root.panels() {
let info = crate::state::DockTab::from_id(panel)
.map(|tab| tab.info())
.or_else(|| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == panel).copied());
if let Some((_, label, icon)) = info
&& crate::widgets::icon_button(ui, icon, tl!(label), app.ui.open_panel.as_ref() == Some(panel), 30.0).clicked()
{
app.ui.open_panel = if app.ui.open_panel.as_ref() == Some(panel) { None } else { Some(panel.clone()) };
}
}
}
let mut style = craft_ui::docking::DockStyle::from_ui(ui);
style.tab_height = 33.0;
style.background = t.panel;
style.tab_background = t.tab_strip;
style.active_background = t.panel;
style.text = t.text_strong;
style.inactive_text = t.text_dim;
style.border = egui::Stroke::new(1.0, t.border);
style.accent = t.accent;
style.float_label = tl!("Float Panel").to_string();
style.close_label = tl!("Close Panel").to_string();
style.move_label = tl!("Group with").to_string();
style.panels_label = tl!("Panels").to_string();
style.resize_label = tl!("Resize panels").to_string();
style.resize_window_label = tl!("Resize panel window").to_string();
let mut content = PanelContent { app, commands: Vec::new() };
let output = craft_ui::docking::DockArea::new(egui::Id::new("vectorcraft-panel-docking")).show_customized(
ui,
&display,
&style,
|id| crate::state::all_panels().find(|p| p.0 == id).map(|p| tl!(p.1).to_string()).unwrap_or_else(|| id.clone()),
|_| craft_ui::docking::Permissions::default(),
|_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() },
&mut content,
);
for (id, params) in content.commands {
if let Err(error) = app.run(id, params) {
app.ui.status = error;
}
}
output
};
let output = if layout.root.is_some() {
let panel = egui::Panel::right("shared-panel-dock")
.default_size(310.0)
.size_range(220.0..=720.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel));
let panel = if collapsed { panel.exact_size(38.0).resizable(false) } else { panel };
let shown = panel.show(ui, &mut draw);
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::dock_rect_id(), shown.response.rect));
shown.inner
} else {
draw(ui)
};
if let Some(panel) = output.group_drag.as_deref().and_then(normalize)
&& let Some(pos) = ui.input(|input| input.pointer.interact_pos())
&& let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
crate::floating::preview(ui.ctx(), target, rect);
}
if let Some(error) = output.error {
app.ui.status = error.to_string();
}
// Tab and icon tears finish as model actions; title moves finish as group_drop.
// Native sets are absent from the shared render tree, so their pointer target must win
// over a float proposal or a root target drawn behind the native window.
let tab_drop = ui.input(|input| input.pointer.primary_released().then(|| input.pointer.interact_pos()).flatten()).and_then(|pos| {
output.actions.iter().find_map(|action| {
let panel = match action {
Action::Float { panel, .. } | Action::Move { panel, .. } => normalize(panel)?,
_ => return None,
};
crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
.filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock))
.map(|(target, _)| (panel, target))
})
});
if ui.input(|input| input.pointer.any_released()) {
let native_drop = output.group_drop.as_ref().is_some_and(|(panel, pos)| {
normalize(panel).and_then(|id| crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(id), *pos)).is_some()
});
if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. }))
&& !native_drop
&& tab_drop.is_none()
&& cancel_legacy_drag(app, ui.ctx())
{
return app.ui.docking.is_some();
}
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin")));
}
for action in output.actions {
if tab_drop
.is_some_and(|(panel, _)| matches!(&action, Action::Float { panel: moved, .. } | Action::Move { panel: moved, .. } if moved == panel))
{
continue;
}
if let Err(error) = app.run("window.panel.layout", json!({"action": action})) {
app.ui.status = error;
}
}
let group_drop = output.group_drop.and_then(|(panel, pos)| {
let panel = normalize(&panel)?;
crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos).map(|(target, _)| (panel, target, true))
});
if let Some((panel, target, whole_group)) = group_drop.or_else(|| tab_drop.map(|(panel, target)| (panel, target, false))) {
let (command, params) = match target {
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})),
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})),
crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"below":onto})),
crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"above":onto})),
crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":whole_group,"column":slot})),
};
// Shared and native windows have already chosen their render ownership for this
// frame. Switch representations only after both renderers finish, so a panel's
// widgets never appear in two layers in the same frame.
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("vectorcraft-native-panel-drop"), (command, params)));
}
for group in &display.floating {
if let Some(first) = group.panels.first()
&& let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(area.with(("floating", first))))
{
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect));
}
}
icon_rail(app, ui);
crate::floating::docked_columns(app, ui);
true
}
fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let column = egui::Panel::right("shared-panel-icons")
.exact_size(38.0)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel))
.show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
for &(id, label, icon) in crate::state::ICON_PANEL_GROUPS
.iter()
.flat_map(|group| group.iter())
.filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id))
{
if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) {
continue;
}
let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0));
let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag());
let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), app.ui.open_panel.as_deref() == Some(id));
if response.clicked() {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) };
}
legacy_tab(app, ui, id, &response);
}
});
})
.response
.rect;
ui.ctx().data_mut(|data| {
let dock = if app.ui.docking.as_ref().is_some_and(|layout| layout.root.is_some()) {
data.get_temp::<egui::Rect>(crate::floating::dock_rect_id()).unwrap_or(column)
} else {
column
};
data.insert_temp(crate::floating::dock_rect_id(), dock.union(column));
data.insert_temp(crate::floating::icons_rect_id(), column);
data.insert_temp(egui::Id::new("dock-icon-column-left"), column.left());
});
}
pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
let Some(panel) = normalize(panel) else { return };
response.context_menu(|ui| {
if ui.button(tl!("Float Panel")).clicked() {
if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) {
app.ui.status = error;
}
ui.close();
}
ui.menu_button(tl!("Group with"), |ui| {
for (anchor, label) in crate::state::all_panels() {
if anchor != panel && ui.button(tl!(label)).clicked() {
// A closed icon panel is made visible before becoming the destination.
let result = app
.run("window.panel.activate", json!({"panel": anchor}))
.and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor})));
if let Err(error) = result {
app.ui.status = error;
}
ui.close();
}
}
});
});
if response.drag_started()
&& let Some(id) = normalize(panel)
{
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let bounds = ui.max_rect();
let source = egui::Rect::from_min_size(
egui::pos2(bounds.left(), response.rect.top()),
egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)),
);
if ensure(&mut layout, id).is_ok()
&& craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("vectorcraft-panel-docking"), id.to_string(), source)
{
ui.ctx().data_mut(|data| {
data.insert_temp(
egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"),
LegacyDrag {
layout: app.ui.docking.clone(),
open_panel: app.ui.open_panel.clone(),
floating: app.ui.floating_panels.clone(),
icons: app.ui.docking_icons.clone(),
},
)
});
if !app.ui.docking.as_ref().unwrap_or(&legacy(app)).contains(&id.to_string())
&& crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id)
{
app.ui.docking_icons.insert(id.into());
}
app.ui.docking = Some(layout);
app.ui.open_panel = None;
}
}
}
// Keep the legacy inspection schema useful; shared layout remains the rendering authority.
pub(crate) fn decorated(group: &crate::state::FloatingPanels) -> bool {
group.column.is_some() || group.docked.is_some() || group.collapsed
}
/// Keep the native set metadata alongside the validated shared ownership tree. Return locations
/// and root splits survive the mirror update; failed reconciliation restores the whole UI state.
pub(crate) fn reconcile_native(app: &mut VectorcraftApp, before: crate::state::UiState) -> Result<(), String> {
if before.docking.is_none() {
return Ok(());
}
let desired = app.ui.floating_panels.clone();
let result = (|| {
let prior = before.docking.as_ref().ok_or("missing panel layout")?;
app.ui.docking = Some(prior.clone());
for group in &prior.floating {
for panel in &group.panels {
if !desired.iter().any(|g| g.panels.contains(panel)) {
change(app, "dock", &json!({"panel":panel}))?;
}
}
}
let mut layout = app.ui.docking.clone().ok_or("missing panel layout")?;
for group in &desired {
for panel in &group.panels {
if (Layout { root: layout.root.clone(), floating: vec![] }).contains(panel) {
if let Ok(location) = layout.location(panel) {
app.ui.docking_hidden.insert(panel.clone(), location);
}
layout.apply(Action::Close { panel: panel.clone() }).map_err(|e| e.to_string())?;
}
}
}
layout.floating = desired
.iter()
.map(|group| {
let height = prior.floating.iter().find(|g| g.panels.iter().any(|id| group.panels.contains(id))).map_or(400.0, |g| g.rect[3]);
Floating {
panels: group.panels.clone(),
active: group.active,
rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0), height],
}
})
.collect();
if !valid(&layout) {
return Err("invalid native panel set".into());
}
app.ui.docking = Some(layout);
app.ui.floating_panels = desired;
Ok(())
})();
if result.is_err() {
app.ui = before;
}
result
}
fn native_change(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result<Value, String> {
let before = app.ui.clone();
let result = (|| {
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
if crate::floating::group_of(&app.ui, panel).is_none() {
let mut initial = params.clone();
if let Some(object) = initial.as_object_mut() {
for key in ["above", "below", "column", "collapsed", "onto"] {
object.remove(key);
}
}
change(app, "float", &initial)?;
}
let baseline = app.ui.clone();
let value = crate::floating::command(app, id, params).ok_or("unsupported native docking operation")??;
reconcile_native(app, baseline)?;
Ok(value)
})();
if result.is_err() {
app.ui = before;
}
result
}
fn sync_legacy(app: &mut VectorcraftApp) {
if let Some(layout) = &app.ui.docking {
let old = app.ui.floating_panels.clone();
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, active } => {
if let Some(tab) = panels.get(*active).and_then(|id| crate::state::DockTab::from_id(id)) {
app.ui.dock_tab = tab;
}
}
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
_ => {}
}
}
app.ui.floating_panels = layout
.floating
.iter()
.map(|group| crate::state::FloatingPanels {
panels: group.panels.clone(),
active: group.active,
pos: [group.rect[0], group.rect[1]],
width: Some(group.rect[2]),
column: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.column),
collapsed: old
.iter()
.find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first)))
.is_some_and(|g| g.collapsed),
docked: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.docked),
})
.collect();
}
}
#[cfg(test)]
#[path = "panel_docking_tests.rs"]
mod tests;
/// Commit a pointer drop after shared and native panel windows have finished rendering.
pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) {
if let Some((command, params)) =
ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop")))
&& let Err(error) = app.run(command, params)
{
app.ui.status = error;
}
}
+576
View File
@@ -0,0 +1,576 @@
use super::*;
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
fn app() -> VectorcraftApp {
VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default())
}
#[test]
fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap();
let before_float = app.ui.docking.clone().unwrap();
app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"]));
app.run("window.panel.dock", json!({"panel":"properties"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
app.run("window.panel.close", json!({"panel":"properties"})).unwrap();
assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into()));
app.run("window.panel.activate", json!({"panel":"properties"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
let layout = app.ui.docking.as_ref().unwrap();
assert!(valid(layout));
let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect();
assert_eq!(unique.len(), layout.panels().len());
let saved = serde_json::to_value(&app.ui).unwrap();
let restored: crate::state::UiState = serde_json::from_value(saved).unwrap();
assert_eq!(restored.docking, app.ui.docking);
assert_eq!(restored.docking_hidden, app.ui.docking_hidden);
}
#[test]
fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() {
let mut app = app();
for (id, params) in [
("window.panel.move", json!({"panel":"layers","anchor":"unknown"})),
("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})),
("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})),
("window.panel.float", json!({"panel":"layers","width":-10})),
("window.panel.float", json!({"panel":"unknown"})),
("window.panel.float", json!({"panel":"layers","x":"invalid"})),
] {
let old = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
assert!(app.run(id, params).is_err(), "{id}");
assert_eq!(app.ui.docking, old);
assert_eq!(app.ui.docking_hidden, hidden);
}
}
fn harness(app: VectorcraftApp, size: egui::Vec2) -> Harness<'static, VectorcraftApp> {
let mut ready = false;
let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
egui_extras::install_image_loaders(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark);
ready = true;
return;
}
crate::floating::track(app, ui.ctx());
crate::dock::show(app, ui);
egui::CentralPanel::default().show(ui, |_| {});
crate::floating::show(app, ui.ctx());
},
app,
);
harness.run_steps(4);
harness
}
fn drag(h: &mut Harness<'static, VectorcraftApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerMoved(from));
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for step in 1..=8 {
h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0)));
h.run_steps(1);
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
drag(&mut h, source, egui::pos2(160.0, 140.0));
let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking");
assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}");
let source = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
let destination = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
drag(&mut h, source, destination);
let layout = h.state().ui.docking.as_ref().unwrap();
assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into())));
assert!(valid(layout));
}
#[test]
fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap();
app.run("window.panel.close", json!({"panel":"swatches"})).unwrap();
let layout = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
app.run("window.workspace.new", json!({"name":"Custom docking"})).unwrap();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
assert!(app.ui.docking.is_none());
app.run("window.workspace", json!({"name":"Custom docking"})).unwrap();
assert_eq!(app.ui.docking, layout);
assert_eq!(app.ui.docking_hidden, hidden);
}
#[test]
fn legacy_float_group_aliases_use_atomic_shared_groups_and_preserve_tools() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers","group":true,"x":30,"y":50})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 3);
app.run("window.panel.float", json!({"panel":"swatches","onto":"layers"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 4);
let before = app.ui.docking.clone();
assert!(app.run("window.panel.float", json!({"panel":"properties","onto":"missing","group":true})).is_err());
assert_eq!(app.ui.docking, before);
app.run("window.panel.float", json!({"panel":"tools","x":70,"y":90})).unwrap();
assert_eq!(app.ui.toolbar_pos, Some([70.0, 90.0]));
assert_eq!(app.ui.docking, before);
app.run("window.panel.dock", json!({"panel":"layers","group":true})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().floating.is_empty());
}
/// Explicit offscreen evidence; this creates no native windows.
#[test]
fn capture_custom_panel_docking_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for theme in crate::theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap();
let mut ready = false;
let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), theme);
ready = true;
return;
}
crate::dock::show(app, ui);
egui::CentralPanel::default().show(ui, |ui| {
ui.label("Custom panel workspace");
});
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(5);
assert!(valid(harness.state().ui.docking.as_ref().unwrap()));
harness.render().unwrap().save(directory.join(format!("vectorcraft-shared-docking-{}-{width}-{scale}x.png", theme.id()))).unwrap();
}
}
}
}
#[test]
fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
assert!(h.state().ui.docking.is_some());
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
assert!(h.state().ui.docking.is_none());
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(h.state().ui.docking.is_none());
}
#[test]
fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap();
assert!(
matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001)
);
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap();
assert_eq!(
app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect,
[44.0, 66.0, 360.0, 450.0]
);
app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}}))
.unwrap();
assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]);
let before = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
for action in [
json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}),
json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}),
json!({"Open":{"panel":"unknown","anchor":null}}),
json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}),
] {
assert!(app.run("window.panel.layout", json!({"action":action})).is_err());
assert_eq!(app.ui.docking, before);
assert_eq!(app.ui.docking_hidden, hidden);
}
app.ui.docking = Some(Layout {
root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }),
floating: Vec::new(),
});
app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap();
app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap();
let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") };
assert!(!entries[0].open);
assert_eq!(entries[0].height, Some(150.0));
}
fn group_members_for_test(layout: &Layout<String>, panel: &str) -> Vec<String> {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(),
Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]),
_ => {}
}
}
Vec::new()
}
#[test]
fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() {
let mut app = app();
app.run("file.new", json!({"width":480,"height":360})).unwrap();
let id = app.run("shape.rectangle", json!({"x":80,"y":70,"width":250,"height":160})).unwrap()["id"].clone();
app.run("select.set", json!({"ids":[id]})).unwrap();
app.run("paint.setFill", json!({"color":"#5297bb"})).unwrap();
app.run("stroke.set", json!({"weight":8})).unwrap();
app.run("window.panel.float", json!({"panel":"stroke","x":40,"y":140,"width":310,"height":490})).unwrap();
let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from);
let builder = Harness::builder().with_size(vec2(1280.0, 800.0));
let builder = if directory.is_some() { builder.wgpu() } else { builder };
let mut h = builder.build_ui_state(
|ui, app: &mut VectorcraftApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.input_mut().max_texture_side = Some(8192);
h.run_steps(5);
let mut pending: Vec<_> = h.output().shapes.iter().map(|shape| &shape.shape).collect();
let mut checkbox = None;
while let Some(shape) = pending.pop() {
match shape {
egui::Shape::Text(text) if text.galley.text() == "Dashed Line" => {
checkbox = Some(text.visual_bounding_rect().center());
break;
}
egui::Shape::Vec(shapes) => pending.extend(shapes),
_ => {}
}
}
let at = checkbox.expect("painted Dashed Line checkbox");
h.hover_at(at);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
let dash = crate::panels::current_stroke(h.state()).unwrap().dash.unwrap();
assert_eq!(dash.pattern, [12.0, 12.0]);
if let Some(directory) = &directory {
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-floating.png")).unwrap();
}
let from = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center();
let to = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
drag(&mut h, from, to);
assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty());
assert_eq!(crate::panels::current_stroke(h.state()).unwrap().dash.unwrap(), dash);
if let Some(directory) = &directory {
h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-redocked.png")).unwrap();
}
}
#[test]
fn changing_workspace_during_a_drag_cancels_the_stale_gesture() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let destination = app.ui.docking.clone();
app.run("window.workspace.new", json!({"name":"Destination"})).unwrap();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap();
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
assert_eq!(h.state().ui.docking, destination);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.docking, destination);
}
#[test]
fn floating_icon_groups_redock_to_the_rail_and_keep_saved_origins() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"color"})).unwrap();
app.run("window.panel.float", json!({"panel":"swatches", "onto":"color"})).unwrap();
assert_eq!(app.ui.docking_icons.len(), 2);
let workspace = crate::workspaces::capture(&app.ui, "Icon origins");
let saved = serde_json::to_vec(&workspace).unwrap();
let restored: crate::workspaces::Workspace = serde_json::from_slice(&saved).unwrap();
crate::workspaces::apply(&mut app.ui, &restored);
app.run("window.panel.dock", json!({"panel":"color", "group":true})).unwrap();
let layout = app.ui.docking.as_ref().unwrap();
assert!(!layout.contains(&"color".into()));
assert!(!layout.contains(&"swatches".into()));
assert!(app.ui.floating_panels.is_empty());
assert_eq!(app.ui.open_panel.as_deref(), Some("color"), "the requested icon pops out after redocking");
app.run("window.panel", json!({"panel":"color"})).unwrap();
assert!(app.ui.open_panel.is_none(), "the same icon puts it away");
app.run("window.panel", json!({"panel":"color"})).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("color"));
}
#[test]
fn all_dock_group_origins_survive_float_and_return() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers", "group":true})).unwrap();
assert_eq!(app.ui.floating_panels[0].panels.len(), 3);
assert!(app.ui.docking_hidden.contains_key("properties"));
assert!(app.ui.docking_hidden.contains_key("layers"));
assert!(app.ui.docking_hidden.contains_key("libraries"));
app.run("window.panel.dock", json!({"panel":"layers", "group":true})).unwrap();
let layout = app.ui.docking.as_ref().unwrap();
assert!(layout.floating.is_empty());
assert_eq!(layout.panels().into_iter().map(String::as_str).collect::<Vec<_>>(), ["properties", "layers", "libraries"]);
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
assert!(valid(layout));
}
#[test]
fn panel_specific_menu_executes_after_first_float_regroup_and_reload() {
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":100})).unwrap();
let mut h = harness(app, vec2(1280.0, 900.0));
for stage in 0..3 {
if stage == 1 {
h.state_mut().run("window.panel.float", json!({"panel":"color","x":60,"y":120})).unwrap();
h.state_mut().run("window.panel.float", json!({"panel":"swatches","onto":"color"})).unwrap();
} else if stage == 2 {
let saved = serde_json::to_vec(&h.state().ui).unwrap();
h.state_mut().ui = serde_json::from_slice(&saved).unwrap();
}
h.run_steps(4);
let at = h
.query_all_by_label("Panel menu")
.find(|node| node.rect().center().x < 800.0)
.expect("floating panel menu has an accessible button")
.rect()
.center();
h.hover_at(at);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
let action = h.query_all_by_label(" New Swatch…").next().expect("app-specific menu action remains reachable").rect().center();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: action, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
assert_eq!(h.state().ui.dialog.as_ref().map(|dialog| dialog.kind.as_str()), Some("newSwatch"), "stage {stage}: actual panel action executes");
h.state_mut().ui.dialog = None;
}
}
#[test]
fn iconic_origin_tracks_explicit_custom_dock_and_legacy_saved_floats() {
let mut custom = app();
custom.run("window.panel.move", json!({"panel":"color","anchor":"layers"})).unwrap();
let before = custom.ui.docking.clone().unwrap();
custom.run("window.panel.float", json!({"panel":"color"})).unwrap();
custom.run("window.panel.dock", json!({"panel":"color"})).unwrap();
assert_eq!(custom.ui.docking.as_ref(), Some(&before), "explicit custom tabs are the saved destination");
assert!(custom.ui.open_panel.is_none(), "a docked custom tab is not an icon popout");
let mut legacy = app();
crate::floating::float(&mut legacy.ui, &["color"], "color", egui::pos2(120.0, 160.0));
let saved = serde_json::to_vec(&legacy.ui).unwrap();
legacy.ui = serde_json::from_slice(&saved).unwrap();
assert!(legacy.ui.docking.is_none());
legacy.run("window.panel.dock", json!({"panel":"color"})).unwrap();
assert!(!legacy.ui.docking.as_ref().unwrap().contains(&"color".into()), "legacy icon floats return to rail");
assert_eq!(legacy.ui.open_panel.as_deref(), Some("color"));
}
#[test]
fn native_sets_columns_and_collapse_preserve_shared_splits_and_saved_origins() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let original = app.ui.docking.clone().unwrap();
app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap();
app.run("window.panel.float", json!({"panel":"color","below":"stroke","collapsed":true})).unwrap();
let root = app.ui.docking.as_ref().unwrap().root.clone();
assert_eq!(root, original.root);
let column = app.ui.floating_panels.first().unwrap().column;
assert!(column.is_some());
assert_eq!(app.ui.floating_panels.len(), 2);
assert!(app.ui.floating_panels.iter().all(|g| g.column == column));
app.run("window.panel.dock", json!({"panel":"stroke","group":true,"column":true})).unwrap();
assert!(app.ui.floating_panels.iter().all(|g| g.docked == Some(1)));
app.run("window.workspace.new", json!({"name":"Mixed shared native"})).unwrap();
let saved = app.ui.floating_panels.clone();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
app.run("window.workspace", json!({"name":"Mixed shared native"})).unwrap();
assert_eq!(app.ui.floating_panels, saved);
assert_eq!(app.ui.docking.as_ref().unwrap().root, root);
app.run("window.panel.float", json!({"panel":"stroke","group":true})).unwrap();
assert!(app.ui.floating_panels.iter().all(|g| g.docked.is_none()));
let before = serde_json::to_value(&app.ui).unwrap();
assert!(app.run("window.panel.float", json!({"panel":"stroke","below":"unknown"})).is_err());
// Error reporting may change status, but ownership, metadata and root remain atomic.
let mut after = serde_json::to_value(&app.ui).unwrap();
let mut before = before;
before.as_object_mut().unwrap().remove("status");
after.as_object_mut().unwrap().remove("status");
assert_eq!(before, after);
app.run("window.panel.dock", json!({"panel":"stroke","group":true})).unwrap();
app.run("window.panel.dock", json!({"panel":"color","group":true})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().root, original.root);
assert!(app.ui.floating_panels.is_empty());
assert!(valid(app.ui.docking.as_ref().unwrap()));
}
#[test]
fn native_set_close_pointer_restores_shared_split_ownership() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let root = app.ui.docking.as_ref().unwrap().root.clone();
app.run("window.panel.float", json!({"panel":"stroke","x":70,"y":90})).unwrap();
app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap();
let mut h = harness(app, vec2(1280.0, 900.0));
if let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR") {
let directory = std::path::PathBuf::from(directory);
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(directory.join("vectorcraft-native-set-shared-split.png")).unwrap();
}
let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap();
let at = egui::pos2(area.right() - 10.0, area.top() + 1.0 + crate::floating::TITLE / 2.0);
for pressed in [true, false] {
h.event(Event::PointerMoved(at));
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(3);
assert!(h.state().ui.floating_panels.is_empty());
assert_eq!(h.state().ui.docking.as_ref().unwrap().root, root);
assert!(valid(h.state().ui.docking.as_ref().unwrap()));
}
#[test]
fn a_single_native_tab_redock_retains_the_remaining_float_and_return_origin() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let root = app.ui.docking.as_ref().unwrap().root.clone();
app.run("window.panel.float", json!({"panel":"swatches","x":80,"y":90})).unwrap();
app.run("window.panel.float", json!({"panel":"color","onto":"swatches","collapsed":true})).unwrap();
assert!(
matches!(app.ui.docking.as_ref().unwrap().root.as_ref(), Some(Node::Tabs { panels, active }) if panels.get(*active).is_some_and(|panel| panel == "properties")),
"floating Color intentionally activates the first root fallback, Properties"
);
let mut expected = Layout { root, floating: vec![] };
expected.apply(Action::Activate { panel: "properties".to_string() }).unwrap();
app.run("window.panel.dock", json!({"panel":"swatches"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().root, expected.root);
assert_eq!(app.ui.floating_panels.len(), 1);
assert_eq!(app.ui.floating_panels[0].panels, ["color"]);
assert!(valid(app.ui.docking.as_ref().unwrap()));
}
#[test]
fn explicit_shared_activation_expands_a_native_collapsed_owner() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers","collapsed":true})).unwrap();
assert!(app.ui.floating_panels[0].collapsed);
app.run("window.panel.layout", json!({"action":Action::Activate { panel:"layers".to_string() }})).unwrap();
assert!(!app.ui.floating_panels[0].collapsed);
assert_eq!(app.ui.floating_panels[0].panels, ["layers"]);
assert!(valid(app.ui.docking.as_ref().unwrap()));
}
#[test]
fn escape_restores_native_set_position_and_metadata_after_several_frames() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"stroke","x":80,"y":100})).unwrap();
app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap();
let mut h = harness(app, vec2(1280.0, 900.0));
let original = h.state().ui.floating_panels.clone();
let layout = h.state().ui.docking.clone();
let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap();
let from = area.left_top() + vec2(30.0, 1.0 + crate::floating::TITLE / 2.0);
h.event(Event::PointerMoved(from));
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for step in 1..=8 {
h.event(Event::PointerMoved(from + vec2(step as f32 * 12.0, step as f32 * 9.0)));
h.run_steps(1);
}
assert_ne!(h.state().ui.floating_panels, original, "actual title gesture moved the set");
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.floating_panels, original);
assert_eq!(h.state().ui.docking, layout);
}
#[test]
fn first_legacy_icon_tear_can_join_a_native_set() {
let mut app = app();
crate::floating::float(&mut app.ui, &["stroke"], "stroke", egui::pos2(100.0, 100.0));
crate::floating::float(&mut app.ui, &["color"], "color", egui::pos2(100.0, 100.0));
crate::floating::attach(&mut app.ui, &[1], "stroke", true);
assert!(app.ui.docking.is_none());
let mut h = harness(app, vec2(1280.0, 900.0));
let source = h.query_all_by_label("Swatches").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
let color = h.ctx.data(|data| data.get_temp::<egui::Rect>(crate::floating::group_rect_id("color"))).unwrap();
drag(&mut h, source, egui::pos2(color.center().x, color.bottom() + 2.0));
assert!(h.state().ui.docking.is_some(), "successful first tear retains shared ownership");
let groups = &h.state().ui.floating_panels;
let color = groups.iter().find(|g| g.panels.iter().any(|p| p == "color")).unwrap();
let swatches = groups.iter().find(|g| g.panels.iter().any(|p| p == "swatches")).unwrap();
assert!(color.column.is_some());
assert_eq!(swatches.column, color.column);
assert!(valid(h.state().ui.docking.as_ref().unwrap()));
}
#[test]
fn native_set_drop_targets_respect_the_visible_layer_above_an_ordinary_float() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"swatches","x":100,"y":100})).unwrap();
app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap();
app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap();
let h = harness(app, vec2(1280.0, 900.0));
let rect = h.ctx.data(|data| data.get_temp::<egui::Rect>(crate::floating::group_rect_id("stroke"))).unwrap();
let at = rect.center();
assert_eq!(h.ctx.layer_id_at(at).unwrap().id, crate::floating::area_id("stroke"), "native set is visibly above the ordinary shared float");
let target = crate::floating::drop_target(h.state(), &h.ctx, crate::floating::Moving::Panel("layers"), at).unwrap().0;
assert!(
matches!(target, crate::floating::Drop::Above("stroke") | crate::floating::Drop::Below("stroke")),
"visible native group owns the drop: {target:?}"
);
}
+43
View File
@@ -143,6 +143,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn defaults_are_valid_commands() {
@@ -154,4 +155,46 @@ mod tests {
}
}
}
/// Actions recorded with Align to Artboard and Paste in Place, and the default Center on
/// Artboard, play on the artboard active when they play, not on the one active when they were
/// recorded.
#[test]
fn actions_play_on_the_artboard_active_when_they_play() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let r = app.run("shape.rectangle", json!({"x": boards[1].x0 + 10.0, "y": boards[1].y0 + 15.0, "width": 20, "height": 20})).unwrap();
let rect = vectorcraft_doc::NodeId(r["id"].as_u64().unwrap());
let bounds = |app: &VectorcraftApp, ids: &[vectorcraft_doc::NodeId]| app.session.active().unwrap().doc.bounds_of(ids, false).unwrap();
// Each recorded as the panel records an action, with artboard 0 active.
let record = |app: &mut VectorcraftApp, name: &str, id: &str, params: Value| {
let start = app.session.journal.len();
app.run(id, params).unwrap();
let steps = app.session.journal_for_action(start);
app.ui.action_sets[0].actions.push(Action { name: name.into(), steps });
app.ui.action_sets[0].actions.len() - 1
};
app.run("edit.copy", json!({})).unwrap();
let align = record(&mut app, "Align Left", "object.align", json!({"horizontal": "left", "to": "artboard"}));
assert_eq!(bounds(&app, &[rect]).x0, boards[0].x0, "aligned to artboard 0 while recording");
let paste = record(&mut app, "Paste in Place", "edit.pasteInPlace", json!({}));
app.view_mut().unwrap().artboard = 2;
app.run("select.set", json!({"ids": [rect.0]})).unwrap();
play(&mut app, 0, align).unwrap();
assert_eq!(bounds(&app, &[rect]).x0, boards[2].x0, "aligned to artboard 2, active when the action plays");
let ids: Vec<vectorcraft_doc::NodeId> = play(&mut app, 0, paste).unwrap()["results"][0]["ids"]
.as_array()
.unwrap()
.iter()
.map(|v| vectorcraft_doc::NodeId(v.as_u64().unwrap()))
.collect();
let b = bounds(&app, &ids);
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0), "pasted onto artboard 2, where it was on artboard 1");
app.view_mut().unwrap().artboard = 1;
app.run("select.set", json!({"ids": [rect.0]})).unwrap();
let center = app.ui.action_sets[0].actions.iter().position(|a| a.name == "Center on Artboard").unwrap();
play(&mut app, 0, center).unwrap();
assert_eq!(bounds(&app, &[rect]).center(), boards[1].center(), "centered on artboard 1, the active one");
}
}
+42 -2
View File
@@ -29,7 +29,7 @@ impl AlignTo {
/// `object.align` params: a single object aligns to the artboard (Illustrator does the same).
pub fn align_params(base: Value, to: AlignTo, n_selected: usize) -> Value {
let mut p = base;
let to = if n_selected == 1 && to == AlignTo::Selection { AlignTo::Artboard } else { to };
let to = if n_selected == 1 { AlignTo::Artboard } else { to };
p["to"] = json!(to.param());
p
}
@@ -184,7 +184,47 @@ mod tests {
assert_eq!(p["to"], "artboard");
let p = align_params(json!({"vertical": "top"}), AlignTo::Selection, 3);
assert_eq!(p["to"], "selection");
assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key");
assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "artboard");
}
/// A Shift-click that leaves the key object selected on its own lets the key go. With Align to
/// Key Object chosen, the align buttons of the Control bar and the Properties panel, and then
/// the Align panel's, align that object to the artboard.
#[test]
fn a_lone_object_aligns_to_the_artboard() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
let ctx = egui::Context::default();
set_pstate(&ctx, "align-to", AlignTo::Key);
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("select.key", json!({"id": b})).unwrap();
app.run("select.toggle", json!({"id": a})).unwrap();
let id = vectorcraft_doc::NodeId(b.as_u64().unwrap());
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
// `align_buttons` (the Control bar and the Properties panel): one frame finds the first
// button, and the next clicks Horizontal Align Right, the third.
let at = std::cell::Cell::new(egui::Pos2::ZERO);
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
ui.horizontal(|ui| {
at.set(ui.cursor().min);
align_buttons(app, ui, 24.0);
});
});
out.textures_delta.clear();
};
frame(&mut app, vec![]);
let right = at.get() + egui::vec2(2.0 * (24.0 + ctx.global_style().spacing.item_spacing.x) + 12.0, 12.0);
let press = |pressed| egui::Event::PointerButton { pos: right, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(right), press(true)]);
frame(&mut app, vec![press(false)]);
assert_eq!(bounds(&app).x1, 200.0, "the artboard's right edge");
// The Align panel's Horizontal Align Left.
let p = align_params(json!({"horizontal": "left", "bounds": "geometric"}), align_to(&app, &ctx), selection_len(&app));
app.run("object.align", p).unwrap();
assert_eq!(bounds(&app).x0, 0.0, "the artboard's left edge");
}
/// #541: while the selection has a key object Align aligns to it, whatever Align To says;
+1 -1
View File
@@ -655,7 +655,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
});
// Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`).
// Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`, `vpal`).
let proportional = s.as_ref().is_some_and(|s| s.proportional_metrics);
if menu_item(ui, tl!("Proportional Metrics"), has, proportional) {
format(app, json!({"proportionalMetrics": !proportional}));
+3 -1
View File
@@ -730,8 +730,10 @@ impl LibraryKind for GraphicStyleLibraries {
}
fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.separator();
// As a command, so its file dialog, shown off the UI thread, loads the library when it
// answers.
if menu_item(ui, tl!("Other Library…"), true, false)
&& let Err(e) = other_library(app, None)
&& let Err(e) = app.run("window.graphicStyleLibrary.other", json!({}))
{
app.status(e);
}
+1
View File
@@ -200,6 +200,7 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, ui.id().with(("panel-menu", id)), Sense::click());
icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panel menu")));
let resp = resp.on_hover_text(tl!("Panel menu"));
egui::Popup::menu(&resp).show(|ui| {
crate::widgets::menu_scroll(ui, |ui| {
+18
View File
@@ -28,6 +28,24 @@ fn format(app: &mut VectorcraftApp, p: Value) {
para_cmd(app, "text.setFormat", p);
}
/// The alignment shortcuts' commands (Cmd+Shift+L, C, R, J and F, as in page layout apps) and the
/// `justify` each sets ([`align`]).
pub const ALIGN_COMMANDS: [(&str, &str); 5] = [
("type.alignLeft", "left"),
("type.alignCenter", "center"),
("type.alignRight", "right"),
("type.justifyLeft", "justifyLeft"),
("type.justifyAll", "justifyAll"),
];
/// Align the paragraphs of the selected type, or those the Type tool's selection touches, as
/// `justify` (an [`ALIGN_COMMANDS`] alignment, #1111).
pub(crate) fn align(app: &mut VectorcraftApp, justify: &str) -> Result<Value, String> {
text_style(app).ok_or("select some type first")?;
para_cmd(app, "text.setStyle", json!({ "justify": justify }));
Ok(Value::Null)
}
fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
+41 -9
View File
@@ -356,13 +356,11 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
});
divider(ui);
section_header(ui, tl!("Snap Options"));
let mut sp = app.ui.view.snap_to_point;
if ui.checkbox(&mut sp, tl!("Snap to Point")).changed() {
app.ui.view.snap_to_point = sp;
if widgets::check(ui, tl!("Snap to Point"), app.ui.view.snap_to_point, true) {
app.ui.view.snap_to_point ^= true;
}
let mut sg = app.ui.view.snap_to_grid;
if ui.checkbox(&mut sg, tl!("Snap to Grid")).changed() {
app.ui.view.snap_to_grid = sg;
if widgets::check(ui, tl!("Snap to Grid"), app.ui.view.snap_to_grid, true) {
app.ui.view.snap_to_grid ^= true;
}
divider(ui);
section_header(ui, tl!("Preferences"));
@@ -373,9 +371,9 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
app.session.prefs.keyboard_increment = v.max(0.001);
}
});
let mut ss = app.session.prefs.scale_strokes;
if ui.checkbox(&mut ss, tl!("Scale Strokes & Effects")).changed() {
super::transform::set_pref(app, "scaleStrokes", ss);
let ss = app.session.prefs.scale_strokes;
if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) {
super::transform::set_pref(app, "scaleStrokes", !ss);
}
divider(ui);
section_header(ui, tl!("Quick Actions"));
@@ -584,6 +582,40 @@ mod tests {
frame(app, ctx, vec![b(false)]);
}
/// The Snap Options and Scale Strokes & Effects checkboxes keep a visible box at rest in every
/// theme, as the Preferences ones do (#1080), and toggle when clicked.
#[test]
fn the_document_checkboxes_show_their_boxes_and_toggle() {
for brightness in crate::theme::Brightness::ALL {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, brightness);
frame(&mut app, &ctx, vec![]);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui));
out.textures_delta.clear();
let boxes = out
.shapes
.iter()
.filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.stroke.width > 0.0 && (r.rect.width() - 13.0).abs() < 0.5 && (r.rect.height() - 13.0).abs() < 0.5))
.count();
assert!(boxes >= 3, "{brightness:?}: {boxes} checkbox outlines");
}
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = frame(&mut app, &ctx, vec![]);
let (grid, strokes) = (app.ui.view.snap_to_grid, app.session.prefs.scale_strokes);
click(&mut app, &ctx, &texts, "Snap to Grid");
assert_eq!(app.ui.view.snap_to_grid, !grid);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Scale Strokes & Effects");
assert_eq!(app.session.prefs.scale_strokes, !strokes);
}
/// #921: with the Gradient tool, a Gradient section: the type, and for a freeform gradient
/// Draw as Points or Lines radio buttons.
#[test]
+8 -8
View File
@@ -108,14 +108,14 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) {
run(app, "edit.colorSettings", json!({"bpc": !st.bpc}));
}
ui.horizontal(|ui| {
if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked()
&& let Some(path) = app
.services
.pick_open
.as_mut()
.and_then(|f| f(&crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() }))
{
run(app, "color.loadProfile", json!({ "path": path }));
if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked() {
// Its file dialog, shown off the UI thread, loads the profile when it answers.
crate::picks::button(app, |app| {
let pick = crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() };
if let Some(path) = crate::picks::open(app, &pick) {
run(app, "color.loadProfile", json!({ "path": path }));
}
});
}
if widgets::flat_button(ui, tl!("Gamut Check"), 100.0).clicked() {
match app.run("color.gamutCheck", json!({})) {
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more