Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 c4e139af19 #885: every checkbox keeps a visible box at rest
The theme leaves egui's own checkbox without an outline while inactive,
so an unchecked box was invisible and a checked one a bare tick. #1080
moved Preferences to widgets::check; the remaining ui.checkbox uses
follow: the Properties panel's Snap to Point, Snap to Grid and Scale
Strokes & Effects, Revolve's Keep visible surfaces and Shading, Save
options, Export for Screens, the Variables dialog and plug-in forms.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:13 +07:00
Nigel Ihza Firdausy 0d748500d6 fix(shaper): reject invalid explicit scribble targets
Reapply on current upstream main.
2026-10-11 07:43:13 +07:00
Nigel Ihza Firdausy d47857a20f fix(layers): validate explicit Layer Options targets
Reapply on current upstream main.
2026-10-11 07:43:13 +07:00
BFlat c9236ed3f7 Merge pull request #1084 from storytold/fix/1068-ai-artboard-layout
#1068: a .ai read as its PDF part keeps its artboard layout
2026-10-11 07:35:59 +07:00
bflatasticandClaude Opus 5.5 2480a505cb #1068: a .ai read as its PDF part keeps its artboard layout
When a .ai's layers can't be read from its editing data yet, it opens as
its PDF part, which lists the artboards as pages; those were laid out in
a row, so a grid of artboards (or any other arrangement) was lost. The
editing data's artboards are now read even then (ai::artboards, as far
as the reader gets) and, when there are as many as pages and of the same
sizes, each page goes where its artboard is, with the art on it (the art
of a page: the objects mostly on it, else nearest it). Otherwise the
pages stay in a row as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:29:16 +07:00
BFlat 231a97dd51 Merge pull request #1074 from dotCooCoo/feat/align-compound-key-stays
Align: a compound path or selected group that contains the key object stays in place
2026-10-11 07:18:50 +07:00
dotCooCoo 1819921e03 Align: a compound path or selected group that contains the key object stays in place
Align and Distribute Spacing move a selected compound path or group as
one object, and they found the key object only when its own id was
among the objects they move. When the key was inside a compound path
or a selected group, aligning to the key moved that compound path or
group like any other object, and the key moved with it. In that case,
Distribute Spacing with a spacing held the first object still instead
of the key.

The key object can end up inside a selected group or a compound path.
Dragging its row into a group in the Layers panel (layer.move) keeps
the selection and the key. A Selection-tool Shift-click on that group,
when it is unselected and directly in a layer, then adds it to the
selection (select.toggle) and keeps the key. Other ways of adding the
group keep the key as well: a Shift-click in the Layers panel's
selection column or on the group's target circle, a Group Selection
click that hits the key itself, select.add, and select.anchors or
select.anchorsMany in add or toggle mode. In isolation mode, a
Selection-tool click on one of several selected objects in the
isolated group makes that object the key. In an isolated compound
path, the same click on the outline of a selected member makes that
member the key.

Align to the key object and Distribute Spacing with a spacing now keep
in place the selected object that is the key or contains it. Both
commands look for that object among the key and its ancestors, so a
group around the key's group also stays in place. Aligning to the key
still lines the others up with the key's own bounds.

Docs: docs/target-app-parity.md.
2026-10-11 07:18:28 +07:00
BFlat 06226bcb73 Merge pull request #1062 from dotCooCoo/feat/eps-arc-angles
EPS: arc and arcn with huge angles no longer hang the import
2026-10-11 07:15:53 +07:00
BFlat 20eaecd624 Merge pull request #1059 from dotCooCoo/feat/command-sweep-stale-exclusions
Tests: the command sweeps no longer skip junk scale, spacing and artboard values
2026-10-11 07:15:38 +07:00
BFlat 653945c8f8 Merge pull request #1058 from Ni-zav/fix/noissue-saved-view-geometry-s20261010T2146Z-4e82
fix(views): validate saved-view coordinates and rotation
2026-10-11 07:15:22 +07:00
BFlat bfde4dd918 Merge pull request #1057 from Ni-zav/fix/noissue-corner-dialog-explicit-inputs-s20261010T2146Z-4e82
fix(corners): reject malformed explicit dialog targets
2026-10-11 07:15:12 +07:00
BFlat a71348821a Merge pull request #1056 from Ni-zav/fix/noissue-freeform-line-indices-s20261010T2146Z-4e82
fix(gradient): reject malformed freeform line index lists
2026-10-11 07:15:03 +07:00
BFlat 4f60296e77 Merge pull request #1055 from Ni-zav/fix/noissue-textwrap-explicit-targets-s20261010T2146Z-4e82
fix(textwrap): reject malformed explicit object targets atomically
2026-10-11 07:14:46 +07:00
BFlat 98ccc7980b Merge pull request #1054 from Ni-zav/fix/1018-all-tools-compact-s20261010T2146Z-4e82
fix(ui): keep All Tools drawer compact on wide screens
2026-10-11 07:14:31 +07:00
BFlat 1639666a34 Merge pull request #1052 from MarekSpodBiedry/issue-1050
Gradient mesh: a fill colour recolours the point last clicked (#1050)
2026-10-11 07:14:14 +07:00
BFlat 47af13c3d4 Merge pull request #1051 from bobtherobot/fix/build-scripts-unchanged-output
Build: generated sources are rewritten only when they change
2026-10-11 07:14:00 +07:00
bflatasticandClaude Opus 5.5 1da3c0c052 ROADMAP: EPS arcs with huge angles in the progress log (#1062)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:38 +07:00
bflatasticandClaude Opus 5.5 b19731b307 ROADMAP: the command sweeps in the progress log (#1059)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:38 +07:00
dotCooCoo 2d95ac1694 EPS: arc and arcn with huge angles no longer hang the import
arc raised its second angle by 2π in a loop until it was no longer
below the first, and arcn lowered it the same way. For an angle of
1e18 degrees that loop takes about 3e15 steps: vectorcraft-cli info
was still reading an EPS file with "newpath 100 100 50 1e18 0 arc
stroke" when "timeout 20" stopped it. For 1e308 the loop never ends:
from 2^56 radians (about 4.1e18 degrees) up, adding 2π leaves a double
unchanged. The import fuzz test eps_hostile_programs_never_panic builds
programs from tokens that include arc, arcn, 1e308 and 1e15.

arc now raises the second angle by multiples of 360 until it is at
least the first, and arcn lowers it until it is at most the first, as
the PostScript Language Reference defines them. The code works out both
sweeps from the remainders of the two angles after whole turns, and it
places the first endpoint at the remainder of the first angle. Ordinary
angles give the same arcs as before, apart from rounding in the last
digits. One case changes: when raising (arc) or lowering (arcn) the
second angle by whole turns makes it equal to the first, as in
60 -300 arc or -60 300 arcn, rounding sometimes made the old loop add
another turn and draw a full circle. Those arcs are now empty, as the
PostScript Language Reference defines them.

Tests: 1e18 0 arc, 1e308 0 arc, 0 1e18 arcn and 0 1e308 arcn draw the
arc between the angles' remainders and leave the current point at the
second angle. The test reads each program on a thread of its own and
fails when no result arrives within 20 seconds.
2026-10-11 07:13:38 +07:00
bflatasticandClaude Opus 5.5 68241453dd Review: New View reads its centre with finite_numbers; ROADMAP (#1058)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:37 +07:00
bflatasticandClaude Opus 5.5 39099dcc57 Review: Corners reuses its messages; i18n; ROADMAP (#1057)
A malformed id gets the existing object-id message, and a listed anchor
that is not a corner the existing "give corners of the path" one, so
only the malformed-list message is new (translated in every catalog
that covers messages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:37 +07:00
dotCooCoo 9ea392e6ee Tests: the command sweeps no longer skip junk scale, spacing and artboard values
The sweeps `every_command_with_junk_params` and
`fuzzed_calls_keep_documents_serializable` skipped every case that set
`object.scale`'s `sx`, `object.transformEach`'s `scaleH` or `scaleV`,
`object.distributeSpacing`'s `spacing`, or `artboard.setProps`'s `x`,
`y`, `width` or `height`. The comment above `KNOWN_BUGS`, the list of
those (command, param) pairs, referred to `#[ignore]`d repro tests, but
`bug_huge_transform_makes_document_unloadable` and
`bug_artboard_huge_props_break_svg` have no `#[ignore]` and run with the
other tests. Since 42df20ca, the commit that added the list,
`Session::edit` returns an error when an edit leaves an artboard or a
selected object with coordinates that are not finite or exceed
`MAX_COORD`, and `object.transformEach` clamps `scaleH` and `scaleV`.

The list and its two checks are removed, so both sweeps run those
cases.
2026-10-11 07:13:37 +07:00
Nigel Ihza Firdausy fffec4a714 fix(views): validate saved-view coordinates and rotation 2026-10-11 07:13:37 +07:00
bflatasticandClaude Opus 5.5 adfe6e3820 Review: one point-index parser for freeform lines; i18n; ROADMAP (#1056)
parse_freeform and paint.freeform.addLine share point_indices, which also
keeps addLine from wrapping a huge index round on 32-bit targets (wasm).
The new messages are in the catalogs that translate every message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:36 +07:00
Nigel Ihza Firdausy 935a588aa2 fix(corners): reject malformed explicit dialog targets 2026-10-11 07:13:36 +07:00
bflatasticandClaude Opus 5.5 212fb9994a Review: one checked ids-or-selection helper; ROADMAP (#1055)
Text Wrap and the variable bindings share checked_ids_or_selection in
cmd/mod.rs instead of each keeping its own copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:35 +07:00
Nigel Ihza Firdausy f5111045a9 fix(gradient): reject malformed freeform line index lists 2026-10-11 07:13:35 +07:00
bflatasticandClaude Opus 5.5 c3e0ca3dfa Review: All Tools width from its cells; test order; ROADMAP (#1054)
The drawer width is worked out from the tool cell, and the new test no
longer sits between the existing test and its doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:34 +07:00
bflatasticandClaude Opus 5.5 50dbe4c10e ROADMAP: recolouring mesh points in the progress log (#1052)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:34 +07:00
Nigel Ihza Firdausy ec41d72e0d fix(textwrap): reject malformed explicit object targets atomically 2026-10-11 07:13:34 +07:00
Nigel Ihza Firdausy 63026402a1 fix(ui): keep All Tools drawer compact on wide screens (#1018) 2026-10-11 07:13:34 +07:00
bflatasticandClaude Opus 5.5 a785b53e7b ROADMAP: generated sources in the progress log (#1051)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:33 +07:00
MarekSpodBiedry 1217d58ca1 Gradient mesh: a fill colour recolours the point last clicked (#1050) 2026-10-11 07:13:33 +07:00
Michael GiesonandClaude Opus 5.5 c912ef59d1 Build: generated sources are rewritten only when they change
crates/text/build.rs (craft_fonts.rs) and crates/ui-egui/build.rs (credits.rs) wrote their
output on every run. Where cargo's recorded times lose their sub-second part (an ecryptfs home
directory, as Mint and Ubuntu offer at install), a file written just before its crate compiles
reads as newer than that compile, so the next build compiled the crate and everything above it
a second time: a full app rebuild for nothing after any build-script rerun. Unchanged output is
now left alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 07:13:32 +07:00
BFlat af505b197a Merge pull request #1076 from storytold/fix/1032-lab-spots
#1032: spot colours defined in Lab keep their Lab values
2026-10-11 07:06:46 +07:00
bflatasticandClaude Opus 5.5 f043a1a392 #1032: spot colours defined in Lab keep their Lab values
A .ai or PDF whose spot colours are defined in Lab opened them wrong:
- the PDF part gives such an ink an ICC Lab profile as its alternate
  space ([/ICCBased profile], 3 components), which was read as RGB, so
  the Lab values were clamped into wildly wrong colours (PANTONE 249 C
  came in yellow). A profile whose Alternate is [/Lab ...], or whose
  header names the Lab data colour space, is now read as Lab;
- the editing data writes them as `c m y k L a b (name) tint 2 Xx`,
  type 2 being Lab, and only the CMYK equivalent was read. Type 2 now
  gives the Lab colour, in the swatches and on the art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:06:05 +07:00
BFlat d01d7b2ef0 Merge pull request #1060 from dotCooCoo/feat/align-no-op-no-undo
Object: Align, Move, Arrange and other commands that change nothing record no undo step
2026-10-11 07:02:47 +07:00
dotCooCoo dc6e8a4253 Object: Align, Move, Arrange and other commands that change nothing record no undo step
These Object commands edited the document even when nothing changed, so
the call recorded an undo step, emptied Redo and marked the document
modified:

- object.align, object.distributeSpacing and object.distribute (three
  or more objects) when no object moves. With one object selected,
  Object › Align aligns it to its own bounds, so it recorded a step
  every time. Calls on objects that were already aligned or evenly
  spaced also recorded a step, including objects that an earlier Align
  or Distribute left a rounding error away from the position the next
  call computes.
- Move, Rotate, Scale, Shear, Transform and Nudge by an identity
  transform without a copy, Transform Again after one, Set Bounds with
  the current position and size, and a Selection tool drag that ends
  where it started.
- Nudge by 0 with anchors direct-selected or with only ruler guides
  selected, and Transform with nothing selected or with ids that name
  no object, with or without a copy.
- Bring to Front and Bring Forward on objects already in front, Send to
  Back and Send Backward on objects already at the back, and Send to
  Current Layer on objects already on top of the current layer.
- Ungroup, Release Compound Path and Release Clipping Mask with none of
  their objects selected, and Expand Shape with no live shape selected.
- Lock with every selected object already locked, Hide with every one
  already hidden, Unlock All with nothing locked and Show All with
  nothing hidden.
- object.setProps with the values the objects already have, or with no
  object.
- object.setLiveShape with the values the shape already has: a
  polygon's sides, angle, radius or equal sides, or a side length that
  gives exactly its current radius; an ellipse's pie, including Invert
  Pie on a whole ellipse; and a corner radius or kind that the corners
  already have.

These commands now return without editing the document, and Align and
Distribute count a move of 1e-9 pt or less on both axes as none. Such a
call records no undo step, keeps the step to redo and leaves the
document unmodified. In these cases Ungroup no longer resets the
selection, and Release Compound Path, Unlock All and Show All no longer
empty it. A Transform with a copy of no object keeps the selection.
Lock and Hide still deselect the objects. A Nudge by 0 no longer
expands a live shape whose anchors are direct-selected. The replies
have the same form as before. An identity transform still sets what
Transform Again repeats. The journal still records each call, so the
Actions panel records it as before.

When every value given to Set Bounds is the current one, it now applies
the identity transform instead of the transform computed from those
values, which can differ from the identity by a rounding error on a
rotated object or with Use Preview Bounds.

When a drag comes back to its start, the identity preview no longer
edits the document. Session::preview now advances the revision when it
undoes the previous preview, so views redraw that drag.

The undo and redo row of docs/mcp.md now notes that many calls that
change nothing record no undo step, so undo after one undoes the step
before it.
2026-10-11 07:02:28 +07:00
BFlat 327737b21a Merge pull request #1067 from imachus/pr/ja-proportional-metrics
Type: Proportional Metrics — full-width glyphs on the font's proportional widths, `palt` (#966)
2026-10-11 07:01:17 +07:00
BFlat a7a4d952ea Merge pull request #1045 from lollinoo/codex/1034-text-scale
Fix font size and horizontal scale after text/group scaling
2026-10-11 07:01:05 +07:00
imachusandClaude Opus 5.5 621e1a6640 Type: Proportional Metrics — full-width glyphs on the font's proportional widths, palt (#966)
A character attribute (Character panel menu with the East Asian options;
`text.setFormat` / `text.setRangeStyle {proportionalMetrics}`), saved only
when on. In horizontal type it applies `palt`; vertical type sets as before
until the vertical shaping change (`vpal`) follows.

Line-end Punctuation Half Width takes nothing more off the punctuation
`palt` re-spaced (the flush opening bracket, consecutive punctuation, the
line-end half width): the segment is shaped once more without `palt`, and
the glyphs whose advance differs are marked. Punctuation the font leaves
full width is trimmed as before. SVG writes `"palt" 1` with the
character's other features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:00:35 +07:00
lollinoo c5274beb23 #1034: scaled type shows and takes its scaled sizes
After scaling 12 pt type 2x, the Character panel still showed 12 pt and
typing 10 pt gave 20 pt type. Character controls and text commands now
work in document points: the size, leading and baseline shift shown and
set are the stored ones times the text's scale along its em height, and
horizontal scale includes the ratio of the transformed em axes. Rotation,
reflection and shear stay in the transform, so text remains editable.
Groups' text counts, keyboard increments use the same units, Shrink
Text to Fit works in document tenths, and a collapsed transform rejects
dimensional edits without touching the document.

text.getRange takes effective: true for the document-space values.
2026-10-11 07:00:34 +07:00
BFlat 611f6855ef Merge pull request #1075 from storytold/fix/1063-char-paint
#1063: a fill or stroke chosen with characters selected colours just them
2026-10-11 07:00:07 +07:00
bflatasticandClaude Opus 5.5 75c587c8a0 #1063: a fill chosen with characters selected colours just them
Choosing a fill or stroke (Color panel, Swatches, the proxies) while the
Type tool had a few characters selected painted every run of the type
object. As in Illustrator, it now goes to the selected characters only:
the runs are split at the selection's ends and the paint is placed on
the runs in between, as one undo step; a stroke gets a 1 pt weight
where it had none. With only a caret, or with explicit ids or an item,
paint.setFill/setStroke behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:59:36 +07:00
skylar taylor-barrick ed14640756 UI.9: integrate shared docking and preserve panel workflows 2026-10-10 14:49:35 -06:00
97 changed files with 5095 additions and 436 deletions

No files matched your search

Generated
+115
View File
@@ -200,6 +200,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.6.1"
@@ -713,6 +719,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -790,6 +806,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -861,6 +886,19 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dify"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1066,6 +1104,27 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"dify",
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"open",
"pollster 1.0.1",
"serde",
"tempfile",
"toml",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -1438,6 +1497,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1940,6 +2008,25 @@ dependencies = [
"generic-array",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2067,6 +2154,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "krilla"
version = "0.8.2"
@@ -2119,6 +2216,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2740,6 +2843,16 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -4744,8 +4857,10 @@ dependencies = [
name = "vectorcraft-ui-egui"
version = "0.9.0"
dependencies = [
"craft-ui",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
"serde",
+1
View File
@@ -11,6 +11,7 @@ rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
+5 -1
View File
@@ -1,5 +1,9 @@
# VectorCraft Roadmap
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
@@ -123,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). |
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
+81 -1
View File
@@ -189,7 +189,42 @@ fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
return None;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
serde_json::from_slice(&bytes).ok()
decode_saved_prefs(&bytes)
}
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
use serde_json::{Map, Value, json};
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
if let Some(layout) = object.get("docking")
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
{
object.remove("docking");
}
if let Some(hidden) = object.get_mut(hidden_key) {
if let Value::Object(entries) = hidden {
entries.retain(|panel, location| {
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
});
} else {
object.remove(hidden_key);
}
}
}
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
}
}
}
serde_json::from_value(value).ok()
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
@@ -649,6 +684,51 @@ mod tests {
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
}
#[test]
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
use serde_json::json;
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
app.ui.toolbar = false;
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
app.ui.custom_workspaces =
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
let original = serde_json::to_value(&app.ui).unwrap();
let valid_location = original["docking_hidden"]["layers"].clone();
assert!(valid_location.is_object());
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
let mut raw = original.clone();
raw["docking"] = invalid_layout.clone();
raw["docking_hidden"] = invalid_hidden.clone();
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
assert!(!loaded.toolbar);
assert!(loaded.docking.is_none());
assert!(!loaded.docking_hidden.contains_key("layers"));
if invalid_hidden.is_object() {
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
} else {
assert!(loaded.docking_hidden.is_empty());
}
assert_eq!(loaded.custom_workspaces.len(), 2);
assert_eq!(loaded.custom_workspaces[0].name, "Future");
assert!(loaded.custom_workspaces[0].docking.is_none());
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
}
}
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
assert!(
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
"unrelated fields retain normal strict parsing"
);
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// Preferences › User Interface › System Title Bar gives the window back its system
+5 -11
View File
@@ -4,21 +4,15 @@
use std::path::PathBuf;
#[path = "profile.rs"]
mod profile;
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
/// BSD).
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
let base = if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join(lower))
};
base.map(|b| b.join("ui.json"))
let legacy = name == "DrawCraft" || lower == "drawcraft";
profile::prefs_path(legacy)
}
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which
+66
View File
@@ -0,0 +1,66 @@
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
use std::ffi::OsString;
use std::path::PathBuf;
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
}
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
}
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
let base = match os {
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
};
base.map(|p| p.join("ui.json"))
}
#[cfg(test)]
mod tests {
use super::*;
fn environment(key: &str) -> Option<OsString> {
match key {
"HOME" => Some("/user".into()),
"APPDATA" => Some("/roaming".into()),
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
_ => None,
}
}
#[test]
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
for os in ["macos", "windows", "linux"] {
let env = |key: &str| {
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
};
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with(os, true, env), None);
}
}
#[test]
fn empty_override_does_not_fall_back_to_user_files() {
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
assert_eq!(prefs_path_with("macos", false, env), None);
assert_eq!(prefs_path_with("macos", true, env), None);
}
#[test]
fn unset_override_keeps_platform_paths_and_legacy_migration() {
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
assert_eq!(
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
Some("/user/.config/drawcraft/ui.json".into())
);
}
}
+3
View File
@@ -17,6 +17,9 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.lollinoo]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
+61
View File
@@ -95,6 +95,11 @@ pub struct CharStyle {
/// their line line up with it.
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
pub char_align: CharAlign,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966). Vertical type sets them as before
/// for now.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
}
/// Where a character smaller than the largest on its line lines up with it: on the Roman
@@ -205,6 +210,7 @@ impl Default for CharStyle {
stroke_dash: None,
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
proportional_metrics: false,
small_caps: None,
}
}
@@ -954,7 +960,49 @@ pub struct TextObject {
pub cached_baselines: Vec<(Point, Point)>,
}
/// Conversion from stored character attributes to document-space type controls.
/// Point size follows the transformed em-height axis; horizontal scale is the ratio of
/// the two transformed em axes. Rotation, reflection and shear remain in the affine.
#[derive(Clone, Copy, Debug)]
pub struct TextStyleScale {
pub points: f64,
pub horizontal: f64,
}
impl TextObject {
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
/// be edited by compensating the stored attributes.
pub fn style_scale(&self) -> Option<TextStyleScale> {
let [a, b, c, d, e, f] = self.xf.as_coeffs();
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
return None;
}
let (sx, sy) = (a.hypot(b), c.hypot(d));
let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy);
let horizontal = sx / sy;
if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 {
return None;
}
// Avoid a pure rotation turning otherwise identical fields into mixed values.
let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v };
Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) })
}
/// Character attributes as presented by controls (stored runs stay local for layout
/// and rich-text clipboard operations). Explicit vertical character scale stays separate.
pub fn effective_char_style(&self, style: &CharStyle) -> Option<CharStyle> {
let scale = self.style_scale()?;
let mut style = style.clone();
style.size *= scale.points;
style.leading = style.leading.map(|v| v * scale.points);
style.baseline_shift *= scale.points;
style.h_scale *= scale.horizontal;
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
return None;
}
Some(style)
}
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
@@ -1190,6 +1238,19 @@ impl TextObject {
mod tests {
use super::*;
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] {
t.xf = Affine::new(matrix);
assert!(t.effective_char_style(&t.first_style()).is_none());
}
t.xf = Affine::rotate(0.83);
assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0);
t.xf = Affine::scale(1e308);
assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size");
}
#[test]
fn point_text_basics() {
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
+5 -3
View File
@@ -323,11 +323,13 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.remove";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let mut indices: Vec<usize> = match (p.get("indices").and_then(Value::as_array), p.get("index").and_then(Value::as_u64)) {
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect(),
(None, Some(i)) => vec![i as usize],
let indices = match (p.get("indices"), p.get("index")) {
(Some(a), _) => point_indices(a),
(None, Some(i)) => point_indices(&json!([i])),
(None, None) => return Err(bad(C, "missing index")),
};
// A malformed index fails the whole delete instead of being dropped while the others go.
let mut indices = indices.ok_or_else(|| bad(C, "no such width point"))?;
indices.sort_unstable();
indices.dedup();
s.edit("Delete Width Point", |d, _| {
+28 -8
View File
@@ -148,9 +148,12 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String>
point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?;
f.points.push(pt);
}
for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() {
let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default();
f.add_line(ix)?;
if let Some(lines) = v.get("lines") {
let lines = lines.as_array().ok_or("`freeform.lines` must be an array")?;
for (i, l) in lines.iter().enumerate() {
let ix = point_indices(l).ok_or_else(|| format!("freeform line {i} must be an array of point indices"))?;
f.add_line(ix).map_err(|e| format!("freeform line {i}: {e}"))?;
}
}
if let Some(m) = str_param(v, "mode") {
f.mode = parse_mode(m)?;
@@ -279,11 +282,7 @@ fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
fn add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.addLine";
let points: Vec<usize> = p
.get("points")
.and_then(Value::as_array)
.and_then(|a| a.iter().map(|v| v.as_u64().map(|i| i as usize)).collect())
.ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
let points = p.get("points").and_then(point_indices).ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?;
Ok(json!({ "line": line }))
}
@@ -323,3 +322,24 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
v["selected"] = json!(selected);
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
/// A line with an index that isn't a point's is refused, not shortened to the indices that are.
#[test]
fn malformed_freeform_lines_are_not_silently_shortened() {
let points = json!([
{"at": [0, 0], "color": "#ff0000"},
{"at": [50, 0], "color": "#00ff00"},
{"at": [100, 0], "color": "#0000ff"}
]);
for lines in [json!([[0, "broken", 2]]), json!([[0, -1, 2]]), json!([[0, 1.5, 2]]), json!([{"points": [0, 1]}]), json!("not an array")] {
let value = json!({"points": points, "lines": lines, "mode": "lines"});
assert!(parse_freeform(&value).is_err(), "invalid lines accepted: {value}");
}
let good = parse_freeform(&json!({"points": points, "lines": [[0, 1, 2]], "mode": "lines"})).unwrap();
assert_eq!(good.lines, vec![vec![0, 1, 2]]);
}
}
+11
View File
@@ -316,6 +316,11 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str
values.iter().map(|value| checked_id(s, value, command)).collect()
}
/// The objects a command acts on: `ids` when given (every one checked), else the selected objects.
pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", command) } else { edit::selected_roots(s) }
}
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
if s.doc()?.doc.node(id).is_none() {
@@ -335,6 +340,12 @@ pub(crate) fn finite_numbers<const N: usize>(v: &Value) -> Option<[f64; N]> {
Some(out)
}
/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no
/// malformed index is dropped or wrapped round.
pub(crate) fn point_indices(v: &Value) -> Option<Vec<usize>> {
v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect()
}
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
let a = p.get(key)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
+179 -23
View File
@@ -251,17 +251,26 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
let areas = bool_or(p, "typeAreas", false);
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
sc.patterns = transform_patterns(s, p, &ids)?;
let ids = s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
for id in &targets {
if let Some(n) = d.node_mut(*id)
&& !(areas && resize_type_area(n, xf))
{
n.transform(xf, sc);
// A transform of ids that name no object, or an identity transform without a copy, moves
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
// With a copy, the reply lists the copies, and there are none.
let d = &s.doc()?.doc;
let none = ids.iter().all(|id| d.node(*id).is_none());
let ids = if none || (!copy && xf == Affine::IDENTITY) {
if copy { vec![] } else { ids }
} else {
s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
for id in &targets {
if let Some(n) = d.node_mut(*id)
&& !(areas && resize_type_area(n, xf))
{
n.transform(xf, sc);
}
}
}
Ok(targets)
})?;
Ok(targets)
})?
};
let st = s.doc_mut()?;
if st.interaction.is_none() {
st.last_transform = Some((xf, copy));
@@ -300,10 +309,19 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
if !s.doc()?.selection.anchors.is_empty() {
// When the anchors move by 0, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 {
return ok();
}
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
}
if s.doc()?.selection.is_empty() {
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) }));
let copy = bool_or(p, "copy", false);
// When guides move by 0 without a copy, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 && !copy {
return ok();
}
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
}
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p)
@@ -386,7 +404,9 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
Arrange::Backward => "Send Backward",
Arrange::Back => "Send to Back",
};
s.edit(label, |d, _| {
// When nothing changes order, the document and its undo history stay as they are.
super::overprint::edit_counted(s, label, |d| {
let mut moved = 0;
// Arrange within each parent independently (selections may span layers/groups).
let mut by_parent: Vec<(Option<vectorcraft_doc::NodeId>, Vec<vectorcraft_doc::NodeId>)> = vec![];
for id in &ids {
@@ -411,10 +431,11 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
};
if to != idx {
d.move_node(*id, par, to)?;
moved += 1;
}
}
}
Ok(())
Ok(moved)
})?;
ok()
}
@@ -424,6 +445,11 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
// Ids are reused after undo: the remembered current layer must still be a layer.
let st = s.doc()?;
let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?;
// When the selected objects are already the topmost in the current layer, in paint order, the
// document and its undo history stay as they are.
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
return ok();
}
s.edit("Send to Current Layer", |d, _| {
for id in &ids {
d.move_node(*id, Some(layer), usize::MAX)?;
@@ -457,6 +483,11 @@ pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no group selected, the document, the selection and the undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) {
return ok();
}
s.edit("Ungroup", |d, sel| {
let mut new_sel = vec![];
for id in &ids {
@@ -492,6 +523,13 @@ fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
fn lock(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With every selected object already locked, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Lock", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -516,6 +554,10 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
// With nothing locked, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Unlock All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -530,6 +572,13 @@ fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
fn hide(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With every selected object already hidden, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Hide", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -544,6 +593,10 @@ fn hide(s: &mut Session, _: &Value) -> Result<Value> {
fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
// With nothing hidden, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Show All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -602,6 +655,12 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no compound path selected, the document, the selection and the undo history stay as
// they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) {
return ok();
}
s.edit("Release Compound Path", |d, sel| {
let mut out = vec![];
for id in &ids {
@@ -678,6 +737,11 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no clip group selected, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) {
return ok();
}
s.edit("Release Clipping Mask", |d, _| {
for id in &ids {
release_clip(d, *id);
@@ -785,6 +849,27 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
),
None => None,
};
// When every object already has every value given, the document and its undo history stay as
// they are.
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let has_values = |n: &Node| {
str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty()))
&& flag("visible").is_none_or(|v| n.visible == v)
&& flag("locked").is_none_or(|v| n.locked == v)
&& opacity.is_none_or(|v| n.opacity == v)
&& blend.is_none_or(|b| n.blend == b)
&& flag("isolate").is_none_or(|v| n.isolate == v)
&& knockout.is_none_or(|k| n.knockout == k)
&& flag("knockoutShape").is_none_or(|v| n.knockout_shape == v)
&& data
.iter()
.flatten()
.all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref())
};
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
return ok();
}
s.edit("Object Properties", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
@@ -864,6 +949,11 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
}
/// Is every move in `moves` a rounding error (1e-9 pt or less on both axes)?
fn moves_nothing(moves: &[(NodeId, Vec2)]) -> bool {
moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
}
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
/// selection.
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
@@ -886,13 +976,22 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
}
}
/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the
/// compound path or selected group the key is in. Align to the key object and Distribute Spacing
/// with a spacing keep it in place.
fn key_root(s: &Session, ids: &[NodeId]) -> Option<NodeId> {
let st = s.doc().ok()?;
let ancestry = st.doc.ancestry(st.selection.key?)?;
ids.iter().copied().find(|id| ancestry.contains(id))
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.align")?;
let r = reference_rect(s, p, &ids, preview)?;
let h = str_param(p, "horizontal");
let v = str_param(p, "vertical");
let key = s.doc()?.selection.key;
let key = key_root(s, &ids);
let moves: Vec<(NodeId, Vec2)> = {
let d = &s.doc()?.doc;
items_bounds(d, &ids, preview)
@@ -915,6 +1014,10 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect()
};
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
}
s.edit("Align", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
@@ -965,6 +1068,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
.collect();
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
}
s.edit("Distribute", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
@@ -1002,12 +1109,16 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
deltas.push((*id, pos - start(r)));
pos += size(r) + gap;
}
// With a spacing (aligning to a key object) the key object stays where it is: the others
// are spaced from it.
let key = s.doc()?.selection.key;
// With a spacing (aligning to a key object) the key object stays where it is, with the
// compound path or selected group it is in: the others are spaced from it.
let key = key_root(s, &ids);
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
}
s.edit("Distribute Spacing", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
@@ -1057,12 +1168,23 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
// x, y place the reference point on the page.
let page = frame.to_doc() * rp;
// When every value given is the current one, the transform is the identity. Computing it can
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p);
}
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
}
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no live shape selected, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) {
return ok();
}
s.edit("Expand Shape", |d, _| {
for id in &ids {
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
@@ -1138,6 +1260,45 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
// An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole
// ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
let pie_of = |pie: (f64, f64)| {
let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie }
};
// When every value given is the current one, the document and its undo history stay as they
// are.
let st = s.doc()?;
let has_values = |id: &NodeId| {
let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true };
match live {
Some(l @ LiveShape::Polygon { sides: now, .. })
if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now))
&& (!equal || l.polygon_sides_equal())
&& polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a))
&& side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) =>
{
return false;
}
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
_ => {}
}
if radius.is_none() && kind.is_none() {
return true;
}
let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true };
let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id)));
// Setting corners folds a scale left in a rectangle's transform into its size, which
// changes it.
live.as_ref().is_none_or(|l| l.folded() == *l)
&& picked
.iter()
.all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd))
};
if ids.iter().all(has_values) {
return ok();
}
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -1163,12 +1324,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
&& (pie_start.is_some() || pie_end.is_some() || invert)
{
if let LiveShape::Ellipse { pie, .. } = l {
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert {
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
}
*pie = pie_of(*pie);
}
*path = l.to_path();
}
+49 -1
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -375,10 +375,41 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
/// remember the paint.
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
if p.get("ids").is_none()
&& p.get("item").is_none()
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
&& a != b
{
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let (xf, lb) = (t.xf, t.local_bounds());
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
if fill {
st.fill = place_run_paint(&paint, p, xf, lb);
} else {
if !paint.is_none() {
run_stroke_weight(st);
}
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
}
});
super::typecmd::refresh_bounds(t);
Ok(())
})?;
if bool_or(p, "focus", true) {
s.fill_active = fill;
}
s.remember_paint(&paint);
return ok();
}
// Resolve the requested targets before mutating defaults or the active proxy.
let item = item_target(s, p, cmd)?.of_kind(s, fill);
let selected = item.targets(s, p)?;
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
// The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill
// recolours just that point of its mesh.
let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None };
let label = if fill { "Fill Color" } else { "Stroke Color" };
edit_items_then(
s,
@@ -388,6 +419,15 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
label,
fill,
|n, index| {
if index.is_none()
&& let Paint::Solid { color, .. } = &paint
&& let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id)
&& let NodeKind::Mesh(m) = &mut n.kind
&& let Some(q) = m.points.get_mut(i)
{
q.color = *color;
return Ok(());
}
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
{
@@ -429,6 +469,14 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
ok()
}
/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct
/// Selection's point last clicked).
fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {
let o = s.tool_options();
let pt = o.get("meshPoint")?;
Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?))
}
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
if !paint.is_none() {
+12 -13
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -185,14 +185,7 @@ const MAX_PARA: f64 = 1296.0;
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids: Vec<NodeId> = {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
};
if ids.is_empty() {
return Err(bad(C, "no text objects selected"));
}
let ids = super::typecmd::text_targets(s, p, C)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let kerning = match p.get("kerning") {
@@ -222,6 +215,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"direction",
"leadingModel",
"charAlign",
"proportionalMetrics",
"burasagari",
"composer",
];
@@ -285,15 +279,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?;
let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?;
super::typecmd::style_chars(t, range, |st| {
if let Some(k) = kerning {
st.kerning = k;
}
if let Some(v) = num("baselineShift") {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
if let Some(v) = baseline_shift {
st.baseline_shift = v;
}
if let Some(v) = num("hScale") {
st.h_scale = v.clamp(1.0, 10000.0);
if let Some(v) = h_scale {
st.h_scale = v;
}
if let Some(v) = num("vScale") {
st.v_scale = v.clamp(1.0, 10000.0);
@@ -319,6 +315,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = char_align {
st.char_align = v;
}
if let Some(v) = flag("proportionalMetrics") {
st.proportional_metrics = v;
}
});
let span = super::typecmd::para_span(range, t);
t.edit_paras(span, |para| {
+1 -1
View File
@@ -500,7 +500,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?;
let expand = supplied.is_none() && st.selection.objects.len() < 2;
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
let eligible: Vec<NodeId> = ids
+51 -16
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Get Text Range",
[],
None,
"{id, start?, end?} → {text, runs: [{text, style}], length}",
"{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
has_doc,
get_range
),
@@ -174,7 +174,12 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len);
let runs = edit::slice_runs(&t.runs, a, b);
let mut runs = edit::slice_runs(&t.runs, a, b);
if p.get("effective").and_then(Value::as_bool).unwrap_or(false) {
for run in &mut runs {
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
}
}
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
Ok(json!({"text": text, "runs": runs, "length": len}))
}
@@ -203,6 +208,7 @@ pub(crate) struct CharChange {
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
char_align: Option<vectorcraft_doc::CharAlign>,
proportional_metrics: Option<bool>,
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
@@ -286,6 +292,7 @@ impl CharChange {
position,
small_caps,
char_align: char_align_param(p, cmd)?,
proportional_metrics: flag("proportionalMetrics"),
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@@ -314,6 +321,16 @@ impl CharChange {
&& self.position.is_none()
&& self.small_caps.is_none()
&& self.char_align.is_none()
&& self.proportional_metrics.is_none()
}
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
use super::typecmd::local_type_value;
self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?;
self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?;
self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?;
Ok(())
}
pub(crate) fn apply(&self, st: &mut CharStyle) {
@@ -338,10 +355,10 @@ impl CharChange {
st.font_version = None;
}
if let Some(v) = self.size {
st.size = v.clamp(0.1, 1296.0);
st.size = v;
}
if let Some(v) = self.leading {
st.leading = v.map(|l| l.clamp(0.1, 5000.0));
st.leading = v;
}
if let Some(v) = self.tracking {
st.tracking = v.clamp(-1000.0, 10000.0);
@@ -350,10 +367,10 @@ impl CharChange {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
if let Some(v) = self.baseline_shift {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
st.baseline_shift = v;
}
if let Some(v) = self.h_scale {
st.h_scale = v.clamp(1.0, 10000.0);
st.h_scale = v;
}
if let Some(v) = self.v_scale {
st.v_scale = v.clamp(1.0, 10000.0);
@@ -391,6 +408,9 @@ impl CharChange {
if let Some(v) = self.char_align {
st.char_align = v;
}
if let Some(v) = self.proportional_metrics {
st.proportional_metrics = v;
}
}
}
@@ -445,12 +465,13 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
},
};
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
let t = text_ref(s, id)?;
change.localize(&t, C)?;
let (a, b) = range_of(p, edit::runs_len(&t.runs));
let n = s.edit("Character", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
@@ -542,7 +563,7 @@ enum Step {
}
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
pub(crate) fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
let o = s.tool_options();
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
if super::edit::typing_in_progress(s) {
@@ -605,13 +626,27 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for &(id, a, b) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
edit::style_range(&mut t.runs, a, b, |st| match step {
Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0),
Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)),
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0),
});
let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) {
t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points
} else {
1.0
};
let mut runs = edit::slice_runs(&t.runs, a, b);
for run in &mut runs {
let st = &mut run.style;
match step {
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
Step::Leading => {
st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::BaselineShift => {
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
}
}
}
edit::replace_range_styled(&mut t.runs, a, b, &runs);
refresh_bounds(t);
}
Ok(())
+32 -11
View File
@@ -8,7 +8,6 @@ use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape};
use vectorcraft_geom::{PathData, shapes};
use super::edit::selected_roots;
use super::typecmd::refresh_bounds;
use super::*;
@@ -36,19 +35,12 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match p.get("ids").and_then(Value::as_array) {
Some(a) => Ok(a.iter().filter_map(Value::as_u64).map(NodeId).collect()),
None => selected_roots(s),
}
}
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
}
fn make(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?;
let w = wrap_param(p, TextWrap::default());
s.edit("Make Text Wrap", |d, _| {
for id in &ids {
@@ -66,7 +58,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = {
let d = &s.doc()?.doc;
targets(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
};
if ids.is_empty() {
return Err(EngineError::Other("Text Wrap: select a wrap object".into()));
@@ -83,7 +75,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?;
let current = {
let d = &s.doc()?.doc;
ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default()
@@ -212,6 +204,35 @@ mod tests {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.iter().map(|l| l.avail).collect()
}
/// Invalid explicit ids must not turn a requested multi-object edit into a partial one.
#[test]
fn text_wrap_commands_reject_malformed_target_lists_without_editing() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
let other = s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let before = s.doc().unwrap().history.undo.len();
for command in ["object.textWrap.make", "object.textWrap.release", "object.textWrap.options"] {
for ids in [json!([id, "invalid"]), json!([id, 99999]), json!({"not": "an array"})] {
let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string();
assert!(e.contains("id") || e.contains("array"), "{command}: {e}");
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error");
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
}
}
s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some());
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0);
s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
}
#[test]
fn area_type_wraps_around_objects_above_and_follows_edits() {
let mut s = Session::new();
+48 -2
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
has_doc,
set_style
),
@@ -289,6 +289,22 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
Ok(t)
}
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
/// the local result would make small sizes impossible after enlarging an object.
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
if !value.is_finite() {
return Err(bad(cmd, "type dimension must be finite"));
}
let scale = t.style_scale().ok_or_else(|| bad(cmd, "text transform is collapsed or unrepresentable"))?;
let local = value.clamp(limits.0, limits.1) / if horizontal { scale.horizontal } else { scale.points };
// Bound local dimensions too: an extreme inverse scale can produce a finite size
// whose leading, glyph advances or layout bounds overflow before the edit's geometry check.
if !local.is_finite() || local.abs() > crate::MAX_COORD || (value != 0.0 && local == 0.0) {
return Err(bad(cmd, "type dimension cannot be represented with this text transform"));
}
Ok(local)
}
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
/// paragraphs it touches. Without either, the command applies to all the text.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -415,6 +431,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
let leading = leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, C)).transpose()).transpose()?;
let before = protect.then(|| t.runs.clone());
style_chars(t, range, |st| {
if let Some(f) = &font {
@@ -428,7 +446,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
st.font_version = None;
}
if let Some(v) = size {
st.size = v.clamp(0.1, 1296.0);
st.size = v;
}
if let Some(l) = leading {
st.leading = l;
@@ -841,6 +859,34 @@ mod area_tests {
}
}
/// Proportional Metrics (#966): a character attribute, saved only when on.
#[test]
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
assert!(!runs(&s)[0].style.proportional_metrics);
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.proportional_metrics));
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!runs(&s)[0].style.proportional_metrics);
// Characters selected with the Type tool: only the range takes it (雅 is 3 bytes).
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap();
let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect();
assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]);
// Styles written without it (documents from before it) read as off.
let old = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert!(old.get("proportionalMetrics").is_none());
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
}
#[test]
fn new_type_is_composed_with_line_end_half_width_punctuation() {
use vectorcraft_doc::Mojikumi;
+2 -7
View File
@@ -49,11 +49,6 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
}
}
/// The objects a bind acts on: `ids` when given (every one checked), else the selection.
fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) }
}
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
/// layer is not the document's to rewrite, and a text variable only drives type.
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
@@ -203,7 +198,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.bind";
let name = named(str_param(p, "variable"), "variable", C)?;
let ids = target_ids(s, p, C)?;
let ids = checked_ids_or_selection(s, p, C)?;
if ids.is_empty() {
return Err(bad(C, "nothing to bind"));
}
@@ -218,7 +213,7 @@ fn bind(s: &mut Session, p: &Value) -> Result<Value> {
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.unbind";
let ids = target_ids(s, p, C)?;
let ids = checked_ids_or_selection(s, p, C)?;
let name = defined_variable(s, p, C)?;
let n = s.edit("Unbind Variable", |d, _| {
let mut n = 0;
+30 -3
View File
@@ -55,12 +55,16 @@ fn list_views(s: &mut Session, _: &Value) -> Result<Value> {
fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.saved.new";
let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
let center = center.ok_or_else(|| bad(C, "missing center [x, y]"))?;
let [x, y] = p.get("center").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "center must be [x, y]"))?;
let zoom = f64_req(p, "zoom", C)?;
if !(zoom.is_finite() && zoom > 0.0) {
return Err(bad(C, "zoom must be positive"));
}
// Given, the rotation is a number: nothing malformed falls back to 0°.
let rotation = match p.get("rotation") {
Some(r) => r.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(C, "rotation must be a number of degrees"))?,
None => 0.0,
};
let st = s.doc()?;
if st.doc.views.len() >= MAX_VIEWS {
return Err(bad(C, format!("a document keeps at most {MAX_VIEWS} views")));
@@ -72,7 +76,7 @@ fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
if st.doc.views.iter().any(|v| v.name == name) {
return Err(bad(C, format!("a view named `{name}` exists")));
}
let view = SavedView { name: name.clone(), center, zoom, rotation: f64_or(p, "rotation", 0.0) };
let view = SavedView { name: name.clone(), center: Point::new(x, y), zoom, rotation };
let index = s.edit("New View", |d, _| {
d.views.push(view);
Ok(d.views.len() - 1)
@@ -108,6 +112,29 @@ mod tests {
use crate::Session;
/// Malformed dimensions are rejected rather than saving an arbitrary or partial viewport.
#[test]
fn saved_views_require_exact_finite_coordinates_and_rotation() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let before = s.doc().unwrap().history.undo.len();
for params in [
json!({"center": [10], "zoom": 2}),
json!({"center": [10, 20, 30], "zoom": 2}),
json!({"center": [10, "bad"], "zoom": 2}),
json!({"center": [10, 20], "zoom": 2, "rotation": "bad"}),
json!({"center": [10, 20], "zoom": 2, "rotation": null}),
] {
assert!(s.execute("view.saved.new", &params).is_err(), "accepted {params}");
assert!(s.doc().unwrap().doc.views.is_empty());
assert_eq!(s.doc().unwrap().history.undo.len(), before);
}
s.execute("view.saved.new", &json!({"center": [10, 20], "zoom": 2, "rotation": -15})).unwrap();
let views = &s.doc().unwrap().doc.views;
assert_eq!(views.len(), 1);
assert_eq!(views[0].rotation, -15.0);
}
#[test]
fn save_rename_delete_views() {
let mut s = Session::new();
+7
View File
@@ -1324,6 +1324,11 @@ impl Session {
{
let st = self.doc_mut()?;
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
// Undoing the previous preview counts as a change, so views redraw even when the command
// below returns without an edit.
if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection {
st.revision += 1;
}
st.doc = it.doc.clone();
st.selection = it.selection.clone();
}
@@ -1686,6 +1691,8 @@ mod tests_transparencygrid;
#[cfg(test)]
mod tests_typearea;
#[cfg(test)]
mod tests_typescale;
#[cfg(test)]
mod tests_units;
#[cfg(test)]
mod tests_variables;
+481
View File
@@ -52,6 +52,67 @@ fn group_ungroup() {
assert_eq!(s.doc().unwrap().selection.len(), 2);
}
/// Makes 10 pt squares at (0, 90) and (50, 100) into a compound path or group with the commands in
/// `make`, each run on the selection (the last one makes `outer`), draws a 10 pt square `r` at
/// (200, 0), selects the second square and `r`, and sets the selection's `key` to that square
/// → (session, key, outer, r).
fn key_inside(make: &[&str]) -> (Session, NodeId, NodeId, NodeId) {
let mut s = session();
let a = rect(&mut s, 0.0, 90.0, 10.0, 10.0);
let key = rect(&mut s, 50.0, 100.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, key.0]})).unwrap();
let mut outer = key;
for cmd in make {
outer = NodeId(s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap());
}
let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [key.0, r.0]})).unwrap();
s.select(|_, sel| sel.key = Some(key)).unwrap();
(s, key, outer, r)
}
/// Aligns the selection left to its key object and, after Undo, spaces it 5 pt apart vertically
/// → the top-left corners of `outer` and `r` after each.
fn aligned_then_spaced(s: &mut Session, outer: NodeId, r: NodeId) -> [((f64, f64), (f64, f64)); 2] {
let at = |s: &Session, id| {
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
(b.x0, b.y0)
};
s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap();
let aligned = (at(s, outer), at(s, r));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 5, "bounds": "geometric"})).unwrap();
[aligned, (at(s, outer), at(s, r))]
}
/// Align and Distribute Spacing move a compound path as one object. With one of its members as the
/// key object, the compound path stays where it is: Align moves the others to the member's edge,
/// and Distribute Spacing spaces them from the compound path.
#[test]
fn a_compound_path_that_contains_the_key_object_stays_in_place() {
let (mut s, key, outer, r) = key_inside(&["object.compoundPath.make"]);
assert_eq!(s.doc().unwrap().selection.key, Some(key));
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "it stays, the other moved to the key");
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "5 pt apart, it stays");
}
/// The key object stays the key when the group that contains it is added to the selection, by
/// `select.add` or by the Group Selection tool's click on a selected member. Align and Distribute
/// Spacing move that group as one object, and the group stays where it is, also when the key is in
/// a group inside it.
#[test]
fn a_selected_group_that_contains_the_key_object_stays_in_place() {
for make in [&["object.group"][..], &["object.group", "object.group"]] {
let (mut s, key, outer, r) = key_inside(make);
s.execute("select.add", &json!({"ids": [outer.0]})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, Some(key), "{make:?}");
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "{make:?}: it stays, the other moved to the key");
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "{make:?}: 5 pt apart, it stays");
}
}
#[test]
fn arrange_order() {
let mut s = session();
@@ -330,6 +391,426 @@ fn selection_tool_drag_is_one_undo_step() {
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0);
}
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
fn history(s: &Session) -> (usize, usize, bool) {
let st = s.doc().unwrap();
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
}
/// Select `ids` with a move of them left to redo, and mark the document saved.
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.doc_mut().unwrap().mark_saved();
}
/// An Align that moves nothing (one object on the artboard's left edge, a pair already aligned, a
/// pair that an earlier Align left a rounding error apart) records no undo step: the step to redo
/// stays, the document stays unmodified, and the journal, which the Actions panel records from,
/// still has the command.
#[test]
fn an_align_that_moves_nothing_records_no_undo_step() {
let mut s = session();
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let left = json!({"horizontal": "left"});
let a = rect(&mut s, 0.0, 50.0, 40.0, 40.0);
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
// Object › Align sends no `to`, and the Align panel aligns one object to the artboard.
for p in [left.clone(), json!({"horizontal": "left", "to": "artboard"})] {
s.execute("object.align", &p).unwrap();
assert_eq!(history(&s), before, "one object and no key: {p}");
assert_eq!(s.journal.last().unwrap(), &("object.align".to_string(), p));
}
let b = rect(&mut s, 0.0, 200.0, 80.0, 20.0);
select_with_a_step_to_redo(&mut s, &[a, b]);
let before = history(&s);
s.execute("object.align", &left).unwrap();
assert_eq!(history(&s), before, "a pair already aligned");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b)), (5.0, 5.0), "Redo brings back the move");
// An Align that moves something is one undo step, and it clears Redo.
let c = rect(&mut s, 120.0, 300.0, 20.0, 20.0);
select_with_a_step_to_redo(&mut s, &[a, c]);
let undo = history(&s).0;
s.execute("object.align", &left).unwrap();
assert_eq!(history(&s), (undo + 1, 0, true));
assert_eq!(x0(&s, c), 5.0);
// Aligning a rectangle at 51.8 to one at 10.7 leaves it a rounding error off 10.7, and a
// second Align records no step.
let d = rect(&mut s, 10.7, 400.0, 20.0, 20.0);
let e = rect(&mut s, 51.8, 450.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [d.0, e.0]})).unwrap();
s.execute("object.align", &left).unwrap();
assert!(x0(&s, e) != 10.7 && (x0(&s, e) - 10.7).abs() < 1e-12, "{}", x0(&s, e));
s.doc_mut().unwrap().mark_saved();
let before = history(&s);
s.execute("object.align", &left).unwrap();
assert_eq!(history(&s), before, "a pair the first Align aligned");
}
/// Distribute and Distribute Spacing over objects already evenly spaced move nothing and record no
/// undo step either.
#[test]
fn a_distribute_that_moves_nothing_records_no_undo_step() {
let mut s = session();
// The rectangles are 20 pt wide and 30 pt apart.
let ids = [0.0, 50.0, 100.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
select_with_a_step_to_redo(&mut s, &ids);
let before = history(&s);
let calls = [
("object.distribute", json!({"horizontal": "left"})),
("object.distribute", json!({"horizontal": "center"})),
("object.distribute", json!({"horizontal": "right"})),
("object.distributeSpacing", json!({"axis": "horizontal"})),
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 30})),
];
for (id, p) in calls {
s.execute(id, &p).unwrap();
assert_eq!(history(&s), before, "{id} {p}");
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
}
// Distribute Spacing keeps the key object still and spaces the others from it.
s.execute("select.key", &json!({"id": ids[1].0})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, Some(ids[1]));
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 30})).unwrap();
assert_eq!(history(&s), before, "spaced 30 pt from the key object");
// After the middle one moves 10 pt right, Distribute moves it back in one undo step.
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
let undo = history(&s).0;
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
assert_eq!(history(&s).0, undo + 1);
assert_eq!(s.doc().unwrap().doc.node(ids[1]).unwrap().geometric_bounds().unwrap().x0, 50.0);
// For rectangles 30.1 pt apart, the gap Distribute Spacing computes moves the third one by a
// rounding error.
let ids = [0.1, 50.2, 100.3].map(|x| rect(&mut s, x, 100.0, 20.0, 20.0));
select_with_a_step_to_redo(&mut s, &ids);
let before = history(&s);
s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap();
assert_eq!(history(&s), before, "30.1 pt apart");
// Distributing these three leaves the middle one a rounding error off halfway between the
// others, and a second Distribute records no step.
let ids = [40.045, 433.482, 1935.587].map(|x| rect(&mut s, x, 200.0, 20.0, 20.0));
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = history(&s);
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
assert_eq!(history(&s), before, "three objects the first Distribute spaced");
}
/// Move by 0 pt, Rotate and Shear by 0°, Scale to 100%, an identity Transform, Set Bounds with the
/// current position and size, a Nudge of 0 (of objects, anchors or ruler guides), a Transform with
/// no object to transform and a Selection tool drag that ends where it started record no undo
/// step. Transform Again then repeats the identity, and a Nudge of 0 of a live rectangle's anchor
/// leaves the rectangle a live shape.
#[test]
fn a_transform_that_moves_nothing_records_no_undo_step() {
let mut s = session();
// The Nudges of a guide and of anchors below use this ruler guide and this plain path.
s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap();
let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 300}, {"x": 400, "y": 300}]})).unwrap();
let path = NodeId(made["id"].as_u64().unwrap());
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
let calls = [
("object.move", json!({"dx": 0, "dy": 0})),
("object.rotate", json!({"angle": 0})),
("object.scale", json!({"sx": 100})),
("object.shear", json!({"angle": 0})),
("object.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})),
("object.nudge", json!({"dx": 0, "dy": 0})),
("object.setBounds", json!({})),
("object.transformAgain", json!({})),
];
for (id, p) in calls {
assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}");
assert_eq!(history(&s), before, "{id} {p}");
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
}
assert_eq!(s.doc().unwrap().last_transform, Some((Affine::IDENTITY, false)));
// A Nudge of 0 with an anchor of the rectangle direct-selected keeps it a live shape.
let is_live = |s: &Session| matches!(s.doc().unwrap().doc.node(a).unwrap().kind, NodeKind::Path { live: Some(_), .. });
assert!(is_live(&s), "a rectangle is a live shape");
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
s.execute("object.nudge", &json!({"dx": 0, "dy": 0})).unwrap();
assert!(is_live(&s), "a Nudge of 0 keeps it a live shape");
assert_eq!(history(&s), before, "an anchor of the live rectangle, then object.nudge");
// A Nudge of 0 with anchors direct-selected or with only a ruler guide selected, and a
// Transform with nothing selected or with ids that name no object, with or without a copy,
// record no undo step either.
let calls = [
("select.anchors", json!({"id": path.0, "anchors": [[0, 0]]}), "object.nudge", json!({"dx": 0, "dy": 0})),
("guide.select", json!({"indexes": [0]}), "object.nudge", json!({"dx": 0, "dy": 0})),
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})),
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})),
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999]})),
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true})),
];
for (select, sp, id, p) in calls {
s.execute(select, &sp).unwrap();
s.execute(id, &p).unwrap();
assert_eq!(history(&s), before, "{select} {sp}, then {id} {p}");
}
// A copy of no object lists no copies and keeps the selection.
let p = json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true});
assert_eq!(s.execute("object.transform", &p).unwrap(), json!({"ids": []}));
assert_eq!(s.doc().unwrap().selection.objects, [a]);
// A Selection tool drag that ends where it started records no undo step either. Back at the
// start, the revision advances, so views redraw the object where it was.
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let x0 = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0;
let revision = |s: &Session| s.doc().unwrap().revision;
s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 35.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 35.0), v).unwrap();
assert_ne!(x0(&s), 10.0, "the preview moves it");
let moved = revision(&s);
s.pointer(&PointerEvent::new(PointerKind::Drag, 30.0, 35.0), v).unwrap();
assert_eq!(x0(&s), 10.0);
assert!(revision(&s) > moved, "views redraw it back at the start");
s.pointer(&PointerEvent::new(PointerKind::Up, 30.0, 35.0), v).unwrap();
assert_eq!(history(&s), before, "a drag back to the start");
assert_eq!(s.journal.last().unwrap().0, "object.transform");
// With a copy, a Move by 0 pt duplicates the object in one undo step.
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
let n = count(&s);
s.execute("object.move", &json!({"dx": 0, "dy": 0, "copy": true})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
assert_eq!(count(&s), n + 1);
}
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
/// Current Layer on objects already on top of the current layer.
#[test]
fn an_arrange_that_changes_no_order_records_no_undo_step() {
let mut s = session();
let ids = [0.0, 30.0, 60.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
let calls = [
(vec![ids[2]], "object.arrange.bringToFront"),
(vec![ids[2]], "object.arrange.bringForward"),
(vec![ids[0]], "object.arrange.sendToBack"),
(vec![ids[0]], "object.arrange.sendBackward"),
(vec![ids[2]], "object.arrange.sendToCurrentLayer"),
(vec![ids[1], ids[2]], "object.arrange.bringToFront"),
(vec![ids[1], ids[2]], "object.arrange.bringForward"),
(vec![ids[0], ids[1]], "object.arrange.sendToBack"),
(vec![ids[0], ids[1]], "object.arrange.sendBackward"),
(vec![ids[1], ids[2]], "object.arrange.sendToCurrentLayer"),
];
for (sel, c) in calls {
select_with_a_step_to_redo(&mut s, &sel);
let before = history(&s);
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), before, "{c} {sel:?}");
assert_eq!(order(&s), ids, "{c} {sel:?}");
}
// Bring to Front on the back object is one undo step, and it clears Redo.
select_with_a_step_to_redo(&mut s, &[ids[0]]);
let undo = history(&s).0;
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
assert_eq!(history(&s), (undo + 1, 0, true));
assert_eq!(order(&s), [ids[1], ids[2], ids[0]]);
}
/// Ungroup, Release Compound Path and Release Clipping Mask with none of their objects selected,
/// and Expand Shape with no live shape selected, record no undo step and keep the selection and its
/// key object.
#[test]
fn ungroup_release_and_expand_shape_with_nothing_to_act_on_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
// Expand Shape makes the two rectangles plain paths in one undo step.
let undo = history(&s).0;
s.execute("object.expandShape", &json!({})).unwrap();
assert_eq!(history(&s).0, undo + 1);
select_with_a_step_to_redo(&mut s, &[a, b]);
s.execute("select.key", &json!({"id": a.0})).unwrap();
let (before, selection) = (history(&s), s.doc().unwrap().selection.clone());
assert_eq!(selection.key, Some(a));
for c in ["object.ungroup", "object.compoundPath.release", "object.clippingMask.release", "object.expandShape"] {
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), before, "{c}");
assert_eq!(s.doc().unwrap().selection, selection, "{c}");
assert_eq!(s.journal.last().unwrap().0, c);
}
// Ungroup with a group selected is one undo step.
s.execute("object.group", &json!({})).unwrap();
let undo = history(&s).0;
s.execute("object.ungroup", &json!({})).unwrap();
assert_eq!(history(&s).0, undo + 1);
}
/// Unlock All with nothing locked and Show All with nothing hidden record no undo step and keep the
/// selection. Lock with every selected object already locked and Hide with every one already
/// hidden record no undo step and deselect them.
#[test]
fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
for c in ["object.unlockAll", "object.showAll"] {
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}");
assert_eq!(history(&s), before, "{c}");
assert_eq!(s.doc().unwrap().selection.objects, [a], "{c}");
}
// With the object locked or hidden, each is one undo step that selects it.
for (off, on) in [("object.lock", "object.unlockAll"), ("object.hide", "object.showAll")] {
s.execute(off, &json!({})).unwrap();
let undo = history(&s).0;
assert_eq!(s.execute(on, &json!({})).unwrap(), json!({"count": 1}), "{on}");
assert_eq!(history(&s).0, undo + 1, "{on}");
assert_eq!(s.doc().unwrap().selection.objects, [a], "{on}");
}
// Object Properties locks (then hides) a new rectangle `b`, and `select.set` selects it.
// Lock (then Hide) deselects it and records no undo step. With `a` selected too, it is one
// undo step.
for (c, props) in [("object.lock", json!({"locked": true})), ("object.hide", json!({"visible": false}))] {
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
s.execute("object.setProps", &props).unwrap();
select_with_a_step_to_redo(&mut s, &[a]);
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let before = history(&s);
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), before, "{c}");
assert!(s.doc().unwrap().selection.objects.is_empty(), "{c}");
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true), "{c} with a");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// Set Bounds with the current position and size records no undo step on a rotated object, with
/// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these
/// cases computing the transform can leave a rounding error in it.
#[test]
fn set_bounds_with_the_current_position_and_size_records_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
let c = rect(&mut s, 0.1, 0.05, 10.1, 6.06);
s.execute("stroke.set", &json!({"weight": 0.3})).unwrap();
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
for (id, preview) in [(a, false), (a, true), (c, true)] {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": preview})).unwrap();
// The Transform panel's values: X and Y place the reference point, W and H are the sides.
let b = s.transform_box(&[id]).unwrap();
let (rp, w, h) = (b.reference_point(4), b.rect.width(), b.rect.height());
for p in [json!({}), json!({"x": rp.x, "y": rp.y, "width": w, "height": h}), json!({"width": w, "proportional": true})] {
assert_eq!(s.execute("object.setBounds", &p).unwrap(), json!({"ids": [id.0]}), "{p}");
assert_eq!(history(&s), before, "{id:?}, Use Preview Bounds {preview}: {p}");
assert_eq!(s.journal.last().unwrap().0, "object.setBounds");
}
}
// A new width is one undo step, and it clears Redo.
s.execute("object.setBounds", &json!({"width": 50})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// Object Properties with the values the objects already have records no undo step: every
/// property, data already set or already absent, and no object. A value that differs on one of
/// the objects is one undo step, and an id that names no object is still an error.
#[test]
fn object_properties_already_set_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
let set = json!({"ids": [a.0], "name": "A", "opacity": 50, "blend": "Multiply", "knockout": "on", "data": {"pivot": "1,2"}});
s.execute("object.setProps", &set).unwrap();
select_with_a_step_to_redo(&mut s, &[a, b]);
let before = history(&s);
let defaults = json!({
"id": b.0, "name": "", "visible": true, "locked": false, "opacity": 100, "blend": "Normal", "isolate": false,
"knockout": "neutral", "knockoutShape": false, "data": {"pivot": null}
});
let calls =
[set, defaults, json!({"id": a.0, "data": {"data-pivot": "1,2", "other": null}}), json!({"visible": true}), json!({}), json!({"ids": []})];
for p in calls {
s.execute("object.setProps", &p).unwrap();
assert_eq!(history(&s), before, "{p}");
assert_eq!(s.journal.last().unwrap(), &("object.setProps".to_string(), p));
}
// `a` already has 50% opacity, and `b` does not.
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err());
}
/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle,
/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the
/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that
/// differs is one undo step. Setting the radius of a rectangle that kept a scale in its transform
/// is one undo step too, because it folds the scale into the rectangle's size.
#[test]
fn live_shape_properties_already_set_record_no_undo_step() {
let mut s = session();
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
let r = rect(&mut s, 0.0, 0.0, 100.0, 60.0);
let polygon = id(s.execute("shape.polygon", &json!({"cx": 300, "cy": 100, "radius": 50, "sides": 6})).unwrap());
let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap());
let star = id(s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 60, "radius2": 30})).unwrap());
// The rectangle's corners get 8 pt radii, its top-right corner gets 12 pt, and the polygon gets
// 30 pt sides.
for p in [json!({"id": r.0, "radius": 8}), json!({"id": r.0, "corners": [1], "radius": 12}), json!({"id": polygon.0, "sideLength": 30})] {
s.execute("object.setLiveShape", &p).unwrap();
}
let NodeKind::Path { live: Some(l), .. } = &s.doc().unwrap().doc.node(polygon).unwrap().kind else { panic!("not a live polygon") };
let (angle, radius) = (l.polygon_angle().unwrap(), l.polygon_radius().unwrap());
select_with_a_step_to_redo(&mut s, &[r, polygon, ellipse, star]);
let before = history(&s);
let calls = [
json!({"id": polygon.0, "sides": 6, "polygonAngle": angle, "polygonRadius": radius, "makeSidesEqual": true}),
json!({"id": polygon.0, "sideLength": 30, "kind": "round"}),
json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 360}),
json!({"id": ellipse.0, "invertPie": true}),
json!({"id": r.0, "corners": [0, 2, 3], "radius": 8, "kind": "round"}),
json!({"items": [{"id": r.0, "corners": [1]}], "radius": 12}),
json!({"id": star.0, "radius": 0}),
];
for p in calls {
s.execute("object.setLiveShape", &p).unwrap();
assert_eq!(history(&s), before, "{p}");
assert_eq!(s.journal.last().unwrap(), &("object.setLiveShape".to_string(), p));
}
// With an anchor of the top-left corner direct-selected, the radius applies to that corner.
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap();
s.execute("object.setLiveShape", &json!({"radius": 8})).unwrap();
assert_eq!(history(&s), before, "the Direct-Selected corner");
for p in [json!({"id": polygon.0, "sides": 7}), json!({"id": ellipse.0, "pieEnd": 90}), json!({"radius": 9})] {
let undo = history(&s).0;
s.execute("object.setLiveShape", &p).unwrap();
assert_eq!(history(&s).0, undo + 1, "{p}");
}
// The rectangle is now 50 × 60 pt with 10 pt corners, stretched to 100 × 60 pt by its
// transform, as files from before #291 saved it. Setting the radius its corners show makes the
// corners circles.
{
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") };
let xf = Affine::scale_non_uniform(2.0, 1.0);
*live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf };
*path = live.to_path();
}
let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0);
let undo = history(&s).0;
s.execute("object.setLiveShape", &json!({"id": r.0, "radius": shown})).unwrap();
assert_eq!(history(&s).0, undo + 1, "a stretched rectangle");
}
#[test]
fn rectangle_tool_draws() {
let mut s = session();
+34
View File
@@ -3,8 +3,10 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::Node;
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
use crate::tooling::ViewInfo;
fn session() -> Session {
let mut s = Session::new();
@@ -99,6 +101,38 @@ fn edit_colors_reach_mesh_points() {
assert_eq!(mesh_colors(&s, m), pts, "a mesh's points are fill colours");
}
/// Clicking a gradient mesh's point with the Mesh tool or Direct Selection picks it: a solid fill
/// colour then recolours that point alone (#1050).
#[test]
fn a_clicked_mesh_point_takes_the_fill_colour() {
let mut s = session();
let m = mesh(&mut s);
let before = mesh_colors(&s, m);
let v = ViewInfo::default();
let click = |s: &mut Session, i: usize| {
let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") };
let q = g.points[i].p;
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, q.x, q.y), v).unwrap();
}
};
// The Mesh tool selects the mesh whose point it clicks.
s.execute("select.none", &json!({})).unwrap();
s.select_tool("mesh", v).unwrap();
click(&mut s, 1);
assert_eq!(s.doc().unwrap().selection.objects, [m]);
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
let after = mesh_colors(&s, m);
assert_eq!(after[1].to_hex(), "#0000ff", "the clicked point takes the colour");
assert_eq!((after[0], after[2], after[3]), (before[0], before[2], before[3]), "the other points keep theirs");
s.select_tool("directSelection", v).unwrap();
click(&mut s, 2);
s.execute("paint.setFill", &json!({"color": "#ffff00"})).unwrap();
let after = mesh_colors(&s, m);
assert_eq!((after[1].to_hex(), after[2].to_hex()), ("#0000ff".to_string(), "#ffff00".to_string()));
assert_eq!((after[0], after[3]), (before[0], before[3]));
}
#[test]
fn blend_moves_a_mesh_to_the_blended_colour() {
let mut s = session();
+16
View File
@@ -42,6 +42,22 @@ fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
})
}
/// An invalid explicit Shaper target must never silently change which shapes get edited.
#[test]
fn shaper_scribble_rejects_malformed_explicit_targets() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let untouched = s.doc().unwrap().doc.layers.clone();
let undo = s.doc().unwrap().history.undo.len();
for ids in [json!([a.0, "bad"]), json!([a.0, 999999]), json!({"id": a.0}), json!("bad")] {
let err = s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": ids}));
assert!(err.is_err(), "malformed targets were accepted");
assert_eq!(s.doc().unwrap().doc.layers, untouched);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": [a.0]})).unwrap();
}
#[test]
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
for reverse in [false, true] {
+32
View File
@@ -563,3 +563,35 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() {
legacy["para"]["justify"] = json!("Left");
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
}
/// #1063: a fill (or stroke) chosen while the Type tool has characters selected colours just
/// those characters, as one undo step; the rest of the type keeps its colour.
#[test]
fn a_fill_chosen_with_characters_selected_colours_only_them() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 50.0, 300.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("Sample Text", v).unwrap();
let before = obj(&s, id).runs[0].style.fill.clone();
s.set_tool_option("select", &json!({"start": 0, "end": 6}));
s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap();
let t = obj(&s, id);
assert_eq!(t.runs.len(), 2);
assert_eq!(t.runs[0].text, "Sample");
assert_eq!(t.runs[0].style.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 160, 0)));
assert_eq!(t.runs[1].text, " Text");
assert_eq!(t.runs[1].style.fill, before);
// A stroke goes on the selected characters only, with a weight to show.
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
let t = obj(&s, id);
assert!(t.runs[0].style.stroke_width > 0.0 && !t.runs[0].style.stroke.is_none());
assert!(t.runs[1].style.stroke.is_none());
// One undo step each.
let labels = undo_labels(&s);
assert_eq!(&labels[labels.len() - 2..], ["Fill Color", "Stroke Color"]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before));
}
+229
View File
@@ -0,0 +1,229 @@
//! #1034: Character sizes and horizontal scales account for the object's affine transform.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
use vectorcraft_geom::Point;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn create(s: &mut Session, size: f64) -> NodeId {
NodeId(s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Axis label", "size": size})).unwrap()["id"].as_u64().unwrap())
}
fn text(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn em(t: &TextObject, run: usize) -> (f64, f64) {
let st = &t.runs[run].style;
let origin = t.xf * Point::ZERO;
((t.xf * Point::new(st.size * st.h_scale / 100.0, 0.0) - origin).hypot(), (t.xf * Point::new(0.0, st.size * st.v_scale / 100.0) - origin).hypot())
}
fn near(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}");
}
#[test]
fn scaled_font_size_setter_sets_document_points_and_undoes() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
s.execute("object.scale", &json!({"sx": 150})).unwrap();
let before = text(&s, id);
near(em(&before, 0).1, 36.0);
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
let after = text(&s, id);
near(em(&after, 0).1, 10.0);
assert_eq!(after.xf, before.xf, "font editing preserves placement and rotation");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(text(&s, id), after);
}
#[test]
fn group_font_size_setter_handles_different_accumulated_scales() {
let mut s = session();
let a = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
let b = create(&mut s, 18.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("object.scale", &json!({"sx": 200})).unwrap();
near(em(&text(&s, a), 0).1, 48.0);
near(em(&text(&s, b), 0).1, 36.0);
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
near(em(&text(&s, a), 0).1, 10.0);
near(em(&text(&s, b), 0).1, 10.0);
}
#[test]
fn scaled_range_size_and_keyboard_steps_use_document_points() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "size": 10})).unwrap();
near(em(&text(&s, id), 0).1, 10.0);
near(em(&text(&s, id), 1).1, 24.0);
s.prefs.type_size_increment = 2.0;
s.execute("type.step", &json!({"attribute": "size", "id": id.0, "start": 0, "end": 4})).unwrap();
near(em(&text(&s, id), 0).1, 12.0);
near(em(&text(&s, id), 1).1, 24.0);
}
#[test]
fn horizontal_scale_reset_removes_object_stretch_including_groups() {
for grouped in [false, true] {
let mut s = session();
let id = create(&mut s, 12.0);
if grouped {
s.execute("object.group", &json!({})).unwrap();
}
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let before = text(&s, id);
s.execute("text.setFormat", &json!({"hScale": 100, "vScale": 100})).unwrap();
let after = text(&s, id);
near(em(&after, 0).0, 12.0);
near(em(&after, 0).1, 12.0);
assert_eq!(after.xf, before.xf);
}
}
#[test]
fn rotated_reflected_nonuniform_range_edits_preserve_the_affine() {
let mut s = session();
let id = create(&mut s, 12.0);
// A quarter turn, reflected in the baseline axis: horizontal ×2, vertical ×3.
s.execute("object.transform", &json!({"matrix": [0, -2, -3, 0, 0, 0]})).unwrap();
let before = text(&s, id);
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100, "vScale": 100})).unwrap();
let after = text(&s, id);
near(em(&after, 0).0, 10.0);
near(em(&after, 0).1, 10.0);
assert_eq!(after.xf, before.xf);
// The native document representation retains both the compensation and the affine.
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
let reopened = vectorcraft_format::load(&bytes).unwrap();
let NodeKind::Text(t) = &reopened.node(id).unwrap().kind else { panic!("not text") };
near(em(t, 0).0, 10.0);
near(em(t, 0).1, 10.0);
}
#[test]
fn effective_range_query_leaves_raw_runs_available_for_clipboard() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
let query = |s: &mut Session, effective| -> Value {
s.execute("text.getRange", &json!({"id": id.0, "effective": effective})).unwrap()["runs"][0]["style"].clone()
};
let effective = query(&mut s, true);
assert_eq!(effective["size"], 24.0);
assert_eq!(effective["h_scale"], 150.0);
let raw = query(&mut s, false);
assert_eq!(raw["size"], 12.0);
assert_eq!(raw["h_scale"], 100.0);
}
#[test]
fn scaled_point_units_and_small_sizes_are_converted_before_local_storage() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
s.execute("text.setStyle", &json!({"size": 0.1, "leading": 14})).unwrap();
let t = text(&s, id);
near(em(&t, 0).1, 0.1);
near(t.runs[0].style.leading.unwrap() * 2.0, 14.0);
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 12, "baselineShift": 4, "leading": 16})).unwrap();
let t = text(&s, id);
near(t.runs[0].style.baseline_shift * 2.0, 4.0);
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
}
#[test]
fn singular_text_size_edit_returns_an_error_and_rolls_back_the_group() {
let mut s = session();
let a = create(&mut s, 12.0);
let b = create(&mut s, 18.0);
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
assert!(s.execute("text.setStyle", &json!({"size": 10})).is_err());
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
assert!(s.execute("text.setRangeStyle", &json!({"id": b.0, "size": 10})).is_err());
assert!(s.execute("text.setFormat", &json!({"id": b.0, "hScale": 100})).is_err());
// Non-dimensional edits remain possible, including on imported collapsed text.
s.execute("text.setStyle", &json!({"fill": "#123456"})).unwrap();
}
#[test]
fn sheared_text_keeps_its_affine_and_accepts_exact_em_dimensions() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.transform", &json!({"matrix": [2, 0, 1, 2, 0, 0]})).unwrap();
let before = text(&s, id);
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100})).unwrap();
let after = text(&s, id);
near(em(&after, 0).0, 10.0);
near(em(&after, 0).1, 10.0);
assert_eq!(after.xf, before.xf);
}
#[test]
fn area_frame_resize_reflows_at_same_size_and_object_scaling_reports_scaled_size() {
let mut s = session();
let id = NodeId(
s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Area label", "size": 12, "area": {"width": 120, "height": 80}})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0], "typeAreas": true})).unwrap();
near(em(&text(&s, id), 0).1, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
near(em(&text(&s, id), 0).1, 24.0);
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
near(em(&text(&s, id), 0).1, 10.0);
}
#[test]
fn point_based_keyboard_increments_and_format_fields_follow_document_units() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
s.execute("text.setStyle", &json!({"leading": 14})).unwrap();
s.execute("text.setFormat", &json!({"baselineShift": 4})).unwrap();
s.prefs.type_size_increment = 2.0;
s.prefs.baseline_shift_increment = 3.0;
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
s.execute("type.step", &json!({"attribute": "baselineShift"})).unwrap();
let t = text(&s, id);
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
near(t.runs[0].style.baseline_shift * 2.0, 7.0);
}
#[test]
fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.transform", &json!({"matrix": [1e-306, 0, 0, 1e-306, 0, 0]})).unwrap();
let before = text(&s, id);
let history = s.doc().unwrap().history.undo.len();
let error = s.execute("text.setStyle", &json!({"size": 160})).unwrap_err();
assert!(matches!(error, EngineError::BadParams { .. }), "reject before attempting unsafe layout: {error}");
assert_eq!(text(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
+25
View File
@@ -30,6 +30,31 @@ fn dark(s: &Session, x: u32, y: u32) -> bool {
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y)[0] < 128
}
/// Invalid entries in an explicit multi-point delete must not delete the valid entries.
#[test]
fn invalid_width_point_indices_leave_the_entire_profile_unchanged() {
let (mut s, id) = line_session();
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
let before = points(&s, id);
let history = s.doc().unwrap().history.undo.len();
for bad in [
json!({"indices": [1, "bad"]}),
json!({"indices": [-1, 1]}),
json!({"indices": [1.5, 1]}),
json!({"indices": {"point": 1}}),
json!({"index": "bad"}),
] {
let mut params = bad.as_object().unwrap().clone();
params.insert("id".into(), json!(id.0));
let err = s.execute("stroke.widthPoint.remove", &serde_json::Value::Object(params));
assert!(err.is_err(), "accepted malformed width point indices");
assert_eq!(points(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "indices": [1]})).unwrap();
assert_eq!(points(&s, id).len(), before.len() - 1);
}
#[test]
fn a_point_moved_onto_another_makes_a_discontinuous_point_that_renders_a_step() {
let (mut s, id) = line_session();
-23
View File
@@ -81,23 +81,6 @@ fn every_command_without_a_document() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Known bugs (see the `#[ignore]` repro tests below): (command, param key) pairs skipped by the
/// junk sweep so the rest of the sweep stays meaningful.
const KNOWN_BUGS: &[(&str, &str)] = &[
("object.scale", "sx"),
("object.transformEach", "scaleH"),
("object.transformEach", "scaleV"),
("object.distributeSpacing", "spacing"),
("artboard.setProps", "x"),
("artboard.setProps", "y"),
("artboard.setProps", "width"),
("artboard.setProps", "height"),
];
fn known_bug(id: &str, p: &Value) -> bool {
KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some())
}
/// Regression: huge column counts used to panic with `capacity overflow`; in every layout and
/// order (#681), with huge and negative spacing too.
#[test]
@@ -157,9 +140,6 @@ fn every_command_with_junk_params() {
cases.truncate(12);
}
for p in &cases {
if known_bug(c.id, p) {
continue;
}
calls += 1;
if let Some(f) = probe(fx, c.id, p) {
failures.push(f);
@@ -494,9 +474,6 @@ fn fuzzed_calls_keep_documents_serializable() {
let k = if k == "path" { continue } else { k };
let mut s = Fixture::Multi.session();
let p = json!({ k.clone(): v });
if known_bug(c.id, &p) {
continue;
}
let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false);
if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
failures.push(format!("{} {p}: {e}", c.id));
+1 -1
View File
@@ -27,7 +27,7 @@ use std::io::Read as _;
use super::lex::find;
use crate::ps;
pub use read::{Structure, read};
pub use read::{Structure, artboards, read};
pub(crate) use read::{slot_frame, slot_of};
#[cfg(test)]
+22 -7
View File
@@ -2,10 +2,10 @@
//!
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
//! values its equivalent), `Xx`/`XX` a named colour of either model
//! (`c m y k [r g b] (name) tint type`), `Xk`/`XK` a global process colour (its operands as
//! `Xx`'s: a global swatch that isn't an ink of its own). Lower case paints fills, upper case
//! strokes.
//! values its equivalent), `Xx`/`XX` a named colour of any model
//! (`c m y k [r g b | L a b] (name) tint type`, type 0 CMYK, 1 RGB, 2 Lab), `Xk`/`XK` a global
//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own). Lower
//! case paints fills, upper case strokes.
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
@@ -57,8 +57,8 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
}
}
/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces
/// the model.
/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab);
/// `rgb` forces RGB or CMYK.
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
let name = vals.get(at)?.text()?;
@@ -67,9 +67,14 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let unit = |v: f32| v.clamp(0.0, 1.0);
let tint = 1.0 - unit(*after.first()?);
let n = before.len();
let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) {
let kind = after.get(1).copied();
let color = if rgb.unwrap_or(kind == Some(1.0)) {
let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None };
Color::rgb(unit(*r), unit(*g), unit(*b))
} else if rgb.is_none() && kind == Some(2.0) {
// Type 2: a colour defined in Lab, its L a b values after the CMYK equivalent (#1032).
let [l, a, b] = before.get(n.checked_sub(3)?..)? else { return None };
Color::lab(l.clamp(0.0, 100.0), a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0))
} else {
// The CMYK values come first (an RGB equivalent may follow them).
let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? };
@@ -266,6 +271,16 @@ mod tests {
v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]);
let (p, _) = color_op("Xk", &v).unwrap();
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0)));
// A spot colour defined in Lab (type 2): its L a b values, not its CMYK equivalent (#1032).
let mut v = nums(&[0.52, 0.98, 0.28, 0.1, 32.9412, 45.0, -18.0]);
v.extend([V::Str(b"Plum Ink".to_vec()), V::Num(0.0), V::Num(2.0)]);
let (p, named) = color_op("Xx", &v).unwrap();
let named = named.unwrap();
assert_eq!((named.color, named.spot, named.tint), (Color::lab(32.9412, 45.0, -18.0), true, 1.0));
assert_eq!(p.color(), Some(Color::lab(32.9412, 45.0, -18.0)));
v[8] = V::Num(0.25);
let (_, named) = color_op("XX", &v).unwrap();
assert_eq!(named.map(|n| (n.color, n.tint)), Some((Color::lab(32.9412, 45.0, -18.0), 0.75)));
}
#[test]
+9
View File
@@ -106,6 +106,15 @@ pub fn read(data: &[u8]) -> Result<Structure, String> {
r.finish()
}
/// The artboards (art space) of the editing data `data` that [`read`] can't read as a whole, as far
/// as it gets: where to put the pages of its PDF part instead (#1068).
pub fn artboards(data: &[u8]) -> Vec<Rect> {
let mut r = Reader::new(data);
// Artboards read before an error still say where they are.
let _ = r.run();
r.artboards.into_iter().map(|(_, rect)| rect).collect()
}
#[derive(Clone, Debug)]
struct GState {
fill: Paint,
+18 -11
View File
@@ -339,6 +339,12 @@ fn turn(a: f64, b: f64) -> f64 {
if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d }
}
/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left
/// of each after whole turns.
fn ccw_turn(a: f64, b: f64) -> f64 {
(b % 360.0 - a % 360.0).rem_euclid(360.0)
}
impl Interp<'_> {
/// User space → document space.
pub fn xf(&self) -> Affine {
@@ -436,18 +442,19 @@ impl Interp<'_> {
/// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise).
fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res {
let (a1, mut a2) = (a1.to_radians(), a2.to_radians());
let tau = std::f64::consts::TAU;
if ccw {
while a2 < a1 {
a2 += tau;
}
// `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it
// until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps,
// and the first endpoint, are worked out from what is left of each angle after whole turns.
let sweep = if ccw && a2 < a1 {
ccw_turn(a1, a2)
} else if !ccw && a2 > a1 {
-ccw_turn(a2, a1)
} else {
while a2 > a1 {
a2 -= tau;
}
}
let sweep = (a2 - a1).clamp(-tau * 4.0, tau * 4.0);
a2 - a1
};
let tau = std::f64::consts::TAU;
let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0);
let a1 = (a1 % 360.0).to_radians();
let start = c + Vec2::new(r * a1.cos(), r * a1.sin());
self.append_user(std::iter::once(PathEl::MoveTo(start)))?;
let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0);
+42
View File
@@ -1041,11 +1041,53 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
Err(why) => {
let mut all = warnings;
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
let mut visible = visible;
place_pages(&ai::artboards(&data), &mut visible);
(visible, all)
}
}
}
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) moved, with
/// the art on each, to where `boards` (the artboards of its editing data, in art space) are, so
/// the artboards keep their layout when the layers can't be read (#1068). Left as they are unless
/// there are as many artboards as pages, of the same sizes.
fn place_pages(boards: &[Rect], doc: &mut Document) {
let (Some(first), true) = (boards.first(), boards.len() == doc.artboards.len() && boards.len() > 1) else { return };
if boards.iter().zip(&doc.artboards).any(|(b, a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
return;
}
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it.
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]);
let pages: Vec<Rect> = doc.artboards.iter().map(|a| a.rect).collect();
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|(b, page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
for layer in &mut doc.layers {
place_art(Arc::make_mut(layer), &pages, &moves);
}
for (a, v) in doc.artboards.iter_mut().zip(&moves) {
a.rect = a.rect + *v;
}
}
/// The objects in `layer` (and its sublayers) moved by the move of the page they are on.
fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
let Some(children) = layer.children_mut() else { return };
for child in children {
if matches!(child.kind, NodeKind::Layer { .. }) {
place_art(Arc::make_mut(child), pages, moves);
continue;
}
let Some(r) = child.visual_bounds() else { continue };
// The page it is on: the one with most of it (pages keep only the art that reaches into
// them), else the nearest.
let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot());
let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue };
if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) {
Arc::make_mut(child).transform(Affine::translate(*v), false);
}
}
}
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
+36
View File
@@ -640,6 +640,42 @@ fn hostile_executable_files_and_type_loops_are_refused() {
blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor");
}
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes
/// more than 20 s.
fn read_soon(body: &str) -> crate::Imported {
let (tx, rx) = std::sync::mpsc::channel();
let ps = eps(body);
std::thread::spawn(move || tx.send(import(&ps)).ok());
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap()
}
/// `arc` raises its second angle by multiples of 360 until it is at least the first, and `arcn`
/// lowers it until it is at most the first (PLRM 3rd ed., `arc` and `arcn` in chapter 8), however
/// large the angles: the arc runs between the angles' remainders after whole turns and ends at
/// the second angle, the new current point.
#[test]
fn arcs_with_huge_angles_end_at_their_second_angle() {
// `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in
// document space.
let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin());
// Each program is listed with the remainders of its first and second angles after whole turns.
for (body, first, second) in [
("50 50 10 1e18 0 arc", 280.0, 0.0),
("50 50 10 1e308 0 arc", 296.0, 0.0),
("50 50 10 0 1e18 arcn", 0.0, 280.0),
("50 50 10 0 1e308 arcn", 0.0, 296.0),
] {
let r = read_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill"));
let o = objects(&r.document);
assert_eq!(o.len(), 2, "{body}: {:?}", r.warnings);
// The arc lies within one quarter of the circle, so its endpoints span its bounds.
let (a, b) = (at(first), at(second));
assert!(near(bounds(&o[0]), Rect::from_points(a, b)), "{body}: {:?}", bounds(&o[0]));
// The second object is the square drawn at the current point.
assert!(near(bounds(&o[1]), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(&o[1]));
}
}
#[test]
fn cshow_runs_its_procedure_for_each_character() {
check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq");
+43
View File
@@ -237,6 +237,49 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
}
/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its
/// pages still go where its editing data puts its artboards, with their art (#1068).
#[test]
fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
// Two 100-point artboards, the second below the first (art space, y up).
let board = |x0: i32, y0: i32| {
format!(
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
y0 + 100,
x0 + 100
)
};
let boards = |list: &[(i32, i32)]| {
let all: String = list.iter().map(|(x, y)| board(*x, *y)).collect();
format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{all}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n")
};
// An operator this doesn't know, so the layers aren't read.
let unread = |list: &[(i32, i32)]| native(&(boards(list) + &layer("Art", true, &(square(10, 10) + "\n1 Zz"))));
// The PDF part: the pages side by side, 20 points apart, a square on each.
let mut pdf = pdf_part(10.0, 10.0);
let second = pdf_part(130.0, 10.0).layers[0].children().unwrap()[0].clone();
let mut second = (*second).clone();
second.id = pdf.alloc_id();
let layer_id = pdf.layers[0].id;
pdf.insert(Some(layer_id), 1, second).unwrap();
let mut page = pdf.artboards[0].clone();
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
pdf.artboards.push(page);
let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::<Vec<_>>();
let (d, w) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
assert!(w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
let moved = squares(&d);
assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}");
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
// Artboards that don't match the pages leave them in a row.
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
let (d, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
}
}
#[test]
fn a_gray_is_a_grayscale_colour() {
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour
+31 -6
View File
@@ -389,12 +389,16 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
Object::Array(a) => {
let mut it = a.flex_iter();
match it.next::<Name<'_>>()?.as_str() {
"ICCBased" => match it.next::<Stream<'_>>()?.dict().get::<u8>(b"N")? {
1 => Some(Alt::Gray),
3 => Some(Alt::Rgb),
4 => Some(Alt::Cmyk),
_ => None,
},
"ICCBased" => {
let profile = it.next::<Stream<'_>>()?;
match profile.dict().get::<u8>(b"N")? {
1 => Some(Alt::Gray),
3 if lab_profile(&profile) => Some(Alt::Lab),
3 => Some(Alt::Rgb),
4 => Some(Alt::Cmyk),
_ => None,
}
}
"Lab" => Some(Alt::Lab),
"CalRGB" => Some(Alt::Rgb),
"CalGray" => Some(Alt::Gray),
@@ -406,6 +410,27 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
}
}
/// Is the ICC profile of an `ICCBased` space a Lab one (its alternate space `Lab`, else its header's
/// data colour space `Lab `)? Spot colours defined in Lab come with such a profile as their
/// alternate space (#1032), and their tint transforms give Lab values.
fn lab_profile(profile: &Stream<'_>) -> bool {
use std::io::Read;
if let Some(Object::Array(a)) = profile.dict().get::<Object<'_>>(b"Alternate") {
return a.flex_iter().next::<Name<'_>>().is_some_and(|n| n.as_str() == "Lab");
}
// The header's first 20 bytes, without decoding the rest of the profile.
let raw = profile.raw_data();
let mut head = vec![];
match profile.filters().as_slice() {
[] => head.extend(raw.iter().take(20)),
[hayro_syntax::Filter::FlateDecode] => {
let _ = flate2::read::ZlibDecoder::new(&*raw).take(20).read_to_end(&mut head); // a damaged profile reads as not Lab
}
_ => return false,
}
head.get(16..20) == Some(b"Lab ")
}
/// An alternate-space colour (the tint transform's output) as a VectorCraft colour.
fn alt_color(alt: Alt, v: &[f32]) -> Option<Color> {
let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite());
+30
View File
@@ -215,3 +215,33 @@ fn colour_spaces_are_read_from_the_interpreters_debug_form() {
assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4]));
assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None);
}
/// Spot colours defined in Lab can have an ICC Lab profile as their alternate space, `[/ICCBased
/// profile]` with 3 components: the tint transform gives Lab values, which are not RGB (#1032). The
/// profile is told by its `Alternate` (`[/Lab …]`), else by its header's data colour space.
#[test]
fn spot_colours_with_an_icc_lab_alternate_keep_their_lab_values() {
let f = first_extra(1);
// A profile's first 20 bytes: size, CMM, version, class, then the data colour space.
let profile = |head: &str, alternate: &str| format!("<< /N 3 {alternate} /Length {} >>\nstream\n{head}\nendstream", head.len());
let lab_header = profile("0000none0000spacLab XYZ ", "");
let lab_alternate = profile("0000none0000spacXYZ XYZ ", "/Alternate [/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-128 127 -128 127] >>]");
let rgb = profile("0000none0000mntrRGB XYZ ", "");
let tint = |c1: &str| format!("<< /FunctionType 2 /Domain [0 1] /C0 [100 0 0] /C1 [{c1}] /N 1 >>");
let spaces = format!(
"/CS0 [/Separation /Plum [/ICCBased {f} 0 R] {}] /CS1 [/Separation /Sky [/ICCBased {} 0 R] {}] /CS2 [/Separation /Leafy [/ICCBased {} 0 R] << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0.5 0.9 0.1] /N 1 >>]",
tint("32.9412 45 -18"),
f + 1,
tint("51 -26 -45"),
f + 2
);
let page = page_with(&spaces, &[("CS0", "1"), ("CS1", "0.5"), ("CS2", "1")]);
let d = import(&pdf_with(&[page], &[&lab_header, &lab_alternate, &rgb], None)).unwrap();
let f = fills(&d);
assert_eq!(solid(&f[0]).1, Some("Plum"));
assert!(near(d.swatch("Plum").unwrap().paint.color().unwrap(), Color::lab(32.941, 45.0, -18.0)), "{:?}", d.swatch("Plum"));
assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Sky"), 0.5));
assert!(near(d.swatch("Sky").unwrap().paint.color().unwrap(), Color::lab(51.0, -26.0, -45.0)), "{:?}", d.swatch("Sky"));
// An RGB profile stays RGB.
assert!(near(d.swatch("Leafy").unwrap().paint.color().unwrap(), Color::rgb(0.5, 0.9, 0.1)), "{:?}", d.swatch("Leafy"));
}
+1 -1
View File
@@ -977,7 +977,7 @@ impl Renderer {
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
let doc = std::ptr::from_ref(f.doc) as usize;
if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) {
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
}
let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?;
+13 -9
View File
@@ -109,15 +109,19 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
// Manual kerning replaces the font's pair kerning.
p.push(("font-kerning", "none".into()));
}
if !st.features.is_empty() {
let v: Vec<String> = st
.features
.iter()
.map(|t| match t.strip_prefix('-') {
Some(off) => format!("\"{off}\" 0"),
None => format!("\"{t}\" 1"),
})
.collect();
let mut v: Vec<String> = st
.features
.iter()
.map(|t| match t.strip_prefix('-') {
Some(off) => format!("\"{off}\" 0"),
None => format!("\"{t}\" 1"),
})
.collect();
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
if st.proportional_metrics {
v.push("\"palt\" 1".into());
}
if !v.is_empty() {
p.push(("font-feature-settings", v.join(", ")));
}
match (st.underline, st.strikethrough) {
+14
View File
@@ -105,6 +105,20 @@ fn rich_text_writes_spacing_shift_and_scale() {
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
}
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
#[test]
fn proportional_metrics_write_palt() {
let proportional = CharStyle { proportional_metrics: true, ..style(12.0) };
let both = CharStyle { proportional_metrics: true, features: vec!["dlig".into()], ..style(12.0) };
let d = area_doc(vec![run("雅楽 ", style(12.0)), run("「あ」 ", proportional), run("ab", both)], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert_eq!(tags.len(), 3, "{svg}");
assert!(!tags[0].contains("palt"), "{}", tags[0]);
assert!(tags[1].contains("font-feature-settings:&quot;palt&quot; 1"), "{}", tags[1]);
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
}
#[test]
fn fewer_tspans_writes_one_per_line() {
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };
+6
View File
@@ -51,6 +51,12 @@ fn main() {
}
let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n");
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
// Unchanged output keeps its mtime. Rewritten just before this crate compiles, it can read as
// newer than that compile where cargo's recorded times lose their sub-second part (ecryptfs
// home directories), and the next build would compile this crate and everything above it again.
if std::fs::read_to_string(&out).is_ok_and(|old| old == src) {
return;
}
if let Err(e) = std::fs::write(&out, src) {
println!("cargo::error=writing {}: {e}", out.display());
}
+17
View File
@@ -160,6 +160,23 @@ impl OtFeatures {
// and digits itself (they would lie upside down, and tate-chu-yoko break).
v.push(f(b"vert", true));
}
if self.proportional(st) {
v.push(f(b"palt", true));
}
v
}
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
/// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before.
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
st.proportional_metrics && !self.vertical
}
/// The harfrust features for text in style `st` with full-width glyphs on their full widths:
/// [`Self::resolve`] without `palt`.
pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec<Feature> {
let mut v = self.resolve(st);
v.retain(|x| x.tag != Tag::new(b"palt"));
v
}
}
+12 -4
View File
@@ -200,15 +200,21 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
}
/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which
/// it fits, searched by bisection over the first run's size in steps of 0.1 pt (so its scaled size
/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size
/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there,
/// still overflowing. None (keep the full-size layout) when the text has no usable size.
fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option<TextLayout> {
let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?;
// Exact size edits after object scaling may store a local size below 0.1. Fit in the
// same document points as the Character controls, independent of that representation.
let points = t.style_scale()?.points;
let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?;
if !(min.is_finite() && min < 1.0) {
return None;
}
let tenths = size * 10.0;
if !tenths.is_finite() {
return None;
}
// Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow
// unless it is below the full size; `lo` is the smallest allowed.
let mut lo = (tenths * min).ceil().max(1.0);
@@ -234,7 +240,7 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) ->
}
// Invariant: everything at `hi` or above overflows; `lo` fits or is the floor.
while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES {
let mid = ((lo + hi) * 0.5).floor();
let mid = (lo + (hi - lo) * 0.5).floor();
let l = at(mid);
passes += 1;
if l.overflow {
@@ -381,7 +387,8 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its
/// advance an em, give or take a tenth), the space that mojikumi can take off.
fn punct_half(g: &SGlyph, kind: Punct) -> Option<f64> {
if punct(g.ch)? != kind || g.tcy.is_some() {
// A glyph Proportional Metrics re-spaced has no empty half left to take off (#966).
if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional {
return None;
}
let em = g.face.units_per_em();
@@ -1170,6 +1177,7 @@ mod wrapping_tests {
tcy: None,
inline: None,
lead: 0.0,
proportional: false,
level: unicode_bidi::Level::ltr(),
})
.collect()
+44 -24
View File
@@ -49,6 +49,9 @@ pub(crate) struct SGlyph {
/// Japanese composition: the space taken off before the glyph (an opening bracket after
/// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line.
pub lead: f64,
/// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width,
/// and Japanese composition takes no more space off it (#966).
pub proportional: bool,
/// Resolved Unicode bidi embedding level (logical source order).
pub level: Level,
/// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph.
@@ -117,6 +120,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
h.len = 0;
h.ch = '-';
h.inline = None;
h.proportional = false;
h
}
@@ -422,6 +426,7 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
ch: text.get(range.clone()).and_then(|s| s.chars().next()).unwrap_or(INLINE_CHAR),
tcy: None,
lead: 0.0,
proportional: false,
// U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in
// RTL text like any other glyph once the line is reordered.
level,
@@ -470,34 +475,48 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
// Proportional Metrics: which glyphs `palt` gave another advance than their full width.
let mut proportional = vec![];
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
if upper {
for u in c.to_uppercase() {
buf.add(u, cl);
let shape = |feats: &[Feature]| {
let mut buf = UnicodeBuffer::new();
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
if upper {
for u in c.to_uppercase() {
buf.add(u, cl);
}
} else {
buf.add(c, cl);
}
} else {
buf.add(c, cl);
}
}
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
// glyphs for it. This retains context at real style/font segmentation boundaries without
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
if let Some(pre) = text.get(context.start..range.start) {
buf.set_pre_context(pre);
}
if let Some(post) = text.get(range.end..context.end) {
buf.set_post_context(post);
}
let feats: Vec<Feature> = feats.resolve(st);
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
// glyphs for it. This retains context at real style/font segmentation boundaries without
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
if let Some(pre) = text.get(context.start..range.start) {
buf.set_pre_context(pre);
}
if let Some(post) = text.get(range.end..context.end) {
buf.set_post_context(post);
}
let gb = shaper.shape(buf, ShapeOptions::new().features(feats));
gb.glyph_infos()
.iter()
.zip(gb.glyph_positions())
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset))
.collect::<Vec<_>>()
};
raw = shape(&feats.resolve(st));
if feats.proportional(st) {
// `palt` is positioning only: the same glyphs set on their full widths tell which
// advances it changed (kerning and the rest change both alike).
let full = shape(&feats.resolve_fixed_width(st));
if full.len() == raw.len() && full.iter().zip(&raw).all(|(a, b)| a.0 == b.0) {
proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect();
}
}
});
if shaped.is_none() {
@@ -553,6 +572,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
ch,
tcy: None,
lead: 0.0,
proportional: proportional.get(gi).copied().unwrap_or(false),
level: *level,
inline: None,
});
+13
View File
@@ -54,6 +54,19 @@ fn shrink_text_makes_overflowing_text_fit() {
assert_eq!(layout(db(), &t).fit_scale, l.fit_scale);
}
#[test]
fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() {
// A 14 pt story represented in a scaled local coordinate system. Its local size is
// below 0.1, as happens when setting an exact size after enlarging a text group.
let mut t = area(COPY, 0.014, Rect::new(0.0, 0.0, 0.2, 0.09), shrink(30.0));
t.xf = Affine::scale(1000.0);
assert!(at_size(&t, 0.014).overflow);
let l = layout(db(), &t);
assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale);
let document_size = 14.0 * l.fit_scale;
assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6);
}
#[test]
fn shrink_text_respects_its_minimum() {
let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0));
+45
View File
@@ -749,3 +749,48 @@ fn layout_10k_area_text_is_fast() {
assert!(per < budget, "{justify:?} {composer:?}: {per} ms");
}
}
/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional
/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt`
/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a
/// line's end); punctuation it leaves full width is trimmed as before. Vertical type sets as
/// before. Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
/// 〘〙 (skipped without it).
#[test]
fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
use vectorcraft_doc::Mojikumi;
if db().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() };
// Right-aligned in a frame ten ems wide, so a line's end trim moves the line.
let lay = |text: &str, on: bool, m: Mojikumi, vertical: bool| {
let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right);
t.vertical = vertical;
t.para.mojikumi = m;
layout(db(), &t)
};
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::<Vec<_>>();
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
let off = adv(&lay("「あ漢」", false, Mojikumi::None, false));
let on = adv(&lay("「あ漢」", true, Mojikumi::None, false));
assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}");
assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}");
assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}");
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's
// start (「), between punctuation (」、) or at the line's end (」).
for text in ["「あ」、漢", "漢「あ」"] {
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf, false), lay(text, true, Mojikumi::None, false));
assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none));
assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none));
}
// Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end.
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf, false), lay("〘あ〙", true, Mojikumi::None, false));
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
let moved = x0(&half)[1] - x0(&none)[1];
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
// Vertical type sets as before.
assert!(near(&adv(&lay("「あ漢」", true, Mojikumi::None, true)), &adv(&lay("「あ漢」", false, Mojikumi::None, true))));
}
+5 -1
View File
@@ -15,7 +15,8 @@
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
//! and mesh envelopes and their handles ([`MeshEdit`]). Dragging a corner or an edge of area
//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient
//! mesh's point last clicked). Dragging a corner or an edge of area
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
//!
@@ -291,6 +292,9 @@ impl Tool for DirectSelectionTool {
fn busy(&self) -> bool {
!matches!(self.state, State::Idle) || self.guide.busy()
}
fn options(&self) -> Value {
self.mesh.options()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if let Some(out) = self.guide.pointer(cx, ev) {
return out;
+13 -2
View File
@@ -8,7 +8,8 @@
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
//! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the
//! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point →
//! `object.mesh.deletePoint`.
//! `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the next
//! solid fill colour recolours just that point.
use serde_json::json;
use vectorcraft_color::Paint;
@@ -183,7 +184,14 @@ impl Tool for MeshTool {
self.edit.unfocus();
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))];
}
return self.edit.press(g);
// A gradient mesh's point is picked for the next fill colour, so its mesh is
// what gets selected.
let mut out = vec![];
if cx.selection.objects != [g.id] && cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {
out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]})));
}
out.extend(self.edit.press(g));
return out;
}
self.edit.unfocus();
let Some((top, leaf)) = hit_top(cx, p) else { return vec![] };
@@ -236,6 +244,9 @@ impl Tool for MeshTool {
None => Cursor::PenAdd,
}
}
fn options(&self) -> serde_json::Value {
self.edit.options()
}
fn busy(&self) -> bool {
self.edit.dragging()
}
+9 -2
View File
@@ -1,10 +1,11 @@
//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct
//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a
//! point focuses it: its handles show and can be dragged too.
//! point focuses it: its handles show and can be dragged too, and on a gradient mesh a solid fill
//! colour recolours it (the tool's `meshPoint` option).
use std::borrow::Cow;
use serde_json::json;
use serde_json::{Value, json};
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
use vectorcraft_geom::Point;
@@ -91,6 +92,12 @@ impl MeshEdit {
self.focus = None;
}
/// `{meshPoint: {id, index}|null}`: the focused point, which a solid fill colour recolours
/// (`paint.setFill` on a gradient mesh).
pub fn options(&self) -> Value {
json!({"meshPoint": self.focus.map(|(id, index)| json!({"id": id.0, "index": index}))})
}
/// The focused point's handles (lines and ends).
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some((id, index)) = self.focus else { return vec![] };
+2
View File
@@ -9,6 +9,7 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap
workspace = true
[dependencies]
craft-ui = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
@@ -33,6 +34,7 @@ serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
vectorcraft-testkit = { workspace = true }
wat = { workspace = true }
+7 -1
View File
@@ -21,7 +21,13 @@ fn main() {
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
.unwrap_or_default();
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
let src = generate(&json, &people);
// Unchanged output keeps its mtime, so the next build doesn't compile this crate again
// (see crates/text/build.rs).
if std::fs::read_to_string(&out).is_ok_and(|old| old == src) {
return;
}
if let Err(e) = std::fs::write(&out, src) {
println!("cargo::warning=writing {}: {e}", out.display());
}
}
+179 -12
View File
@@ -47,13 +47,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.add_space(2.0);
// With the macOS menu bar the menus are at the top of the screen instead.
let menus_end = if app.services.native_menu.is_some() {
let full = ui.max_rect();
ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text);
ui.cursor().min.x
} else {
menus::menu_bar(app, ui)
};
let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) };
// The right-side group fills the space after the menus from the right; when it runs
// short, Discord goes first (it is also under Help), then the search icon (the palette
// stays under its shortcut), then the workspace switcher narrows.
@@ -97,6 +91,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(10.0);
crate::community::discord_button(app, ui, false);
}
if app.services.native_menu.is_some() {
let title = ui.painter().layout_no_wrap("VectorCraft".into(), egui::FontId::proportional(13.5), t.text);
let title_rect = egui::Rect::from_center_size(full.center(), title.size());
// Keep the title centered only while the interactive groups leave room for it.
if title_rect.left() >= menus_end + 8.0 && title_rect.right() <= ui.min_rect().left() - 8.0 {
ui.painter().galley(title_rect.min, title, t.text);
}
}
});
});
if custom {
@@ -153,7 +155,9 @@ pub enum AnchorControls {
/// Which anchor controls show for the selection and the active tool.
pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls {
let Some(st) = app.session.active() else { return AnchorControls::None };
let Some(st) = app.session.active() else {
return AnchorControls::None;
};
if !st.selection.anchors.is_empty() {
return AnchorControls::All;
}
@@ -229,7 +233,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchors = anchor_controls(app);
let label = match &first {
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => tl!("Anchor Point"),
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => {
tl!("Anchor Point")
}
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
if im.link.is_some() {
tl!("Linked File")
@@ -237,7 +243,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
tl!("Embedded")
}
}
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => tl!("Image Tracing"),
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => {
tl!("Image Tracing")
}
_ => tl!(crate::panels::appearance::object_label(app)),
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
@@ -571,11 +579,12 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let more = app.background.jobs.len() - 1;
let label = crate::i18n::msg(&job.label);
let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") };
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
ui.add(egui::Label::new(egui::RichText::new(text).size(11.0).color(t.text)).truncate());
} else if !app.ui.status.is_empty() {
// The message stays English in `ui.status` (agents and tests read it).
ui.label(egui::RichText::new(crate::i18n::msg(&app.ui.status)).size(11.0).color(t.text));
let message = crate::i18n::msg(&app.ui.status);
ui.add(egui::Label::new(egui::RichText::new(message).size(11.0).color(t.text)).truncate());
}
});
});
@@ -1074,3 +1083,161 @@ mod tests {
assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft");
}
}
#[cfg(test)]
mod visual_regressions {
use super::*;
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
input
}
struct NativeMenus;
impl crate::native_menu::Backend for NativeMenus {
fn sync(&mut self, _: &crate::native_menu::MenuBar) {}
fn drain(&mut self) -> Vec<crate::native_menu::Event> {
Vec::new()
}
}
#[test]
fn capture_chrome_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else {
return;
};
std::fs::create_dir_all(&directory).unwrap();
for brightness in theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
let mut ready = false;
let mut harness =
egui_kittest::Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
theme::install_fonts(ui.ctx());
theme::apply(ui.ctx(), brightness);
ready = true;
return;
}
app_bar(app, ui);
status_bar(app, ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(4);
let stem = format!("vectorcraft-chrome-{brightness:?}-{width}-{scale}x");
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
let mut regions = serde_json::Map::new();
for id in ["app_bar", "status_bar"] {
let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect;
regions.insert(id.into(), json!([rect.left(), rect.top(), rect.width(), rect.height()]));
}
let dynamic: Vec<_> = harness
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.job.text.contains(" fps") => {
let r = text.visual_bounding_rect();
Some(json!([r.left(), r.top(), r.width(), r.height()]))
}
_ => None,
})
.collect();
std::fs::write(
directory.join(format!("{stem}.json")),
serde_json::to_vec_pretty(&json!({
"screen": [width, 200.0], "scale": scale, "regions": regions, "native_dynamic_text_regions": dynamic
}))
.unwrap(),
)
.unwrap();
}
}
}
}
#[test]
fn native_title_does_not_overlap_header_controls() {
for brightness in theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, brightness);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| app_bar(&mut app, ui));
output.textures_delta.clear();
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
assert_eq!(ctx.pixels_per_point(), scale);
let texts: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) => Some(text),
_ => None,
})
.collect();
let title = texts.iter().find(|text| text.galley.job.text == "VectorCraft");
if width >= 1280.0 {
assert!(title.is_some(), "the title remains visible when the header has room");
}
if let Some(title) = title {
for text in texts.iter().filter(|text| text.galley.job.text != "VectorCraft") {
assert!(
!title.visual_bounding_rect().intersects(text.visual_bounding_rect()),
"{width}: title overlaps {:?}",
text.galley.job.text
);
}
}
}
}
}
}
#[test]
fn long_status_stays_between_navigation_and_performance_readouts() {
for brightness in theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, brightness);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| status_bar(&mut app, ui));
output.textures_delta.clear();
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
assert_eq!(ctx.pixels_per_point(), scale);
let texts: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) => Some(text),
_ => None,
})
.collect();
let status = texts.iter().find(|text| text.galley.job.text.starts_with("Saved ")).expect("status stays visible");
assert_eq!(status.galley.rows.len(), 1, "status is one line");
assert!(status.galley.elided, "long status retains the full text for the label tooltip");
let bounds = status.visual_bounding_rect();
assert!(bounds.left() >= 0.0 && bounds.right() <= width, "{width}: {bounds:?}");
for text in texts.iter().filter(|text| !text.galley.job.text.starts_with("Saved ")) {
assert!(!bounds.intersects(text.visual_bounding_rect()), "{width}: status overlaps {:?}", text.galley.job.text);
}
}
}
}
}
}
+28 -1
View File
@@ -10,11 +10,16 @@ use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, icons, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: 560.0, ..DialogSpec::FORM };
/// A compact drawer two tools wide, however wide the window (#1018); the dialog frame narrows it
/// further in a narrow window.
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: WIDTH, max_width: Some(WIDTH), ..DialogSpec::FORM };
/// A tool's cell: its icon and name.
const CELL: egui::Vec2 = vec2(176.0, 28.0);
const ICON: f32 = 20.0;
/// The drawer's width: two cells, the gap between them and room for the scroll bar.
const WIDTH: f32 = 2.0 * CELL.x + 24.0;
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
/// Advanced toolbar shows.
@@ -107,4 +112,26 @@ mod tests {
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
}
/// The drawer stays two tools wide in a wide window and fits a narrow one (#1018).
#[test]
fn all_tools_dialog_has_a_bounded_width() {
use egui::{Pos2, RawInput, Rect};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.ui.dialog = Some(Dialog::new("allTools", serde_json::json!({})));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
for width in [1280.0, 430.0] {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0));
for _ in 0..6 {
let mut out = ctx.run_ui(RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
}
let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "allTools")))).expect("drawer is shown");
assert!(rect.width() <= width, "drawer exceeds viewport: {rect:?}");
assert!(rect.width() < 3.0 * CELL.x, "drawer wider than two tools: {rect:?}");
}
}
}
+28 -5
View File
@@ -27,8 +27,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").int
/// `corners` (default: the selected corners), filled in with their kind and radius.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let id = match p.get("id").and_then(Value::as_u64) {
Some(id) => NodeId(id),
let id = match p.get("id") {
Some(v) => NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?),
None => match st.selection.objects[..] {
[id] => id,
_ => return Err("select one path, or give its `id`".into()),
@@ -37,11 +37,15 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let Some(live) = st.doc.node(id).and_then(LiveCorners::of) else {
return Err("Corners edits the corners of a path".into());
};
let corners: BTreeSet<usize> = match p.get("corners").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_u64).filter_map(|k| usize::try_from(k).ok()).filter(|k| live.corner(*k).is_some()).collect(),
let corners: BTreeSet<usize> = match p.get("corners") {
Some(v) => v
.as_array()
.and_then(|a| a.iter().map(|k| usize::try_from(k.as_u64()?).ok()).collect())
.ok_or_else(|| format!("`corners` must be an array of anchor indices, not {v}"))?,
None => live.picked(st.selection.partial(id)),
};
if corners.is_empty() {
// Every corner given is one of the path's: none is left out of the edit.
if corners.is_empty() || !corners.iter().all(|k| live.corner(*k).is_some()) {
return Err("give corners of the path: anchors between two straight sides".into());
}
let (radius, kind) = live.style(&corners);
@@ -149,6 +153,25 @@ mod tests {
v
}
/// Explicit UI-command parameters cannot silently fall back to the selection or drop corners.
#[test]
fn malformed_explicit_corner_targets_never_open_a_partial_edit() {
let (mut app, id) = app();
for params in [
json!({"id": "invalid"}),
json!({"id": -1}),
json!({"id": id, "corners": [0, "bad", 2]}),
json!({"id": id, "corners": [0, 1.5]}),
json!({"id": id, "corners": [0, 9]}),
json!({"id": id, "corners": "bad"}),
] {
assert!(app.run("ui.corners", params.clone()).is_err(), "accepted {params}");
assert!(app.ui.dialog.is_none(), "opened a partial edit for {params}");
}
app.run("ui.corners", json!({"id": id, "corners": [0, 2]})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["corners"], json!([0, 2]));
}
#[test]
fn double_clicking_a_direct_selected_corner_edits_it_alone() {
let (mut app, id) = app();
@@ -353,8 +353,10 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
}
}
if let Some(b) = boards.get_mut(i) {
ui.checkbox(b, name);
if let Some(b) = boards.get_mut(i)
&& crate::widgets::check_name(ui, name, *b)
{
*b ^= true;
}
});
}
@@ -399,10 +401,10 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
crate::panels::asset_export::paint_thumb(ui, st, a, r);
let mut checked = on.contains(&a.id);
if ui.checkbox(&mut checked, a.name.as_str()).changed() {
let checked = on.contains(&a.id);
if crate::widgets::check_name(ui, &a.name, checked) {
on.retain(|id| *id != a.id);
if checked {
if !checked {
on.push(a.id);
}
}
+2 -2
View File
@@ -320,8 +320,8 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String,
crate::widgets::range_field(ui, ("plugin-int", k), x, *min as f64..=*max as f64, "", 0, 100.0).map(|x| json!(x as i64))
}
ParamSpec::Bool { .. } => {
let mut b = cur.as_bool().unwrap_or(false);
ui.checkbox(&mut b, "").changed().then(|| json!(b))
let b = cur.as_bool().unwrap_or(false);
crate::widgets::check(ui, "", b, true).then(|| json!(!b))
}
ParamSpec::Choice { options, .. } => {
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
+25 -3
View File
@@ -54,9 +54,16 @@ fn fields_of(n: &Node) -> serde_json::Map<String, Value> {
/// the current layer), filled in from the first.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let ids: Vec<NodeId> = match (p.get("ids").and_then(Value::as_array), p.get("id").and_then(Value::as_u64)) {
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(NodeId).collect(),
(None, Some(id)) => vec![NodeId(id)],
// Rows given are used as given: a malformed or unknown id fails rather than being skipped or
// the dialog opening on the highlighted rows instead.
let row = |v: &Value| -> Result<NodeId, String> {
let id = NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?);
st.doc.node(id).map(|_| id).ok_or_else(|| "no such layer or object".to_string())
};
let ids: Vec<NodeId> = match (p.get("ids"), p.get("id")) {
(Some(Value::Array(a)), _) => a.iter().map(row).collect::<Result<_, _>>()?,
(Some(v), _) => return Err(format!("`ids` must be an array of object ids, not {v}")),
(None, Some(v)) => vec![row(v)?],
(None, None) => {
let rows = st.highlighted_rows();
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
@@ -252,6 +259,21 @@ mod tests {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
}
/// Explicit object ids must not silently disappear or fall back to the highlighted row.
#[test]
fn invalid_layer_options_targets_do_not_open_a_partial_dialog() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
for params in
[json!({"ids": [id, "invalid"]}), json!({"ids": [id, 999999]}), json!({"ids": "invalid"}), json!({"id": "invalid"}), json!({"id": null})]
{
assert!(app.run("ui.layerOptions", params.clone()).is_err(), "accepted {params}");
assert!(app.ui.dialog.is_none(), "opened partial options for {params}");
}
app.run("ui.layerOptions", json!({"ids": [id]})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["ids"], json!([id]));
}
#[test]
fn layer_options_edit_the_layer_and_ok_is_one_undo_step() {
let mut app = app();
+9 -8
View File
@@ -93,14 +93,15 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
super::form::check(ui, d, "__showAxis", tl!("Show axis"));
ui.add_space(6.0);
ui.horizontal(|ui| {
let mut visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
let response = ui.allocate_ui_with_layout(
let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
let tip = tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.");
let toggled = ui.allocate_ui_with_layout(
vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.checkbox(&mut visible, egui::RichText::new(tl!("Keep visible surfaces only when expanding")).size(12.0))
widgets::check_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip)
},
).inner;
if response.on_hover_text(tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.")).changed() {
d.fields.insert("expandVisibleOnly".into(), json!(visible));
if toggled {
d.fields.insert("expandVisibleOnly".into(), json!(!visible));
}
if reset_dot(ui, "expandVisibleOnly", !visible, "true") {
d.fields.insert("expandVisibleOnly".into(), json!(true));
@@ -111,9 +112,9 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
let mut shade = d.bool("shade");
if ui.checkbox(&mut shade, tl!("Shading")).changed() {
d.fields.insert("shade".into(), json!(shade));
let shade = d.bool("shade");
if widgets::check(ui, tl!("Shading"), shade, true) {
d.fields.insert("shade".into(), json!(!shade));
}
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "shade", !shade, "true") {
+3 -3
View File
@@ -145,9 +145,9 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma
let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null);
if o.ty == "boolean" {
ui.label("");
let mut on = value.as_bool().unwrap_or(false);
if ui.checkbox(&mut on, label.trim_end_matches(':')).on_hover_text(o.description).changed() {
d.fields.insert(o.name.into(), json!(on));
let on = value.as_bool().unwrap_or(false);
if widgets::check_tip(ui, label.trim_end_matches(':'), on, true, o.description) {
d.fields.insert(o.name.into(), json!(!on));
}
ui.end_row();
if o.name == "separateArtboards" {
+8 -8
View File
@@ -202,9 +202,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, &v.name);
let key = value_key(&v.name);
match v.kind {
VariableKind::Text => text(ui, d, &key, 220.0),
VariableKind::Text => {
text(ui, d, &key, 220.0);
}
VariableKind::Visibility => checkbox(ui, d, &key),
};
}
ui.end_row();
}
}
@@ -224,13 +226,11 @@ fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Respo
}
/// A checkbox for `key`, written back as it is toggled.
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response {
let mut b = d.bool(key);
let r = ui.checkbox(&mut b, "");
if r.changed() {
d.fields.insert(key.into(), json!(b));
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
let b = d.bool(key);
if widgets::check(ui, "", b, true) {
d.fields.insert(key.into(), json!(!b));
}
r
}
/// A dropdown over the document's names `options` (shown as they are, never translated) for
+208 -28
View File
@@ -26,7 +26,7 @@ fn column_left_id() -> egui::Id {
/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's
/// (egui temp memory, one frame old). Popped-out panels open left of it.
fn dock_left_id() -> egui::Id {
pub(crate) fn dock_left_id() -> egui::Id {
egui::Id::new("dock-left")
}
@@ -43,7 +43,7 @@ pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
/// The strip along the top of a dock column with its double arrow: » collapses the dock to icons,
/// « expands it. The whole strip is the click target (as on the toolbar). True when clicked.
fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
pub(crate) fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
@@ -66,20 +66,22 @@ fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
/// One icon of the column: a click pops its panel out (or puts it away); dragged out of the
/// column (`column`), the panel floats.
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, column: Rect) {
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, _column: Rect) {
let open = app.ui.open_panel.as_deref() == Some(id);
// Its own id: the icons below it move up when it floats, and mustn't take over its drag.
let (_, rect) = ui.allocate_space(vec2(30.0, 30.0));
let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label)));
if resp.clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
if resp.dragged() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !column.contains(p)) {
floating::tear(app, ui.ctx(), &[id], id, floating::strip_grab(ui.ctx(), resp.rect.left()));
}
crate::panel_docking::legacy_tab(app, ui, id, &resp);
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if crate::panel_docking::show(app, ui) {
return;
}
let t = Tokens::get(ui.ctx());
let shown = floating::shown_tab(&app.ui);
// Main tabbed group, unless it is collapsed to icons or all its panels float.
@@ -173,36 +175,36 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let out = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
let outside = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
// What a drag out of the strip floats: the panels, the one shown, where its tab began.
let mut tear: Option<(Vec<&'static str>, &'static str, f32)> = None;
let mut x = strip.left();
for tab in DockTab::ALL {
let (id, label, _) = tab.info();
if floating::group_of(&app.ui, id).is_some() {
continue;
}
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if tab == active { t.text_strong } else { t.text_dim });
let r = Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click_and_drag());
if tab == active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
let tabs: Vec<_> = floating::docked_tabs(&app.ui).collect();
let names: Vec<_> = tabs
.iter()
.map(|tab| {
let (id, label, _) = tab.info();
(id, label)
})
.collect();
let selected = tabs.iter().position(|&tab| tab == active).unwrap_or(0);
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let area = Rect::from_min_max(strip.min, egui::pos2((menu.left() - 4.0).max(strip.left()), strip.bottom() - 1.0));
let out = panel_tabs(ui, "docktab", area, &names, selected, theme::semibold(12.5), true);
for (i, resp) in out.responses {
let Some(&tab) = tabs.get(i) else { continue };
let id = tab.info().0;
if resp.clicked() {
app.ui.dock_tab = tab;
} else if resp.dragged() && out {
tear = Some((vec![id], id, r.left()));
}
x = r.right();
crate::panel_docking::legacy_tab(app, ui, id, &resp);
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(egui::pos2(x, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() {
app.ui.dock_tab = tab;
}
let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag());
if resp.dragged() && out {
if resp.dragged() && outside {
tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left()));
}
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
@@ -215,6 +217,92 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
floating::group_of(&app.ui, active.info().0).is_some()
}
/// App styling and overflow menus around the shared tab fitting/interaction components. Docking
/// and floating groups consume the responses themselves, preserving their different drag rules.
pub(crate) struct PanelTabs {
pub responses: Vec<(usize, egui::Response)>,
pub picked: Option<usize>,
pub right: f32,
}
pub(crate) fn panel_tabs(
ui: &mut Ui,
id: &str,
area: Rect,
tabs: &[(&str, &str)],
selected: usize,
font: egui::FontId,
separators: bool,
) -> PanelTabs {
let t = Tokens::get(ui.ctx());
let labels: Vec<_> = tabs.iter().map(|&(_, label)| tl!(label)).collect();
let galleys: Vec<_> = labels
.iter()
.enumerate()
.map(|(i, label)| ui.painter().layout_no_wrap((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim }))
.collect();
let natural: Vec<_> = galleys.iter().map(|g| g.size().x + 24.0).collect();
let fit = craft_ui::tabs::fit(&natural, selected, area.width(), 40.0, 18.0);
let mut out = PanelTabs { responses: Vec::with_capacity(fit.shown.len()), picked: None, right: area.left() };
for (i, width) in fit.shown {
let Some((key, _)) = tabs.get(i) else { continue };
let Some(label) = labels.get(i) else { continue };
let Some(galley) = galleys.get(i) else { continue };
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(width, area.height()));
let shrunk = natural.get(i).is_some_and(|natural| *natural > width + 0.5);
let galley = if shrunk {
let mut job =
egui::text::LayoutJob::simple_singleline((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim });
job.wrap =
egui::text::TextWrapping { max_width: (width - 8.0).max(1.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
ui.painter().layout_job(job)
} else {
galley.clone()
};
let resp = craft_ui::tabs::Tab::new(ui.id().with((id, key)), label, i == selected)
.sense(Sense::click_and_drag())
.focus_stroke(Stroke::new(1.0, t.accent))
.show_at(ui, rect, |ui, _| {
if i == selected {
ui.painter().rect_filled(rect, 0.0, t.panel);
}
ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).galley(rect.center() - galley.size() / 2.0, galley, t.text);
if separators {
ui.painter().line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0, t.border));
}
});
out.responses.push((i, if shrunk { resp.on_hover_text(*label) } else { resp }));
out.right = rect.right();
}
if !fit.overflow.is_empty() && fit.overflow_width > 0.0 {
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(fit.overflow_width, area.height()));
let resp = ui.interact(rect, ui.id().with((id, "overflow")), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(rect, 0.0, t.hover);
}
if resp.has_focus() && rect.width() > 4.0 {
ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
icons::paint(ui, "chevrons-right", Rect::from_center_size(rect.center(), vec2(12.0, 12.0)).intersect(rect), t.text);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panels")));
let resp = resp.on_hover_text(tl!("Panels"));
egui::Popup::menu(&resp).show(|ui| {
widgets::menu_scroll(ui, |ui| {
for i in fit.overflow {
if let Some(label) = labels.get(i)
&& ui.button(*label).clicked()
{
out.picked = Some(i);
ui.close();
}
}
});
});
out.right = rect.right();
}
out
}
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
/// while the dock is collapsed. Dragging its title floats it.
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
@@ -337,6 +425,98 @@ mod tests {
use super::*;
use crate::toolbar::tests::{wheel, widget_rects};
#[test]
fn tab_headers_fit_narrow_widths_keep_the_selected_tab_and_clip_labels() {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, theme::Brightness::MediumDark);
let names = [("one", "Long panel name"), ("two", "Another long panel name"), ("three", "Layers")];
for width in [0.0, 1.0, 5.0, 18.0, 19.0, 80.0, 180.0, 300.0] {
for selected in 0..names.len() {
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(width, 32.0));
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let out = panel_tabs(ui, "fit-test", area, &names, selected, theme::semibold(12.5), true);
assert!(out.responses.iter().any(|(i, _)| *i == selected), "selected index {selected} stays on the strip");
assert!(out.right <= area.right() + 0.001, "{width}: strip ends at {}", out.right);
for (_, response) in &out.responses {
if response.rect.is_positive() {
assert!(area.contains_rect(response.rect), "{width}: {:?}", response.rect);
}
}
});
output.textures_delta.clear();
for shape in output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Text(_))) {
assert!(shape.clip_rect.right() <= area.right() + 0.001, "{width}: a label is clipped to its tab");
}
if width > 0.0 && width < 18.0 {
assert!(
output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(rect) if rect.brush.is_some())),
"the actual overflow SVG was painted"
);
}
}
}
}
#[test]
fn overflow_menu_can_activate_a_hidden_panel() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let ctx = egui::Context::default();
ctx.enable_accesskit();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, theme::Brightness::MediumDark);
let names = [("properties", "Properties"), ("layers", "Layers"), ("libraries", "Libraries")];
let mut selected = 0;
let mut time = 0.0;
let mut frame = |events| {
time += 0.1;
let input = egui::RawInput {
events,
time: Some(time),
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 300.0))),
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| {
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(90.0, 32.0));
let out = panel_tabs(ui, "overflow-test", area, &names, selected, theme::semibold(12.5), true);
if let Some(picked) = out.picked {
selected = picked;
}
});
output.textures_delta.clear();
output
};
let button = |output: &egui::FullOutput, label: &str| {
let bounds = output
.platform_output
.accesskit_update
.as_ref()
.unwrap()
.nodes
.iter()
.find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button)
.unwrap()
.1
.bounds()
.unwrap();
egui::pos2((bounds.x0 + bounds.x1) as f32 * 0.5, (bounds.y0 + bounds.y1) as f32 * 0.5)
};
let press =
|pos, pressed| egui::Event::PointerButton { pos, pressed, button: egui::PointerButton::Primary, modifiers: egui::Modifiers::NONE };
frame(vec![]);
let output = frame(vec![]);
let overflow = button(&output, "Panels");
frame(vec![egui::Event::PointerMoved(overflow), press(overflow, true)]);
frame(vec![press(overflow, false)]);
let output = frame(vec![]);
let libraries = button(&output, "Libraries");
frame(vec![egui::Event::PointerMoved(libraries), press(libraries, true)]);
frame(vec![press(libraries, false)]);
assert_eq!(selected, 2);
}
#[test]
fn every_flyout_stays_left_of_the_icon_column() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+283 -71
View File
@@ -50,7 +50,7 @@ const TRAVEL: f32 = 4.0;
/// What a move carries.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Moving {
pub(crate) enum Moving {
/// The floating group holding this panel.
Panel(&'static str),
/// The floating Tools panel.
@@ -59,16 +59,17 @@ enum Moving {
/// A move in progress: what moves, where the pointer holds it from its top-left corner, and where
/// the press began.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
struct Move {
what: Moving,
grab: Vec2,
from: Pos2,
original: UiState,
}
/// Where a moved group or Tools panel lands.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Drop {
pub(crate) enum Drop {
/// Back in the dock (the Tools panel: at the window's left edge).
Dock,
/// Stacked with the floating group holding this panel.
@@ -85,6 +86,15 @@ fn move_id() -> Id {
Id::new("floating-move")
}
fn suppressed_id() -> Id {
move_id().with("suppressed-until-primary-press")
}
fn gesture_allowed(ctx: &egui::Context) -> bool {
ctx.input(|input| input.focused && !input.key_pressed(egui::Key::Escape))
&& !ctx.data(|data| data.get_temp::<bool>(suppressed_id()).unwrap_or(false))
}
/// The dock (tabbed group and icon column) as last drawn: a group dropped there docks.
pub(crate) fn dock_rect_id() -> Id {
Id::new("floating-dock-rect")
@@ -266,9 +276,12 @@ pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 {
}
/// Start moving `what` with the pointer, held `grab` from its top-left corner.
fn start(ctx: &egui::Context, what: Moving, grab: Vec2) {
fn start(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, grab: Vec2) {
if !gesture_allowed(ctx) {
return;
}
let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return };
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from }));
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from, original: app.ui.clone() }));
}
/// Where the pointer holds a group torn off by a press `left` points right of where the group's
@@ -281,30 +294,49 @@ pub(crate) fn strip_grab(ctx: &egui::Context, left: f32) -> Vec2 {
/// Float `ids` under the pointer, held `grab` from the new group's top-left corner, and carry on
/// moving them with the pointer (a tab or group dragged out of the dock or out of its group).
pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'static str], active: &'static str, grab: Vec2) {
// A restored popout may retain its drag response in the cancellation frame. Do not
// immediately tear it out again after track restored the gesture's original layout.
if !gesture_allowed(ctx) {
return;
}
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
float(&mut app.ui, ids, active, at - grab);
start(ctx, Moving::Panel(active), grab);
let original = app.ui.clone();
let pos = at - grab;
if let Err(error) = app.run("window.panel.float", json!({"panel":active,"group":ids.len() > 1,"x":pos.x,"y":pos.y})) {
app.ui.status = error;
return;
}
start(app, ctx, Moving::Panel(active), grab);
ctx.data_mut(|data| {
if let Some(mut drag) = data.get_temp::<Move>(move_id()) {
drag.original = original;
data.insert_temp(move_id(), drag);
}
});
}
/// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging
/// it out of the panel floats the panel under the pointer; floating at `floating`, a drag moves it.
pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: &egui::Response, bounds: Rect, floating: Option<Pos2>) {
if !gesture_allowed(ctx) {
return;
}
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
match floating {
Some(pos) if title.drag_started() => start(ctx, Moving::Tools, at - pos),
Some(pos) if title.drag_started() => start(app, ctx, Moving::Tools, at - pos),
None if title.dragged() && !bounds.contains(at) => {
// Held where the press grabbed the title bar.
let grab = ctx.input(|i| i.pointer.press_origin()).map_or(vec2(10.0, 7.0), |p| p - bounds.min);
let p = at - grab;
start(app, ctx, Moving::Tools, grab);
app.ui.toolbar_pos = Some([p.x, p.y]);
start(ctx, Moving::Tools, grab);
}
_ => {}
}
}
/// Where the pointer at `at` would drop `what`, and the zone that lights up for it.
fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
let zone = |id: Id| ctx.data(|d| d.get_temp::<Rect>(id)).filter(|r| r.contains(at));
let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) };
let own = group_of(&app.ui, id);
@@ -327,7 +359,39 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
let columns = ctx.data(|d| d.get_temp::<Vec<Rect>>(columns_id())).unwrap_or_default();
for (i, g) in app.ui.floating_panels.iter().enumerate() {
let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue };
let Some(mut r) = ctx.data(|d| d.get_temp::<Rect>(group_rect_id(first))) else { continue };
let Some(mut r) = ctx
.data(|d| d.get_temp::<Rect>(group_rect_id(first)))
.or_else(|| ctx.memory(|m| m.area_rect(Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())))))
else {
continue;
};
// A visible window owns hits inside its body. Old rectangles and lower windows
// must not capture a drop through another panel or popup; edge insertion remains
// available just outside the window. Dock columns reserve their own panel space.
if g.docked.is_none() && r.contains(at) {
let area = if app.ui.docking.is_some() && !crate::panel_docking::decorated(g) {
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
} else {
let lead = set_of(&app.ui, i).first().and_then(|&index| app.ui.floating_panels.get(index)).and_then(|group| group.panels.first());
area_id(lead.map_or(first, String::as_str))
};
let moving_areas: Vec<Id> = moving
.iter()
.filter_map(|&index| app.ui.floating_panels.get(index).and_then(|group| group.panels.first()))
.flat_map(|panel| [area_id(panel), Id::new("vectorcraft-panel-docking").with(("floating", panel))])
.collect();
let owner = ctx.memory(|memory| {
let layers: Vec<_> = memory.layer_ids().collect();
layers.into_iter().rev().find(|layer| {
!moving_areas.contains(&layer.id)
&& memory.areas().is_visible(layer)
&& memory.area_rect(layer.id).is_some_and(|rect| rect.contains(at))
})
});
if owner.is_some_and(|layer| layer.id != area) {
continue;
}
}
// In a dock column that scrolls, only the part in view: a group scrolled away takes nothing.
if let Some(col) = g.docked.and_then(|d| columns.get(usize::try_from(d).ok()?.checked_sub(1)?)) {
r = r.intersect(*col);
@@ -368,7 +432,7 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
}
/// Light up a drop zone: over the docked panels, under the floating ones (the moved one too).
fn highlight(ctx: &egui::Context, zone: Rect) {
pub(crate) fn highlight(ctx: &egui::Context, zone: Rect) {
let t = Tokens::get(ctx);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::new("floating-drop-zone")));
painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
@@ -388,10 +452,43 @@ fn insertion_line(ctx: &egui::Context, line: Rect) {
painter.rect_filled(line, CornerRadius::same(2), t.accent);
}
pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) {
match target {
Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect),
Drop::Stack(_) => tab_zone(ctx, rect),
Drop::Dock => highlight(ctx, rect),
}
}
/// Carry a move on (at the start of a frame, before the panels are drawn): the group or Tools panel
/// follows the pointer and the zone it would drop on lights up; the release drops it there.
pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
if ctx.input(|input| input.pointer.primary_pressed()) {
ctx.data_mut(|data| data.remove::<bool>(suppressed_id()));
}
let before = app.ui.clone();
let Some(m) = ctx.data(|d| d.get_temp::<Move>(move_id())) else { return };
if m.original.docking_generation != app.ui.docking_generation {
ctx.data_mut(|data| {
data.remove::<Move>(move_id());
data.insert_temp(suppressed_id(), true);
});
return;
}
if ctx.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
app.ui.docking = m.original.docking;
app.ui.docking_hidden = m.original.docking_hidden;
app.ui.docking_icons = m.original.docking_icons;
app.ui.floating_panels = m.original.floating_panels;
app.ui.open_panel = m.original.open_panel;
app.ui.dock_tab = m.original.dock_tab;
app.ui.toolbar_pos = m.original.toolbar_pos;
ctx.data_mut(|data| {
data.remove::<Move>(move_id());
data.insert_temp(suppressed_id(), true);
});
return;
}
let at = ctx.input(|i| i.pointer.interact_pos());
let target = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_target(app, ctx, m.what, at));
if ctx.input(|i| i.pointer.primary_down()) {
@@ -409,6 +506,9 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
Moving::Tools => {}
}
}
if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
app.ui.status = error;
}
match target {
Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line),
Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs),
@@ -441,6 +541,9 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
Drop::Above(onto) => attach(&mut app.ui, &set, onto, false),
Drop::Column(slot) => dock_column(&mut app.ui, &set, slot),
}
if let Err(error) = crate::panel_docking::reconcile_native(app, before) {
app.ui.status = error;
}
}
/// What a press on a floating or docked group did.
@@ -490,11 +593,12 @@ fn docked(ui: &UiState, set: &[usize]) -> bool {
/// the dock (Tab, Presentation Mode). Sets docked as dock columns are drawn by [`docked_columns`].
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 {
crate::panel_docking::finish_native_drop(app, ctx);
return;
}
let mut action = None;
for gi in 0..app.ui.floating_panels.len() {
if leads(&app.ui, gi) {
if leads(&app.ui, gi) && (app.ui.docking.is_none() || app.ui.floating_panels.get(gi).is_some_and(crate::panel_docking::decorated)) {
let set = set_of(&app.ui, gi);
if !docked(&app.ui, &set) {
show_set(app, ctx, &set, &mut action);
@@ -502,10 +606,18 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
apply(app, ctx, action);
crate::panel_docking::finish_native_drop(app, ctx);
}
/// Carry out what a press on a floating or docked group did.
fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>) {
if action.is_none() {
return;
}
if matches!(action, Some(Action::Move(..) | Action::Detach(..) | Action::Tear(..) | Action::Undock(..))) && !gesture_allowed(ctx) {
return;
}
let before = app.ui.clone();
match action {
Some(Action::Activate(gi, k)) => {
if let Some(g) = app.ui.floating_panels.get_mut(gi)
@@ -531,7 +643,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>)
set_pos(&mut app.ui, gi, [pos.x, pos.y]);
}
if let Some(at) = ctx.input(|i| i.pointer.interact_pos()) {
start(ctx, Moving::Panel(id), at - pos);
start(app, ctx, Moving::Panel(id), at - pos);
}
}
Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left),
@@ -547,10 +659,19 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>)
}
}
FloatingPanels::tidy(&mut app.ui.floating_panels);
start(ctx, Moving::Panel(id), grab);
start(app, ctx, Moving::Panel(id), grab);
}
None => {}
}
if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
app.ui.status = error;
}
ctx.data_mut(|data| {
if let Some(mut drag) = data.get_temp::<Move>(move_id()) {
drag.original = before;
data.insert_temp(move_id(), drag);
}
});
}
/// Take group `gi` out of its set (or its dock column) under the pointer, its tab strip having
@@ -566,7 +687,7 @@ fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) {
g.pos = [p.x, p.y];
}
FloatingPanels::tidy(&mut app.ui.floating_panels);
start(ctx, Moving::Panel(id), grab);
start(app, ctx, Moving::Panel(id), grab);
}
/// Put the groups `set` in the dock as a new column at `slot` (1: next to the icon column, counting
@@ -655,7 +776,13 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec
});
rects.push(panel.response.rect);
}
ui.ctx().data_mut(|d| d.insert_temp(columns_id(), rects.clone()));
ui.ctx().data_mut(|d| {
let icons = d.get_temp::<Rect>(icons_rect_id());
if let Some(left) = icons.map(|r| rects.iter().map(|r| r.left()).fold(r.left(), f32::min)) {
d.insert_temp(dock::dock_left_id(), left);
}
d.insert_temp(columns_id(), rects.clone());
});
let ctx = ui.ctx().clone();
apply(app, &ctx, action);
rects
@@ -753,18 +880,13 @@ fn draw_groups(
let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
let lone = g.ids.len() == 1;
let mut left = strip.left();
for (j, &id) in g.ids.iter().enumerate() {
let label = panel(id).map_or(id, |(_, label)| label);
let color = if id == g.active { t.text_strong } else { t.text_dim };
let galley = ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color);
let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP));
left = r.right();
let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag());
if id == g.active && !g.collapsed {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom()));
let names: Vec<_> = g.ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect();
let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0);
let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false);
for (j, resp) in out.responses {
let Some(&id) = g.ids.get(j) else { continue };
if resp.double_clicked() {
*action = Some(Action::Collapse(g.gi));
} else if resp.clicked() {
@@ -775,11 +897,13 @@ fn draw_groups(
None => Action::Detach(g.gi, strip.left()),
});
} else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) {
*action = Some(Action::Tear(id, r.left()));
*action = Some(Action::Tear(id, resp.rect.left()));
}
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
if let Some(j) = out.picked {
*action = Some(Action::Activate(g.gi, j));
}
let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag());
if resp.drag_started() {
@@ -1003,7 +1127,9 @@ mod tests {
fn frame(&mut self, events: Vec<Event>) {
self.time += 0.05;
let screen = Rect::from_min_size(Pos2::ZERO, self.screen);
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() };
let focused =
events.iter().rev().find_map(|event| if let Event::WindowFocused(focused) = event { Some(*focused) } else { None }).unwrap_or(true);
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), focused, events, ..Default::default() };
let app = &mut self.app;
self.ctx
.run_ui(raw, |ui| {
@@ -1050,11 +1176,22 @@ mod tests {
/// The floating group whose first panel is `first`, as drawn.
fn group(&self, first: &str) -> Rect {
self.ctx.memory(|m| m.area_rect(area_id(first))).unwrap()
let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated);
let id = if self.app.ui.docking.is_some() && !native {
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
} else {
area_id(first)
};
self.ctx.memory(|m| m.area_rect(id)).unwrap()
}
/// A point on a floating group's title bar (left of its ×).
fn title(&self, first: &str) -> Pos2 {
let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated);
if self.app.ui.docking.is_some() && !native {
let rect = self.group(first);
return rect.left_top() + vec2(6.0, 16.0);
}
let r = self.group(first);
pos2(r.left() + 30.0, r.top() + 1.0 + TITLE / 2.0)
}
@@ -1068,15 +1205,29 @@ mod tests {
r
}
fn shared_tab(&self, panel: &str) -> Rect {
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect
}
/// The dock's tabs, left to right.
fn dock_tabs(&self) -> Vec<Rect> {
if let Some(layout) = &self.app.ui.docking {
let root = craft_ui::docking::Layout { root: layout.root.clone(), floating: Vec::new() };
return root
.panels()
.into_iter()
.filter_map(|panel| {
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect)
})
.collect();
}
let dock = self.temp_rect(dock_rect_id());
self.widgets(|r| r.height() == 32.0 && dock.contains_rect(r))
}
/// The icon column's panel icons, top to bottom.
fn icons(&self) -> Vec<Rect> {
let dock = self.temp_rect(dock_rect_id());
let dock = self.temp_rect(if self.app.ui.docking.is_some() { icons_rect_id() } else { dock_rect_id() });
self.widgets(|r| r.size() == vec2(30.0, 30.0) && dock.contains_rect(r))
}
@@ -1089,30 +1240,19 @@ mod tests {
fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() {
let mut h = Harness::new();
let tabs = h.dock_tabs();
assert_eq!(tabs.len(), 3, "Properties, Layers, Libraries: {tabs:?}");
assert_eq!(tabs.len(), 3);
let to = pos2(600.0, 400.0);
h.drag(tabs[1].center(), to);
assert_eq!(h.groups(), [["layers"]], "Layers floats");
assert!(h.group("layers").contains(to), "under the pointer: {:?}", h.group("layers"));
assert_eq!(h.dock_tabs().len(), 2, "its tab left the dock");
assert_eq!(h.groups(), [["layers"]]);
assert!(h.group("layers").contains(to));
assert_eq!(h.dock_tabs().len(), 2);
assert_eq!(h.app.ui.dock_tab, DockTab::Properties);
// A click on its title bar moves nothing, even over the dock.
let dock = h.temp_rect(dock_rect_id());
h.app.run("window.panel.float", json!({"panel": "layers", "x": dock.left() + 10.0, "y": 300})).unwrap();
h.settle();
h.click(h.title("layers"));
assert_eq!(h.groups(), [["layers"]], "a click doesn't dock it");
// Dragged by its title bar onto the dock, the dock lights up, and the release docks it.
h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 300})).unwrap();
h.settle();
let over = dock.center();
h.hold(h.title("layers"), over);
assert!(h.group("layers").contains(over), "the group follows the pointer");
let target = drop_target(&h.app, &h.ctx, Moving::Panel("layers"), over);
assert_eq!(target, Some((Drop::Dock, dock)), "the dock is the drop zone, lit whole: its tab comes back");
h.release(over);
assert!(h.groups().is_empty(), "docked: {:?}", h.groups());
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers), "back in the dock, shown");
assert_eq!(h.groups(), [["layers"]], "a click only activates the floating tab");
let over = h.shared_tab("properties").center();
h.drag(h.title("layers"), over);
assert!(h.groups().is_empty());
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers));
}
#[test]
@@ -1127,7 +1267,9 @@ mod tests {
assert!(column.width() < 40.0, "only the icon column is left: {column:?}");
// Its × puts them back.
let g = h.group("properties");
h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0));
let close =
if h.app.ui.docking.is_some() { pos2(g.right() - 36.0, g.top() + 16.0) } else { pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0) };
h.click(close);
assert!(h.groups().is_empty());
assert_eq!(h.dock_tabs().len(), 3);
}
@@ -1143,32 +1285,40 @@ mod tests {
assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::<usize>(), "every group's icons are drawn");
h.drag(icons[2].center(), pos2(500.0, 250.0));
assert_eq!(h.groups(), [["swatches"]]);
assert_eq!(h.icons().len(), count - 1, "its icon left the column");
h.drag(h.icons()[0].center(), pos2(500.0, 650.0));
assert_eq!(h.icons().len(), count - 1);
let separate = pos2(900.0, 650.0);
assert!(!h.group("swatches").contains(separate));
h.drag(h.icons()[0].center(), separate);
assert_eq!(h.groups(), [["swatches"], ["color"]]);
// Color's title bar dropped on the Swatches group's tabs stacks them.
let onto = h.group("swatches").left_top() + vec2(150.0, 1.0 + TITLE + STRIP / 2.0);
h.hold(h.title("color"), onto);
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), onto).map(|t| t.0), Some(Drop::Stack("swatches")));
h.release(onto);
let onto = h.shared_tab("swatches").right_center() - vec2(3.0, 0.0);
h.drag(h.title("color"), onto);
assert_eq!(h.groups(), [["swatches", "color"]]);
assert_eq!(h.app.ui.floating_panels[0].active, 1, "showing the panel dropped on it");
// A click shows a tab; a tab dragged out of the strip floats on its own again.
let g = h.group("swatches");
let tabs = h.widgets(|r| r.height() == STRIP && g.contains_rect(r));
assert_eq!(tabs.len(), 3, "two tabs and the strip right of them: {tabs:?}");
h.click(tabs[0].center());
assert_eq!(h.app.ui.floating_panels[0].active, 1);
h.click(h.shared_tab("swatches").center());
assert_eq!(h.app.ui.floating_panels[0].active, 0);
let to = pos2(900.0, 500.0);
h.drag(tabs[1].center(), to);
h.drag(h.shared_tab("color").center(), to);
assert_eq!(h.groups(), [["swatches"], ["color"]]);
assert!(h.group("color").contains(to));
// Window › Color shows it where it floats; Dock Panel puts it back.
assert_eq!(h.app.run("window.panel", json!({"panel": "Color"})).unwrap(), json!({"floating": ["color"]}));
assert_eq!(crate::menus::checked(&h.app, "window.panel", &json!({"panel": "color"})), Some(true));
h.app.run("window.panel.dock", json!({"panel": "color"})).unwrap();
h.settle();
assert!(h.groups().iter().all(|group| !group.contains(&"color")));
assert_eq!(h.icons().len(), count - 1, "Color is back in the column");
assert_eq!(h.app.ui.open_panel.as_deref(), Some("color"));
}
#[test]
fn an_icon_panel_can_be_dropped_into_another_docked_tab_group() {
let mut h = Harness::new();
h.drag(h.icons()[0].center(), pos2(500.0, 400.0));
assert_eq!(h.groups(), [["color"]]);
h.drag(h.shared_tab("color").center(), h.shared_tab("layers").center());
assert!(h.groups().is_empty());
assert!(h.app.ui.docking.as_ref().unwrap().contains(&"color".into()));
assert!(h.app.ui.open_panel.is_none(), "the actual shared group owns Color");
assert_eq!(h.dock_tabs().len(), 4);
}
#[test]
@@ -1204,6 +1354,68 @@ mod tests {
assert!(h.groups().is_empty());
}
#[test]
fn popped_out_title_drag_cancels_on_escape_focus_loss_and_workspace_change() {
for reason in ["escape", "focus", "workspace"] {
let mut h = Harness::new();
h.app.run("window.panel", json!({"panel":"swatches"})).unwrap();
h.settle();
let original = h.app.ui.clone();
let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap();
let to = pos2(500.0, 400.0);
h.hold(flyout.left_top() + vec2(20.0, 13.0), to);
assert!(h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"swatches".into())));
match reason {
"escape" => h.frame(vec![Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Default::default(),
}]),
"focus" => h.frame(vec![Event::WindowFocused(false)]),
_ => {
crate::workspaces::apply(&mut h.app.ui, &crate::workspaces::Workspace::default());
h.frame(vec![Event::PointerMoved(pos2(650.0, 450.0))]);
}
}
assert!(h.ctx.data(|d| d.get_temp::<Move>(move_id())).is_none(), "{reason}: stale gesture removed");
assert!(h.app.ui.docking.is_none(), "{reason}: original/selected workspace retained");
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: no stale floating group");
if reason != "workspace" {
assert_eq!(h.app.ui.open_panel, original.open_panel);
}
if reason == "focus" {
h.frame(vec![Event::WindowFocused(true)]);
}
for step in 1..=3 {
h.frame(vec![Event::PointerMoved(to + vec2(15.0 * step as f32, 8.0))]);
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: canceled drag stays canceled on held frame {step}");
}
h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: true, modifiers: Default::default() }]);
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: secondary press does not restart primary gesture");
h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: false, modifiers: Default::default() }]);
h.release(to);
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: release does not restart canceled gesture");
}
}
#[test]
fn docked_tools_drag_cancellation_restores_the_docked_origin() {
let mut h = Harness::new();
let zone = h.temp_rect(tools_zone_id());
let title = zone.left_top() + vec2(30.0, 7.0);
let to = pos2(400.0, 300.0);
h.hold(title, to);
assert!(h.app.ui.toolbar_pos.is_some());
h.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
assert_eq!(h.app.ui.toolbar_pos, None, "cancel restores docked Tools origin");
h.frame(vec![Event::PointerMoved(to + vec2(30.0, 20.0))]);
assert_eq!(h.app.ui.toolbar_pos, None);
h.release(to);
assert_eq!(h.app.ui.toolbar_pos, None);
}
#[test]
fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() {
let mut h = Harness::new();
+8
View File
@@ -695,6 +695,14 @@
Ocean Ripple… Vlnky na hladině…
Rename Data Set Přejmenovat sadu dat
Rename Variable Přejmenovat proměnnou
# Shared panel docking
Activate Panel Aktivovat panel
Change Panel Layout Změnit rozložení panelů
Close Panel Zavřít panel
Move Panel Přesunout panel
Group with Seskupit s
Resize panels Změnit velikost panelů
Resize panel window Změnit velikost okna panelu
# Interactive Revolve controls.
Lighting Osvětlení
Can't render this file because it contains an unexpected character in line 2 and column 73.
+32
View File
@@ -2000,6 +2000,7 @@
Proof Colors Farbproof
Proof Setup Proof einrichten
Properties Eigenschaften
Proportional Metrics Proportionale Metrik
Proportional Oldstyle Proportionale Mediävalziffern
Pucker & Bloat… Zusammenziehen und aufblasen…
Pucker Tool Zusammenziehen-Werkzeug
@@ -3320,6 +3321,7 @@ theme Appearance Erscheinungsbild
@msg `color` must be a colour `color` muss eine Farbe sein
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` muss ein Befehl sein, dessen Menüeintrag ein Dialogfeld öffnet (siehe ui.menuDialog)
@msg `freeform.points` must be an array `freeform.points` muss ein Array sein
@msg `freeform.lines` must be an array `freeform.lines` muss ein Array sein
@msg `gradient` must be an object `gradient` muss ein Objekt sein
@msg `library` is a library id or name (see {list}) `library` ist die ID oder der Name einer Bibliothek (siehe {list})
@msg `mode` applies to freeform gradients `mode` gilt für Freiformverläufe
@@ -3421,6 +3423,7 @@ theme Appearance Erscheinungsbild
@msg empty path leerer Pfad
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) die Dateiversion {0} ist neuer, als dieses VectorCraft unterstützt ({VERSION})
@msg freeform point {i} needs `at` [x, y] and a valid `color` Freiformpunkt {i} braucht `at` [x, y] und eine gültige `color`
@msg freeform line {i} must be an array of point indices Freiformlinie {i} muss ein Array von Punktindizes sein
@msg give both `start` and `end` [x, y] (or neither) geben Sie sowohl `start` als auch `end` [x, y] an (oder keines von beiden)
@msg give corners of the path: anchors between two straight sides geben Sie Ecken des Pfads an: Ankerpunkte zwischen zwei geraden Seiten
@msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an
@@ -3851,6 +3854,7 @@ theme Appearance Erscheinungsbild
ICF Bottom/Left ICF unten/links
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` ist der Schrittverweis {r}, den nur `run` und `command_batch` auflösen
@msg `ids` must be an array of object ids, not {v} `ids` muss ein Array von Objekt-IDs sein, nicht {v}
@msg `corners` must be an array of anchor indices, not {v} `corners` muss ein Array von Ankerpunkt-Indizes sein, nicht {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: Das Ergebnis von Schritt {i} hat keinen solchen Wert (schreiben Sie $$ für ein literales $)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: Vor diesem Schritt gibt es keinen Schritt {i} (Schritte zählen ab 0; schreiben Sie $$ für ein literales $)
@msg object ids are non-negative integers, not {v} Objekt-IDs sind nicht negative ganze Zahlen, nicht {v}
@@ -4003,6 +4007,31 @@ theme Appearance Erscheinungsbild
Define a variable first Definieren Sie zuerst eine Variable
Text Text
Visibility Sichtbarkeit
Activate Panel Bedienfeld aktivieren
Change Panel Layout Bedienfeldanordnung ändern
Close Panel Bedienfeld schließen
Move Panel Bedienfeld verschieben
Group with Gruppieren mit
Resize panel window Bedienfeldfenster skalieren
Resize panels Bedienfelder skalieren
@msg Invalid panel layout; restored the default dock Ungültige Bedienfeldanordnung; Standarddock wiederhergestellt
@msg action is required Eine Aktion ist erforderlich
@msg anchor must name a registered panel Der Anker muss ein registriertes Bedienfeld benennen
@msg before must name a panel before muss ein Bedienfeld benennen
@msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad
@msg group must be true or false group muss true oder false sein
@msg move requires a destination panel Das Verschieben erfordert ein Zielbedienfeld
@msg move requires an existing destination panel Das Verschieben erfordert ein vorhandenes Zielbedienfeld
@msg onto must name a floating panel onto muss ein schwebendes Bedienfeld benennen
@msg panel group is empty Die Bedienfeldgruppe ist leer
@msg panel must name a registered panel panel muss ein registriertes Bedienfeld benennen
@msg saved panel layout is invalid; reset the workspace first Die gespeicherte Bedienfeldanordnung ist ungültig; zuerst den Arbeitsbereich zurücksetzen
@msg x and y must be provided together x und y müssen zusammen angegeben werden
@msg zone must be a string zone muss eine Zeichenfolge sein
@msg zone must be tab, center, left, right, top or bottom zone muss tab, center, left, right, top oder bottom sein
@msg {key} must be a finite coordinate {key} muss eine endliche Koordinate sein
@msg invalid restored panel group Ungültige wiederhergestellte Bedienfeldgruppe
# 3D and Materials › Revolve: its options dialog and gizmos.
Ambient Umgebungslicht
Axis side Achsenseite
@@ -4056,3 +4085,6 @@ theme Appearance Erscheinungsbild
Stroke Pressure Strichdruck
Right Diagonal Diagonal nach rechts
Left Diagonal Diagonal nach links
@msg invalid native panel set Ungültiger nativer Bedienfeldsatz
@msg missing panel layout Bedienfeldanordnung fehlt
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
Can't render this file because it contains an unexpected character in line 2 and column 73.
+32
View File
@@ -2051,6 +2051,7 @@
Proof Colors Colores de prueba
Proof Setup Ajuste de prueba
Properties Propiedades
Proportional Metrics Métrica proporcional
Proportional Oldstyle Estilo antiguo proporcional
Pucker & Bloat… Fruncir y engordar…
Pucker Tool Herramienta Fruncir
@@ -3421,6 +3422,7 @@ axis Both Ambos
@msg `color` must be a colour `color` debe ser un color
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` debe ser un comando cuyo elemento de menú abra un cuadro de diálogo (consulta ui.menuDialog)
@msg `freeform.points` must be an array `freeform.points` debe ser una lista
@msg `freeform.lines` must be an array `freeform.lines` debe ser una lista
@msg `gradient` must be an object `gradient` debe ser un objeto
@msg `library` is a library id or name (see {list}) `library` es el id o el nombre de una biblioteca (consulta {list})
@msg `mode` applies to freeform gradients `mode` se aplica a los degradados de forma libre
@@ -3522,6 +3524,7 @@ axis Both Ambos
@msg empty path trazado vacío
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versión de archivo {0} es más reciente de lo que admite este VectorCraft ({VERSION})
@msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido
@msg freeform line {i} must be an array of point indices la línea de forma libre {i} debe ser una lista de índices de puntos
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
@msg give corners of the path: anchors between two straight sides indica esquinas del trazado: puntos de ancla entre dos lados rectos
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
@@ -3938,6 +3941,7 @@ axis Both Ambos
Perspective Distort Distorsión de perspectiva
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` es la referencia a un paso {r}, que solo `run` y `command_batch` resuelven
@msg `ids` must be an array of object ids, not {v} `ids` debe ser una lista de identificadores de objeto, no {v}
@msg `corners` must be an array of anchor indices, not {v} `corners` debe ser una lista de índices de puntos de ancla, no {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal)
@msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v}
@@ -4043,6 +4047,31 @@ axis Both Ambos
Define a variable first Defina primero una variable
Text Texto
Visibility Visibilidad
# Shared panel docking
Activate Panel Activar panel
Change Panel Layout Cambiar distribución de paneles
Close Panel Cerrar panel
Move Panel Mover panel
Group with Agrupar con
Resize panels Cambiar tamaño de los paneles
Resize panel window Cambiar tamaño de la ventana del panel
@msg Invalid panel layout; restored the default dock La distribución de paneles no es válida; se restauró el anclaje predeterminado
@msg action is required Se requiere action
@msg anchor must name a registered panel anchor debe indicar un panel registrado
@msg before must name a panel before debe indicar un panel
@msg docking action contains an unknown panel or an excessive split path La acción de anclaje contiene un panel desconocido o una ruta de divisiones demasiado larga
@msg group must be true or false group debe ser true o false
@msg move requires a destination panel Para mover se requiere un panel de destino
@msg move requires an existing destination panel Para mover se requiere un panel de destino existente
@msg onto must name a floating panel onto debe indicar un panel flotante
@msg panel group is empty El grupo de paneles está vacío
@msg panel must name a registered panel panel debe indicar un panel registrado
@msg saved panel layout is invalid; reset the workspace first La distribución guardada de paneles no es válida; primero restablece el espacio de trabajo
@msg zone must be a string zone debe ser una cadena de texto
@msg zone must be tab, center, left, right, top or bottom zone debe ser tab, center, left, right, top o bottom
@msg {key} must be a finite coordinate {key} debe ser una coordenada finita
@msg x and y must be provided together x e y deben proporcionarse juntos
@msg invalid restored panel group El grupo de paneles restaurado no es válido
# Revolve profile guidance.
@@ -4101,3 +4130,6 @@ lighting Light Luz
Vertical Guide Guía vertical
Horizontal Guide Guía horizontal
Move Guide to Layer Mover guía a capa
@msg invalid native panel set Conjunto nativo de paneles no válido
@msg missing panel layout Falta la distribución de paneles
@msg unsupported native docking operation Operación nativa de anclaje no admitida
Can't render this file because it contains an unexpected character in line 2 and column 73.
+32
View File
@@ -2048,6 +2048,7 @@
Proof Colors Couleurs d’épreuve
Proof Setup Format d’épreuve
Properties Propriétés
Proportional Metrics Métrique proportionnelle
Proportional Oldstyle Elzévirien proportionnel
Pucker & Bloat… Contracter et dilater…
Pucker Tool Outil Contraction
@@ -3418,6 +3419,7 @@ axis Both Les deux
@msg `color` must be a colour `color` doit être une couleur
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` doit être une commande dont l’élément de menu ouvre une boîte de dialogue (voir ui.menuDialog)
@msg `freeform.points` must be an array `freeform.points` doit être un tableau
@msg `freeform.lines` must be an array `freeform.lines` doit être un tableau
@msg `gradient` must be an object `gradient` doit être un objet
@msg `library` is a library id or name (see {list}) `library` est l’identifiant ou le nom d’une bibliothèque (voir {list})
@msg `mode` applies to freeform gradients `mode` s’applique aux dégradés de forme libre
@@ -3519,6 +3521,7 @@ axis Both Les deux
@msg empty path tracé vide
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la version de fichier {0} est plus récente que celles que prend en charge cette version de VectorCraft ({VERSION})
@msg freeform point {i} needs `at` [x, y] and a valid `color` le point de forme libre {i} nécessite `at` [x, y] et une `color` valide
@msg freeform line {i} must be an array of point indices la ligne de forme libre {i} doit être un tableau d’indices de points
@msg give both `start` and `end` [x, y] (or neither) indiquez à la fois `start` et `end` [x, y] (ou aucun des deux)
@msg give corners of the path: anchors between two straight sides indiquez des angles du tracé : des points d’ancrage entre deux côtés droits
@msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur
@@ -3949,6 +3952,7 @@ axis Both Les deux
ICF Bottom/Left ICF bas/gauche
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` est la référence d’étape {r}, que seuls `run` et `command_batch` résolvent
@msg `ids` must be an array of object ids, not {v} `ids` doit être une liste d’identifiants d’objet, pas {v}
@msg `corners` must be an array of anchor indices, not {v} `corners` doit être une liste d’indices de points d’ancrage, pas {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral)
@msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v}
@@ -4055,6 +4059,31 @@ effect Soft Doux
Define a variable first Définissez d’abord une variable
Text Texte
Visibility Visibilité
# Shared panel docking
Activate Panel Activer le panneau
Change Panel Layout Modifier la disposition des panneaux
Close Panel Fermer le panneau
Move Panel Déplacer le panneau
Group with Regrouper avec
Resize panels Redimensionner les panneaux
Resize panel window Redimensionner la fenêtre du panneau
@msg Invalid panel layout; restored the default dock La disposition des panneaux est invalide ; la disposition ancrée par défaut a été restaurée
@msg action is required action est requis
@msg anchor must name a registered panel anchor doit désigner un panneau enregistré
@msg before must name a panel before doit désigner un panneau
@msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long
@msg group must be true or false group doit être true ou false
@msg move requires a destination panel Le déplacement nécessite un panneau de destination
@msg move requires an existing destination panel Le déplacement nécessite un panneau de destination existant
@msg onto must name a floating panel onto doit désigner un panneau flottant
@msg panel group is empty Le groupe de panneaux est vide
@msg panel must name a registered panel panel doit désigner un panneau enregistré
@msg saved panel layout is invalid; reset the workspace first La disposition enregistrée des panneaux est invalide ; réinitialisez d’abord l’espace de travail
@msg zone must be a string zone doit être une chaîne de caractères
@msg zone must be tab, center, left, right, top or bottom zone doit être tab, center, left, right, top ou bottom
@msg {key} must be a finite coordinate {key} doit être une coordonnée finie
@msg x and y must be provided together x et y doivent être fournis ensemble
@msg invalid restored panel group Le groupe de panneaux restauré est invalide
# Interactive Revolve controls.
Lighting Éclairage
@@ -4111,3 +4140,6 @@ lighting Light Lumière
Vertical Guide Repère vertical
Horizontal Guide Repère horizontal
Move Guide to Layer Déplacer le repère vers le calque
@msg invalid native panel set Ensemble natif de panneaux invalide
@msg missing panel layout Disposition des panneaux manquante
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
Can't render this file because it contains an unexpected character in line 2 and column 73.
+32
View File
@@ -2051,6 +2051,7 @@
Proof Colors Colori di prova
Proof Setup Impostazione prova
Properties Proprietà
Proportional Metrics Metrica proporzionale
Proportional Oldstyle Proporzionali stile antico
Pucker & Bloat… Contrai e gonfia…
Pucker Tool Strumento Contrai
@@ -3421,6 +3422,7 @@ axis Both Entrambi
@msg `color` must be a colour `color` deve essere un colore
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` deve essere un comando la cui voce di menu apre una finestra di dialogo (vedi ui.menuDialog)
@msg `freeform.points` must be an array `freeform.points` deve essere un array
@msg `freeform.lines` must be an array `freeform.lines` deve essere un array
@msg `gradient` must be an object `gradient` deve essere un oggetto
@msg `library` is a library id or name (see {list}) `library` è l'id o il nome di una libreria (vedi {list})
@msg `mode` applies to freeform gradients `mode` si applica alle sfumature a forma libera
@@ -3522,6 +3524,7 @@ axis Both Entrambi
@msg empty path tracciato vuoto
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versione del file {0} è più recente di quelle supportate da questo VectorCraft ({VERSION})
@msg freeform point {i} needs `at` [x, y] and a valid `color` il punto a forma libera {i} richiede `at` [x, y] e un `color` valido
@msg freeform line {i} must be an array of point indices la linea a forma libera {i} deve essere un array di indici di punti
@msg give both `start` and `end` [x, y] (or neither) specifica sia `start` sia `end` [x, y] (o nessuno dei due)
@msg give corners of the path: anchors between two straight sides specifica angoli del tracciato: punti di ancoraggio tra due lati diritti
@msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza
@@ -3938,6 +3941,7 @@ axis Both Entrambi
Perspective Distort Distorsione prospettica
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` è il riferimento al passaggio {r}, che solo `run` e `command_batch` risolvono
@msg `ids` must be an array of object ids, not {v} `ids` deve essere un elenco di ID oggetto, non {v}
@msg `corners` must be an array of anchor indices, not {v} `corners` deve essere un elenco di indici di punti di ancoraggio, non {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale)
@msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v}
@@ -4045,6 +4049,31 @@ effect Regular Normale
Define a variable first Definisci prima una variabile
Text Testo
Visibility Visibilità
# Shared panel docking
Activate Panel Attiva pannello
Change Panel Layout Modifica disposizione dei pannelli
Close Panel Chiudi pannello
Move Panel Sposta pannello
Group with Raggruppa con
Resize panels Ridimensiona pannelli
Resize panel window Ridimensiona finestra del pannello
@msg Invalid panel layout; restored the default dock La disposizione dei pannelli non è valida; è stato ripristinato l’ancoraggio predefinito
@msg action is required action è obbligatorio
@msg anchor must name a registered panel anchor deve indicare un pannello registrato
@msg before must name a panel before deve indicare un pannello
@msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo
@msg group must be true or false group deve essere true o false
@msg move requires a destination panel Lo spostamento richiede un pannello di destinazione
@msg move requires an existing destination panel Lo spostamento richiede un pannello di destinazione esistente
@msg onto must name a floating panel onto deve indicare un pannello mobile
@msg panel group is empty Il gruppo di pannelli è vuoto
@msg panel must name a registered panel panel deve indicare un pannello registrato
@msg saved panel layout is invalid; reset the workspace first La disposizione salvata dei pannelli non è valida; reimposta prima l’area di lavoro
@msg zone must be a string zone deve essere una stringa
@msg zone must be tab, center, left, right, top or bottom zone deve essere tab, center, left, right, top o bottom
@msg {key} must be a finite coordinate {key} deve essere una coordinata finita
@msg x and y must be provided together x e y devono essere forniti insieme
@msg invalid restored panel group Il gruppo di pannelli ripristinato non è valido
# Revolve profile guidance.
@@ -4103,3 +4132,6 @@ lighting Light Luce
Vertical Guide Guida verticale
Horizontal Guide Guida orizzontale
Move Guide to Layer Sposta guida nel livello
@msg invalid native panel set Gruppo nativo di pannelli non valido
@msg missing panel layout Disposizione dei pannelli mancante
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -37,6 +37,7 @@
Em Box Top/Right 仮想ボディの上 / 右
ICF Top/Right 平均字面の上 / 右
ICF Bottom/Left 平均字面の下 / 左
Proportional Metrics プロポーショナルメトリクス
Favorites お気に入り
Flip 反転
Japanese 日本語
@@ -3470,9 +3471,11 @@ theme Appearance 外観
@msg `at` must be [x, y] `at` は [x, y] の形式で指定してください
@msg `color` must be a colour `color` にはカラーを指定してください
@msg `freeform.points` must be an array `freeform.points` には配列を指定してください
@msg `freeform.lines` must be an array `freeform.lines` には配列を指定してください
@msg `{key}` must be a number `{key}` には数値を指定してください
@msg a freeform gradient keeps at least one point フリーフォームグラデーションには少なくとも 1 つのポイントが必要です
@msg freeform point {i} needs `at` [x, y] and a valid `color` フリーフォームのポイント {i} には `at` [x, y] と有効な `color` が必要です
@msg freeform line {i} must be an array of point indices フリーフォームのライン {i} にはポイントのインデックスの配列を指定してください
@msg no point {i} (the gradient has {_1}) ポイント {i} はありません (このグラデーションのポイントは {_1} 個です)
@msg no segment {segment} on line {line} ライン {line} にセグメント {segment} はありません
@msg unknown freeform mode `{m}` (points, lines) `{m}` は不明なフリーフォームのモードです (points、lines)
@@ -3931,6 +3934,7 @@ theme Appearance 外観
@msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです
@msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません)
@msg `corners` must be an array of anchor indices, not {v} `corners` はアンカーポイントのインデックスの配列にしてください ({v} は使えません)
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください)
@msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません)
@@ -4038,6 +4042,32 @@ effect Vertical 垂直
Define a variable first 先に変数を定義してください
Text テキスト
Visibility 表示状態
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
# Shared panel docking
Activate Panel パネルをアクティブにする
Change Panel Layout パネルの配置を変更
Close Panel パネルを閉じる
Move Panel パネルを移動
Group with グループ化先
Resize panels パネルのサイズを変更
Resize panel window パネルウィンドウのサイズを変更
@msg invalid restored panel group 復元されたパネルグループが無効です
@msg Invalid panel layout; restored the default dock パネル配置が無効です。既定のドックを復元しました
@msg action is required action を指定してください
@msg anchor must name a registered panel anchor に登録済みパネルを指定してください
@msg before must name a panel before にパネルを指定してください
@msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています
@msg group must be true or false group は true または false にしてください
@msg move requires a destination panel 移動先のパネルを指定してください
@msg move requires an existing destination panel 移動先には既存のパネルを指定してください
@msg onto must name a floating panel onto にフローティングパネルを指定してください
@msg panel group is empty パネルグループが空です
@msg panel must name a registered panel panel に登録済みパネルを指定してください
@msg saved panel layout is invalid; reset the workspace first 保存されたパネル配置が無効です。先にワークスペースをリセットしてください
@msg x and y must be provided together x と y は両方指定してください
@msg zone must be a string zone は文字列にしてください
@msg zone must be tab, center, left, right, top or bottom zone は tab、center、left、right、top、bottom のいずれかにしてください
@msg {key} must be a finite coordinate {key} は有限の座標にしてください
# Revolve profile guidance.
@@ -4096,3 +4126,6 @@ lighting Light ライト
Vertical Guide 垂直ガイド
Horizontal Guide 水平ガイド
Move Guide to Layer ガイドをレイヤーへ移動
@msg invalid native panel set ネイティブパネルセットが無効です
@msg missing panel layout パネル配置がありません
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -1721,6 +1721,7 @@
Proof Colors Provar Cores
Proof Setup Configuração de Prova
Properties Propriedades
Proportional Metrics Métrica Proporcional
Pucker & Bloat… Encolher e Inchar…
Pucker Tool Ferramenta Encolher
Punctuation Space Espaço de Pontuação
@@ -2755,6 +2756,14 @@
Ocean Ripple… Ondulação oceânica…
Rename Data Set Renomear conjunto de dados
Rename Variable Renomear variável
# Shared panel docking
Activate Panel Ativar Painel
Change Panel Layout Alterar Layout dos Painéis
Close Panel Fechar Painel
Move Panel Mover Painel
Group with Agrupar com
Resize panels Redimensionar painéis
Resize panel window Redimensionar janela do painel
# Interactive Revolve controls.
Lighting Iluminação
Can't render this file because it contains an unexpected character in line 6 and column 47.
+32
View File
@@ -2047,6 +2047,7 @@
Proof Colors Цветопроба
Proof Setup Настройка цветопробы
Properties Свойства
Proportional Metrics Пропорциональные метрики
Proportional Oldstyle Пропорциональные старого стиля
Pucker & Bloat… Сморщивание и раздувание…
Pucker Tool Инструмент «Сморщивание»
@@ -3417,6 +3418,7 @@ axis Both Оба
@msg `color` must be a colour `color` должно быть цветом
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` должно быть командой, пункт меню которой открывает диалоговое окно (см. ui.menuDialog)
@msg `freeform.points` must be an array `freeform.points` должно быть массивом
@msg `freeform.lines` must be an array `freeform.lines` должно быть массивом
@msg `gradient` must be an object `gradient` должно быть объектом
@msg `library` is a library id or name (see {list}) `library` — это идентификатор или имя библиотеки (см. {list})
@msg `mode` applies to freeform gradients `mode` применяется к произвольным градиентам
@@ -3518,6 +3520,7 @@ axis Both Оба
@msg empty path пустой контур
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) версия файла {0} новее, чем поддерживает этот VectorCraft ({VERSION})
@msg freeform point {i} needs `at` [x, y] and a valid `color` произвольной точке {i} нужны `at` [x, y] и допустимый `color`
@msg freeform line {i} must be an array of point indices произвольная линия {i} должна быть массивом индексов точек
@msg give both `start` and `end` [x, y] (or neither) укажите и `start`, и `end` [x, y] (или ни одного)
@msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами
@msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины
@@ -3936,6 +3939,7 @@ axis Both Оба
Exit Выйти
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — это ссылка на шаг {r}, которую разрешают только `run` и `command_batch`
@msg `ids` must be an array of object ids, not {v} `ids` должен быть массивом идентификаторов объектов, а не {v}
@msg `corners` must be an array of anchor indices, not {v} `corners` должен быть массивом индексов опорных точек, а не {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$)
@msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v}
@@ -4041,6 +4045,31 @@ axis Both Оба
Define a variable first Сначала определите переменную
Text Текст
Visibility Видимость
# Shared panel docking
Activate Panel Активировать панель
Change Panel Layout Изменить расположение панелей
Close Panel Закрыть панель
Move Panel Переместить панель
Group with Сгруппировать с
Resize panels Изменить размер панелей
Resize panel window Изменить размер окна панели
@msg Invalid panel layout; restored the default dock Недопустимое расположение панелей; восстановлено расположение по умолчанию
@msg action is required Необходимо указать action
@msg anchor must name a registered panel anchor должен указывать зарегистрированную панель
@msg before must name a panel before должен указывать панель
@msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений
@msg group must be true or false group должен иметь значение true или false
@msg move requires a destination panel Для перемещения требуется целевая панель
@msg move requires an existing destination panel Для перемещения требуется существующая целевая панель
@msg onto must name a floating panel onto должен указывать плавающую панель
@msg panel group is empty Группа панелей пуста
@msg panel must name a registered panel panel должен указывать зарегистрированную панель
@msg saved panel layout is invalid; reset the workspace first Сохранённое расположение панелей недопустимо; сначала сбросьте рабочую среду
@msg zone must be a string zone должен быть строкой
@msg zone must be tab, center, left, right, top or bottom zone должен иметь значение tab, center, left, right, top или bottom
@msg {key} must be a finite coordinate {key} должен быть конечной координатой
@msg x and y must be provided together x и y необходимо указывать вместе
@msg invalid restored panel group Восстановленная группа панелей недопустима
# Revolve profile guidance.
@@ -4099,3 +4128,6 @@ lighting Light Свет
Vertical Guide Вертикальная направляющая
Horizontal Guide Горизонтальная направляющая
Move Guide to Layer Переместить направляющую на слой
@msg invalid native panel set Недопустимый собственный набор панелей
@msg missing panel layout Отсутствует расположение панелей
@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления
Can't render this file because it contains an unexpected character in line 2 and column 73.
+32
View File
@@ -2043,6 +2043,7 @@
Proof Colors Пробні кольори
Proof Setup Налаштування кольоропроби
Properties Властивості
Proportional Metrics Пропорційні метрики
Proportional Oldstyle Пропорційні цифри старого стилю
Pucker & Bloat… Зморщування та роздування …
Pucker Tool Інструмент «Зморщування»
@@ -3404,6 +3405,7 @@ axis Both Обидві
@msg `color` must be a colour `color` має бути кольором
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` має бути командою, пункт меню якої відкриває діалогове вікно (див. ui.menuDialog)
@msg `freeform.points` must be an array `freeform.points` має бути масивом
@msg `freeform.lines` must be an array `freeform.lines` має бути масивом
@msg `gradient` must be an object `gradient` має бути об’єктом
@msg `library` is a library id or name (see {list}) `library` — це ідентифікатор або ім’я бібліотеки (див. {list})
@msg `mode` applies to freeform gradients `mode` застосовується до довільних градієнтів
@@ -3505,6 +3507,7 @@ axis Both Обидві
@msg empty path порожній контур
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) версія файлу {0} новіша, ніж підтримує цей VectorCraft ({VERSION})
@msg freeform point {i} needs `at` [x, y] and a valid `color` довільній точці {i} потрібні `at` [x, y] та допустимий `color`
@msg freeform line {i} must be an array of point indices довільна лінія {i} має бути масивом індексів точок
@msg give both `start` and `end` [x, y] (or neither) вкажіть `start`, `end` [x, y] (або жодного)
@msg give corners of the path: anchors between two straight sides вкажіть кути контуру: опорні точки між двома прямими сторонами
@msg give the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини
@@ -3921,6 +3924,7 @@ axis Both Обидві
Exit Вийти
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — це посилання на крок {r}, яке розв’язують лише `run` і `command_batch`
@msg `ids` must be an array of object ids, not {v} `ids` має бути масивом ідентифікаторів об’єктів, а не {v}
@msg `corners` must be an array of anchor indices, not {v} `corners` має бути масивом індексів опорних точок, а не {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$)
@msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v}
@@ -4026,6 +4030,31 @@ axis Both Обидві
Define a variable first Спочатку визначте змінну
Text Текст
Visibility Видимість
# Shared panel docking
Activate Panel Активувати панель
Change Panel Layout Змінити розташування панелей
Close Panel Закрити панель
Move Panel Перемістити панель
Group with Згрупувати з
Resize panels Змінити розмір панелей
Resize panel window Змінити розмір вікна панелі
@msg Invalid panel layout; restored the default dock Неприпустиме розташування панелей; відновлено типове розташування
@msg action is required Потрібно вказати action
@msg anchor must name a registered panel anchor має вказувати зареєстровану панель
@msg before must name a panel before має вказувати панель
@msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів
@msg group must be true or false group має мати значення true або false
@msg move requires a destination panel Для переміщення потрібна цільова панель
@msg move requires an existing destination panel Для переміщення потрібна наявна цільова панель
@msg onto must name a floating panel onto має вказувати плаваючу панель
@msg panel group is empty Група панелей порожня
@msg panel must name a registered panel panel має вказувати зареєстровану панель
@msg saved panel layout is invalid; reset the workspace first Збережене розташування панелей неприпустиме; спочатку скиньте робоче середовище
@msg zone must be a string zone має бути рядком
@msg zone must be tab, center, left, right, top or bottom zone має мати значення tab, center, left, right, top або bottom
@msg {key} must be a finite coordinate {key} має бути скінченною координатою
@msg x and y must be provided together x та y потрібно вказувати разом
@msg invalid restored panel group Відновлена група панелей неприпустима
# Interactive Revolve controls.
Lighting Освітлення
@@ -4082,3 +4111,6 @@ lighting Light Світло
Vertical Guide Вертикальна напрямна
Horizontal Guide Горизонтальна напрямна
Move Guide to Layer Перемістити напрямну на шар
@msg invalid native panel set Неприпустимий власний набір панелей
@msg missing panel layout Відсутнє розташування панелей
@msg unsupported native docking operation Непідтримувана власна операція прикріплення
Can't render this file because it contains an unexpected character in line 659 and column 40.
+9
View File
@@ -1976,6 +1976,7 @@
Proof Colors 校样颜色
Proof Setup 校样设置
Properties 属性
Proportional Metrics 比例度量
Proportional Oldstyle 比例旧式数字
Pucker & Bloat… 缩拢与膨胀…
Pucker Tool 收缩工具
@@ -3669,6 +3670,14 @@ effect Vertical 垂直
Define a variable first 请先定义一个变量
Text 文本
Visibility 可见性
# Shared panel docking
Activate Panel 激活面板
Change Panel Layout 更改面板布局
Close Panel 关闭面板
Move Panel 移动面板
Group with 组合到
Resize panels 调整面板大小
Resize panel window 调整面板窗口大小
# Revolve profile guidance.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -2029,6 +2029,7 @@
Proof Colors 校樣色彩
Proof Setup 校樣設定
Properties 內容
Proportional Metrics 比例度量
Proportional Oldstyle 比例舊式數字
Pucker & Bloat… 縮攏與膨脹…
Pucker Tool 縮攏工具
@@ -3450,6 +3451,14 @@ effect Soft 柔和
Define a variable first 請先定義一個變數
Text 文字
Visibility 可見性
# Shared panel docking
Activate Panel 啟用面板
Change Panel Layout 變更面板配置
Close Panel 關閉面板
Move Panel 移動面板
Group with 群組至
Resize panels 調整面板大小
Resize panel window 調整面板視窗大小
# Revolve profile guidance.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+37 -17
View File
@@ -1,28 +1,19 @@
//! Icon lookup and drawing (SVGs rasterized by egui_extras, tinted with theme colours).
use std::collections::HashMap;
use std::sync::OnceLock;
use craft_ui::icons::{SvgIconSet, SvgStyle};
use egui::{Color32, ImageSource, Rect, Ui, Vec2};
fn table() -> &'static HashMap<&'static str, &'static [u8]> {
static T: OnceLock<HashMap<&'static str, &'static [u8]>> = OnceLock::new();
fn table() -> &'static SvgIconSet {
static T: OnceLock<SvgIconSet> = OnceLock::new();
T.get_or_init(|| {
crate::icon_data::ICONS
.iter()
.map(|(n, b)| {
// Icons use currentColor; render white and tint at draw time. Slightly thinner strokes
// than Lucide's default read closer to Illustrator's glyph weight.
let s = String::from_utf8_lossy(b).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\"");
let leaked: &'static [u8] = Box::leak(s.into_bytes().into_boxed_slice());
(*n, leaked)
})
.collect()
SvgIconSet::new(crate::icon_data::ICONS, SvgStyle { current_color: "white", stroke_width: Some("1.6") }, "bytes://icon/", "square-dashed")
})
}
pub fn exists(name: &str) -> bool {
table().contains_key(name)
table().contains(name)
}
/// Map tool-catalogue icon names to SVG files.
@@ -102,7 +93,7 @@ pub fn tool_icon(name: &str) -> &'static str {
"tool-zoom" => "zoom-in",
other => {
if exists(other) {
table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed")
table().canonical_name(other).unwrap_or("square-dashed")
} else {
"square-dashed"
}
@@ -111,8 +102,7 @@ pub fn tool_icon(name: &str) -> &'static str {
}
pub fn source(name: &str) -> ImageSource<'static> {
let bytes = table().get(name).or_else(|| table().get("square-dashed")).copied().unwrap_or(&[]);
ImageSource::Bytes { uri: format!("bytes://icon/{name}.svg").into(), bytes: egui::load::Bytes::Static(bytes) }
table().source(name)
}
/// Paint icon `name` into `rect` tinted with `tint`.
@@ -126,3 +116,33 @@ pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response
paint(ui, name, rect, tint);
resp
}
#[cfg(test)]
mod tests {
use super::*;
/// Characterize the pre-extraction loader against every embedded asset, including custom
/// icons. A shared-crate update must not silently change our stroke weight or cache keys.
#[test]
fn shared_catalog_preserves_existing_icons() {
for (name, original) in crate::icon_data::ICONS {
let expected = String::from_utf8_lossy(original).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\"");
let ImageSource::Bytes { uri, bytes } = source(name) else { panic!("expected SVG bytes") };
assert_eq!(uri.as_ref(), format!("bytes://icon/{name}.svg"));
assert_eq!(bytes.as_ref(), expected.as_bytes(), "{name}");
assert!(exists(name));
assert_eq!(tool_icon(name), *name);
}
}
#[test]
fn shared_catalog_preserves_fallback_and_tool_aliases() {
let ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") };
let ImageSource::Bytes { bytes: fallback, .. } = source("square-dashed") else { panic!("expected SVG bytes") };
assert_eq!(uri.as_ref(), "bytes://icon/missing-icon.svg");
assert_eq!(bytes.as_ref(), fallback.as_ref());
assert!(!exists("missing-icon"));
assert_eq!(tool_icon("missing-icon"), "square-dashed");
assert_eq!(tool_icon("tool-selection"), "dc-selection");
}
}
+1
View File
@@ -36,6 +36,7 @@ pub mod io;
pub mod menus;
pub mod native_menu;
pub mod palette;
mod panel_docking;
pub mod panels;
pub mod picks;
pub mod place;
+20
View File
@@ -256,6 +256,20 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
(
"window.panel.layout",
"Change Panel Layout",
"",
"{action} serialized docking action, including floating geometry, divider sizes and accordion state",
),
(
"window.panel.move",
"Move Panel",
"",
"{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} group, split or reorder a panel",
),
("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"),
("window.panel.close", "Close Panel", "", "{panel} hide a panel"),
(
"window.panel.float",
"Float Panel",
@@ -843,6 +857,9 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
/// Handle a UI command. `None` = not a UI command (the engine handles it).
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if let Some(result) = crate::panel_docking::command(app, id, p) {
return Some(result);
}
if id == "app.language" {
let lang = p.get("lang").and_then(Value::as_str).unwrap_or("");
let value = if lang.eq_ignore_ascii_case("auto") {
@@ -1449,6 +1466,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
let canonical = normalize_panel(panel);
match canonical.and_then(DockTab::from_id) {
_ if app.ui.docking.is_some() => canonical.is_some_and(|id| {
app.ui.open_panel.as_deref() == Some(id) || app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string()))
}),
_ if canonical.is_some_and(|id| crate::floating::group_of(&app.ui, id).is_some()) => true,
Some(tab) if !app.ui.dock_collapsed => crate::floating::shown_tab(&app.ui) == Some(tab),
_ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)),
+991
View File
@@ -0,0 +1,991 @@
//! Application-owned panel IDs and commands around the shared docking layout.
use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone};
use serde_json::{Value, json};
use crate::VectorcraftApp;
#[derive(Clone, Default)]
struct LegacyDrag {
layout: Option<Layout<String>>,
open_panel: Option<String>,
floating: Vec<crate::state::FloatingPanels>,
icons: std::collections::BTreeSet<String>,
}
#[derive(Clone)]
struct RootGroupDrag {
panel: String,
layout: Layout<String>,
hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
generation: u64,
}
fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
let key = egui::Id::new("vectorcraft-panel-docking").with("legacy-origin");
let previous = ctx.data_mut(|data| data.remove_temp::<LegacyDrag>(key));
if let Some(previous) = previous {
app.ui.open_panel = previous.open_panel;
app.ui.floating_panels = previous.floating;
app.ui.docking = previous.layout;
app.ui.docking_icons = previous.icons;
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("vectorcraft-panel-docking"));
true
} else {
false
}
}
fn normalize(raw: &str) -> Option<&'static str> {
crate::menus::normalize_panel(raw)
}
pub(crate) fn valid(layout: &Layout<String>) -> bool {
layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str()))
}
fn legacy(app: &VectorcraftApp) -> Layout<String> {
let panels: Vec<String> = crate::floating::docked_tabs(&app.ui).map(|tab| tab.info().0.to_string()).collect();
let active = panels.iter().position(|id| id == app.ui.dock_tab.info().0).unwrap_or(0);
let root = (!panels.is_empty()).then_some(Node::Tabs { panels, active });
let mut layout = Layout { root, floating: Vec::new() };
for group in &app.ui.floating_panels {
let panels: Vec<String> = group.panels.iter().filter(|id| normalize(id).is_some() && !layout.contains(id)).cloned().collect();
if !panels.is_empty() && group.pos.iter().all(|v| v.is_finite()) {
let active = group.active.min(panels.len().saturating_sub(1));
layout.floating.push(Floating {
panels,
active,
rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0).clamp(200.0, 720.0), 400.0],
});
}
}
layout
}
fn icon_origins(app: &VectorcraftApp) -> std::collections::BTreeSet<String> {
let mut icons = app.ui.docking_icons.clone();
if app.ui.docking.is_none() {
// Legacy workspaces have no custom groups in the dock. Icon-panel floats therefore
// retain their native rail destination when first converted to a shared layout.
icons.extend(
app.ui
.floating_panels
.iter()
.flat_map(|group| group.panels.iter())
.filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str()))
.cloned(),
);
}
icons
}
fn first_root(layout: &Layout<String>, except: &str) -> Option<String> {
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
root.panels().into_iter().find(|p| p.as_str() != except).cloned()
}
// Preserve the native dock's selected tab when another tab leaves. If the selected tab
// itself leaves, the remaining group's first tab is the native fallback.
fn float_fallback(layout: &Layout<String>, panel: &str) -> Option<String> {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, active } if panels.iter().any(|id| id == panel) => {
return panels.get(*active).filter(|id| id.as_str() != panel).or_else(|| panels.iter().find(|id| id.as_str() != panel)).cloned();
}
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
_ => {}
}
}
None
}
fn restore_float_fallback(layout: &mut Layout<String>, panel: Option<String>) -> Result<(), String> {
if let Some(panel) = panel.filter(|panel| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(panel)) {
layout.apply(Action::Activate { panel }).map_err(|error| error.to_string())?;
}
Ok(())
}
fn ensure(layout: &mut Layout<String>, panel: &str) -> Result<(), String> {
let anchor = first_root(layout, panel);
layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string())
}
fn group_members(layout: &Layout<String>, panel: &str) -> Vec<String> {
if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|id| id == panel)) {
return group.panels.clone();
}
let mut visit: Vec<_> = layout.root.iter().collect();
while let Some(node) = visit.pop() {
match node {
Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return panels.clone(),
Node::Split { first, second, .. } => {
visit.push(second);
visit.push(first);
}
_ => {}
}
}
vec![panel.into()]
}
fn number(params: &Value, key: &str, default: f32) -> Result<f32, String> {
match params.get(key) {
None => Ok(default),
Some(v) => v
.as_f64()
.filter(|v| v.is_finite() && v.abs() <= 1_000_000.0)
.map(|v| v as f32)
.ok_or_else(|| format!("{key} must be a finite coordinate")),
}
}
fn placement(params: &Value) -> Result<Placement<String>, String> {
if let Some(before) = params.get("before") {
let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?;
return Ok(Placement::Tab { before: Some(id.into()) });
}
let zone = match params.get("zone") {
None => "tab",
Some(value) => value.as_str().ok_or("zone must be a string")?,
};
Ok(match zone {
"tab" => Placement::Tab { before: None },
"center" => Placement::Split(Zone::Center),
"left" => Placement::Split(Zone::Left),
"right" => Placement::Split(Zone::Right),
"top" => Placement::Split(Zone::Top),
"bottom" => Placement::Split(Zone::Bottom),
_ => return Err("zone must be tab, center, left, right, top or bottom".into()),
})
}
pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if matches!(id, "window.panel.float" | "window.panel.dock")
&& (params.get("above").is_some()
|| params.get("below").is_some()
|| params.get("column").is_some()
|| params.get("collapsed").is_some()
|| params
.get("onto")
.and_then(Value::as_str)
.and_then(normalize)
.and_then(|id| crate::floating::group_of(&app.ui, id))
.and_then(|i| app.ui.floating_panels.get(i))
.is_some_and(decorated)
|| params
.get("panel")
.and_then(Value::as_str)
.and_then(normalize)
.and_then(|id| crate::floating::group_of(&app.ui, id))
.and_then(|i| app.ui.floating_panels.get(i))
.is_some_and(decorated))
{
return Some(native_change(app, id, params));
}
if id == "window.panel.layout" {
return Some(
params
.get("action")
.ok_or_else(|| "action is required".to_string())
.and_then(|value| serde_json::from_value::<Action<String>>(value.clone()).map_err(|error| error.to_string()))
.and_then(|action| apply_action(app, action)),
);
}
if params.get("panel").and_then(Value::as_str).is_some_and(|s| s.eq_ignore_ascii_case("tools") || s.eq_ignore_ascii_case("toolbar")) {
return None;
}
let operation = match id {
"window.panel.move" => "move",
"window.panel.float" => "float",
"window.panel.dock" => "dock",
"window.panel.activate" => "activate",
"window.panel.close" => "close",
"window.panel" if app.ui.docking.is_some() => "activate",
_ => return None,
};
Some(change(app, operation, params).map(|value| {
if id == "window.panel"
&& let Some(panel) = params.get("panel").and_then(Value::as_str).and_then(normalize)
&& let Some(group) = app.ui.floating_panels.iter().find(|group| group.panels.iter().any(|id| id == panel))
{
return json!({"floating":group.panels});
}
value
}))
}
// UI output and automation share this atomic dispatcher. The application owns the registry
// and saved return locations; craft-ui owns tree validation and structural changes.
fn apply_action(app: &mut VectorcraftApp, action: Action<String>) -> Result<Value, String> {
let registered = |id: &String| normalize(id) == Some(id.as_str());
let known = match &action {
Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => {
registered(panel)
&& registered(anchor)
&& match placement {
Placement::Tab { before: Some(id) } => registered(id),
_ => true,
}
}
Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered),
Action::Float { panel, .. }
| Action::Close { panel }
| Action::Activate { panel }
| Action::MoveFloating { panel, .. }
| Action::SetStackOpen { panel, .. }
| Action::ResizeStack { panel, .. } => registered(panel),
Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH,
};
if !known {
return Err("docking action contains an unknown panel or an excessive split path".into());
}
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
return Err("saved panel layout is invalid; reset the workspace first".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let mut icons = icon_origins(app);
let explicit_dock = match &action {
Action::Move { panel, anchor, .. } | Action::OpenAt { panel, anchor, .. }
if (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(anchor) =>
{
Some(panel.clone())
}
_ => None,
};
let returning = match &action {
Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)),
Action::Float { panel, .. } => {
layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location))
}
_ => None,
};
let fallback = match &action {
Action::Float { panel, .. } => float_fallback(&layout, panel),
_ => None,
};
let activated = match &action {
Action::Activate { panel } => Some(panel.clone()),
_ => None,
};
layout.apply(action).map_err(|error| error.to_string())?;
restore_float_fallback(&mut layout, fallback)?;
if let Some((panel, location)) = returning {
app.ui.docking_hidden.insert(panel, location);
}
if let Some(panel) = explicit_dock {
icons.remove(&panel);
}
app.ui.docking_icons = icons;
app.ui.docking = Some(layout);
if let Some(panel) = activated {
for group in &mut app.ui.floating_panels {
if group.panels.contains(&panel) {
group.collapsed = false;
}
}
}
sync_legacy(app);
app.ui.open_panel = None;
Ok(json!({"layout": app.ui.docking}))
}
fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result<Value, String> {
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
return Err("saved panel layout is invalid; reset the workspace first".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
if params.get("x").is_some() != params.get("y").is_some() {
return Err("x and y must be provided together".into());
}
let whole_group = match params.get("group") {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => return Err("group must be true or false".into()),
};
let members = if whole_group { group_members(&layout, panel) } else { vec![panel.to_string()] };
let mut hidden: std::collections::BTreeMap<_, _> =
app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect();
let mut icons = icon_origins(app);
let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel);
if icon_panel && !layout.contains(&panel.to_string()) {
icons.insert(panel.to_string());
}
if operation == "activate" && icon_panel && !layout.contains(&panel.to_string()) {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel) { None } else { Some(panel.into()) };
return Ok(json!({"panel": panel, "open": app.ui.open_panel}));
}
if operation == "dock" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
let mut candidate = layout.clone();
let mut open = None;
for member in &members {
if candidate.contains(member) {
candidate.apply(Action::Close { panel: member.clone() }).map_err(|error| error.to_string())?;
}
}
// Remove the complete group before restoring any saved neighbor. Otherwise a
// still-floating group member can accidentally receive the first restored tab.
for member in &members {
if icons.contains(member) {
open = Some(member.clone());
} else if let Some(location) = hidden.get(member).filter(|location| {
location.floating.is_none()
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: candidate.root.clone(), floating: Vec::new() }.contains(anchor))
}) {
if candidate.restore(member.clone(), location).is_err() {
// A removed neighboring group uses the normal dock as its documented fallback.
ensure(&mut candidate, member)?;
}
} else {
ensure(&mut candidate, member)?;
}
}
if candidate.contains(&panel.to_string()) {
candidate.apply(Action::Activate { panel: panel.to_string() }).map_err(|error| error.to_string())?;
} else if icons.contains(panel) {
open = Some(panel.to_string());
}
if !valid(&candidate) {
return Err("invalid restored panel group".into());
}
app.ui.docking = Some(candidate);
app.ui.docking_icons = icons;
for member in &members {
hidden.remove(member);
}
app.ui.docking_hidden = hidden;
sync_legacy(app);
app.ui.open_panel = open;
return Ok(json!({"panel": panel, "floating": false, "layout": app.ui.docking}));
}
let mut restored = false;
if operation == "dock"
&& params.get("anchor").or_else(|| params.get("onto")).is_none()
&& let Some(location) = hidden.get(panel).filter(|location| {
location.floating.is_none()
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor))
})
{
let mut candidate = layout.clone();
if candidate.contains(&panel.to_string()) {
candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
if candidate.restore(panel.into(), location).is_ok() {
layout = candidate;
restored = true;
hidden.remove(panel);
}
}
match operation {
"dock" if restored => {}
"float" => {
let old = layout
.floating
.iter()
.find(|group| group.panels.iter().any(|id| id == panel) && group.panels.len() == members.len())
.map(|group| group.rect)
.unwrap_or([400.0 + 24.0 * layout.floating.len().min(12) as f32, 140.0 + 24.0 * layout.floating.len().min(12) as f32, 300.0, 400.0]);
let rect =
[number(params, "x", old[0])?, number(params, "y", old[1])?, number(params, "width", old[2])?, number(params, "height", old[3])?];
let fallback = float_fallback(&layout, panel).or_else(|| first_root(&layout, panel));
for member in &members {
if let Ok(location) = layout.location(member)
&& location.floating.is_none()
{
hidden.insert(member.clone(), location);
}
}
ensure(&mut layout, panel)?;
if let Ok(location) = layout.location(&panel.to_string())
&& location.floating.is_none()
{
hidden.insert(panel.into(), location);
}
if let Some(onto) = params.get("onto") {
let onto = onto.as_str().and_then(normalize).ok_or("onto must name a floating panel")?;
if members.iter().any(|id| id == onto)
|| layout.floating.iter().any(|group| group.panels.iter().any(|id| id == panel) && group.panels.iter().any(|id| id == onto))
|| !layout.floating.iter().any(|group| group.panels.iter().any(|id| id == onto))
{
return Err("onto must name a panel floating in another group".into());
}
for member in &members {
layout
.apply(Action::Move { panel: member.clone(), anchor: onto.into(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
} else {
let lead = members.first().ok_or("panel group is empty")?;
layout.apply(Action::Float { panel: lead.clone(), rect }).map_err(|e| e.to_string())?;
for member in members.iter().filter(|id| *id != lead) {
layout
.apply(Action::Move { panel: member.clone(), anchor: lead.clone(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
}
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
restore_float_fallback(&mut layout, fallback)?;
}
"move" | "dock" => {
if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
return Err("move requires a destination panel".into());
}
let anchor = match params.get("anchor").or_else(|| params.get("onto")) {
Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()),
None => first_root(&layout, panel),
};
let placement = placement(params)?;
if let Some(anchor) = anchor {
ensure(&mut layout, panel)?;
layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?;
} else if operation == "dock" {
if layout.contains(&panel.to_string()) {
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?;
} else {
return Err("move requires an existing destination panel".into());
}
}
"close" => {
hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?);
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
_ => {
if !layout.contains(&panel.to_string())
&& let Some(location) = hidden.get(panel)
{
// A missing neighbor is allowed: the normal visible dock becomes the fallback.
let _ = layout.restore(panel.into(), location);
}
ensure(&mut layout, panel)?;
hidden.remove(panel);
}
}
if operation == "dock" && whole_group {
for member in members.iter().filter(|id| id.as_str() != panel) {
layout
.apply(Action::Move { panel: member.clone(), anchor: panel.into(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
}
let group = layout.floating.iter().find(|group| group.panels.iter().any(|id| id == panel));
let result = json!({"panel": panel, "floating": group.is_some(), "group": group.map(|g| &g.panels), "pos": group.map(|g| [g.rect[0], g.rect[1]]), "layout": layout});
if matches!(operation, "move" | "dock") && (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(&panel.to_string()) {
icons.remove(panel);
}
app.ui.docking = Some(layout);
app.ui.docking_hidden = hidden;
app.ui.docking_icons = icons;
if operation == "activate" {
for group in &mut app.ui.floating_panels {
if group.panels.iter().any(|id| id == panel) {
group.collapsed = false;
}
}
}
sync_legacy(app);
app.ui.open_panel = None;
app.ui.dock = true;
if matches!(operation, "activate" | "move" | "dock") {
app.ui.dock_collapsed = false;
}
Ok(result)
}
struct PanelContent<'a> {
app: &'a mut VectorcraftApp,
commands: Vec<(&'static str, Value)>,
}
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, panel: &String) {
let width = ui.available_width();
let height = (ui.available_height() - 20.0).max(0.0);
if crate::state::DockTab::from_id(panel).is_some() {
crate::dock::panel_body(self.app, ui, panel, width, height);
} else {
egui::ScrollArea::vertical()
.id_salt(("shared-panel-body", panel))
.auto_shrink([false, false])
.show(ui, |ui| crate::dock::panel_body(self.app, ui, panel, width, height));
}
}
fn panel_menu(&mut self, ui: &mut egui::Ui, panel: &String) {
crate::panels::panel_menu_items(self.app, ui, panel);
}
fn group_header_width(&self, _panels: &[String], _active: &String, floating: bool) -> f32 {
if floating { 48.0 } else { 80.0 }
}
fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[String], active: &String, floating: bool) {
let rect = ui.max_rect();
let menu = egui::Rect::from_center_size(rect.right_center() - egui::vec2(12.0, 0.0), egui::vec2(16.0, 16.0));
crate::panels::panel_menu(self.app, ui, active, menu);
let grip = egui::Rect::from_min_max(rect.min, egui::pos2(menu.left() - 4.0, rect.bottom()));
if floating {
let response = ui.put(grip, egui::Button::new("×")).on_hover_text(tl!("Dock Panel"));
if response.clicked() {
self.commands.push(("window.panel.dock", json!({"panel": active, "group": true})));
}
} else {
let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::drag());
if response.drag_started()
&& let Some(pos) = response.interact_pointer_pos()
{
ui.ctx().data_mut(|data| {
data.insert_temp(
egui::Id::new("vectorcraft-root-group-drag"),
RootGroupDrag {
panel: active.clone(),
layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)),
hidden: self.app.ui.docking_hidden.clone(),
generation: self.app.ui.docking_generation,
},
)
});
self.commands.push(("window.panel.float", json!({"panel": active, "group": true, "x": pos.x, "y": pos.y})));
}
response.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
}
}
}
pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
let root_key = egui::Id::new("vectorcraft-root-group-drag");
if let Some(drag) = ui.ctx().data(|data| data.get_temp::<RootGroupDrag>(root_key)) {
if drag.generation != app.ui.docking_generation {
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
} else if ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
app.ui.docking = Some(drag.layout);
app.ui.docking_hidden = drag.hidden;
sync_legacy(app);
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
} else if let Some(pos) = ui.input(|input| input.pointer.interact_pos()) {
if ui.input(|input| input.pointer.primary_down()) {
if let Err(error) = app.run("window.panel.float", json!({"panel":drag.panel, "group":true, "x":pos.x, "y":pos.y})) {
app.ui.status = error;
}
if let Some(panel) = normalize(&drag.panel)
&& let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
crate::floating::preview(ui.ctx(), target, rect);
}
} else {
if let Some(panel) = normalize(&drag.panel)
&& let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
let (id, params) = match target {
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})),
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})),
crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"below":onto})),
crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"above":onto})),
crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":true,"column":slot})),
};
if let Err(error) = app.run(id, params) {
app.ui.status = error;
}
}
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
}
}
}
let area = egui::Id::new("vectorcraft-panel-docking");
let changed_workspace = ui.ctx().data_mut(|data| {
let previous = data.get_temp::<u64>(area.with("generation"));
data.insert_temp(area.with("generation"), app.ui.docking_generation);
previous.is_some_and(|generation| generation != app.ui.docking_generation)
});
if changed_workspace {
craft_ui::docking::cancel_drag::<String>(ui.ctx(), area);
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(area.with("legacy-origin")));
}
let cancelled =
ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released()));
if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() {
return false;
}
let Some(layout) = app.ui.docking.as_ref() else { return false };
if !valid(layout) {
app.ui.docking = None;
app.ui.status = "Invalid panel layout; restored the default dock".into();
return false;
}
let layout = layout.clone();
let t = crate::theme::Tokens::get(ui.ctx());
let collapsed = app.ui.dock_collapsed;
let mut display = if collapsed { Layout { root: None, floating: layout.floating.clone() } } else { layout.clone() };
display
.floating
.retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id))));
let mut draw = |ui: &mut egui::Ui| {
if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) {
let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed}));
}
if collapsed {
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
for panel in root.panels() {
let info = crate::state::DockTab::from_id(panel)
.map(|tab| tab.info())
.or_else(|| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == panel).copied());
if let Some((_, label, icon)) = info
&& crate::widgets::icon_button(ui, icon, tl!(label), app.ui.open_panel.as_ref() == Some(panel), 30.0).clicked()
{
app.ui.open_panel = if app.ui.open_panel.as_ref() == Some(panel) { None } else { Some(panel.clone()) };
}
}
}
let mut style = craft_ui::docking::DockStyle::from_ui(ui);
style.tab_height = 33.0;
style.background = t.panel;
style.tab_background = t.tab_strip;
style.active_background = t.panel;
style.text = t.text_strong;
style.inactive_text = t.text_dim;
style.border = egui::Stroke::new(1.0, t.border);
style.accent = t.accent;
style.float_label = tl!("Float Panel").to_string();
style.close_label = tl!("Close Panel").to_string();
style.move_label = tl!("Group with").to_string();
style.panels_label = tl!("Panels").to_string();
style.resize_label = tl!("Resize panels").to_string();
style.resize_window_label = tl!("Resize panel window").to_string();
let mut content = PanelContent { app, commands: Vec::new() };
let output = craft_ui::docking::DockArea::new(egui::Id::new("vectorcraft-panel-docking")).show_customized(
ui,
&display,
&style,
|id| crate::state::all_panels().find(|p| p.0 == id).map(|p| tl!(p.1).to_string()).unwrap_or_else(|| id.clone()),
|_| craft_ui::docking::Permissions::default(),
|_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() },
&mut content,
);
for (id, params) in content.commands {
if let Err(error) = app.run(id, params) {
app.ui.status = error;
}
}
output
};
let output = if layout.root.is_some() {
let panel = egui::Panel::right("shared-panel-dock")
.default_size(310.0)
.size_range(220.0..=720.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel));
let panel = if collapsed { panel.exact_size(38.0).resizable(false) } else { panel };
let shown = panel.show(ui, &mut draw);
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::dock_rect_id(), shown.response.rect));
shown.inner
} else {
draw(ui)
};
if let Some(panel) = output.group_drag.as_deref().and_then(normalize)
&& let Some(pos) = ui.input(|input| input.pointer.interact_pos())
&& let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
crate::floating::preview(ui.ctx(), target, rect);
}
if let Some(error) = output.error {
app.ui.status = error.to_string();
}
// Tab and icon tears finish as model actions; title moves finish as group_drop.
// Native sets are absent from the shared render tree, so their pointer target must win
// over a float proposal or a root target drawn behind the native window.
let tab_drop = ui.input(|input| input.pointer.primary_released().then(|| input.pointer.interact_pos()).flatten()).and_then(|pos| {
output.actions.iter().find_map(|action| {
let panel = match action {
Action::Float { panel, .. } | Action::Move { panel, .. } => normalize(panel)?,
_ => return None,
};
crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
.filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock))
.map(|(target, _)| (panel, target))
})
});
if ui.input(|input| input.pointer.any_released()) {
let native_drop = output.group_drop.as_ref().is_some_and(|(panel, pos)| {
normalize(panel).and_then(|id| crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(id), *pos)).is_some()
});
if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. }))
&& !native_drop
&& tab_drop.is_none()
&& cancel_legacy_drag(app, ui.ctx())
{
return app.ui.docking.is_some();
}
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin")));
}
for action in output.actions {
if tab_drop
.is_some_and(|(panel, _)| matches!(&action, Action::Float { panel: moved, .. } | Action::Move { panel: moved, .. } if moved == panel))
{
continue;
}
if let Err(error) = app.run("window.panel.layout", json!({"action": action})) {
app.ui.status = error;
}
}
let group_drop = output.group_drop.and_then(|(panel, pos)| {
let panel = normalize(&panel)?;
crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos).map(|(target, _)| (panel, target, true))
});
if let Some((panel, target, whole_group)) = group_drop.or_else(|| tab_drop.map(|(panel, target)| (panel, target, false))) {
let (command, params) = match target {
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})),
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})),
crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"below":onto})),
crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"above":onto})),
crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":whole_group,"column":slot})),
};
// Shared and native windows have already chosen their render ownership for this
// frame. Switch representations only after both renderers finish, so a panel's
// widgets never appear in two layers in the same frame.
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("vectorcraft-native-panel-drop"), (command, params)));
}
for group in &display.floating {
if let Some(first) = group.panels.first()
&& let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(area.with(("floating", first))))
{
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect));
}
}
icon_rail(app, ui);
crate::floating::docked_columns(app, ui);
true
}
fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let column = egui::Panel::right("shared-panel-icons")
.exact_size(38.0)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel))
.show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
for &(id, label, icon) in crate::state::ICON_PANEL_GROUPS
.iter()
.flat_map(|group| group.iter())
.filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id))
{
if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) {
continue;
}
let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0));
let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag());
let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), app.ui.open_panel.as_deref() == Some(id));
if response.clicked() {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) };
}
legacy_tab(app, ui, id, &response);
}
});
})
.response
.rect;
ui.ctx().data_mut(|data| {
let dock = if app.ui.docking.as_ref().is_some_and(|layout| layout.root.is_some()) {
data.get_temp::<egui::Rect>(crate::floating::dock_rect_id()).unwrap_or(column)
} else {
column
};
data.insert_temp(crate::floating::dock_rect_id(), dock.union(column));
data.insert_temp(crate::floating::icons_rect_id(), column);
data.insert_temp(egui::Id::new("dock-icon-column-left"), column.left());
});
}
pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
let Some(panel) = normalize(panel) else { return };
response.context_menu(|ui| {
if ui.button(tl!("Float Panel")).clicked() {
if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) {
app.ui.status = error;
}
ui.close();
}
ui.menu_button(tl!("Group with"), |ui| {
for (anchor, label) in crate::state::all_panels() {
if anchor != panel && ui.button(tl!(label)).clicked() {
// A closed icon panel is made visible before becoming the destination.
let result = app
.run("window.panel.activate", json!({"panel": anchor}))
.and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor})));
if let Err(error) = result {
app.ui.status = error;
}
ui.close();
}
}
});
});
if response.drag_started()
&& let Some(id) = normalize(panel)
{
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let bounds = ui.max_rect();
let source = egui::Rect::from_min_size(
egui::pos2(bounds.left(), response.rect.top()),
egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)),
);
if ensure(&mut layout, id).is_ok()
&& craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("vectorcraft-panel-docking"), id.to_string(), source)
{
ui.ctx().data_mut(|data| {
data.insert_temp(
egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"),
LegacyDrag {
layout: app.ui.docking.clone(),
open_panel: app.ui.open_panel.clone(),
floating: app.ui.floating_panels.clone(),
icons: app.ui.docking_icons.clone(),
},
)
});
if !app.ui.docking.as_ref().unwrap_or(&legacy(app)).contains(&id.to_string())
&& crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id)
{
app.ui.docking_icons.insert(id.into());
}
app.ui.docking = Some(layout);
app.ui.open_panel = None;
}
}
}
// Keep the legacy inspection schema useful; shared layout remains the rendering authority.
pub(crate) fn decorated(group: &crate::state::FloatingPanels) -> bool {
group.column.is_some() || group.docked.is_some() || group.collapsed
}
/// Keep the native set metadata alongside the validated shared ownership tree. Return locations
/// and root splits survive the mirror update; failed reconciliation restores the whole UI state.
pub(crate) fn reconcile_native(app: &mut VectorcraftApp, before: crate::state::UiState) -> Result<(), String> {
if before.docking.is_none() {
return Ok(());
}
let desired = app.ui.floating_panels.clone();
let result = (|| {
let prior = before.docking.as_ref().ok_or("missing panel layout")?;
app.ui.docking = Some(prior.clone());
for group in &prior.floating {
for panel in &group.panels {
if !desired.iter().any(|g| g.panels.contains(panel)) {
change(app, "dock", &json!({"panel":panel}))?;
}
}
}
let mut layout = app.ui.docking.clone().ok_or("missing panel layout")?;
for group in &desired {
for panel in &group.panels {
if (Layout { root: layout.root.clone(), floating: vec![] }).contains(panel) {
if let Ok(location) = layout.location(panel) {
app.ui.docking_hidden.insert(panel.clone(), location);
}
layout.apply(Action::Close { panel: panel.clone() }).map_err(|e| e.to_string())?;
}
}
}
layout.floating = desired
.iter()
.map(|group| {
let height = prior.floating.iter().find(|g| g.panels.iter().any(|id| group.panels.contains(id))).map_or(400.0, |g| g.rect[3]);
Floating {
panels: group.panels.clone(),
active: group.active,
rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0), height],
}
})
.collect();
if !valid(&layout) {
return Err("invalid native panel set".into());
}
app.ui.docking = Some(layout);
app.ui.floating_panels = desired;
Ok(())
})();
if result.is_err() {
app.ui = before;
}
result
}
fn native_change(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result<Value, String> {
let before = app.ui.clone();
let result = (|| {
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
if crate::floating::group_of(&app.ui, panel).is_none() {
let mut initial = params.clone();
if let Some(object) = initial.as_object_mut() {
for key in ["above", "below", "column", "collapsed", "onto"] {
object.remove(key);
}
}
change(app, "float", &initial)?;
}
let baseline = app.ui.clone();
let value = crate::floating::command(app, id, params).ok_or("unsupported native docking operation")??;
reconcile_native(app, baseline)?;
Ok(value)
})();
if result.is_err() {
app.ui = before;
}
result
}
fn sync_legacy(app: &mut VectorcraftApp) {
if let Some(layout) = &app.ui.docking {
let old = app.ui.floating_panels.clone();
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, active } => {
if let Some(tab) = panels.get(*active).and_then(|id| crate::state::DockTab::from_id(id)) {
app.ui.dock_tab = tab;
}
}
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
_ => {}
}
}
app.ui.floating_panels = layout
.floating
.iter()
.map(|group| crate::state::FloatingPanels {
panels: group.panels.clone(),
active: group.active,
pos: [group.rect[0], group.rect[1]],
width: Some(group.rect[2]),
column: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.column),
collapsed: old
.iter()
.find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first)))
.is_some_and(|g| g.collapsed),
docked: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.docked),
})
.collect();
}
}
#[cfg(test)]
#[path = "panel_docking_tests.rs"]
mod tests;
/// Commit a pointer drop after shared and native panel windows have finished rendering.
pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) {
if let Some((command, params)) =
ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop")))
&& let Err(error) = app.run(command, params)
{
app.ui.status = error;
}
}
+576
View File
@@ -0,0 +1,576 @@
use super::*;
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
fn app() -> VectorcraftApp {
VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default())
}
#[test]
fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap();
let before_float = app.ui.docking.clone().unwrap();
app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"]));
app.run("window.panel.dock", json!({"panel":"properties"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
app.run("window.panel.close", json!({"panel":"properties"})).unwrap();
assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into()));
app.run("window.panel.activate", json!({"panel":"properties"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
let layout = app.ui.docking.as_ref().unwrap();
assert!(valid(layout));
let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect();
assert_eq!(unique.len(), layout.panels().len());
let saved = serde_json::to_value(&app.ui).unwrap();
let restored: crate::state::UiState = serde_json::from_value(saved).unwrap();
assert_eq!(restored.docking, app.ui.docking);
assert_eq!(restored.docking_hidden, app.ui.docking_hidden);
}
#[test]
fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() {
let mut app = app();
for (id, params) in [
("window.panel.move", json!({"panel":"layers","anchor":"unknown"})),
("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})),
("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})),
("window.panel.float", json!({"panel":"layers","width":-10})),
("window.panel.float", json!({"panel":"unknown"})),
("window.panel.float", json!({"panel":"layers","x":"invalid"})),
] {
let old = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
assert!(app.run(id, params).is_err(), "{id}");
assert_eq!(app.ui.docking, old);
assert_eq!(app.ui.docking_hidden, hidden);
}
}
fn harness(app: VectorcraftApp, size: egui::Vec2) -> Harness<'static, VectorcraftApp> {
let mut ready = false;
let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
egui_extras::install_image_loaders(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark);
ready = true;
return;
}
crate::floating::track(app, ui.ctx());
crate::dock::show(app, ui);
egui::CentralPanel::default().show(ui, |_| {});
crate::floating::show(app, ui.ctx());
},
app,
);
harness.run_steps(4);
harness
}
fn drag(h: &mut Harness<'static, VectorcraftApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerMoved(from));
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for step in 1..=8 {
h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0)));
h.run_steps(1);
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
drag(&mut h, source, egui::pos2(160.0, 140.0));
let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking");
assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}");
let source = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
let destination = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
drag(&mut h, source, destination);
let layout = h.state().ui.docking.as_ref().unwrap();
assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into())));
assert!(valid(layout));
}
#[test]
fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap();
app.run("window.panel.close", json!({"panel":"swatches"})).unwrap();
let layout = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
app.run("window.workspace.new", json!({"name":"Custom docking"})).unwrap();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
assert!(app.ui.docking.is_none());
app.run("window.workspace", json!({"name":"Custom docking"})).unwrap();
assert_eq!(app.ui.docking, layout);
assert_eq!(app.ui.docking_hidden, hidden);
}
#[test]
fn legacy_float_group_aliases_use_atomic_shared_groups_and_preserve_tools() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers","group":true,"x":30,"y":50})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 3);
app.run("window.panel.float", json!({"panel":"swatches","onto":"layers"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 4);
let before = app.ui.docking.clone();
assert!(app.run("window.panel.float", json!({"panel":"properties","onto":"missing","group":true})).is_err());
assert_eq!(app.ui.docking, before);
app.run("window.panel.float", json!({"panel":"tools","x":70,"y":90})).unwrap();
assert_eq!(app.ui.toolbar_pos, Some([70.0, 90.0]));
assert_eq!(app.ui.docking, before);
app.run("window.panel.dock", json!({"panel":"layers","group":true})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().floating.is_empty());
}
/// Explicit offscreen evidence; this creates no native windows.
#[test]
fn capture_custom_panel_docking_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for theme in crate::theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap();
let mut ready = false;
let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), theme);
ready = true;
return;
}
crate::dock::show(app, ui);
egui::CentralPanel::default().show(ui, |ui| {
ui.label("Custom panel workspace");
});
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(5);
assert!(valid(harness.state().ui.docking.as_ref().unwrap()));
harness.render().unwrap().save(directory.join(format!("vectorcraft-shared-docking-{}-{width}-{scale}x.png", theme.id()))).unwrap();
}
}
}
}
#[test]
fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
assert!(h.state().ui.docking.is_some());
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
assert!(h.state().ui.docking.is_none());
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(h.state().ui.docking.is_none());
}
#[test]
fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap();
assert!(
matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001)
);
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap();
assert_eq!(
app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect,
[44.0, 66.0, 360.0, 450.0]
);
app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}}))
.unwrap();
assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]);
let before = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
for action in [
json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}),
json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}),
json!({"Open":{"panel":"unknown","anchor":null}}),
json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}),
] {
assert!(app.run("window.panel.layout", json!({"action":action})).is_err());
assert_eq!(app.ui.docking, before);
assert_eq!(app.ui.docking_hidden, hidden);
}
app.ui.docking = Some(Layout {
root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }),
floating: Vec::new(),
});
app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap();
app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap();
let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") };
assert!(!entries[0].open);
assert_eq!(entries[0].height, Some(150.0));
}
fn group_members_for_test(layout: &Layout<String>, panel: &str) -> Vec<String> {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(),
Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]),
_ => {}
}
}
Vec::new()
}
#[test]
fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() {
let mut app = app();
app.run("file.new", json!({"width":480,"height":360})).unwrap();
let id = app.run("shape.rectangle", json!({"x":80,"y":70,"width":250,"height":160})).unwrap()["id"].clone();
app.run("select.set", json!({"ids":[id]})).unwrap();
app.run("paint.setFill", json!({"color":"#5297bb"})).unwrap();
app.run("stroke.set", json!({"weight":8})).unwrap();
app.run("window.panel.float", json!({"panel":"stroke","x":40,"y":140,"width":310,"height":490})).unwrap();
let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from);
let builder = Harness::builder().with_size(vec2(1280.0, 800.0));
let builder = if directory.is_some() { builder.wgpu() } else { builder };
let mut h = builder.build_ui_state(
|ui, app: &mut VectorcraftApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.input_mut().max_texture_side = Some(8192);
h.run_steps(5);
let mut pending: Vec<_> = h.output().shapes.iter().map(|shape| &shape.shape).collect();
let mut checkbox = None;
while let Some(shape) = pending.pop() {
match shape {
egui::Shape::Text(text) if text.galley.text() == "Dashed Line" => {
checkbox = Some(text.visual_bounding_rect().center());
break;
}
egui::Shape::Vec(shapes) => pending.extend(shapes),
_ => {}
}
}
let at = checkbox.expect("painted Dashed Line checkbox");
h.hover_at(at);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
let dash = crate::panels::current_stroke(h.state()).unwrap().dash.unwrap();
assert_eq!(dash.pattern, [12.0, 12.0]);
if let Some(directory) = &directory {
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-floating.png")).unwrap();
}
let from = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center();
let to = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
drag(&mut h, from, to);
assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty());
assert_eq!(crate::panels::current_stroke(h.state()).unwrap().dash.unwrap(), dash);
if let Some(directory) = &directory {
h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-redocked.png")).unwrap();
}
}
#[test]
fn changing_workspace_during_a_drag_cancels_the_stale_gesture() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let destination = app.ui.docking.clone();
app.run("window.workspace.new", json!({"name":"Destination"})).unwrap();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap();
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
assert_eq!(h.state().ui.docking, destination);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.docking, destination);
}
#[test]
fn floating_icon_groups_redock_to_the_rail_and_keep_saved_origins() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"color"})).unwrap();
app.run("window.panel.float", json!({"panel":"swatches", "onto":"color"})).unwrap();
assert_eq!(app.ui.docking_icons.len(), 2);
let workspace = crate::workspaces::capture(&app.ui, "Icon origins");
let saved = serde_json::to_vec(&workspace).unwrap();
let restored: crate::workspaces::Workspace = serde_json::from_slice(&saved).unwrap();
crate::workspaces::apply(&mut app.ui, &restored);
app.run("window.panel.dock", json!({"panel":"color", "group":true})).unwrap();
let layout = app.ui.docking.as_ref().unwrap();
assert!(!layout.contains(&"color".into()));
assert!(!layout.contains(&"swatches".into()));
assert!(app.ui.floating_panels.is_empty());
assert_eq!(app.ui.open_panel.as_deref(), Some("color"), "the requested icon pops out after redocking");
app.run("window.panel", json!({"panel":"color"})).unwrap();
assert!(app.ui.open_panel.is_none(), "the same icon puts it away");
app.run("window.panel", json!({"panel":"color"})).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("color"));
}
#[test]
fn all_dock_group_origins_survive_float_and_return() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers", "group":true})).unwrap();
assert_eq!(app.ui.floating_panels[0].panels.len(), 3);
assert!(app.ui.docking_hidden.contains_key("properties"));
assert!(app.ui.docking_hidden.contains_key("layers"));
assert!(app.ui.docking_hidden.contains_key("libraries"));
app.run("window.panel.dock", json!({"panel":"layers", "group":true})).unwrap();
let layout = app.ui.docking.as_ref().unwrap();
assert!(layout.floating.is_empty());
assert_eq!(layout.panels().into_iter().map(String::as_str).collect::<Vec<_>>(), ["properties", "layers", "libraries"]);
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
assert!(valid(layout));
}
#[test]
fn panel_specific_menu_executes_after_first_float_regroup_and_reload() {
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":100})).unwrap();
let mut h = harness(app, vec2(1280.0, 900.0));
for stage in 0..3 {
if stage == 1 {
h.state_mut().run("window.panel.float", json!({"panel":"color","x":60,"y":120})).unwrap();
h.state_mut().run("window.panel.float", json!({"panel":"swatches","onto":"color"})).unwrap();
} else if stage == 2 {
let saved = serde_json::to_vec(&h.state().ui).unwrap();
h.state_mut().ui = serde_json::from_slice(&saved).unwrap();
}
h.run_steps(4);
let at = h
.query_all_by_label("Panel menu")
.find(|node| node.rect().center().x < 800.0)
.expect("floating panel menu has an accessible button")
.rect()
.center();
h.hover_at(at);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
let action = h.query_all_by_label(" New Swatch…").next().expect("app-specific menu action remains reachable").rect().center();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: action, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
assert_eq!(h.state().ui.dialog.as_ref().map(|dialog| dialog.kind.as_str()), Some("newSwatch"), "stage {stage}: actual panel action executes");
h.state_mut().ui.dialog = None;
}
}
#[test]
fn iconic_origin_tracks_explicit_custom_dock_and_legacy_saved_floats() {
let mut custom = app();
custom.run("window.panel.move", json!({"panel":"color","anchor":"layers"})).unwrap();
let before = custom.ui.docking.clone().unwrap();
custom.run("window.panel.float", json!({"panel":"color"})).unwrap();
custom.run("window.panel.dock", json!({"panel":"color"})).unwrap();
assert_eq!(custom.ui.docking.as_ref(), Some(&before), "explicit custom tabs are the saved destination");
assert!(custom.ui.open_panel.is_none(), "a docked custom tab is not an icon popout");
let mut legacy = app();
crate::floating::float(&mut legacy.ui, &["color"], "color", egui::pos2(120.0, 160.0));
let saved = serde_json::to_vec(&legacy.ui).unwrap();
legacy.ui = serde_json::from_slice(&saved).unwrap();
assert!(legacy.ui.docking.is_none());
legacy.run("window.panel.dock", json!({"panel":"color"})).unwrap();
assert!(!legacy.ui.docking.as_ref().unwrap().contains(&"color".into()), "legacy icon floats return to rail");
assert_eq!(legacy.ui.open_panel.as_deref(), Some("color"));
}
#[test]
fn native_sets_columns_and_collapse_preserve_shared_splits_and_saved_origins() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let original = app.ui.docking.clone().unwrap();
app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap();
app.run("window.panel.float", json!({"panel":"color","below":"stroke","collapsed":true})).unwrap();
let root = app.ui.docking.as_ref().unwrap().root.clone();
assert_eq!(root, original.root);
let column = app.ui.floating_panels.first().unwrap().column;
assert!(column.is_some());
assert_eq!(app.ui.floating_panels.len(), 2);
assert!(app.ui.floating_panels.iter().all(|g| g.column == column));
app.run("window.panel.dock", json!({"panel":"stroke","group":true,"column":true})).unwrap();
assert!(app.ui.floating_panels.iter().all(|g| g.docked == Some(1)));
app.run("window.workspace.new", json!({"name":"Mixed shared native"})).unwrap();
let saved = app.ui.floating_panels.clone();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
app.run("window.workspace", json!({"name":"Mixed shared native"})).unwrap();
assert_eq!(app.ui.floating_panels, saved);
assert_eq!(app.ui.docking.as_ref().unwrap().root, root);
app.run("window.panel.float", json!({"panel":"stroke","group":true})).unwrap();
assert!(app.ui.floating_panels.iter().all(|g| g.docked.is_none()));
let before = serde_json::to_value(&app.ui).unwrap();
assert!(app.run("window.panel.float", json!({"panel":"stroke","below":"unknown"})).is_err());
// Error reporting may change status, but ownership, metadata and root remain atomic.
let mut after = serde_json::to_value(&app.ui).unwrap();
let mut before = before;
before.as_object_mut().unwrap().remove("status");
after.as_object_mut().unwrap().remove("status");
assert_eq!(before, after);
app.run("window.panel.dock", json!({"panel":"stroke","group":true})).unwrap();
app.run("window.panel.dock", json!({"panel":"color","group":true})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().root, original.root);
assert!(app.ui.floating_panels.is_empty());
assert!(valid(app.ui.docking.as_ref().unwrap()));
}
#[test]
fn native_set_close_pointer_restores_shared_split_ownership() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let root = app.ui.docking.as_ref().unwrap().root.clone();
app.run("window.panel.float", json!({"panel":"stroke","x":70,"y":90})).unwrap();
app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap();
let mut h = harness(app, vec2(1280.0, 900.0));
if let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR") {
let directory = std::path::PathBuf::from(directory);
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(directory.join("vectorcraft-native-set-shared-split.png")).unwrap();
}
let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap();
let at = egui::pos2(area.right() - 10.0, area.top() + 1.0 + crate::floating::TITLE / 2.0);
for pressed in [true, false] {
h.event(Event::PointerMoved(at));
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(3);
assert!(h.state().ui.floating_panels.is_empty());
assert_eq!(h.state().ui.docking.as_ref().unwrap().root, root);
assert!(valid(h.state().ui.docking.as_ref().unwrap()));
}
#[test]
fn a_single_native_tab_redock_retains_the_remaining_float_and_return_origin() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let root = app.ui.docking.as_ref().unwrap().root.clone();
app.run("window.panel.float", json!({"panel":"swatches","x":80,"y":90})).unwrap();
app.run("window.panel.float", json!({"panel":"color","onto":"swatches","collapsed":true})).unwrap();
assert!(
matches!(app.ui.docking.as_ref().unwrap().root.as_ref(), Some(Node::Tabs { panels, active }) if panels.get(*active).is_some_and(|panel| panel == "properties")),
"floating Color intentionally activates the first root fallback, Properties"
);
let mut expected = Layout { root, floating: vec![] };
expected.apply(Action::Activate { panel: "properties".to_string() }).unwrap();
app.run("window.panel.dock", json!({"panel":"swatches"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().root, expected.root);
assert_eq!(app.ui.floating_panels.len(), 1);
assert_eq!(app.ui.floating_panels[0].panels, ["color"]);
assert!(valid(app.ui.docking.as_ref().unwrap()));
}
#[test]
fn explicit_shared_activation_expands_a_native_collapsed_owner() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers","collapsed":true})).unwrap();
assert!(app.ui.floating_panels[0].collapsed);
app.run("window.panel.layout", json!({"action":Action::Activate { panel:"layers".to_string() }})).unwrap();
assert!(!app.ui.floating_panels[0].collapsed);
assert_eq!(app.ui.floating_panels[0].panels, ["layers"]);
assert!(valid(app.ui.docking.as_ref().unwrap()));
}
#[test]
fn escape_restores_native_set_position_and_metadata_after_several_frames() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"stroke","x":80,"y":100})).unwrap();
app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap();
let mut h = harness(app, vec2(1280.0, 900.0));
let original = h.state().ui.floating_panels.clone();
let layout = h.state().ui.docking.clone();
let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap();
let from = area.left_top() + vec2(30.0, 1.0 + crate::floating::TITLE / 2.0);
h.event(Event::PointerMoved(from));
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for step in 1..=8 {
h.event(Event::PointerMoved(from + vec2(step as f32 * 12.0, step as f32 * 9.0)));
h.run_steps(1);
}
assert_ne!(h.state().ui.floating_panels, original, "actual title gesture moved the set");
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.floating_panels, original);
assert_eq!(h.state().ui.docking, layout);
}
#[test]
fn first_legacy_icon_tear_can_join_a_native_set() {
let mut app = app();
crate::floating::float(&mut app.ui, &["stroke"], "stroke", egui::pos2(100.0, 100.0));
crate::floating::float(&mut app.ui, &["color"], "color", egui::pos2(100.0, 100.0));
crate::floating::attach(&mut app.ui, &[1], "stroke", true);
assert!(app.ui.docking.is_none());
let mut h = harness(app, vec2(1280.0, 900.0));
let source = h.query_all_by_label("Swatches").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
let color = h.ctx.data(|data| data.get_temp::<egui::Rect>(crate::floating::group_rect_id("color"))).unwrap();
drag(&mut h, source, egui::pos2(color.center().x, color.bottom() + 2.0));
assert!(h.state().ui.docking.is_some(), "successful first tear retains shared ownership");
let groups = &h.state().ui.floating_panels;
let color = groups.iter().find(|g| g.panels.iter().any(|p| p == "color")).unwrap();
let swatches = groups.iter().find(|g| g.panels.iter().any(|p| p == "swatches")).unwrap();
assert!(color.column.is_some());
assert_eq!(swatches.column, color.column);
assert!(valid(h.state().ui.docking.as_ref().unwrap()));
}
#[test]
fn native_set_drop_targets_respect_the_visible_layer_above_an_ordinary_float() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"swatches","x":100,"y":100})).unwrap();
app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap();
app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap();
let h = harness(app, vec2(1280.0, 900.0));
let rect = h.ctx.data(|data| data.get_temp::<egui::Rect>(crate::floating::group_rect_id("stroke"))).unwrap();
let at = rect.center();
assert_eq!(h.ctx.layer_id_at(at).unwrap().id, crate::floating::area_id("stroke"), "native set is visibly above the ordinary shared float");
let target = crate::floating::drop_target(h.state(), &h.ctx, crate::floating::Moving::Panel("layers"), at).unwrap().0;
assert!(
matches!(target, crate::floating::Drop::Above("stroke") | crate::floating::Drop::Below("stroke")),
"visible native group owns the drop: {target:?}"
);
}
+140 -21
View File
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, NodeId, NodeKind};
use vectorcraft_tools::{Mods, ToolKey};
use super::{first_selected, pstate, set_pstate};
use super::{pstate, set_pstate};
use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::widgets::{self, SpinPick, menu_item};
@@ -20,26 +20,62 @@ pub const SIZE_PRESETS: [f64; 15] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0,
pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0];
/// The character style shown by the panel: the selected range's (or the caret's) while the Type
/// tool edits text, else the first selected text object's first run. With it, the paragraph
/// The stored character style: the selected range's (or the caret's) while the Type
/// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph
/// attributes of the paragraph the selection starts in (else of the first paragraph).
/// Font/paragraph controls and named-style capture use this; dimensional fields use `shared_effective`.
pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> {
if let Some((id, a, b)) = text_editing(app)
&& let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind)
{
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone()));
}
let n = first_selected(app)?;
match &n.kind {
NodeKind::Text(t) => Some((t.first_style(), t.para_at(0).clone())),
_ => None,
let t = first_selected_text(app)?;
Some((t.first_style(), t.para_at(0).clone()))
}
/// The first selected text descendant, shared by Character and Paragraph controls.
pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> {
let st = app.session.active()?;
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
let mut text = None;
node.walk(&mut |n| {
if text.is_none()
&& let NodeKind::Text(t) = &n.kind
{
text = Some(t.as_ref());
}
});
if text.is_some() {
return text;
}
}
None
}
/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
/// blank field) where they differ, or with no type.
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
shared_styles(app, |_, style| Some(f(style)))
}
/// Dimensional controls use document units and tolerate arithmetic roundoff when comparing
/// compensated runs. Keep raw styles available to font/paragraph controls and style definitions,
/// even if a collapsed affine prevents editing dimensions.
fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Option<f64> {
shared_styles(app, |t, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0)
}
struct Metric(f64);
impl PartialEq for Metric {
fn eq(&self, other: &Self) -> bool {
(self.0 - other.0).abs() <= 1e-9 * self.0.abs().max(other.0.abs()).max(1.0)
}
}
fn shared_styles<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject, &CharStyle) -> Option<T>) -> Option<T> {
let st = app.session.active()?;
let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t),
@@ -49,26 +85,26 @@ pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle)
if let Some((id, a, b)) = text_editing(app) {
let t = text(id)?;
if b <= a {
return Some(f(&vectorcraft_text::edit::insertion_style(&t.runs, a, b)));
return f(t, &vectorcraft_text::edit::insertion_style(&t.runs, a, b));
}
let mut end = 0;
for r in &t.runs {
let start = end;
end += r.text.len();
if start < b && end > a {
acc.add(f(&r.style));
acc.add(f(t, &r.style));
}
}
} else {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
t.runs.iter().for_each(|r| acc.add(f(&r.style)));
t.runs.iter().for_each(|r| acc.add(f(t, &r.style)));
}
});
}
}
acc.value()
acc.value().flatten()
}
/// The value a run of styles share so far ([`shared`]).
@@ -402,7 +438,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw));
cell(ui, "A↕", tl!("Leading"), |ui| {
let leading = shared(app, CharStyle::effective_leading);
let leading = shared_effective(app, CharStyle::effective_leading);
let auto = shared(app, |s| s.leading.is_none()) == Some(true);
match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) {
Some(SpinPick::Value(v)) => style(app, json!({"leading": v})),
@@ -427,18 +463,18 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
return;
}
cell(ui, "IT", tl!("Vertical Scale %"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
if let Some(v) = widgets::spin_plain(ui, "ch-vs", shared(app, |s| s.v_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
format(app, json!({"vScale": v}));
}
});
cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
if let Some(v) = widgets::spin_plain(ui, "ch-hs", shared_effective(app, |s| s.h_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
format(app, json!({"hScale": v}));
}
});
ui.end_row();
cell(ui, "Aª", tl!("Baseline Shift"), |ui| {
if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
if let Some(v) = widgets::spin_field(ui, "ch-bs", shared_effective(app, |s| s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
format(app, json!({"baselineShift": v}));
}
});
@@ -454,7 +490,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
/// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or
/// picked from the presets, in the type unit, blank where the selected text's sizes differ.
pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) {
let size = shared(app, |s| s.size);
let size = shared_effective(app, |s| s.size);
if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) {
style(app, json!({"size": v}));
}
@@ -619,6 +655,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
});
// Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`).
let proportional = s.as_ref().is_some_and(|s| s.proportional_metrics);
if menu_item(ui, tl!("Proportional Metrics"), has, proportional) {
format(app, json!({"proportionalMetrics": !proportional}));
}
ui.separator();
}
for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] {
@@ -646,6 +687,81 @@ mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone();
app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap();
let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap();
app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap();
for (actual, expected) in [
(shared_effective(&app, |s| s.size), 7.0),
(shared_effective(&app, CharStyle::effective_leading), 9.0),
(shared_effective(&app, |s| s.h_scale), 130.0),
(shared_effective(&app, |s| s.baseline_shift), 3.0),
] {
assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8);
}
}
#[test]
fn scaled_collapsed_text_keeps_nondimensional_controls_available() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap();
app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
assert!(text_style(&app).is_some());
assert_eq!(shared(&app, |s| s.tracking), Some(0.0));
assert_eq!(shared_effective(&app, |s| s.size), None);
app.run("text.setStyle", json!({"tracking": 40})).unwrap();
app.run("text.setFormat", json!({"leftIndent": 4})).unwrap();
assert_eq!(shared(&app, |s| s.tracking), Some(40.0));
assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0);
let ctx = egui::Context::default();
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show);
assert!(!texts.iter().any(|(s, _)| s == "No text selected"));
}
#[test]
fn scaled_character_fields_show_document_sizes_and_mixed_group_values() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone();
app.run("object.scale", json!({"sx": 200})).unwrap();
assert_eq!(shared_effective(&app, |s| s.size), Some(24.0));
assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes");
let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("object.group", json!({})).unwrap();
assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size");
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0));
app.run("text.setStyle", json!({"size": 10})).unwrap();
assert_eq!(shared_effective(&app, |s| s.size), Some(10.0));
app.run("text.setFormat", json!({"hScale": 100})).unwrap();
assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0));
}
#[test]
fn scaled_range_fields_follow_the_type_tool_selection() {
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": "Axis label", "size": 12})).unwrap()["id"].clone();
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
assert!(text_editing(&app).is_some());
app.run("object.scale", json!({"sx": 200})).unwrap();
app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap();
app.session.set_tool_option("selectAll", &json!(true));
assert_eq!(shared_effective(&app, |s| s.size), None);
app.session.set_tool_option("select", &json!({"start": 0, "end": 4}));
assert_eq!(shared_effective(&app, |s| s.size), Some(10.0));
assert_eq!(text_style(&app).unwrap().0.size, 5.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() {
@@ -673,17 +789,20 @@ mod tests {
assert!(!crate::menus::enabled(&app, "type.bold"));
}
/// Character Alignment is in the panel menu with the East Asian options only (as Mojikumi Set
/// and Top-to-Top Leading are in the Paragraph panel).
/// Character Alignment and Proportional Metrics are in the panel menu with the East Asian
/// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel).
#[test]
fn character_alignment_shows_with_the_east_asian_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Alignment");
assert!(!shown(&mut app));
let shown = |app: &mut VectorcraftApp| {
let text = crate::tests_labels::painted_text(app, menu);
(text.contains("Character Alignment"), text.contains("Proportional Metrics"))
};
assert_eq!(shown(&mut app), (false, false));
app.session.prefs.show_east_asian_options = true;
assert!(shown(&mut app));
assert_eq!(shown(&mut app), (true, true));
}
}
+1
View File
@@ -200,6 +200,7 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, ui.id().with(("panel-menu", id)), Sense::click());
icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panel menu")));
let resp = resp.on_hover_text(tl!("Panel menu"));
egui::Popup::menu(&resp).show(|ui| {
crate::widgets::menu_scroll(ui, |ui| {
+7 -3
View File
@@ -42,9 +42,11 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
/// right to left: its Paragraph Direction, else from its first strong character?
fn resolved_rtl(app: &VectorcraftApp, para: &ParaStyle) -> bool {
let editing = super::character::text_editing(app);
let selected = super::first_selected(app);
let node = editing.and_then(|(id, _, _)| app.session.active().and_then(|d| d.doc.node(id))).or(selected.as_ref());
let Some(NodeKind::Text(t)) = node.map(|n| &n.kind) else { return para.direction == Some(ParaDirection::RightToLeft) };
let text = editing.and_then(|(id, _, _)| match app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.as_ref()),
_ => None,
});
let Some(t) = text.or_else(|| super::character::first_selected_text(app)) else { return para.direction == Some(ParaDirection::RightToLeft) };
let plain = t.plain_text();
let paragraph = vectorcraft_text::edit::paragraph_at(&plain, editing.map_or(0, |(_, a, _)| a));
vectorcraft_text::paragraph_is_rtl(plain.get(paragraph).unwrap_or_default(), para.direction)
@@ -246,6 +248,8 @@ mod tests {
shown_alignment(para.justify, resolved_rtl(app, &para))
};
assert_eq!(shown(&app), Justify::Right);
app.session.execute("object.group", &json!({})).unwrap();
assert_eq!(shown(&app), Justify::Right, "group controls resolve the same text descendant's direction");
app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
assert_eq!(shown(&app), Justify::Left);
app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap();
+41 -9
View File
@@ -356,13 +356,11 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
});
divider(ui);
section_header(ui, tl!("Snap Options"));
let mut sp = app.ui.view.snap_to_point;
if ui.checkbox(&mut sp, tl!("Snap to Point")).changed() {
app.ui.view.snap_to_point = sp;
if widgets::check(ui, tl!("Snap to Point"), app.ui.view.snap_to_point, true) {
app.ui.view.snap_to_point ^= true;
}
let mut sg = app.ui.view.snap_to_grid;
if ui.checkbox(&mut sg, tl!("Snap to Grid")).changed() {
app.ui.view.snap_to_grid = sg;
if widgets::check(ui, tl!("Snap to Grid"), app.ui.view.snap_to_grid, true) {
app.ui.view.snap_to_grid ^= true;
}
divider(ui);
section_header(ui, tl!("Preferences"));
@@ -373,9 +371,9 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
app.session.prefs.keyboard_increment = v.max(0.001);
}
});
let mut ss = app.session.prefs.scale_strokes;
if ui.checkbox(&mut ss, tl!("Scale Strokes & Effects")).changed() {
super::transform::set_pref(app, "scaleStrokes", ss);
let ss = app.session.prefs.scale_strokes;
if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) {
super::transform::set_pref(app, "scaleStrokes", !ss);
}
divider(ui);
section_header(ui, tl!("Quick Actions"));
@@ -584,6 +582,40 @@ mod tests {
frame(app, ctx, vec![b(false)]);
}
/// The Snap Options and Scale Strokes & Effects checkboxes keep a visible box at rest in every
/// theme, as the Preferences ones do (#1080), and toggle when clicked.
#[test]
fn the_document_checkboxes_show_their_boxes_and_toggle() {
for brightness in crate::theme::Brightness::ALL {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, brightness);
frame(&mut app, &ctx, vec![]);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui));
out.textures_delta.clear();
let boxes = out
.shapes
.iter()
.filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.stroke.width > 0.0 && (r.rect.width() - 13.0).abs() < 0.5 && (r.rect.height() - 13.0).abs() < 0.5))
.count();
assert!(boxes >= 3, "{brightness:?}: {boxes} checkbox outlines");
}
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = frame(&mut app, &ctx, vec![]);
let (grid, strokes) = (app.ui.view.snap_to_grid, app.session.prefs.scale_strokes);
click(&mut app, &ctx, &texts, "Snap to Grid");
assert_eq!(app.ui.view.snap_to_grid, !grid);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Scale Strokes & Effects");
assert_eq!(app.session.prefs.scale_strokes, !strokes);
}
/// #921: with the Gradient tool, a Gradient section: the type, and for a freeform gradient
/// Draw as Points or Lines radio buttons.
#[test]
@@ -61,6 +61,38 @@ fn a_picked_font_size_preset_applies_in_one_undo_step() {
assert_eq!(undo_len(&app), undo + 1, "one undo step");
}
#[test]
fn scaled_font_size_fields_and_preset_use_document_points() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let (mut app, id) = app_with_text();
run(&mut app, "object.scale", json!({"sx": 200}));
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt"));
assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt"));
let at = Cell::new(egui::Pos2::ZERO);
frame_events(&ctx, &mut app, vec![], size_combo(&at));
click(&ctx, &mut app, at.get() + egui::vec2(110.0, 13.0), size_combo(&at));
let t = frame_events(&ctx, &mut app, vec![], size_combo(&at));
let undo = undo_len(&app);
click(&ctx, &mut app, text_rect(&t, "10 pt").center(), size_combo(&at));
assert_eq!(style(&app, id).size * 2.0, 10.0);
assert_eq!(undo_len(&app), undo + 1);
assert!(shows(&frame_events(&ctx, &mut app, vec![], size_combo(&at)), "10 pt"));
}
#[test]
fn scaled_group_shows_character_and_control_bar_size_fields() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let (mut app, _) = app_with_text();
run(&mut app, "object.group", json!({}));
run(&mut app, "object.scale", json!({"sx": 200}));
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt"));
assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt"));
run(&mut app, "text.setStyle", json!({"size": 10}));
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "10 pt"));
}
#[test]
fn font_size_presets_show_in_the_type_unit() {
let ctx = egui::Context::default();
+29 -5
View File
@@ -363,9 +363,10 @@ fn picas_in_use(d: &Dialog) -> bool {
}
fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let mut b = d.bool(key);
if ui.checkbox(&mut b, tl!(label)).changed() {
d.fields.insert(key.into(), json!(b));
// The app's outlined checkbox (it translates `label`): egui's own has no border at rest.
let on = d.bool(key);
if widgets::check(ui, label, on, ui.is_enabled()) {
d.fields.insert(key.into(), json!(!on));
}
}
@@ -386,6 +387,28 @@ mod tests {
use crate::Services;
use vectorcraft_engine::Session;
/// A resting checkbox must have a visible outline in both states and every brightness.
#[test]
fn preference_checkboxes_draw_outlines_without_hover() {
for brightness in Brightness::ALL {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
theme::apply(&ctx, brightness);
for checked in [false, true] {
let mut d = Dialog::new("preferences", json!({"showToolTips": checked}));
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 200.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| bool_row(ui, &mut d, "showToolTips", "Show Tool Tips"));
out.textures_delta.clear();
let outlined = out.shapes.iter().any(|shape| match &shape.shape {
egui::Shape::Rect(r) => r.stroke.width > 0.0 && r.rect.width() <= 24.0 && r.rect.height() <= 24.0,
_ => false,
});
assert!(outlined, "{brightness:?}, checked={checked}: no checkbox outline");
}
}
}
/// User Interface › Canvas Color (#663): White and the three greys paint the canvas around the
/// artboards; Match User Interface Brightness keeps the theme's.
#[test]
@@ -560,8 +583,9 @@ mod tests {
out.textures_delta.clear();
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
assert!(shown.iter().any(|t| t == "System Title Bar"), "{shown:?}");
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos")));
assert_eq!(shown.iter().any(|t| t == "System Title Bar"), supported, "{shown:?}");
assert_eq!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), supported, "{shown:?}");
a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(true));
confirm(&mut a).unwrap();
assert!(a.session.prefs.system_title_bar);
+6 -3
View File
@@ -624,7 +624,8 @@ mod tests {
out.textures_delta.clear();
};
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
// The undo steps and the journal entries (the dialog's OK runs its command, which the journal records).
let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len());
app.select_tool("selection");
frame(&mut app, vec![enter()]);
assert_eq!(kind(&app), None, "nothing is selected");
@@ -645,9 +646,11 @@ mod tests {
frame(&mut app, vec![]);
assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: and it stays open");
assert_eq!(steps(&app), before, "{tool}: the Enter that opened it didn't confirm it");
// The next Enter is its OK.
// The next Enter is its OK. With its default values only Reflect changes the art, so only
// Reflect records an undo step.
frame(&mut app, vec![enter()]);
assert_eq!((kind(&app), steps(&app)), (None, before + 1), "{tool}: Enter confirms");
let after = (before.0 + usize::from(dialog == "reflect"), before.1 + 1);
assert_eq!((kind(&app), steps(&app)), (None, after), "{tool}: Enter confirms");
}
app.select_tool("rectangle");
frame(&mut app, vec![enter()]);
+17
View File
@@ -277,6 +277,17 @@ pub struct UiState {
/// Panels dragged out of the dock: each group floats as its own stack of tabs.
#[serde(default)]
pub floating_panels: Vec<FloatingPanels>,
/// Shared panel membership and placement; older preferences use the legacy dock.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docking: Option<craft_ui::docking::Layout<String>>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
/// Panels whose saved return destination is the iconic rail.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub docking_icons: std::collections::BTreeSet<String>,
/// Invalidates in-flight gestures when a workspace is replaced.
#[serde(skip)]
pub docking_generation: u64,
/// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`:
/// docked at the window's left edge.
#[serde(default)]
@@ -534,6 +545,8 @@ impl UiState {
f.tools.first().is_some_and(|k| seen.insert(k.clone()))
});
FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos);
self.docking = self.docking.filter(crate::panel_docking::valid);
self.docking_icons.retain(|id| ICON_PANELS.iter().any(|entry| entry.0 == id));
// Overrides that can't fire (modifier-only chords recorded by older versions, #487) give
// the default back.
self.shortcut_overrides.retain(|_, c| c.is_empty() || crate::shortcut_editor::normalize(c).is_some());
@@ -558,6 +571,10 @@ impl Default for UiState {
slot_tool: Default::default(),
floating_flyouts: vec![],
floating_panels: vec![],
docking: None,
docking_hidden: Default::default(),
docking_icons: Default::default(),
docking_generation: 0,
toolbar_pos: None,
status_bar: true,
dock: true,
+5 -12
View File
@@ -302,18 +302,11 @@ pub fn apply(ctx: &egui::Context, b: Brightness) {
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 60 }) };
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 70 }) };
let w = &mut v.widgets;
for (wv, fill) in [
(&mut w.noninteractive, t.panel),
(&mut w.inactive, t.button),
(&mut w.hovered, t.hover),
(&mut w.active, t.tool_active),
(&mut w.open, t.hover),
] {
wv.bg_fill = fill;
wv.weak_bg_fill = fill;
wv.corner_radius = t.cr();
wv.fg_stroke = Stroke::new(1.0, t.text);
}
craft_ui::theme::WidgetPalette { noninteractive: t.panel, inactive: t.button, hovered: t.hover, active: t.tool_active, open: t.hover }.apply_to(
w,
t.cr(),
Stroke::new(1.0, t.text),
);
w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
w.inactive.bg_stroke = Stroke::NONE;
w.hovered.bg_stroke = Stroke::new(1.0, t.input_border);
+17 -12
View File
@@ -982,12 +982,11 @@ pub fn link_label(ui: &mut Ui, text: &str) -> Response {
resp
}
/// The row of a checkbox or radio button: allocates a 13 pt box plus `label`, draws the label and
/// returns the box, the response and the box's border colour.
fn choice_row(ui: &mut Ui, label: &str, enabled: bool) -> (Rect, Response, Color32) {
/// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated),
/// draws the text and returns the box, the response and the box's border colour.
fn choice_row(ui: &mut Ui, text: &str, enabled: bool) -> (Rect, Response, Color32) {
let t = Tokens::get(ui.ctx());
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled });
let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled });
let (rect, resp) =
ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() });
let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0));
@@ -1009,21 +1008,27 @@ pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool {
/// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled.
pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool {
let (clicked, resp) = check_row(ui, label, Some(value), enabled);
let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled);
resp.on_hover_text(tip);
clicked
}
/// Three-state [`check`]: `None` shows a dash (a neutral or mixed state). Returns true when clicked.
pub fn check3(ui: &mut Ui, label: &str, value: Option<bool>, enabled: bool) -> bool {
check_row(ui, label, value, enabled).0
check_row(ui, tl!(label), value, enabled).0
}
/// [`check3`], also returning the row's response (what a tooltip goes on: a widget around it never
/// counts as hovered under the box).
fn check_row(ui: &mut Ui, label: &str, value: Option<bool>, enabled: bool) -> (bool, Response) {
/// [`check`] for a name of the document's (an artboard's, an asset's), shown as it is, never
/// translated. Returns true when toggled.
pub fn check_name(ui: &mut Ui, name: &str, value: bool) -> bool {
check_row(ui, name, Some(value), true).0
}
/// [`check3`] with its `text` already translated, also returning the row's response (what a
/// tooltip goes on: a widget around it never counts as hovered under the box).
fn check_row(ui: &mut Ui, text: &str, value: Option<bool>, enabled: bool) -> (bool, Response) {
let t = Tokens::get(ui.ctx());
let (bx, resp, border) = choice_row(ui, label, enabled);
let (bx, resp, border) = choice_row(ui, text, enabled);
let checked = value == Some(true);
ui.painter().rect_filled(bx, CornerRadius::same(2), if checked && enabled { t.accent_strong } else { t.input });
ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside);
@@ -1045,7 +1050,7 @@ fn check_row(ui: &mut Ui, label: &str, value: Option<bool>, enabled: bool) -> (b
/// Radio button in the style of [`check`], with a disabled state. Returns true when clicked.
pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (bx, resp, border) = choice_row(ui, label, enabled);
let (bx, resp, border) = choice_row(ui, tl!(label), enabled);
let c = bx.center();
ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border));
if selected {
+16
View File
@@ -31,6 +31,14 @@ pub struct Workspace {
pub floating_flyouts: Vec<FloatingFlyout>,
/// Panels dragged out of the dock, where they float (built-in workspaces: none).
pub floating_panels: Vec<FloatingPanels>,
/// Custom docking tree, including floating tab groups.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docking: Option<craft_ui::docking::Layout<String>>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
/// Panels whose saved return destination is the iconic rail.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub docking_icons: std::collections::BTreeSet<String>,
/// Where the Tools panel floats (built-in workspaces: docked).
pub toolbar_pos: Option<[f32; 2]>,
}
@@ -128,6 +136,9 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
status_bar: ui.status_bar,
floating_flyouts: ui.floating_flyouts.clone(),
floating_panels: ui.floating_panels.clone(),
docking: ui.docking.clone(),
docking_hidden: ui.docking_hidden.clone(),
docking_icons: ui.docking_icons.clone(),
toolbar_pos: ui.toolbar_pos,
}
}
@@ -147,6 +158,11 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.status_bar = w.status_bar;
ui.floating_flyouts = w.floating_flyouts.clone();
ui.floating_panels = w.floating_panels.clone();
ui.docking_generation = ui.docking_generation.wrapping_add(1);
ui.docking = w.docking.clone().filter(crate::panel_docking::valid);
ui.docking_hidden = w.docking_hidden.clone();
ui.docking_icons =
w.docking_icons.iter().filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())).take(64).cloned().collect();
ui.toolbar_pos = w.toolbar_pos;
FloatingPanels::sanitize(&mut ui.floating_panels, &mut ui.toolbar_pos);
ui.flyout = None;
+36
View File
@@ -3,6 +3,11 @@
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). One JSON request per line:
`{"id": 1, "method": "ui.inspect", "params": {}}` → `{"id": 1, "ok": true, "result": {...}}` or `{"id":1,"ok":false,"error":"..."}`.
Set `VECTORCRAFT_CONFIG_DIR` to a dedicated directory for an isolated native test profile. Preferences,
logs, recovery and user libraries then use that root, without importing the legacy DrawCraft profile.
An unset override preserves the usual platform paths and legacy migration; an explicitly empty override
disables those paths. `VECTORCRAFT_NO_PREFS` continues to disable preference reads and writes.
**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params`
are optional; blank lines are skipped). Anything else gets one error reply
(`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**, so
@@ -697,3 +702,34 @@ drags its points (a press on a point focuses it, and a press on one of the focus
handle); a Mesh tool click inside a mesh envelope adds a row and a column. While an envelope or its content is
selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and
distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command.
## Shared panel docking
Panel arrangements support tab grouping and reordering, horizontal and vertical splits,
and movable, resizable floating groups inside the application window. Drag a panel tab,
or use its context menu. Floating groups remain within the application viewport.
The shared layout begins with the existing workspace on the first docking operation.
Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the
positions of hidden panels. Resetting a built-in workspace restores its default dock.
- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group.
`zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders
tabs by panel ID. The destination must already be visible.
- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel.
- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous
dock placement when possible, or to the supplied destination.
- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved
position when possible. `window.panel.close {panel}` hides it.
Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and
invalid destinations return an error without changing the arrangement.
`window.panel.layout {action}` also accepts the shared, externally tagged action schema.
For example, `{"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}}`
updates a floating group's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}`
resizes the root divider (`false`/`true` path entries select first/second children).
`Move` with `placement: {"Tab":{"before":"swatches"}}` reorders tabs, and
`SetStackOpen {panel,open}` / `ResizeStack {panel,height}` control accordion entries.
The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale
split paths return an error without changing the workspace.
+4 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, from the `FORMATS` table and Illustrator 2026's documented formats) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.ai` artboard layout when read as its PDF part, #1068) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public
@@ -44,7 +44,7 @@ private editing data. VectorCraft:
### Illustrator's private editing data
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
## Format by format
@@ -80,6 +80,8 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | A `.ai` read as its PDF part keeps its artboard layout from the editing data (#1068) |
| 2026-10-11 | minor | Spot colours defined in Lab keep their Lab values, from the editing data and from a PDF part whose ink alternate is an ICC Lab profile (#1032) |
| 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) |
| 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) |
| 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures |
+6 -2
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (moved from the ROADMAP's "Where we're lacking", re-ranked, added file-format, stability, hardware, localization, ecosystem and AI gaps) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
@@ -86,6 +86,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G11 Correctness bugs in core tools
- **Done:** scaled text's size and horizontal-scale controls account for object/group transforms (#1034),
including exact sizes across grouped labels and resetting horizontal stretch while keeping editable text.
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885);
PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834).
- **Impact:** wrong output in everyday work.
@@ -96,7 +98,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical
composition (ruby, mojikumi sets and dialog, proportional vertical metrics #966, kinsoku settings #633, manual
tate-chu-yoko), Middle Eastern features beyond bidi, variable font axes, spell check with an open dictionary,
Touch Type, Retype, Snap to Glyph. Itemized in [type-parity.md](type-parity.md).
Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement).
Itemized in [type-parity.md](type-parity.md).
- **Impact:** professional typography and Japanese/Chinese publishing.
- **Estimate:** 34–47 h (type core 14–19 h blocks beta).
@@ -200,6 +203,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | #1034: corrected scaled type controls; optional group font-size preservation remains separate. Estimates unchanged at this rounding. |
| 2026-10-11 | minor | G9: legacy `.ai` without the prolog opens with its layers (#1027) |
| 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) |
| 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left |
+14 -1
View File
@@ -284,7 +284,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | |
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. |
With `mods.shift`, a marquee dragged with the Selection tool toggles the objects it reaches (the selected ones leave
the selection, the others join it, the rest stays), and one dragged with Direct or Group Selection toggles the anchors
@@ -2627,6 +2627,19 @@ without ideographs uses its em box. One undo step.
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"charAlign":"icfTop"}}}
```
## Proportional Metrics
`text.setFormat {proportionalMetrics: true}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end,
proportionalMetrics}` (a range) set full-width glyphs on the proportional widths the font gives them with the
OpenType `palt` feature; glyphs the font doesn't re-space keep their full width. Line-end Punctuation Half Width then takes nothing more off the punctuation `palt` re-spaced (the opening
bracket at a line's start, consecutive punctuation, the closing mark at a line's end); punctuation left full width is
trimmed as before. Off by default and saved only when on. Horizontal type only for now: vertical type sets as before.
One undo step.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}}
```
## New type in a Japanese interface
While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em
+3 -2
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (added the Alpha gate table; earlier the same day: moved the milestone table and direction here from ROADMAP.md, added Current focus) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (M7: accurate character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
@@ -54,7 +54,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
@@ -79,6 +79,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | M7: document-point character sizes, leading and baseline shift, and resettable horizontal stretching after object/group scaling (#1034). Optional group font-size preservation remains separate; scores/hours unchanged. |
| 2026-10-10 | minor | M9 row from main: initial Revolve |
| 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha |
| 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list |
+7 -5
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (#1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -76,7 +76,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated |
| Raster effects (Effect Gallery) | 4 | 48% | 29 Photoshop-style filters (Artistic, Sketch), the Effect Gallery dialog, Load Texture | 24–40 | measured count 28/57 |
| 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) |
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, see [type-parity.md](type-parity.md) | 14–19 | estimated |
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) |
| Image Trace, graphs, image tools | 3 | 75% | graph Design/Column/Marker, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) |
@@ -305,7 +305,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
@@ -396,6 +396,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself.
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
@@ -419,8 +420,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)).
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boLine truncated
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type, and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (vertical type's `vpal` not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
@@ -458,6 +459,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%): 16 core features weighted, depth ~91%, discounted for launch, discoverability and files people send |
| 2026-10-10 | minor | Second look at ready for real work at the owner's request: 50% → 55% full Illustrator, and a new mainstream-illustrator figure of 65%. Why: the old 25-point discount was a judgement with no breakdown. Now each discount is written down and checked against the tracker: 86% of 79 core-drawing issues closed, 20 of 28 `.ai`/EPS issues fixed and the open ones about structure, praise from 15 people, no reports of switching; dimensions a typical illustrator rarely hits are taken out of readiness. Feature breadth, depth and hours unchanged |
| 2026-10-10 | minor | Stage checked against the core-workflow gate: passes, stays alpha |
| 2026-10-10 | minor | #1034: accurate transformed character controls; type-core score/hours and headline estimates unchanged at this rounding. |
| 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file |
| 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones |
| 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate |
+4 -1
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, split out of the parity table's two type rows) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -23,6 +23,8 @@ against Illustrator's public documentation; **estimated** unless noted.
| Threaded text, Fit Headline, Text Wrap | done | |
| Type on a Path effects and options; brackets | done | #429 |
| Character and Paragraph panels, styles with overrides | done | mixed-value display for differing paragraphs missing |
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394) |
| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 |
| Variable fonts | partial | named instances as styles; axis sliders missing |
@@ -48,4 +50,5 @@ against Illustrator's public documentation; **estimated** unless noted.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | #1034: transformed character controls and regression coverage; preserving group font sizes remains a separate enhancement. Scores/hours unchanged at this rounding. |
| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows |
+3 -2
View File
@@ -92,7 +92,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
/// External crates that constitute a UI toolkit / windowing dependency.
/// Entries ending in `*` are prefixes.
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy*"];
/// First layer allowed to use UI crates.
pub const UI_MIN_LAYER: u8 = 6;
@@ -305,11 +305,12 @@ mod tests {
#[test]
fn ui_crates_below_l6_flagged() {
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
for dep in ["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy_ecs", "bevy"] {
let v = check(&[c("vectorcraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
}
assert!(check(&[c("vectorcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
assert!(check(&[c("vectorcraft-ui-egui", &[("craft-ui", Normal, false)])]).is_empty());
assert!(check(&[c("vectorcraft-mcp", &[("winit", Normal, false)])]).is_empty());
}