Compare commits

..
Author SHA1 Message Date
andeecollardandClaude Opus 5.5 b7018a3370 docs: quick Image Trace presets trace at once; the Image Trace button opens the controls
The UI parity row (and G12 in gaps.md, M12 in roadmap.md) said quick Image
Trace presets reveal the editable controls. Since baadbe2c6 a quick trace
leaves the panel as it was (test a_quick_trace_leaves_the_panel_as_it_was)
and the Image Trace button in the Control bar or Properties opens the
controls. That matches the public documentation: choosing a preset traces
the image with it straight away, and the panel is opened with the Image
Trace button in the Control panel or the Properties panel's Image Trace
section
(https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html).

Reworded the three rows, cited the URL, added revision-history entries and
status-line notes. Wording only; scores, statuses and hours unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 13:20:25 +07:00
dotCooCoo ee61583606 Artboards: commands given an index that names no artboard fail instead of editing another one
artboard.setProps and artboard.fitToSelection read an `index` that is
negative, fractional, a string or null as 0 and edited the first
artboard, and artboard.setProps edited the first artboard when the
call named no `index`. artboard.duplicate, artboard.copy and
artboard.cut took the Artboard tool's artboard for such an `index`.
artboard.reorder failed for it with "missing index".

These commands now read `index` as artboard.delete, artboard.move and
artboard.fitToArt do, and fail with "no such artboard" when it names
no artboard; the document and the clipboard stay as they were.
artboard.setProps without `index` fails with "missing index". Without
`index`, artboard.fitToSelection still fits the first artboard and the
copy commands still take the Artboard tool's artboard.

Docs: docs/mcp.md.
2026-10-17 13:20:23 +07:00
NetterSilverandClaude Haiku 5.5 92647fa5b6 Quote graph data CSV labels so they round-trip through graph.setData
Found while testing crates/doc/src/graph.rs:429 (GraphSpec::to_csv) and crates/engine/src/cmd/graph.rs:85 (parse_csv)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:23 +07:00
dotCooCoo 1e3886c053 Document Raster Effects Settings: a file's resolution outside 1 to 2400 ppi opens in range
document.rasterEffectsSettings accepts a resolution from 1 to 2,400
ppi, but a file's raster_effects_ppi opened as it was saved. With 0,
-5 or 5000 in the file, object.rasterize without a ppi param failed
with "ppi must be between 1 and 2400", and document.rasterEffectsSettings
and document.info reported the stored value. With 0 or -5, EPS and
PDF export wrote a freeform gradient as a 1 x 1 px image, and EMF
export wrote every gradient as one. A saved New Document preset in the
preferences kept such a value too.

A file's or a saved preset's resolution outside 1 to 2,400 ppi is now
read as the nearer end of that range. Documents with a resolution in
range open as before.

Docs: docs/mcp.md (resolution, rasterEffectsPpi).
2026-10-17 13:20:23 +07:00
dotCooCoo 5eea7e4f66 Appearance: the inline effect editor's text fields keep what is typed
The Appearance panel's inline effect editor drew each text parameter
(such as Drop Shadow's color and Revolve's edge) as a text field over
a copy of the stored value made again in every frame. A character
typed in one frame was gone in the next, so the field changed a value
only when its new text and the Enter press arrived in the same frame,
as with a paste.

The field is now the panels' shared exact_text_field, which keeps the
text typed while it has the focus, as the number fields beside it do.
Enter or a click elsewhere sends the value with effect.setParams as
one undo step, and Escape drops it.
2026-10-17 13:20:23 +07:00
dotCooCoo 2e9870b1d0 Rotate View: a Space+Shift drag rotates the view from any tool
A drag with Space and Shift held panned the view, as a Space drag does.

With Space and Shift held and Cmd up, a drag on the canvas now turns
the view about the canvas center, as a Rotate View tool drag does,
whatever the tool. Shift does not round the angle to 15° as it does
with the Rotate View tool, and the drag keeps turning the view when
Shift is let go. Space alone still pans, and Cmd+Space still zooms
with or without Shift. The pointer shows the rotate cursor while Space
and Shift are held without Cmd, and during the drag. The Rotate View
tool also shows it now, where it showed the arrow.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo 951f7c2842 View: in Presentation Mode no key but ←, → and Escape acts, and no context menu opens
In Presentation Mode the canvas showed the active tool's cursor, a
right click selected the object under the pointer and opened the
context menu, and every key other than the arrows and Escape worked as
in Normal Screen Mode: tool keys, Delete, Enter, Tab, F and the
command shortcuts. A two-finger tap undid. With a tool that claims
Escape, such as the Intertwine tool, Escape did not leave the mode.

In the mode only the Escape, Left Arrow and Right Arrow keys now act,
and Escape leaves the mode whatever the tool. Every other key press,
the text it types and the clipboard keys are dropped before any
shortcut reads them, the macOS menu bar's key equivalents included. A
right click or a long touch opens no context menu, a two- or
three-finger tap undoes or redoes nothing, and the pointer over the
canvas is the default one. F and Shift+F no longer leave the mode;
Escape does.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo 34ae88292d Links: Shift-click selects a range of links
In the Links panel, Shift-click and Cmd/Ctrl-click both added or
removed the image of the one row clicked. Shift-click now selects the
images of every row from the last row clicked without Shift to the
clicked one, in the order the panel shows them (its Show filter and
Sort order), and Cmd/Ctrl-Shift-click adds them to the selection.
Cmd/Ctrl-click still adds or removes one image, a plain click selects
one, and a double-click still goes to the link. When the last row
clicked is not shown, or the last click was in another document,
Shift-click acts as a click without Shift.

A click on a row runs select.set, select.add or select.toggle, and the
range is worked out as in the Layers panel's Shift-click.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo a8fbae1b1f Graphic Styles: Shift-click selects a range of styles
In the Graphic Styles panel, Shift-click and Cmd/Ctrl-click both added
or removed the one style clicked. Shift-click now adds to the selection
every style from the last one clicked without Shift through the
clicked one, in the order the panel lists them, and Cmd/Ctrl-click
still adds or removes one. A plain click applies the style and selects
it alone, Alt-click still adds it on top, and the Control bar's Style
picker still applies on every click.

The panel uses swatches::click_selection, as the Swatches panel and
the library panel do.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo 3c5d0e7ce4 Libraries: a plain click sets where a Shift-click range starts
A plain click on an item in the library panel selected the item but did
not record it as the anchor that a Shift-click ranges from. A later
Shift-click then selected only the clicked item, or ranged from an
older anchor set by an earlier Cmd/Ctrl-click or folder click. This
applied to swatch libraries and graphic style libraries.

Every click on an item or folder now goes through click_selection, as
in the Swatches panel. A plain click still selects the item alone and
adds and applies it.
2026-10-17 13:20:23 +07:00
dotCooCoo fbcb217789 Part of #2384: imported PDF text keeps its place after wide gaps between words
PDF import reads a gap wider than a fifth of an em between two glyphs
as a space, and a space that the line's tracking cannot place gets
tracking of its own. That tracking was clamped to 1000 thousandths of
an em, while type takes tracking from -1000 to 10000. A gap wider than
an em plus the width of a space came in short by the excess, so the
words after it sat to the left of where the file put them, and each
further wide gap added to the shift.

The tracking that import sets for such a space, and for the line around
it, now has the same range as type's.
2026-10-17 13:20:23 +07:00
Brian TorresandClaude Opus 5.5 7c80b286c7 Fix #2540: Object → Expand expands Intertwine groups
Expand had nothing for Intertwine groups. It reported success but left
the group live (it only dropped the members' live shapes), and its
Stroke and Fill options could not reach the members, so a second Expand
found nothing to do. Ungroup takes apart plain groups only, so it did
nothing either.

Expand's Object option now turns every Intertwine group in the selection
(inside groups too, inner ones first) into a plain group of what it
draws: its objects, keeping their ids and order, then each part drawn
over the others as a clip group. The group keeps its id, place,
transparency and appearance, everything happens in one undo step, and
the dialog enables Object for an Intertwine group. Ungroup then takes
the result apart, as with an expanded blend or repeat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:23 +07:00
vangel76andClaude Opus 5.5 91fa3af21d Export: raster document.export takes a page rectangle (rect) (#2412)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:23 +07:00
vangel76andClaude Opus 5.5 577239d734 Gradient chips: follow the angle and the gradient type (#2494)
gradient_chip now takes the GradientPaint and lays the gradient out as it would be on an object of the chip's shape: linear along its angle, radial from the centre, freeform from its colour points. Gradient panel swatch menu and the Swatch Options dialog carry the full paint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:22 +07:00
bflatastic 39979d4753 Tests: the floating-panel test skips fills that paint nothing (#2421) 2026-10-17 13:20:22 +07:00
bflatastic 47b6b3285d Panels: floating groups get an opaque fill beneath them; the docked card keeps its rounded corners (#2421) 2026-10-17 13:06:52 +07:00
vangel76andClaude Opus 5.5 ecbbba4f2e Panels: floating panel groups paint an opaque background (#2421)
The shared dock left its pane and tab strip backgrounds transparent because the docked card behind them is filled. Floating groups lie on the canvas, so the canvas showed through. They now paint the panel colour, which is what the card shows when docked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:52 +07:00
bflatastic 424a49604c Layers: Merge Selected keeps to the highlighted layers, as documented (#2383)
Reverts the fallback that merged the layers of art selected on the canvas when no
layer row is highlighted: Illustrator's Merge Selected merges the highlighted rows
into the last one highlighted.
2026-10-17 13:06:52 +07:00
vangel76andClaude Opus 5.5 fdc08e25d6 Layers: Merge Selected merges the layers of art selected on the canvas (#2383)
With no highlighted rows and art selected in two or more layers (Select All), layer.merge merges those layers; the panel menu enables it then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 1ff7ac7557 Layers: Merge Selected with a layer and its sublayer highlighted merges into the last top-level layer (#2383)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
bflatastic 2a3efa02f2 Overprint Preview: overprinting characters draw with Darken too (#2415) 2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 7e423f91e9 Overprint Preview: overprinting draws with Darken, so a colour over itself stays that colour (#2415)
Multiply squared a colour overprinting itself (stroke over same-colour fill showed a darker band). Darken keeps the stronger ink per plate/channel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 8ac4c9d268 Color: Generic CMYK compensates black both ways so black round-trips (#2493)
sRGB to Generic CMYK applies black-point compensation (black separates to the
darkest rich black) but CMYK to display did not undo it, so hex 000000 became
C67 M67 Y67 K100 and then showed as #1b1818. The display XYZ now maps the press
black to black when the colour settings use black-point compensation, in the
sRGB conversion, the ink lookup table the canvas uses, Lab of a CMYK colour and
the gamut warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 d39e02b3fe Type: selected overflowing type shows its red out port with any tool (#2323)
The overflow marker was drawn by the Type tools only, so with the Selection tool a text box smaller than its text showed no sign that text was hidden. Session::overlays now adds the marker of every selected area type and type on a path that overflows, unless a Type tool is active (it draws its own). The verdict is cached per object and font generation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 5fd7fd4312 Character: the Kerning field at a caret kerns the pair, not the whole text (#2217)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 57c79dc0b4 PNG export: an sRGB chunk marks the colours as sRGB, embedIcc turns it off (#2221)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
bflatastic 1fbd35560d Tests: the cursor-theme test compares paths, so it passes on Windows (#2450) 2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 37dfdd5da5 Cursor: keep the system pointer where no cursor theme loads on Wayland (#2450)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:50:38 +07:00
vangel76andClaude Opus 5.5 743930ac11 Wayland: give winit the desktop cursor theme so the pointer returns over panels (#2450)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:50:38 +07:00
dotCooCoo 476d228262 Tests: a language one ui-egui test sets no longer translates the tests running beside it
The language the UI is drawn in was one process-wide value in unit
tests too, and the tests run on parallel threads.
current_language_is_a_frame_setting sets Traditional Chinese for a
moment, and a test that drew text in that moment read it in Chinese.
chrome::tests::the_zoom_hint_follows_animated_zoom failed that way in
29 of 500 runs together with the i18n tests.

The drawing language is now one per process in the app and one per
thread in unit tests, as ui_mirror! keeps the shortcut and workspace
mirrors. The app keeps the same static. A new test sets the language
on another thread and checks that its own thread still draws in
English.
2026-10-17 12:50:38 +07:00
dotCooCoo 6961bed99d Tests: CLI tests do not fail on folders an earlier run left behind
The tmp() helpers in apps/vectorcraft-cli/tests/cli.rs and fileio.rs
name their folder after the process id (vectorcraft-cli-test-<pid>,
vectorcraft-cli-fileio-<pid> in the temp folder) and never empty or
remove it. When a later run gets the same process id, it finds the
earlier run's files there. mcp_automation_roots then fails on Unix with
EEXIST (Os { code: 17, kind: AlreadyExists, message: "File exists" }):
its first symlink() call finds the link in/escape.svg that the earlier
run made. A stale output file can also let a fileio test read an
earlier run's export.

The first tmp() call in each test process now removes the folder and
creates it again empty. The roots test's body moves into
automation_roots(), and mcp_automation_roots_in_a_folder_an_earlier_run_left
runs it twice in one folder, emptying the folder in between as tmp()
does. Without the removal the second run fails with the same error.
2026-10-17 12:50:38 +07:00
dotCooCoo efdb04db8a Lint: action_batch.rs compiles without an unused import on wasm32
On main, cargo xtask wasm printed an unused-import warning for json in
crates/engine/src/cmd/fileio/action_batch.rs, and this check reported
it as an error:

    RUSTFLAGS=-Dwarnings cargo check -p vectorcraft-engine --target wasm32-unknown-unknown

Every use of json! in action_batch.rs is in code compiled only for
native targets. The wasm32 version of action_batch uses Value only.

The json import now has the same #[cfg(not(target_arch = "wasm32"))]
attribute as the code that uses it. Native builds import the same
names as before.
2026-10-17 12:50:37 +07:00
NetterSilverandClaude Haiku 5.5 80685831ac Escape XML ID first characters that cannot start an XML name
Found while testing crates/doc/src/xml_id.rs:31 (from_object_name) / :10 (to_object_name)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 12:50:37 +07:00
NetterSilverandClaude Haiku 5.5 3688ad7a46 Reject '+' and other non-hex characters in Color::from_hex
Found while testing crates/color/src/lib.rs:184

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 12:50:37 +07:00
NetterSilverandClaude Haiku 5.5 8ca4cd2cbf Fix graph axis labels saturating beyond the i64 range
Found while testing crates/engine/src/cmd/graph.rs:193 (fmt_value)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 12:50:37 +07:00
dotCooCoo 3d2e8bbe82 PDF export: Add Around is clamped when a file opens, and art too large for f64 gets no effect image
document.rasterEffectsSettings accepts addAround from 0 to 1,000 pt,
but a file's raster_effects.addAround opened as it was saved. With
addAround 1e300, the area of a raster effect image overflowed f64,
the image scale became 0 and the image got the transform
[inf, NaN, NaN, inf, x, y]. PDF export left that image out, so the
drop shadow was missing.

A file's addAround now opens clamped to 0 to 1,000 pt. An effect
image whose area or scale is not finite is not made. Export then
keeps the object with its effects, and Expand Appearance expands it
without an image, as when its PNG can't be encoded.

Docs: docs/mcp.md (addAround).
2026-10-17 12:50:36 +07:00
bflatastic 0af1741e24 Crop Image: the RGBA size check can't overflow on a huge header 2026-10-17 12:50:36 +07:00
dotCooCoo 6b0d1ed1c0 Crop Image: trim checks the image's RGBA size before decoding it
Crop Image's trim (object.cropImage {trim: true}) decoded the image
within MAX_RASTER_ALLOC (2 GB) and then converted it to RGBA. The
decoder holds the decoded image, before conversion, to that allowance,
so a gray or 1-bit image within it could take up to four times as much
once converted.

Trim now reads the image's size from its header first and, when
width × height × 4 is over the allowance, fails before anything is
decoded, with the check and message Load Texture uses: "an image of
W × H pixels is too large to draw". Images within the allowance are
trimmed as before.
2026-10-17 12:48:19 +07:00
dotCooCoo db2bbcf405 Images: sampling and recoloring check an image's RGBA size without overflowing
Before the Eyedropper samples an image, or Edit Colors, Recolor Artwork
or a color adjustment effect recolors an embedded one, `rgba_within` reads
the image's size from its header and checks its size as RGBA against the
allowance. The check multiplied width × height × 4 in 64 bits. A TIFF's
sides go up to 2^32 - 1 pixels, so a header of 2^31 × 2^31 pixels
overflowed that product: debug and test builds panicked with "attempt to
multiply with overflow", and release builds wrapped it to 0 before the
decoder's own 512 MB limit refused the image.

The check now compares width × height with the allowance divided by 4.
Every size whose RGBA bytes fit in 64 bits gets the same answer as
before, and a larger one is refused.
2026-10-17 12:48:19 +07:00
dotCooCoo 9bee299dd5 Render: an export no longer warns about images another thread could not decode
A raster export compared the process-wide count of images that could
not be decoded before and after it drew, and warned that "an image
couldn't be decoded and is missing from the picture" when the count
rose. Any other thread that failed to decode an image in that time
raised the count too: a second export, the UI thread drawing the
canvas, or the canvas, thumbnail and placed-document workers drawing a
document with such an image. The export then warned about an image it
does not contain. In the engine's tests,
options_not_applied_yet_and_document_features_warn failed in 5 of 60
runs of the tests_raster and tests_pdf* tests.

The count is now kept per thread. An export reads it on the thread it
draws on. That thread decodes every image the export draws that is not
already in the shared image store, and an image that failed to decode
is never stored.
2026-10-17 12:48:19 +07:00
dotCooCoo 0f987812b0 Render: check an image's RGBA size before decoding it
The renderer decoded each image within its 2 GB allowance (512 MB in
the web build) and then converted it to RGBA. The decoder holds the
decoded image, before conversion, to that allowance, so an image stored
with fewer than four bytes a pixel (gray, 1-bit, gray with alpha, or
RGB without alpha) could take up to four times as much (4/3 times for
RGB) once converted, and as much again as the pixmap drawn from it.

The renderer now reads the image's size from its header first. When
width × height × 4 is over the allowance it does not decode the image
and handles it as one it can't decode: the renderer draws nothing for
it, and a raster export warns that an image is missing. Images within
the allowance are decoded and drawn as before.
2026-10-17 12:48:19 +07:00
BFlat 49bdc5cdd4 Merge pull request #1510 from marcoarnulfo/fix/ai-layers-collapsed
Layers: a .ai another app wrote opens with its top-level layers closed, as its groups
2026-10-17 12:46:37 +07:00
BFlat 7a8387f467 Merge pull request #1365 from Messhias/feat/394-highlight-alternate-glyphs
Part of #394: Highlight Alternate Glyphs paints pink behind OpenType substitutions
2026-10-17 12:46:18 +07:00
BFlat 12758df31f Merge pull request #2334 from bobtherobot/feat/tool-cursors
Cursors: 36 more tool states get cursors of their own, listed in cursors.toml
2026-10-17 12:46:03 +07:00
BFlat a76faaa21f Merge pull request #2393 from pechnikov2026-star/i18n-nl-da-hi-id
i18n: add Dutch, Danish, Hindi and Indonesian interfaces
2026-10-17 12:45:46 +07:00
BFlat 21a9da7360 Merge pull request #2379 from dotCooCoo/fix/macos-save-format-menu
Fix #866: macOS save sheets have a Format menu again
2026-10-17 12:45:28 +07:00
Marco Arnulfo f31d91bf1a Layers: a .ai another app wrote opens with its top-level layers closed, as its groups (fixes #1475) 2026-10-17 12:26:14 +07:00
bflatastic 52771905e6 Part of #394: rustfmt the alternate-glyph test 2026-10-17 12:26:14 +07:00
Fabio William Conceição 0ffe0c7c0e Part of #394: Highlight Alternate Glyphs defaults to off. 2026-10-17 12:26:13 +07:00
Fabio William Conceição 8689863fbf Part of #394: Highlight Alternate Glyphs paints pink behind OpenType substitutions. 2026-10-17 12:26:13 +07:00
Michael GiesonandClaude Opus 5.5 2d0da1472f Cursors: 36 more tool states get cursors of their own, listed in cursors.toml
Node editing as the public manual describes it: Direct Selection's anchor squares (hollow
or filled), Reshape Segment over a curved segment, Group Selection's plus (also Direct
Selection with Alt), the arrow hollow while a drag snaps, and Alt-copy arrowheads. The
Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows (ShearH,
ShearV) are drawn in code. Lasso, the freehand, symbolism and other tools return cursors
of their own that borrow their old look until drawn (Cursor::stand_in).

crates/tools/src/cursors.toml lists every Cursor value, when it shows and its hotspot,
for whoever draws them; `cargo xtask cursors` (in ci) warns about a missing, unknown,
repeated or incomplete entry and never fails. The busy cursor is listed as missing (G1).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 12:26:12 +07:00
sanches 816097c6bf i18n: add Dutch, Danish and Indonesian interfaces
Complete catalogs for nl, da and id, registered with their locales. Hindi from the same PR waits for a Devanagari UI font decision (with Lao and Georgian), and the strings main gained since the catalogs were written are added.
2026-10-17 12:26:11 +07:00
dotCooCoo 1d7ad173aa Fix #866: macOS save sheets have a Format menu again
On macOS, the save sheets of Save As, Save a Copy and the first Save of
a new document have a Format menu again, and the Save as Template sheet
now has one too. The panel is an NSSavePanel built in
apps/vectorcraft/src/mac_save_format.rs, and the menu is its accessory
view, set before beginSheetModalForWindow:completionHandler: shows the
sheet. The menu from #1235 was added after rfd had begun the sheet, and
recent macOS versions dismissed the sheet at once (#1800, #1831,
#1518); #1855 removed it.

Choosing a format changes the extension in the name field and puts
that format's extension first in the allowed file types, which AppKit
adds to a name typed without one. A name typed with another listed
extension is saved in that format. Save panels with one file type and
the open and folder panels are still shown by rfd.

Docs: docs/target-app-parity.md, docs/ui-parity.md.
2026-10-17 12:26:10 +07:00
118 changed files with 19297 additions and 414 deletions

No files matched your search

+1
View File
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+1
View File
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+1
View File
@@ -135,6 +135,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
+24 -3
View File
@@ -7,10 +7,18 @@ use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-test-<pid>` in the temp folder. The first call removes
/// that folder and creates it again empty.
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id()));
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name)
}
/// Removes `dir` and everything in it, creates it again empty and returns it.
fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
dir
}
#[test]
@@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() {
/// server starts, and the process then reads and writes only inside its roots.
#[test]
fn mcp_automation_roots() {
let base = tmp("roots");
automation_roots(&tmp("roots"));
}
/// A run that has the process id of an earlier run finds that run's files and links in its folder.
/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing
/// with "File exists".
#[test]
fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
let dir = tmp("earlier-run");
automation_roots(&dir.join("roots"));
automation_roots(&empty_dir(dir).join("roots"));
}
fn automation_roots(base: &std::path::Path) {
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
+9 -2
View File
@@ -8,9 +8,16 @@ use serde_json::Value;
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-fileio-<pid>` in the temp folder. The first call
/// removes that folder and creates it again empty.
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
let dir = DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
});
dir.join(name).to_string_lossy().to_string()
}
+212
View File
@@ -0,0 +1,212 @@
//! Linux Wayland: tell winit where the cursor theme is (#2450).
//!
//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor
//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps
//! showing its default one, until the canvas hides the system pointer to paint a tool glyph
//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup
//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the
//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads.
//!
//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does
//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph.
//!
//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before
//! any thread starts and before the window opens.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// Where a Flatpak sandbox mounts the host's cursor themes (user and system).
const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"];
/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`).
#[derive(Debug, Default, PartialEq)]
pub struct DesktopCursor {
pub theme: Option<String>,
pub size: Option<u32>,
}
/// The variables to set, given the environment, the host icon folders that exist and the
/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set
/// stay as they are.
pub fn plan(var: impl Fn(&str) -> Option<OsString>, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) {
return Vec::new();
}
let mut out = Vec::new();
if !set("XCURSOR_PATH") && !host_icons.is_empty() {
out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":"))));
}
if !set("XCURSOR_THEME")
&& let Some(theme) = desktop.theme.filter(|t| !t.is_empty())
{
out.push(("XCURSOR_THEME", OsString::from(theme)));
}
if !set("XCURSOR_SIZE")
&& let Some(size) = desktop.size.filter(|s| (1..=512).contains(s))
{
out.push(("XCURSOR_SIZE", OsString::from(size.to_string())));
}
out
}
/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`).
const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"];
/// The themes `index.theme` lists after `Inherits=`.
fn inherits(index: &str) -> Vec<String> {
index
.lines()
.find_map(|l| l.trim().strip_prefix("Inherits="))
.map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect())
.unwrap_or_default()
}
/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does
/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of
/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells
/// whether a file is there.
pub fn theme_usable(
var: impl Fn(&str) -> Option<OsString>,
home: Option<&Path>,
read: impl Fn(&Path) -> Option<String>,
exists: impl Fn(&Path) -> bool,
) -> bool {
let dirs: Vec<PathBuf> = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) {
Some(v) => std::env::split_paths(&v).collect(),
None => DEFAULT_ICON_DIRS
.iter()
.filter_map(|d| match d.strip_prefix("~/") {
Some(rest) => home.map(|h| h.join(rest)),
None => Some(PathBuf::from(d)),
})
.collect(),
};
let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into());
let mut todo = vec![first];
let mut seen: Vec<String> = Vec::new();
while let Some(theme) = todo.pop() {
// Bounded: themes inherit in short chains, and a cycle must end.
if seen.contains(&theme) || seen.len() >= 16 {
continue;
}
for dir in &dirs {
let base = dir.join(&theme);
if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) {
return true;
}
if let Some(index) = read(&base.join("index.theme")) {
todo.extend(inherits(&index));
}
}
seen.push(theme);
}
false
}
/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never
/// sets a pointer, and the canvas must not hide the system one (#2450).
pub fn theme_missing() -> bool {
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
!theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists())
}
/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`.
fn gsettings_value(output: &str) -> &str {
output.trim().trim_matches('\'')
}
#[cfg(target_os = "linux")]
fn desktop_cursor() -> DesktopCursor {
let get = |key: &str| {
let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
};
DesktopCursor {
theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()),
size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()),
}
}
/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them
/// unset. Call first thing in `main`, while the process is single-threaded.
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
pub fn apply() {
let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect();
for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) {
log::debug!("cursor theme: {name}={value:?}");
// SAFETY: called at the top of `main`, before the app starts any thread.
unsafe { std::env::set_var(name, value) };
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v))
}
fn adwaita() -> DesktopCursor {
DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) }
}
#[test]
fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() {
let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita());
assert_eq!(
got,
vec![
("XCURSOR_PATH", OsString::from("/run/host/share/icons")),
("XCURSOR_THEME", OsString::from("Adwaita")),
("XCURSOR_SIZE", OsString::from("24")),
]
);
}
#[test]
fn settings_the_user_made_and_other_sessions_stay_untouched() {
let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")];
assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty());
assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty());
// A theme-less desktop and an absurd size set nothing.
let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) };
assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty());
}
#[test]
fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() {
let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"];
// Compared as paths, so the joins match on Windows too, where they use `\`.
let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p);
let read = |p: &Path| {
if p == Path::new("/i/Breeze/index.theme") {
Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned())
} else if p == Path::new("/i/Loop/index.theme") {
Some("Inherits=Loop\n".to_owned())
} else {
None
}
};
let with = |theme: &'static str| {
move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme)))
};
assert!(theme_usable(with("Bibata"), None, read, exists));
assert!(theme_usable(with("Breeze"), None, read, exists));
// No such theme, a theme without cursors, an inheritance loop: nothing to load.
assert!(!theme_usable(with("Nope"), None, read, exists));
assert!(!theme_usable(with("Loop"), None, read, exists));
// Unset: Xcursor's default folders and the `default` theme.
assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists));
}
#[test]
fn gsettings_output_is_unquoted() {
assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita");
assert_eq!(gsettings_value("24\n"), "24");
}
}
+253
View File
@@ -0,0 +1,253 @@
//! macOS: the Format menu on save sheets (#866). A save panel with two or more file types (Save As,
//! Save a Copy, Save as Template and the first Save of a new document) is an `NSSavePanel` made here
//! instead of by rfd, whose Cocoa backend lists the extensions of every filter but not their names.
//! The panel gets a Format menu as its accessory view
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/accessoryview>) before
//! `beginSheetModalForWindow:completionHandler:`
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/beginsheetmodal(for:completionhandler:)>)
//! shows it: an accessory set after the sheet began made recent macOS versions dismiss the sheet at
//! once (#1800, #1831, #1518). Choosing a format changes the extension in the name field and puts
//! the format's extension first in the allowed file types, which AppKit adds to a name typed without
//! one. The save command takes the format from the extension, so an extension typed by hand still
//! decides it.
//!
//! The panel retains the accessory, and the completion handler holds the panel until the sheet
//! closes; the popup's target is the accessory itself. The accessory holds the panel weakly, so
//! closing the sheet releases both. Reading the window from its handle and subclassing `NSView` for
//! the menu's action have no safe API; this module allows `unsafe_code`.
#![allow(unsafe_code)]
use std::sync::mpsc::{Receiver, channel};
use block2::RcBlock;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
/// The accessory's height and the room around and between its label and menu, in points.
const HEIGHT: f64 = 38.0;
const MARGIN: f64 = 12.0;
const GAP: f64 = 8.0;
/// The Format menu's size, in points.
const MENU: NSSize = NSSize::new(260.0, 26.0);
struct Ivars {
panel: Weak<NSSavePanel>,
filters: Vec<Filter>,
}
define_class!(
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
#[ivars = Ivars]
#[name = "VectorcraftSaveFormatAccessory"]
struct FormatAccessory;
// SAFETY: NSObjectProtocol has no additional requirements.
unsafe impl NSObjectProtocol for FormatAccessory {}
impl FormatAccessory {
// SAFETY: AppKit's target/action sends the popup as the single object argument.
#[unsafe(method(chooseFormat:))]
fn choose_format(&self, sender: &NSPopUpButton) {
let Some(panel) = self.ivars().panel.load() else { return };
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
let Some(extension) = self.ivars().filters.get(index).and_then(|(_, extensions)| extensions.first()) else { return };
set_allowed_types(&panel, &self.ivars().filters, index);
let name = panel.nameFieldStringValue().to_string();
panel.setNameFieldStringValue(&NSString::from_str(&filename(&name, extension, &self.ivars().filters)));
}
}
);
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
/// two remain, and the panel is then left to rfd.
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
if named.len() < 2 { Vec::new() } else { named }
}
/// The menu entry of the suggested name's type, or the first (the request's default).
fn initial_format(name: &str, filters: &[Filter]) -> usize {
let extension = fileio::extension(name);
filters.iter().position(|(_, extensions)| extensions.iter().any(|x| extension.eq_ignore_ascii_case(x))).unwrap_or(0)
}
/// `name` with `extension`: a format's extension (one in the menu, or any other the app reads or
/// writes) is replaced, and other dots stay part of the name (a version number).
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
if name.is_empty() {
return String::new();
}
let has = fileio::extension(name);
let known =
filters.iter().any(|(_, extensions)| extensions.iter().any(|x| has.eq_ignore_ascii_case(x))) || fileio::format_for_name(name).is_some();
if known { std::path::Path::new(name).with_extension(extension).to_string_lossy().into_owned() } else { format!("{name}.{extension}") }
}
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
/// the first one to a name typed without an extension, and a name typed with another of them is
/// saved in that format.
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
first.iter().chain(others).copied().collect()
}
/// Set `panel`'s allowed file types ([`allowed_types`]).
fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
let types: Vec<Retained<NSString>> = allowed_types(filters, selected).into_iter().map(NSString::from_str).collect();
// rfd sets the same deprecated property (`add_filters` in its `panel_ffi.rs`).
#[allow(deprecated)]
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
}
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
/// named file types, or `window` has no AppKit window; nothing is shown then.
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
let mtm = MainThreadMarker::new()?;
let filters = menu_formats(&pick.filters);
if filters.is_empty() {
return None;
}
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and winit hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let parent = view.window()?;
let panel = NSSavePanel::savePanel(mtm);
let selected = initial_format(&pick.name, &filters);
set_allowed_types(&panel, &filters, selected);
if let Some(folder) = &pick.folder {
panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(folder), true)));
}
panel.setExtensionHidden(false);
if !pick.name.is_empty() {
panel.setNameFieldStringValue(&NSString::from_str(&pick.name));
}
let accessory = accessory(mtm, &panel, filters, selected);
panel.setAccessoryView(Some(&accessory));
let (tx, rx) = channel();
let answered = panel.clone();
let handler = RcBlock::new(move |response: NSModalResponse| {
let picked = answered.URL().and_then(|url| url.path()).map(|p| p.to_string());
let paths = if response == NSModalResponseOK { picked.into_iter().collect() } else { Vec::new() };
// The app gone meanwhile has no use for the answer.
let _ = tx.send(paths);
});
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
// until the sheet closes.
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
Some(rx)
}
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, selected: usize) -> Retained<FormatAccessory> {
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
label.sizeToFit();
let size = label.frame().size;
label.setFrameOrigin(NSPoint::new(MARGIN, (HEIGHT - size.height) / 2.0));
let x = MARGIN + size.width + GAP;
let popup =
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(x, (HEIGHT - MENU.height) / 2.0), MENU), false);
for (name, _) in &filters {
popup.addItemWithTitle(&NSString::from_str(i18n::tr_ctx(i18n::current(), "file-filter", name)));
}
// The filter list comes from the built-in format registry, small enough for NSInteger.
if let Ok(index) = isize::try_from(selected) {
popup.selectItemAtIndex(index);
}
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
let frame = NSRect::new(NSPoint::ZERO, NSSize::new(x + MENU.width + MARGIN, HEIGHT));
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: frame] };
// SAFETY: the accessory holds the popup as a subview and AppKit keeps the accessory while the
// sheet is open. The selector has the target/action signature NSControl calls it with.
unsafe {
popup.setTarget(Some(&accessory));
popup.setAction(Some(sel!(chooseFormat:)));
}
accessory.addSubview(&label);
accessory.addSubview(&popup);
accessory
}
#[cfg(test)]
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
/// named type, or one for every file, gets no menu, and rfd shows it.
#[test]
fn save_panels_with_several_formats_get_the_menu() {
let save = menu_formats(&fileio::save_filters("vectorcraft"));
assert!(save.len() >= 2, "{save:?}");
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
}
#[test]
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
assert_eq!(initial_format(name, FILTERS), index, "{name}");
}
}
#[test]
fn choosing_a_format_changes_the_extension_and_keeps_the_name() {
for (name, expected) in [
("図面 1.vectorcraft", "図面 1.svg"),
("a.PDF", "a.svg"),
("art", "art.svg"),
("art.v2", "art.v2.svg"),
("art.svg", "art.svg"),
("old.drawcraft", "old.svg"),
("photo.png", "photo.svg"),
("", ""),
] {
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
}
}
/// A name typed without an extension gets the chosen format's: AppKit adds the first allowed
/// type. The others stay allowed.
#[test]
fn the_chosen_format_is_the_first_allowed_type() {
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
for (label, extensions) in &filters {
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
}
}
/// The menu's label is translated in every language with complete menus.
#[test]
fn the_label_is_translated() {
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
}
}
}
+20 -7
View File
@@ -27,6 +27,7 @@ mod clipboard;
#[cfg(feature = "wgpu")]
mod compositor_gpu;
mod control_server;
mod cursor_theme;
#[cfg(feature = "wgpu")]
mod gpu;
mod logging;
@@ -35,6 +36,8 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_screen_color;
#[cfg(target_os = "macos")]
mod mac_window;
@@ -473,7 +476,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
})
.ok()?;
make_folder(&request);
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
// The panel is built and the sheet is begun below, 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 {
@@ -485,12 +488,18 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(_) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
// A save panel with two or more named file types is built by `mac_save_format`, which sets
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
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() })
@@ -517,6 +526,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
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"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
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.
@@ -646,6 +656,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
";
fn main() -> std::process::ExitCode {
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
#[cfg(target_os = "linux")]
cursor_theme::apply();
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control = automation_args::ControlArg::default();
+24 -7
View File
@@ -171,9 +171,25 @@ impl GenericCmyk {
xyz_to_lab(self.to_xyz(cmyk))
}
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
/// With `bpc` (media-relative only) the press black displays as black, undoing the
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
/// darkest rich black shows as black again.
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
if absolute {
return self.to_xyz(cmyk);
}
let x = self.to_xyz_rel(cmyk);
if !bpc {
return x;
}
let bp = self.black_rel;
[0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0))
}
/// Display sRGB, see [`Self::display_xyz`].
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
}
/// Lab of the darkest printable black (media-relative).
@@ -425,11 +441,12 @@ impl GenericCmyk {
self.from_lab(srgb_to_lab(rgb), intent, bpc)
}
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
pub fn gamut_error(&self, src: Lab) -> f32 {
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
/// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays
/// (with `bpc`, the press black showing as black).
pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 {
let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc);
let cmyk = self.separate(target);
delta_e2000(self.to_lab_rel(cmyk), target)
delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src)
}
}
+16 -7
View File
@@ -346,10 +346,10 @@ impl Cms {
}
}
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
match space {
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc),
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
CmykSpace::Icc(p) => {
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
@@ -376,7 +376,7 @@ impl Cms {
{
return v;
}
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc)
}
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
@@ -408,6 +408,15 @@ impl Cms {
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
}
}
/// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in
/// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows
/// as black). Unclipped, for interpolating tables.
pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab {
match &self.cmyk {
CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)),
_ => self.cmyk_to_lab(cmyk),
}
}
/// Lab → working CMYK with `intent`.
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
match &self.cmyk {
@@ -429,7 +438,7 @@ impl Cms {
/// CIE Lab (D50) of any colour.
pub fn lab(&self, c: &Color) -> Lab {
match *c {
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]),
Color::Lab { l, a, b } => Lab::new(l, a, b),
_ => lab::srgb_to_lab(self.display_rgb(c)),
}
@@ -490,7 +499,7 @@ impl Cms {
Color::Rgb { .. } | Color::Lab { .. } => {
let src = self.lab(c);
match &self.cmyk {
CmykSpace::Generic(g) => g.gamut_error(src),
CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc),
CmykSpace::Device => 0.0,
CmykSpace::Icc(_) => {
let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false);
@@ -523,7 +532,7 @@ impl Cms {
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => {
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc)
}
Err(_) => srgb,
},
@@ -534,7 +543,7 @@ impl Cms {
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
match &proof.target {
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc),
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
},
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
+14
View File
@@ -245,6 +245,20 @@ fn loads_system_cmyk_icc() {
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
}
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
#[test]
fn generic_cmyk_black_round_trips_with_bpc() {
let c = cms();
let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8);
assert_eq!(back, [0, 0, 0], "{cmyk:?}");
// The paper still shows as white, and without compensation the press black stays a dark grey.
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false);
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
}
#[test]
fn lab_colours_round_trip_through_the_cms() {
let c = cms_no_bpc();
+2 -1
View File
@@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() {
assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk);
let p = IccProfile::from_bytes(None, &bytes).unwrap();
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk));
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap();
// The encoded profile converts without black-point compensation, so compare without it.
let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap();
for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] {
let want = cms.cmyk_to_srgb(cmyk, false);
let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap();
+12
View File
@@ -181,6 +181,10 @@ impl Color {
_ => {}
}
let h = s.strip_prefix('#').unwrap_or(s);
// `from_str_radix` accepts a leading `+`, so check the digits first.
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
match h.len() {
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
@@ -412,6 +416,14 @@ mod tests {
assert_eq!(Color::from_hex("zz"), None);
}
#[test]
fn hex_rejects_sign_characters() {
for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] {
assert_eq!(Color::from_hex(bad), None, "{bad}");
}
assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233"));
}
#[test]
fn hsb_roundtrip() {
for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] {
+12 -7
View File
@@ -430,17 +430,22 @@ impl GraphSpec {
let cells = self.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
let mut out = String::from(",");
out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
// Quote a label holding a separator, quote or line break (inner quotes doubled), or one with
// surrounding spaces; `always` also quotes blank and numeric labels so they stay labels.
let field = |c: &str, always: bool| {
let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim();
if special || (always && (c.trim().is_empty() || c.trim().parse::<f64>().is_ok())) {
format!("\"{}\"", c.replace('"', "\"\""))
} else {
c.to_string()
}
};
out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::<Vec<_>>().join(","));
out.push('\n');
let labelled = !self.categories.is_empty();
for (i, r) in cells.iter().enumerate() {
if labelled {
let c = self.categories.get(i).map(String::as_str).unwrap_or_default();
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
out.push_str(&format!("\"{c}\""));
} else {
out.push_str(c);
}
out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true));
out.push(',');
}
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
+9 -8
View File
@@ -94,26 +94,27 @@ impl Node {
}
}
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
/// Draw the overprinting fills and strokes of `a`'s subtree with Darken (those with a blend
/// mode of their own keep it), copying only the nodes on the way to them: on screen and on a
/// printed plate this approximates their inks printing over the inks below. With `discard_white`
/// printed plate this approximates their inks printing over the inks below. Darken keeps the
/// stronger ink of each plate (and each additive channel), so a colour overprinting itself stays
/// that colour, where Multiply would darken it. With `discard_white`
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
pub fn darken_overprints(a: &mut Arc<Node>, discard_white: bool) {
if !a.has_overprint() {
return;
}
let n = Arc::make_mut(a);
let multiplies =
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
let darkens = |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
for it in &mut n.appearance.items {
match it {
crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
crate::AppearanceItem::Fill(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
crate::AppearanceItem::Stroke(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
_ => {}
}
}
for c in n.children_mut().into_iter().flatten() {
multiply_overprints(c, discard_white);
darken_overprints(c, discard_white);
}
}
+33 -1
View File
@@ -16,7 +16,7 @@ pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 }
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) * 4 > max {
if u64::from(w) * u64::from(h) > max / 4 {
return None;
}
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
@@ -143,6 +143,38 @@ mod tests {
assert!(rgba_within(b"not an image", u64::MAX).is_none());
}
/// A little-endian TIFF whose header gives `w` × `h` 8-bit gray pixels in one strip. The file holds
/// no pixel data.
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"II*\0".to_vec();
out.extend(8u32.to_le_bytes());
// Each entry is (tag, type: 3 SHORT or 4 LONG, value) and holds one value.
let entries: [(u16, u16, u32); 9] =
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
out.extend((entries.len() as u16).to_le_bytes());
for (tag, kind, value) in entries {
out.extend(tag.to_le_bytes());
out.extend(kind.to_le_bytes());
out.extend(1u32.to_le_bytes());
out.extend(value.to_le_bytes());
}
out.extend(0u32.to_le_bytes());
out
}
/// A TIFF's sides go up to 2^32 - 1 pixels: a size whose RGBA bytes don't fit in 64 bits is
/// refused like any other past the allowance, and an image within the allowance still decodes.
#[test]
fn a_size_past_64_bits_as_rgba_is_not_decoded() {
let huge = tiff_header(1 << 31, 1 << 31);
assert!(rgba_within(&huge, MAX_RASTER_ALLOC).is_none());
assert!(rgba_within(&huge, u64::MAX).is_none());
let gray = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
let mut png = Vec::new();
gray.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(rgba_within(&png, 35).map(|i| i.dimensions()), Some((4, 2)), "32 bytes as RGBA, within 35");
}
#[test]
fn samples_a_pixel_or_the_average_around_it() {
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
+17 -12
View File
@@ -27,7 +27,8 @@ pub fn to_object_name(id: &str) -> String {
out
}
/// The XML id for an object name (the inverse of [`to_object_name`]).
/// The XML id for an object name (the inverse of [`to_object_name`]). The empty name stays empty,
/// since a bare `_` would read back as a space.
pub fn from_object_name(name: &str) -> String {
let mut out = String::with_capacity(name.len().saturating_mul(2));
for (i, c) in name.chars().enumerate() {
@@ -35,17 +36,14 @@ pub fn from_object_name(name: &str) -> String {
out.push('_');
} else if c == '_' {
out.push_str("_x5F_");
} else if i == 0 && c.is_ascii_digit() {
} else if (i == 0 && !(c.is_alphabetic() || c == ':')) || !(c.is_alphanumeric() || c == '.' || c == '-' || c == ':') {
// A first character that cannot start an XML name is escaped, never given a bare `_`
// prefix (that would read back as a space).
out.push_str(&format!("_x{:X}_", u32::from(c)));
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
out.push(c);
} else {
out.push_str(&format!("_x{:X}_", u32::from(c)));
out.push(c);
}
}
if out.is_empty() || !starts_name(&out) {
out.insert(0, '_');
}
out
}
@@ -66,10 +64,6 @@ pub fn is_valid(id: &str) -> bool {
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
}
fn starts_name(id: &str) -> bool {
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
@@ -114,4 +108,15 @@ mod tests {
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
}
#[test]
fn round_trip_names_that_cannot_start_an_xml_name() {
for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {
let id = from_object_name(name);
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
}
assert_eq!(from_object_name("-abc"), "_x2D_abc");
assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm");
assert_eq!(from_object_name("²x"), "_xB2_x");
}
}
+12 -3
View File
@@ -1,5 +1,5 @@
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection,
expand
),
@@ -108,6 +108,8 @@ struct Expandable {
placed: bool,
/// Repeats (Object expands them into groups of their instances).
repeats: bool,
/// Intertwine groups (Object expands them into groups of what they draw).
intertwines: bool,
}
impl Expandable {
@@ -123,6 +125,7 @@ impl Expandable {
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
@@ -134,7 +137,7 @@ impl Expandable {
}
fn object(&self) -> bool {
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
}
}
@@ -162,6 +165,12 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
}
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
// over the others, expand below.
if object && e.intertwines {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?;
}
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
if object && e.blends {
let roots = selected_roots(s)?;
+3 -1
View File
@@ -5,7 +5,9 @@
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
use serde_json::{Value, json};
use serde_json::Value;
#[cfg(not(target_arch = "wasm32"))]
use serde_json::json;
use super::super::*;
+38
View File
@@ -307,6 +307,32 @@ fn use_artboards(p: &Value) -> Result<Option<bool>> {
}
}
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
/// contradict it). The image size limits are checked with the scale, as for an artboard.
fn page_rect(f: &Format, p: &Value) -> Result<Option<Rect>> {
let v = match p.get("rect") {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
if !f.raster {
return Err(bad(C, format!("rect applies to raster formats, not {}", f.label)));
}
if let Some(k) = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).find(|k| p.get(**k).is_some_and(|v| !v.is_null())) {
return Err(bad(C, format!("rect and {k} contradict each other: give the area or the artboards")));
}
let nums: Option<Vec<f64>> = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect());
let Some(&[x, y, w, h]) = nums.as_deref() else {
return Err(bad(C, "rect is [x, y, width, height] in points"));
};
let (x1, y1) = (x + w, y + h);
if !(x.is_finite() && y.is_finite() && w > 0.0 && h > 0.0 && x1.is_finite() && y1.is_finite()) {
return Err(bad(C, "rect needs finite numbers and a positive width and height"));
}
Ok(Some(Rect::new(x, y, x1, y1)))
}
/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds).
pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document {
with_single_artboard(doc.clone(), rect, name)
@@ -408,6 +434,15 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
if let Some(rect) = page_rect(f, p)? {
let mut q = super::export::without_artboards(p);
if let Some(o) = q.as_object_mut() {
o.remove("rect");
o.remove("useArtboards");
}
return encode_files(&single_artboard(doc, rect, "Rect"), f, &q);
}
if use_artboards == Some(false) {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
@@ -479,6 +514,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
} else {
bytes
};
// PNG: say the colours are sRGB (what the renderer draws), unless `embedIcc` is false.
let bytes =
if format == RasterFormat::Png && settings.jpeg.embed_icc { vectorcraft_render::encode::png::with_srgb(bytes) } else { bytes };
enc.files.push((Some(b), bytes));
}
if enc.files.is_empty() {
+5
View File
@@ -377,6 +377,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
doc.template = false;
doc.title = s.next_untitled();
}
// A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start
// closed, its top-level layers as its groups (#1475). Ours carry their own.
if matches!(format.id, "ai" | "ait") && !restored && doc.layers_open.is_none() {
doc.layers_open = Some(vec![]);
}
// Save writes the file back only in a format it writes, only a whole PDF (see
// [`LoadOptions::is_partial`]), and a .ai file only when it carried the native document (Save
// writes .ai that way).
+16 -6
View File
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -311,6 +311,12 @@ const USE_ARTBOARDS: FormatOption = FormatOption {
default: "null",
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF and AI: one page each); false: the bounds of the visible art",
};
const RECT: FormatOption = FormatOption {
name: "rect",
ty: "array",
default: "null",
description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards",
};
const PPI: FormatOption = FormatOption {
name: "ppi",
ty: "number",
@@ -348,7 +354,7 @@ const EMBED_ICC: FormatOption = FormatOption {
name: "embedIcc",
ty: "boolean",
default: "true",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)",
};
const IMAGE_MAP: FormatOption = FormatOption {
name: "imageMap",
@@ -398,6 +404,7 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -419,7 +426,7 @@ const BYTE_ORDER: FormatOption = FormatOption {
description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)",
};
const TIFF_OPTIONS: &[FormatOption] =
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
const BMP_COLOR_MODEL: FormatOption =
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
const BMP_DEPTH: FormatOption = FormatOption {
@@ -447,6 +454,7 @@ const BMP_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -466,7 +474,7 @@ const TGA_DEPTH: FormatOption = FormatOption {
default: "24",
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
};
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const PSD_LAYERS: FormatOption = FormatOption {
name: "layers",
ty: "boolean",
@@ -490,6 +498,7 @@ const PSD_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -560,7 +569,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
},
Format {
id: "jpg",
@@ -575,6 +584,7 @@ pub const FORMATS: &[Format] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -596,7 +606,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
},
Format {
id: "tiff",
@@ -374,3 +374,43 @@ fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
open(&mut s, name, &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
}
/// The rows that open in the Layers panel (layers, sublayers and groups), and which of them are
/// open.
fn open_rows(s: &Session) -> (Vec<String>, Vec<String>) {
let st = s.doc().unwrap();
let (mut rows, mut open) = (vec![], vec![]);
st.doc.walk(|n| {
if n.children().is_some() {
rows.push(n.name.clone().unwrap_or_default());
if st.layers_open.contains(n.id) {
open.push(n.name.clone().unwrap_or_default());
}
}
});
(rows, open)
}
/// #1475: a `.ai` another app wrote keeps no open rows VectorCraft reads, so it opens with every
/// row closed, its top-level layers as its groups; a `.ai` VectorCraft wrote, and other formats,
/// open as before (with their top-level layers open unless the file says otherwise).
#[test]
fn an_illustrator_ai_file_opens_with_its_layers_closed() {
let mut s = Session::new();
for file in [ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), ai::eps(&ai::sample_data(), ai::page_ps())] {
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
let (rows, open) = open_rows(&s);
assert!(rows.contains(&"Art".into()) && rows.contains(&"Pair".into()) && rows.contains(&"Sub".into()), "{rows:?}");
assert_eq!(open, Vec::<String>::new(), "{r}");
}
// The same art as an EPS: its top-level layers open.
open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
// Saved as .ai by VectorCraft, it opens with the rows it was saved with.
let saved = s.execute("document.save", &json!({"format": "ai"})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
assert_eq!(r["restored"], json!(true), "{r}");
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
}
@@ -159,6 +159,21 @@ fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone()
assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks");
}
#[test]
fn png_says_it_is_srgb_unless_embed_icc_is_false() {
let mut s = session(40.0, 30.0, 1);
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
let on = png(&mut s, json!({"format": "png"}));
assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent");
let off = png(&mut s, json!({"format": "png", "embedIcc": false}));
assert!(png_chunk(&off, b"sRGB").is_none());
assert_eq!(rgba(&on), rgba(&off), "same pixels either way");
let r = s.execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc");
}
#[test]
fn formats_list_the_raster_options() {
let mut s = Session::new();
@@ -217,6 +232,53 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
}
}
#[test]
fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
// While another thread keeps exporting a document whose image can't be decoded (two exports at
// once, or the canvas drawing such a document), PNG exports of art with no image report
// nothing, and every export on the other thread still reports its own image.
use std::sync::atomic::{AtomicBool, Ordering};
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
let mut broken = vectorcraft_doc::Document::new(40.0, 20.0);
broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
let im = ImageObject {
key: "broken".into(),
width: 2,
height: 1,
xf: vectorcraft_geom::Affine::scale(20.0),
link: None,
placement: Default::default(),
};
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let mut s = session(400.0, 400.0, 1);
s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap();
let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded")));
let stop = AtomicBool::new(false);
let (tx, rx) = std::sync::mpsc::channel();
// Nothing in the scope panics, so the other thread is always told to stop.
let (theirs, ours) = std::thread::scope(|scope| {
let other = scope.spawn(|| {
let mut warned = vec![];
while !stop.load(Ordering::Relaxed) {
let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::<Vec<_>>());
warned.push(w.is_ok_and(|w| says_so(&w)));
let _ = tx.send(());
}
warned
});
let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok();
let ours: Vec<Option<bool>> = (0..5)
.map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w))))
.collect();
stop.store(true, Ordering::Relaxed);
(other.join().ok(), started.then_some(ours))
});
let theirs = theirs.expect("the other thread's exports");
assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}");
assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image");
}
#[test]
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
@@ -233,3 +295,41 @@ fn a_raster_export_says_when_an_image_could_not_be_decoded() {
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
}
#[test]
fn export_rect_writes_that_page_area_and_leaves_the_document_alone() {
let mut s = session(200.0, 400.0, 1);
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let boards = s.doc().unwrap().doc.artboards.len();
let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap();
let img = rgba(&b64(&r));
assert_eq!((img.width(), img.height()), (40, 300));
assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art");
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left");
// Twice the resolution, and a window that holds both art and empty page.
let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap()));
assert_eq!((img.width(), img.height()), (80, 20));
assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255));
}
#[test]
fn export_rect_refuses_what_cannot_be_exported() {
let mut s = session(200.0, 400.0, 1);
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let bad = [
json!({"format": "png", "rect": [0, 0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, -5, 10]}),
json!({"format": "png", "rect": [0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, "a", 10]}),
json!({"format": "png", "rect": "20,40,40,300"}),
json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}),
json!({"format": "png", "rect": [0, 0, 100000, 100000]}),
json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}),
json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}),
json!({"format": "svg", "rect": [0, 0, 10, 10]}),
];
for p in bad {
assert!(s.execute("document.export", &p).is_err(), "{p}");
}
}
+69 -11
View File
@@ -82,20 +82,42 @@ impl Cell {
}
}
/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside).
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
let mut parts = vec![];
let mut quoted = false;
let mut start = 0;
for (i, ch) in s.char_indices() {
if ch == '"' {
quoted = !quoted;
} else if ch == sep && !quoted {
parts.push(s.get(start..i).unwrap_or_default());
start = i + ch.len_utf8();
}
}
parts.push(s.get(start..).unwrap_or_default());
parts
}
/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed.
fn cell(c: &str) -> Cell {
let c = c.trim();
match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) {
Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true },
None => Cell { text: c.trim_matches('"').to_string(), quoted: false },
}
}
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
let rows: Vec<Vec<Cell>> = csv
.lines()
// Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes.
let rows: Vec<Vec<Cell>> = split_unquoted(csv, '\n')
.into_iter()
.map(|l| l.trim_end_matches('\r'))
.filter(|l| !l.trim().is_empty())
.map(|l| {
let sep = if l.contains('\t') { '\t' } else { ',' };
l.split(sep)
.map(|c| {
let c = c.trim();
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
Cell { text: c.trim_matches('"').to_string(), quoted }
})
.collect()
let tabs = split_unquoted(l, '\t');
let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') };
parts.into_iter().map(cell).collect()
})
.collect();
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
@@ -192,7 +214,9 @@ fn drop_empty_rows(g: &mut GraphSpec) {
fn fmt_value(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
format!("{}", v.round() as i64)
// Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so
// `{:.0}` prints its exact digits.
if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") }
} else {
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
}
@@ -1900,6 +1924,16 @@ mod tests {
use crate::{NodeId, Session};
#[test]
fn fmt_value_does_not_saturate_beyond_i64() {
assert_eq!(super::fmt_value(1e19), "10000000000000000000");
assert_eq!(super::fmt_value(2e19), "20000000000000000000");
assert_eq!(super::fmt_value(-2e19), "-20000000000000000000");
assert_eq!(super::fmt_value(42.0), "42");
assert_eq!(super::fmt_value(-0.0), "0");
assert_eq!(super::fmt_value(2.5), "2.5");
}
fn graph(s: &mut Session, ty: &str) -> NodeId {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
NodeId(
@@ -2358,6 +2392,30 @@ mod tests {
assert_eq!(cells, g.cells(), "{csv}");
}
#[test]
fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]);
let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]);
let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]);
let r = s
.execute(
"graph.create",
&json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}),
)
.unwrap();
let id = r["id"].as_u64().unwrap();
let before = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(before["series"], series);
assert_eq!(before["categories"], categories);
s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap();
let after = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(after["series"], before["series"], "{}", before["csv"]);
assert_eq!(after["categories"], before["categories"], "{}", before["csv"]);
assert_eq!(after["rows"], before["rows"], "{}", before["csv"]);
}
#[test]
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
+37 -1
View File
@@ -5,7 +5,8 @@
//! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn
//! around them (`area`), and changes which member is drawn on top there: each member keeps the
//! area where it is drawn over the others (`NodeExtras::intertwine`, see
//! `vectorcraft_doc::intertwine`).
//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it
//! draws ([`expand_intertwines`]), which Ungroup takes apart.
//!
//! An overlap area is a face of the planar arrangement of what the members paint (their fills and
//! strokes, type outlined; other objects by their bounds): the part where two or more of them
@@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- Expand ----------
/// Replace Intertwine group `id` by a plain group of the art it draws
/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a
/// clip group), keeping everything about the group itself; the generated art gets ids of its own.
fn expand_group(d: &mut Document, id: NodeId) -> Result<()> {
let Some(mut g) = d.node(id).cloned() else { return Ok(()) };
let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) };
g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false };
super::live::fix_ids(d, &mut g);
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
Ok(())
}
/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art
/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how
/// many. A live Intertwine is expanded before it can be ungrouped:
/// <https://community.adobe.com/questions-652/expand-ungroup-800281>.
pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result<usize> {
let mut groups = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if is_intertwine(c) {
groups.push(c.id);
}
});
}
}
for g in groups.iter().rev() {
expand_group(d, *g)?;
}
Ok(groups.len())
}
// ---------- Arrange ----------
#[derive(Clone, Copy, PartialEq)]
+2 -2
View File
@@ -834,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let now = rect_from(p, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
@@ -933,8 +933,8 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let st = s.doc()?;
let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? };
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
+13 -2
View File
@@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
if ids.len() < 2 {
return Err(bad(C, "highlight two or more rows to merge"));
}
let into =
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
// The last highlighted layer that is not inside another highlighted row: with a layer and its
// sublayer both highlighted (Select All in the panel), the sublayer travels with its layer
// instead of becoming the target (#2383).
let roots: HashSet<NodeId> = row_roots(d, &ids).into_iter().collect();
let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer);
let into = ids
.iter()
.rev()
.copied()
.filter(is_layer)
.find(|id| roots.contains(id))
.or_else(|| ids.iter().rev().copied().find(is_layer))
.ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
let around = d.ancestry(into).unwrap_or_default();
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
+1 -1
View File
@@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<Vec<No
}
/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids.
fn fix_ids(d: &mut Document, n: &mut Node) {
pub(super) fn fix_ids(d: &mut Document, n: &mut Node) {
if n.id == NodeId(0) {
n.id = d.alloc_id();
}
+12 -3
View File
@@ -664,9 +664,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
}
// Print rasters run to gigabytes decoded: the same allowance opening them has.
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
.with_guessed_format()
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
let reader = || {
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
.with_guessed_format()
.map_err(|_| bad(C, "the image's pixels can't be read"))
};
// The image's size as RGBA, read from its header, is held to that allowance before anything
// is decoded (#1717).
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
}
let mut reader = reader()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
reader.limits(limits);
+7
View File
@@ -103,6 +103,11 @@ impl PreviewMode {
}
}
/// A saved preset's raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
Ok(Some(f64::deserialize(d)?).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0))
}
/// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -125,6 +130,8 @@ pub struct DocSettings {
pub bleed: [f64; 4],
pub background: Background,
pub color_mode: ColorMode,
/// 1 to 2400 ppi (a saved preset's value outside that range is read as the nearer end).
#[serde(deserialize_with = "de_ppi")]
pub raster_effects_ppi: f64,
pub preview_mode: PreviewMode,
}
+17 -6
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
let i = artboard_index(&s.doc()?.doc, p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
@@ -70,10 +70,21 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
/// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy
/// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option.
fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
/// An `index` that names no artboard is an error.
fn board_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(usize, bool)> {
let t = s.tool_options_of("artboard");
let i = p.get("index").and_then(Value::as_u64).or_else(|| t["active"].as_u64()).unwrap_or(0) as usize;
(i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))
let i = match p.get("index") {
Some(_) => artboard_index(&s.doc()?.doc, p, cmd)?,
None => t["active"].as_u64().unwrap_or(0) as usize,
};
Ok((i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
}
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
/// [`artboard_indices`] (`missing index`, `no such artboard`).
pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> {
let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?;
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
}
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
@@ -106,7 +117,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
}
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let (i, art) = board_and_art(s, p, "artboard.duplicate")?;
let art = artboard_art(s, i, art)?;
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() };
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
@@ -119,7 +130,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
/// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut
/// then deletes them.
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let (i, art) = board_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
let art = artboard_art(s, i, art)?;
let st = s.doc()?;
if cut && st.doc.artboards.len() <= 1 {
+11 -1
View File
@@ -170,6 +170,8 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 {
/// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out
/// of the image, which then only holds what the effects add around it (a shadow to go under the
/// vector object). The document's raster effects settings add room around it and finish its pixels.
/// `None` when it paints nothing, when the area it covers overflows `f64` or the scale is not
/// positive, or when its PNG can't be encoded.
pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option<Node> {
// What it paints (its members' effects included), and how far its fills' and strokes' raster
// effects reach beyond that.
@@ -177,8 +179,16 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
let look = out.raster_effects.clone();
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
let b = b.inflate(reach, reach);
// An area too large for f64 gives a zero scale and an image transform that is not finite.
let area = b.width() * b.height();
if !area.is_finite() {
return None;
}
// Whole pixels, and no larger than 64 Mpx.
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt());
if !(scale.is_finite() && scale > 0.0) {
return None;
}
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
// Anti-alias off: hard edges (the effects themselves stay smooth).
let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
+5 -2
View File
@@ -345,7 +345,7 @@ static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64
/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold.
/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only
/// its top-level layers open.
/// its top-level layers open (a `.ai` another app wrote opens with none open).
#[derive(Clone, Debug, Default)]
pub struct OpenRows {
ids: std::collections::HashSet<NodeId>,
@@ -1007,7 +1007,7 @@ impl Default for Prefs {
font_preview_size: s("medium"),
recent_fonts_count: 10,
missing_glyph_protection: true,
highlight_alternate_glyphs: true,
highlight_alternate_glyphs: false,
placeholder_text: true,
units_general: s("points"),
units_stroke: s("points"),
@@ -1198,6 +1198,8 @@ pub struct Session {
pub(crate) plane_widget_press: bool,
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
/// Which selected type overflows its frame, for the markers every tool but the Type tools shows.
pub(crate) overflow_marks: vectorcraft_tools::text::OverflowMarks,
/// The last search for the files of missing fonts (`text.findFontFiles`), until another
/// starts; dropping it stops it.
pub(crate) font_search: Option<cmd::fontfiles::FontSearch>,
@@ -1268,6 +1270,7 @@ impl Session {
liquify_stroke: None,
plane_widget_press: false,
ruler_guide: None,
overflow_marks: Default::default(),
font_search: None,
link_search: None,
search_rules: None,
+61
View File
@@ -151,6 +151,67 @@ fn release_gives_the_objects_back_as_they_were() {
assert!(red(&render(&s), 50, 30));
}
/// #2540: Object › Expand turns the group into a plain group of what it draws (the rings, then the
/// part of A drawn over B as a clip group) in one undo step, looking the same; Ungroup then takes
/// it apart.
#[test]
fn expand_turns_the_group_into_plain_art_that_ungroups() {
let mut s = session();
let (g, a, b) = intertwined(&mut s);
arrange(&mut s, json!({"at": [50, 30]}));
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true));
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
let NodeKind::Group { children, clip: false } = &n.kind else { panic!("a group: {}", n.kind_label()) };
assert_eq!(children.len(), 3, "the rings, then the part of A drawn over B");
assert_eq!((children[0].id, children[1].id), (a, b), "the rings keep their ids and order");
assert!(matches!(children[2].kind, NodeKind::Group { clip: true, .. }));
assert!(children.iter().all(|c| c.extras().intertwine.is_none()), "no area is left over");
let mut ids = vec![];
n.walk(&mut |c| ids.push(c.id));
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert!(unique.len() == ids.len() && !ids.contains(&NodeId(0)), "the generated art has ids of its own: {ids:?}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "nothing live is left");
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
// Plain art: Ungroup takes it apart.
s.execute("object.ungroup", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
assert_eq!(s.doc().unwrap().selection.objects.len(), 3);
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70));
// Undo brings the Intertwine group back.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(s.doc().unwrap().doc.node(g).unwrap().kind, NodeKind::Intertwine { .. }));
}
/// Expand reaches an Intertwine group inside a selected group, and with every option on also
/// outlines the strokes of the rings and of the part drawn over the other one.
#[test]
fn expand_reaches_intertwine_groups_in_groups_and_outlines_their_strokes() {
let mut s = session();
let (g, ..) = intertwined(&mut s);
arrange(&mut s, json!({"at": [50, 30]}));
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap());
select(&mut s, &[g, c]);
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
s.execute("object.expand", &json!({})).unwrap();
let (mut intertwines, mut strokes) = (0, 0);
s.doc().unwrap().doc.node(outer).unwrap().walk(&mut |n| {
intertwines += usize::from(matches!(n.kind, NodeKind::Intertwine { .. }));
strokes += usize::from(!n.appearance.stroke_paint().is_none());
});
assert_eq!((intertwines, strokes), (0, 0));
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
let info = s.execute("object.expand.info", &json!({})).unwrap();
assert_eq!((&info["object"], &info["stroke"]), (&json!(false), &json!(false)), "nothing is left to expand");
}
#[test]
fn the_arranged_areas_move_with_the_group() {
let mut s = session();
+31 -1
View File
@@ -578,7 +578,11 @@ fn collect_merge_flatten_and_reverse() {
run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]}));
assert!(s.doc().unwrap().doc.node(col).is_none());
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer");
// A layer and its sublayer: the sublayer merges into its layer, never the other way round.
run(&mut s, "layer.merge", json!({"ids": [layer.0, sub.0]}));
assert!(s.doc().unwrap().doc.node(sub).is_none());
assert_eq!(children(&s, layer), vec![c, a, g]);
run(&mut s, "edit.undo", json!({}));
assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err());
// Flatten: other visible layers merge in, hidden ones go, templates stay.
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
@@ -595,6 +599,32 @@ fn collect_merge_flatten_and_reverse() {
assert_eq!(undos(&s), before + 1);
}
/// Merge Selected with every row highlighted (a layer, its sublayer and another layer, in panel
/// order) merges the layers into the last top-level one instead of refusing a sublayer target (#2383).
#[test]
fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level_layer() {
for reversed in [false, true] {
let mut s = session();
let (layer, sub, a, _, _, c) = tree(&mut s);
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
let d = rect(&mut s, 500.0);
// Panel order, top first: Layer 2, Layer 1, its sublayer.
let mut rows = vec![l2.0, layer.0, sub.0];
if reversed {
rows.reverse();
}
run(&mut s, "layer.highlight", json!({"ids": rows}));
let into = id(&run(&mut s, "layer.merge", json!({})));
let st = s.doc().unwrap();
assert_eq!(st.doc.layers.len(), 1, "reversed: {reversed}");
assert_eq!(into, st.doc.layers[0].id);
for n in [a, c, d] {
assert!(st.doc.layer_of(n) == Some(into), "{n:?} reversed: {reversed}");
}
assert!(st.doc.node(sub).is_some() || st.doc.node(layer).is_some() || st.doc.node(l2).is_some());
}
}
/// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a
/// layer, and on top of the layer holding its group when it is in a group. When that row is in a
/// hidden or locked layer, the selected art it collects from another layer is deselected, the key
+1 -1
View File
@@ -19,7 +19,7 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId {
/// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that
/// placing and listing read).
fn png_header(w: u32, h: u32) -> Vec<u8> {
pub(crate) fn png_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
+43
View File
@@ -736,6 +736,31 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
}
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names
/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as
/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
/// artboard.
#[test]
fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let (was, n) = (boards(&s), undo_len(&s));
for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] {
for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] {
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
}
}
let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string();
assert_eq!(e, "invalid parameters for `artboard.setProps`: missing index");
assert_eq!((boards(&s), undo_len(&s)), (was.clone(), n), "the artboards and the undo steps as they were");
s.execute("artboard.fitToSelection", &json!({})).unwrap();
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 150.0, 150.0), was[1]], "the first artboard by default");
}
// ---------- Edit Colors ----------
#[test]
@@ -2423,6 +2448,24 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
assert_eq!(bounds(&s, img), b);
}
#[test]
fn crop_image_trim_checks_the_rgba_size_before_decoding() {
// 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA
// (4 bytes a pixel) they are just over or just within the 2 GB allowance.
let trim = |w: u32, h: u32| {
let png = super::tests_linkspanel::png_header(w, h);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.cropImage", &json!({"trim": true})).unwrap_err().to_string()
};
let over = trim(23_171, 23_170);
assert!(over.contains("an image of 23171 × 23170 pixels is too large to draw"), "{over}");
// One column fewer is within the allowance: it is decoded, and its missing rows can't be read.
let within = trim(23_170, 23_170);
assert!(within.contains("the image's pixels can't be read"), "{within}");
}
#[test]
fn crop_image_trim_leaves_a_cmyk_image_alone() {
// CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
+11
View File
@@ -225,6 +225,17 @@ fn saved_presets_live_in_the_preferences() {
assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err());
}
#[test]
fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() {
let mut s = Session::new();
let saved = json!({"newDocPresets": [{"name": "Zero", "rasterEffectsPpi": 0}, {"name": "Huge", "rasterEffectsPpi": 5000}]});
s.apply_prefs(serde_json::from_value(saved).unwrap());
let ppi: Vec<Option<f64>> = presets(&mut s, "Saved").iter().map(|p| p["rasterEffectsPpi"].as_f64()).collect();
assert_eq!(ppi, [Some(1.0), Some(2400.0)]);
s.execute("file.new", &json!({"preset": "Zero"})).unwrap();
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0);
}
#[test]
fn rearrange_keeps_its_grid() {
let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3}));
+21
View File
@@ -130,6 +130,27 @@ fn duplicate_artboard_copies_its_art() {
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
}
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
/// artboards, the art and the clipboard stay as they were.
#[test]
fn artboard_copies_refuse_an_index_that_names_no_artboard() {
let mut s = session();
rect(&mut s);
s.execute("artboard.new", &json!({})).unwrap();
s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap());
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] {
for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] {
let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) };
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
}
}
let st = s.doc().unwrap();
assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n));
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboard.is_none(), "nothing copied");
}
/// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places
/// it right of the last artboard, Paste in Place where it was, also in another document; each paste
/// is one undo step, and the tool takes the pasted artboard.
+27 -13
View File
@@ -391,13 +391,22 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
("pen", Cursor::Pen),
("addAnchor", Cursor::PenAdd),
("deleteAnchor", Cursor::PenDelete),
("anchorPoint", Cursor::Pen),
("curvature", Cursor::Pen),
("mesh", Cursor::PenAdd),
("anchorPoint", Cursor::AnchorPoint),
("curvature", Cursor::Curvature),
("mesh", Cursor::MeshAdd),
("eyedropper", Cursor::Eyedropper),
("slice", Cursor::Slice),
("blend", Cursor::Blend),
("shapeBuilder", Cursor::ShapeBuilder),
("paintbrush", Cursor::Paintbrush),
("blobBrush", Cursor::BlobBrush),
("shaper", Cursor::Shaper),
("pencil", Cursor::Pencil),
("smooth", Cursor::Smooth),
("pathEraser", Cursor::PathEraser),
("join", Cursor::Join),
("eraser", Cursor::Eraser),
("knife", Cursor::Knife),
];
for (tool, c) in own {
assert_eq!(cursor(&mut s, tool), c, "{tool} off");
@@ -415,15 +424,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
"polygon",
"star",
"flare",
"paintbrush",
"blobBrush",
"shaper",
"pencil",
"smooth",
"pathEraser",
"join",
"eraser",
"knife",
"mirrorCut",
"lineCut",
"rectCut",
@@ -440,7 +440,7 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on");
}
assert_eq!(cursor(&mut s, "type"), Cursor::Text);
assert_eq!(cursor(&mut s, "scissors"), Cursor::NotAllowed);
assert_eq!(cursor(&mut s, "scissors"), Cursor::ScissorsNotAllowed);
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
for (tool, c) in own {
assert_eq!(cursor(&mut s, tool), c, "{tool} off again");
@@ -467,6 +467,8 @@ fn precise_cursors_replace_every_pen_state() {
Cursor::BlendAnchor,
Cursor::ShapeBuilder,
Cursor::ShapeBuilderErase,
Cursor::AnchorPoint,
Cursor::Curvature,
] {
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
}
@@ -1284,6 +1286,18 @@ fn show_font_names_in_english_preference_is_stored() {
assert!(s.prefs.font_names_in_english);
}
/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so OpenType
/// substitutions are not pink on every canvas until the user turns the preference on.
#[test]
fn highlight_alternate_glyphs_preference_is_off_by_default() {
let mut s = Session::new();
assert!(!s.prefs.highlight_alternate_glyphs, "off by default");
set_pref(&mut s, "highlightAlternateGlyphs", json!(true));
assert!(s.prefs.highlight_alternate_glyphs);
set_pref(&mut s, "highlightAlternateGlyphs", json!(false));
assert!(!s.prefs.highlight_alternate_glyphs);
}
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
#[test]
+100
View File
@@ -2,6 +2,7 @@
//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults.
use serde_json::{Value, json};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings};
use super::*;
@@ -98,6 +99,60 @@ fn add_around_grows_the_effect_image() {
assert_eq!((after.width() - before.width(), after.height() - before.height()), (72, 72));
}
/// The transforms of the images in `doc` flattened for output.
fn image_transforms(doc: &Document) -> Vec<[f64; 6]> {
let mut xfs = vec![];
crate::flatten_raster_effects(doc).unwrap().walk(|n| {
if let NodeKind::Image(im) = &n.kind {
xfs.push(im.xf.as_coeffs());
}
});
xfs
}
#[test]
fn a_file_with_add_around_out_of_range_opens_with_it_in_range() {
let mut s = session();
shadowed(&mut s);
let mut d = (*s.doc().unwrap().doc).clone();
d.raster_effects.add_around = 1e300;
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap();
assert_eq!(back.raster_effects.add_around, MAX_ADD_AROUND);
let xfs = image_transforms(&back);
assert!(!xfs.is_empty() && xfs.iter().flatten().all(|c| c.is_finite()), "{xfs:?}");
// 1,000 pt on every side at 72 ppi.
assert_eq!(effect_image(&back).width() - effect_image(&s.doc().unwrap().doc).width(), 2000);
d.raster_effects.add_around = -5.0;
assert_eq!(vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap().raster_effects.add_around, 0.0);
}
#[test]
fn art_too_large_for_f64_gets_no_effect_image() {
let mut s = session();
let r = shadowed(&mut s);
// Settings out of range in a document made without opening a file.
let mut d = (*s.doc().unwrap().doc).clone();
d.raster_effects.add_around = 1e300;
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
let mut d = (*s.doc().unwrap().doc).clone();
let NodeKind::Path { path, live, .. } = &mut d.node_mut(r).unwrap().kind else { panic!("a path") };
(*path, *live) = (vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(-1e300, -1e300, 1e300, 1e300)), None);
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
// Expand Appearance of it by id, with nothing selected, puts no image into the document (the
// rectangle at its own size gets one).
let normal = (*s.doc().unwrap().doc).clone();
let mut expanded_images = |d: Document| {
let st = s.doc_mut().unwrap();
(st.doc, st.selection) = (std::sync::Arc::new(d), Default::default());
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
let mut images = 0;
s.doc().unwrap().doc.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
images
};
assert_eq!(expanded_images(normal), 1);
assert_eq!(expanded_images(d), 0);
}
#[test]
fn the_color_model_and_anti_alias_reach_the_pixels() {
let mut s = session();
@@ -158,3 +213,48 @@ fn raster_settings_round_trip_and_old_files_load() {
let back = vectorcraft_format::load(&bytes).unwrap();
assert_eq!((back.raster_effects_ppi, back.raster_effects), (72.0, RasterEffectsSettings::default()));
}
/// Save the active document with the raster effects resolution `ppi` and open the file.
fn reopen_with_ppi(s: &mut Session, ppi: f64) {
let mut d = (*s.doc().unwrap().doc).clone();
d.raster_effects_ppi = ppi;
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "ppi.vectorcraft", "dataBase64": data})).unwrap();
}
#[test]
fn a_file_with_the_resolution_out_of_range_opens_with_it_in_range() {
for (saved, opened, pixels) in [(0.0, 1.0, 1), (-5.0, 1.0, 1), (5000.0, 2400.0, 334), (f64::MAX, 2400.0, 334)] {
let mut s = session();
let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap());
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
reopen_with_ppi(&mut s, saved);
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, opened, "saved as {saved}");
assert_eq!(s.execute(C, &json!({})).unwrap()["resolution"], opened);
assert_eq!(s.execute("document.info", &json!({})).unwrap()["document"]["rasterEffectsPpi"], opened);
// Rasterize without a ppi param renders the 10 pt square at it.
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let out = s.execute("object.rasterize", &json!({})).unwrap();
assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(pixels), Some(pixels)), "saved as {saved}");
}
}
#[test]
fn a_gradient_exports_at_the_resolution_the_file_opens_with() {
for saved in [0.0, -5.0] {
let mut s = session();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 720, "height": 720})).unwrap());
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [r.0]})).unwrap();
reopen_with_ppi(&mut s, saved);
// EMF writes the gradient as an image at the raster effects resolution: 720 pt at 1 ppi.
let region = vectorcraft_geom::Rect::new(0.0, 0.0, 720.0, 720.0);
let out = vectorcraft_metafile::export(&s.doc().unwrap().doc, region, vectorcraft_metafile::Kind::Emf).unwrap();
let mut sizes = vec![];
vectorcraft_metafile::import(&out.bytes).unwrap().document.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
sizes.push((im.width, im.height));
}
});
assert_eq!(sizes, [(10, 10)], "saved as {saved}");
}
}
+20
View File
@@ -737,3 +737,23 @@ fn a_colour_previewed_while_typing_keeps_the_typing() {
s.tool_text("d", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abcd");
}
/// The red out port of selected overflowing area type is drawn with the Selection tool too, and
/// not twice with the Type tool, which draws it itself (#2323).
#[test]
fn overflow_marker_shows_with_the_selection_tool() {
let mut s = session();
let v = ViewInfo::default();
let long = "Words that fill more than the frame holds. ".repeat(20);
let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.select_tool("selection", v).unwrap();
let red = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Path { color: [230, 30, 30], .. })).count();
assert_eq!(red(&mut s), 1, "the out port");
s.select_tool("type", v).unwrap();
assert_eq!(red(&mut s), 1, "the Type tool's own, not a second one");
s.execute("select.set", &json!({"ids": []})).unwrap();
s.select_tool("selection", v).unwrap();
assert_eq!(red(&mut s), 0, "nothing selected");
}
+6
View File
@@ -399,6 +399,12 @@ impl Session {
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
// The Type tools draw the overflow markers of selected type themselves.
if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") {
let mut marks = std::mem::take(&mut self.overflow_marks);
v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::overflow_markers(&mut marks, cx)));
self.overflow_marks = marks;
}
if let Some(g) = self.ruler_guide.take() {
v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx)));
self.ruler_guide = Some(g);
+5
View File
@@ -189,6 +189,11 @@ fn every_command_with_junk_params() {
fn structured_junk() {
let cases: Vec<(&str, Value)> = vec![
("select.set", json!({"ids": [0, u64::MAX, -1, "x", null, 1.5]})),
("document.export", json!({"format": "png", "rect": [1e308, -1e308, 1e308, 1e308]})),
("document.export", json!({"format": "png", "rect": [0, 0, 1e300, 1e300], "scale": 64})),
("document.export", json!({"format": "tiff", "rect": [null, "x", -1, 0.0000001], "useArtboards": true})),
("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})),
("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})),
("select.add", json!({"ids": [[1], {"a": 1}]})),
("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})),
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})),
+1 -1
View File
@@ -102,7 +102,7 @@ fn alt_at_another_endpoint_reports_conversion_and_preserves_objects_through_undo
assert_eq!(node(&s, b), &before_b);
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
// Check the captured cursor last: a mismatch must not hide the actual conversion/history controls.
assert_eq!(advertised, Cursor::PenConvert, "Alt cursor must describe the conversion actually performed, not Join");
assert_eq!(advertised, Cursor::AnchorPoint, "Alt cursor must describe the conversion actually performed, not Join");
}
#[test]
+3
View File
@@ -552,8 +552,11 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
// Untrusted: capped, named and pointing at objects the file has.
doc.tidy_saved_selections();
doc.tidy_graph_designs();
doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND);
let mut depth_notes = vec![];
doc.limit_depth_noting(&mut depth_notes);
// Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect();
Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
}
+1 -1
View File
@@ -225,7 +225,7 @@ fn approx_equal_documents_compare_equal() {
fn bug_f64_bit_exact_roundtrip() {
check_native_roundtrip_exact(&rich_doc()).unwrap();
let mut d = Document::new(126.86291501015239, 214.69463130731182);
d.raster_effects_ppi = 0.1 + 0.2;
d.raster_effects_ppi = (0.1 + 0.2) * 1000.0;
check_native_roundtrip_exact(&d).unwrap();
}
+15
View File
@@ -1072,6 +1072,21 @@ fn show_font_names_in_english_does_not_change_font_list_for_agents() {
assert!(families.iter().any(|f| f.as_str() == Some("Source Sans 3")), "{families:?}");
}
/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so the canvas stays
/// clear until an agent (or the Preferences dialog) turns it on. Headless MCP has no canvas.
#[test]
fn highlight_alternate_glyphs_pref_round_trips_for_agents() {
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "prefs.get", "params": {"key": "highlightAlternateGlyphs"}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap(), json!(false));
let r = call(&mut s, 2, "run_command", json!({"command": "prefs.set", "params": {"key": "highlightAlternateGlyphs", "value": true}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 3, "run_command", json!({"command": "prefs.get", "params": {"key": "highlightAlternateGlyphs"}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap(), json!(true));
}
/// Type preferences reach agents (#394): type the Type tool places starts with placeholder text,
/// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading.
#[test]
+1 -1
View File
@@ -494,7 +494,7 @@ fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, s
tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::<f64>()) / rest } else { 0.0 };
}
own.sort_by_key(|(b, _)| *b);
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round();
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 10000.0).round();
(em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect())
}
+4 -4
View File
@@ -36,7 +36,7 @@ use kurbo::{Affine, Rect};
use serde::Serialize;
use vectorcraft_color::cms::{self, ProfileKind};
use vectorcraft_doc::marks::PrinterMarks;
use vectorcraft_doc::overprint::{clear_overprints, multiply_overprints};
use vectorcraft_doc::overprint::{clear_overprints, darken_overprints};
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
pub use layout::{MAX_TILES, TileGrid};
@@ -167,7 +167,7 @@ pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, D
}
/// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated
/// (drawn with Multiply, as Overprint Preview shows them), after which nothing overprints any
/// (drawn with Darken, as Overprint Preview shows them), after which nothing overprints any
/// more; or preserved. Separations always honour them.
fn composite_overprints(doc: &mut Document, set: &PrintSettings) {
let how = set.advanced.overprints;
@@ -179,7 +179,7 @@ fn composite_overprints(doc: &mut Document, set: &PrintSettings) {
let nodes = doc.layers.iter_mut().chain(symbols).chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut()));
for n in nodes {
if how == PrintOverprints::Simulate {
multiply_overprints(n, discard_white);
darken_overprints(n, discard_white);
}
clear_overprints(n);
}
@@ -411,7 +411,7 @@ fn print_inner(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfEr
ex.intent = opts.settings.color.intent;
// The writer's colour output: the printer profile's conversion.
ex.out = w.out.clone();
// Composite output keeps overprints as the Advanced option says (plates multiply them).
// Composite output keeps overprints as the Advanced option says (plates darken them).
ex.overprint = !separations && pdf.advanced.overprint == Overprint::Preserve;
}
for &i in &plan.order {
+3 -3
View File
@@ -7,7 +7,7 @@ use vectorcraft_color::Color;
use vectorcraft_color::cms::{self, Cms, Model, ProfileKind};
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors};
use vectorcraft_doc::overprint::multiply_overprints;
use vectorcraft_doc::overprint::darken_overprints;
use vectorcraft_doc::{ColorMode, Document, Node};
use super::{DEFAULT_FREQUENCY, PrintInk, PrintSettings};
@@ -93,14 +93,14 @@ impl<'a> Separator<'a> {
}
/// The document as plate `plate`: art, symbols, pattern tiles and images in the grey of the
/// ink's coverage. Overprinting fills and strokes multiply, so where they have none of this
/// ink's coverage. Overprinting fills and strokes darken, so where they have none of this
/// ink the plate below shows through instead of being knocked out.
pub fn plate(&self, plate: &str) -> Document {
let mut d = self.doc.clone();
let discard_white = d.setup.discard_white_overprint;
let nodes = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| p.art.iter_mut()));
for n in nodes {
multiply_overprints(n, discard_white);
darken_overprints(n, discard_white);
self.node(plate, Arc::make_mut(n));
}
for blob in d.images.values_mut() {
+1 -1
View File
@@ -340,7 +340,7 @@ fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_
assert_ne!(converted, flat(ink, 16));
let source = cms::active();
let dest = cms::Cms::new(&cms::ColorSettings { cmyk: DEVICE_CMYK.into(), ..source.settings().clone() }).unwrap();
let want = dest.lab_to_cmyk(source.cmyk_to_lab(ink.map(|v| v as f32 / 255.0)), source.settings().intent).map(|v| (v * 255.0).round() as u8);
let want = dest.lab_to_cmyk(source.cmyk_display_lab(ink.map(|v| v as f32 / 255.0)), source.settings().intent).map(|v| (v * 255.0).round() as u8);
assert!(close(&converted[..4], &want, 3), "{converted:?} vs {want:?}");
// To RGB: the colour shown on screen.
let (w, blob, _) = first(json!({"output": {"conversion": "destination", "destination": SRGB}}));
+14
View File
@@ -191,6 +191,20 @@ fn spaces_read_from_wide_gaps_keep_the_glyphs_after_them_in_place() {
assert!((at(9) - f64::from(x2)).abs() < 0.05, "the third group starts where the file has it: {} vs {x2}", at(9));
}
#[test]
fn words_set_ems_apart_keep_their_place() {
// Gaps of about 1.75 and 2.6 em (#2384): each space's own tracking is more than an em.
let (x1, x2) = (48.0, 84.0);
let bytes = source_sans(&[(20.0, 50.0, 10.0, "ab"), (x1, 50.0, 10.0, "cd"), (x2, 50.0, 10.0, "ef")]);
let t = texts(&import(&bytes).unwrap());
assert_eq!(t.len(), 1);
assert_eq!(t[0].plain_text(), "ab cd ef");
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]);
let at = |byte: usize| (t[0].xf * laid.glyphs.iter().find(|g| g.byte == byte).unwrap().origin).x;
assert!((at(3) - f64::from(x1)).abs() < 0.05, "the second word starts where the file has it: {} vs {x1}", at(3));
assert!((at(6) - f64::from(x2)).abs() < 0.05, "the third word starts where the file has it: {} vs {x2}", at(6));
}
/// A document of area type `text` (Source Sans 3, 10 pt) `width` wide at (20, 30).
fn area_doc(text: &str, justify: Justify, width: f64, space_before: f64) -> Document {
let style = CharStyle { size: 10.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
+2 -2
View File
@@ -98,13 +98,13 @@ fn discarded_and_simulated_overprints_write_no_overprint() {
assert!(preserve.warnings.is_empty(), "{:?}", preserve.warnings);
assert!(String::from_utf8_lossy(&preserve.bytes).contains("/OP true/op true/OPM 1"), "preserved overprints overprint in the file");
assert!(overprints(&printed_document(&d, &settings(json!({})))), "preserved");
for (how, multiplies) in [("discard", false), ("simulate", true)] {
for (how, darkens) in [("discard", false), ("simulate", true)] {
let set = json!({"advanced": {"overprints": how}});
assert!(!overprints(&printed_document(&d, &settings(set.clone()))), "{how}: nothing overprints");
let r = job(&d, set);
let text = String::from_utf8_lossy(&r.bytes);
assert!(!text.contains("/OP"), "{how}: no overprint in the file");
assert_eq!(text.contains("/Multiply"), multiplies, "{how}");
assert_eq!(text.contains("/Darken"), darkens, "{how}");
assert!(!r.warnings.iter().any(|w| w.contains("knockouts")), "{how}: {:?}", r.warnings);
}
// Separations honour overprints whatever the option says.
+1 -1
View File
@@ -153,7 +153,7 @@ pub(crate) struct InkLut(CmykLut);
impl InkLut {
fn build(cms: &Cms) -> Self {
Self(CmykLut::build(|inks| {
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_to_lab(inks));
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(inks));
[x, y, z, 0.0]
}))
}
+44 -16
View File
@@ -123,6 +123,10 @@ pub struct RenderOptions {
pub mask_view: Option<NodeId>,
/// Screen view: highlight substituted fonts and glyphs as Document Setup asks.
pub highlight_substitutions: bool,
/// Screen view: Preferences › Type › Highlight Alternate Glyphs. Pink behind glyphs OpenType
/// features substituted (ligatures, contextual forms), not Document Setup's missing-glyph
/// highlights.
pub highlight_alternate_glyphs: bool,
/// Edge smoothing (raster export option).
pub anti_alias: AntiAlias,
/// Screen view: placed documents draw from cached bitmaps made in the background (see
@@ -227,6 +231,7 @@ impl Default for RenderOptions {
guide: ([0x4a, 0xd8, 0xff], false),
mask_view: None,
highlight_substitutions: false,
highlight_alternate_glyphs: false,
anti_alias: AntiAlias::Art,
progressive_placed: false,
trace_views: false,
@@ -1768,12 +1773,12 @@ impl Renderer {
return;
}
let tb = t.xf.transform_rect_bbox(g.bounds);
// Overprinting characters multiply per draw, like overprinting fills and strokes
// Overprinting characters darken per draw, like overprinting fills and strokes
// ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer.
let overprints = proof::overprints(f.opts);
let overprint = |ctx: &mut RenderContext, on: bool| {
if overprints {
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() });
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Darken) } else { BlendMode::default() });
}
};
// The object's own fills and strokes paint the whole outline: those below the Characters
@@ -1789,8 +1794,11 @@ impl Renderer {
}
if f.opts.highlight_substitutions {
let setup = &f.doc.setup;
for (on, path) in [(setup.highlight_substituted_fonts, &g.substituted_fonts), (setup.highlight_substituted_glyphs, &g.substituted_glyphs)]
{
for (on, path) in [
(setup.highlight_substituted_fonts, &g.substituted_fonts),
(setup.highlight_substituted_glyphs, &g.substituted_glyphs),
(f.opts.highlight_alternate_glyphs, &g.alternate_glyphs),
] {
if on && !path.elements().is_empty() {
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
@@ -2218,6 +2226,9 @@ struct TextGeom {
/// font lacks (drawn from a fallback font): Document Setup's substitution highlights.
substituted_fonts: BezPath,
substituted_glyphs: BezPath,
/// Boxes of glyphs OpenType features substituted (ligatures, contextual forms): Preferences ›
/// Highlight Alternate Glyphs.
alternate_glyphs: BezPath,
/// Inline graphics placed by the layout.
inlines: Vec<vectorcraft_text::InlineGlyph>,
}
@@ -2243,12 +2254,18 @@ impl TextGeom {
/// Approximate heap bytes, for the cache's budget.
fn bytes(&self) -> usize {
size_of::<Self>()
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs, &self.alternate_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
+ self.spans.len() * size_of::<(usize, usize, usize)>()
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
}
}
/// OpenType-substituted form: a ligature (several source characters → one glyph) or a glyph whose
/// id is not the face's cmap mapping for its first character (contextual / stylistic alternate).
pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_chars: usize) -> bool {
shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid)
}
fn text_geom(t: &TextObject) -> TextGeom {
text_geom_snapped(t, None)
}
@@ -2288,30 +2305,39 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
None => (true, None),
})
.collect();
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_glyphs) = (BezPath::new(), BezPath::new(), BezPath::new());
let plain = t.plain_text();
for g in &layout.glyphs {
push(&mut all, g.run, &g.outline);
let Some(&(missing, face)) = faces.get(g.run) else { continue };
let target = if missing {
&mut substituted_fonts
} else if face.is_some_and(|f| f != g.font_id) {
&mut substituted_glyphs
} else {
continue;
};
let Some(line) = layout.lines.get(g.line) else { continue };
let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1);
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
target.extend(cell.iter());
if missing {
substituted_fonts.extend(cell.iter());
continue;
}
if face.is_some_and(|f| f != g.font_id) {
substituted_glyphs.extend(cell.iter());
continue;
}
// Same face: OpenType may have swapped the glyph (liga, calt, …).
let Some(cluster) = plain.get(g.byte..g.byte.saturating_add(g.len)) else { continue };
let cluster_chars = cluster.chars().count();
let Some(first) = cluster.chars().next() else { continue };
let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0);
if is_opentype_alternate(nominal, g.gid, cluster_chars) {
alternate_glyphs.extend(cell.iter());
}
}
// Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
push(&mut all, run, &bar);
}
TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines }
TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, alternate_glyphs, inlines: layout.inlines }
}
/// Document Setup's highlight behind substituted fonts and glyphs (screen only).
/// Document Setup's and Preferences › Highlight Alternate Glyphs pink (screen only).
const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110);
/// The pixel size of `region` rendered at `scale` pixels per point (at least 1×1).
pub fn region_pixels(region: Rect, scale: f64) -> (u32, u32) {
@@ -2367,6 +2393,8 @@ mod tests;
#[cfg(test)]
mod tests_adjust;
#[cfg(test)]
mod tests_alternate;
#[cfg(test)]
mod tests_blend;
#[cfg(test)]
mod tests_charstroke;
+32 -14
View File
@@ -56,27 +56,45 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
/// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it.
const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
/// Images that couldn't be decoded (and so drew nothing), counted for exports to report.
static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// How many images have failed to decode so far (process-wide): an export compares the count
/// before and after to say it left images out.
pub fn decode_failures() -> u64 {
DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
thread_local! {
/// Images that couldn't be decoded (and so drew nothing) on this thread, counted for exports to
/// report.
static DECODE_FAILURES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`].
/// How many images have failed to decode on this thread so far: an export compares the count
/// before and after it draws to say it left images out. Images that other threads (another export,
/// the canvas, thumbnails) fail to decode meanwhile are not counted.
pub fn decode_failures() -> u64 {
DECODE_FAILURES.with(std::cell::Cell::get)
}
/// `bytes` decoded within [`MAX_DECODE_ALLOC`] ([`decode_within_allowance`]); a failure is counted
/// in [`decode_failures`].
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| {
let img = decode_within_allowance(bytes).ok();
if img.is_none() {
DECODE_FAILURES.with(|n| n.set(n.get() + 1));
}
img
}
/// `bytes` decoded within [`MAX_DECODE_ALLOC`], counted as RGBA: the image's size is read from its
/// header first, and an image of more than that as RGBA gets a limit error before anything is
/// decoded (#1717).
pub(crate) fn decode_within_allowance(bytes: &[u8]) -> image::ImageResult<image::DynamicImage> {
let reader = || -> image::ImageResult<_> {
let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_DECODE_ALLOC);
r.limits(limits);
r.decode().ok()
});
if img.is_none() {
DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(r)
};
let (w, h) = reader()?.into_dimensions()?;
if u64::from(w) * u64::from(h) > MAX_DECODE_ALLOC / 4 {
return Err(image::ImageError::Limits(image::error::LimitError::from_kind(image::error::LimitErrorKind::InsufficientMemory)));
}
img
reader()?.decode()
}
/// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels).
+5 -5
View File
@@ -11,7 +11,7 @@
//! Placed raster images are not separated (limitation).
//! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that
//! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with
//! Multiply, which approximates their inks printing over the inks below: a zero ink lets the
//! Darken, which approximates their inks printing over the inks below: a zero ink lets the
//! inks below show through, where a knockout would replace them.
//!
//! The app's current view state lives in [`view`] / [`set_view`]; the canvas copies it into
@@ -25,7 +25,7 @@ use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES};
pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget};
use vectorcraft_color::{Color, Swatch, SwatchGroup};
pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color};
use vectorcraft_doc::overprint::multiply_overprints;
use vectorcraft_doc::overprint::darken_overprints;
use vectorcraft_doc::{Document, Node, NodeId, Shared};
use crate::RenderOptions;
@@ -103,8 +103,8 @@ impl PartialEq for Plates {
}
}
/// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply
/// ([`multiply_overprints`]), and with Separations Preview every color shows as the visible plates
/// What [`prepare`] does to the fills and strokes of each node: overprinting ones darken
/// ([`darken_overprints`]), and with Separations Preview every color shows as the visible plates
/// print it ([`plate_color`]).
struct How<'a> {
doc: &'a Document,
@@ -122,7 +122,7 @@ impl How<'_> {
/// Change the fills, strokes and other colors of node `a`, which holds no members.
fn apply(&self, a: &mut Arc<Node>) {
multiply_overprints(a, self.discard_white);
darken_overprints(a, self.discard_white);
if let Some((c, proof, visible)) = self.plates {
map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw));
}
+84
View File
@@ -776,6 +776,23 @@ fn invalid_dash_pattern_strokes_solid() {
}
}
/// An image that can't be decoded is counted on the thread that tried to decode it: an export
/// compares [`decode_failures`] before and after it draws, so another thread's failure meanwhile is
/// not its own.
#[test]
fn decode_failures_counts_this_threads_images_only() {
let undecodable = || paint::decode_pixmap(b"\x89PNG\r\n\x1a\n").is_none();
let before = decode_failures();
let other = std::thread::spawn(move || {
let start = decode_failures();
undecodable() && decode_failures() == start + 1
});
assert!(other.join().unwrap(), "counted on the other thread");
assert_eq!(decode_failures(), before, "the other thread's failure");
assert!(undecodable());
assert_eq!(decode_failures(), before + 1, "this thread's failure");
}
/// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen
/// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as
/// Anti-aliased Images off, #394).
@@ -799,6 +816,73 @@ fn images_take_their_nearest_pixel_without_smoothing() {
assert_eq!(greys(false), 0, "nearest: black, then white");
}
/// A PNG that says it is `w` × `h` 1-bit gray pixels (its header and no rows).
fn png_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
out.extend((chunk.len() as u32 - 4).to_be_bytes());
out.extend(chunk);
let mut crc = flate2::Crc::new();
crc.update(chunk);
out.extend(crc.sum().to_be_bytes());
}
out
}
/// A little-endian TIFF that says it is `w` × `h` 8-bit gray pixels in one strip (its header and
/// no pixels).
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"II*\0".to_vec();
out.extend(8u32.to_le_bytes());
// (tag, type: 3 SHORT or 4 LONG, value), one value each.
let entries: [(u16, u16, u32); 9] =
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
out.extend((entries.len() as u16).to_le_bytes());
for (tag, kind, value) in entries {
out.extend(tag.to_le_bytes());
out.extend(kind.to_le_bytes());
out.extend(1u32.to_le_bytes());
out.extend(value.to_le_bytes());
}
out.extend(0u32.to_le_bytes());
out
}
/// The decode allowance (2 GB) counts an image's pixels as RGBA, read from its header (#1717): a
/// 1-bit gray image whose 8-bit gray pixels are within it but whose RGBA is over it is not decoded
/// and draws nothing, counted for an export to report; one a column narrower goes on to decoding.
#[test]
fn an_image_too_large_as_rgba_is_not_decoded() {
let over = paint::decode_within_allowance(&png_header(23_171, 23_170));
assert!(matches!(over, Err(image::ImageError::Limits(_))), "{over:?}");
let failed = decode_failures();
assert!(paint::decode_pixmap(&png_header(23_171, 23_170)).is_none());
assert!(decode_failures() > failed);
// Within the allowance: decoded, and its missing rows can't be read.
let within = paint::decode_within_allowance(&png_header(23_170, 23_170));
assert!(matches!(within, Err(image::ImageError::Decoding(_))), "{within:?}");
// A header whose size as RGBA does not fit in 64 bits gets the same limit error.
let huge = paint::decode_within_allowance(&tiff_header(1 << 31, 1 << 31));
assert!(matches!(huge, Err(image::ImageError::Limits(_))), "{huge:?}");
}
/// A gray image within the allowance draws its gray levels.
#[test]
fn a_gray_image_draws_its_gray_levels() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
let img = image::GrayImage::from_fn(2, 1, |x, _| image::Luma([if x == 0 { 40 } else { 200 }]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = Document::new(40.0, 20.0);
d.images.insert("gray".into(), ImageBlob::new("image/png", png));
let id = d.alloc_id();
let im = ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() };
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() });
assert_eq!((r.pixel(5, 10), r.pixel(35, 10)), ([40, 40, 40, 255], [200, 200, 200, 255]));
}
/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no
/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer
/// edges stay smooth.
+42
View File
@@ -0,0 +1,42 @@
//! Preferences › Type › Highlight Alternate Glyphs (#394): OpenType substitutions get a pink
//! cell behind them on screen when the preference is on.
use super::*;
#[test]
fn is_opentype_alternate_marks_ligatures_and_forms() {
assert!(is_opentype_alternate(100, 100, 2), "ligature cluster");
assert!(is_opentype_alternate(50, 99, 1), "contextual / stylistic form");
assert!(!is_opentype_alternate(50, 50, 1), "nominal cmap glyph");
assert!(!is_opentype_alternate(50, 0, 2), "missing glyph");
}
#[test]
fn highlight_alternate_glyphs_paints_pink_behind_a_ligature() {
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
// Source Serif 4 ships with standard ligatures for "fi".
let mut d = Document::new(200.0, 100.0);
let style = CharStyle { size: 48.0, font_family: "Source Serif 4".into(), ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 70.0), "fi", style))));
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let off = RenderOptions {
background: Some([255, 255, 255, 255]),
highlight_substitutions: true,
highlight_alternate_glyphs: false,
..Default::default()
};
let on = RenderOptions { highlight_alternate_glyphs: true, ..off.clone() };
let mut r = Renderer::new();
let without = r.render(&d, 200, 100, Affine::IDENTITY, &off);
let with = r.render(&d, 200, 100, Affine::IDENTITY, &on);
assert_ne!(without.pixels, with.pixels, "the preference changes the frame");
// Pink SUBSTITUTED = (255, 120, 190) at alpha 110, blended over white / ink.
let pinkish = with.pixels.chunks(4).filter(|p| p[0] > 200 && p[1] < 180 && p[2] > 150 && p[2] < 230).count();
assert!(pinkish > 10, "pink highlight cells behind the ligature ({pinkish} pixels)");
}
+19
View File
@@ -261,3 +261,22 @@ fn rich_black_shows_black_ink_alone_as_rgb_black() {
add(&mut d, n);
assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is");
}
/// An overprinting stroke in the colour of the fill under it shows no darker band in Overprint
/// Preview: the same ink printed over itself is that ink (#2415). Multiply would square the colour.
#[test]
fn overprint_of_the_same_colour_shows_no_darker_band() {
for (mode, c) in [(ColorMode::Rgb, Color::rgb(0.0, 0.55, 0.29)), (ColorMode::Cmyk, Color::cmyk(0.85, 0.0, 1.0, 0.0))] {
let mut d = Document::new_with_mode(40.0, 40.0, mode);
let mut n =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(Paint::solid(c), Paint::solid(c), 8.0));
for it in &mut n.appearance.items {
*it.overprint_mut() = true;
}
add(&mut d, n);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), overprint_preview: true, ..Default::default() };
let r = Renderer::new().render_region_with(&d, d.artboards[0].rect, 1.0, &opts);
// (20, 20) is fill only; (12, 20) lies in the stroke's inner half, over the fill.
assert!(near(r.pixel(12, 20), r.pixel(20, 20), 2), "{mode:?}: {:?} vs {:?}", r.pixel(12, 20), r.pixel(20, 20));
}
}
+2 -1
View File
@@ -1535,7 +1535,8 @@ fn proof_previews_draw_changes_as_a_new_renderer_does() {
// A square in spot color `Lab Spot`, defined in Lab in a color group, on its own plate.
let lab_doc = || {
let mut d = proof_doc();
let lab = Color::lab(50.0, 60.0, 40.0);
// Outside the press gamut, so its CMYK alternate looks different from the Lab colour.
let lab = Color::lab(50.0, 90.0, 70.0);
let swatch = vectorcraft_color::Swatch { name: "Lab Spot".into(), paint: Paint::solid(lab), global: true, spot: true };
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Lab".into(), swatches: vec![swatch] });
let fill = Paint::Solid { color: lab, swatch: Some("Lab Spot".into()), tint: 1.0 };
+1 -1
View File
@@ -705,7 +705,7 @@ impl Tool for LivePaintBucketTool {
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
Cursor::LivePaintBucket
}
}
+665
View File
@@ -0,0 +1,665 @@
# The cursors VectorCraft needs: one [[cursor]] per `Cursor` value (crates/tools/src/lib.rs).
#
# This is the source of truth for what each cursor is for, written for whoever draws them:
# designers never decide what a cursor is for, this file does. `cargo xtask cursors` warns about
# a `Cursor` value without an entry. Adding a cursor: CLAUDE.md › Adding a cursor.
#
# app the app's name as designers read it
# id the `Cursor` variant's name (PenAdd), which names its drawing
# group the heading it's listed under for designers
# shown_when every situation that shows it, one plain sentence each
# hotspot where the click lands
# look optional: only what the drawing must mean or match (a badge, a sibling cursor).
# Never describe another app's cursors; describe what ours must say.
# designer optional, default true: false for a cursor the operating system draws on purpose
# (nothing to draw)
#
# Wording: plain sentences for a designer, behaviour only. `cargo xtask brands` rejects vendor
# names here.
app = "VectorCraft"
# ── Selection ─────────────────────────────────────────────────────────────────────────────────────
[[cursor]]
id = "Arrow"
group = "Selection"
shown_when = [
"The Selection tool over empty canvas or over an unselected object (no Alt).",
"Dragging with the Selection tool (a move), or with Direct Selection (an anchor, a segment or a handle).",
"Any tool with no cursor of its own (Touch Type, Rotate View).",
"The resting state of Live Paint Selection, Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.",
]
hotspot = "The arrow's tip."
look = "The default pointer: a solid arrow. ArrowHollow is its hollow twin."
[[cursor]]
id = "ArrowHollow"
group = "Selection"
shown_when = [
"Direct Selection at rest: away from anchors and curved segments, and over handles and fills.",
"The pen-family tools with Cmd/Ctrl held (which lends Direct Selection).",
"While a Selection or Direct Selection drag snaps to a point or a guide (the solid arrow turns hollow).",
]
hotspot = "The arrow's tip."
look = "The same pointer as Arrow, hollow: white with a dark outline."
[[cursor]]
id = "DirectAnchor"
group = "Selection"
shown_when = ["Direct Selection over an anchor of a path that isn't selected."]
hotspot = "The arrow's tip."
look = "ArrowHollow with a small hollow square badge (an unselected anchor)."
[[cursor]]
id = "DirectAnchorSelected"
group = "Selection"
shown_when = ["Direct Selection over an anchor of a selected path."]
hotspot = "The arrow's tip."
look = "ArrowHollow with a small filled square badge (a selected anchor)."
[[cursor]]
id = "ReshapeSegment"
group = "Selection"
shown_when = [
"Direct Selection over a curved segment of a path that isn't selected as a whole.",
"The Anchor Point tool over a segment, and while it reshapes one.",
"The Pen with Alt held over a segment of a selected path, and while it reshapes one.",
]
hotspot = "The arrowhead's tip."
look = "Says \"a drag bends this segment\": an arrowhead with a small curve between two anchor marks."
[[cursor]]
id = "GroupSelection"
group = "Selection"
shown_when = [
"The Group Selection tool: each click selects the next enclosing group.",
"Direct Selection with Alt held (Alt makes it Group Selection for the next click).",
]
hotspot = "The arrow's tip."
look = "ArrowHollow with a plus badge."
[[cursor]]
id = "SelectionCopy"
group = "Selection"
shown_when = [
"Alt-dragging a copy with the Selection tool (the drag leaves the original behind).",
"Alt-dragging a copy with Direct Selection, dragging a path whole (by its fill, or by an anchor of a path selected whole).",
]
hotspot = "The front arrowhead's tip."
look = "Says \"a copy\": a double arrowhead, one in front of the other."
[[cursor]]
id = "Move"
group = "Selection"
shown_when = [
"The Selection tool over a selected object, or over any object with Alt held (once an Alt-drag starts, SelectionCopy shows).",
"Reshape over a path or while dragging.",
"Inside the Free Transform and Crop Image boxes.",
"Puppet Warp over a pin.",
"The Mesh tool over a mesh point or handle.",
"The Artboard tool inside an artboard.",
"The Gradient tool over the annotator's handles and during most gradient drags.",
"The Perspective Grid and Perspective Selection over plane widgets and handles.",
]
hotspot = "The centre."
look = "A four-way move cursor."
[[cursor]]
id = "CornerRadius"
group = "Selection"
shown_when = ["Selection and Direct Selection (not Group Selection) over a Live Corners widget, and while dragging one (rounding the corners)."]
hotspot = "The arrow's tip."
look = "ArrowHollow with a rounded-corner badge."
[[cursor]]
id = "PathBracket"
group = "Selection"
shown_when = ["Selection and Direct Selection over a bracket of selected type on a path, or dragging one (the type slides along its path)."]
hotspot = "The arrow's tip."
look = "Arrow with a bracket badge."
[[cursor]]
id = "TypeWidget"
group = "Selection"
shown_when = ["The Selection tool over the type widget of selected type (a double-click converts point type to area type and back)."]
hotspot = "The arrow's tip."
look = "Arrow with a type badge (a letter T)."
# ── Pen family ────────────────────────────────────────────────────────────────────────────────────
[[cursor]]
id = "Pen"
group = "Pen"
shown_when = [
"The Pen tool where a click starts a new path.",
"The Curvature tool away from its points.",
"With Use Precise Cursors on, the crosshair shows instead.",
]
hotspot = "The nib's tip."
look = "A pen nib. Every Pen-family cursor is this nib with a different badge."
[[cursor]]
id = "PenDrawing"
group = "Pen"
shown_when = [
"The Pen tool while a path is being drawn, where a click adds the path's next point.",
"The close, join and convert badges still show over the points they act on.",
]
hotspot = "The nib's tip."
look = "The Pen nib with no badge."
[[cursor]]
id = "PenAdd"
group = "Pen"
shown_when = [
"The Pen over a segment of a selected path with Auto Add/Delete on (Shift off), or over a selected blend's spine.",
"The Add Anchor Point tool everywhere (and the Delete Anchor Point tool with Alt).",
"The Curvature tool over a segment.",
"Stands in for MeshAdd and PuppetPin until they're drawn.",
]
hotspot = "The nib's tip."
look = "The Pen nib with a plus badge."
[[cursor]]
id = "PenDelete"
group = "Pen"
shown_when = [
"The Pen over an anchor of a selected path (Auto Add/Delete), or over a blend-spine point.",
"The Delete Anchor Point tool everywhere (and the Add Anchor Point tool with Alt).",
"The Mesh tool with Alt over a mesh point.",
]
hotspot = "The nib's tip."
look = "The Pen nib with a minus badge."
[[cursor]]
id = "PenClose"
group = "Pen"
shown_when = [
"The Pen while drawing, over the first anchor of the path (a click closes it).",
"The Curvature tool over its first point (three points or more), or near the other end of the current open path.",
]
hotspot = "The nib's tip."
look = "The Pen nib with a ring badge (closing the loop)."
[[cursor]]
id = "PenContinue"
group = "Pen"
shown_when = [
"The Pen (not drawing) over an end of an open path (a click continues it).",
"The Curvature tool over an open end.",
]
hotspot = "The nib's tip."
look = "The Pen nib with a slash badge."
[[cursor]]
id = "PenJoin"
group = "Pen"
shown_when = ["The Pen while drawing, over an end of another open path (a click joins the two)."]
hotspot = "The nib's tip."
look = "The Pen nib with a badge showing a line running into an end point."
[[cursor]]
id = "PenConvert"
group = "Pen"
shown_when = ["The Pen while drawing, over the last anchor (a click retracts its outgoing handle). With Alt the Pen shows AnchorPoint and ReshapeSegment instead."]
hotspot = "The nib's tip."
look = "The Pen nib with a caret badge (like the AnchorPoint caret)."
[[cursor]]
id = "AnchorPoint"
group = "Pen"
shown_when = [
"The Anchor Point tool, except over a segment (ReshapeSegment shows there).",
"The Pen with Alt held, over an anchor or a handle end of a selected path.",
]
hotspot = "The anchor mark at the caret's apex."
look = "A caret (an upside-down V) with a small anchor square at its apex."
[[cursor]]
id = "Curvature"
group = "Pen"
shown_when = [
"The Curvature tool over empty canvas, or anywhere while drawing a path away from its points.",
"Elsewhere the Curvature tool borrows Pen-family cursors: PenAdd over a segment, Move over an anchor, PenContinue at an open end, PenConvert with Alt, PenClose over the first point of the path being drawn.",
]
hotspot = "The nib's tip."
look = "The Pen nib with a curve badge."
# ── Type ──────────────────────────────────────────────────────────────────────────────────────────
[[cursor]]
id = "Text"
group = "Type"
shown_when = [
"Every horizontal type tool: Type, Area Type and Type on a Path (the Area and Path ones show the crosshair over a suitable path).",
"Stands in for TypeVertical until it's drawn.",
]
hotspot = "The I-beam's centre."
look = "An I-beam with a short crossbar marking the baseline."
[[cursor]]
id = "TypeVertical"
group = "Type"
shown_when = ["The vertical type tools: Vertical Type, Vertical Area Type and Vertical Type on a Path (the Area and Path ones show the crosshair over a suitable path)."]
hotspot = "The I-beam's centre."
look = "The Text I-beam turned a quarter turn."
# ── Transform and resize ──────────────────────────────────────────────────────────────────────────
[[cursor]]
id = "Crosshair"
group = "Transform and resize"
shown_when = [
"The generic drawing cursor: all shape, line, grid, flare, cut, graph and liquify tools, and Place.",
"The Rotate, Reflect, Scale and Shear tools with a selection (Reflect, Scale and Shear even mid-drag).",
"The Area Type and Type on a Path tools over a suitable path.",
"The Artboard tool outside every artboard; the Gradient tool away from the annotator; Free Transform corners in Distort or Perspective mode.",
"With Use Precise Cursors on, it replaces the Pen, Eyedropper, Slice, Blend and Shape Builder cursors.",
"Stands in for every drawing tool cursor not drawn yet.",
]
hotspot = "The centre of the open gap."
look = "Four arms around an open centre, no dot, so the art under the hotspot stays visible."
[[cursor]]
id = "ResizeH"
group = "Transform and resize"
shown_when = [
"The left and right handles of the bounding box (or whichever handle reads as left/right once the box is rotated) with Selection, Free Transform, Artboard, Slice Selection and Crop Image.",
"Over or dragging a vertical ruler guide.",
"Perspective Grid extent handles and near-horizontal Perspective Selection handles.",
"The freeform gradient's spread ring.",
]
hotspot = "The centre."
look = "A horizontal double arrow. ResizeV, ResizeNwSe and ResizeNeSw are the same arrow turned."
[[cursor]]
id = "ResizeV"
group = "Transform and resize"
shown_when = [
"The top and bottom handles of the same boxes as ResizeH.",
"Over or dragging a horizontal ruler guide.",
"The Perspective Grid's horizon, height and cell-size handles, and near-vertical Perspective Selection handles.",
]
hotspot = "The centre."
look = "The ResizeH arrow, vertical."
[[cursor]]
id = "ResizeNwSe"
group = "Transform and resize"
shown_when = [
"The top-left and bottom-right corner handles of the same boxes as ResizeH (rotation-adjusted).",
"Perspective Selection handles at 112.5° to 157.5°.",
]
hotspot = "The centre."
look = "The ResizeH arrow, diagonal from top-left to bottom-right."
[[cursor]]
id = "ResizeNeSw"
group = "Transform and resize"
shown_when = [
"The top-right and bottom-left corner handles of the same boxes as ResizeH (rotation-adjusted).",
"Perspective Selection handles at 22.5° to 67.5°.",
]
hotspot = "The centre."
look = "The ResizeH arrow, diagonal from bottom-left to top-right."
[[cursor]]
id = "ShearH"
group = "Transform and resize"
shown_when = [
"Free Transform with Cmd/Ctrl held over the top or bottom handle of the bounding box, and while that drag shears the object sideways.",
"Without Cmd/Ctrl the same handles show ResizeV.",
]
hotspot = "The centre, between the two arrows."
look = "Two single arrows side by side, one above the other, pointing opposite ways (left and right): one side sliding past the other."
[[cursor]]
id = "ShearV"
group = "Transform and resize"
shown_when = [
"Free Transform with Cmd/Ctrl held over the left or right handle of the bounding box, and while that drag shears the object up or down.",
"Without Cmd/Ctrl the same handles show ResizeH.",
]
hotspot = "The centre, between the two arrows."
look = "The ShearH arrows turned upright."
[[cursor]]
id = "Rotate"
group = "Transform and resize"
shown_when = [
"The Rotate tool while dragging.",
"The Gradient tool over its rotate zone, or rotating.",
"Puppet Warp with Alt near a selected pin.",
"The bounding-box rotate cursor when the pointer is exactly on the box's centre.",
]
hotspot = "Below the arc, in its empty middle."
look = "An arc with an arrowhead at each end. RotateToward is the same arc, turned."
[[cursor]]
id = "RotateToward"
group = "Transform and resize"
shown_when = ["Selection and Free Transform in the rotate zone just outside a bounding-box corner, and while rotating there. The Rotate, Gradient and Puppet Warp rotate cursors don't turn."]
hotspot = "Below the arc, in its empty middle, as for Rotate."
look = "The Rotate arc drawn for the top-right corner (direction 1). VectorCraft turns it in 45° steps so it wraps whichever corner the pointer is at."
# ── Navigation ────────────────────────────────────────────────────────────────────────────────────
[[cursor]]
id = "Hand"
group = "Navigation"
shown_when = ["The Hand tool, or Space held with any tool (not with Cmd/Ctrl): ready to pan."]
hotspot = "The middle of the hand."
look = "An open hand. HandGrab is the same hand closed."
[[cursor]]
id = "HandGrab"
group = "Navigation"
shown_when = ["While panning: a Hand tool drag, a Space drag, or a middle-button drag with any tool."]
hotspot = "The middle of the hand, as for Hand."
look = "The Hand, closed (grabbing)."
[[cursor]]
id = "ZoomIn"
group = "Navigation"
shown_when = ["The Zoom tool, or Cmd/Ctrl+Space with any tool: a click zooms in."]
hotspot = "The lens centre."
look = "A magnifier with a plus in its lens."
[[cursor]]
id = "ZoomOut"
group = "Navigation"
shown_when = ["The Zoom tool, or Cmd/Ctrl+Space, with Alt held: a click zooms out."]
hotspot = "The lens centre."
look = "The ZoomIn magnifier with a minus in its lens."
[[cursor]]
id = "NotAllowed"
group = "Navigation"
shown_when = [
"The Rotate, Reflect, Scale and Shear tools with nothing selected.",
"Puppet Warp off the mesh, where no pin can go.",
]
hotspot = "The centre."
designer = false
# ── Colour and gradient ───────────────────────────────────────────────────────────────────────────
[[cursor]]
id = "Eyedropper"
group = "Colour and gradient"
shown_when = ["The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead."]
hotspot = "The pipette's tip."
look = "A pipette."
[[cursor]]
id = "AddStop"
group = "Colour and gradient"
shown_when = [
"The Gradient tool over the annotator's bar (a click adds a stop).",
"The freeform gradient over its line or the art.",
]
hotspot = "The arrow's tip."
look = "Arrow with a plus badge. RemoveStop is the same with a minus."
[[cursor]]
id = "RemoveStop"
group = "Colour and gradient"
shown_when = [
"The Gradient tool while a stop is dragged off the bar (releasing deletes it).",
"The freeform gradient while a point is dragged out.",
]
hotspot = "The arrow's tip."
look = "Arrow with a minus badge."
[[cursor]]
id = "MeshAdd"
group = "Colour and gradient"
shown_when = [
"The Mesh tool over a mesh or a path, off a mesh point: a click adds a mesh point (turning a path into a mesh).",
"Elsewhere the Mesh tool shows PenDelete (Alt over a mesh point) and Move (over a point or handle).",
]
hotspot = "Where the mesh point goes."
[[cursor]]
id = "LivePaintBucket"
group = "Colour and gradient"
shown_when = ["The Live Paint Bucket: a click fills a face (or an edge) of a Live Paint group."]
hotspot = "Where the paint pours out."
# ── Width, blend, slice and shape builder ─────────────────────────────────────────────────────────
[[cursor]]
id = "Width"
group = "Width, blend, slice and shape builder"
shown_when = ["The Width tool away from strokes."]
hotspot = "The arrow's tip."
look = "ArrowHollow with a lens badge (a stroke that swells in the middle). WidthAdd and WidthPoint build on it."
[[cursor]]
id = "WidthAdd"
group = "Width, blend, slice and shape builder"
shown_when = ["The Width tool over a stroke, away from its width points (a drag adds one)."]
hotspot = "The arrow's tip."
look = "The Width cursor with a plus."
[[cursor]]
id = "WidthPoint"
group = "Width, blend, slice and shape builder"
shown_when = ["The Width tool over a width point or a handle end, and during any Width drag."]
hotspot = "The arrow's tip."
look = "The Width cursor with a mark across the swell (the width being dragged)."
[[cursor]]
id = "Blend"
group = "Width, blend, slice and shape builder"
shown_when = ["The Blend tool away from art. With Use Precise Cursors on, the crosshair shows instead."]
hotspot = "The crosshair's centre."
look = "Crosshair with a hollow square badge. BlendObject and BlendAnchor change the badge."
[[cursor]]
id = "BlendObject"
group = "Width, blend, slice and shape builder"
shown_when = ["The Blend tool over an object it can blend (away from its anchors)."]
hotspot = "The crosshair's centre."
look = "Crosshair with a filled square badge."
[[cursor]]
id = "BlendAnchor"
group = "Width, blend, slice and shape builder"
shown_when = ["The Blend tool over an anchor point (the blend starts there)."]
hotspot = "The crosshair's centre."
look = "Crosshair with a target badge (a ring with a centre dot)."
[[cursor]]
id = "Slice"
group = "Width, blend, slice and shape builder"
shown_when = ["The Slice tool, always. With Use Precise Cursors on, the plain crosshair shows instead."]
hotspot = "The crosshair's centre."
look = "Crosshair with a blade badge."
[[cursor]]
id = "SliceSelect"
group = "Width, blend, slice and shape builder"
shown_when = ["The Slice Selection tool away from the selected slice's handles."]
hotspot = "The arrow's tip."
look = "Arrow with a small slice badge (a rectangle cut by a line)."
[[cursor]]
id = "ShapeBuilder"
group = "Width, blend, slice and shape builder"
shown_when = ["The Shape Builder tool (merge mode), and during a drag that started without Alt."]
hotspot = "The crosshair's centre."
look = "Crosshair with a plus badge. ShapeBuilderErase is the same with a minus."
[[cursor]]
id = "ShapeBuilderErase"
group = "Width, blend, slice and shape builder"
shown_when = ["The Shape Builder with Alt held (erase mode), and during a drag that started with Alt."]
hotspot = "The crosshair's centre."
look = "Crosshair with a minus badge."
# ── Drawing and editing tools ─────────────────────────────────────────────────────────────────────
[[cursor]]
id = "Pencil"
group = "Drawing and editing tools"
shown_when = ["The Pencil tool: draws a freeform path; starting near the end of a selected open path continues it; Alt while drawing closes the path."]
hotspot = "The lead tip."
[[cursor]]
id = "Paintbrush"
group = "Drawing and editing tools"
shown_when = ["The Paintbrush tool: draws a brushed freeform path (with pen pressure), continuing a selected path from its end; Alt closes it."]
hotspot = "The bristle tip."
[[cursor]]
id = "BlobBrush"
group = "Drawing and editing tools"
shown_when = ["The Blob Brush tool: paints filled shapes that merge with touching ones of the same colour. No brush-size outline shows while hovering."]
hotspot = "The brush centre."
[[cursor]]
id = "Eraser"
group = "Drawing and editing tools"
shown_when = ["The Eraser tool: a drag erases the area it covers. No size outline shows while hovering."]
hotspot = "The eraser's centre."
[[cursor]]
id = "Smooth"
group = "Drawing and editing tools"
shown_when = ["The Smooth tool: a drag along a selected path smooths it."]
hotspot = "The tip."
[[cursor]]
id = "PathEraser"
group = "Drawing and editing tools"
shown_when = ["The Path Eraser tool: a drag along a selected path erases that part of it."]
hotspot = "The tip."
[[cursor]]
id = "Join"
group = "Drawing and editing tools"
shown_when = ["The Join tool: scrubbing across the gap or overlap between open path ends joins them."]
hotspot = "The tip."
[[cursor]]
id = "Knife"
group = "Drawing and editing tools"
shown_when = ["The Knife tool: a freehand drag cuts objects along the line."]
hotspot = "The blade's point."
[[cursor]]
id = "Scissors"
group = "Drawing and editing tools"
shown_when = ["The Scissors tool over a path segment: a click splits the path there."]
hotspot = "Where the blades cross, exact enough to cut where aimed."
look = "Scissors. ScissorsNotAllowed is this drawing with a badge."
[[cursor]]
id = "ScissorsNotAllowed"
group = "Drawing and editing tools"
shown_when = ["The Scissors tool away from every path segment: a click does nothing."]
hotspot = "The same point as Scissors."
look = "The Scissors with a small not-allowed badge, so the cursor keeps its size and shape and only the badge says a click won't cut."
[[cursor]]
id = "Shaper"
group = "Drawing and editing tools"
shown_when = ["The Shaper tool: a rough drawn shape becomes a clean live shape (rectangle, ellipse, polygon, line); scribbling over art deletes it."]
hotspot = "The tip."
[[cursor]]
id = "Lasso"
group = "Drawing and editing tools"
shown_when = ["The Lasso tool: drag a loop round anchor points and segments to select them; Shift adds to the selection, Alt takes from it."]
hotspot = "Where the loop is drawn from."
[[cursor]]
id = "MagicWand"
group = "Drawing and editing tools"
shown_when = ["The Magic Wand tool: a click selects objects like the one clicked (per the Magic Wand options); Shift adds, Alt subtracts."]
hotspot = "The wand's tip."
[[cursor]]
id = "PuppetPin"
group = "Drawing and editing tools"
shown_when = [
"Puppet Warp over the selection's mesh where a pin can go: a click adds a pin.",
"Elsewhere Puppet Warp shows NotAllowed (off the mesh), Move (over a pin) and Rotate (Alt near a selected pin).",
]
hotspot = "Where the pin goes."
[[cursor]]
id = "Measure"
group = "Drawing and editing tools"
shown_when = ["The Measure tool: drag to measure distance and angle."]
hotspot = "Exact, because precision matters here (an open centre suits it)."
[[cursor]]
id = "PrintTiling"
group = "Drawing and editing tools"
shown_when = ["The Print Tiling tool: a drag moves the printable page area over the artboard (it snaps to the artboard's edges)."]
hotspot = "The page corner being placed."
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
# The brush size is drawn by the app as a circle around the cursor.
[[cursor]]
id = "SymbolSprayer"
group = "Symbolism tools"
shown_when = ["The Symbol Sprayer: a drag sprays instances of the current symbol; Alt reverses it."]
hotspot = "The spray centre."
[[cursor]]
id = "SymbolShifter"
group = "Symbolism tools"
shown_when = ["The Symbol Shifter: a drag pushes symbol instances along; Alt reverses it."]
hotspot = "The brush centre."
[[cursor]]
id = "SymbolScruncher"
group = "Symbolism tools"
shown_when = ["The Symbol Scruncher: a drag pulls instances together; Alt reverses it."]
hotspot = "The brush centre."
[[cursor]]
id = "SymbolSizer"
group = "Symbolism tools"
shown_when = ["The Symbol Sizer: a drag enlarges instances; Alt reverses it."]
hotspot = "The brush centre."
[[cursor]]
id = "SymbolSpinner"
group = "Symbolism tools"
shown_when = ["The Symbol Spinner: a drag rotates instances in its direction; Alt reverses it."]
hotspot = "The brush centre."
[[cursor]]
id = "SymbolStainer"
group = "Symbolism tools"
shown_when = ["The Symbol Stainer: a drag tints instances toward the fill colour; Alt reverses it."]
hotspot = "The brush centre."
[[cursor]]
id = "SymbolScreener"
group = "Symbolism tools"
shown_when = ["The Symbol Screener: a drag makes instances more transparent; Alt reverses it."]
hotspot = "The brush centre."
[[cursor]]
id = "SymbolStyler"
group = "Symbolism tools"
shown_when = ["The Symbol Styler: a drag applies the chosen graphic style to instances; Alt reverses it."]
hotspot = "The brush centre."
# ── Not needed ────────────────────────────────────────────────────────────────────────────────────
# Tools that deliberately have no cursor of their own (listed for designers so nobody draws one).
[[not_needed]]
what = "Liquify tools (Warp, Twirl, Pucker, Bloat, Scallop, Crystallize, Wrinkle)"
why = "They show the Crosshair, and the app draws the brush's real size and tilt as an outline; a fixed-size cursor would disagree with it."
[[not_needed]]
what = "Shape, line, grid, flare, graph, cut and gradient tools"
why = "The generic Crosshair is the intended drawing cursor for them."
+171 -3
View File
@@ -33,7 +33,7 @@ use std::borrow::Cow;
use serde_json::{Value, json};
use vectorcraft_doc::hit::{HitKind, hit_test};
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2};
use crate::bbox::move_delta;
use crate::corners::{self, CornerDrag, over_widget};
@@ -122,6 +122,16 @@ enum State {
},
}
/// How far (pt) a snap must move a dragged point for the arrow to say it snapped.
const SNAPPED: f64 = 1e-9;
/// Whether a drag step snapped: the snapped move `d` left where the pointer alone put it
/// (`unsnapped`). With Shift the constraint (General › Constrain Angle) moves it too, so only a
/// snap's guide tells then.
fn snapped(d: Vec2, unsnapped: Vec2, shift: bool, guides: &[Overlay]) -> bool {
if shift { !guides.is_empty() } else { (d - unsnapped).hypot() > SNAPPED }
}
pub struct DirectSelectionTool {
group: bool,
state: State,
@@ -139,6 +149,8 @@ pub struct DirectSelectionTool {
handle_snap: HandleSnap,
/// What the objects being moved snap to, gathered when the move begins.
move_snap: Option<MoveSnap>,
/// The drag snapped to a point or a guide at its last step: the arrow turns hollow.
snapped: bool,
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
hover: Option<(NodeId, Point)>,
guide: GuideEdit,
@@ -161,6 +173,7 @@ impl DirectSelectionTool {
copy_snap: None,
handle_snap: HandleSnap::default(),
move_snap: None,
snapped: false,
hover: None,
guide: GuideEdit::default(),
click: None,
@@ -466,6 +479,7 @@ impl Tool for DirectSelectionTool {
return vec![Action::Begin("Reshape Spine".into())];
}
self.spine = None;
self.snapped = false;
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
self.state = State::Mesh;
return self.mesh.press(g);
@@ -574,6 +588,7 @@ impl Tool for DirectSelectionTool {
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
}
let mut d = move_delta(start, p, ev.mods.shift);
let unsnapped = d;
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
self.guides.clear();
@@ -582,6 +597,7 @@ impl Tool for DirectSelectionTool {
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
(d, self.guides) = (q - grab, guides);
}
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
out
}
@@ -618,6 +634,7 @@ impl Tool for DirectSelectionTool {
}
}
let mut d = move_delta(start, p, ev.mods.shift);
let unsnapped = 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) {
@@ -627,6 +644,7 @@ impl Tool for DirectSelectionTool {
} else if let Some(snap) = &self.move_snap {
(d, self.guides) = snap.snap(cx, start, d, ev.mods.alt);
}
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
out.push(Action::Preview(
"object.transform".into(),
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
@@ -648,6 +666,7 @@ impl Tool for DirectSelectionTool {
}
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift);
self.snapped = snapped(q.to_vec2(), p.to_vec2(), ev.mods.shift, &guides);
self.guides = guides;
vec![Action::Preview(
"path.setHandle".into(),
@@ -803,15 +822,89 @@ impl Tool for DirectSelectionTool {
}
}
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p, true)) {
return Cursor::CornerRadius;
}
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
return Cursor::PathBracket;
}
let rest = if self.group { Cursor::GroupSelection } else { Cursor::ArrowHollow };
if !self.group {
match self.state {
// Alt-dragging leaves a copy behind.
State::MoveObject { began: true, .. } if m.alt => return Cursor::SelectionCopy,
// Bending a segment keeps the cursor that offered it.
State::Segment { began: true, .. } | State::SpineSegment { began: true, .. } => return Cursor::ReshapeSegment,
// A drag shows the filled arrow, hollow while it snaps to a point or a guide.
State::Handle { .. }
| State::SpineHandle { .. }
| State::SpinePoint { began: true, .. }
| State::TypeArea { began: true, .. }
| State::MoveAnchors { began: true, .. }
| State::MoveObject { began: true, .. } => {
return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow };
}
// Alt makes it the Group Selection tool for the next press, unless the press takes
// a handle, a mesh or spine point, a type frame's edge or a ruler guide.
State::Idle if m.alt => {
let other = self.takes_point(cx, p)
|| hovered_anchor(cx, p).is_none() && (hit_frame_edge(cx, p, cx.pick_tol()).is_some() || self.guide.cursor(cx, p).is_some());
if !other {
return Cursor::GroupSelection;
}
}
State::Idle => {
if let Some(c) = self.part_under(cx, p, m) {
return c;
}
}
_ => {}
}
}
// A press on the highlighted anchor picks it, not the guide.
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(Cursor::ArrowHollow)
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(rest)
}
}
impl DirectSelectionTool {
/// A press at `p` takes a mesh point or handle, a direction handle's end, the clicked spine
/// point's handle or a spine point, before any anchor or segment.
fn takes_point(&self, cx: &ToolContext, p: Point) -> bool {
let point = cx.point_tol();
self.mesh.hit(cx, &cx.selection.objects, p).is_some()
|| hit_handle(cx, p, point).is_some()
|| self.spine.is_some_and(|sel| hit_spine_handle(cx, sel, p, point).is_some())
|| hit_spine_point(cx, p, point).is_some()
}
/// The cursor for the path part a press at `p` takes, in the press's order: an anchor (its
/// square filled when its path is selected), then a curved segment a drag reshapes (a path's
/// not selected whole, or a selected blend's spine's). Handles, mesh and spine points, frame
/// edges, ruler guides, straight segments and fills keep the plain cursor.
fn part_under(&self, cx: &ToolContext, p: Point, m: Mods) -> Option<Cursor> {
let tol = cx.pick_tol();
if self.takes_point(cx, p) {
return None;
}
// A selected path's anchors or the anchors of the path under the pointer, as Highlight
// anchors on mouse over finds them: walking every path on each hover is too slow.
if let Some((id, _)) = hovered_anchor(cx, p) {
return Some(if cx.selection.contains(id) { Cursor::DirectAnchorSelected } else { Cursor::DirectAnchor });
}
if hit_frame_edge(cx, p, tol).is_some() {
return None;
}
if matches!(hit_spine_segment(cx, p, tol), Some((.., false))) {
return Some(Cursor::ReshapeSegment);
}
if m.shift || self.guide.cursor(cx, p).is_some() {
return None;
}
let h = hit_test(cx.doc, p, cx.hit_options())?;
let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none();
let curved = matches!(h.kind, HitKind::Stroke | HitKind::Outline) && segment_at(cx, h.leaf, p).is_some_and(|s| s.curved);
(!whole && curved).then_some(Cursor::ReshapeSegment)
}
}
@@ -1423,6 +1516,81 @@ mod tests {
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle");
}
/// Direct Selection's cursors from Illustrator's manual: the white arrow at rest; over an anchor
/// a square, hollow on an unselected path and filled on a selected one; the Reshape Segment
/// cursor over a curved segment a drag reshapes, not over a straight one (which moves) or a
/// path selected whole (which moves whole); Group Selection's plus with Alt and as its tool.
#[test]
fn hovering_shows_the_anchor_square_reshape_and_group_cursors() {
let (d, arch) = arch_doc();
let p = paint();
let m = Mods::default();
let pt = Point::new;
let none = Selection::default();
let c = cx(&d, &none, &p);
let t = DirectSelectionTool::new(false);
assert_eq!(t.cursor(&c, pt(20.0, 20.0), m), Cursor::ArrowHollow);
assert_eq!(t.cursor(&c, pt(100.0, 300.0), m), Cursor::DirectAnchor);
assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ReshapeSegment);
assert_eq!(t.cursor(&c, pt(150.0, 100.0), m), Cursor::ArrowHollow, "a straight segment moves");
assert_eq!(t.cursor(&c, pt(150.0, 150.0), m), Cursor::ArrowHollow, "the fill");
let alt = Mods { alt: true, ..m };
assert_eq!(t.cursor(&c, pt(100.0, 300.0), alt), Cursor::GroupSelection);
assert_eq!(DirectSelectionTool::new(true).cursor(&c, pt(100.0, 300.0), m), Cursor::GroupSelection);
let mut whole = Selection::default();
whole.set([arch]);
let c = cx(&d, &whole, &p);
assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected);
assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ArrowHollow, "a path selected whole moves whole");
// One anchor selected: its handle end shows and keeps the plain arrow, Alt or not (a press
// takes the handle).
let s = anchors_of(arch, &[(0, 0)]);
let c = cx(&d, &s, &p);
assert_eq!(t.cursor(&c, pt(100.0, 250.0), m), Cursor::ArrowHollow, "a handle end");
assert_eq!(t.cursor(&c, pt(100.0, 250.0), alt), Cursor::ArrowHollow, "a handle end with Alt");
assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected);
// Bending the curve keeps the Reshape Segment cursor.
let mut t = DirectSelectionTool::new(false);
let c = cx(&d, &none, &p);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 262.5));
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0));
assert_eq!(t.cursor(&c, pt(150.0, 230.0), m), Cursor::ReshapeSegment);
}
/// A drag shows the filled arrow, hollow while it snaps to a point; an Alt-drag copy shows the
/// double arrowhead.
#[test]
fn drags_show_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() {
let (d, _, b) = two_squares();
let p = paint();
let none = Mods::default();
let at = |kind, x, y, m: Mods| PointerEvent::new(kind, x, y).with_mods(m);
let one = anchors_of(b, &[(0, 0)]);
let c = cx(&d, &one, &p);
let mut t = DirectSelectionTool::new(false);
t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, none));
t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, none));
assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), none), Cursor::Arrow);
// Onto A's bottom-right corner.
t.pointer(&c, &at(PointerKind::Drag, 203.0, 199.0, none));
assert_eq!(t.cursor(&c, Point::new(203.0, 199.0), none), Cursor::ArrowHollow);
t.pointer(&c, &at(PointerKind::Up, 203.0, 199.0, none));
// Shift with a Constrain Angle of 10°: the constraint alone isn't a snap.
let shift = Mods { shift: true, ..none };
let tilted = ToolContext { constrain_angle: 10.0, ..cx(&d, &one, &p) };
t.pointer(&tilted, &at(PointerKind::Down, 300.0, 300.0, shift));
t.pointer(&tilted, &at(PointerKind::Drag, 380.0, 262.0, shift));
assert_eq!(t.cursor(&tilted, Point::new(380.0, 262.0), shift), Cursor::Arrow);
t.pointer(&tilted, &at(PointerKind::Up, 380.0, 262.0, shift));
let mut whole = Selection::default();
whole.set([b]);
let c = cx(&d, &whole, &p);
let alt = Mods { alt: true, ..none };
t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, alt));
t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, alt));
assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), alt), Cursor::SelectionCopy);
}
/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B).
fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) {
let (mut d, a) = doc_with_rect();
+2 -1
View File
@@ -475,7 +475,7 @@ impl Tool for PuppetWarpTool {
} else if Self::pin_at(cx, &c.set, p).is_some() {
Cursor::Move
} else if c.set.rest_of(p).is_some() {
Cursor::PenAdd
Cursor::PuppetPin
} else {
// Off the mesh: no pin goes there.
Cursor::NotAllowed
@@ -733,6 +733,7 @@ mod tests {
#[test]
fn a_click_off_the_mesh_places_no_pin_and_says_so() {
let mut r = Rig::new();
assert_eq!(r.cursor(130.0, 170.0, Mods::default()), Cursor::PuppetPin, "on the mesh, away from the pins");
r.click(130.0, 170.0, Mods::default());
assert_eq!(r.cursor(400.0, 400.0, Mods::default()), Cursor::NotAllowed);
assert!(r.click(400.0, 400.0, Mods::default()).is_empty());
+37 -3
View File
@@ -219,22 +219,56 @@ impl Tool for AnchorTool {
"deleteAnchor" => Cursor::PenAdd,
"scissors" => {
if hit_segment(cx, p, cx.pick_tol()).is_some() {
Cursor::Crosshair
Cursor::Scissors
} else {
Cursor::NotAllowed
Cursor::ScissorsNotAllowed
}
}
_ => Cursor::Pen,
_ => match self.state {
State::Reshape { .. } => Cursor::ReshapeSegment,
State::Idle => press_cursor(cx, p),
State::Convert { .. } | State::Handle { .. } => Cursor::AnchorPoint,
},
}
}
}
/// The Anchor Point tool's cursor for a press at `p`: the Reshape Segment cursor where it takes a
/// segment (a path's or a selected blend's spine's), else its caret. Handles and anchors win
/// over the segments through them, as the press has it.
pub(crate) fn press_cursor(cx: &ToolContext, p: Point) -> Cursor {
let (point, tol) = (cx.point_tol(), cx.pick_tol());
let segment = hit_handle(cx, p, point).is_none()
&& hit_anchor(cx, p, point).is_none()
&& (hit_segment(cx, p, tol).is_some() || crate::direct::hit_spine_segment(cx, p, tol).is_some());
if segment { Cursor::ReshapeSegment } else { Cursor::AnchorPoint }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
/// The Anchor Point tool has its own caret cursor, not the Pen's start-a-path one, and over a
/// segment the Reshape Segment cursor, kept while the segment is dragged.
#[test]
fn anchor_point_tool_shows_its_caret_and_reshape_over_segments() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = AnchorTool::new("anchorPoint");
let none = Mods::default();
for at in [Point::new(20.0, 20.0), Point::new(200.0, 100.0)] {
assert_eq!(t.cursor(&cx, at, none), Cursor::AnchorPoint, "{at:?}");
}
assert_eq!(t.cursor(&cx, Point::new(150.0, 101.0), none), Cursor::ReshapeSegment);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 140.0));
assert_eq!(t.cursor(&cx, Point::new(150.0, 140.0), none), Cursor::ReshapeSegment);
}
#[test]
fn add_and_delete_anchor_clicks() {
let (d, id) = doc_with_rect();
+12 -2
View File
@@ -621,7 +621,7 @@ impl Tool for CurvatureTool {
Some(0) if !self.closed && self.pts.len() >= 3 => Cursor::PenClose,
Some(_) if m.alt => Cursor::PenConvert,
Some(_) => Cursor::Move,
None => Cursor::Pen,
None => Cursor::Curvature,
};
}
let tol = point_tol(cx);
@@ -642,7 +642,7 @@ impl Tool for CurvatureTool {
if end.is_none() && segment_in(cx, editable_paths(cx), p, tol).is_some() {
return Cursor::PenAdd;
}
Cursor::Pen
Cursor::Curvature
}
}
@@ -653,6 +653,16 @@ mod tests {
use vectorcraft_doc::{Appearance, Document, Node, Selection};
use vectorcraft_geom::{ParamCurve, PathData};
/// The Curvature tool has its own cursor, not the Pen's start-a-path one.
#[test]
fn curvature_tool_shows_its_cursor() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
assert_eq!(CurvatureTool::default().cursor(&cx, Point::new(10.0, 10.0), Mods::default()), Cursor::Curvature);
}
#[test]
fn first_click_creates_then_notify_tracks_path() {
let (d, id) = doc_with_rect();
+10 -1
View File
@@ -312,7 +312,16 @@ impl Tool for GestureTool {
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
match self.id {
"paintbrush" => Cursor::Paintbrush,
"blobBrush" => Cursor::BlobBrush,
"eraser" => Cursor::Eraser,
"knife" => Cursor::Knife,
"smooth" => Cursor::Smooth,
"pathEraser" => Cursor::PathEraser,
"join" => Cursor::Join,
_ => Cursor::Pencil,
}
}
fn options(&self) -> Value {
match self.id {
+1 -1
View File
@@ -393,7 +393,7 @@ impl Tool for ShaperTool {
vec![Overlay::Path { path: crate::draw2::polyline(&self.points), color: BLUE, width: 1.5, dashed: false }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
Cursor::Shaper
}
}
+211 -4
View File
@@ -427,12 +427,30 @@ pub enum Cursor {
#[default]
Arrow,
ArrowHollow,
/// Direct Selection over an anchor of a path that isn't selected: the white arrow with a
/// hollow square.
DirectAnchor,
/// Direct Selection over an anchor of a selected path: the white arrow with a filled square.
DirectAnchorSelected,
/// Over a segment a drag reshapes (Direct Selection, the Anchor Point tool, the Pen with Alt):
/// an arrowhead with a small curve between two anchor marks.
ReshapeSegment,
/// The Group Selection tool, and Direct Selection with Alt held: the white arrow with a plus.
GroupSelection,
/// Alt-dragging a copy with the Selection or Direct Selection tool: a double arrowhead.
SelectionCopy,
Move,
Crosshair,
ResizeH,
ResizeV,
ResizeNwSe,
ResizeNeSw,
/// Free Transform with Cmd/Ctrl over the top or bottom side, where a drag shears the object
/// sideways: two arrows side by side, pointing opposite ways along that side.
ShearH,
/// Free Transform with Cmd/Ctrl over the left or right side, where a drag shears the object up
/// or down: the ShearH arrows turned upright.
ShearV,
Rotate,
/// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off
/// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps
@@ -449,10 +467,14 @@ pub enum Cursor {
PenDelete,
PenClose,
PenContinue,
/// The Anchor Point tool, and the Pen with Alt held (which lends its gestures): a caret, its tip
/// on the anchor it converts.
AnchorPoint,
/// The Curvature tool: the pen nib with a curve badge.
Curvature,
/// While drawing, over an end of another open path (a click joins the two).
PenJoin,
/// Over the last anchor of the path being drawn (a click retracts its outgoing handle), or with
/// Alt held over a selected path's handle or anchor (the Anchor Point tool's gesture).
/// Over the last anchor of the path being drawn: a click retracts its outgoing handle.
PenConvert,
Text,
Hand,
@@ -490,6 +512,60 @@ pub enum Cursor {
ShapeBuilder,
/// ...or, with Alt held, a minus (erase mode).
ShapeBuilderErase,
/// The Lasso tool.
Lasso,
/// The Magic Wand tool.
MagicWand,
/// The Pencil tool.
Pencil,
/// The Paintbrush tool.
Paintbrush,
/// The Blob Brush tool.
BlobBrush,
/// The Eraser tool.
Eraser,
/// The Smooth tool.
Smooth,
/// The Path Eraser tool.
PathEraser,
/// The Join tool.
Join,
/// The Knife tool.
Knife,
/// The Shaper tool.
Shaper,
/// The Live Paint Bucket.
LivePaintBucket,
/// The Measure tool.
Measure,
/// The Print Tiling tool.
PrintTiling,
/// The Symbol Sprayer tool.
SymbolSprayer,
/// The Symbol Shifter tool.
SymbolShifter,
/// The Symbol Scruncher tool.
SymbolScruncher,
/// The Symbol Sizer tool.
SymbolSizer,
/// The Symbol Spinner tool.
SymbolSpinner,
/// The Symbol Stainer tool.
SymbolStainer,
/// The Symbol Screener tool.
SymbolScreener,
/// The Symbol Styler tool.
SymbolStyler,
/// The vertical type tools: a turned I-beam.
TypeVertical,
/// The Scissors over a path segment (a click splits the path there)...
Scissors,
/// ...or away from every segment (a click does nothing).
ScissorsNotAllowed,
/// The Mesh tool off a mesh point: a click adds one.
MeshAdd,
/// Puppet Warp over the selection's mesh where a pin can go: a click adds one.
PuppetPin,
}
impl Cursor {
@@ -506,7 +582,7 @@ impl Cursor {
}
/// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the
/// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
/// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
pub fn precise(self) -> Self {
match self {
Cursor::Pen
@@ -517,16 +593,79 @@ impl Cursor {
| Cursor::PenContinue
| Cursor::PenJoin
| Cursor::PenConvert
| Cursor::AnchorPoint
| Cursor::Curvature
| Cursor::Eyedropper
| Cursor::Slice
| Cursor::Blend
| Cursor::BlendObject
| Cursor::BlendAnchor
| Cursor::ShapeBuilder
| Cursor::ShapeBuilderErase => Cursor::Crosshair,
| Cursor::ShapeBuilderErase
| Cursor::Lasso
| Cursor::MagicWand
| Cursor::Pencil
| Cursor::Paintbrush
| Cursor::BlobBrush
| Cursor::Eraser
| Cursor::Smooth
| Cursor::PathEraser
| Cursor::Join
| Cursor::Knife
| Cursor::Shaper
| Cursor::LivePaintBucket
| Cursor::Measure
| Cursor::PrintTiling
| Cursor::SymbolSprayer
| Cursor::SymbolShifter
| Cursor::SymbolScruncher
| Cursor::SymbolSizer
| Cursor::SymbolSpinner
| Cursor::SymbolStainer
| Cursor::SymbolScreener
| Cursor::SymbolStyler
| Cursor::Scissors
| Cursor::MeshAdd
| Cursor::PuppetPin => Cursor::Crosshair,
c => c,
}
}
/// The cursor this one borrows until it's drawn: the generic crosshair for the
/// tools, the I-beam for vertical type, the Pen's add cursor for the Mesh tool and Puppet
/// Warp, not-allowed for the Scissors away from a path. `None` for a cursor with a look of
/// its own.
pub fn stand_in(self) -> Option<Self> {
match self {
Cursor::Lasso
| Cursor::MagicWand
| Cursor::Pencil
| Cursor::Paintbrush
| Cursor::BlobBrush
| Cursor::Eraser
| Cursor::Smooth
| Cursor::PathEraser
| Cursor::Join
| Cursor::Knife
| Cursor::Shaper
| Cursor::LivePaintBucket
| Cursor::Measure
| Cursor::PrintTiling
| Cursor::SymbolSprayer
| Cursor::SymbolShifter
| Cursor::SymbolScruncher
| Cursor::SymbolSizer
| Cursor::SymbolSpinner
| Cursor::SymbolStainer
| Cursor::SymbolScreener
| Cursor::SymbolStyler
| Cursor::Scissors => Some(Cursor::Crosshair),
Cursor::TypeVertical => Some(Cursor::Text),
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed),
_ => None,
}
}
}
/// A tool state machine.
@@ -749,3 +888,71 @@ pub(crate) mod testutil {
}
}
}
#[cfg(test)]
mod cursor_tests {
use super::testutil::{cx, doc_with_rect, paint};
use super::*;
fn cursor_at(id: &str, d: &Document, p: Point) -> Cursor {
let (s, paint) = (Selection::default(), paint());
create(id).cursor(&cx(d, &s, &paint), p, Mods::default())
}
/// Each of these tools has a cursor of its own (borrowing the crosshair until it's drawn), and
/// Use Precise Cursors turns it into the crosshair.
#[test]
fn drawing_tools_show_their_own_cursor() {
let (d, _) = doc_with_rect();
let away = Point::new(400.0, 400.0);
for (id, c) in [
("lasso", Cursor::Lasso),
("magicWand", Cursor::MagicWand),
("pencil", Cursor::Pencil),
("paintbrush", Cursor::Paintbrush),
("blobBrush", Cursor::BlobBrush),
("eraser", Cursor::Eraser),
("smooth", Cursor::Smooth),
("pathEraser", Cursor::PathEraser),
("join", Cursor::Join),
("knife", Cursor::Knife),
("shaper", Cursor::Shaper),
("livePaintBucket", Cursor::LivePaintBucket),
("measure", Cursor::Measure),
("printTiling", Cursor::PrintTiling),
("symbolSprayer", Cursor::SymbolSprayer),
("symbolShifter", Cursor::SymbolShifter),
("symbolScruncher", Cursor::SymbolScruncher),
("symbolSizer", Cursor::SymbolSizer),
("symbolSpinner", Cursor::SymbolSpinner),
("symbolStainer", Cursor::SymbolStainer),
("symbolScreener", Cursor::SymbolScreener),
("symbolStyler", Cursor::SymbolStyler),
("mesh", Cursor::MeshAdd),
] {
assert_eq!(cursor_at(id, &d, away), c, "{id}");
assert_eq!(c.precise(), Cursor::Crosshair, "{id}");
assert_eq!(c.stand_in(), Some(if id == "mesh" { Cursor::PenAdd } else { Cursor::Crosshair }), "{id}");
}
}
#[test]
fn the_vertical_type_tools_show_the_turned_i_beam() {
let (d, _) = doc_with_rect();
let away = Point::new(400.0, 400.0);
for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] {
assert_eq!(cursor_at(id, &d, away), Cursor::TypeVertical, "{id}");
}
assert_eq!(cursor_at("type", &d, away), Cursor::Text);
assert_eq!(Cursor::TypeVertical.stand_in(), Some(Cursor::Text));
}
#[test]
fn the_scissors_say_whether_a_click_cuts() {
let (d, _) = doc_with_rect();
assert_eq!(cursor_at("scissors", &d, Point::new(100.0, 150.0)), Cursor::Scissors, "over a segment");
assert_eq!(cursor_at("scissors", &d, Point::new(400.0, 400.0)), Cursor::ScissorsNotAllowed, "away from paths");
assert_eq!(Cursor::ScissorsNotAllowed.stand_in(), Some(Cursor::NotAllowed));
assert_eq!(Cursor::ScissorsNotAllowed.precise(), Cursor::ScissorsNotAllowed, "nothing to aim at");
}
}
+1 -1
View File
@@ -241,7 +241,7 @@ impl Tool for MeshTool {
match self.grab_at(cx, p) {
Some(g) if mods.alt && g.handle.is_none() => Cursor::PenDelete,
Some(_) => Cursor::Move,
None => Cursor::PenAdd,
None => Cursor::MeshAdd,
}
}
fn options(&self) -> serde_json::Value {
+21 -4
View File
@@ -152,6 +152,17 @@ fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
|| crate::draw2::segment_in(cx, crate::draw2::shown_paths(cx), p, cx.pick_tol()).is_some())
}
/// The cursor where [`alt_converts`]: Alt lends the Anchor Point tool, so its caret over a handle
/// end or an anchor, the Reshape Segment cursor over a segment.
fn alt_cursor(cx: &ToolContext, p: Point) -> Cursor {
let point = cx.point_tol();
if hit_handle(cx, p, point).is_some() || crate::draw2::anchor_in(cx, crate::draw2::shown_paths(cx), p, point).is_some() {
Cursor::AnchorPoint
} else {
Cursor::ReshapeSegment
}
}
/// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone.
fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action {
Action::Preview(
@@ -390,6 +401,10 @@ impl Tool for PenTool {
o
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
// An Alt gesture under way keeps the Anchor Point tool's cursor.
if let Some(t) = &self.convert {
return t.cursor(cx, p, m);
}
let tol = cx.tol(5.0).max(cx.point_tol());
let active = self.active(cx);
if let Some((id, first, last, _)) = active {
@@ -404,7 +419,7 @@ impl Tool for PenTool {
}
}
if alt_converts(cx, p, m) {
return Cursor::PenConvert;
return alt_cursor(cx, p);
}
// These show whenever no path is being drawn, also after another path was selected while
// the Pen was drawing.
@@ -1212,8 +1227,9 @@ mod tests {
let alt = Mods { alt: true, ..Mods::default() };
let t = PenTool::default();
let cx0 = cx(&d, &s, &p);
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::PenConvert);
// Alt lends the Anchor Point tool, and its caret with it.
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::AnchorPoint);
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::AnchorPoint);
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen);
assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen);
// A drag of the handle moves it alone, as one gesture.
@@ -1249,10 +1265,11 @@ mod tests {
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Mods::default() };
let mut t = PenTool { drawing: true, ..PenTool::default() };
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::ReshapeSegment);
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::PenDrawing);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt));
assert_eq!(t.cursor(&cx, Point::new(150.0, 262.0), alt), Cursor::ReshapeSegment, "kept during the reshape");
assert_eq!(a[0], Action::Begin("Reshape".into()));
assert!(
matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0),
+1 -1
View File
@@ -87,7 +87,7 @@ impl Tool for PrintTilingTool {
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
Cursor::PrintTiling
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
+36 -1
View File
@@ -82,6 +82,8 @@ pub struct SelectionTool {
/// What a bounding-box handle being dragged snaps to.
targets: Option<Targets>,
moving: Option<MoveSnap>,
/// The move snapped to a point or a guide at its last step: the arrow turns hollow.
snapped: bool,
guide: GuideEdit,
/// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]).
hover: Option<(Point, &'static str)>,
@@ -425,9 +427,12 @@ impl Tool for SelectionTool {
self.moving = Some(MoveSnap::new(cx));
}
let mut d = move_delta(start, p, m.shift);
let unsnapped = d;
if let Some(snap) = &self.moving {
(d, self.guides) = snap.snap(cx, start, d, m.alt);
}
// Shift has already constrained `d`: any change is a snap.
self.snapped = (d - unsnapped).hypot() > 1e-9;
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)));
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
@@ -553,7 +558,10 @@ impl Tool for SelectionTool {
match self.state {
State::Rotating { center, .. } => return Cursor::rotate_about(center, p),
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
State::Moving { began: true, .. } => return Cursor::Arrow,
// Alt-dragging leaves a copy behind; otherwise the filled arrow, hollow while the move
// snaps to a point or a guide.
State::Moving { began: true, .. } if m.alt => return Cursor::SelectionCopy,
State::Moving { began: true, .. } => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow },
State::Corner(_) => return Cursor::CornerRadius,
State::Pie(_) => return Cursor::ArrowHollow,
State::Bracket(_) => return Cursor::PathBracket,
@@ -868,6 +876,33 @@ mod tests {
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0);
}
/// A move shows the filled arrow, hollow while it snaps (here B's left side onto A's right
/// one); Alt, which leaves a copy behind, the double arrowhead.
#[test]
fn a_move_shows_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, _) = doc_with_rect();
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
let l = d.layers[0].id;
let b = d.alloc_id();
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0));
d.insert(Some(l), 1, Node::path(b, sq, Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.add(b);
let p = paint();
let c = cx(&d, &s, &p);
let none = Mods::default();
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 350.0, 350.0));
t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0));
assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), none), Cursor::Arrow);
t.pointer(&c, &ev(PointerKind::Drag, 253.0, 371.0));
assert_eq!(t.cursor(&c, Point::new(253.0, 371.0), none), Cursor::ArrowHollow, "lined up with A");
let alt = Mods { alt: true, ..none };
t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0).with_mods(alt));
assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), alt), Cursor::SelectionCopy);
}
/// #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]
+10 -1
View File
@@ -170,7 +170,16 @@ impl Tool for SymbolismTool {
vec![Overlay::Path { path: circle, color: FEEDBACK, width: 1.0, dashed: false }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
match self.id {
"symbolShifter" => Cursor::SymbolShifter,
"symbolScruncher" => Cursor::SymbolScruncher,
"symbolSizer" => Cursor::SymbolSizer,
"symbolSpinner" => Cursor::SymbolSpinner,
"symbolStainer" => Cursor::SymbolStainer,
"symbolScreener" => Cursor::SymbolScreener,
"symbolStyler" => Cursor::SymbolStyler,
_ => Cursor::SymbolSprayer,
}
}
fn options(&self) -> Value {
json!({"diameter": self.diameter, "intensity": self.intensity, "density": self.density, "symbol": self.symbol, "style": self.style})
+50 -17
View File
@@ -305,26 +305,59 @@ impl TypeTool {
}
fn overflow_marker(t: &TextObject, lay: &TextLayout, cx: &ToolContext, o: &mut Vec<Overlay>) {
if !lay.overflow {
return;
if lay.overflow {
overflow_port(t, cx, o);
}
// The out port: bottom-right of the frame (end of the path for type on a path).
let port = match &t.kind {
TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)),
TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)),
TextKind::Point => None,
};
let Some(c) = port else { return };
let h = cx.tol(4.5);
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
let red = [230, 30, 30];
o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false });
let (k, m) = (cx.tol(2.5), r.center());
o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false });
o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false });
}
}
/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit): at the
/// bottom right of the frame, at the end of the path for type on a path.
fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
let port = match &t.kind {
TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)),
TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)),
TextKind::Point => None,
};
let Some(c) = port else { return };
let h = cx.tol(4.5);
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
let red = [230, 30, 30];
o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false });
let (k, m) = (cx.tol(2.5), r.center());
o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false });
o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false });
}
/// Which selected text overflows, for [`overflow_markers`]: each text object's layout verdict kept
/// with the object and the fonts it was made from, as overlays run every frame.
#[derive(Default)]
pub struct OverflowMarks {
seen: std::collections::HashMap<NodeId, ((u64, TextObject), bool)>,
}
/// The overflow markers of the selected area type and type on a path, for every tool but the Type
/// tools (they draw their own): an out port with a red "+" tells that text is hidden by the size of
/// its frame, whatever tool is active.
pub fn overflow_markers(marks: &mut OverflowMarks, cx: &ToolContext) -> Vec<Overlay> {
let fonts = FontDb::global().generation();
let mut o = Vec::new();
let mut kept = std::collections::HashMap::new();
for id in &cx.selection.objects {
let Some(t) = TypeTool::text(cx, *id).filter(|t| !matches!(t.kind, TextKind::Point)) else { continue };
let over = match marks.seen.remove(id) {
Some(((g, was), over)) if g == fonts && &was == t => over,
_ => vectorcraft_text::layout(FontDb::global(), t).overflow,
};
if over {
overflow_port(t, cx, &mut o);
}
kept.insert(*id, ((fonts, t.clone()), over));
}
marks.seen = kept;
o
}
/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes).
/// `None` when `r` runs past the end of `s` or backwards.
/// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking;
@@ -814,7 +847,7 @@ impl Tool for TypeTool {
if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() {
return Cursor::Crosshair;
}
Cursor::Text
if self.vertical { Cursor::TypeVertical } else { Cursor::Text }
}
}
+18
View File
@@ -400,3 +400,21 @@ fn escape_returns_to_previous_tool_when_set() {
let acts2 = key(&mut tool2, &d2, ToolKey::Escape, Mods::default());
assert_eq!(acts2.last(), Some(&Action::SwitchTool("selection".into())));
}
/// Selected area type whose text doesn't fit its frame shows the red out port with any tool, not
/// only the Type tools (#2323); text that fits shows none.
#[test]
fn overflowing_selected_type_shows_its_marker_without_the_type_tool() {
let (mut d, id) = crate::testutil::doc_with_area_type();
let (mut sel, p) = (Selection::default(), paint());
sel.set([id]);
let mut marks = OverflowMarks::default();
assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "the text fits");
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.runs = vec![TextRun::new("Words that fill more than the frame holds. ".repeat(20), CharStyle { size: 20.0, ..Default::default() })];
}
let ov = overflow_markers(&mut marks, &cx(&d, &sel, &p));
assert_eq!(ov.len(), 3, "a box and a plus: {ov:?}");
sel.clear();
assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows it");
}
+24 -1
View File
@@ -315,7 +315,9 @@ impl Tool for FreeTransformTool {
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
Some(Op::Rotate) => Cursor::rotate_about(r.center(), p),
Some(Op::Move) => Cursor::Move,
Some(Op::Scale(h) | Op::Shear(h)) => match h {
Some(Op::Shear(Handle::Top | Handle::Bottom)) => Cursor::ShearH,
Some(Op::Shear(_)) => Cursor::ShearV,
Some(Op::Scale(h)) => match h {
Handle::Top | Handle::Bottom => Cursor::ResizeV,
Handle::Left | Handle::Right => Cursor::ResizeH,
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
@@ -401,6 +403,27 @@ mod tests {
assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9);
}
#[test]
fn cmd_over_a_side_shows_the_shear_cursor_along_it() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = FreeTransformTool::default();
let cmd = Mods { cmd: true, ..Default::default() };
let (top, left) = (Point::new(150.0, 100.0), Point::new(100.0, 150.0));
assert_eq!(t.cursor(&cx, top, cmd), Cursor::ShearH);
assert_eq!(t.cursor(&cx, left, cmd), Cursor::ShearV);
// Without Cmd the sides scale; corners never shear.
assert_eq!(t.cursor(&cx, top, Mods::default()), Cursor::ResizeV);
assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), cmd), Cursor::Crosshair);
// The cursor stays while the shear is dragged.
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0).with_mods(cmd));
t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd));
assert_eq!(t.cursor(&cx, Point::new(170.0, 100.0), cmd), Cursor::ShearH);
}
#[test]
fn modifiers_pressed_during_a_handle_drag_change_what_it_does() {
let (d, id) = doc_with_rect();
+1 -1
View File
@@ -68,7 +68,7 @@ impl Tool for MeasureTool {
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
Cursor::Measure
}
fn options(&self) -> Value {
+2 -2
View File
@@ -41,7 +41,7 @@ impl Tool for MagicWandTool {
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
Cursor::MagicWand
}
}
@@ -145,7 +145,7 @@ impl Tool for LassoTool {
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
Cursor::Lasso
}
}
+160 -16
View File
@@ -82,9 +82,12 @@ enum Drag {
held: f32,
moved: bool,
},
/// A Rotate View tool drag, or a Space+Shift drag with any tool. With `snaps` (the Rotate
/// View tool), Shift rounds the angle to 15°.
RotateView {
start_angle: f64,
start_rot: f64,
snaps: bool,
},
/// A Selection tool move dragged off the canvas: the panels get the art
/// ([`widgets::PanelDrag::Art`]).
@@ -236,7 +239,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let xf = Xf::new(rect, &v);
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None };
panel_drop(app, ui, &resp, &xf);
context_menu(app, &resp, &xf);
// In Presentation Mode a right click or a long touch opens no context menu.
if app.ui.screen_mode < 3 {
context_menu(app, &resp, &xf);
}
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
@@ -302,6 +308,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
hidden: vec![],
rot: v.rotation,
anti_alias: app.session.prefs.anti_aliased_artwork,
highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs,
placed: vectorcraft_render::placed_document::generation(),
pixel,
smooth_images,
@@ -354,6 +361,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
guide: key.guide,
mask_view,
highlight_substitutions: true,
// Type › Highlight Alternate Glyphs (#394): pink behind OpenType substitutions.
highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs,
// General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and
// pattern tiles stay smooth), as in Illustrator.
anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None },
@@ -520,12 +529,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if resp.hovered() {
let m = ui.input(|i| i.modifiers);
let space = ui.input(|i| i.key_down(egui::Key::Space));
let panning = matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Pan { .. }));
let drag = ui.data(|d| d.get_temp::<Drag>(drag_id()));
let panning = matches!(drag, Some(Drag::Pan { .. }));
let zooming = if space { m.command } else { app.session.tool_id() == "zoom" };
// A rotating drag keeps its cursor when Shift is let go.
let rotating =
matches!(drag, Some(Drag::RotateView { .. })) || if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" };
// The hand and the magnifier are drawn like the tool cursors below: Windows has no
// system cursor for them (winit shows the four-headed move cursor for Grab).
let view_cursor = if panning {
Some(Cursor::HandGrab)
} else if rotating {
Some(Cursor::Rotate)
} else if zooming {
Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn })
} else if space || app.session.tool_id() == "hand" {
@@ -533,7 +548,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} else {
None
};
let cur = if let Some(c) = view_cursor.or_else(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info))) {
// In Presentation Mode the pointer is the default one, with no tool cursor.
let tool_cursor =
(app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info))));
let cur = if let Some(c) = tool_cursor.flatten() {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")));
// The loaded place cursor carries the file's thumbnail.
if app.session.tool_id() == "place"
@@ -562,7 +580,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
&& crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused))
&& crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) =>
{
egui::CursorIcon::None
// No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450).
if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) }
}
_ => cursor_icon(c),
}
@@ -576,11 +595,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
use egui::CursorIcon as C;
match c {
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius | Cursor::PathBracket | Cursor::TypeWidget => C::Default,
Cursor::Arrow
| Cursor::ArrowHollow
| Cursor::DirectAnchor
| Cursor::DirectAnchorSelected
| Cursor::ReshapeSegment
| Cursor::GroupSelection
| Cursor::CornerRadius
| Cursor::PathBracket
| Cursor::TypeWidget => C::Default,
Cursor::SelectionCopy => C::Copy,
Cursor::Move => C::Move,
Cursor::Crosshair => C::Crosshair,
Cursor::ResizeH => C::ResizeHorizontal,
Cursor::ResizeV => C::ResizeVertical,
Cursor::ResizeH | Cursor::ShearH => C::ResizeHorizontal,
Cursor::ResizeV | Cursor::ShearV => C::ResizeVertical,
Cursor::ResizeNwSe => C::ResizeNwSe,
Cursor::ResizeNeSw => C::ResizeNeSw,
Cursor::Rotate | Cursor::RotateToward(_) => C::Alias,
@@ -591,7 +619,9 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
| Cursor::PenClose
| Cursor::PenContinue
| Cursor::PenJoin
| Cursor::PenConvert => C::Crosshair,
| Cursor::PenConvert
| Cursor::AnchorPoint
| Cursor::Curvature => C::Crosshair,
Cursor::Text => C::Text,
Cursor::Hand => C::Grab,
Cursor::HandGrab => C::Grabbing,
@@ -606,6 +636,34 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
Cursor::WidthPoint => C::Move,
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
Cursor::TypeVertical => C::VerticalText,
// Not drawn yet: the cursor each borrows.
Cursor::Lasso
| Cursor::MagicWand
| Cursor::Pencil
| Cursor::Paintbrush
| Cursor::BlobBrush
| Cursor::Eraser
| Cursor::Smooth
| Cursor::PathEraser
| Cursor::Join
| Cursor::Knife
| Cursor::Shaper
| Cursor::LivePaintBucket
| Cursor::Measure
| Cursor::PrintTiling
| Cursor::SymbolSprayer
| Cursor::SymbolShifter
| Cursor::SymbolScruncher
| Cursor::SymbolSizer
| Cursor::SymbolSpinner
| Cursor::SymbolStainer
| Cursor::SymbolScreener
| Cursor::SymbolStyler
| Cursor::Scissors
| Cursor::ScissorsNotAllowed
| Cursor::MeshAdd
| Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
}
}
@@ -686,10 +744,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
let tool = app.session.tool_id();
// Held, Space is the Hand tool for the moment, and Cmd+Space the Zoom tool (with Alt, zooming
// out), whatever the tool.
// Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and
// Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool.
let zoom_mode = if space { m.command } else { tool == "zoom" };
let pan_mode = if space { !m.command } else { tool == "hand" };
let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" };
let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" };
let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. }));
// egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a
// press on the canvas itself reaches the tools, else it would land at the canvas's centre.
@@ -712,9 +771,9 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } }
} else if pan_mode {
Drag::Pan { start: p, center: v.center, middle: false }
} else if tool == "rotateView" {
} else if rotate_mode {
let c = rect.center();
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation }
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space }
} else {
// Cmd with another tool drags with the selection tool used last (the session lends it).
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
@@ -746,11 +805,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
vm.center = center - d;
}
}
Drag::RotateView { start_angle, start_rot } => {
Drag::RotateView { start_angle, start_rot, snaps } => {
let c = rect.center();
let a = (p.y - c.y).atan2(p.x - c.x) as f64;
let mut deg = start_rot + (a - start_angle).to_degrees();
if m.shift {
if snaps && m.shift {
deg = (deg / 15.0).round() * 15.0;
}
if let Some(vm) = app.view_mut() {
@@ -1167,7 +1226,10 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec
}
}
ui.data_mut(|d| d.insert_temp(key, taps));
if let Some(cmd) = tap {
// In Presentation Mode a tap of two or three fingers undoes or redoes nothing.
if let Some(cmd) = tap
&& app.ui.screen_mode < 3
{
crate::menus::invoke(app, cmd, serde_json::json!({}));
}
}
@@ -5065,6 +5127,88 @@ mod tests {
assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}");
}
#[test]
fn space_shift_drag_rotates_the_view_whatever_the_tool() {
use egui::{Event, Modifiers};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.select_tool("rectangle");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let c = app.canvas_rect.unwrap().center();
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
// `drag` drags from `from` to `to` with `held` down, and with Space as well when `with_space`.
let drag = |app: &mut VectorcraftApp, held: Modifiers, with_space: bool, from: Pos2, to: Pos2| {
let keys = |pressed| if with_space { vec![space(pressed, held)] } else { vec![] };
frame(app, &ctx, [vec![Event::ModifiersChanged(held)], keys(true), vec![Event::PointerMoved(from)]].concat());
frame(app, &ctx, vec![button(from, true, held)]);
frame(app, &ctx, vec![Event::PointerMoved(to)]);
frame(app, &ctx, vec![button(to, false, held)]);
frame(app, &ctx, [keys(false), vec![Event::ModifiersChanged(Modifiers::NONE)]].concat());
};
// `at(deg)` is the point 100 points from the canvas center, `deg` degrees clockwise from the right.
let at = |deg: f32| c + 100.0 * vec2(deg.to_radians().cos(), deg.to_radians().sin());
let before = *app.view().unwrap();
drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(90.0));
let after = *app.view().unwrap();
assert!((after.rotation - 90.0).abs() < 1e-3, "{after:?}");
assert_eq!((after.center, after.zoom), (before.center, before.zoom), "the view turns about its center");
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
// Shift does not round the angle: 90° and 20° more make 110°, not 105°.
drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(20.0));
assert!((app.view().unwrap().rotation - 110.0).abs() < 1e-3, "{:?}", app.view());
// Space alone still pans, and Cmd+Shift+Space still zooms.
let v = *app.view().unwrap();
drag(&mut app, Modifiers::NONE, true, c, c + vec2(30.0, 0.0));
let panned = *app.view().unwrap();
assert_eq!((panned.rotation, panned.zoom), (v.rotation, v.zoom));
assert_ne!(panned.center, v.center, "Space pans");
drag(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, true, c, c);
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(v.zoom, true), "Cmd+Shift+Space click zooms in");
assert_eq!(app.view().unwrap().rotation, v.rotation);
// The Rotate View tool's Shift still rounds to 15°.
app.select_tool("rotateView");
app.view_mut().unwrap().rotation = 0.0;
drag(&mut app, Modifiers::SHIFT, false, at(0.0), at(20.0));
assert_eq!(app.view().unwrap().rotation, 15.0);
drag(&mut app, Modifiers::NONE, false, at(0.0), at(20.0));
assert!((app.view().unwrap().rotation - 35.0).abs() < 1e-3, "{:?}", app.view());
}
#[test]
fn the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool() {
use egui::{CursorIcon, Event, Modifiers};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.select_tool("rectangle");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let c = app.canvas_rect.unwrap().center();
// `shown` returns the system cursor the canvas sets over a window without the focus. On macOS
// the window paints the glyph only with the focus, and on the other desktop builds (not
// Wayland, which the tests do not set) the window sets the system cursor next to the bitmap.
let shown = |app: &mut VectorcraftApp, events: Vec<Event>| {
let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out.platform_output.cursor_icon
};
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT };
assert_ne!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias);
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT)]), CursorIcon::Alias);
// Shift let go during the drag keeps the rotate cursor; after it, Space shows the hand.
shown(&mut app, vec![button(c, true)]);
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(c + vec2(20.0, 30.0))]), CursorIcon::Alias);
assert_eq!(shown(&mut app, vec![button(c + vec2(20.0, 30.0), false)]), CursorIcon::Grab);
// Cmd+Shift+Space zooms.
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)]), CursorIcon::ZoomIn);
assert_ne!(shown(&mut app, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]), CursorIcon::Alias);
app.select_tool("rotateView");
assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool");
}
#[test]
fn space_pressed_mid_drag_moves_what_the_tool_draws() {
use egui::{Event, Modifiers};
+267 -44
View File
@@ -95,6 +95,13 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) {
}
"-" => line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)),
"/" => line(p, b + vec2(0.0, 6.0), b + vec2(5.0, 0.0)),
"~" => {
// A quarter circle bulging up-left, from the badge's bottom-left to its top-right.
let c = b + vec2(7.0, 7.0);
let arc: Vec<Pos2> = (0..=8).map(|i| c + egui::Vec2::angled(std::f32::consts::PI * (1.0 + i as f32 / 16.0)) * 6.0).collect();
p.add(Shape::line(arc.clone(), Stroke::new(3.0, HALO)));
p.add(Shape::line(arc, Stroke::new(1.2, INK)));
}
// Join: a segment running into a square end point.
"j" => {
line(p, b + vec2(0.0, 6.0), b + vec2(3.5, 2.5));
@@ -114,6 +121,79 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) {
}
}
/// The Anchor Point tool's caret (as its toolbar icon): a small hollow square on the anchor at
/// the hotspot, legs spreading down from its lower corners. Every halo goes under every stroke, so
/// the legs join the square.
fn anchor_point(p: &mut Ink, o: Pos2) {
let s = 2.5;
let square = vec![o + vec2(-s, -s), o + vec2(s, -s), o + vec2(s, s), o + vec2(-s, s)];
let legs = [[o + vec2(-s, s), o + vec2(-7.5, 13.0)], [o + vec2(s, s), o + vec2(7.5, 13.0)]];
for leg in legs {
p.line_segment(leg, Stroke::new(3.0, HALO));
}
p.add(Shape::closed_line(square.clone(), Stroke::new(3.0, HALO)));
p.add(Shape::convex_polygon(square.clone(), HALO, Stroke::NONE));
for leg in legs {
p.line_segment(leg, Stroke::new(1.2, INK));
}
p.add(Shape::closed_line(square, Stroke::new(1.0, INK)));
}
/// Halos under every stroke first, then the ink, so crossing strokes don't cut each other.
fn strokes(p: &mut Ink, parts: &[Vec<Pos2>]) {
for s in parts {
p.add(Shape::line(s.clone(), Stroke::new(3.0, HALO)));
}
for s in parts {
p.add(Shape::line(s.clone(), Stroke::new(1.2, INK)));
}
}
/// A stemless arrowhead, tip at `o`.
fn arrowhead(o: Pos2) -> Vec<Pos2> {
vec![o, o + vec2(0.0, 14.0), o + vec2(4.2, 10.4), o + vec2(10.0, 10.0)]
}
/// Direct Selection over an anchor: the white arrow with a small square at its lower right,
/// filled when the anchor's path is selected.
fn arrow_square(p: &mut Ink, o: Pos2, filled: bool) {
arrow(p, o, true);
let b = o + vec2(11.0, 13.0);
let square = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)].map(|(x, y)| b + vec2(x, y)).to_vec();
poly(p, square, if filled { INK } else { HALO }, INK);
}
/// The Reshape Segment cursor: a black arrowhead with a small curve between two anchor marks at
/// its lower right.
fn reshape_segment(p: &mut Ink, o: Pos2) {
poly(p, arrowhead(o), INK, INK);
let (a, c) = (o + vec2(10.0, 19.0), o + vec2(19.0, 19.0));
let curve: Vec<Pos2> = (0..=12)
.map(|i| {
let t = i as f32 / 12.0;
a + (c - a) * t - vec2(0.0, 4.5 * (std::f32::consts::PI * t).sin())
})
.collect();
strokes(p, &[curve]);
for m in [a, c] {
let mark = [(-1.5, -1.5), (1.5, -1.5), (1.5, 1.5), (-1.5, 1.5)].map(|(x, y)| m + vec2(x, y)).to_vec();
poly(p, mark, INK, INK);
}
}
/// Group Selection: the white arrow with a plus at its lower right.
fn arrow_plus(p: &mut Ink, o: Pos2) {
arrow(p, o, true);
let c = o + vec2(14.0, 15.0);
strokes(p, &[vec![c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], vec![c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)]]);
}
/// An Alt-drag copy: a black arrowhead in front of a white one set down and to the right.
fn double_arrowhead(p: &mut Ink, o: Pos2) {
poly(p, arrowhead(o + vec2(4.0, 4.0)), HALO, INK);
poly(p, arrowhead(o), INK, INK);
}
fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
let d = dir.normalized() * 8.0;
let n = vec2(-d.y, d.x) * 0.45;
@@ -123,6 +203,19 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
}
}
/// Two single arrows side by side across the hotspot, pointing opposite ways along `dir`: a side
/// sliding past its opposite one, as a shear does.
fn shear(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
let d = dir.normalized() * 7.0;
let n = vec2(-d.y, d.x) / 7.0;
for (side, way) in [(-3.5, 1.0), (3.5, -1.0)] {
let (c, d) = (o + n * side, d * way);
line(p, c - d, c + d * 0.55);
let back = c + d * 0.4;
poly(p, vec![c + d, back + n * 2.6, back - n * 2.6], INK, INK);
}
}
/// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along
/// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a
/// bounding box it wraps the corner the pointer is at.
@@ -314,13 +407,102 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
}
}
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
#[cfg(test)]
const GLYPHS: [Cursor; 74] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
Cursor::ZoomOut,
Cursor::Arrow,
Cursor::ArrowHollow,
Cursor::DirectAnchor,
Cursor::DirectAnchorSelected,
Cursor::ReshapeSegment,
Cursor::GroupSelection,
Cursor::SelectionCopy,
Cursor::Move,
Cursor::Crosshair,
Cursor::ResizeH,
Cursor::ResizeV,
Cursor::ResizeNwSe,
Cursor::ResizeNeSw,
Cursor::ShearH,
Cursor::ShearV,
Cursor::Rotate,
Cursor::RotateToward(1),
Cursor::CornerRadius,
Cursor::Pen,
Cursor::PenDrawing,
Cursor::PenAdd,
Cursor::PenDelete,
Cursor::PenClose,
Cursor::PenContinue,
Cursor::PenJoin,
Cursor::PenConvert,
Cursor::AnchorPoint,
Cursor::Curvature,
Cursor::Text,
Cursor::Eyedropper,
Cursor::AddStop,
Cursor::RemoveStop,
Cursor::Slice,
Cursor::SliceSelect,
Cursor::Width,
Cursor::WidthAdd,
Cursor::WidthPoint,
Cursor::Blend,
Cursor::BlendObject,
Cursor::BlendAnchor,
Cursor::PathBracket,
Cursor::TypeWidget,
Cursor::ShapeBuilder,
Cursor::ShapeBuilderErase,
Cursor::Lasso,
Cursor::MagicWand,
Cursor::Pencil,
Cursor::Paintbrush,
Cursor::BlobBrush,
Cursor::Eraser,
Cursor::Smooth,
Cursor::PathEraser,
Cursor::Join,
Cursor::Knife,
Cursor::Shaper,
Cursor::LivePaintBucket,
Cursor::Measure,
Cursor::PrintTiling,
Cursor::SymbolSprayer,
Cursor::SymbolShifter,
Cursor::SymbolScruncher,
Cursor::SymbolSizer,
Cursor::SymbolSpinner,
Cursor::SymbolStainer,
Cursor::SymbolScreener,
Cursor::SymbolStyler,
Cursor::TypeVertical,
Cursor::Scissors,
Cursor::MeshAdd,
Cursor::PuppetPin,
];
/// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors
/// (the busy states).
/// (the busy states): the one drawn here, else the glyph of the cursor it borrows
/// ([`Cursor::stand_in`]).
fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
let ink = &mut Ink::default();
// A cursor not drawn yet: the one it borrows.
if let Some(stand_in) = c.stand_in() {
return glyph(stand_in, p);
}
match c {
Cursor::Arrow => arrow(ink, p, false),
Cursor::ArrowHollow => arrow(ink, p, true),
Cursor::DirectAnchor => arrow_square(ink, p, false),
Cursor::DirectAnchorSelected => arrow_square(ink, p, true),
Cursor::ReshapeSegment => reshape_segment(ink, p),
Cursor::GroupSelection => arrow_plus(ink, p),
Cursor::SelectionCopy => double_arrowhead(ink, p),
Cursor::Move => {
arrow(ink, p, false);
double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
@@ -331,6 +513,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)),
Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)),
Cursor::ShearH => shear(ink, p, vec2(1.0, 0.0)),
Cursor::ShearV => shear(ink, p, vec2(0.0, -1.0)),
Cursor::Rotate => rotate(ink, p, 0),
Cursor::RotateToward(octant) => rotate(ink, p, octant),
Cursor::CornerRadius => corner_radius(ink, p),
@@ -340,6 +524,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
Cursor::PenDelete => pen(ink, p, "-"),
Cursor::PenClose => pen(ink, p, "o"),
Cursor::PenContinue => pen(ink, p, "/"),
Cursor::AnchorPoint => anchor_point(ink, p),
Cursor::Curvature => pen(ink, p, "~"),
Cursor::PenJoin => pen(ink, p, "j"),
Cursor::PenConvert => pen(ink, p, "^"),
Cursor::Text => ibeam(ink, p),
@@ -395,6 +581,13 @@ pub fn os_cursors(build: bool, wayland: bool) -> bool {
build && !wayland
}
/// Does the canvas hide the system pointer to show a painted tool glyph alone? Not when there is no
/// cursor theme on Wayland ([`crate::Services::no_cursor_theme`]): winit sets no pointer then, and a
/// hidden one would not come back over the panels (#2450). The system pointer stays beside the glyph.
pub fn hide_system_pointer(no_cursor_theme: bool) -> bool {
!no_cursor_theme
}
/// Does the window hide the pointer only while it is the key window? macOS does: winit hides it with
/// a cursor rect holding a transparent cursor, and macOS applies the rect only in the key window.
/// Over a window behind another app or under a sheet, the system pointer shows (#2020).
@@ -519,55 +712,68 @@ mod tests {
use crate::VectorcraftApp;
/// Every cursor with a glyph.
const GLYPHS: [Cursor; 39] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
Cursor::ZoomOut,
Cursor::Arrow,
Cursor::ArrowHollow,
Cursor::Move,
Cursor::Crosshair,
Cursor::ResizeH,
Cursor::ResizeV,
Cursor::ResizeNwSe,
Cursor::ResizeNeSw,
Cursor::Rotate,
Cursor::RotateToward(1),
Cursor::CornerRadius,
Cursor::Pen,
Cursor::PenDrawing,
Cursor::PenAdd,
Cursor::PenDelete,
Cursor::PenClose,
Cursor::PenContinue,
Cursor::PenJoin,
Cursor::PenConvert,
Cursor::Text,
Cursor::Eyedropper,
Cursor::AddStop,
Cursor::RemoveStop,
Cursor::Slice,
Cursor::SliceSelect,
Cursor::Width,
Cursor::WidthAdd,
Cursor::WidthPoint,
Cursor::Blend,
Cursor::BlendObject,
Cursor::BlendAnchor,
Cursor::PathBracket,
Cursor::TypeWidget,
Cursor::ShapeBuilder,
Cursor::ShapeBuilderErase,
];
/// The straight RGBA of pixel (x, y).
fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] {
let i = (y * img.size[0] as usize + x) * 4;
[img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]]
}
/// The Anchor Point caret's hotspot is the middle of its hollow square: white there, with the
/// square's ink a few points above it.
#[test]
fn anchor_point_hotspot_is_inside_its_square() {
for ppp in [1.0f32, 2.0] {
let img = rasterize(Cursor::AnchorPoint, ppp).expect("anchor point has a bitmap");
let [hx, hy] = img.hotspot.map(usize::from);
assert_eq!(pixel(&img, hx, hy), [255, 255, 255, 255], "hollow at the hotspot at {ppp}x");
let reach = (4.0 * ppp).round() as usize;
let inked = (1..=reach.min(hy)).any(|d| {
let p = pixel(&img, hx, hy - d);
p[0] < 64 && p[3] > 200
});
assert!(inked, "ink on the square's top edge within 4 points above the hotspot at {ppp}x");
}
}
/// Direct Selection's anchor square is hollow over an unselected path and filled over a
/// selected one; the arrow stays white. The Reshape Segment and copy arrowheads are black and
/// Group Selection's arrow white, just inside their tips.
#[test]
fn selection_arrows_are_white_or_black() {
let at = |img: &CustomCursorImage, x: f32, y: f32, ppp: f32| {
let [hx, hy] = img.hotspot.map(usize::from);
pixel(img, hx + (x * ppp).round() as usize, hy + (y * ppp).round() as usize)
};
for ppp in [1.0f32, 2.0] {
for c in [Cursor::DirectAnchor, Cursor::DirectAnchorSelected] {
let img = rasterize(c, ppp).expect("a bitmap");
let tip = at(&img, 1.5, 6.0, ppp);
assert!(tip[0] > 192 && tip[3] > 200, "{c:?}'s arrow is white at {ppp}x: {tip:?}");
}
// Same arrow, so the filled square shows as more ink than the hollow one.
let ink = |c| {
let img = rasterize(c, ppp).expect("a bitmap");
img.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 64 && p[3] > 200).count()
};
let (hollow, filled) = (ink(Cursor::DirectAnchor), ink(Cursor::DirectAnchorSelected));
assert!(filled > hollow, "the selected path's square is filled at {ppp}x: {filled} vs {hollow}");
for (c, white) in [(Cursor::ReshapeSegment, false), (Cursor::SelectionCopy, false), (Cursor::GroupSelection, true)] {
let img = rasterize(c, ppp).expect("a bitmap");
let tip = at(&img, 1.5, 6.0, ppp);
assert_eq!(tip[0] > 192, white, "{c:?} at {ppp}x: {tip:?}");
assert!(tip[3] > 200, "{c:?} at {ppp}x: {tip:?}");
}
}
}
#[test]
fn a_cursor_not_drawn_yet_looks_like_the_one_it_borrows() {
for c in GLYPHS {
let Some(stand_in) = c.stand_in() else { continue };
assert_eq!(rasterize(c, 2.0), rasterize(stand_in, 2.0), "{c:?} borrows {stand_in:?}");
}
}
#[test]
fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() {
for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] {
@@ -590,6 +796,7 @@ mod tests {
assert!(pixel(&dropper, hx, hy)[3] > 0, "the tip covers the hotspot at {ppp}");
}
assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors");
assert!(rasterize(Cursor::ScissorsNotAllowed, 1.0).is_none(), "until it's drawn it borrows the system not-allowed cursor");
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
}
@@ -727,6 +934,22 @@ mod tests {
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None);
}
/// Without a cursor theme the canvas keeps the system pointer visible (#2450).
#[test]
fn no_cursor_theme_keeps_the_system_pointer() {
assert!(!hide_system_pointer(true));
assert!(hide_system_pointer(false));
let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, no_cursor_theme: true, ..Default::default() });
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
for _ in 0..3 {
frame(&mut app, &ctx, vec![]);
}
let over = app.canvas_rect.unwrap().center();
let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]);
assert_ne!(out.platform_output.cursor_icon, egui::CursorIcon::None);
}
/// On macOS a window without the focus shows the system pointer over the canvas, and the glyph
/// is not painted next to it (#2020). With the focus back, the canvas sets `CursorIcon::None` again
/// on the next frame, without a pointer move.
+2 -2
View File
@@ -76,7 +76,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
Ok(Value::Null)
}
Paint::Gradient(g) => {
let fields = json!({"__swatch": name, "name": name, "__gradient": g.gradient, "preview": true});
let fields = json!({"__swatch": name, "name": name, "__gradient": g, "preview": true});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
@@ -105,7 +105,7 @@ fn edit_params(d: &Dialog) -> Value {
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::<vectorcraft_color::Gradient>(g.clone()).ok()) {
let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::<vectorcraft_color::GradientPaint>(g.clone()).ok()) {
Some(g) => {
let changed = grid(ui, |ui| name_row(ui, d));
ui.add_space(6.0);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+39 -5
View File
@@ -94,7 +94,7 @@ fn plural_polish(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 20] = [
pub static LANGUAGES: [LangInfo; 23] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Greek: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; every `el-*` locale (including `el-GR` and `el-CY`) resolves here.
@@ -232,6 +232,26 @@ pub static LANGUAGES: [LangInfo; 20] = [
plural: plural_none,
complete_menus: true,
catalog: OnceLock::new(),
}, // Dutch; every `nl-*` locale (`nl-NL`, `nl-BE`, `nl_NL.UTF-8`…) resolves here.
LangInfo {
code: "nl",
name: "Nederlands",
source: include_str!("nl.tsv"),
plural: plural_one_other,
complete_menus: true,
catalog: OnceLock::new(),
},
// Danish; every `da-*` locale (`da-DK`, `da_DK.UTF-8`…) resolves here.
LangInfo { code: "da", name: "Dansk", source: include_str!("da.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
// Indonesian; every `id-*` locale (`id-ID`, `id_ID.UTF-8`…) resolves here. CLDR `id` counts
// have one form, so `@plural` entries list a single form.
LangInfo {
code: "id",
name: "Bahasa Indonesia",
source: include_str!("id.tsv"),
plural: plural_none,
complete_menus: true,
catalog: OnceLock::new(),
},
// Finnish: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; one/other plurals with the partitive after any number but 1 ("2 tasoa").
@@ -437,19 +457,33 @@ fn detect_system_lang() -> Lang {
Lang::EN
}
/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame.
static CURRENT: AtomicUsize = AtomicUsize::new(0);
/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame. The app keeps one
/// index for the whole process. Unit tests keep one per thread, as `ui_mirror!` keeps the
/// shortcut and workspace mirrors, so a language one test sets does not change the language of
/// the tests running beside it on other threads.
fn current_index() -> &'static AtomicUsize {
#[cfg(not(test))]
{
static CURRENT: AtomicUsize = AtomicUsize::new(0);
&CURRENT
}
#[cfg(test)]
{
thread_local!(static CURRENT: &'static AtomicUsize = Box::leak(Box::default()));
CURRENT.with(|c| *c)
}
}
/// Set the UI language for drawing (the shell calls this once per frame from the preference), so
/// widgets can translate without every call site carrying a language around.
pub fn set_current(lang: Lang) {
let i = LANGUAGES.iter().position(|l| l.code == lang.code()).unwrap_or(0);
CURRENT.store(i, Ordering::Relaxed);
current_index().store(i, Ordering::Relaxed);
}
/// The language the UI is drawn in.
pub fn current() -> Lang {
Lang(LANGUAGES.get(CURRENT.load(Ordering::Relaxed)).unwrap_or(&LANGUAGES[0]))
Lang(LANGUAGES.get(current_index().load(Ordering::Relaxed)).unwrap_or(&LANGUAGES[0]))
}
/// Does `lang` have a catalog entry for this plain string? (English never does: it is the source.)
File diff suppressed because it is too large. Load diff
+12 -4
View File
@@ -8,7 +8,7 @@ fn tags_map_to_languages() {
assert_eq!(lang_from_tag("en_US.UTF-8"), Some(Lang::EN));
assert_eq!(lang_from_tag("C"), Some(Lang::EN));
assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
assert_eq!(lang_from_tag("nl_NL"), None);
assert_eq!(lang_from_tag("qaa-QM"), None, "the private-use tag names no language");
// German: Germany, Austria, Switzerland and the rest share the one catalog.
for tag in ["de", "de_DE.UTF-8", "de-AT", "de_CH", "de-LU", "de_DE.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(de()), "{tag}");
@@ -67,7 +67,7 @@ fn candidates_walk_from_specific_to_general() {
#[test]
fn os_language_lists_are_parsed() {
assert_eq!(first_supported("(\n \"zh-Hant-TW\",\n \"en-US\"\n)\n"), Some(ZH()));
assert_eq!(first_supported("(\n \"nl-NL\",\n \"en-US\"\n)\n"), Some(Lang::EN));
assert_eq!(first_supported("(\n \"qaa-QM\",\n \"en-US\"\n)\n"), Some(Lang::EN), "the private-use tag falls through to the next entry");
assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Some(de()));
assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr"));
assert_eq!(first_supported("("), None);
@@ -100,6 +100,15 @@ fn current_language_is_a_frame_setting() {
assert_eq!(t("Layers"), "Layers");
}
/// Unit tests run on parallel threads, and each one draws in its own language: a language set on
/// another thread (by a test running beside this one) leaves this thread's text in English.
#[test]
fn a_language_set_by_another_test_leaves_this_one_in_english() {
std::thread::spawn(|| set_current(ZH())).join().unwrap();
assert_eq!(current(), Lang::EN);
assert_eq!(t("Layers"), "Layers");
}
#[test]
fn lookups_fall_back_to_english() {
assert_eq!(tr(Lang::EN, "Layers"), "Layers");
@@ -197,8 +206,7 @@ fn partial_catalogs_only_translate_strings_the_ui_shows() {
}
/// The engine learns the language the UI is drawn in each frame: new type takes the Japanese
/// defaults while it is Japanese (#432). The frames stay in English: the drawing language is
/// process-wide, and a Japanese frame would translate the tests running beside this one.
/// defaults while it is Japanese (#432). The frames stay in English.
#[test]
fn the_engine_follows_the_interface_language() {
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
+6
View File
@@ -275,6 +275,10 @@ pub struct Services {
/// The window is on Wayland: the tool cursors are painted into it instead of handed to the
/// compositor as bitmaps ([`cursors::os_cursors`]).
pub wayland: bool,
/// On Wayland, no cursor theme can be loaded, so winit sets no pointer and the compositor's
/// default one would never come back once the canvas hid it: the canvas keeps the system
/// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450).
pub no_cursor_theme: bool,
}
/// Cached canvas raster.
@@ -332,6 +336,8 @@ pub struct CacheKey {
pub rot: f64,
/// General › Anti-aliased Artwork.
pub anti_alias: bool,
/// Type › Highlight Alternate Glyphs (#394).
pub highlight_alternate_glyphs: bool,
/// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since.
pub placed: u64,
/// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown
+2 -2
View File
@@ -1242,8 +1242,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"view.screenMode" => {
app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) {
Some(m) => m.min(3) as u8,
// F cycles the three screen modes; from Presentation Mode (not in the cycle) it
// goes back to Normal.
// F cycles the three screen modes. Without a mode, the command also goes from
// Presentation Mode (not in the cycle) back to Normal.
None if app.ui.screen_mode >= 2 => 0,
None => app.ui.screen_mode + 1,
};
+19 -2
View File
@@ -625,6 +625,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
display
.floating
.retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id))));
// Floating groups lie on the canvas, not on the dock's card: reserve a fill under each one
// before the dock draws it, sized once it is placed, so the canvas doesn't show through (#2421).
let fills: Vec<_> = display
.floating
.iter()
.filter_map(|group| group.panels.first())
.map(|first| {
let layer = egui::LayerId::new(egui::Order::Middle, area.with(("floating", first)));
(layer, ui.ctx().layer_painter(layer).add(egui::Shape::Noop))
})
.collect();
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}));
@@ -644,7 +655,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
}
let mut style = craft_ui::docking::DockStyle::from_ui(ui);
style.tab_height = 33.0;
// The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour.
// The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour
// (square fills would cover its rounded corners). Floating groups get the fills below.
style.background = egui::Color32::TRANSPARENT;
style.tab_background = egui::Color32::TRANSPARENT;
style.active_background = t.button;
@@ -659,7 +671,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
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(
let output = craft_ui::docking::DockArea::new(area).show_customized(
ui,
&display,
&style,
@@ -754,6 +766,11 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect));
}
}
for (layer, fill) in fills {
if let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(layer.id)) {
ui.ctx().layer_painter(layer).set(fill, egui::Shape::rect_filled(rect, 0.0, t.panel));
}
}
icon_rail(app, ui);
crate::floating::docked_columns(app, ui);
true
+27 -1
View File
@@ -1,5 +1,5 @@
use super::*;
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
fn app() -> VectorcraftApp {
@@ -614,3 +614,29 @@ fn the_window_menu_item_hides_a_shown_panel_and_shows_it_again() {
app.run("window.panel", json!({"panel":"properties"})).unwrap();
assert!(shown(&app), "without the menu's toggle the command only shows the panel");
}
/// A floating group lies on the canvas, not on the dock's card, so its tab strip and body paint
/// their own opaque background (#2421).
#[test]
fn floating_group_paints_an_opaque_background() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"properties","x":400,"y":200,"width":300,"height":420})).unwrap();
let h = harness(app, vec2(1280.0, 800.0));
// Paint order: the last big filled rectangle over a point decides what the user sees there
// (outlines, small widgets and fully transparent fills, which paint nothing, don't count).
fn topmost(shape: &egui::Shape, p: Pos2, found: &mut Option<egui::Color32>) {
match shape {
egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 200.0 && r.stroke.is_empty() && r.fill.a() > 0 => *found = Some(r.fill),
egui::Shape::Vec(v) => v.iter().for_each(|s| topmost(s, p, found)),
_ => {}
}
}
let opaque_at = |p: Pos2| {
let mut found = None;
h.output().shapes.iter().for_each(|s| topmost(&s.shape, p, &mut found));
found.is_some_and(|c| c.a() == 255)
};
// Tab strip to the right of the tabs, and the body's empty bottom-right corner.
assert!(opaque_at(pos2(690.0, 210.0)), "tab strip is transparent");
assert!(opaque_at(pos2(690.0, 610.0)), "body is transparent");
}
+68 -4
View File
@@ -782,10 +782,8 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
widgets::dim_label(ui, &size.map_or_else(String::new, |(w, h)| format!("{w} × {h} px")));
}
Value::String(s) => {
let mut buf = s.clone();
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(90.0));
if r.lost_focus() && buf != *s {
change = Some((key.clone(), json!(buf)));
if let Some(typed) = widgets::exact_text_field(ui, ("fx", item, k, key.as_str()), s, 90.0) {
change = Some((key.clone(), json!(typed)));
}
}
// Free Distort's corners are dragged in its dialog.
@@ -1059,6 +1057,72 @@ mod tests {
assert_eq!(editor_params(&e).get("angle"), Some(&json!(270)));
}
/// A text field of the inline editor keeps what is typed from one frame to the next: a Drop
/// Shadow's color typed a character a frame and confirmed with Enter is set in one undo step.
/// Escape drops what is typed.
#[test]
fn the_effect_editor_keeps_text_typed_across_frames() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out
};
// Every text drawn, with its center.
fn texts(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let drawn = |out: &egui::FullOutput| {
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
};
run(&mut app, vec![]);
let shown = drawn(&run(&mut app, vec![]));
let at = shown.iter().find(|(t, _)| t == "#000000").map(|(_, p)| *p);
let Some(at) = at else { panic!("no color field: {shown:?}") };
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
run(&mut app, events);
}
run(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
let undo = app.session.active().unwrap().history.undo.len();
for ch in "#ff0000".chars() {
run(&mut app, vec![egui::Event::Text(ch.to_string())]);
}
let shown = drawn(&run(&mut app, vec![]));
assert!(shown.iter().any(|(t, _)| t == "#ff0000"), "the field shows what was typed: {shown:?}");
run(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
run(&mut app, vec![]);
let color = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["color"].clone();
assert_eq!(color(&app), json!("#ff0000"));
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step");
// Escape drops what is typed.
for events in [vec![button(true)], vec![button(false)]] {
run(&mut app, events);
}
run(&mut app, vec![egui::Event::Text("0".into())]);
let shown = drawn(&run(&mut app, vec![]));
assert!(shown.iter().any(|(t, _)| t.len() == 8 && t.starts_with('#')), "a 0 typed into the color: {shown:?}");
run(&mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]);
run(&mut app, vec![]);
assert_eq!(color(&app), json!("#ff0000"));
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
}
#[test]
fn drops_pick_the_command() {
use vectorcraft_doc::{Appearance, FillLayer};
+47 -1
View File
@@ -147,6 +147,28 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
range_style(app, p);
}
/// Set the Kerning field's `value` (a number or "auto"). At a caret in text the Type tool edits
/// it is the pair kerning after the character before the caret, as Alt+←/→ steps it
/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like
/// the others: the selected range, or all the text.
fn set_kerning(app: &mut VectorcraftApp, value: Value) {
let Some((id, a, b)) = text_editing(app) else {
format(app, json!({"kerning": value}));
return;
};
if b > a {
range_style(app, json!({"kerning": value}));
return;
}
let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return };
let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a);
if start >= a {
return;
}
end_typing(app);
app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok();
}
/// The faces Type › Bold and Type › Italic switch to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Face {
@@ -450,7 +472,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
cell(ui, "VA", tl!("Kerning (0 = Auto)"), |ui| {
let kerning = shared(app, |s| s.kerning.unwrap_or(0.0));
if let Some(v) = widgets::spin_plain(ui, "ch-kern", kerning, "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) {
format(app, if v == 0.0 { json!({"kerning": "auto"}) } else { json!({"kerning": v}) });
set_kerning(app, if v == 0.0 { json!("auto") } else { json!(v) });
}
});
cell(ui, "VA↔", tl!("Tracking"), |ui| {
@@ -783,6 +805,30 @@ mod tests {
assert_eq!(text_style(&app).unwrap().0.size, 5.0);
}
/// The Kerning field at a caret kerns that pair, not the whole text (#2217).
#[test]
fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX", "size": 12})).unwrap()["id"].clone();
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
app.session.set_tool_option("select", &json!({"start": 2, "end": 2}));
set_kerning(&mut app, json!(-50));
let kerning = |app: &VectorcraftApp, at: usize| {
let st = app.session.active().unwrap();
let Some(NodeKind::Text(t)) = st.doc.node(NodeId(id.as_u64().unwrap())).map(|n| &n.kind) else { panic!("text") };
vectorcraft_text::edit::style_at(&t.runs, at + 1).kerning // the character at `at`
};
assert_eq!(kerning(&app, 1), Some(-50.0), "the character before the caret");
assert_eq!(kerning(&app, 3), None, "the rest is untouched");
assert_eq!(kerning(&app, 0), None);
// A selected range still takes it whole.
app.session.set_tool_option("select", &json!({"start": 2, "end": 4}));
set_kerning(&mut app, json!(30));
assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0)));
}
/// Bold and Italic pick the family's own faces by name, and toggle back (#724).
#[test]
fn bold_and_italic_pick_the_familys_own_faces() {
+3 -3
View File
@@ -165,14 +165,14 @@ fn save_to_swatches(app: &mut VectorcraftApp, g: &GradientPaint) {
}
/// The document's gradient swatches (groups included): name and gradient.
fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, Gradient)> {
fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, GradientPaint)> {
let Some(st) = app.session.active() else { return vec![] };
let d = &st.doc;
d.swatches
.iter()
.chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter()))
.filter_map(|s| match &s.paint {
Paint::Gradient(g) => Some((s.name.clone(), g.gradient.clone())),
Paint::Gradient(g) => Some((s.name.clone(), (**g).clone())),
_ => None,
})
.collect()
@@ -370,7 +370,7 @@ fn freeform_section(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint) {
fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click_and_drag());
widgets::gradient_chip(ui, r, &g.gradient);
widgets::gradient_chip(ui, r, g);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
let resp = resp.on_hover_text(tl!("Gradient Fill: click to apply, drag onto art"));
if resp.clicked() && !is_grad {
+66 -24
View File
@@ -1,11 +1,12 @@
//! Graphic Styles panel: the document's styles as rendered thumbnails (Thumbnail View) or rows
//! (Small and Large List View), shown on a square or on type. Clicking a style applies it to the
//! selection (Alt-click adds it on top), Shift/Cmd-click selects several, double-click opens
//! Graphic Style Options. Dragging a style moves it in the list, or applies it to the art it is
//! dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance panel's
//! thumbnail dropped on the panel becomes a new style. Holding the right button on a style shows it
//! large, on the selected object when there is one. The Control bar's Style picker lists the same
//! tiles ([`picker`]). Graphic style libraries open in the library panel
//! selection (Alt-click adds it on top); Shift-click adds to the panel's selection every style from
//! the last one clicked without Shift through this one; Cmd/Ctrl-click adds or removes one;
//! double-click opens Graphic Style Options. Dragging a style moves it in the list, or applies it
//! to the art it is dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance
//! panel's thumbnail dropped on the panel becomes a new style. Holding the right button on a style
//! shows it large, on the selected object when there is one. The Control bar's Style picker lists
//! the same tiles ([`picker`]). Graphic style libraries open in the library panel
//! ([`GraphicStyleLibraries`]).
use std::cell::RefCell;
@@ -270,8 +271,8 @@ fn large_preview(app: &VectorcraftApp, ctx: &egui::Context, s: &Shown, i: usize)
/// What a click on a style does.
enum Click {
Apply(String),
Toggle(String),
/// A click with the modifiers held.
Pick(String, egui::Modifiers),
Options(String),
}
@@ -294,8 +295,7 @@ fn clicked(ui: &Ui, resp: &Response, name: &str) -> Option<Click> {
if resp.double_clicked() {
Some(Click::Options(name.to_string()))
} else if resp.clicked() {
let m = ui.input(|i| i.modifiers);
Some(if m.shift || m.command { Click::Toggle(name.to_string()) } else { Click::Apply(name.to_string()) })
Some(Click::Pick(name.to_string(), ui.input(|i| i.modifiers)))
} else {
None
}
@@ -419,24 +419,19 @@ fn act(app: &mut VectorcraftApp, ui: &Ui, s: &Shown, ev: Events, picker: bool) {
run(app, "graphicStyle.new", json!({ "id": id.0 }));
}
match ev.click {
Some(Click::Apply(name) | Click::Toggle(name)) if picker => {
Some(Click::Pick(name, _)) if picker => {
app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok();
ui.close();
}
Some(Click::Apply(name)) => {
// With nothing selected the next object drawn takes the style.
app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok();
set_pstate(ui.ctx(), "gs-sel", vec![name]);
}
Some(Click::Toggle(name)) => {
let mut sel = selected(ui.ctx(), s.d);
match sel.iter().position(|n| *n == name) {
Some(i) => {
sel.remove(i);
}
None => sel.push(name),
}
Some(Click::Pick(name, m)) => {
let order: Vec<&str> = s.d.graphic_styles.iter().map(|g| g.name.as_str()).collect();
let sel = super::swatches::click_selection(ui, "gs-anchor", selected(ui.ctx(), s.d), &order, &name, m);
set_pstate(ui.ctx(), "gs-sel", sel);
// A plain click also applies the style (with nothing selected the next object drawn
// takes it); Shift and Cmd/Ctrl clicks only select.
if !(m.shift || m.command) {
app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok();
}
}
Some(Click::Options(name)) => {
app.run("ui.graphicStyleOptions", json!({ "name": name })).ok();
@@ -938,6 +933,53 @@ mod tests {
assert_eq!(d.node(rect).unwrap().extras().graphic_style, d.graphic_styles.last().map(|g| g.id));
}
#[test]
fn shift_click_selects_a_range_and_cmd_click_adds_or_removes_one_style() {
let mut app = app();
app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap();
let n = names(&app);
assert_eq!(n.len(), 5);
let ctx = egui::Context::default();
set_pstate(&ctx, "gs-view", View::SmallList);
frame(&ctx, |ui| show(&mut app, ui));
// A click on row `i` (Small List View: rows of 18 points, 3 apart) with `modifiers` held,
// a second after the last one so that two clicks on a row are not a double-click.
let mut time = 0.0;
let mut click = |app: &mut VectorcraftApp, i: usize, modifiers: egui::Modifiers| {
let at = pos2(60.0, 10.0 + 21.0 * i as f32);
time += 1.0;
for pressed in [true, false] {
let button = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers };
let raw = egui::RawInput {
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 600.0))),
events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(at), button],
time: Some(time + if pressed { 0.0 } else { 0.05 }),
..Default::default()
};
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
}
selected(&ctx, &app.session.active().unwrap().doc.clone())
};
let linked = |app: &VectorcraftApp| app.session.selection_graphic_style().map(|(g, _)| g.name.clone());
// A plain click applies the style and selects it alone.
assert_eq!(click(&mut app, 0, egui::Modifiers::NONE), [n[0].clone()]);
assert_eq!(linked(&app).as_ref(), Some(&n[0]));
// Shift-click selects from that style to the clicked one, and applies nothing.
assert_eq!(click(&mut app, 3, egui::Modifiers::SHIFT), n[..4]);
assert_eq!(linked(&app).as_ref(), Some(&n[0]));
// Cmd/Ctrl-click removes a selected style and adds it back.
assert_eq!(click(&mut app, 2, egui::Modifiers::COMMAND), [n[0].clone(), n[1].clone(), n[3].clone()]);
assert_eq!(click(&mut app, 2, egui::Modifiers::COMMAND), [n[0].clone(), n[1].clone(), n[3].clone(), n[2].clone()]);
// Shift-click extends from the style Cmd/Ctrl-clicked last.
click(&mut app, 0, egui::Modifiers::NONE);
click(&mut app, 2, egui::Modifiers::COMMAND);
assert_eq!(click(&mut app, 4, egui::Modifiers::SHIFT), [n[0].clone(), n[2].clone(), n[3].clone(), n[4].clone()]);
assert_eq!(linked(&app).as_ref(), Some(&n[0]));
// A plain click applies the next style.
assert_eq!(click(&mut app, 4, egui::Modifiers::NONE), [n[4].clone()]);
assert_eq!(linked(&app).as_ref(), Some(&n[4]));
}
#[test]
fn the_right_button_shows_a_large_preview_and_the_picker_applies_styles() {
let mut app = app();
+22 -2
View File
@@ -274,10 +274,10 @@ fn body<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K
});
});
if let Some((name, folder, m)) = clicked {
// A plain click on an item adds and applies it; folders and modifier clicks select.
// A plain click on an item also adds and applies it; folders and modifier clicks only select.
let only_select = folder || m.shift || m.command;
let order: Vec<&str> = rows.iter().map(|r| r.name).collect();
let sel = if only_select { click_selection(ui, &key::<K>("anchor"), selected.clone(), &order, &name, m) } else { vec![name.clone()] };
let sel = click_selection(ui, &key::<K>("anchor"), selected, &order, &name, m);
set_selection::<K>(ui.ctx(), id, sel);
if !only_select {
K::click(app, ui, id, &name);
@@ -485,6 +485,26 @@ mod tests {
assert!(selection::<SwatchLibraries>(&ctx, "brights").is_empty());
}
#[test]
fn a_plain_click_sets_where_a_shift_click_range_starts() {
let mut app = app();
let ctx = context();
app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap();
let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap();
let order: Vec<String> = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect();
click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0);
click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0);
assert_eq!(selection::<SwatchLibraries>(&ctx, "pastels"), order[1..=4], "the range starts at the plain click");
// A plain click after a Cmd/Ctrl-click moves the anchor from the Cmd/Ctrl-clicked item.
click(&mut app, &ctx, &order[8], Modifiers::COMMAND, 4.0);
click(&mut app, &ctx, &order[2], Modifiers::NONE, 6.0);
click(&mut app, &ctx, &order[3], Modifiers::SHIFT, 8.0);
assert_eq!(selection::<SwatchLibraries>(&ctx, "pastels"), order[2..=3]);
let d = &app.session.doc().unwrap().doc;
assert!(d.swatch(&order[2]).is_some(), "a plain click still adds the swatch");
assert!(d.swatch(&order[3]).is_none(), "a Shift-click only selects");
}
#[test]
fn find_filters_list_view_and_arrows_step_through_the_libraries() {
let mut app = app();
+35 -13
View File
@@ -1,9 +1,11 @@
//! Links panel: every image in the document, linked files with their status (missing, modified)
//! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects
//! its image; the buttons Relink, Go To, Update and Edit Original act on the selected rows; Link
//! Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find
//! Linked Files: `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort,
//! thumbnail size and Show in Folder.
//! its image, Ctrl/Cmd-click adds or removes it, and Shift-click selects the images of the rows
//! from the last row clicked without Shift to it (with Ctrl/Cmd, adds them); the buttons Relink,
//! Go To, Update and Edit Original act on the selected rows; Link Info shows the details of the
//! first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files:
//! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort, thumbnail size
//! and Show in Folder.
//!
//! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI
//! commands: they hand the file to the system).
@@ -245,6 +247,31 @@ fn status_tip(r: &Value) -> String {
}
}
/// A click on the row of image `id` among the shown `rows`: plain selects the image alone,
/// Ctrl/Cmd adds or removes it, and Shift selects the images of the rows from the last row clicked
/// without Shift in this document to it, or with Ctrl/Cmd adds them to the selection. A
/// Shift-click while that row is not shown, or after the last click was in another document, acts
/// as a click without Shift.
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, m: egui::Modifiers) {
let Some(uid) = app.session.active().map(|st| st.uid) else { return };
let anchor: Option<(u64, u64)> = pstate(ctx, "links-anchor");
let at = |id: u64| rows.iter().position(|r| r["id"].as_u64() == Some(id));
if m.shift
&& let Some((_, a)) = anchor.filter(|(doc, _)| *doc == uid)
&& let (Some(i), Some(j)) = (at(a), at(id))
{
let range: Vec<u64> = rows.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().filter_map(|r| r["id"].as_u64()).collect();
app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": range })).ok();
return;
}
set_pstate(ctx, "links-anchor", Some((uid, id)));
if m.command {
app.run("select.toggle", json!({ "id": id })).ok();
} else {
app.run("select.set", json!({ "ids": [id] })).ok();
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let o = options(ui.ctx());
@@ -257,7 +284,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let thumbs = super::layers::ThumbSource::of(app, st, &doc);
let selected = selected_images(app);
let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice);
let mut clicked: Option<(u64, bool, bool)> = None;
let mut clicked: Option<(u64, egui::Modifiers, bool)> = None;
widgets::list_box(ui, |ui| {
ui.set_min_height(110.0);
ui.set_width(ui.available_width());
@@ -317,21 +344,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
);
let resp = resp.on_hover_text(status_tip(r));
if resp.double_clicked() {
clicked = Some((id, false, true));
clicked = Some((id, egui::Modifiers::NONE, true));
} else if resp.clicked() {
clicked = Some((id, ui.input(|i| i.modifiers.shift || i.modifiers.command), false));
clicked = Some((id, ui.input(|i| i.modifiers), false));
}
}
});
});
match clicked {
Some((id, _, true)) => go_to(app, id),
Some((id, true, _)) => {
app.run("select.toggle", json!({ "id": id })).ok();
}
Some((id, false, _)) => {
app.run("select.set", json!({ "ids": [id] })).ok();
}
Some((id, m, false)) => click(app, ui.ctx(), rows, id, m),
None => {}
}
if o.info {
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more